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:
parent
c550597a86
commit
5cc10c5b6c
@ -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
|
||||||
|
|
||||||
|
|
||||||
class PermissionsMiddleware(BaseHTTPMiddleware):
|
class PermissionsMiddleware(BaseHTTPMiddleware):
|
||||||
@ -43,6 +43,7 @@ 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.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@ -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}
|
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
|
||||||
|
|
||||||
|
|||||||
297
app/routers/quickwin.py
Normal file
297
app/routers/quickwin.py
Normal 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})
|
||||||
@ -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)
|
||||||
|
|
||||||
@ -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),
|
async def servers_export_csv(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)):
|
search: str = Query(None)):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
import io, csv
|
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")
|
servers, total = list_servers(db, filters, page=1, per_page=99999, sort="hostname", sort_dir="asc")
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
w = csv.writer(output, delimiter=";")
|
w = csv.writer(output, delimiter=";")
|
||||||
|
|||||||
254
app/services/quickwin_service.py
Normal file
254
app/services/quickwin_service.py
Normal 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
|
||||||
@ -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']}%"
|
||||||
|
|
||||||
|
|||||||
@ -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,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/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 %}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.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 %}
|
||||||
|
|||||||
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 %}
|
||||||
271
app/templates/quickwin_detail.html
Normal file
271
app/templates/quickwin_detail.html
Normal 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">← 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 '?' }} — pas de reboot né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é</span>
|
||||||
|
{% elif run.status == 'completed' %}
|
||||||
|
<span class="badge badge-green" style="padding:4px 12px">Terminé</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é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é</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>
|
||||||
|
<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é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>
|
||||||
|
</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é</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>
|
||||||
|
<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 }} — {{ 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écé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é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>
|
||||||
|
</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é</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 }} — {{ 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écé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 %}
|
||||||
@ -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">
|
||||||
<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>
|
||||||
</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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user