Module QuickWin complet + filtres serveurs OS/owner

- QuickWin: campagnes patching rapide avec exclusions générales (OS/reboot) et spécifiques (applicatifs)
- Config serveurs: pagination, filtres (search, env, domain, zone, per_page), dry run, bulk edit
- Détail campagne: pagination hprod/prod séparée, filtres (search, status, domain), section prod masquée si hprod non terminé
- Auth: redirection qw_only vers /quickwin, profil lecture seule quickwin
- Serveurs: filtres OS (Linux/Windows) et Owner (secops/ipop/na), exclusion EOL
- Sidebar: lien QuickWin conditionné sur permission campaigns ou quickwin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-08 16:27:45 +02:00
parent c550597a86
commit 5cc10c5b6c
11 changed files with 1197 additions and 8 deletions

View File

@ -6,7 +6,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from .config import APP_NAME, APP_VERSION
from .dependencies import get_current_user, get_user_perms
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
class PermissionsMiddleware(BaseHTTPMiddleware):
@ -43,6 +43,7 @@ app.include_router(contacts.router)
app.include_router(qualys.router)
app.include_router(safe_patching.router)
app.include_router(audit_full.router)
app.include_router(quickwin.router)
@app.get("/")

View File

@ -40,7 +40,14 @@ async def login(request: Request, username: str = Form(...), password: str = For
user = {"sub": row.username, "role": row.role, "uid": row.id}
log_login(db, request, user)
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)
return response

297
app/routers/quickwin.py Normal file
View File

@ -0,0 +1,297 @@
"""Router QuickWin — Campagnes patching rapide avec exclusions par serveur"""
import json
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from ..dependencies import get_db, get_current_user, get_user_perms, can_view, can_edit, base_context
from ..services.quickwin_service import (
get_server_configs, upsert_server_config, delete_server_config,
get_eligible_servers, list_runs, get_run, get_run_entries,
create_run, delete_run, update_entry_field,
can_start_prod, get_run_stats, inject_yum_history,
DEFAULT_GENERAL_EXCLUDES,
)
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/quickwin", response_class=HTMLResponse)
async def quickwin_page(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, "campaigns") and not can_view(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
runs = list_runs(db)
configs = get_server_configs(db)
now = datetime.now()
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"runs": runs,
"configs": configs,
"config_count": len(configs),
"current_week": now.isocalendar()[1],
"current_year": now.isocalendar()[0],
"can_create": can_edit(perms, "campaigns"),
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin.html", ctx)
# -- Config exclusions par serveur --
@router.get("/quickwin/config", response_class=HTMLResponse)
async def quickwin_config_page(request: Request, db=Depends(get_db),
page: int = Query(1),
per_page: int = Query(14),
search: str = Query(""),
env: str = Query(""),
domain: str = Query(""),
zone: str = Query("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
perms = get_user_perms(db, user)
if not can_view(perms, "campaigns") and not can_view(perms, "quickwin"):
return RedirectResponse(url="/dashboard")
configs = get_server_configs(db)
# Filtres
filtered = configs
if search:
filtered = [s for s in filtered if search.lower() in s.hostname.lower()]
if env:
filtered = [s for s in filtered if s.environnement == env]
if domain:
filtered = [s for s in filtered if s.domaine == domain]
if zone:
filtered = [s for s in filtered if (s.zone or '') == zone]
# Pagination
per_page = max(5, min(per_page, 100))
total = len(filtered)
total_pages = max(1, (total + per_page - 1) // per_page)
page = max(1, min(page, total_pages))
start = (page - 1) * per_page
page_servers = filtered[start:start + per_page]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"all_servers": page_servers,
"all_configs": configs,
"default_excludes": DEFAULT_GENERAL_EXCLUDES,
"total_count": total,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"filters": {"search": search, "env": env, "domain": domain, "zone": zone},
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_config.html", ctx)
@router.post("/quickwin/config/save")
async def quickwin_config_save(request: Request, db=Depends(get_db),
server_id: int = Form(0),
general_excludes: str = Form(""),
specific_excludes: str = Form(""),
notes: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if server_id:
upsert_server_config(db, server_id, general_excludes.strip(),
specific_excludes.strip(), notes.strip())
return RedirectResponse(url="/quickwin/config?msg=saved", status_code=303)
@router.post("/quickwin/config/delete")
async def quickwin_config_delete(request: Request, db=Depends(get_db),
config_id: int = Form(0)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if config_id:
delete_server_config(db, config_id)
return RedirectResponse(url="/quickwin/config?msg=deleted", status_code=303)
@router.post("/quickwin/config/bulk-add")
async def quickwin_config_bulk_add(request: Request, db=Depends(get_db),
server_ids: str = Form(""),
general_excludes: str = Form("")):
"""Ajouter plusieurs serveurs d'un coup avec les memes exclusions generales"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
for sid in ids:
upsert_server_config(db, sid, general_excludes.strip(), "", "")
return RedirectResponse(url=f"/quickwin/config?msg=added_{len(ids)}", status_code=303)
# -- Runs QuickWin --
@router.post("/quickwin/create")
async def quickwin_create(request: Request, db=Depends(get_db),
label: str = Form(""),
week_number: int = Form(0),
year: int = Form(0),
server_ids: str = Form(""),
notes: 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, "campaigns"):
return RedirectResponse(url="/quickwin")
if not label:
label = f"Quick Win S{week_number:02d} {year}"
ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()]
if not ids:
# Prendre tous les serveurs configures, sinon tous les eligibles
configs = get_server_configs(db)
ids = [c.server_id for c in configs]
if not ids:
eligible = get_eligible_servers(db)
ids = [s.id for s in eligible]
if not ids:
return RedirectResponse(url="/quickwin?msg=no_servers", status_code=303)
try:
run_id = create_run(db, year, week_number, label, user.get("uid"), ids, notes)
return RedirectResponse(url=f"/quickwin/{run_id}", status_code=303)
except Exception as e:
db.rollback()
return RedirectResponse(url=f"/quickwin?msg=error", status_code=303)
@router.get("/quickwin/{run_id}", response_class=HTMLResponse)
async def quickwin_detail(request: Request, run_id: int, db=Depends(get_db),
search: str = Query(""),
status: str = Query(""),
domain: str = Query(""),
hp_page: int = Query(1),
p_page: int = Query(1),
per_page: int = Query(14)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
run = get_run(db, run_id)
if not run:
return RedirectResponse(url="/quickwin")
entries = get_run_entries(db, run_id)
stats = get_run_stats(db, run_id)
prod_ok = can_start_prod(db, run_id)
hprod_all = [e for e in entries if e.branch == "hprod"]
prod_all = [e for e in entries if e.branch == "prod"]
# Filtres
def apply_filters(lst):
filtered = lst
if search:
filtered = [e for e in filtered if search.lower() in e.hostname.lower()]
if status:
filtered = [e for e in filtered if e.status == status]
if domain:
filtered = [e for e in filtered if e.domaine == domain]
return filtered
hprod = apply_filters(hprod_all)
prod = apply_filters(prod_all)
# Pagination
per_page = max(5, min(per_page, 100))
hp_total = len(hprod)
hp_total_pages = max(1, (hp_total + per_page - 1) // per_page)
hp_page = max(1, min(hp_page, hp_total_pages))
hp_start = (hp_page - 1) * per_page
hprod_page = hprod[hp_start:hp_start + per_page]
p_total = len(prod)
p_total_pages = max(1, (p_total + per_page - 1) // per_page)
p_page = max(1, min(p_page, p_total_pages))
p_start = (p_page - 1) * per_page
prod_page = prod[p_start:p_start + per_page]
ctx = base_context(request, db, user)
ctx.update({
"app_name": APP_NAME,
"run": run, "entries": entries, "stats": stats,
"hprod": hprod_page, "prod": prod_page,
"hprod_total": hp_total, "prod_total": p_total,
"hp_page": hp_page, "hp_total_pages": hp_total_pages,
"p_page": p_page, "p_total_pages": p_total_pages,
"per_page": per_page,
"prod_ok": prod_ok,
"filters": {"search": search, "status": status, "domain": domain},
"msg": request.query_params.get("msg"),
})
return templates.TemplateResponse("quickwin_detail.html", ctx)
@router.post("/quickwin/{run_id}/delete")
async def quickwin_delete(request: Request, run_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, "campaigns"):
return RedirectResponse(url="/quickwin")
delete_run(db, run_id)
return RedirectResponse(url="/quickwin?msg=deleted", status_code=303)
# -- API JSON --
@router.post("/api/quickwin/entry/update")
async def quickwin_entry_update(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
body = await request.json()
entry_id = body.get("id")
field = body.get("field")
value = body.get("value")
if not entry_id or not field:
return JSONResponse({"error": "id and field required"}, 400)
ok = update_entry_field(db, entry_id, field, value)
return JSONResponse({"ok": ok})
@router.post("/api/quickwin/inject-yum-history")
async def quickwin_inject_yum(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
body = await request.json()
if not isinstance(body, list):
return JSONResponse({"error": "expected list"}, 400)
updated, inserted = inject_yum_history(db, body)
return JSONResponse({"ok": True, "updated": updated, "inserted": inserted})
@router.get("/api/quickwin/prod-check/{run_id}")
async def quickwin_prod_check(request: Request, run_id: int, db=Depends(get_db)):
"""Verifie si le prod peut demarrer (tous hprod termines)"""
user = get_current_user(request)
if not user:
return JSONResponse({"error": "unauthorized"}, 401)
ok = can_start_prod(db, run_id)
return JSONResponse({"can_start_prod": ok})

View File

@ -18,13 +18,14 @@ templates = Jinja2Templates(directory="app/templates")
async def servers_list(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), page: int = Query(1),
sort: str = Query("hostname"), sort_dir: str = Query("asc")):
user = get_current_user(request)
if not user:
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)
domains_list, envs_list = get_reference_data(db)
@ -41,12 +42,13 @@ async def servers_list(request: Request, db=Depends(get_db),
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, "search": search}
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=";")

View File

@ -0,0 +1,254 @@
"""Service QuickWin — gestion des campagnes + exclusions par serveur"""
import json
from sqlalchemy import text
# 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.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 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(*) as total,
COUNT(*) FILTER (WHERE branch = 'hprod') as hprod_total,
COUNT(*) FILTER (WHERE branch = 'prod') 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 = '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) as reboot_count
FROM quickwin_entries WHERE run_id = :rid
"""), {"rid": run_id}).fetchone()
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

View File

@ -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'")
else:
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"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"

View File

@ -51,7 +51,7 @@
</div>
<nav class="flex-1 p-3 space-y-1">
{% 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.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 %}
@ -61,6 +61,7 @@
{% 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.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 %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">QuickWin</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 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 %}

136
app/templates/quickwin.html Normal file
View 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 &mdash; exclusions par serveur &mdash; hors-prod d'abord &mdash; pas de reboot n&eacute;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&eacute;e{% elif msg == 'error' %}Erreur cr&eacute;ation{% elif msg == 'no_servers' %}Aucun serveur configur&eacute;{% 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&eacute;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&eacute;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&eacute;&eacute; 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&eacute;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&eacute;</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&eacute;</span>
{% elif r.status == 'cancelled' %}<span class="badge badge-red">Annul&eacute;</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&eacute;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&eacute;s)</label>
<input type="text" name="server_ids" placeholder="Laisser vide pour tous les serveurs configur&eacute;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&eacute;er</button>
</div>
</form>
</div>
</div>
{% endblock %}

View 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">&larr; 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 &mdash; exclusions g&eacute;n&eacute;rales par d&eacute;faut pr&eacute;-remplies &mdash; pas de reboot n&eacute;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&eacute;e{% elif 'deleted' in msg %}Exclusions sp&eacute;cifiques retir&eacute;es{% elif 'added' in msg %}{{ msg.split('_')[1] }} serveur(s) mis &agrave; jour{% elif 'bulk' in msg %}Mise &agrave; jour group&eacute;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">&times;</button>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<div class="text-xs text-gray-500 mb-1 font-bold">Exclusions g&eacute;n&eacute;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&eacute;cifiques (applicatifs &mdash; hors p&eacute;rim&egrave;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&eacute;n&eacute;rales</th>
<th class="px-2 py-2">Exclusions sp&eacute;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&eacute;</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 }} &mdash; {{ 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&eacute;c&eacute;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&eacute;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&eacute;n&eacute;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 %}

View File

@ -0,0 +1,271 @@
{% extends "base.html" %}
{% block title %}QuickWin #{{ run.id }}{% endblock %}
{% 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">&larr; 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 }} &mdash; Cr&eacute;&eacute; par {{ run.created_by_name or '?' }} &mdash; pas de reboot n&eacute;cessaire</p>
</div>
<div class="flex gap-2 items-center">
{% if run.status == 'draft' %}
<span class="badge badge-gray" style="padding:4px 12px">Brouillon</span>
{% elif run.status == 'hprod_done' %}
<span class="badge badge-blue" style="padding:4px 12px">H-Prod termin&eacute;</span>
{% elif run.status == 'completed' %}
<span class="badge badge-green" style="padding:4px 12px">Termin&eacute;</span>
{% else %}
<span class="badge badge-yellow" style="padding:4px 12px">{{ run.status }}</span>
{% endif %}
<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 %}
<!-- Stats -->
<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&eacute;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>
<!-- 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 serveur..." style="width:200px">
<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="in_progress" {% if filters.status == 'in_progress' %}selected{% endif %}>En cours</option>
<option value="patched" {% if filters.status == 'patched' %}selected{% endif %}>Patch&eacute;</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&eacute;</option>
</select>
<select name="domain" onchange="this.form.submit()" style="width:160px">
<option value="">Tous domaines</option>
{% set all_entries_list = entries %}
{% set doms = all_entries_list|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="per_page" onchange="this.form.submit()" style="width:150px">
<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/{{ run.id }}" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>
<!-- Regle hprod first -->
{% 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.pending }} serveur(s) hprod en attente. Terminer le hprod avant de lancer le prod.</p>
</div>
{% endif %}
<!-- H-PROD -->
<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:#00d4ff">HORS-PRODUCTION ({{ hprod_total }})</h2>
<div class="flex gap-1 items-center">
<span class="badge badge-green">{{ hprod|selectattr('status','eq','patched')|list|length }} OK</span>
<span class="badge badge-red">{{ hprod|selectattr('status','eq','failed')|list|length }} KO</span>
<span class="badge badge-gray">{{ hprod|selectattr('status','eq','pending')|list|length }} en attente</span>
</div>
</div>
<div class="table-wrap">
<table class="table-cyber w-full">
<thead><tr>
<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>
<th class="px-2 py-2">Exclusions g&eacute;n.</th>
<th class="px-2 py-2">Exclusions sp&eacute;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>
</tr></thead>
<tbody>
{% for e in hprod %}
<tr data-id="{{ e.id }}">
<td class="px-2 py-2 font-bold" style="color:#00d4ff">{{ 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&eacute;</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&eacute;</span>
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
</td>
<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>
</tr>
{% endfor %}
{% if not hprod %}<tr><td colspan="10" class="px-2 py-6 text-center text-gray-500">Aucun serveur hors-production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
</tbody>
</table>
</div>
<!-- Pagination H-PROD -->
{% if hp_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 {{ hp_page }} / {{ hp_total_pages }} &mdash; {{ hprod_total }} serveur(s)</span>
<div class="flex gap-2">
{% if hp_page > 1 %}<a href="{{ qs(hp=hp_page - 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %}
{% if hp_page < hp_total_pages %}<a href="{{ qs(hp=hp_page + 1, pp=p_page) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
{% endif %}
</div>
<!-- PROD -->
{% if prod_ok %}
<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:#ffcc00">PRODUCTION ({{ prod_total }})</h2>
<div class="flex gap-1 items-center">
<span class="badge badge-green">{{ prod|selectattr('status','eq','patched')|list|length }} OK</span>
<span class="badge badge-red">{{ prod|selectattr('status','eq','failed')|list|length }} KO</span>
<span class="badge badge-gray">{{ prod|selectattr('status','eq','pending')|list|length }} en attente</span>
</div>
</div>
<div class="table-wrap">
<table class="table-cyber w-full">
<thead><tr>
<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>
<th class="px-2 py-2">Exclusions g&eacute;n.</th>
<th class="px-2 py-2">Exclusions sp&eacute;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>
</tr></thead>
<tbody>
{% for e in prod %}
<tr data-id="{{ e.id }}">
<td class="px-2 py-2 font-bold" style="color:#ffcc00">{{ 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&eacute;</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>
{% else %}<span class="badge badge-gray">En attente</span>{% endif %}
</td>
<td class="px-2 py-2 text-xs" style="color:#ffcc00;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
<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">
<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>
</tr>
{% endfor %}
{% if not prod %}<tr><td colspan="10" class="px-2 py-6 text-center text-gray-500">Aucun serveur production{% if filters.search or filters.status or filters.domain %} (filtre actif){% endif %}</td></tr>{% endif %}
</tbody>
</table>
</div>
<!-- Pagination PROD -->
{% if p_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 {{ p_page }} / {{ p_total_pages }} &mdash; {{ prod_total }} serveur(s)</span>
<div class="flex gap-2">
{% if p_page > 1 %}<a href="{{ qs(hp=hp_page, pp=p_page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Pr&eacute;c&eacute;dent</a>{% endif %}
{% if p_page < p_total_pages %}<a href="{{ qs(hp=hp_page, pp=p_page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
{% endif %}
</div>
{% 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 %}
<script>
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() === '—' ? '' : 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 || '—';
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 || '—'; }
});
});
});
</script>
{% endblock %}

View File

@ -2,7 +2,7 @@
{% block title %}Serveurs{% endblock %}
{% 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 %}
{% macro sort_icon(col) -%}
@ -10,14 +10,14 @@
{%- endmacro %}
{% 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 %}
{% block content %}
<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>
<div class="flex gap-2">
<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 '' }}" class="btn-sm bg-cyber-green text-black">Export CSV</a>
<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>
@ -38,6 +38,15 @@
<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 %}
</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>
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>