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:
commit
8277653c43
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.env
|
||||
keys/
|
||||
*.log
|
||||
start.sh
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
25
app/auth.py
Normal file
25
app/auth.py
Normal 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
18
app/config.py
Normal 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
7
app/database.py
Normal 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
20
app/dependencies.py
Normal 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
28
app/main.py
Normal 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
0
app/models/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
44
app/routers/auth.py
Normal file
44
app/routers/auth.py
Normal 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
212
app/routers/campaigns.py
Normal 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
48
app/routers/dashboard.py
Normal 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
200
app/routers/planning.py
Normal 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
113
app/routers/servers.py
Normal 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
202
app/routers/settings.py
Normal 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
159
app/routers/specifics.py
Normal 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
142
app/routers/users.py
Normal 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
0
app/services/__init__.py
Normal file
287
app/services/campaign_service.py
Normal file
287
app/services/campaign_service.py
Normal 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
|
||||
291
app/services/prereq_service.py
Normal file
291
app/services/prereq_service.py
Normal 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
|
||||
156
app/services/qualys_service.py
Normal file
156
app/services/qualys_service.py
Normal 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
|
||||
64
app/services/secrets_service.py
Normal file
64
app/services/secrets_service.py
Normal 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)
|
||||
227
app/services/server_service.py
Normal file
227
app/services/server_service.py
Normal 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
90
app/templates/base.html
Normal 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>
|
||||
250
app/templates/campaign_detail.html
Normal file
250
app/templates/campaign_detail.html
Normal 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 %}
|
||||
82
app/templates/campaigns.html
Normal file
82
app/templates/campaigns.html
Normal 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 %}
|
||||
79
app/templates/dashboard.html
Normal file
79
app/templates/dashboard.html
Normal 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
27
app/templates/login.html
Normal 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 %}
|
||||
49
app/templates/partials/campaign_preview.html
Normal file
49
app/templates/partials/campaign_preview.html
Normal 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>
|
||||
120
app/templates/partials/server_detail.html
Normal file
120
app/templates/partials/server_detail.html
Normal 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">×</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>
|
||||
85
app/templates/partials/server_edit.html
Normal file
85
app/templates/partials/server_edit.html
Normal 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">×</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>
|
||||
182
app/templates/partials/specific_edit.html
Normal file
182
app/templates/partials/specific_edit.html
Normal 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">×</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
243
app/templates/planning.html
Normal 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
109
app/templates/servers.html
Normal 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' %}▲{% else %}▼{% 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
389
app/templates/settings.html
Normal 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 }}' ? '▼' : '▶'"></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' ? '▼' : '▶'"></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' ? '▼' : '▶'"></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 %}
|
||||
96
app/templates/specifics.html
Normal file
96
app/templates/specifics.html
Normal 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
110
app/templates/users.html
Normal 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 }}' ? '▼' : '▶'"></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 %}
|
||||
Loading…
Reference in New Issue
Block a user