PatchCenter v2.0 — Initial commit

Modules: Dashboard, Serveurs, Campagnes, Planning, Specifiques, Settings, Users
Stack: FastAPI + Jinja2 + HTMX + Alpine.js + TailwindCSS + PostgreSQL
Features: Qualys sync, prereqs auto, planning annuel, server specifics, role-based access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalid MOUTAOUAKIL 2026-04-04 03:00:12 +02:00
commit 8277653c43
38 changed files with 4165 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
*.pyc
venv/
.env
keys/
*.log
start.sh

0
app/__init__.py Normal file
View File

25
app/auth.py Normal file
View File

@ -0,0 +1,25 @@
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict):
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None

18
app/config.py Normal file
View File

@ -0,0 +1,18 @@
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://patchcenter:PatchCenter2026!@localhost:5432/patchcenter_db")
SECRET_KEY = os.getenv("SECRET_KEY", "slpm-patchcenter-secret-key-2026-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 480 # 8 heures
APP_NAME = "PatchCenter"
APP_VERSION = "2.0"
# Qualys API
QUALYS_URL = os.getenv("QUALYS_URL", "https://qualysapi.qualys.eu")
QUALYS_USER = os.getenv("QUALYS_USER", "sanef-ae")
QUALYS_PASS = os.getenv("QUALYS_PASS", 'DW:Q\\*"JEZr2tjZ=!Ox4')
# iTop API (a configurer)
ITOP_URL = os.getenv("ITOP_URL", "")
ITOP_USER = os.getenv("ITOP_USER", "")
ITOP_PASS = os.getenv("ITOP_PASS", "")

7
app/database.py Normal file
View File

@ -0,0 +1,7 @@
"""SQLAlchemy engine et session"""
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .config import DATABASE_URL
engine = create_engine(DATABASE_URL, pool_pre_ping=True, pool_size=10)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

20
app/dependencies.py Normal file
View File

@ -0,0 +1,20 @@
"""Dependances communes pour les routers"""
from fastapi import Request
from .auth import decode_token
from .database import SessionLocal
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(request: Request):
"""Extrait l'utilisateur du cookie JWT"""
token = request.cookies.get("access_token")
if not token:
return None
return decode_token(token)

28
app/main.py Normal file
View File

@ -0,0 +1,28 @@
"""PatchCenter v2 — Entry point FastAPI"""
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .config import APP_NAME, APP_VERSION
from .routers import auth, dashboard, servers, settings, users, campaigns, planning, specifics
app = FastAPI(title=APP_NAME, version=APP_VERSION)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(servers.router)
app.include_router(settings.router)
app.include_router(users.router)
app.include_router(campaigns.router)
app.include_router(planning.router)
app.include_router(specifics.router)
@app.get("/")
async def root():
return RedirectResponse(url="/login")
@app.get("/health")
async def health():
return {"status": "ok", "app": APP_NAME, "version": APP_VERSION}

0
app/models/__init__.py Normal file
View File

0
app/routers/__init__.py Normal file
View File

44
app/routers/auth.py Normal file
View File

@ -0,0 +1,44 @@
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..auth import verify_password, create_access_token, hash_password
from ..config import APP_NAME, APP_VERSION
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": None
})
@router.post("/login")
async def login(request: Request, username: str = Form(...), password: str = Form(...), db=Depends(get_db)):
row = db.execute(text("SELECT id, username, password_hash, role FROM users WHERE LOWER(username) = LOWER(:u)"),
{"u": username}).fetchone()
if not row:
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Utilisateur inconnu"
})
# Verifier mot de passe (bcrypt pour web, PBKDF2 legacy pour SLPM)
try:
ok = verify_password(password, row.password_hash)
except Exception:
ok = False
if not ok:
return templates.TemplateResponse("login.html", {
"request": request, "app_name": APP_NAME, "version": APP_VERSION, "error": "Mot de passe incorrect"
})
token = create_access_token({"sub": row.username, "role": row.role, "uid": row.id})
response = RedirectResponse(url="/dashboard", status_code=303)
response.set_cookie(key="access_token", value=token, httponly=True, samesite="lax")
return response
@router.get("/logout")
async def logout():
response = RedirectResponse(url="/login", status_code=302)
response.delete_cookie("access_token")
return response

212
app/routers/campaigns.py Normal file
View File

@ -0,0 +1,212 @@
"""Router campagnes — creation depuis planning + gestion exclusions"""
from datetime import datetime
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..services.campaign_service import (
list_campaigns, get_campaign, get_campaign_sessions, get_campaign_stats,
create_campaign_from_planning, get_servers_for_planning,
update_campaign_status, exclude_session, restore_session,
validate_prereq, get_prereq_stats, can_plan_campaign,
bulk_auto_exclude_failed_prereqs,
)
from ..services.prereq_service import check_prereqs_campaign, check_single_prereq
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
EXCLUSION_REASONS = [
("eol", "Fin de vie (EOL)"),
("creneau_inadequat", "Creneau non adequat"),
("intervention_non_secops", "Intervention non-SecOps prevue"),
("report_cycle", "Report au cycle suivant"),
("non_patchable", "Serveur non patchable"),
("autre", "Autre"),
]
@router.get("/campaigns", response_class=HTMLResponse)
async def campaigns_list(request: Request, db=Depends(get_db),
year: int = Query(None), status: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if not year:
year = datetime.now().year
campaigns = list_campaigns(db, year=year, status=status)
# Semaines planifiees pour cette annee (pour le formulaire de creation)
now = datetime.now()
current_week = now.isocalendar()[1]
planned_weeks = db.execute(text("""
SELECT DISTINCT pp.week_number, pp.week_code, pp.week_start, pp.week_end,
string_agg(DISTINCT d.name || ' (' || pp.env_scope || ')', ', ' ORDER BY d.name || ' (' || pp.env_scope || ')') as scope
FROM patch_planning pp
LEFT JOIN domains d ON pp.domain_code = d.code
WHERE pp.year = :y AND pp.status = 'open' AND pp.domain_code IS NOT NULL
AND pp.week_number >= :cw
GROUP BY pp.week_number, pp.week_code, pp.week_start, pp.week_end
ORDER BY pp.week_number
"""), {"y": year, "cw": current_week}).fetchall()
return templates.TemplateResponse("campaigns.html", {
"request": request, "user": user, "app_name": APP_NAME,
"campaigns": campaigns, "year": year, "status_filter": status,
"planned_weeks": planned_weeks,
})
@router.get("/campaigns/preview", response_class=HTMLResponse)
async def campaign_preview(request: Request, db=Depends(get_db),
year: int = Query(...), week: int = Query(...)):
"""HTMX: preview des serveurs pour une semaine du planning"""
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
servers, planning = get_servers_for_planning(db, year, week)
scope = ", ".join(set(f"{p.domain_name} ({p.env_scope})" for p in planning if p.domain_code))
return templates.TemplateResponse("partials/campaign_preview.html", {
"request": request, "servers": servers, "scope": scope,
"week": week, "year": year, "count": len(servers),
})
@router.post("/campaigns/create")
async def campaign_create(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
year = int(form.get("year", datetime.now().year))
week = int(form.get("week_number", 0))
label = form.get("label", f"Patch S{week:02d} {year}")
# Serveurs exclus (checkboxes non cochees)
excluded = []
for key in form.keys():
if key.startswith("exclude_"):
sid = int(key.replace("exclude_", ""))
excluded.append(sid)
cid = create_campaign_from_planning(db, year, week, label, user.get("uid"), excluded)
if not cid:
return RedirectResponse(url=f"/campaigns?year={year}&msg=no_servers", status_code=303)
return RedirectResponse(url=f"/campaigns/{cid}", status_code=303)
@router.get("/campaigns/{campaign_id}", response_class=HTMLResponse)
async def campaign_detail(request: Request, campaign_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
campaign = get_campaign(db, campaign_id)
if not campaign:
return RedirectResponse(url="/campaigns")
sessions = get_campaign_sessions(db, campaign_id)
stats = get_campaign_stats(db, campaign_id)
prereq = get_prereq_stats(db, campaign_id)
can_plan = can_plan_campaign(db, campaign_id)
return templates.TemplateResponse("campaign_detail.html", {
"request": request, "user": user, "app_name": APP_NAME,
"c": campaign, "sessions": sessions, "stats": stats,
"prereq": prereq, "can_plan": can_plan,
"exclusion_reasons": EXCLUSION_REASONS,
"msg": request.query_params.get("msg"),
})
@router.post("/campaigns/{campaign_id}/status")
async def campaign_status_change(request: Request, campaign_id: int,
db=Depends(get_db), new_status: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
# Bloquer planned si prereqs non valides
if new_status == "planned" and not can_plan_campaign(db, campaign_id):
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=prereq_needed", status_code=303)
update_campaign_status(db, campaign_id, new_status)
return RedirectResponse(url=f"/campaigns/{campaign_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/prereq")
async def session_prereq(request: Request, session_id: int, db=Depends(get_db),
prereq_ssh: str = Form(...), prereq_satellite: str = Form(...),
rollback_method: str = Form(""), rollback_justif: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
validate_prereq(db, session_id, prereq_ssh, prereq_satellite,
rollback_method or None, rollback_justif, user.get("sub"))
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_saved#row-{session_id}", status_code=303)
@router.post("/campaigns/{campaign_id}/auto-exclude-failed")
async def auto_exclude_failed(request: Request, campaign_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
count = bulk_auto_exclude_failed_prereqs(db, campaign_id, user.get("sub"))
return RedirectResponse(url=f"/campaigns/{campaign_id}?msg=auto_excluded_{count}", status_code=303)
@router.post("/campaigns/{campaign_id}/check-prereqs")
async def campaign_check_prereqs(request: Request, campaign_id: int, db=Depends(get_db)):
"""Lance la verification automatique des prereqs pour toute la campagne"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
checked, auto_excluded = check_prereqs_campaign(db, campaign_id)
return RedirectResponse(
url=f"/campaigns/{campaign_id}?msg=checked_{checked}_{auto_excluded}",
status_code=303
)
@router.post("/campaigns/session/{session_id}/check-prereq")
async def session_check_prereq(request: Request, session_id: int, db=Depends(get_db)):
"""Lance la verification prereq pour un seul serveur"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
check_single_prereq(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=prereq_checked#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/exclude")
async def session_exclude(request: Request, session_id: int, db=Depends(get_db),
reason: str = Form(...), detail: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
exclude_session(db, session_id, reason, detail, user.get("sub"))
# Retrouver campaign_id
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=excluded#row-{session_id}", status_code=303)
@router.post("/campaigns/session/{session_id}/restore")
async def session_restore(request: Request, session_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
restore_session(db, session_id)
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
cid = row.campaign_id if row else 0
return RedirectResponse(url=f"/campaigns/{cid}?msg=restored#row-{session_id}", status_code=303)

48
app/routers/dashboard.py Normal file
View File

@ -0,0 +1,48 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
# Stats
stats = {}
stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar()
stats["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='en_production'")).scalar()
stats["linux"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='linux'")).scalar()
stats["windows"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='windows'")).scalar()
stats["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='decommissionne'")).scalar()
stats["eol"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='eol'")).scalar()
stats["qualys_assets"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar()
stats["qualys_tags"] = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar()
# Par domaine
domains = db.execute(text("""
SELECT d.name, d.code, COUNT(s.id) as total,
COUNT(*) FILTER (WHERE s.etat='en_production') as actifs,
COUNT(*) FILTER (WHERE s.os_family='linux') as linux,
COUNT(*) FILTER (WHERE s.os_family='windows') as windows
FROM servers s
JOIN domain_environments de ON s.domain_env_id = de.id
JOIN domains d ON de.domain_id = d.id
GROUP BY d.name, d.code, d.display_order
ORDER BY d.display_order
""")).fetchall()
# Par tier
tiers = db.execute(text("SELECT tier, COUNT(*) FROM servers GROUP BY tier ORDER BY tier")).fetchall()
return templates.TemplateResponse("dashboard.html", {
"request": request, "user": user, "app_name": APP_NAME,
"stats": stats, "domains": domains, "tiers": tiers
})

200
app/routers/planning.py Normal file
View File

@ -0,0 +1,200 @@
"""Router planning — planning annuel de patching par domaine"""
from datetime import datetime, date, timedelta
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
MONTHS = ["Jan", "Fev", "Mar", "Avr", "Mai", "Jun", "Jul", "Aou", "Sep", "Oct", "Nov", "Dec"]
DOMAIN_COLORS = {
"TRA": "#E67E22", "PEA": "#8E44AD", "FL": "#2ECC71", "BI": "#F1C40F",
"INF": "#3498DB", "GES": "#1ABC9C", "EMV": "#E74C3C", "DMZ": "#D4A0A0",
}
ENV_SCOPES = ["prod", "hprod", "all", "pilot", "prod_pilot"]
STATUSES = ["open", "freeze", "holiday", "empty"]
def _week_dates(year, week_num):
jan4 = date(year, 1, 4)
start_of_w1 = jan4 - timedelta(days=jan4.isoweekday() - 1)
monday = start_of_w1 + timedelta(weeks=week_num - 1)
sunday = monday + timedelta(days=6)
return monday, sunday
def _get_planning_data(db, year):
rows = db.execute(text("""
SELECT pp.*, d.name as domain_name
FROM patch_planning pp
LEFT JOIN domains d ON pp.domain_code = d.code
WHERE pp.year = :y
ORDER BY pp.week_number, pp.domain_code
"""), {"y": year}).fetchall()
domains = db.execute(text("""
SELECT d.code, d.name, COUNT(s.id) as srv_count
FROM domains d
LEFT JOIN domain_environments de ON de.domain_id = d.id
LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'en_production'
GROUP BY d.code, d.name, d.display_order
ORDER BY d.display_order
""")).fetchall()
freeze_weeks = set()
grid = {}
for r in rows:
if r.status == 'freeze':
freeze_weeks.add(r.week_number)
if r.domain_code:
if r.domain_code not in grid:
grid[r.domain_code] = {}
grid[r.domain_code][r.week_number] = {
"id": r.id, "cycle": r.cycle, "env_scope": r.env_scope,
"note": r.note, "status": r.status,
}
all_domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
# Annees disponibles
years_in_db = db.execute(text("SELECT DISTINCT year FROM patch_planning ORDER BY year")).fetchall()
available_years = [r.year for r in years_in_db]
return rows, domains, grid, freeze_weeks, all_domains, available_years
@router.get("/planning", response_class=HTMLResponse)
async def planning_page(request: Request, db=Depends(get_db),
year: int = Query(None), msg: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if not year:
year = datetime.now().year
rows, domains, grid, freeze_weeks, all_domains, available_years = _get_planning_data(db, year)
now = datetime.now()
next_week = now.isocalendar()[1] + 1
if next_week > 53:
next_week = 1
return templates.TemplateResponse("planning.html", {
"request": request, "user": user, "app_name": APP_NAME,
"year": year, "domains": domains, "grid": grid,
"freeze_weeks": freeze_weeks, "months": MONTHS,
"domain_colors": DOMAIN_COLORS, "weeks": range(1, 54),
"entries": rows, "all_domains": all_domains,
"env_scopes": ENV_SCOPES, "statuses": STATUSES,
"available_years": available_years, "msg": msg,
"default_week": next_week,
})
@router.post("/planning/add")
async def planning_add(request: Request, db=Depends(get_db),
year: str = Form(...), week_number: str = Form(...),
domain_code: str = Form(""), env_scope: str = Form("all"),
cycle: str = Form(""), status: str = Form("open"), note: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
y = int(year)
wn = int(week_number) if week_number else 0
cyc = int(cycle) if cycle.strip() else None
if not wn or wn < 1 or wn > 53:
return RedirectResponse(url=f"/planning?year={y}&msg=err_week", status_code=303)
if not domain_code and status == 'open':
return RedirectResponse(url=f"/planning?year={y}&msg=err_domain", status_code=303)
# Pas dans le passe — semaine en cours acceptee lundi/mardi (MEP urgente)
now = datetime.now()
current_week = now.isocalendar()[1]
current_year = now.isocalendar()[0]
weekday = now.isoweekday() # 1=lundi, 7=dimanche
if y < current_year:
return RedirectResponse(url=f"/planning?year={y}&msg=err_past", status_code=303)
if y == current_year:
if wn < current_week:
return RedirectResponse(url=f"/planning?year={y}&msg=err_past", status_code=303)
if wn == current_week and weekday > 2:
return RedirectResponse(url=f"/planning?year={y}&msg=err_past_wed", status_code=303)
ws, we = _week_dates(y, wn)
wc = f"S{wn:02d}"
db.execute(text("""
INSERT INTO patch_planning (year, week_number, week_code, week_start, week_end, cycle, domain_code, env_scope, status, note)
VALUES (:y, :wn, :wc, :ws, :we, :cyc, :dc, :es, :st, :nt)
"""), {"y": y, "wn": wn, "wc": wc, "ws": ws, "we": we,
"cyc": cyc, "dc": domain_code or None, "es": env_scope, "st": status,
"nt": note or None})
db.commit()
return RedirectResponse(url=f"/planning?year={y}&msg=add", status_code=303)
@router.post("/planning/{entry_id}/edit")
async def planning_edit(request: Request, entry_id: int, db=Depends(get_db),
domain_code: str = Form(""), env_scope: str = Form("all"),
cycle: str = Form(""), status: str = Form("open"), note: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
cyc = int(cycle) if cycle.strip() else None
db.execute(text("""
UPDATE patch_planning SET domain_code = :dc, env_scope = :es, cycle = :cyc, status = :st, note = :nt
WHERE id = :id
"""), {"dc": domain_code or None, "es": env_scope, "cyc": cyc,
"st": status, "nt": note or None, "id": entry_id})
db.commit()
y = row.year if row else datetime.now().year
return RedirectResponse(url=f"/planning?year={y}&msg=edit", status_code=303)
@router.post("/planning/{entry_id}/delete")
async def planning_delete(request: Request, entry_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
row = db.execute(text("SELECT year FROM patch_planning WHERE id = :id"), {"id": entry_id}).fetchone()
db.execute(text("DELETE FROM patch_planning WHERE id = :id"), {"id": entry_id})
db.commit()
y = row.year if row else datetime.now().year
return RedirectResponse(url=f"/planning?year={y}&msg=delete", status_code=303)
@router.post("/planning/duplicate")
async def planning_duplicate(request: Request, db=Depends(get_db),
source_year: int = Form(...), target_year: int = Form(...)):
"""Duplique le planning d'une annee vers une autre"""
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
# Verifier que l'annee cible est vide
existing = db.execute(text("SELECT COUNT(*) FROM patch_planning WHERE year = :y"),
{"y": target_year}).scalar()
if existing > 0:
return RedirectResponse(url=f"/planning?year={target_year}&msg=exists", status_code=303)
# Copier toutes les entrees en recalculant les dates
sources = db.execute(text("SELECT * FROM patch_planning WHERE year = :y ORDER BY week_number"),
{"y": source_year}).fetchall()
for s in sources:
ws, we = _week_dates(target_year, s.week_number)
wc = f"S{s.week_number:02d}"
db.execute(text("""
INSERT INTO patch_planning (year, week_number, week_code, week_start, week_end, cycle, domain_code, env_scope, status, note)
VALUES (:y, :wn, :wc, :ws, :we, :cyc, :dc, :es, :st, :nt)
"""), {"y": target_year, "wn": s.week_number, "wc": wc, "ws": ws, "we": we,
"cyc": s.cycle, "dc": s.domain_code, "es": s.env_scope, "st": s.status,
"nt": s.note})
db.commit()
return RedirectResponse(url=f"/planning?year={target_year}&msg=duplicate", status_code=303)

113
app/routers/servers.py Normal file
View File

@ -0,0 +1,113 @@
"""Router serveurs — CRUD + detail + edit via HTMX"""
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from ..dependencies import get_db, get_current_user
from ..services.server_service import (
get_server_full, get_server_tags, get_server_ips,
list_servers, update_server, get_reference_data
)
from ..services.qualys_service import sync_server_qualys
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
@router.get("/servers", response_class=HTMLResponse)
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),
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}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
domains_list, envs_list = get_reference_data(db)
return templates.TemplateResponse("servers.html", {
"request": request, "user": user, "app_name": APP_NAME,
"servers": servers, "total": total, "page": page, "per_page": 50,
"domains_list": domains_list, "envs_list": envs_list, "filters": filters,
"sort": sort, "sort_dir": sort_dir,
})
@router.get("/servers/{server_id}/detail", response_class=HTMLResponse)
async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
s = get_server_full(db, server_id)
if not s:
return HTMLResponse("<p>Serveur non trouve</p>")
tags = get_server_tags(db, s.qid)
ips = get_server_ips(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips
})
@router.get("/servers/{server_id}/edit", response_class=HTMLResponse)
async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
s = get_server_full(db, server_id)
if not s:
return HTMLResponse("<p>Serveur non trouve</p>")
domains, envs = get_reference_data(db)
ips = get_server_ips(db, server_id)
return templates.TemplateResponse("partials/server_edit.html", {
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips
})
@router.put("/servers/{server_id}", response_class=HTMLResponse)
async def server_update(request: Request, server_id: int, db=Depends(get_db),
domain_code: str = Form(None), env_code: str = Form(None),
zone: str = Form(None), tier: str = Form(None), etat: str = Form(None),
patch_os_owner: str = Form(None), responsable_nom: str = Form(None),
referent_nom: str = Form(None), mode_operatoire: str = Form(None),
commentaire: str = Form(None),
ip_reelle: str = Form(None), ip_connexion: str = Form(None),
ssh_method: str = Form(None)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
data = {
"domain_code": domain_code, "env_code": env_code, "zone": zone,
"tier": tier, "etat": etat, "patch_os_owner": patch_os_owner,
"responsable_nom": responsable_nom, "referent_nom": referent_nom,
"mode_operatoire": mode_operatoire, "commentaire": commentaire,
"ip_reelle": ip_reelle, "ip_connexion": ip_connexion,
"ssh_method": ssh_method,
}
update_server(db, server_id, data, user.get("sub"))
s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid)
ips = get_server_ips(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips
})
@router.post("/servers/{server_id}/sync-qualys", response_class=HTMLResponse)
async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
result = sync_server_qualys(db, server_id)
s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid) if s else []
ips = get_server_ips(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips,
"sync_msg": result.get("msg"), "sync_ok": result.get("ok"),
})

202
app/routers/settings.py Normal file
View File

@ -0,0 +1,202 @@
"""Router settings — configuration modules externes + connexions"""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..services.secrets_service import get_secret, set_secret, list_secrets, init_secrets_from_config
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
SECTIONS = {
"qualys": [
("qualys_url", "URL API", False),
("qualys_user", "Utilisateur", False),
("qualys_pass", "Mot de passe", True),
("qualys_proxy", "Proxy (ex: http://proxy:3128)", False),
],
"itop": [
("itop_url", "URL API", False),
("itop_user", "Utilisateur", False),
("itop_pass", "Mot de passe", True),
],
"ssh_key": [
("ssh_key_default_user", "User SSH par defaut", False),
("ssh_key_default_port", "Port par defaut", False),
("ssh_key_private_key", "Cle privee (PEM)", True),
],
"ssh_pwd": [
("ssh_pwd_default_user", "User par defaut", False),
("ssh_pwd_default_pass", "Password par defaut", True),
],
"ssh_psmp": [
("psmp_host", "Adresse PSMP", False),
("psmp_port", "Port PSMP", False),
("psmp_user_format", "Format user", False),
("psmp_cyberark_user", "Compte CyberArk", False),
("psmp_target_user", "Utilisateur cible", False),
("psmp_default_safe", "Safe par defaut", False),
],
"rdp_psm": [
("rdp_psm_pvwa_url", "URL PVWA", False),
("rdp_psm_pvwa_user", "User PVWA", False),
("rdp_psm_pvwa_pass", "Password PVWA", True),
("rdp_psm_component", "Connection Component", False),
],
"rdp_pwd": [
("rdp_pwd_default_user", "User par defaut", False),
("rdp_pwd_default_pass", "Password par defaut", True),
("rdp_pwd_default_port", "Port RDP", False),
],
"vsphere": [
("vsphere_user", "Utilisateur vCenter", False),
("vsphere_pass", "Mot de passe vCenter", True),
],
"splunk": [
("splunk_hec_url", "URL HEC", False),
("splunk_hec_token", "Token HEC", True),
("splunk_index", "Index", False),
("splunk_sourcetype", "Sourcetype", False),
("splunk_verify_ssl", "Verifier SSL (true/false)", False),
],
"teams": [
("teams_webhook_url", "Webhook URL (canal)", False),
("teams_sp_site_url", "SharePoint Site URL", False),
("teams_sp_library", "SharePoint Library", False),
("teams_sp_folder", "SharePoint Folder", False),
("teams_sp_client_id", "App Client ID", False),
("teams_sp_client_secret", "App Client Secret", True),
("teams_sp_tenant_id", "Tenant ID", False),
],
}
def _load_section_values(db):
vals = {}
for section, fields in SECTIONS.items():
for key, label, is_secret in fields:
v = get_secret(db, key)
if is_secret and v:
vals[key] = "********"
else:
vals[key] = v or ""
return vals
# Regles d'acces par section: visible = qui peut voir, editable = qui peut modifier
SECTION_ACCESS = {
"qualys": {"visible": ["admin"], "editable": ["admin"]},
"ssh_key": {"visible": ["admin"], "editable": ["admin"]},
"ssh_pwd": {"visible": ["admin", "operator"], "editable": ["admin", "operator"]},
"ssh_psmp": {"visible": ["admin", "operator"], "editable": ["admin", "operator"]},
"rdp_psm": {"visible": ["admin"], "editable": ["admin"]},
"rdp_pwd": {"visible": [], "editable": []},
"vsphere": {"visible": ["admin", "operator", "coordinator"], "editable": ["admin", "operator"]},
"splunk": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"teams": {"visible": ["admin", "coordinator"], "editable": ["admin", "coordinator"]},
"itop": {"visible": ["admin"], "editable": ["admin"]},
}
def _build_context(db, user, saved=None):
init_secrets_from_config(db)
role = user.get("role", "viewer")
q_tags = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar()
q_assets = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).scalar()
q_linked = db.execute(text("SELECT COUNT(*) FROM servers WHERE qualys_asset_id IS NOT NULL")).scalar()
vcenters = db.execute(text("SELECT * FROM vcenters ORDER BY name")).fetchall()
# Filtrer les sections visibles selon le role
visible = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["visible"] for s in SECTIONS}
editable = {s: s in SECTION_ACCESS and role in SECTION_ACCESS[s]["editable"] for s in SECTIONS}
return {
"user": user, "app_name": APP_NAME, "role": role,
"sections": SECTIONS, "vals": _load_section_values(db),
"q_tags": q_tags, "q_assets": q_assets, "q_linked": q_linked,
"vcenters": vcenters, "saved": saved,
"visible": visible, "editable": editable,
}
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
ctx = _build_context(db, user)
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/{section}", response_class=HTMLResponse)
async def settings_save(request: Request, section: str, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if section not in SECTIONS:
return HTMLResponse("<p>Section inconnue</p>", status_code=400)
form = await request.form()
for key, label, is_secret in SECTIONS[section]:
val = form.get(key, "")
if is_secret and val == "********":
continue
if val:
set_secret(db, key, val, label)
ctx = _build_context(db, user, saved=section)
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
# --- vCenter CRUD ---
@router.post("/settings/vcenter/add", response_class=HTMLResponse)
async def vcenter_add(request: Request, db=Depends(get_db),
vc_name: str = Form(...), vc_endpoint: str = Form(...),
vc_datacenter: str = Form(""), vc_description: str = Form(""),
vc_responsable: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text(
"INSERT INTO vcenters (name, endpoint, datacenter, description, responsable) VALUES (:n, :e, :dc, :desc, :resp)"
), {"n": vc_name, "e": vc_endpoint, "dc": vc_datacenter or None, "desc": vc_description or None, "resp": vc_responsable or None})
db.commit()
ctx = _build_context(db, user, saved="vsphere")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
@router.post("/settings/vcenter/{vc_id}/delete", response_class=HTMLResponse)
async def vcenter_delete(request: Request, vc_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("UPDATE vcenters SET is_active = false WHERE id = :id"), {"id": vc_id})
db.commit()
ctx = _build_context(db, user, saved="vsphere")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)
# --- Secret individuel ---
@router.post("/settings/secret/update", response_class=HTMLResponse)
async def secret_update(request: Request, db=Depends(get_db),
secret_key: str = Form(...), secret_value: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
if secret_value and secret_value != "********":
# Recuperer la description existante
existing = db.execute(text("SELECT description FROM app_secrets WHERE key = :k"),
{"k": secret_key}).fetchone()
desc = existing.description if existing else secret_key
set_secret(db, secret_key, secret_value, desc)
ctx = _build_context(db, user, saved="secret")
ctx["request"] = request
return templates.TemplateResponse("settings.html", ctx)

159
app/routers/specifics.py Normal file
View File

@ -0,0 +1,159 @@
"""Router serveurs specifiques — vue et edition des specificites patching"""
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
APP_TYPES = [
"SAP BOC", "Oracle ASM", "Oracle OEM", "Podman FL", "OCR Flux Libre",
"SI Patrimoine", "HAProxy FL", "Site institutionnel", "Centreon Poller",
"Sextan", "OCTAN", "DATI", "Covoiturage", "Scoop Docker", "Splunk Enterprise",
"SAS BI", "SMTP Relay", "PostgreSQL", "Masterparc", "Gaspar",
"Temps de parcours", "PAIPOR", "COMMVAULT", "Talend", "Autre",
]
def _list_specifics(db, app_type=None, search=None):
where = ["1=1"]
params = {}
if app_type:
where.append("ss.app_type = :at"); params["at"] = app_type
if search:
where.append("s.hostname ILIKE :q"); params["q"] = f"%{search}%"
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT ss.*, s.hostname, s.fqdn, s.os_family, s.tier,
d.name as domaine, e.name as environnement,
dep.hostname as dep_hostname
FROM server_specifics ss
JOIN servers s ON ss.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 servers dep ON ss.dependency_server_id = dep.id
WHERE {wc}
ORDER BY ss.app_type, ss.patch_order_group, ss.reboot_order, s.hostname
"""), params).fetchall()
@router.get("/specifics", response_class=HTMLResponse)
async def specifics_list(request: Request, db=Depends(get_db),
app_type: str = Query(None), search: str = Query(None)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
entries = _list_specifics(db, app_type, search)
# Types en base
types_in_db = db.execute(text(
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
)).fetchall()
return templates.TemplateResponse("specifics.html", {
"request": request, "user": user, "app_name": APP_NAME,
"entries": entries, "app_types": APP_TYPES,
"types_in_db": [r.app_type for r in types_in_db],
"filter_type": app_type, "filter_search": search,
})
@router.get("/specifics/{spec_id}/edit", response_class=HTMLResponse)
async def specific_edit(request: Request, spec_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return HTMLResponse("<p>Non autorise</p>")
row = db.execute(text("""
SELECT ss.*, s.hostname FROM server_specifics ss
JOIN servers s ON ss.server_id = s.id WHERE ss.id = :id
"""), {"id": spec_id}).fetchone()
if not row:
return HTMLResponse("<p>Non trouve</p>")
return templates.TemplateResponse("partials/specific_edit.html", {
"request": request, "sp": row, "app_types": APP_TYPES,
})
@router.post("/specifics/{spec_id}/save")
async def specific_save(request: Request, spec_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
def val(k): v = form.get(k, ""); return v.strip() if v else None
def bval(k): return form.get(k) == "on"
def ival(k): v = form.get(k, ""); return int(v) if v.strip() else None
db.execute(text("""
UPDATE server_specifics SET
app_type = :app_type, reboot_order = :reboot_order, reboot_order_note = :reboot_order_note,
cmd_before_patch = :cmd_before_patch, cmd_after_patch = :cmd_after_patch,
cmd_before_reboot = :cmd_before_reboot, cmd_after_reboot = :cmd_after_reboot,
stop_command = :stop_command, start_command = :start_command,
stop_user = :stop_user, start_user = :start_user,
is_cluster = :is_cluster, cluster_role = :cluster_role, cluster_note = :cluster_note,
is_db = :is_db, db_type = :db_type, db_note = :db_note,
is_middleware = :is_middleware, mw_type = :mw_type, mw_note = :mw_note,
has_agent_special = :has_agent_special, agent_note = :agent_note,
has_service_critical = :has_service_critical, service_note = :service_note,
needs_manual_step = :needs_manual_step, manual_step_detail = :manual_step_detail,
kernel_update_blocked = :kernel_update_blocked, kernel_block_reason = :kernel_block_reason,
reboot_min_interval_minutes = :reboot_min_interval, reboot_delay_minutes = :reboot_delay,
sentinel_disable_required = :sentinel, ip_forwarding_required = :ip_fwd,
rolling_update = :rolling, rolling_update_note = :rolling_note,
auto_restart = :auto_restart, patch_order_group = :pog,
extra_excludes = :extra_excludes, patch_excludes = :patch_excludes,
no_reboot_reason = :no_reboot, note = :note
WHERE id = :id
"""), {
"id": spec_id, "app_type": val("app_type"),
"reboot_order": ival("reboot_order"), "reboot_order_note": val("reboot_order_note"),
"cmd_before_patch": val("cmd_before_patch"), "cmd_after_patch": val("cmd_after_patch"),
"cmd_before_reboot": val("cmd_before_reboot"), "cmd_after_reboot": val("cmd_after_reboot"),
"stop_command": val("stop_command"), "start_command": val("start_command"),
"stop_user": val("stop_user"), "start_user": val("start_user"),
"is_cluster": bval("is_cluster"), "cluster_role": val("cluster_role"), "cluster_note": val("cluster_note"),
"is_db": bval("is_db"), "db_type": val("db_type"), "db_note": val("db_note"),
"is_middleware": bval("is_middleware"), "mw_type": val("mw_type"), "mw_note": val("mw_note"),
"has_agent_special": bval("has_agent_special"), "agent_note": val("agent_note"),
"has_service_critical": bval("has_service_critical"), "service_note": val("service_note"),
"needs_manual_step": bval("needs_manual_step"), "manual_step_detail": val("manual_step_detail"),
"kernel_update_blocked": bval("kernel_update_blocked"), "kernel_block_reason": val("kernel_block_reason"),
"reboot_min_interval": ival("reboot_min_interval"), "reboot_delay": ival("reboot_delay"),
"sentinel": bval("sentinel"), "ip_fwd": bval("ip_fwd"),
"rolling": bval("rolling"), "rolling_note": val("rolling_note"),
"auto_restart": bval("auto_restart"), "pog": val("patch_order_group"),
"extra_excludes": val("extra_excludes"), "patch_excludes": val("patch_excludes"),
"no_reboot": val("no_reboot"), "note": val("note"),
})
db.commit()
# Recuperer le app_type pour rediriger vers le bon filtre + ancre
saved = db.execute(text("SELECT app_type FROM server_specifics WHERE id = :id"),
{"id": spec_id}).fetchone()
at = saved.app_type if saved and saved.app_type else ""
url = f"/specifics?msg=saved&app_type={at}#row-{spec_id}" if at else f"/specifics?msg=saved#row-{spec_id}"
return RedirectResponse(url=url, status_code=303)
@router.post("/specifics/add")
async def specific_add(request: Request, db=Depends(get_db),
hostname: str = Form(...), app_type: str = Form("")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
row = db.execute(text("SELECT id FROM servers WHERE LOWER(hostname) = LOWER(:h)"),
{"h": hostname.strip()}).fetchone()
if not row:
return RedirectResponse(url="/specifics?msg=not_found", status_code=303)
existing = db.execute(text("SELECT id FROM server_specifics WHERE server_id = :sid"),
{"sid": row.id}).fetchone()
if existing:
return RedirectResponse(url="/specifics?msg=exists", status_code=303)
db.execute(text(
"INSERT INTO server_specifics (server_id, app_type) VALUES (:sid, :at)"
), {"sid": row.id, "at": app_type or None})
db.commit()
return RedirectResponse(url="/specifics?msg=added", status_code=303)

142
app/routers/users.py Normal file
View File

@ -0,0 +1,142 @@
"""Router users — gestion utilisateurs + permissions par module"""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..auth import hash_password
from ..config import APP_NAME
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
MODULES = ["servers", "campaigns", "qualys", "audit", "settings", "users"]
LEVELS = ["view", "edit", "admin"]
def _get_users_with_perms(db):
users = db.execute(text(
"SELECT id, username, display_name, email, role, auth_type, is_active, last_login FROM users ORDER BY username"
)).fetchall()
result = []
for u in users:
perms = {}
rows = db.execute(text(
"SELECT module, level FROM user_permissions WHERE user_id = :uid"
), {"uid": u.id}).fetchall()
for r in rows:
perms[r.module] = r.level
result.append({"user": u, "perms": perms})
return result
@router.get("/users", response_class=HTMLResponse)
async def users_page(request: Request, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": None,
})
@router.post("/users/add", response_class=HTMLResponse)
async def user_add(request: Request, db=Depends(get_db),
new_username: str = Form(...), new_display_name: str = Form(...),
new_email: str = Form(""), new_password: str = Form(...),
new_role: str = Form("operator")):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
pw_hash = hash_password(new_password)
db.execute(text("""
INSERT INTO users (username, display_name, email, password_hash, role)
VALUES (:u, :dn, :e, :ph, :r)
"""), {"u": new_username, "dn": new_display_name, "e": new_email or None,
"ph": pw_hash, "r": new_role})
# Recuperer l'id du nouveau user
row = db.execute(text("SELECT id FROM users WHERE username = :u"), {"u": new_username}).fetchone()
if row:
# Permissions par defaut selon role
default_perms = {
"admin": {m: "admin" for m in MODULES},
"coordinator": {"servers": "edit", "campaigns": "admin", "qualys": "edit", "audit": "view", "settings": "view", "users": "view"},
"operator": {"servers": "edit", "campaigns": "edit", "qualys": "view", "audit": "view"},
"viewer": {"servers": "view", "campaigns": "view", "qualys": "view", "audit": "view"},
}
for mod, lvl in default_perms.get(new_role, {}).items():
db.execute(text(
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l) ON CONFLICT DO NOTHING"
), {"uid": row.id, "m": mod, "l": lvl})
db.commit()
users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": "add",
})
@router.post("/users/{user_id}/permissions", response_class=HTMLResponse)
async def user_permissions_save(request: Request, user_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
form = await request.form()
# Supprimer les anciennes permissions
db.execute(text("DELETE FROM user_permissions WHERE user_id = :uid"), {"uid": user_id})
# Inserer les nouvelles
for mod in MODULES:
lvl = form.get(f"perm_{mod}", "")
if lvl and lvl in LEVELS:
db.execute(text(
"INSERT INTO user_permissions (user_id, module, level) VALUES (:uid, :m, :l)"
), {"uid": user_id, "m": mod, "l": lvl})
db.commit()
users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": f"perms_{user_id}",
})
@router.post("/users/{user_id}/toggle", response_class=HTMLResponse)
async def user_toggle(request: Request, user_id: int, db=Depends(get_db)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
db.execute(text("UPDATE users SET is_active = NOT is_active WHERE id = :id"), {"id": user_id})
db.commit()
users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": "toggle",
})
@router.post("/users/{user_id}/password", response_class=HTMLResponse)
async def user_password(request: Request, user_id: int, db=Depends(get_db),
new_password: str = Form(...)):
user = get_current_user(request)
if not user:
return RedirectResponse(url="/login")
pw_hash = hash_password(new_password)
db.execute(text("UPDATE users SET password_hash = :ph WHERE id = :id"),
{"ph": pw_hash, "id": user_id})
db.commit()
users_data = _get_users_with_perms(db)
return templates.TemplateResponse("users.html", {
"request": request, "user": user, "app_name": APP_NAME,
"users_data": users_data, "modules": MODULES, "levels": LEVELS,
"saved": "password",
})

0
app/services/__init__.py Normal file
View File

View File

@ -0,0 +1,287 @@
"""Service campagnes — logique metier patching"""
from datetime import datetime
from sqlalchemy import text
def list_campaigns(db, year=None, status=None):
where = ["1=1"]
params = {}
if year:
where.append("c.year = :year"); params["year"] = year
if status:
where.append("c.status = :status"); params["status"] = status
wc = " AND ".join(where)
return db.execute(text(f"""
SELECT c.*, u.display_name as created_by_name,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id) as session_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'patched') as patched_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'failed') as failed_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'pending') as pending_count,
(SELECT COUNT(*) FROM patch_sessions ps WHERE ps.campaign_id = c.id AND ps.status = 'excluded') as excluded_count
FROM campaigns c
LEFT JOIN users u ON c.created_by = u.id
WHERE {wc} ORDER BY c.year DESC, c.week_code DESC
"""), params).fetchall()
def get_campaign(db, campaign_id):
return db.execute(text("""
SELECT c.*, u.display_name as created_by_name
FROM campaigns c LEFT JOIN users u ON c.created_by = u.id
WHERE c.id = :id
"""), {"id": campaign_id}).fetchone()
def get_campaign_sessions(db, campaign_id):
return db.execute(text("""
SELECT ps.*, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.etat, s.ssh_method, s.licence_support, s.machine_type,
d.name as domaine, e.name as environnement,
u.display_name as intervenant_name
FROM patch_sessions ps
JOIN servers s ON ps.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 users u ON ps.intervenant_id = u.id
WHERE ps.campaign_id = :cid
ORDER BY CASE ps.status
WHEN 'in_progress' THEN 1
WHEN 'pending' THEN 2
WHEN 'prereq_ok' THEN 3
WHEN 'patched' THEN 4
WHEN 'failed' THEN 5
WHEN 'reported' THEN 6
WHEN 'excluded' THEN 7
WHEN 'cancelled' THEN 8
ELSE 9 END, s.hostname
"""), {"cid": campaign_id}).fetchall()
def get_campaign_stats(db, campaign_id):
return db.execute(text("""
SELECT
COUNT(*) as 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 = 'in_progress') as in_progress,
COUNT(*) FILTER (WHERE status = 'skipped') as skipped,
COUNT(*) FILTER (WHERE status = 'excluded') as excluded,
COUNT(*) FILTER (WHERE status = 'reported') as reported,
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled
FROM patch_sessions WHERE campaign_id = :cid
"""), {"cid": campaign_id}).fetchone()
def get_planning_for_week(db, year, week_number):
"""Retourne les entrees planning pour une semaine donnee"""
return db.execute(text("""
SELECT pp.*, d.name as domain_name
FROM patch_planning pp
LEFT JOIN domains d ON pp.domain_code = d.code
WHERE pp.year = :y AND pp.week_number = :wn AND pp.status = 'open'
ORDER BY pp.domain_code
"""), {"y": year, "wn": week_number}).fetchall()
def get_servers_for_planning(db, year, week_number):
"""Retourne les serveurs a proposer pour une semaine du planning.
Inclut les domaines planifies + DMZ (toujours inclus)."""
planning = get_planning_for_week(db, year, week_number)
if not planning:
return [], []
# Construire les filtres domaine/env depuis le planning
domain_envs = []
for p in planning:
if p.domain_code == 'DMZ':
continue # DMZ traite separement
if p.env_scope == 'prod':
domain_envs.append(("d.code = :dc_{0} AND e.name = 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'hprod':
domain_envs.append(("d.code = :dc_{0} AND e.name != 'Production'".format(len(domain_envs)), p.domain_code))
elif p.env_scope == 'prod_pilot':
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
else: # all
domain_envs.append(("d.code = :dc_{0}".format(len(domain_envs)), p.domain_code))
if not domain_envs:
return [], planning
# Construire la clause OR
or_clauses = []
params = {}
for i, (clause, dc) in enumerate(domain_envs):
or_clauses.append(clause)
params[f"dc_{i}"] = dc
# Toujours inclure DMZ
or_clauses.append("d.code = 'DMZ'")
where = f"""
s.etat = 'en_production'
AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els')
AND ({' OR '.join(or_clauses)})
"""
servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, s.os_family, s.os_version, s.tier,
s.licence_support, s.ssh_method, s.machine_type,
d.name as domaine, d.code as domain_code, e.name as environnement
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
WHERE {where}
ORDER BY d.name, e.name, s.hostname
"""), params).fetchall()
return servers, planning
def create_campaign_from_planning(db, year, week_number, label, user_id, excluded_ids=None):
"""Cree une campagne depuis le planning avec exclusions"""
servers, planning = get_servers_for_planning(db, year, week_number)
if not servers:
return None
wc = f"S{week_number:02d}"
# Dates de la semaine
p = planning[0] if planning else None
ds = p.week_start if p else None
de = p.week_end if p else None
row = db.execute(text("""
INSERT INTO campaigns (week_code, year, label, status, date_start, date_end, created_by)
VALUES (:wc, :y, :label, 'draft', :ds, :de, :uid)
RETURNING id
"""), {"wc": wc, "y": year, "label": label, "ds": ds, "de": de, "uid": user_id}).fetchone()
cid = row.id
excluded = set(excluded_ids or [])
for s in servers:
status = 'excluded' if s.id in excluded else 'pending'
db.execute(text("""
INSERT INTO patch_sessions (campaign_id, server_id, status)
VALUES (:cid, :sid, :st)
ON CONFLICT (campaign_id, server_id) DO NOTHING
"""), {"cid": cid, "sid": s.id, "st": status})
# Update total
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status != 'excluded'"
), {"cid": cid}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": cid})
db.commit()
return cid
def exclude_session(db, session_id, reason, detail, username):
"""Exclut un serveur d'une campagne avec motif"""
db.execute(text("""
UPDATE patch_sessions SET
status = 'excluded', exclusion_reason = :reason,
exclusion_detail = :detail, excluded_by = :by,
excluded_at = now()
WHERE id = :id
"""), {"id": session_id, "reason": reason, "detail": detail, "by": username})
# Recalculer total
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row:
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id})
db.commit()
def restore_session(db, session_id):
"""Restaure un serveur exclu"""
db.execute(text("""
UPDATE patch_sessions SET
status = 'pending', exclusion_reason = NULL,
exclusion_detail = NULL, excluded_by = NULL, excluded_at = NULL
WHERE id = :id
"""), {"id": session_id})
row = db.execute(text("SELECT campaign_id FROM patch_sessions WHERE id = :id"),
{"id": session_id}).fetchone()
if row:
count = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": row.campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": count, "cid": row.campaign_id})
db.commit()
def validate_prereq(db, session_id, ssh, satellite, rollback, rollback_justif, username):
"""Valide les prereqs d'un serveur dans une campagne"""
db.execute(text("""
UPDATE patch_sessions SET
prereq_ssh = :ssh, prereq_satellite = :sat,
rollback_method = :rb, rollback_justif = :rbj,
prereq_validated = CASE WHEN :ssh = 'ok' AND :sat = 'ok' AND :rb IS NOT NULL THEN true ELSE false END,
prereq_validated_by = :by, prereq_validated_at = now(), prereq_date = now()
WHERE id = :id
"""), {"id": session_id, "ssh": ssh, "sat": satellite,
"rb": rollback or None, "rbj": rollback_justif or None, "by": username})
db.commit()
def bulk_auto_exclude_failed_prereqs(db, campaign_id, username):
"""Exclut automatiquement les serveurs qui n'ont pas passe les prereqs"""
failed = db.execute(text("""
SELECT id FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending'
AND prereq_validated = false
AND prereq_date IS NOT NULL
AND (prereq_ssh = 'ko' OR prereq_satellite = 'ko' OR rollback_method IS NULL)
"""), {"cid": campaign_id}).fetchall()
count = 0
for r in failed:
exclude_session(db, r.id, "creneau_inadequat", "Prereqs non valides — report auto", username)
count += 1
return count
def get_prereq_stats(db, campaign_id):
"""Stats prereqs d'une campagne"""
return db.execute(text("""
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as total_pending,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_validated = true) as prereq_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_validated = false AND prereq_date IS NOT NULL) as prereq_ko,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_date IS NULL) as prereq_todo,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_ssh = 'ok') as ssh_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_satellite = 'ok') as sat_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND rollback_method IS NOT NULL) as rollback_ok,
COUNT(*) FILTER (WHERE status = 'pending' AND prereq_disk_ok = true) as disk_ok
FROM patch_sessions WHERE campaign_id = :cid
"""), {"cid": campaign_id}).fetchone()
def can_plan_campaign(db, campaign_id):
"""Verifie si la campagne peut passer en 'planned' (tous les prereqs pending valides)"""
pending_not_validated = db.execute(text("""
SELECT COUNT(*) FROM patch_sessions
WHERE campaign_id = :cid AND status = 'pending' AND prereq_validated = false
"""), {"cid": campaign_id}).scalar()
return pending_not_validated == 0
def update_campaign_status(db, campaign_id, new_status):
db.execute(text("UPDATE campaigns SET status = :s WHERE id = :id"),
{"s": new_status, "id": campaign_id})
db.commit()
def get_reference_data(db):
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall()
return domains, envs

View File

@ -0,0 +1,291 @@
"""Service prerequis — verification automatique des serveurs d'une campagne
Check basique (TCP) + check approfondi (SSH) si accessible"""
import socket
import paramiko
import os
from sqlalchemy import text
# Seuils espace disque (Mo)
DISK_ROOT_MIN_MB = 1200 # 1.2 Go minimum sur /
DISK_VAR_MIN_MB = 800 # 800 Mo minimum sur /var ou /var/log
# SSH config (pour check approfondi)
SSH_KEY = "/opt/patchcenter/keys/id_rsa_cybglobal.pem" # Copier la cle ici
SSH_USER = "cybsecope"
SSH_TIMEOUT = 10
DNS_SUFFIXES = ["", ".sanef.groupe", ".sanef-rec.fr", ".sanef.fr"]
def check_prereqs_campaign(db, campaign_id):
"""Verifie les prereqs de tous les serveurs pending d'une campagne."""
sessions = db.execute(text("""
SELECT ps.id, s.hostname, s.os_family, s.etat, s.licence_support,
s.machine_type, s.satellite_host, s.ssh_method,
d.code as domain_code, z.name as zone
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending'
"""), {"cid": campaign_id}).fetchall()
checked = 0
for s in sessions:
result = _check_server(s)
_save_result(db, s.id, result)
checked += 1
auto_excluded = _auto_exclude(db, campaign_id)
db.commit()
return checked, auto_excluded
def _resolve_host(hostname):
"""Trouve un FQDN resolvable et joignable sur port 22"""
for suffix in DNS_SUFFIXES:
target = hostname + suffix
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
r = sock.connect_ex((target, 22))
sock.close()
if r == 0:
return target
except (socket.gaierror, socket.timeout, OSError):
continue
return None
def _ssh_connect(target):
"""Tente une connexion SSH par cle. Retourne le client ou None."""
if not os.path.exists(SSH_KEY):
return None
for loader in [paramiko.RSAKey.from_private_key_file, paramiko.Ed25519Key.from_private_key_file]:
try:
key = loader(SSH_KEY)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(target, port=22, username=SSH_USER, pkey=key,
timeout=SSH_TIMEOUT, look_for_keys=False, allow_agent=False)
return client
except Exception:
continue
return None
def _ssh_cmd(client, cmd, timeout=8):
"""Execute une commande SSH et retourne stdout"""
try:
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
return stdout.read().decode("utf-8", errors="replace").strip()
except Exception:
return ""
def _check_disk(client):
"""Verifie l'espace disque via SSH. Retourne (root_mb, var_mb, ok)"""
output = _ssh_cmd(client, "df -BM --output=target,avail 2>/dev/null | grep -E '^/ |^/var' | head -5")
root_mb = None
var_mb = None
for line in output.split("\n"):
parts = line.split()
if len(parts) >= 2:
mount = parts[0]
try:
avail = int(parts[1].replace("M", ""))
except ValueError:
continue
if mount == "/":
root_mb = avail
elif mount in ("/var", "/var/log"):
var_mb = avail if var_mb is None or avail < (var_mb or 9999) else var_mb
# Si /var pas monte separement, il est dans /
if var_mb is None and root_mb is not None:
var_mb = root_mb
ok = True
if root_mb is not None and root_mb < DISK_ROOT_MIN_MB:
ok = False
if var_mb is not None and var_mb < DISK_VAR_MIN_MB:
ok = False
return root_mb, var_mb, ok
def _check_satellite_ssh(client):
"""Verifie la connectivite Satellite via SSH"""
output = _ssh_cmd(client, "subscription-manager identity 2>/dev/null | grep -i 'org' || echo 'not_registered'")
if "not_registered" in output or not output:
return "ko"
return "ok"
def _check_server(s):
"""Verifie un serveur. Check basique + approfondi si SSH OK."""
result = {
"ssh": "pending", "satellite": "pending", "rollback": None,
"disk_root_mb": None, "disk_var_mb": None, "disk_ok": None,
"eligible": True, "exclude_reason": None, "exclude_detail": None,
}
# 1. Eligibilite de base
if s.licence_support == 'eol':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_detail"] = "Licence EOL — serveur non supporte"
return result
if s.etat != 'en_production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
return result
# 2. Connectivite TCP port 22
target = _resolve_host(s.hostname)
if not target:
result["ssh"] = "ko"
result["eligible"] = False
result["exclude_reason"] = "creneau_inadequat"
result["exclude_detail"] = f"Port 22 injoignable ({s.hostname})"
return result
result["ssh"] = "ok"
# 3. Rollback
if s.machine_type == 'vm':
result["rollback"] = "snapshot"
else:
result["rollback"] = "na"
# 4. Check approfondi via SSH (si cle dispo)
client = _ssh_connect(target)
if client:
try:
# Espace disque
root_mb, var_mb, disk_ok = _check_disk(client)
result["disk_root_mb"] = root_mb
result["disk_var_mb"] = var_mb
result["disk_ok"] = disk_ok
if not disk_ok:
detail_parts = []
if root_mb is not None and root_mb < DISK_ROOT_MIN_MB:
detail_parts.append(f"/ = {root_mb}Mo (min {DISK_ROOT_MIN_MB}Mo)")
if var_mb is not None and var_mb < DISK_VAR_MIN_MB:
detail_parts.append(f"/var = {var_mb}Mo (min {DISK_VAR_MIN_MB}Mo)")
result["eligible"] = False
result["exclude_reason"] = "creneau_inadequat"
result["exclude_detail"] = "Espace disque insuffisant: " + ", ".join(detail_parts)
# Satellite reel (Linux)
if s.os_family == 'linux':
result["satellite"] = _check_satellite_ssh(client)
else:
result["satellite"] = "na"
finally:
try:
client.close()
except Exception:
pass
else:
# Pas de connexion SSH approfondie — fallback sur les donnees en base
if s.os_family == 'linux':
result["satellite"] = "ok" if s.satellite_host else "ko"
else:
result["satellite"] = "na"
result["disk_ok"] = None # Non verifie
return result
def _save_result(db, session_id, result):
"""Sauvegarde les resultats prereqs"""
all_ok = (result["eligible"]
and result["ssh"] == "ok"
and result.get("disk_ok") is not False)
db.execute(text("""
UPDATE patch_sessions SET
prereq_ssh = :ssh, prereq_satellite = :sat,
rollback_method = COALESCE(rollback_method, :rb),
prereq_disk_root = :dr, prereq_disk_log = :dv,
prereq_disk_root_mb = :drm, prereq_disk_var_mb = :dvm,
prereq_disk_ok = :dok,
prereq_validated = :valid, prereq_date = now()
WHERE id = :id
"""), {
"id": session_id, "ssh": result["ssh"], "sat": result["satellite"],
"rb": result["rollback"],
"dr": result["disk_root_mb"], "dv": result["disk_var_mb"],
"drm": result["disk_root_mb"], "dvm": result["disk_var_mb"],
"dok": result["disk_ok"],
"valid": all_ok,
})
def _auto_exclude(db, campaign_id):
"""Exclut les serveurs non eligibles apres verification"""
non_eligible = db.execute(text("""
SELECT ps.id, s.hostname, s.licence_support, s.etat,
ps.prereq_ssh, ps.prereq_disk_ok
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending'
AND (s.licence_support = 'eol'
OR s.etat != 'en_production'
OR ps.prereq_ssh = 'ko'
OR ps.prereq_disk_ok = false)
"""), {"cid": campaign_id}).fetchall()
count = 0
for s in non_eligible:
if s.licence_support == 'eol':
reason, detail = "eol", "Licence EOL — auto-exclu"
elif s.etat != 'en_production':
reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu"
elif s.prereq_disk_ok is False:
reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu"
else:
reason, detail = "creneau_inadequat", "SSH injoignable — auto-exclu"
db.execute(text("""
UPDATE patch_sessions SET
status = 'excluded', exclusion_reason = :r,
exclusion_detail = :d, excluded_by = 'system', excluded_at = now()
WHERE id = :id
"""), {"id": s.id, "r": reason, "d": detail})
count += 1
if count > 0:
total = db.execute(text(
"SELECT COUNT(*) FROM patch_sessions WHERE campaign_id = :cid AND status NOT IN ('excluded','cancelled')"
), {"cid": campaign_id}).scalar()
db.execute(text("UPDATE campaigns SET total_servers = :c WHERE id = :cid"),
{"c": total, "cid": campaign_id})
return count
def check_single_prereq(db, session_id):
"""Verifie les prereqs d'un seul serveur"""
s = db.execute(text("""
SELECT ps.id, s.hostname, s.os_family, s.etat, s.licence_support,
s.machine_type, s.satellite_host, s.ssh_method,
d.code as domain_code, z.name as zone
FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE ps.id = :sid
"""), {"sid": session_id}).fetchone()
if not s:
return None
result = _check_server(s)
_save_result(db, session_id, result)
db.commit()
return result

View File

@ -0,0 +1,156 @@
"""Service Qualys — sync tags pour un serveur via API"""
import re
import requests
import urllib3
from sqlalchemy import text
from .secrets_service import get_secret
urllib3.disable_warnings()
def _get_qualys_creds(db):
"""Recupere les credentials Qualys depuis les secrets chiffres"""
url = get_secret(db, "qualys_url") or "https://qualysapi.qualys.eu"
user = get_secret(db, "qualys_user") or ""
pwd = get_secret(db, "qualys_pass") or ""
proxy = get_secret(db, "qualys_proxy") or ""
return url, user, pwd, proxy
def parse_xml(txt, tag):
return re.findall(f"<{tag}>([^<]*)</{tag}>", txt)
def sync_server_qualys(db, server_id):
"""Sync les tags Qualys pour un serveur donne. Retourne un dict resultat."""
row = db.execute(text(
"SELECT hostname, qualys_asset_id FROM servers WHERE id = :id"
), {"id": server_id}).fetchone()
if not row:
return {"ok": False, "msg": "Serveur introuvable"}
hostname = row.hostname
qid = row.qualys_asset_id
qualys_url, qualys_user, qualys_pass, qualys_proxy = _get_qualys_creds(db)
if not qualys_user:
return {"ok": False, "msg": "Credentials Qualys non configures (Settings)"}
proxies = {"https": qualys_proxy, "http": qualys_proxy} if qualys_proxy else None
# Chercher l'asset par hostname si pas de qualys_asset_id
if not qid:
qid = _find_asset_by_hostname(qualys_url, qualys_user, qualys_pass, hostname, proxies)
if not qid:
return {"ok": False, "msg": f"Asset '{hostname}' non trouve dans Qualys"}
db.execute(text("UPDATE servers SET qualys_asset_id = :qid WHERE id = :id"),
{"qid": qid, "id": server_id})
# Recuperer l'asset complet avec tags
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/search/am/hostasset",
json={"ServiceRequest": {
"filters": {"Criteria": [
{"field": "id", "operator": "EQUALS", "value": str(qid)}
]}
}},
auth=(qualys_user, qualys_pass),
verify=False, timeout=60, proxies=proxies,
headers={"Content-Type": "application/json"}
)
except Exception as e:
return {"ok": False, "msg": f"Erreur API: {e}"}
if r.status_code != 200 or "SUCCESS" not in r.text:
return {"ok": False, "msg": f"API HTTP {r.status_code}"}
# Parser asset
blocks = r.text.split("<HostAsset>")
if len(blocks) < 2:
return {"ok": False, "msg": "Asset non trouve dans la reponse"}
block = blocks[1].split("</HostAsset>")[0]
fqdn = (parse_xml(block, "fqdn") or [""])[0]
address = (parse_xml(block, "address") or [""])[0]
os_val = (parse_xml(block, "os") or [""])[0]
agent_status = (parse_xml(block, "status") or [""])[0] if "<agentInfo>" in block else ""
agent_version = (parse_xml(block, "agentVersion") or [""])[0]
last_checkin = (parse_xml(block, "lastCheckedIn") or [""])[0] or None
os_family = None
os_low = os_val.lower()
if any(k in os_low for k in ("linux", "red hat", "centos", "debian")):
os_family = "linux"
elif "windows" in os_low:
os_family = "windows"
# Update qualys_assets
db.execute(text("""
INSERT INTO qualys_assets (qualys_asset_id, name, hostname, fqdn, ip_address, os, os_family,
agent_status, agent_version, last_checkin, server_id)
VALUES (:qid, :name, :hn, :fqdn, :ip, :os, :osf, :ast, :av, :lc, :sid)
ON CONFLICT (qualys_asset_id) DO UPDATE SET
fqdn=EXCLUDED.fqdn, ip_address=EXCLUDED.ip_address, os=EXCLUDED.os,
os_family=EXCLUDED.os_family, agent_status=EXCLUDED.agent_status,
agent_version=EXCLUDED.agent_version, last_checkin=EXCLUDED.last_checkin, updated_at=now()
"""), {"qid": qid, "name": hostname, "hn": hostname.split(".")[0].lower(),
"fqdn": fqdn or None, "ip": address or None, "os": os_val, "osf": os_family,
"ast": agent_status, "av": agent_version, "lc": last_checkin, "sid": server_id})
# Enrichir servers
db.execute(text("""
UPDATE servers SET
fqdn = COALESCE(NULLIF(:fqdn, ''), fqdn),
os_version = COALESCE(NULLIF(:os, ''), os_version)
WHERE id = :id
"""), {"fqdn": fqdn, "os": os_val, "id": server_id})
# Tags
tag_count = 0
if "<tags>" in block:
tag_block = block.split("<tags>")[1].split("</tags>")[0]
tag_ids = parse_xml(tag_block, "id")
tag_names = parse_xml(tag_block, "name")
# Supprimer anciens liens
db.execute(text("DELETE FROM qualys_asset_tags WHERE qualys_asset_id = :qid"), {"qid": qid})
for tid, tname in zip(tag_ids, tag_names):
# Upsert tag
db.execute(text("""
INSERT INTO qualys_tags (qualys_tag_id, name) VALUES (:tid, :tn)
ON CONFLICT (qualys_tag_id) DO UPDATE SET name=EXCLUDED.name, updated_at=now()
"""), {"tid": int(tid), "tn": tname})
# Lien asset-tag
db.execute(text("""
INSERT INTO qualys_asset_tags (qualys_asset_id, qualys_tag_id)
VALUES (:qid, :tid) ON CONFLICT DO NOTHING
"""), {"qid": qid, "tid": int(tid)})
tag_count += 1
db.commit()
return {"ok": True, "msg": f"Synchro OK — {tag_count} tags", "tags": tag_count}
def _find_asset_by_hostname(qualys_url, qualys_user, qualys_pass, hostname, proxies=None):
"""Cherche un asset Qualys par hostname"""
try:
r = requests.post(
f"{qualys_url}/qps/rest/2.0/search/am/hostasset",
json={"ServiceRequest": {
"preferences": {"limitResults": 5},
"filters": {"Criteria": [
{"field": "name", "operator": "CONTAINS", "value": hostname}
]}
}},
auth=(qualys_user, qualys_pass),
verify=False, timeout=60, proxies=proxies,
headers={"Content-Type": "application/json"}
)
if r.status_code == 200 and "SUCCESS" in r.text:
ids = parse_xml(r.text, "id")
if ids:
return int(ids[0])
except Exception:
pass
return None

View File

@ -0,0 +1,64 @@
"""Service secrets — chiffrement Fernet pour credentials en base"""
import os
import base64
from cryptography.fernet import Fernet
from sqlalchemy import text
from ..config import SECRET_KEY
# Derive une cle Fernet 32 bytes depuis SECRET_KEY
_raw = SECRET_KEY.encode()[:32].ljust(32, b'\0')
_fernet_key = base64.urlsafe_b64encode(_raw)
_fernet = Fernet(_fernet_key)
def encrypt(value: str) -> str:
return _fernet.encrypt(value.encode()).decode()
def decrypt(value: str) -> str:
return _fernet.decrypt(value.encode()).decode()
def get_secret(db, key: str) -> str | None:
"""Recupere et dechiffre un secret depuis app_secrets"""
row = db.execute(text("SELECT value FROM app_secrets WHERE key = :k"), {"k": key}).fetchone()
if not row:
return None
try:
return decrypt(row.value)
except Exception:
return None
def set_secret(db, key: str, value: str, description: str = ""):
"""Chiffre et stocke un secret dans app_secrets"""
enc = encrypt(value)
db.execute(text("""
INSERT INTO app_secrets (key, value, description, updated_at)
VALUES (:k, :v, :d, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value,
description = EXCLUDED.description, updated_at = now()
"""), {"k": key, "v": enc, "d": description})
db.commit()
def list_secrets(db):
"""Liste les cles (sans valeurs) des secrets"""
rows = db.execute(text(
"SELECT key, description, updated_at FROM app_secrets ORDER BY key"
)).fetchall()
return rows
def init_secrets_from_config(db):
"""Initialise les secrets depuis config si pas encore en base"""
from ..config import QUALYS_URL, QUALYS_USER, QUALYS_PASS
defaults = {
"qualys_url": (QUALYS_URL, "URL API Qualys"),
"qualys_user": (QUALYS_USER, "Utilisateur Qualys"),
"qualys_pass": (QUALYS_PASS, "Mot de passe Qualys"),
"qualys_proxy": ("http://proxy.sanef.fr:8080", "Proxy Qualys"),
}
for key, (val, desc) in defaults.items():
if val and not get_secret(db, key):
set_secret(db, key, val, desc)

View File

@ -0,0 +1,227 @@
"""Logique metier serveurs — requetes SQL separees du router"""
from sqlalchemy import text
def get_server_full(db, server_id):
"""Retourne un serveur avec tous ses JOINs"""
return db.execute(text("""
SELECT s.*, d.name as domaine, d.code as domaine_code,
e.name as environnement, e.code as env_code,
z.name as zone, s.qualys_asset_id as qid
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE s.id = :id
"""), {"id": server_id}).fetchone()
def get_server_tags(db, qualys_asset_id):
"""Retourne les tags Qualys d'un asset"""
if not qualys_asset_id:
return []
rows = db.execute(text("""
SELECT qt.name FROM qualys_asset_tags qat
JOIN qualys_tags qt ON qat.qualys_tag_id = qt.qualys_tag_id
WHERE qat.qualys_asset_id = :aid ORDER BY qt.name
"""), {"aid": qualys_asset_id}).fetchall()
return [r.name for r in rows]
def get_server_ips(db, server_id):
"""Retourne les IPs segmentees: reelle, connexion, autres"""
rows = db.execute(text("""
SELECT ip_address, ip_type, is_ssh, interface, description
FROM server_ips WHERE server_id = :id ORDER BY ip_type, ip_address
"""), {"id": server_id}).fetchall()
ip_reelle = None
ip_connexion = None
autres_ips = []
for r in rows:
ip = str(r.ip_address)
is_primary = (r.ip_type == 'primary')
is_ssh = r.is_ssh
if is_primary and not ip_reelle:
ip_reelle = ip
if is_ssh and not ip_connexion:
ip_connexion = ip
if not is_primary and not is_ssh:
autres_ips.append({
"ip": ip, "type": r.ip_type,
"interface": r.interface or "", "description": r.description or ""
})
return {"ip_reelle": ip_reelle, "ip_connexion": ip_connexion, "autres_ips": autres_ips}
def update_server_ips(db, server_id, ip_reelle, ip_connexion):
"""Met a jour IP reelle et IP de connexion"""
ip_reelle = (ip_reelle or "").strip()
ip_connexion = (ip_connexion or "").strip()
# Supprimer les anciennes entrees primary / ssh
db.execute(text(
"DELETE FROM server_ips WHERE server_id = :sid AND (ip_type = 'primary' OR is_ssh = true)"
), {"sid": server_id})
if not ip_reelle and not ip_connexion:
return
if ip_reelle == ip_connexion:
# Meme IP : une seule row primary + ssh
db.execute(text("""
INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh)
VALUES (:sid, :ip, 'primary', true)
"""), {"sid": server_id, "ip": ip_reelle})
else:
if ip_reelle:
db.execute(text("""
INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh)
VALUES (:sid, :ip, 'primary', false)
"""), {"sid": server_id, "ip": ip_reelle})
if ip_connexion:
db.execute(text("""
INSERT INTO server_ips (server_id, ip_address, ip_type, is_ssh)
VALUES (:sid, :ip, 'secondary', true)
"""), {"sid": server_id, "ip": ip_connexion})
SORT_COLS = {
"hostname": "s.hostname",
"env": "e.name",
"domaine": "d.name",
"tier": "s.tier",
"etat": "s.etat",
"os": "s.os_family",
"owner": "s.patch_os_owner",
"zone": "z.name",
}
def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="asc"):
"""Liste paginee, filtree et triee des serveurs"""
offset = (page - 1) * per_page
where = ["1=1"]
params = {"limit": per_page, "offset": offset}
if filters.get("domain"):
where.append("d.code = :domain"); params["domain"] = filters["domain"]
if filters.get("env"):
where.append("e.code = :env"); params["env"] = filters["env"]
if filters.get("tier"):
where.append("s.tier = :tier"); params["tier"] = filters["tier"]
if filters.get("etat"):
where.append("s.etat = :etat"); params["etat"] = filters["etat"]
if filters.get("search"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
wc = " AND ".join(where)
order_col = SORT_COLS.get(sort, "s.hostname")
order_dir = "DESC" if sort_dir == "desc" else "ASC"
order_clause = f"{order_col} {order_dir}, s.hostname ASC"
servers = db.execute(text(f"""
SELECT s.id, s.hostname, s.fqdn, d.name as domaine, e.name as environnement,
z.name as zone, s.os_family, s.os_version, s.tier, s.etat,
s.licence_support, s.patch_os_owner, s.responsable_nom, s.machine_type,
CASE
WHEN s.os_version ILIKE '%Red Hat%' THEN
'Red Hat ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%Oracle%Linux%' THEN
'Oracle ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%CentOS%' THEN
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d[\d.]*)'))[1], '')
WHEN s.os_version ILIKE '%Ubuntu%' THEN 'Ubuntu'
WHEN s.os_version ILIKE '%Windows Server 2022 Standard%' THEN '2022 Standard'
WHEN s.os_version ILIKE '%Windows Server 2022 Datacenter%' THEN '2022 Datacenter'
WHEN s.os_version ILIKE '%Windows Server 2019 Standard%' THEN '2019 Standard'
WHEN s.os_version ILIKE '%Windows Server 2019 Datacenter%' THEN '2019 Datacenter'
WHEN s.os_version ILIKE '%Windows Server 2016 Standard%' THEN '2016 Standard'
WHEN s.os_version ILIKE '%Windows Server 2016%' THEN '2016 Standard'
WHEN s.os_version ILIKE '%Windows Server 2012%' THEN '2012 R2'
WHEN s.os_version ILIKE '%Windows Server 2008%' THEN '2008 R2'
WHEN s.os_version ILIKE '%Windows 10 Enterprise%' THEN 'Windows 10 Ent'
WHEN s.os_version ILIKE '%Windows%2022%' THEN '2022 Standard'
WHEN s.os_version ILIKE '%Windows%2019%' THEN '2019 Standard'
WHEN s.os_version ILIKE '%Windows%2016%' THEN '2016 Standard'
WHEN s.os_version ILIKE '%Windows%2019%' THEN '2019 Standard'
WHEN s.os_version ILIKE '%Windows%2022%' THEN '2022 Standard'
ELSE LEFT(s.os_version, 25)
END as os_short
FROM servers s
LEFT JOIN domain_environments de ON s.domain_env_id = de.id
LEFT JOIN domains d ON de.domain_id = d.id
LEFT JOIN environments e ON de.environment_id = e.id
LEFT JOIN zones z ON s.zone_id = z.id
WHERE {wc} ORDER BY {order_clause} LIMIT :limit OFFSET :offset
"""), params).fetchall()
total = db.execute(text(f"""
SELECT COUNT(*) 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
WHERE {wc}
"""), params).scalar()
return servers, total
def update_server(db, server_id, data, username):
"""Met a jour un serveur et log l'action"""
# Domain + Env -> domain_env_id
if data.get("domain_code") and data.get("env_code"):
row = db.execute(text("""
SELECT de.id FROM domain_environments de
JOIN domains d ON de.domain_id = d.id
JOIN environments e ON de.environment_id = e.id
WHERE d.code = :dc AND e.code = :ec
"""), {"dc": data["domain_code"], "ec": data["env_code"]}).fetchone()
if row:
db.execute(text("UPDATE servers SET domain_env_id = :deid WHERE id = :id"),
{"deid": row.id, "id": server_id})
# Zone
if data.get("zone"):
zrow = db.execute(text("SELECT id FROM zones WHERE name = :z"), {"z": data["zone"]}).fetchone()
if zrow:
db.execute(text("UPDATE servers SET zone_id = :zid WHERE id = :id"),
{"zid": zrow.id, "id": server_id})
# IPs (reelle + connexion)
update_server_ips(db, server_id, data.get("ip_reelle"), data.get("ip_connexion"))
# Champs directs
updates = []
params = {"id": server_id}
direct_fields = ["tier", "etat", "patch_os_owner", "responsable_nom",
"referent_nom", "mode_operatoire", "commentaire", "ssh_method"]
changed = []
for field in direct_fields:
if data.get(field) is not None:
updates.append(f"{field} = :{field}")
params[field] = data[field]
changed.append(field)
if updates:
updates.append("updated_at = now()")
db.execute(text(f"UPDATE servers SET {', '.join(updates)} WHERE id = :id"), params)
# Audit
db.execute(text(
"INSERT INTO audit_log (username, action, entity_type, entity_id) VALUES (:un, 'EDIT_SERVER', 'server', :sid)"
), {"un": username, "sid": server_id})
db.commit()
return changed
def get_reference_data(db):
"""Retourne les listes de reference pour les filtres/formulaires"""
domains = db.execute(text("SELECT code, name FROM domains ORDER BY display_order")).fetchall()
envs = db.execute(text("SELECT code, name FROM environments ORDER BY display_order")).fetchall()
return domains, envs

90
app/templates/base.html Normal file
View File

@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ app_name }} - {% block title %}{% endblock %}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js" defer></script>
<script>
tailwind.config = {
theme: { extend: { colors: {
cyber: { bg: '#0a0e17', card: '#111827', border: '#1e3a5f', accent: '#00d4ff', green: '#00ff88', red: '#ff3366', yellow: '#ffcc00' }
}}}
}
</script>
<style>
body { background: #0a0e17; color: #e2e8f0; font-family: 'Segoe UI', system-ui, sans-serif; }
.sidebar { background: #111827; border-right: 1px solid #1e3a5f; }
.card { background: #111827; border: 1px solid #1e3a5f; border-radius: 8px; }
.btn-primary { background: #00d4ff; color: #0a0e17; font-weight: 600; border-radius: 6px; }
.btn-primary:hover { background: #00b8e6; }
.btn-danger { background: #ff3366; color: white; border-radius: 6px; }
.btn-sm { padding: 2px 10px; font-size: 0.75rem; border-radius: 4px; cursor: pointer; }
.table-cyber th { background: #1e3a5f; color: #00d4ff; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
.table-cyber td { border-bottom: 1px solid #1e3a5f; font-size: 0.8rem; }
.table-cyber tr:hover { background: #1a2332; cursor: pointer; }
.table-cyber tr.selected { background: #1e3a5f44; }
.badge { padding: 2px 8px; border-radius: 12px; font-size: 0.65rem; font-weight: 600; }
.badge-green { background: #00ff8822; color: #00ff88; }
.badge-red { background: #ff336622; color: #ff3366; }
.badge-yellow { background: #ffcc0022; color: #ffcc00; }
.badge-blue { background: #00d4ff22; color: #00d4ff; }
.badge-gray { background: #4a556822; color: #94a3b8; }
input, select, textarea { background: #0a0e17; border: 1px solid #1e3a5f; color: #e2e8f0; border-radius: 6px; padding: 6px 12px; font-size: 0.85rem; }
input:focus, select:focus, textarea:focus { outline: none; border-color: #00d4ff; box-shadow: 0 0 0 2px #00d4ff33; }
.panel-slide { transition: transform 0.3s ease, opacity 0.3s ease; }
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
.htmx-request .htmx-indicator { opacity: 1; }
.inline-edit { background: transparent; border: 1px solid transparent; padding: 2px 4px; }
.inline-edit:hover { border-color: #1e3a5f; }
.inline-edit:focus { background: #0a0e17; border-color: #00d4ff; }
.toast { position: fixed; bottom: 20px; right: 20px; padding: 12px 24px; border-radius: 8px; z-index: 1000; animation: fadeIn 0.3s; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body class="min-h-screen" hx-headers='{"X-Requested-With": "htmx"}'>
{% if user %}
<div class="flex min-h-screen">
<aside class="sidebar w-52 flex-shrink-0 flex flex-col">
<div class="p-4 border-b border-cyber-border">
<h1 class="text-cyber-accent font-bold text-lg">PatchCenter</h1>
<p class="text-xs text-gray-500">v2.0 — SecOps</p>
</div>
<nav class="flex-1 p-3 space-y-1">
<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>
<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>
<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>
<a href="/campaigns" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'campaigns' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Campagnes</a>
<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>
<a href="#" class="block px-3 py-2 rounded-md text-sm text-gray-600">Tags Qualys</a>
<a href="#" class="block px-3 py-2 rounded-md text-sm text-gray-600">Audit</a>
<a href="/users" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'users' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Utilisateurs</a>
<a href="/settings" class="block px-3 py-2 rounded-md text-sm hover:bg-cyber-border/30 {% if 'settings' in request.url.path %}bg-cyber-border/30 text-cyber-accent{% else %}text-gray-400{% endif %}">Settings</a>
</nav>
</aside>
<main class="flex-1 flex flex-col overflow-hidden">
<!-- Top bar -->
<header class="flex items-center justify-end px-6 py-2 border-b border-cyber-border bg-cyber-card">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-400">{{ user.sub }}</span>
<span class="badge badge-blue">{{ user.role }}</span>
<a href="/logout" class="btn-sm bg-cyber-border text-gray-300 hover:bg-red-900/40 hover:text-cyber-red transition-colors">Deconnexion</a>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<div class="flex-1 p-6 overflow-auto" id="main-content">
{% block content %}{% endblock %}
</div>
<div id="detail-panel" class="w-0 overflow-hidden transition-all duration-300 border-l border-cyber-border bg-cyber-card">
</div>
</div>
</main>
</div>
{% else %}
{% block fullpage %}{% endblock %}
{% endif %}
<div id="toast-container"></div>
</body>
</html>

View File

@ -0,0 +1,250 @@
{% extends 'base.html' %}
{% block title %}{{ c.label or c.week_code }}{% endblock %}
{% block content %}
<!-- Header -->
<div class="flex justify-between items-center mb-4">
<div>
<a href="/campaigns" class="text-xs text-gray-500 hover:text-gray-300">← Campagnes</a>
<h2 class="text-xl font-bold text-cyber-accent">{{ c.label or c.week_code }}</h2>
<div class="flex items-center gap-3 mt-1">
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'planned' %}badge-blue{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
<span class="text-sm text-gray-500">{{ c.week_code }} {{ c.year }}</span>
{% if c.date_start %}<span class="text-sm text-gray-500">{{ c.date_start.strftime('%d/%m/%Y') }}{% if c.date_end %} → {{ c.date_end.strftime('%d/%m/%Y') }}{% endif %}</span>{% endif %}
<span class="text-xs text-gray-600">par {{ c.created_by_name or '-' }}</span>
</div>
</div>
<div class="flex gap-2">
{% if c.status == 'draft' %}
{% if can_plan %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="planned">
<button class="btn-primary px-4 py-2 text-sm">Planifier</button></form>
{% else %}
<button class="btn-sm bg-gray-700 text-gray-500 px-4 py-2 cursor-not-allowed" title="Tous les prereqs doivent etre valides">Planifier (prereqs requis)</button>
{% endif %}
{% elif c.status == 'planned' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="in_progress">
<button class="btn-primary px-4 py-2 text-sm">Demarrer</button></form>
{% elif c.status == 'in_progress' %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="completed">
<button class="btn-sm bg-cyber-green text-black px-4 py-2">Terminer</button></form>
{% endif %}
{% if c.status in ('draft', 'planned') %}
<form method="POST" action="/campaigns/{{ c.id }}/status"><input type="hidden" name="new_status" value="cancelled">
<button class="btn-sm bg-red-900/30 text-cyber-red px-4 py-2" onclick="return confirm('Annuler ?')">Annuler</button></form>
{% endif %}
</div>
</div>
{% if msg %}
<div class="mb-3 p-2 rounded text-sm {% if 'prereq_needed' in msg %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'excluded' %}Serveur exclu.{% elif msg == 'restored' %}Serveur restaure.{% elif msg == 'prereq_saved' %}Prereqs sauvegardes.{% elif msg == 'prereq_checked' %}Prereq re-verifie.{% elif msg == 'prereq_needed' %}Impossible de planifier : tous les serveurs pending doivent avoir leurs prereqs valides.{% elif msg.startswith('checked_') %}Verification terminee : {{ msg.split('_')[1] }} serveur(s) verifies, {{ msg.split('_')[2] }} auto-exclus.{% elif msg.startswith('auto_excluded_') %}{{ msg.split('_')[-1] }} serveur(s) exclus (prereqs KO).{% endif %}
</div>
{% endif %}
<!-- KPIs -->
<div class="grid grid-cols-7 gap-3 mb-4">
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-accent">{{ 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 text-cyber-green">{{ stats.patched }}</div>
<div class="text-xs text-gray-500">Patches</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-red">{{ stats.failed }}</div>
<div class="text-xs text-gray-500">Echoues</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-cyber-yellow">{{ stats.pending }}</div>
<div class="text-xs text-gray-500">En attente</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-gray-500">{{ stats.excluded }}</div>
<div class="text-xs text-gray-500">Exclus</div>
</div>
<div class="card p-3 text-center">
<div class="text-2xl font-bold text-blue-400">{{ stats.reported }}</div>
<div class="text-xs text-gray-500">Reportes</div>
</div>
<div class="card p-3 text-center">
{% set patchable = stats.total - stats.excluded - stats.cancelled %}
{% if patchable > 0 %}
<div class="text-2xl font-bold text-cyber-accent">{{ (stats.patched / patchable * 100)|int }}%</div>
{% else %}<div class="text-2xl font-bold text-gray-600">-</div>{% endif %}
<div class="text-xs text-gray-500">Progression</div>
</div>
</div>
<!-- Prereqs stats (draft only) -->
{% if c.status == 'draft' and prereq %}
<div class="card p-4 mb-4">
<div class="flex justify-between items-center mb-2">
<h3 class="text-sm font-bold text-cyber-accent">Prerequis ({{ prereq.prereq_ok }}/{{ prereq.total_pending }} valides)</h3>
<div class="flex gap-2">
<form method="POST" action="/campaigns/{{ c.id }}/check-prereqs">
<button class="btn-primary px-3 py-1 text-sm" onclick="this.textContent='Verification en cours...'; this.disabled=true; this.form.submit()">Verifier les prereqs</button>
</form>
{% if prereq.prereq_ko > 0 %}
<form method="POST" action="/campaigns/{{ c.id }}/auto-exclude-failed">
<button class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Exclure les {{ prereq.prereq_ko }} serveurs en echec ?')">Exclure {{ prereq.prereq_ko }} KO</button>
</form>
{% endif %}
</div>
</div>
<div class="grid grid-cols-5 gap-3 text-sm">
<div class="flex justify-between"><span class="text-gray-500">A verifier</span><span class="text-cyber-yellow">{{ prereq.prereq_todo }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">SSH OK</span><span class="text-cyber-green">{{ prereq.ssh_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Satellite OK</span><span class="text-cyber-green">{{ prereq.sat_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Rollback OK</span><span class="text-cyber-green">{{ prereq.rollback_ok }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Disque OK</span><span class="text-cyber-green">{{ prereq.disk_ok }}</span></div>
</div>
{% if prereq.total_pending > 0 %}
<div class="w-full h-2 bg-gray-800 rounded-full overflow-hidden mt-2">
<div class="h-full bg-cyber-green" style="width: {{ (prereq.prereq_ok / prereq.total_pending * 100)|int }}%"></div>
</div>
{% endif %}
</div>
{% endif %}
<!-- Table serveurs -->
<div x-data="{ excluding: null, prereqing: null }" class="card overflow-x-auto">
<table class="w-full table-cyber">
<thead><tr>
<th class="text-left p-2">Hostname</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">OS</th>
<th class="p-2">Licence</th>
{% if c.status == 'draft' %}
<th class="p-2">SSH</th>
<th class="p-2">Satellite</th>
<th class="p-2">Rollback</th>
<th class="p-2">Disque</th>
<th class="p-2">Prereq</th>
{% endif %}
<th class="p-2">Statut</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for s in sessions %}
<tr id="row-{{ s.id }}" class="{% if s.status == 'excluded' %}opacity-40{% elif s.status == 'patched' %}opacity-60{% elif s.status == 'failed' %}bg-red-900/10{% endif %}">
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td>
<td class="p-2 text-center text-xs">{{ s.os_family or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
{% if c.status == 'draft' %}
<td class="p-2 text-center">
{% if s.prereq_ssh == 'ok' %}<span class="text-cyber-green text-xs">OK</span>
{% elif s.prereq_ssh == 'ko' %}<span class="text-cyber-red text-xs">KO</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
<td class="p-2 text-center">
{% if s.prereq_satellite == 'ok' %}<span class="text-cyber-green text-xs">OK</span>
{% elif s.prereq_satellite == 'ko' %}<span class="text-cyber-red text-xs">KO</span>
{% elif s.prereq_satellite == 'na' %}<span class="text-gray-500 text-xs">N/A</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
<td class="p-2 text-center">
{% if s.rollback_method %}<span class="badge {% if s.rollback_method == 'force' %}badge-red{% else %}badge-green{% endif %}">{{ s.rollback_method }}</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
<td class="p-2 text-center text-xs">
{% if s.prereq_disk_ok is true %}<span class="text-cyber-green" title="/ {{ s.prereq_disk_root_mb or '?' }}Mo | /var {{ s.prereq_disk_var_mb or '?' }}Mo">OK</span>
{% elif s.prereq_disk_ok is false %}<span class="text-cyber-red" title="/ {{ s.prereq_disk_root_mb or '?' }}Mo | /var {{ s.prereq_disk_var_mb or '?' }}Mo">KO</span>
{% else %}<span class="text-gray-600">-</span>{% endif %}
</td>
<td class="p-2 text-center">
{% if s.prereq_validated %}<span class="badge badge-green">OK</span>
{% elif s.prereq_date %}<span class="badge badge-red">KO</span>
{% else %}<span class="text-gray-600 text-xs">-</span>{% endif %}
</td>
{% endif %}
<td class="p-2 text-center">
<span class="badge {% if s.status == 'patched' %}badge-green{% elif s.status == 'failed' %}badge-red{% elif s.status == 'excluded' %}badge-gray{% elif s.status == 'in_progress' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.status }}</span>
{% if s.exclusion_reason %}
<div class="text-[10px] text-gray-500 mt-0.5" title="{{ s.exclusion_detail or '' }}">
{% if s.exclusion_reason == 'eol' %}EOL
{% elif s.exclusion_reason == 'creneau_inadequat' %}Creneau/Prereq
{% elif s.exclusion_reason == 'intervention_non_secops' %}Non-SecOps
{% elif s.exclusion_reason == 'report_cycle' %}Reporte
{% elif s.exclusion_reason == 'non_patchable' %}Non patchable
{% else %}{{ s.exclusion_reason }}{% endif %}
{% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
</div>
{% if s.exclusion_detail %}<div class="text-[9px] text-gray-600 italic">{{ s.exclusion_detail[:60] }}</div>{% endif %}
{% endif %}
</td>
<td class="p-2 text-center text-xs">
{% if s.status == 'excluded' %}
<form method="POST" action="/campaigns/session/{{ s.id }}/restore" style="display:inline">
<button class="btn-sm bg-green-900/30 text-cyber-green">Restaurer</button>
</form>
{% elif s.status == 'pending' and c.status == 'draft' %}
<div class="flex gap-1 justify-center">
<form method="POST" action="/campaigns/session/{{ s.id }}/check-prereq" style="display:inline">
<button class="btn-sm bg-cyber-border text-cyber-accent" title="Re-verifier ce serveur">Check</button>
</form>
<button @click="prereqing = prereqing === {{ s.id }} ? null : {{ s.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button>
<button @click="excluding = excluding === {{ s.id }} ? null : {{ s.id }}" class="btn-sm bg-cyber-border text-gray-400">Exclure</button>
</div>
{% endif %}
</td>
</tr>
<!-- Formulaire prereq inline -->
{% if s.status == 'pending' and c.status == 'draft' %}
<tr x-show="prereqing === {{ s.id }}">
<td colspan="12" class="p-2 bg-cyber-bg">
<form method="POST" action="/campaigns/session/{{ s.id }}/prereq" class="flex gap-2 items-center flex-wrap">
<span class="text-xs text-gray-500">SSH:</span>
<select name="prereq_ssh" class="text-xs py-1 px-2">
<option value="ok" {% if s.prereq_ssh == 'ok' %}selected{% endif %}>OK</option>
<option value="ko" {% if s.prereq_ssh == 'ko' %}selected{% endif %}>KO</option>
<option value="pending" {% if s.prereq_ssh == 'pending' %}selected{% endif %}>Pending</option>
</select>
<span class="text-xs text-gray-500">Satellite:</span>
<select name="prereq_satellite" class="text-xs py-1 px-2">
<option value="ok" {% if s.prereq_satellite == 'ok' %}selected{% endif %}>OK</option>
<option value="ko" {% if s.prereq_satellite == 'ko' %}selected{% endif %}>KO</option>
<option value="na" {% if s.prereq_satellite == 'na' %}selected{% endif %}>N/A</option>
<option value="pending" {% if s.prereq_satellite == 'pending' %}selected{% endif %}>Pending</option>
</select>
<span class="text-xs text-gray-500">Rollback:</span>
<select name="rollback_method" class="text-xs py-1 px-2">
<option value="">-</option>
<option value="snapshot" {% if s.rollback_method == 'snapshot' %}selected{% endif %}>Snapshot vSphere</option>
<option value="commvault" {% if s.rollback_method == 'commvault' %}selected{% endif %}>Commvault</option>
<option value="commcell" {% if s.rollback_method == 'commcell' %}selected{% endif %}>CommCell</option>
<option value="force" {% if s.rollback_method == 'force' %}selected{% endif %}>Force (justif.)</option>
<option value="na" {% if s.rollback_method == 'na' %}selected{% endif %}>N/A (physique)</option>
</select>
<input type="text" name="rollback_justif" value="{{ s.rollback_justif or '' }}" placeholder="Justification si force" class="text-xs py-1 px-2 flex-1">
<button type="submit" class="btn-sm bg-cyber-accent text-black">Valider</button>
<button type="button" @click="prereqing = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</form>
</td>
</tr>
<!-- Formulaire exclusion inline -->
<tr x-show="excluding === {{ s.id }}">
<td colspan="12" class="p-2 bg-cyber-bg">
<form method="POST" action="/campaigns/session/{{ s.id }}/exclude" class="flex gap-2 items-center flex-wrap">
<span class="text-xs text-gray-500">Motif:</span>
<select name="reason" required class="text-xs py-1 px-2">
{% for code, label in exclusion_reasons %}
<option value="{{ code }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" name="detail" placeholder="Detail / justification" class="text-xs py-1 px-2 flex-1">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red">Confirmer</button>
<button type="button" @click="excluding = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</form>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View File

@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block title %}Campagnes{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Campagnes <span class="text-sm text-gray-500">{{ year }}</span></h2>
<div class="flex gap-2 items-center">
<a href="?year={{ year - 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year - 1 }}</a>
<a href="?year={{ year + 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year + 1 }}</a>
</div>
</div>
<!-- Filtres statut -->
<div class="flex gap-2 mb-4">
<a href="?year={{ year }}" class="btn-sm {% if not status_filter %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Toutes</a>
{% for st in ['draft','planned','in_progress','completed','cancelled'] %}
<a href="?year={{ year }}&status={{ st }}" class="btn-sm {% if status_filter == st %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">{{ st }}</a>
{% endfor %}
</div>
<!-- Liste campagnes -->
<div class="space-y-2">
{% for c in campaigns %}
<a href="/campaigns/{{ c.id }}" class="card p-4 flex items-center justify-between hover:border-cyber-accent/50 transition-colors block">
<div class="flex items-center gap-4">
<span class="font-bold text-cyber-accent">{{ c.week_code }}</span>
<span class="text-sm text-gray-400">{{ c.label or '' }}</span>
<span class="badge {% if c.status == 'draft' %}badge-gray{% elif c.status == 'planned' %}badge-blue{% elif c.status == 'in_progress' %}badge-yellow{% elif c.status == 'completed' %}badge-green{% else %}badge-red{% endif %}">{{ c.status }}</span>
{% if c.date_start %}
<span class="text-xs text-gray-500">{{ c.date_start.strftime('%d/%m') }}{% if c.date_end %} → {{ c.date_end.strftime('%d/%m') }}{% endif %}</span>
{% endif %}
</div>
<div class="flex items-center gap-3">
<div class="flex gap-1 text-xs">
<span class="px-2 py-0.5 rounded bg-gray-800 text-gray-400">{{ c.session_count }} srv</span>
{% if c.patched_count %}<span class="px-2 py-0.5 rounded bg-green-900/30 text-cyber-green">{{ c.patched_count }} ok</span>{% endif %}
{% if c.failed_count %}<span class="px-2 py-0.5 rounded bg-red-900/30 text-cyber-red">{{ c.failed_count }} ko</span>{% endif %}
{% if c.excluded_count %}<span class="px-2 py-0.5 rounded bg-yellow-900/30 text-gray-400">{{ c.excluded_count }} excl</span>{% endif %}
</div>
{% if c.session_count > 0 %}
<div class="w-20 h-2 bg-gray-800 rounded-full overflow-hidden">
<div class="h-full bg-cyber-green" style="width: {{ (c.patched_count / c.session_count * 100)|int }}%"></div>
</div>
{% endif %}
</div>
</a>
{% endfor %}
{% if not campaigns %}
<div class="card p-8 text-center text-gray-500">Aucune campagne pour {{ year }}</div>
{% endif %}
</div>
<!-- Nouvelle campagne depuis le planning -->
<div x-data="{ showCreate: false }" class="mt-6">
<button @click="showCreate = !showCreate" class="btn-primary px-4 py-2 text-sm">Nouvelle campagne</button>
<div x-show="showCreate" class="card p-5 mt-3 space-y-4">
<h3 class="text-lg font-bold text-cyber-accent">Creer depuis le planning</h3>
{% if planned_weeks %}
<div>
<label class="text-xs text-gray-500">Semaine planifiee</label>
<div id="campaign-scope">
<input type="hidden" name="year" value="{{ year }}">
<select name="week" class="w-full"
hx-get="/campaigns/preview" hx-target="#preview-zone" hx-swap="innerHTML"
hx-include="#campaign-scope">
<option value="">Choisir une semaine...</option>
{% for w in planned_weeks %}
<option value="{{ w.week_number }}">
{{ w.week_code }} ({{ w.week_start.strftime('%d/%m') }} → {{ w.week_end.strftime('%d/%m') }}) — {{ w.scope }}
</option>
{% endfor %}
</select>
</div>
</div>
<div id="preview-zone"></div>
{% else %}
<p class="text-gray-500 text-sm">Aucune semaine planifiee a venir pour {{ year }}. Verifiez le planning.</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,79 @@
{% extends 'base.html' %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-4">Dashboard</h2>
<!-- KPIs -->
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-accent">{{ stats.total_servers }}</div>
<div class="text-xs text-gray-500">Serveurs</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-green">{{ stats.patchable }}</div>
<div class="text-xs text-gray-500">Patchables SecOps</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-white">{{ stats.linux }} / {{ stats.windows }}</div>
<div class="text-xs text-gray-500">Linux / Windows</div>
</div>
<div class="card p-4 text-center">
<div class="text-3xl font-bold text-cyber-yellow">{{ stats.qualys_tags }}</div>
<div class="text-xs text-gray-500">Tags Qualys</div>
</div>
</div>
<!-- Par domaine -->
<div class="card p-4 mb-6">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Par domaine</h3>
<table class="w-full table-cyber">
<thead><tr>
<th class="text-left p-2">Domaine</th>
<th class="p-2">Total</th>
<th class="p-2">Actifs</th>
<th class="p-2">Linux</th>
<th class="p-2">Windows</th>
</tr></thead>
<tbody>
{% for d in domains %}
<tr>
<td class="p-2 font-medium">{{ d.name }}</td>
<td class="p-2 text-center">{{ d.total }}</td>
<td class="p-2 text-center text-cyber-green">{{ d.actifs }}</td>
<td class="p-2 text-center">{{ d.linux }}</td>
<td class="p-2 text-center">{{ d.windows }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Par tier -->
<div class="card p-4 mb-6">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Par tier</h3>
<div class="flex gap-4">
{% for t in tiers %}
<div class="flex-1 text-center p-3 rounded" style="background: {% if t[0] == 'tier0' %}#ff336622{% elif t[0] == 'tier1' %}#ff880022{% elif t[0] == 'tier2' %}#ffcc0022{% else %}#00ff8822{% endif %}">
<div class="text-lg font-bold">{{ t[1] }}</div>
<div class="text-xs text-gray-400">{{ t[0] }}</div>
</div>
{% endfor %}
</div>
</div>
<!-- Quick stats -->
<div class="grid grid-cols-3 gap-4">
<div class="card p-3">
<span class="text-xs text-gray-500">Decomissionnes</span>
<span class="float-right text-cyber-red font-bold">{{ stats.decom }}</span>
</div>
<div class="card p-3">
<span class="text-xs text-gray-500">EOL</span>
<span class="float-right text-cyber-red font-bold">{{ stats.eol }}</span>
</div>
<div class="card p-3">
<span class="text-xs text-gray-500">Assets Qualys</span>
<span class="float-right text-cyber-accent font-bold">{{ stats.qualys_assets }}</span>
</div>
</div>
{% endblock %}

27
app/templates/login.html Normal file
View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% block title %}Connexion{% endblock %}
{% block fullpage %}
<div class="min-h-screen flex items-center justify-center">
<div class="card p-8 w-96">
<div class="text-center mb-6">
<h1 class="text-2xl font-bold text-cyber-accent">PatchCenter</h1>
<p class="text-sm text-gray-500 mt-1">Authentification requise</p>
</div>
{% if error %}
<div class="bg-cyber-red/20 text-cyber-red text-sm p-3 rounded mb-4">{{ error }}</div>
{% endif %}
<form method="POST" action="/login" class="space-y-4">
<div>
<label class="text-xs text-gray-400 block mb-1">Utilisateur</label>
<input type="text" name="username" required autofocus class="w-full" placeholder="username">
</div>
<div>
<label class="text-xs text-gray-400 block mb-1">Mot de passe</label>
<input type="password" name="password" required class="w-full" placeholder="password">
</div>
<button type="submit" class="btn-primary w-full py-2 rounded-md">Connexion</button>
</form>
<p class="text-center text-xs text-gray-600 mt-4">v{{ version }}</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,49 @@
<div class="mt-4">
<div class="flex justify-between items-center mb-3">
<div>
<span class="text-sm font-bold text-cyber-accent">{{ count }} serveurs proposes</span>
<span class="text-xs text-gray-500 ml-2">Scope: {{ scope }}</span>
</div>
</div>
<form method="POST" action="/campaigns/create" class="space-y-3">
<input type="hidden" name="year" value="{{ year }}">
<input type="hidden" name="week_number" value="{{ week }}">
<div>
<label class="text-xs text-gray-500">Label campagne</label>
<input type="text" name="label" value="Patch S{{ '%02d' % week }} {{ year }} — {{ scope }}" class="w-full">
</div>
<div class="overflow-x-auto" style="max-height:400px; overflow-y:auto">
<table class="w-full table-cyber text-xs">
<thead><tr>
<th class="p-1 w-8"><input type="checkbox" checked onclick="this.closest('table').querySelectorAll('input[type=checkbox]').forEach(c => c.checked = this.checked)"></th>
<th class="text-left p-1">Hostname</th>
<th class="p-1">Domaine</th>
<th class="p-1">Env</th>
<th class="p-1">OS</th>
<th class="p-1">Tier</th>
<th class="p-1">Connexion</th>
<th class="p-1">Licence</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr class="{% if s.licence_support == 'eol' %}bg-red-900/10{% elif s.licence_support == 'els' %}bg-yellow-900/10{% endif %}">
<td class="p-1 text-center"><input type="checkbox" name="include_{{ s.id }}" checked></td>
<td class="p-1 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-1 text-center">{{ s.domaine or '-' }}</td>
<td class="p-1 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td>
<td class="p-1 text-center">{{ s.os_family or '-' }}</td>
<td class="p-1 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></td>
<td class="p-1 text-center">{{ s.ssh_method or '-' }}</td>
<td class="p-1 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="text-xs text-gray-600">Decochez les serveurs a exclure. Vous pourrez aussi exclure/reporter individuellement apres creation.</p>
<button type="submit" class="btn-primary px-6 py-2 text-sm">Creer la campagne ({{ count }} serveurs)</button>
</form>
</div>

View File

@ -0,0 +1,120 @@
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">{{ s.hostname }}</h3>
<button onclick="closePanel()" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div>
<!-- Identification -->
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Identification</h4>
<div class="space-y-1 text-sm">
<div class="flex justify-between"><span class="text-gray-500">FQDN</span><span>{{ s.fqdn or '-' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Domain.ltd</span><span>{{ s.domain_ltd or '-' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Machine</span><span>{{ s.machine_type }}</span></div>
</div>
</div>
<!-- Reseau / IPs -->
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Reseau</h4>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-gray-500">IP reelle</span>
<span class="font-mono">{{ ips.ip_reelle or '-' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">IP de connexion</span>
<span class="font-mono text-cyber-green">{{ ips.ip_connexion or '-' }}</span>
</div>
{% if ips.autres_ips %}
<div class="mt-1">
<span class="text-gray-500 text-xs">Autres IPs</span>
<div class="mt-1 space-y-0.5">
{% for a in ips.autres_ips %}
<div class="flex justify-between text-xs">
<span class="font-mono text-gray-300">{{ a.ip }}</span>
<span class="text-gray-500">{{ a.type }}{% if a.interface %} ({{ a.interface }}){% endif %}{% if a.description %} - {{ a.description }}{% endif %}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="flex justify-between"><span class="text-gray-500">Mode connexion</span><span class="badge {% if 'psmp' in s.ssh_method or 'psm' in s.ssh_method %}badge-yellow{% elif 'rdp' in s.ssh_method %}badge-blue{% else %}badge-green{% endif %}">{{ s.ssh_method }}</span></div>
</div>
</div>
<!-- Classification -->
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Classification</h4>
<div class="space-y-1 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Domaine</span><span>{{ s.domaine }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Environnement</span><span class="badge {% if s.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ s.environnement }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Zone</span><span class="badge {% if s.zone == 'DMZ' %}badge-red{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Tier</span><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.tier }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Etat</span><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.etat }}</span></div>
</div>
</div>
<!-- Technique -->
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Technique</h4>
<div class="space-y-1 text-sm">
<div class="flex justify-between"><span class="text-gray-500">OS</span><span>{{ s.os_family or '-' }}</span></div>
<div class="text-xs text-gray-400 mt-1">{{ s.os_version or '' }}</div>
<div class="flex justify-between"><span class="text-gray-500">Licence</span><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></div>
</div>
</div>
<!-- Patching -->
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Patching</h4>
<div class="space-y-1 text-sm">
<div class="flex justify-between"><span class="text-gray-500">Owner OS</span><span>{{ s.patch_os_owner }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Frequence</span><span>{{ s.patch_frequency }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Podman</span><span>{{ 'Oui' if s.is_podman else 'Non' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Prevenance</span><span>{{ 'Oui' if s.need_pct else 'Non' }}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Satellite</span><span>{% if s.satellite_host %}{% if 'sat1' in s.satellite_host %}SAT1 (DMZ){% elif 'sat2' in s.satellite_host %}SAT2 (LAN){% else %}{{ s.satellite_host }}{% endif %}{% else %}N/A{% endif %}</span></div>
</div>
</div>
<!-- Responsables -->
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Responsables</h4>
<div class="space-y-1 text-sm">
<div><span class="text-gray-500">Responsable:</span> <span>{{ s.responsable_nom or '-' }}</span></div>
<div><span class="text-gray-500">Referent:</span> <span>{{ s.referent_nom or '-' }}</span></div>
</div>
</div>
{% if s.mode_operatoire %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Mode operatoire</h4>
<p class="text-xs text-gray-400">{{ s.mode_operatoire }}</p>
</div>
{% endif %}
{% if tags %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Tags Qualys</h4>
<div class="flex flex-wrap gap-1">
{% for tag in tags %}
<span class="badge badge-blue">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if sync_msg is defined and sync_msg %}
<div class="mb-3 p-2 rounded text-xs {% if sync_ok %}bg-green-900/30 text-cyber-green{% else %}bg-red-900/30 text-cyber-red{% endif %}">
{{ sync_msg }}
</div>
{% endif %}
<!-- Actions -->
<div class="flex gap-2 mt-4">
<button class="btn-primary px-3 py-1 text-sm flex-1" hx-get="/servers/{{ s.id }}/edit" hx-target="#detail-panel" hx-swap="innerHTML">Editer</button>
<button class="btn-sm bg-cyber-border text-cyber-accent" hx-post="/servers/{{ s.id }}/sync-qualys" hx-target="#detail-panel" hx-swap="innerHTML" hx-indicator="#sync-spin">Sync Qualys</button>
<button class="btn-sm bg-cyber-border text-gray-300" onclick="closePanel()">Fermer</button>
</div>
<span id="sync-spin" class="htmx-indicator text-xs text-gray-500 mt-1">Synchro en cours...</span>
</div>

View File

@ -0,0 +1,85 @@
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">Editer {{ s.hostname }}</h3>
<button onclick="closePanel()" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div>
<form hx-put="/servers/{{ s.id }}" hx-target="#detail-panel" hx-swap="innerHTML" class="space-y-3">
<!-- IPs + mode connexion -->
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-gray-500">IP reelle</label>
<input type="text" name="ip_reelle" value="{{ ips.ip_reelle or '' }}" placeholder="ex: 10.1.2.30" class="w-full font-mono">
</div>
<div>
<label class="text-xs text-gray-500">IP de connexion</label>
<input type="text" name="ip_connexion" value="{{ ips.ip_connexion or '' }}" placeholder="ex: 10.1.2.30" class="w-full font-mono">
</div>
</div>
<div>
<label class="text-xs text-gray-500">Mode de connexion</label>
<select name="ssh_method" class="w-full">
{% for m in ['ssh_key','ssh_pwd','ssh_psmp','rdp_psm','rdp_pwd'] %}<option value="{{ m }}" {% if m == s.ssh_method %}selected{% endif %}>{{ m }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Domaine</label>
<select name="domain_code" class="w-full">
{% for d in domains %}<option value="{{ d.code }}" {% if d.name == s.domaine %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Environnement</label>
<select name="env_code" class="w-full">
{% for e in envs %}<option value="{{ e.code }}" {% if e.name == s.environnement %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Zone</label>
<select name="zone" class="w-full">
{% for z in ['LAN','DMZ','EMV'] %}<option value="{{ z }}" {% if z == s.zone %}selected{% endif %}>{{ z }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Tier</label>
<select name="tier" class="w-full">
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if t == s.tier %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Etat</label>
<select name="etat" class="w-full">
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Owner OS</label>
<select name="patch_os_owner" class="w-full">
{% for o in ['secops','ipop','editeur','tiers','na','a_definir'] %}<option value="{{ o }}" {% if o == s.patch_os_owner %}selected{% endif %}>{{ o }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Responsable</label>
<input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Referent technique</label>
<input type="text" name="referent_nom" value="{{ s.referent_nom or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Mode operatoire</label>
<textarea name="mode_operatoire" rows="3" class="w-full">{{ s.mode_operatoire or '' }}</textarea>
</div>
<div>
<label class="text-xs text-gray-500">Commentaire</label>
<textarea name="commentaire" rows="2" class="w-full">{{ s.commentaire or '' }}</textarea>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="btn-primary px-4 py-2 text-sm flex-1">Sauvegarder</button>
<button type="button" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML">Annuler</button>
</div>
</form>
</div>

View File

@ -0,0 +1,182 @@
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-cyber-accent">{{ sp.hostname }}</h3>
<button onclick="document.getElementById('edit-panel').style.display='none'" class="text-gray-500 hover:text-white text-xl">&times;</button>
</div>
<form method="POST" action="/specifics/{{ sp.id }}/save" class="space-y-4">
<!-- Type + Groupe -->
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Type application</label>
<select name="app_type" class="w-full">
<option value="">-</option>
{% for t in app_types %}<option value="{{ t }}" {% if t == sp.app_type %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Groupe d'ordre</label>
<input type="text" name="patch_order_group" value="{{ sp.patch_order_group or '' }}" placeholder="BOC_SAP" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Ordre reboot</label>
<input type="number" name="reboot_order" value="{{ sp.reboot_order or '' }}" class="w-full">
</div>
</div>
<div>
<label class="text-xs text-gray-500">Note ordre</label>
<input type="text" name="reboot_order_note" value="{{ sp.reboot_order_note or '' }}" class="w-full">
</div>
<!-- Commandes -->
<h4 class="text-xs text-cyber-accent font-bold uppercase pt-2">Commandes</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Stop command</label>
<textarea name="stop_command" rows="2" class="w-full font-mono text-xs">{{ sp.stop_command or '' }}</textarea>
</div>
<div>
<label class="text-xs text-gray-500">Start command</label>
<textarea name="start_command" rows="2" class="w-full font-mono text-xs">{{ sp.start_command or '' }}</textarea>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Stop user</label>
<input type="text" name="stop_user" value="{{ sp.stop_user or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Start user</label>
<input type="text" name="start_user" value="{{ sp.start_user or '' }}" class="w-full">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Cmd avant patch</label>
<textarea name="cmd_before_patch" rows="2" class="w-full font-mono text-xs">{{ sp.cmd_before_patch or '' }}</textarea>
</div>
<div>
<label class="text-xs text-gray-500">Cmd apres patch</label>
<textarea name="cmd_after_patch" rows="2" class="w-full font-mono text-xs">{{ sp.cmd_after_patch or '' }}</textarea>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Cmd avant reboot</label>
<textarea name="cmd_before_reboot" rows="2" class="w-full font-mono text-xs">{{ sp.cmd_before_reboot or '' }}</textarea>
</div>
<div>
<label class="text-xs text-gray-500">Cmd apres reboot</label>
<textarea name="cmd_after_reboot" rows="2" class="w-full font-mono text-xs">{{ sp.cmd_after_reboot or '' }}</textarea>
</div>
</div>
<!-- Flags -->
<h4 class="text-xs text-cyber-accent font-bold uppercase pt-2">Flags</h4>
<div class="grid grid-cols-4 gap-3">
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="auto_restart" {% if sp.auto_restart %}checked{% endif %}> Auto-restart</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_cluster" {% if sp.is_cluster %}checked{% endif %}> Cluster</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_db" {% if sp.is_db %}checked{% endif %}> BDD</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="is_middleware" {% if sp.is_middleware %}checked{% endif %}> Middleware</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="kernel_update_blocked" {% if sp.kernel_update_blocked %}checked{% endif %}> Kernel bloque</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="sentinel" {% if sp.sentinel_disable_required %}checked{% endif %}> Desactiver S1</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="ip_fwd" {% if sp.ip_forwarding_required %}checked{% endif %}> IP Forwarding</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="rolling" {% if sp.rolling_update %}checked{% endif %}> Rolling update</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="has_agent_special" {% if sp.has_agent_special %}checked{% endif %}> Agent special</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="has_service_critical" {% if sp.has_service_critical %}checked{% endif %}> Service critique</label>
<label class="flex items-center gap-2 text-xs"><input type="checkbox" name="needs_manual_step" {% if sp.needs_manual_step %}checked{% endif %}> Etape manuelle</label>
</div>
<!-- Details flags -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Cluster role</label>
<input type="text" name="cluster_role" value="{{ sp.cluster_role or '' }}" placeholder="master/slave/node" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Cluster note</label>
<input type="text" name="cluster_note" value="{{ sp.cluster_note or '' }}" class="w-full">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Type BDD</label>
<input type="text" name="db_type" value="{{ sp.db_type or '' }}" placeholder="PostgreSQL, Oracle..." class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Note BDD</label>
<input type="text" name="db_note" value="{{ sp.db_note or '' }}" class="w-full">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Type Middleware</label>
<input type="text" name="mw_type" value="{{ sp.mw_type or '' }}" placeholder="Tomcat, JBoss..." class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Note Middleware</label>
<input type="text" name="mw_note" value="{{ sp.mw_note or '' }}" class="w-full">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Kernel block reason</label>
<input type="text" name="kernel_block_reason" value="{{ sp.kernel_block_reason or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Rolling update note</label>
<input type="text" name="rolling_note" value="{{ sp.rolling_update_note or '' }}" class="w-full">
</div>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Reboot delay (min)</label>
<input type="number" name="reboot_delay" value="{{ sp.reboot_delay_minutes or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Reboot interval min (min)</label>
<input type="number" name="reboot_min_interval" value="{{ sp.reboot_min_interval_minutes or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">No reboot reason</label>
<input type="text" name="no_reboot" value="{{ sp.no_reboot_reason or '' }}" class="w-full">
</div>
</div>
<!-- Notes speciales -->
<div>
<label class="text-xs text-gray-500">Agent note</label>
<input type="text" name="agent_note" value="{{ sp.agent_note or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Service critique note</label>
<input type="text" name="service_note" value="{{ sp.service_note or '' }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Etape manuelle detail</label>
<textarea name="manual_step_detail" rows="2" class="w-full text-xs">{{ sp.manual_step_detail or '' }}</textarea>
</div>
<!-- Excludes -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Extra excludes (yum)</label>
<input type="text" name="extra_excludes" value="{{ sp.extra_excludes or '' }}" placeholder="sdcss-kmod*,node*" class="w-full font-mono text-xs">
</div>
<div>
<label class="text-xs text-gray-500">Patch excludes</label>
<input type="text" name="patch_excludes" value="{{ sp.patch_excludes or '' }}" class="w-full font-mono text-xs">
</div>
</div>
<!-- Note generale -->
<div>
<label class="text-xs text-gray-500">Note generale</label>
<textarea name="note" rows="3" class="w-full text-xs">{{ sp.note or '' }}</textarea>
</div>
<div class="flex gap-2 pt-2">
<button type="submit" class="btn-primary px-6 py-2 text-sm">Sauvegarder</button>
<button type="button" onclick="document.getElementById('edit-panel').style.display='none'" class="btn-sm bg-cyber-border text-gray-300 px-4 py-2">Annuler</button>
</div>
</form>

243
app/templates/planning.html Normal file
View File

@ -0,0 +1,243 @@
{% extends 'base.html' %}
{% block title %}Planning Patching {{ year }}{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Planning Patching {{ year }}</h2>
<div class="flex gap-2 items-center">
<a href="?year={{ year - 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year - 1 }}</a>
<a href="?year={{ year + 1 }}" class="btn-sm bg-cyber-border text-gray-300">{{ year + 1 }}</a>
<!-- Dupliquer -->
{% if entries %}
<form method="POST" action="/planning/duplicate" class="flex gap-1 items-center ml-4">
<input type="hidden" name="source_year" value="{{ year }}">
<input type="number" name="target_year" value="{{ year + 1 }}" class="text-xs py-1 px-2 w-20">
<button type="submit" class="btn-sm bg-cyber-accent text-black" onclick="return confirm('Dupliquer {{ year }} vers cette annee ?')">Dupliquer vers</button>
</form>
{% endif %}
</div>
</div>
{% if msg %}
<div class="mb-4 p-2 rounded text-sm {% if msg in ('exists', 'err_week', 'err_domain', 'err_past', 'err_past_wed') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'add' %}Entree ajoutee.{% elif msg == 'edit' %}Entree modifiee.{% elif msg == 'delete' %}Entree supprimee.{% elif msg == 'duplicate' %}Planning duplique avec succes.{% elif msg == 'exists' %}L'annee cible contient deja des entrees. Supprimez-les d'abord.{% elif msg == 'err_week' %}Numero de semaine invalide (1-53).{% elif msg == 'err_domain' %}Domaine requis pour une entree ouverte.{% elif msg == 'err_past' %}Impossible d'ajouter dans le passe (semaine deja ecoulee).{% elif msg == 'err_past_wed' %}Semaine en cours : ajout possible uniquement lundi et mardi (MEP urgente).{% endif %}
</div>
{% endif %}
<!-- Legende -->
<div class="flex gap-4 mb-4 text-xs items-center flex-wrap">
<div class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded" style="background:#1e3a8a"></span> Cycle 1</div>
<div class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded" style="background:#7c3aed"></span> Cycle 2</div>
<div class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded" style="background:#166534"></span> Cycle 3</div>
<div class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded" style="background:#f59e0b33"></span> Gel</div>
<div class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded" style="background:#D4A0A0"></span> DMZ (continu)</div>
<span class="text-gray-500 ml-2">HPROD = hors-prod | PROD = production | pilot = prod pilote</span>
</div>
<!-- Gantt -->
<div class="card overflow-x-auto">
<table class="table-cyber" style="min-width:1600px">
<!-- Mois header -->
<thead>
<tr>
<th class="p-2 text-left sticky left-0 bg-cyber-card z-10" style="min-width:140px">Domaine</th>
{% for m in months %}
<th colspan="{% if loop.index in [1,3,5,7,8,10,12] %}5{% elif loop.index == 2 %}4{% else %}4{% endif %}" class="p-1 text-center text-xs">{{ m }}</th>
{% endfor %}
</tr>
<!-- Semaines header -->
<tr>
<th class="p-1 sticky left-0 bg-cyber-card z-10"></th>
{% for w in weeks %}
<th class="p-0 text-center" style="width:22px;min-width:22px">
<span class="text-[9px] {% if w in freeze_weeks %}text-cyber-yellow{% else %}text-gray-600{% endif %}">{{ w }}</span>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for dom in domains %}
<tr>
<td class="p-2 sticky left-0 bg-cyber-card z-10 border-r border-cyber-border">
<div class="flex items-center gap-2">
<span class="inline-block w-2 h-6 rounded-sm" style="background:{{ domain_colors.get(dom.code, '#666') }}"></span>
<div>
<span class="font-bold text-sm" style="color:{{ domain_colors.get(dom.code, '#999') }}">{{ dom.name }}</span>
<span class="text-[10px] text-gray-500 ml-1">({{ dom.srv_count }})</span>
</div>
</div>
</td>
{% for w in weeks %}
{% set entry = grid.get(dom.code, {}).get(w) %}
<td class="p-0 text-center" style="width:22px;min-width:22px">
{% if w in freeze_weeks %}
<div class="h-6" style="background:#f59e0b15" title="Gel S{{ w }}"></div>
{% elif entry %}
{% set bg = '#1e3a8a' %}
{% if entry.cycle == 2 %}{% set bg = '#7c3aed' %}{% endif %}
{% if entry.cycle == 3 %}{% set bg = '#166534' %}{% endif %}
{% if dom.code == 'DMZ' %}{% set bg = '#5f3737' %}{% endif %}
<div class="h-6 rounded-sm flex items-center justify-center cursor-pointer hover:opacity-80"
style="background:{{ bg }}"
title="S{{ w }} — {{ dom.name }} {{ entry.env_scope }}{% if entry.note %} — {{ entry.note }}{% endif %}">
<span class="text-[8px] text-white/80">
{% if entry.env_scope == 'prod' %}P{% elif entry.env_scope == 'hprod' %}H{% elif entry.env_scope == 'prod_pilot' %}PP{% elif entry.env_scope == 'all' %}A{% endif %}
</span>
</div>
{% else %}
<div class="h-6"></div>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Detail par cycle -->
<div class="grid grid-cols-3 gap-4 mt-6">
{% for cycle_num in [1, 2, 3] %}
<div class="card p-4">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Cycle {{ cycle_num }}</h3>
<div class="space-y-1 text-xs">
{% for w in weeks %}
{% for dom in domains %}
{% set entry = grid.get(dom.code, {}).get(w) %}
{% if entry and entry.cycle == cycle_num and dom.code != 'DMZ' %}
<div class="flex justify-between items-center py-0.5">
<div class="flex items-center gap-2">
<span class="text-gray-500 w-7">S{{ '%02d' % w }}</span>
<span class="inline-block w-2 h-2 rounded-full" style="background:{{ domain_colors.get(dom.code, '#666') }}"></span>
<span>{{ dom.name }}</span>
<span class="badge {% if entry.env_scope == 'prod' %}badge-green{% elif entry.env_scope == 'hprod' %}badge-yellow{% else %}badge-blue{% endif %}">{{ entry.env_scope }}</span>
</div>
{% if entry.note %}<span class="text-gray-600 truncate ml-1" style="max-width:100px" title="{{ entry.note }}">{{ entry.note[:20] }}</span>{% endif %}
</div>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Tableau editable -->
<div x-data="{ editing: null }" class="card mt-6 overflow-x-auto">
<div class="flex justify-between items-center p-4 border-b border-cyber-border">
<h3 class="text-sm font-bold text-cyber-accent">Donnees planning {{ year }} ({{ entries|length }} entrees)</h3>
</div>
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="p-2">Sem.</th>
<th class="p-2">Dates</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">Cycle</th>
<th class="p-2">Statut</th>
<th class="text-left p-2">Note</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for e in entries %}
<tr class="{% if e.status == 'freeze' %}bg-yellow-900/10{% endif %}">
<!-- Mode lecture -->
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center font-mono text-xs">{{ e.week_code }}</td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center text-xs text-gray-500">{{ e.week_start.strftime('%d/%m') }} - {{ e.week_end.strftime('%d/%m') }}</td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center"><span style="color:{{ domain_colors.get(e.domain_code or '', '#666') }}">{{ e.domain_name or '-' }}</span></td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center"><span class="badge {% if e.env_scope == 'prod' %}badge-green{% elif e.env_scope == 'hprod' %}badge-yellow{% else %}badge-blue{% endif %}">{{ e.env_scope }}</span></td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center">{{ e.cycle or '-' }}</td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center"><span class="badge {% if e.status == 'freeze' %}badge-yellow{% elif e.status == 'open' %}badge-green{% else %}badge-gray{% endif %}">{{ e.status }}</span></td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-xs text-gray-400">{{ e.note or '' }}</td>
</template>
<template x-if="editing !== {{ e.id }}">
<td class="p-2 text-center">
<button @click="editing = {{ e.id }}" class="btn-sm bg-cyber-border text-cyber-accent">Edit</button>
</td>
</template>
<!-- Mode edition -->
<template x-if="editing === {{ e.id }}">
<td colspan="8" class="p-2">
<form method="POST" action="/planning/{{ e.id }}/edit" class="flex gap-2 items-center flex-wrap">
<span class="font-mono text-xs text-gray-500">{{ e.week_code }}</span>
<select name="domain_code" class="text-xs py-1 px-2">
<option value="">-</option>
{% for d in all_domains %}<option value="{{ d.code }}" {% if d.code == e.domain_code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</select>
<select name="env_scope" class="text-xs py-1 px-2">
{% for es in env_scopes %}<option value="{{ es }}" {% if es == e.env_scope %}selected{% endif %}>{{ es }}</option>{% endfor %}
</select>
<input type="number" name="cycle" value="{{ e.cycle or '' }}" placeholder="Cycle" class="text-xs py-1 px-2 w-16" min="1" max="4">
<select name="status" class="text-xs py-1 px-2">
{% for st in statuses %}<option value="{{ st }}" {% if st == e.status %}selected{% endif %}>{{ st }}</option>{% endfor %}
</select>
<input type="text" name="note" value="{{ e.note or '' }}" placeholder="Note" class="text-xs py-1 px-2 flex-1">
<button type="submit" class="btn-sm bg-cyber-accent text-black">OK</button>
<button type="button" @click="editing = null" class="btn-sm bg-cyber-border text-gray-400">X</button>
</form>
<form method="POST" action="/planning/{{ e.id }}/delete" class="inline ml-2">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Supprimer ?')">Suppr</button>
</form>
</td>
</template>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Ajouter une entree -->
<div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter une entree</h4>
<form method="POST" action="/planning/add" class="flex gap-2 items-end flex-wrap">
<input type="hidden" name="year" value="{{ year }}">
<div>
<label class="text-xs text-gray-500">Semaine</label>
<input type="number" name="week_number" min="1" max="53" value="{{ default_week }}" class="text-xs py-1 px-2 w-16" required>
</div>
<div>
<label class="text-xs text-gray-500">Domaine</label>
<select name="domain_code" class="text-xs py-1 px-2">
<option value="">- (gel)</option>
{% for d in all_domains %}<option value="{{ d.code }}">{{ d.name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Env</label>
<select name="env_scope" class="text-xs py-1 px-2">
{% for es in env_scopes %}<option value="{{ es }}">{{ es }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs text-gray-500">Cycle</label>
<input type="number" name="cycle" min="1" max="4" class="text-xs py-1 px-2 w-16">
</div>
<div>
<label class="text-xs text-gray-500">Statut</label>
<select name="status" class="text-xs py-1 px-2">
{% for st in statuses %}<option value="{{ st }}">{{ st }}</option>{% endfor %}
</select>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500">Note</label>
<input type="text" name="note" class="text-xs py-1 px-2 w-full">
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form>
</div>
{% endblock %}

109
app/templates/servers.html Normal file
View File

@ -0,0 +1,109 @@
{% extends 'base.html' %}
{% 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
{%- endmacro %}
{% macro sort_icon(col) -%}
{% if sort == col %}{% if sort_dir == 'asc' %}&#9650;{% else %}&#9660;{% endif %}{% endif %}
{%- 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 }}
{%- 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">
<button class="btn-sm bg-cyber-green text-black" onclick="alert('Export bientot')">Export CSV</button>
</div>
</div>
<!-- Filtres -->
<form method="GET" class="card p-3 mb-4 flex gap-3 items-center flex-wrap">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="sort_dir" value="{{ sort_dir }}">
<input type="text" name="search" value="{{ filters.search or '' }}" placeholder="Rechercher..." class="w-44" hx-get="/servers" hx-trigger="keyup changed delay:300ms" hx-target="#server-table" hx-select="#server-table" hx-push-url="true">
<select name="domain" onchange="this.form.submit()"><option value="">Domaine</option>
{% for d in domains_list %}<option value="{{ d.code }}" {% if filters.domain == d.code %}selected{% endif %}>{{ d.name }}</option>{% endfor %}
</select>
<select name="env" onchange="this.form.submit()"><option value="">Env</option>
{% for e in envs_list %}<option value="{{ e.code }}" {% if filters.env == e.code %}selected{% endif %}>{{ e.name }}</option>{% endfor %}
</select>
<select name="tier" onchange="this.form.submit()"><option value="">Tier</option>
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select>
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
{% for e in ['en_production','en_implementation','decommissionne'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e }}</option>{% endfor %}
</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>
<!-- Table -->
<div id="server-table" class="card overflow-x-auto">
<table class="w-full table-cyber">
<thead><tr>
<th class="p-2 w-8"><input type="checkbox" id="check-all"></th>
<th class="text-left p-2"><a href="{{ sort_url('hostname') }}" class="hover:text-cyber-accent">Hostname {{ sort_icon('hostname') }}</a></th>
<th class="p-2"><a href="{{ sort_url('domaine') }}" class="hover:text-cyber-accent">Domaine {{ sort_icon('domaine') }}</a></th>
<th class="p-2"><a href="{{ sort_url('env') }}" class="hover:text-cyber-accent">Env {{ sort_icon('env') }}</a></th>
<th class="p-2"><a href="{{ sort_url('zone') }}" class="hover:text-cyber-accent">Zone {{ sort_icon('zone') }}</a></th>
<th class="p-2"><a href="{{ sort_url('os') }}" class="hover:text-cyber-accent">OS {{ sort_icon('os') }}</a></th>
<th class="p-2">Version</th>
<th class="p-2">Licence</th>
<th class="p-2"><a href="{{ sort_url('tier') }}" class="hover:text-cyber-accent">Tier {{ sort_icon('tier') }}</a></th>
<th class="p-2"><a href="{{ sort_url('etat') }}" class="hover:text-cyber-accent">Etat {{ sort_icon('etat') }}</a></th>
<th class="p-2"><a href="{{ sort_url('owner') }}" class="hover:text-cyber-accent">Owner {{ sort_icon('owner') }}</a></th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for s in servers %}
<tr id="row-{{ s.id }}" class="group" hx-get="/servers/{{ s.id }}/detail" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">
<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}"></td>
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</td>
<td class="p-2 text-center text-xs">{{ s.domaine or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.environnement == 'Production' %}badge-green{% elif s.environnement == 'Recette' %}badge-yellow{% else %}badge-gray{% endif %}">{{ (s.environnement or '-')[:6] }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.zone == 'DMZ' %}badge-red{% elif s.zone == 'EMV' %}badge-yellow{% else %}badge-blue{% endif %}">{{ s.zone or 'LAN' }}</span></td>
<td class="p-2 text-center text-xs">{{ s.os_family or '-' }}</td>
<td class="p-2 text-center text-xs text-gray-400" title="{{ s.os_version or '' }}">{{ s.os_short or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'eol' %}badge-red{% elif s.licence_support == 'els' %}badge-yellow{% else %}badge-gray{% endif %}">{{ s.licence_support }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.tier == 'tier0' %}badge-red{% elif s.tier == 'tier1' %}badge-yellow{% elif s.tier == 'tier2' %}badge-blue{% else %}badge-green{% endif %}">{{ s.tier }}</span></td>
<td class="p-2 text-center"><span class="badge {% if s.etat == 'en_production' %}badge-green{% elif s.etat == 'decommissionne' %}badge-red{% else %}badge-yellow{% endif %}">{{ (s.etat or '')[:8] }}</span></td>
<td class="p-2 text-center text-xs">{{ s.patch_os_owner or '-' }}</td>
<td class="p-2 text-center" onclick="event.stopPropagation()">
<button class="btn-sm bg-cyber-border text-cyber-accent" hx-get="/servers/{{ s.id }}/edit" hx-target="#detail-panel" hx-swap="innerHTML" onclick="openPanel()">Edit</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex justify-between items-center mt-4 text-sm text-gray-500">
<span>Page {{ page }} / {{ ((total - 1) // per_page) + 1 }} — {{ total }} serveurs</span>
<div class="flex gap-2">
{% if page > 1 %}<a href="{{ qs(page - 1) }}" class="btn-sm bg-cyber-border text-gray-300">Precedent</a>{% endif %}
{% if page * per_page < total %}<a href="{{ qs(page + 1) }}" class="btn-sm bg-cyber-border text-gray-300">Suivant</a>{% endif %}
</div>
</div>
<script>
function openPanel() {
document.getElementById("detail-panel").scrollTop = 0;
document.getElementById('detail-panel').style.width = '400px';
document.getElementById('detail-panel').style.overflow = 'auto';
window.scrollTo({top: 0, behavior: 'smooth'});
}
function closePanel() {
document.getElementById('detail-panel').style.width = '0';
document.getElementById('detail-panel').style.overflow = 'hidden';
}
document.getElementById('check-all').addEventListener('change', function(e) {
document.querySelectorAll('input[name=srv]').forEach(cb => cb.checked = e.target.checked);
});
</script>
{% endblock %}

389
app/templates/settings.html Normal file
View File

@ -0,0 +1,389 @@
{% extends 'base.html' %}
{% block title %}Settings{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-6">Settings</h2>
{% if saved %}
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
Section "{{ saved }}" sauvegardee.
</div>
{% endif %}
{% macro section_header(key, title, badge_text, badge_class, extra="") %}
<button @click="open = open === '{{ key }}' ? '' : '{{ key }}'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<div class="flex items-center gap-3">
<span class="text-cyber-accent font-bold">{{ title }}</span>
<span class="badge {{ badge_class }}">{{ badge_text }}</span>
{% if extra %}<span class="text-xs text-gray-500">{{ extra }}</span>{% endif %}
</div>
<span class="text-gray-500 text-lg" x-text="open === '{{ key }}' ? '&#9660;' : '&#9654;'"></span>
</button>
{% endmacro %}
<div x-data="{ open: '{{ saved or '' }}' }" class="space-y-2">
<!-- Qualys API -->
{% if visible.qualys %}
<div class="card overflow-hidden">
{{ section_header("qualys", "Qualys API", "Connecte", "badge-green", q_tags|string + " tags / " + q_assets|string + " assets / " + q_linked|string + " lies") }}
<div x-show="open === 'qualys'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/qualys" class="space-y-3">
<div>
<label class="text-xs text-gray-500">URL API</label>
<input type="text" name="qualys_url" value="{{ vals.qualys_url }}" placeholder="https://qualysapi.qualys.eu" class="w-full" {% if not editable.qualys %}disabled{% endif %}>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Utilisateur</label>
<input type="text" name="qualys_user" value="{{ vals.qualys_user }}" class="w-full" {% if not editable.qualys %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Mot de passe</label>
<input type="password" name="qualys_pass" value="{{ vals.qualys_pass }}" class="w-full" {% if not editable.qualys %}disabled{% endif %}>
</div>
</div>
<div>
<label class="text-xs text-gray-500">Proxy</label>
<input type="text" name="qualys_proxy" value="{{ vals.qualys_proxy }}" placeholder="http://proxy.sanef.fr:8080" class="w-full font-mono text-xs" {% if not editable.qualys %}disabled{% endif %}>
</div>
{% if editable.qualys %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- SSH Cle privee -->
{% if visible.ssh_key %}
<div class="card overflow-hidden">
{{ section_header("ssh_key", "SSH Cle privee", "ssh_key", "badge-green") }}
<div x-show="open === 'ssh_key'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/ssh_key" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">User SSH par defaut</label>
<input type="text" name="ssh_key_default_user" value="{{ vals.ssh_key_default_user }}" placeholder="root" class="w-full" {% if not editable.ssh_key %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Port par defaut</label>
<input type="text" name="ssh_key_default_port" value="{{ vals.ssh_key_default_port }}" placeholder="22" class="w-full" {% if not editable.ssh_key %}disabled{% endif %}>
</div>
</div>
<div>
<label class="text-xs text-gray-500">Cle privee (PEM)</label>
<textarea name="ssh_key_private_key" rows="4" class="w-full font-mono text-xs" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" {% if not editable.ssh_key %}disabled{% endif %}>{{ vals.ssh_key_private_key }}</textarea>
</div>
<p class="text-xs text-gray-600">Surchargeable par serveur (ssh_user, ssh_port dans la fiche serveur).</p>
{% if editable.ssh_key %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- SSH Password -->
{% if visible.ssh_pwd %}
<div class="card overflow-hidden">
{{ section_header("ssh_pwd", "SSH Password", "ssh_pwd", "badge-yellow") }}
<div x-show="open === 'ssh_pwd'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/ssh_pwd" class="space-y-3">
<div>
<label class="text-xs text-gray-500">User par defaut</label>
<input type="text" name="ssh_pwd_default_user" value="{{ vals.ssh_pwd_default_user }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Password par defaut</label>
<input type="password" name="ssh_pwd_default_pass" value="{{ vals.ssh_pwd_default_pass }}" class="w-full" {% if not editable.ssh_pwd %}disabled{% endif %}>
</div>
<p class="text-xs text-gray-600">Pour les environnements recette sans cle SSH. Chaque operateur peut configurer son propre compte.</p>
{% if editable.ssh_pwd %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- SSH PSMP (CyberArk) -->
{% if visible.ssh_psmp %}
<div class="card overflow-hidden">
{{ section_header("ssh_psmp", "SSH PSMP — CyberArk", "ssh_psmp", "badge-yellow") }}
<div x-show="open === 'ssh_psmp'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/ssh_psmp" class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Adresse PSMP</label>
<input type="text" name="psmp_host" value="{{ vals.psmp_host }}" placeholder="psmp.sanef.fr" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Port PSMP</label>
<input type="text" name="psmp_port" value="{{ vals.psmp_port }}" placeholder="22" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div>
</div>
<div>
<label class="text-xs text-gray-500">Format user</label>
<input type="text" name="psmp_user_format" value="{{ vals.psmp_user_format }}" placeholder="{cybr_user}@{target_user}@{hostname}" class="w-full font-mono text-xs" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Compte CyberArk</label>
<input type="text" name="psmp_cyberark_user" value="{{ vals.psmp_cyberark_user }}" placeholder="CYBP01336" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Utilisateur cible</label>
<input type="text" name="psmp_target_user" value="{{ vals.psmp_target_user }}" placeholder="cybsecope" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Safe par defaut</label>
<input type="text" name="psmp_default_safe" value="{{ vals.psmp_default_safe }}" class="w-full" {% if not editable.ssh_psmp %}disabled{% endif %}>
</div>
</div>
<p class="text-xs text-gray-600">Auth keyboard-interactive. Chaque operateur configure son propre compte CyberArk. MDP saisi en session.</p>
{% if editable.ssh_psmp %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- RDP PSM (CyberArk) -->
{% if visible.rdp_psm %}
<div class="card overflow-hidden">
{{ section_header("rdp_psm", "RDP PSM — CyberArk", "rdp_psm", "badge-blue") }}
<div x-show="open === 'rdp_psm'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/rdp_psm" class="space-y-3">
<div>
<label class="text-xs text-gray-500">URL PVWA</label>
<input type="text" name="rdp_psm_pvwa_url" value="{{ vals.rdp_psm_pvwa_url }}" placeholder="https://pvwa.sanef.fr" class="w-full" {% if not editable.rdp_psm %}disabled{% endif %}>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">User PVWA</label>
<input type="text" name="rdp_psm_pvwa_user" value="{{ vals.rdp_psm_pvwa_user }}" class="w-full" {% if not editable.rdp_psm %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Password PVWA</label>
<input type="password" name="rdp_psm_pvwa_pass" value="{{ vals.rdp_psm_pvwa_pass }}" class="w-full" {% if not editable.rdp_psm %}disabled{% endif %}>
</div>
</div>
<div>
<label class="text-xs text-gray-500">Connection Component</label>
<input type="text" name="rdp_psm_component" value="{{ vals.rdp_psm_component }}" placeholder="PSM-RDP" class="w-full" {% if not editable.rdp_psm %}disabled{% endif %}>
</div>
<p class="text-xs text-gray-600">Connexion RDP via token PVWA API. Production Windows uniquement.</p>
{% if editable.rdp_psm %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- RDP Password — disabled -->
<!-- vSphere / vCenters -->
{% if visible.vsphere %}
<div class="card overflow-hidden">
<button @click="open = open === 'vsphere' ? '' : 'vsphere'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<div class="flex items-center gap-3">
<span class="text-cyber-accent font-bold">vSphere / vCenters</span>
<span class="badge badge-gray">Snapshots</span>
<span class="text-xs text-gray-500">{{ vcenters|selectattr('is_active')|list|length }} actif(s)</span>
</div>
<span class="text-gray-500 text-lg" x-text="open === 'vsphere' ? '&#9660;' : '&#9654;'"></span>
</button>
<div x-show="open === 'vsphere'" class="border-t border-cyber-border p-4 space-y-4">
{% if editable.vsphere %}
<form method="POST" action="/settings/vsphere" class="space-y-3">
<h4 class="text-xs text-cyber-accent font-bold uppercase">Credentials vSphere (communs)</h4>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Utilisateur</label>
<input type="text" name="vsphere_user" value="{{ vals.vsphere_user }}" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Mot de passe</label>
<input type="password" name="vsphere_pass" value="{{ vals.vsphere_pass }}" class="w-full">
</div>
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder credentials</button>
</form>
{% endif %}
<div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">vCenters enregistres</h4>
<table class="w-full table-cyber text-sm">
<thead><tr>
<th class="text-left p-2">Nom</th>
<th class="text-left p-2">Endpoint</th>
<th class="p-2">Datacenter</th>
<th class="text-left p-2">Description</th>
<th class="p-2">Responsable</th>
<th class="p-2">Actif</th>
{% if editable.vsphere %}<th class="p-2">Action</th>{% endif %}
</tr></thead>
<tbody>
{% for vc in vcenters %}
<tr>
<td class="p-2">{{ vc.name }}</td>
<td class="p-2 font-mono text-xs text-cyber-accent">{{ vc.endpoint }}</td>
<td class="p-2 text-center text-xs">{{ vc.datacenter or '-' }}</td>
<td class="p-2 text-xs text-gray-400">{{ vc.description or '-' }}</td>
<td class="p-2 text-center text-xs">{{ vc.responsable or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if vc.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if vc.is_active else 'Non' }}</span></td>
{% if editable.vsphere %}
<td class="p-2 text-center">
{% if vc.is_active %}
<form method="POST" action="/settings/vcenter/{{ vc.id }}/delete" style="display:inline">
<button type="submit" class="btn-sm bg-red-900/30 text-cyber-red" onclick="return confirm('Desactiver ce vCenter ?')">Desactiver</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if editable.vsphere %}
<form method="POST" action="/settings/vcenter/add" class="space-y-3 pt-2 border-t border-cyber-border">
<h4 class="text-xs text-cyber-accent font-bold uppercase">Ajouter un vCenter</h4>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="text-xs text-gray-500">Nom</label>
<input type="text" name="vc_name" placeholder="vCenter Senlis" class="w-full" required>
</div>
<div>
<label class="text-xs text-gray-500">Endpoint (FQDN)</label>
<input type="text" name="vc_endpoint" placeholder="vcenter01.sanef.groupe" class="w-full font-mono text-xs" required>
</div>
<div>
<label class="text-xs text-gray-500">Datacenter</label>
<input type="text" name="vc_datacenter" placeholder="DC-Senlis" class="w-full">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Description</label>
<input type="text" name="vc_description" placeholder="Gestion + hors-prod" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Responsable</label>
<input type="text" name="vc_responsable" class="w-full">
</div>
</div>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Ajouter</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
<!-- Splunk Remote Log -->
{% if visible.splunk %}
<div class="card overflow-hidden">
{{ section_header("splunk", "Splunk — Remote Log", "HEC", "badge-yellow") }}
<div x-show="open === 'splunk'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/splunk" class="space-y-3">
<div>
<label class="text-xs text-gray-500">URL HEC (HTTP Event Collector)</label>
<input type="text" name="splunk_hec_url" value="{{ vals.splunk_hec_url }}" placeholder="https://splunk.sanef.fr:8088/services/collector" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Token HEC</label>
<input type="password" name="splunk_hec_token" value="{{ vals.splunk_hec_token }}" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Index</label>
<input type="text" name="splunk_index" value="{{ vals.splunk_index }}" placeholder="patchcenter" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Sourcetype</label>
<input type="text" name="splunk_sourcetype" value="{{ vals.splunk_sourcetype }}" placeholder="patchcenter:audit" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div>
</div>
<div>
<label class="text-xs text-gray-500">Verifier SSL (true/false)</label>
<input type="text" name="splunk_verify_ssl" value="{{ vals.splunk_verify_ssl }}" placeholder="true" class="w-full" {% if not editable.splunk %}disabled{% endif %}>
</div>
<p class="text-xs text-gray-600">Envoie les evenements de patching vers Splunk via HEC.</p>
{% if editable.splunk %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- Teams Notifications -->
{% if visible.teams %}
<div class="card overflow-hidden">
{{ section_header("teams", "Teams — Notifications", "Webhook + SharePoint", "badge-blue") }}
<div x-show="open === 'teams'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/teams" class="space-y-3">
<h4 class="text-xs text-cyber-accent font-bold uppercase">Canal Teams (Webhook direct)</h4>
<div>
<label class="text-xs text-gray-500">Webhook URL</label>
<input type="text" name="teams_webhook_url" value="{{ vals.teams_webhook_url }}" placeholder="https://outlook.office.com/webhook/..." class="w-full font-mono text-xs" {% if not editable.teams %}disabled{% endif %}>
</div>
<h4 class="text-xs text-cyber-accent font-bold uppercase mt-4">Conversation groupe (SharePoint + Power Automate)</h4>
<div>
<label class="text-xs text-gray-500">SharePoint Site URL</label>
<input type="text" name="teams_sp_site_url" value="{{ vals.teams_sp_site_url }}" placeholder="https://sanef.sharepoint.com/sites/SecOps" class="w-full font-mono text-xs" {% if not editable.teams %}disabled{% endif %}>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Library</label>
<input type="text" name="teams_sp_library" value="{{ vals.teams_sp_library }}" placeholder="Documents partages" class="w-full" {% if not editable.teams %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">Folder</label>
<input type="text" name="teams_sp_folder" value="{{ vals.teams_sp_folder }}" placeholder="PatchCenter" class="w-full" {% if not editable.teams %}disabled{% endif %}>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-xs text-gray-500">Tenant ID (Azure AD)</label>
<input type="text" name="teams_sp_tenant_id" value="{{ vals.teams_sp_tenant_id }}" class="w-full font-mono text-xs" {% if not editable.teams %}disabled{% endif %}>
</div>
<div>
<label class="text-xs text-gray-500">App Client ID</label>
<input type="text" name="teams_sp_client_id" value="{{ vals.teams_sp_client_id }}" class="w-full font-mono text-xs" {% if not editable.teams %}disabled{% endif %}>
</div>
</div>
<div>
<label class="text-xs text-gray-500">App Client Secret</label>
<input type="password" name="teams_sp_client_secret" value="{{ vals.teams_sp_client_secret }}" class="w-full" {% if not editable.teams %}disabled{% endif %}>
</div>
<p class="text-xs text-gray-600">Power Automate : depose JSON sur SharePoint → lit + poste dans la conversation → supprime.</p>
{% if editable.teams %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
<!-- iTop CMDB -->
{% if visible.itop %}
<div class="card overflow-hidden">
<button @click="open = open === 'itop' ? '' : 'itop'" class="w-full flex items-center justify-between p-4 hover:bg-cyber-border/20 transition-colors">
<div class="flex items-center gap-3">
<span class="text-gray-400 font-bold">iTop CMDB</span>
<span class="badge badge-gray">En attente</span>
</div>
<span class="text-gray-500 text-lg" x-text="open === 'itop' ? '&#9660;' : '&#9654;'"></span>
</button>
<div x-show="open === 'itop'" class="border-t border-cyber-border p-4">
<form method="POST" action="/settings/itop" class="space-y-3">
{% for key, label, is_secret in sections.itop %}
<div>
<label class="text-xs text-gray-500">{{ label }}</label>
<input type="{{ 'password' if is_secret else 'text' }}" name="{{ key }}" value="{{ vals[key] }}" class="w-full" {% if not editable.itop %}disabled{% endif %}>
</div>
{% endfor %}
<div class="text-xs text-gray-600 space-y-1 mt-2">
<div>- Import serveurs + metadata</div>
<div>- Sync responsables / referents</div>
<div>- Lien applications / clusters</div>
<div>- Enrichissement domaine / environnement</div>
</div>
{% if editable.itop %}<button type="submit" class="btn-primary px-4 py-2 text-sm">Sauvegarder</button>{% endif %}
</form>
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -0,0 +1,96 @@
{% extends 'base.html' %}
{% block title %}Serveurs specifiques{% endblock %}
{% block content %}
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-cyber-accent">Serveurs specifiques <span class="text-sm text-gray-500">({{ entries|length }})</span></h2>
</div>
{% set msg = request.query_params.get('msg') %}
{% if msg %}
<div class="mb-4 p-2 rounded text-sm {% if msg in ('not_found','exists') %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{% if msg == 'saved' %}Specificites sauvegardees.{% elif msg == 'added' %}Serveur ajoute.{% elif msg == 'not_found' %}Hostname non trouve en base.{% elif msg == 'exists' %}Ce serveur a deja des specificites.{% endif %}
</div>
{% endif %}
<!-- Filtres -->
<div class="flex gap-3 mb-4 items-center flex-wrap">
<a href="/specifics" class="btn-sm {% if not filter_type %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">Tous ({{ entries|length }})</a>
{% for t in types_in_db %}
<a href="/specifics?app_type={{ t }}" class="btn-sm {% if filter_type == t %}bg-cyber-accent text-black{% else %}bg-cyber-border text-gray-300{% endif %}">{{ t }}</a>
{% endfor %}
<form method="GET" action="/specifics" class="flex gap-1 ml-auto">
<input type="text" name="search" value="{{ filter_search or '' }}" placeholder="Hostname..." class="text-xs py-1 px-2 w-36">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Chercher</button>
</form>
</div>
<!-- Table -->
<div class="card overflow-x-auto">
<table class="w-full table-cyber">
<thead><tr>
<th class="text-left p-2">Hostname</th>
<th class="p-2">Type</th>
<th class="p-2">Domaine</th>
<th class="p-2">Env</th>
<th class="p-2">Flags</th>
<th class="p-2">Ordre</th>
<th class="p-2">Auto-restart</th>
<th class="text-left p-2">Note</th>
<th class="p-2">Actions</th>
</tr></thead>
<tbody>
{% for e in entries %}
<tr id="row-{{ e.id }}">
<td class="p-2 font-mono text-sm text-cyber-accent">{{ e.hostname }}</td>
<td class="p-2 text-center"><span class="badge badge-blue">{{ e.app_type or '-' }}</span></td>
<td class="p-2 text-center text-xs">{{ e.domaine or '-' }}</td>
<td class="p-2 text-center"><span class="badge {% if e.environnement == 'Production' %}badge-green{% else %}badge-yellow{% endif %}">{{ (e.environnement or '-')[:6] }}</span></td>
<td class="p-2 text-center">
<div class="flex gap-0.5 justify-center flex-wrap">
{% if e.kernel_update_blocked %}<span class="badge badge-red" title="Kernel bloque">K</span>{% endif %}
{% if e.is_cluster %}<span class="badge badge-blue" title="Cluster">C</span>{% endif %}
{% if e.is_db %}<span class="badge badge-yellow" title="BDD">DB</span>{% endif %}
{% if e.is_middleware %}<span class="badge badge-gray" title="Middleware">MW</span>{% endif %}
{% if e.sentinel_disable_required %}<span class="badge badge-red" title="SentinelOne">S1</span>{% endif %}
{% if e.ip_forwarding_required %}<span class="badge badge-yellow" title="IP Forward">IP</span>{% endif %}
{% if e.rolling_update %}<span class="badge badge-blue" title="Rolling update">RU</span>{% endif %}
{% if e.needs_manual_step %}<span class="badge badge-yellow" title="Etape manuelle">M</span>{% endif %}
{% if e.extra_excludes %}<span class="badge badge-gray" title="Excludes: {{ e.extra_excludes }}">EX</span>{% endif %}
</div>
</td>
<td class="p-2 text-center text-xs">{% if e.reboot_order %}#{{ e.reboot_order }}{% if e.patch_order_group %} ({{ e.patch_order_group }}){% endif %}{% else %}-{% endif %}</td>
<td class="p-2 text-center"><span class="badge {% if e.auto_restart %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if e.auto_restart else 'Non' }}</span></td>
<td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (e.note or '')[:80] }}{% if e.note and e.note|length > 80 %}...{% endif %}</td>
<td class="p-2 text-center">
<button class="btn-sm bg-cyber-border text-cyber-accent"
hx-get="/specifics/{{ e.id }}/edit" hx-target="#edit-panel" hx-swap="innerHTML"
onclick="document.getElementById('edit-panel').style.display='block'; window.scrollTo({top:0,behavior:'smooth'})">Edit</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Panel edition -->
<div id="edit-panel" class="card mt-4 p-5" style="display:none"></div>
<!-- Ajouter -->
<div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un serveur specifique</h4>
<form method="POST" action="/specifics/add" class="flex gap-3 items-end">
<div>
<label class="text-xs text-gray-500">Hostname</label>
<input type="text" name="hostname" required placeholder="vpinfaweb1" class="text-xs py-1 px-2 w-44">
</div>
<div>
<label class="text-xs text-gray-500">Type application</label>
<select name="app_type" class="text-xs py-1 px-2">
<option value="">-</option>
{% for t in app_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form>
</div>
{% endblock %}

110
app/templates/users.html Normal file
View File

@ -0,0 +1,110 @@
{% extends 'base.html' %}
{% block title %}Utilisateurs{% endblock %}
{% block content %}
<h2 class="text-xl font-bold text-cyber-accent mb-6">Utilisateurs & Permissions</h2>
{% if saved %}
<div class="mb-4 p-3 rounded bg-green-900/30 text-cyber-green text-sm">
{% if saved == 'add' %}Utilisateur cree.{% elif saved == 'password' %}Mot de passe modifie.{% elif saved == 'toggle' %}Statut modifie.{% else %}Permissions sauvegardees.{% endif %}
</div>
{% endif %}
<!-- Liste utilisateurs -->
<div x-data="{ editing: '' }" class="space-y-3">
{% for ud in users_data %}
<div class="card overflow-hidden">
<div class="flex items-center justify-between p-4 cursor-pointer hover:bg-cyber-border/20" @click="editing = editing === '{{ ud.user.id }}' ? '' : '{{ ud.user.id }}'">
<div class="flex items-center gap-3">
<span class="font-bold {% if ud.user.is_active %}text-cyber-accent{% else %}text-gray-600 line-through{% endif %}">{{ ud.user.username }}</span>
<span class="text-sm text-gray-400">{{ ud.user.display_name }}</span>
<span class="badge {% if ud.user.role == 'admin' %}badge-red{% elif ud.user.role == 'coordinator' %}badge-yellow{% elif ud.user.role == 'operator' %}badge-blue{% else %}badge-gray{% endif %}">{{ ud.user.role }}</span>
<span class="badge {% if ud.user.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Actif' if ud.user.is_active else 'Inactif' }}</span>
{% if ud.user.email %}<span class="text-xs text-gray-500">{{ ud.user.email }}</span>{% endif %}
</div>
<div class="flex items-center gap-2">
<!-- Permissions resumees -->
{% for m in modules %}
{% if ud.perms.get(m) %}
<span class="text-xs px-1 rounded {% if ud.perms[m] == 'admin' %}bg-red-900/30 text-cyber-red{% elif ud.perms[m] == 'edit' %}bg-blue-900/30 text-cyber-accent{% else %}bg-gray-800 text-gray-500{% endif %}">{{ m[:3] }}</span>
{% endif %}
{% endfor %}
<span class="text-gray-500 text-lg" x-text="editing === '{{ ud.user.id }}' ? '&#9660;' : '&#9654;'"></span>
</div>
</div>
<div x-show="editing === '{{ ud.user.id }}'" class="border-t border-cyber-border p-4 space-y-4">
<!-- Permissions par module -->
<form method="POST" action="/users/{{ ud.user.id }}/permissions">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2">Permissions par module</h4>
<div class="grid grid-cols-6 gap-2">
{% for m in modules %}
<div>
<label class="text-xs text-gray-500 block mb-1">{{ m }}</label>
<select name="perm_{{ m }}" class="w-full text-xs py-1">
<option value="">-</option>
{% for l in levels %}
<option value="{{ l }}" {% if ud.perms.get(m) == l %}selected{% endif %}>{{ l }}</option>
{% endfor %}
</select>
</div>
{% endfor %}
</div>
<button type="submit" class="btn-primary px-4 py-1 text-sm mt-2">Sauvegarder permissions</button>
</form>
<!-- Actions -->
<div class="flex gap-3 pt-2 border-t border-cyber-border">
<!-- Reset password -->
<form method="POST" action="/users/{{ ud.user.id }}/password" class="flex gap-2 items-center">
<input type="password" name="new_password" placeholder="Nouveau mot de passe" class="text-xs py-1 px-2 w-48">
<button type="submit" class="btn-sm bg-cyber-border text-cyber-accent">Changer MDP</button>
</form>
<!-- Activer/Desactiver -->
<form method="POST" action="/users/{{ ud.user.id }}/toggle">
<button type="submit" class="btn-sm {% if ud.user.is_active %}bg-red-900/30 text-cyber-red{% else %}bg-green-900/30 text-cyber-green{% endif %}">
{{ 'Desactiver' if ud.user.is_active else 'Activer' }}
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Ajouter un utilisateur -->
<div class="card p-5 mt-6">
<h3 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un utilisateur</h3>
<form method="POST" action="/users/add" class="space-y-3">
<div class="grid grid-cols-4 gap-3">
<div>
<label class="text-xs text-gray-500">Username</label>
<input type="text" name="new_username" required class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Nom complet</label>
<input type="text" name="new_display_name" required class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Email</label>
<input type="email" name="new_email" class="w-full">
</div>
<div>
<label class="text-xs text-gray-500">Role global</label>
<select name="new_role" class="w-full">
<option value="operator">operator</option>
<option value="coordinator">coordinator</option>
<option value="admin">admin</option>
<option value="viewer">viewer</option>
</select>
</div>
</div>
<div class="w-64">
<label class="text-xs text-gray-500">Mot de passe</label>
<input type="password" name="new_password" required class="w-full">
</div>
<p class="text-xs text-gray-600">Les permissions par module seront pre-remplies selon le role choisi. Modifiables ensuite.</p>
<button type="submit" class="btn-primary px-4 py-2 text-sm">Creer</button>
</form>
</div>
{% endblock %}

4
run.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
cd /opt/patchcenter
source venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8080 --reload