Misc: servers page (application + equivalent), campagne tweaks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pierre & Lumière 2026-04-12 18:51:36 +02:00
parent a706e240ca
commit caa2be71a4
14 changed files with 198 additions and 44 deletions

View File

@ -27,7 +27,7 @@ router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
EXCLUSION_REASONS = [ EXCLUSION_REASONS = [
("eol", "Fin de vie (EOL)"), ("obsolete", "Fin de vie (EOL)"),
("creneau_inadequat", "Creneau non adequat"), ("creneau_inadequat", "Creneau non adequat"),
("intervention_non_secops", "Intervention non-SecOps prevue"), ("intervention_non_secops", "Intervention non-SecOps prevue"),
("report_cycle", "Report au cycle suivant"), ("report_cycle", "Report au cycle suivant"),

View File

@ -107,6 +107,7 @@ async def contacts_page(request: Request, db=Depends(get_db),
"SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type" "SELECT DISTINCT app_type FROM server_specifics WHERE app_type IS NOT NULL ORDER BY app_type"
)).fetchall() )).fetchall()
perms = get_user_perms(db, user)
ctx = base_context(request, db, user) ctx = base_context(request, db, user)
ctx.update({ ctx.update({
"app_name": APP_NAME, "contacts": contacts, "app_name": APP_NAME, "contacts": contacts,
@ -115,6 +116,7 @@ async def contacts_page(request: Request, db=Depends(get_db),
"domains": domains, "app_types": [r.app_type for r in app_types], "domains": domains, "app_types": [r.app_type for r in app_types],
"search": search, "role_filter": role, "server": server, "server_info": server_info, "search": search, "role_filter": role, "server": server, "server_info": server_info,
"msg": request.query_params.get("msg"), "msg": request.query_params.get("msg"),
"can_edit_contacts": can_edit(perms, "servers") or can_edit(perms, "contacts"),
}) })
return templates.TemplateResponse("contacts.html", ctx) return templates.TemplateResponse("contacts.html", ctx)

View File

@ -18,21 +18,21 @@ async def dashboard(request: Request, db=Depends(get_db)):
# Stats generales # Stats generales
stats = {} stats = {}
stats["total_servers"] = db.execute(text("SELECT COUNT(*) FROM servers")).scalar() 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["patchable"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE patch_os_owner='secops' AND etat='production'")).scalar()
stats["linux"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE os_family='linux'")).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["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["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='obsolete'")).scalar()
stats["eol"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='eol'")).scalar() stats["obsolete"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE licence_support='obsolete'")).scalar()
stats["qualys_assets"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets")).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() stats["qualys_tags"] = db.execute(text("SELECT COUNT(*) FROM qualys_tags")).scalar()
stats["qualys_active"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%active%' AND agent_status NOT ILIKE '%inactive%'")).scalar() stats["qualys_active"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%active%' AND agent_status NOT ILIKE '%inactive%'")).scalar()
stats["qualys_inactive"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%inactive%'")).scalar() stats["qualys_inactive"] = db.execute(text("SELECT COUNT(*) FROM qualys_assets WHERE agent_status ILIKE '%inactive%'")).scalar()
stats["qualys_no_agent"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='en_production' AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(servers.hostname))")).scalar() stats["qualys_no_agent"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='production' AND NOT EXISTS (SELECT 1 FROM qualys_assets qa WHERE LOWER(qa.hostname) = LOWER(servers.hostname))")).scalar()
# Par domaine # Par domaine
domains = db.execute(text(""" domains = db.execute(text("""
SELECT d.name, d.code, COUNT(s.id) as total, SELECT d.name, d.code, COUNT(s.id) as total,
COUNT(*) FILTER (WHERE s.etat='en_production') as actifs, COUNT(*) FILTER (WHERE s.etat='production') as actifs,
COUNT(*) FILTER (WHERE s.os_family='linux') as linux, COUNT(*) FILTER (WHERE s.os_family='linux') as linux,
COUNT(*) FILTER (WHERE s.os_family='windows') as windows COUNT(*) FILTER (WHERE s.os_family='windows') as windows
FROM servers s FROM servers s

View File

@ -40,7 +40,7 @@ def _get_planning_data(db, year):
SELECT d.code, d.name, COUNT(s.id) as srv_count SELECT d.code, d.name, COUNT(s.id) as srv_count
FROM domains d FROM domains d
LEFT JOIN domain_environments de ON de.domain_id = d.id 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' LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'production'
GROUP BY d.code, d.name, d.display_order GROUP BY d.code, d.name, d.display_order
ORDER BY d.display_order ORDER BY d.display_order
""")).fetchall() """)).fetchall()

View File

@ -2,6 +2,7 @@
from fastapi import APIRouter, Request, Depends, Query, Form from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user from ..dependencies import get_db, get_current_user
from ..services.server_service import ( from ..services.server_service import (
get_server_full, get_server_tags, get_server_ips, get_server_full, get_server_tags, get_server_ips,
@ -19,21 +20,38 @@ async def servers_list(request: Request, db=Depends(get_db),
domain: str = Query(None), env: str = Query(None), domain: str = Query(None), env: str = Query(None),
tier: str = Query(None), etat: str = Query(None), tier: str = Query(None), etat: str = Query(None),
os: str = Query(None), owner: str = Query(None), os: str = Query(None), owner: str = Query(None),
application: str = Query(None),
search: str = Query(None), page: int = Query(1), search: str = Query(None), page: int = Query(1),
sort: str = Query("hostname"), sort_dir: str = Query("asc")): sort: str = Query("hostname"), sort_dir: str = Query("asc")):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os, "owner": owner, "search": search} filters = {"domain": domain, "env": env, "tier": tier, "etat": etat, "os": os,
"owner": owner, "application": application, "search": search}
servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir) servers, total = list_servers(db, filters, page, sort=sort, sort_dir=sort_dir)
domains_list, envs_list = get_reference_data(db) domains_list, envs_list = get_reference_data(db)
applications_list = db.execute(text("""SELECT application_name, COUNT(*) as c FROM servers
WHERE application_name IS NOT NULL AND application_name != ''
GROUP BY application_name ORDER BY application_name""")).fetchall()
from ..dependencies import get_user_perms, can_edit
perms = get_user_perms(db, user)
can_edit_servers = can_edit(perms, "servers")
# Correspondances en bulk pour la page en cours
from ..services.correspondance_service import get_links_bulk
links = get_links_bulk(db, [s.id for s in servers])
return templates.TemplateResponse("servers.html", { return templates.TemplateResponse("servers.html", {
"request": request, "user": user, "app_name": APP_NAME, "request": request, "user": user, "app_name": APP_NAME,
"servers": servers, "total": total, "page": page, "per_page": 50, "servers": servers, "total": total, "page": page, "per_page": 50,
"domains_list": domains_list, "envs_list": envs_list, "filters": filters, "domains_list": domains_list, "envs_list": envs_list,
"applications_list": applications_list, "filters": filters,
"sort": sort, "sort_dir": sort_dir, "sort": sort, "sort_dir": sort_dir,
"perms": perms, "can_edit_servers": can_edit_servers,
"links": links,
}) })
@ -78,8 +96,10 @@ async def server_detail(request: Request, server_id: int, db=Depends(get_db)):
return HTMLResponse("<p>Serveur non trouve</p>") return HTMLResponse("<p>Serveur non trouve</p>")
tags = get_server_tags(db, s.qid) tags = get_server_tags(db, s.qid)
ips = get_server_ips(db, server_id) ips = get_server_ips(db, server_id)
from ..services.correspondance_service import get_server_links
links = get_server_links(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", { return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips "request": request, "s": s, "tags": tags, "ips": ips, "links": links
}) })
@ -100,10 +120,14 @@ async def server_edit(request: Request, server_id: int, db=Depends(get_db)):
zones_list = db.execute(sqlt( zones_list = db.execute(sqlt(
"SELECT name FROM zones ORDER BY name" "SELECT name FROM zones ORDER BY name"
)).fetchall() )).fetchall()
applications = db.execute(sqlt(
"SELECT id, nom_court FROM applications WHERE itop_id IS NOT NULL ORDER BY nom_court"
)).fetchall()
return templates.TemplateResponse("partials/server_edit.html", { return templates.TemplateResponse("partials/server_edit.html", {
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips, "request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
"dns_list": [r.name for r in dns_list], "dns_list": [r.name for r in dns_list],
"zones_list": [r.name for r in zones_list], "zones_list": [r.name for r in zones_list],
"applications": applications,
}) })
@ -116,11 +140,15 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
commentaire: str = Form(None), commentaire: str = Form(None),
ip_reelle: str = Form(None), ip_connexion: str = Form(None), ip_reelle: str = Form(None), ip_connexion: str = Form(None),
ssh_method: str = Form(None), domain_ltd: str = Form(None), ssh_method: str = Form(None), domain_ltd: str = Form(None),
pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None)): pref_patch_jour: str = Form(None), pref_patch_heure: str = Form(None),
application_id: str = Form(None)):
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return HTMLResponse("<p>Non autorise</p>") return HTMLResponse("<p>Non autorise</p>")
from ..dependencies import get_user_perms, can_edit
if not can_edit(get_user_perms(db, user), "servers"):
return HTMLResponse("<p>Permission refusee</p>", status_code=403)
data = { data = {
"domain_code": domain_code, "env_code": env_code, "zone": zone, "domain_code": domain_code, "env_code": env_code, "zone": zone,
@ -133,11 +161,47 @@ async def server_update(request: Request, server_id: int, db=Depends(get_db),
} }
update_server(db, server_id, data, user.get("sub")) update_server(db, server_id, data, user.get("sub"))
# Application (changement manuel SecOps) — update + push iTop
if application_id is not None:
app_id_val = int(application_id) if application_id and application_id.strip().isdigit() else None
app_itop_id = None
app_name = None
if app_id_val:
row = db.execute(text("SELECT itop_id, nom_court FROM applications WHERE id=:id"),
{"id": app_id_val}).fetchone()
if row:
app_itop_id = row.itop_id
app_name = row.nom_court
db.execute(text("""UPDATE servers SET application_id=:aid, application_name=:an, updated_at=NOW()
WHERE id=:sid"""), {"aid": app_id_val, "an": app_name, "sid": server_id})
db.commit()
# Push iTop (best effort)
try:
from ..services.itop_service import ITopClient
from ..services.secrets_service import get_secret
srv_row = db.execute(text("SELECT hostname FROM servers WHERE id=:id"), {"id": server_id}).fetchone()
if srv_row:
url = get_secret(db, "itop_url")
u = get_secret(db, "itop_user")
p = get_secret(db, "itop_pass")
if url and u and p:
client = ITopClient(url, u, p)
r = client._call("core/get", **{"class": "VirtualMachine",
"key": f'SELECT VirtualMachine WHERE name = "{srv_row.hostname}"', "output_fields": "name"})
if r.get("objects"):
vm_id = list(r["objects"].values())[0]["key"]
new_list = [{"applicationsolution_id": int(app_itop_id)}] if app_itop_id else []
client.update("VirtualMachine", vm_id, {"applicationsolution_list": new_list})
except Exception:
pass
s = get_server_full(db, server_id) s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid) tags = get_server_tags(db, s.qid)
ips = get_server_ips(db, server_id) ips = get_server_ips(db, server_id)
from ..services.correspondance_service import get_server_links
links = get_server_links(db, server_id)
return templates.TemplateResponse("partials/server_detail.html", { return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips "request": request, "s": s, "tags": tags, "ips": ips, "links": links
}) })
@ -148,6 +212,9 @@ async def servers_bulk(request: Request, db=Depends(get_db),
user = get_current_user(request) user = get_current_user(request)
if not user: if not user:
return RedirectResponse(url="/login") return RedirectResponse(url="/login")
from ..dependencies import get_user_perms, can_edit
if not can_edit(get_user_perms(db, user), "servers"):
return RedirectResponse(url="/servers?msg=forbidden", status_code=303)
if not server_ids or not bulk_field or not bulk_value: if not server_ids or not bulk_field or not bulk_value:
return RedirectResponse(url="/servers", status_code=303) return RedirectResponse(url="/servers", status_code=303)
@ -203,7 +270,9 @@ async def server_sync_qualys(request: Request, server_id: int, db=Depends(get_db
s = get_server_full(db, server_id) s = get_server_full(db, server_id)
tags = get_server_tags(db, s.qid) if s else [] tags = get_server_tags(db, s.qid) if s else []
ips = get_server_ips(db, server_id) ips = get_server_ips(db, server_id)
from ..services.correspondance_service import get_server_links
links = get_server_links(db, server_id) if s else {"as_prod": [], "as_nonprod": []}
return templates.TemplateResponse("partials/server_detail.html", { return templates.TemplateResponse("partials/server_detail.html", {
"request": request, "s": s, "tags": tags, "ips": ips, "request": request, "s": s, "tags": tags, "ips": ips, "links": links,
"sync_msg": result.get("msg"), "sync_ok": result.get("ok"), "sync_msg": result.get("msg"), "sync_ok": result.get("ok"),
}) })

View File

@ -124,7 +124,7 @@ def get_servers_for_planning(db, year, week_number):
or_clauses.append("z.name = 'DMZ'") or_clauses.append("z.name = 'DMZ'")
where = f""" where = f"""
s.etat = 'en_production' AND s.patch_os_owner = 'secops' s.etat = 'production' AND s.patch_os_owner = 'secops'
AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux' AND s.licence_support IN ('active', 'els') AND s.os_family = 'linux'
AND ({' OR '.join(or_clauses)}) AND ({' OR '.join(or_clauses)})
""" """

View File

@ -142,24 +142,24 @@ def _check_server(s):
result["disk_var_mb"] = 3000 result["disk_var_mb"] = 3000
result["disk_ok"] = True result["disk_ok"] = True
# Quand meme exclure les EOL et decom # Quand meme exclure les EOL et decom
if s.licence_support == 'eol': if s.licence_support == 'obsolete':
result["eligible"] = False result["eligible"] = False
result["exclude_reason"] = "eol" result["exclude_reason"] = "obsolete"
result["exclude_detail"] = "Licence EOL" result["exclude_detail"] = "Licence EOL"
elif s.etat != 'en_production': elif s.etat != 'production':
result["eligible"] = False result["eligible"] = False
result["exclude_reason"] = "non_patchable" result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}" result["exclude_detail"] = f"Etat: {s.etat}"
return result return result
# 1. Eligibilite de base # 1. Eligibilite de base
if s.licence_support == 'eol': if s.licence_support == 'obsolete':
result["eligible"] = False result["eligible"] = False
result["exclude_reason"] = "eol" result["exclude_reason"] = "obsolete"
result["exclude_detail"] = "Licence EOL — serveur non supporte" result["exclude_detail"] = "Licence EOL — serveur non supporte"
return result return result
if s.etat != 'en_production': if s.etat != 'production':
result["eligible"] = False result["eligible"] = False
result["exclude_reason"] = "non_patchable" result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}" result["exclude_detail"] = f"Etat: {s.etat}"
@ -255,17 +255,17 @@ def _auto_exclude(db, campaign_id):
FROM patch_sessions ps FROM patch_sessions ps
JOIN servers s ON ps.server_id = s.id JOIN servers s ON ps.server_id = s.id
WHERE ps.campaign_id = :cid AND ps.status = 'pending' WHERE ps.campaign_id = :cid AND ps.status = 'pending'
AND (s.licence_support = 'eol' AND (s.licence_support = 'obsolete'
OR s.etat != 'en_production' OR s.etat != 'production'
OR ps.prereq_ssh = 'ko' OR ps.prereq_ssh = 'ko'
OR ps.prereq_disk_ok = false) OR ps.prereq_disk_ok = false)
"""), {"cid": campaign_id}).fetchall() """), {"cid": campaign_id}).fetchall()
count = 0 count = 0
for s in non_eligible: for s in non_eligible:
if s.licence_support == 'eol': if s.licence_support == 'obsolete':
reason, detail = "eol", "Licence EOL — auto-exclu" reason, detail = "obsolete", "Licence EOL — auto-exclu"
elif s.etat != 'en_production': elif s.etat != 'production':
reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu" reason, detail = "non_patchable", f"Etat {s.etat} — auto-exclu"
elif s.prereq_disk_ok is False: elif s.prereq_disk_ok is False:
reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu" reason, detail = "creneau_inadequat", "Espace disque insuffisant — auto-exclu"

View File

@ -115,15 +115,17 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
if filters.get("tier"): if filters.get("tier"):
where.append("s.tier = :tier"); params["tier"] = filters["tier"] where.append("s.tier = :tier"); params["tier"] = filters["tier"]
if filters.get("etat"): if filters.get("etat"):
if filters["etat"] == "eol": if filters["etat"] == "obsolete":
where.append("s.licence_support = 'eol'") where.append("s.licence_support = 'obsolete'")
else: else:
where.append("s.etat = :etat"); params["etat"] = filters["etat"] where.append("s.etat = :etat"); params["etat"] = filters["etat"]
where.append("COALESCE(s.licence_support, '') != 'eol'") where.append("COALESCE(s.licence_support, '') != 'obsolete'")
if filters.get("os"): if filters.get("os"):
where.append("s.os_family = :os"); params["os"] = filters["os"] where.append("s.os_family = :os"); params["os"] = filters["os"]
if filters.get("owner"): if filters.get("owner"):
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"] where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
if filters.get("application"):
where.append("s.application_name = :application"); params["application"] = filters["application"]
if filters.get("search"): if filters.get("search"):
where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%" where.append("s.hostname ILIKE :search"); params["search"] = f"%{filters['search']}%"
@ -136,14 +138,20 @@ def list_servers(db, filters, page=1, per_page=50, sort="hostname", sort_dir="as
SELECT s.id, s.hostname, s.fqdn, d.name as domaine, e.name as environnement, 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, 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, s.licence_support, s.patch_os_owner, s.responsable_nom, s.machine_type,
s.application_name,
CASE CASE
WHEN s.os_version ILIKE '%Red Hat%' THEN WHEN s.os_version ILIKE '%Red Hat%' THEN
'Red Hat ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '') 'Red Hat ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%Oracle%Linux%' THEN WHEN s.os_version ILIKE '%Oracle%Linux%' THEN
'Oracle ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '') 'Oracle ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%CentOS Stream%' THEN
'CentOS Stream ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
WHEN s.os_version ILIKE '%CentOS%' THEN WHEN s.os_version ILIKE '%CentOS%' THEN
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d[\d.]*)'))[1], '') 'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
WHEN s.os_version ILIKE '%Ubuntu%' THEN 'Ubuntu' WHEN s.os_version ILIKE '%Debian%' THEN
'Debian ' || COALESCE((regexp_match(s.os_version, '(\d+)'))[1], '')
WHEN s.os_version ILIKE '%Ubuntu%' THEN
'Ubuntu ' || COALESCE((regexp_match(s.os_version, '(\d+\.\d+)'))[1], '')
WHEN s.os_version ILIKE '%Windows Server 2022 Standard%' THEN '2022 Standard' 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 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 Standard%' THEN '2019 Standard'

View File

@ -213,7 +213,7 @@ function showForm(id, type) {
<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> <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 %} {% if s.exclusion_reason %}
<div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}"> <div class="text-[9px] text-gray-500" title="{{ s.exclusion_detail or '' }}">
{% if s.exclusion_reason == 'eol' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% elif s.exclusion_reason == 'non_patchable' %}Non patchable{% else %}{{ s.exclusion_reason }}{% endif %} {% if s.exclusion_reason == 'obsolete' %}EOL{% elif s.exclusion_reason == 'creneau_inadequat' %}Prereq KO{% 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 %} {% if s.excluded_by %}<span class="text-gray-600">({{ s.excluded_by }})</span>{% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -51,7 +51,7 @@
<th class="p-2">Rôle</th> <th class="p-2">Rôle</th>
<th class="text-left p-2">Scopes</th> <th class="text-left p-2">Scopes</th>
<th class="p-2">Actif</th> <th class="p-2">Actif</th>
<th class="p-2">Actions</th> {% if can_edit_contacts %}<th class="p-2">Actions</th>{% endif %}
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for c in contacts %} {% for c in contacts %}
@ -61,6 +61,7 @@
<td class="p-2 text-center"><span class="badge {% if 'responsable' in c.role %}badge-blue{% elif 'referent' in c.role %}badge-yellow{% elif 'ra_' in c.role %}badge-green{% else %}badge-gray{% endif %}">{{ c.role }}</span></td> <td class="p-2 text-center"><span class="badge {% if 'responsable' in c.role %}badge-blue{% elif 'referent' in c.role %}badge-yellow{% elif 'ra_' in c.role %}badge-green{% else %}badge-gray{% endif %}">{{ c.role }}</span></td>
<td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (c.scopes_summary or '-')[:80] }}</td> <td class="p-2 text-xs text-gray-400" style="max-width:300px">{{ (c.scopes_summary or '-')[:80] }}</td>
<td class="p-2 text-center"><span class="badge {% if c.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if c.is_active else 'Non' }}</span></td> <td class="p-2 text-center"><span class="badge {% if c.is_active %}badge-green{% else %}badge-red{% endif %}">{{ 'Oui' if c.is_active else 'Non' }}</span></td>
{% if can_edit_contacts %}
<td class="p-2 text-center"> <td class="p-2 text-center">
<div class="flex gap-1 justify-center"> <div class="flex gap-1 justify-center">
<button class="btn-sm bg-cyber-border text-cyber-accent" <button class="btn-sm bg-cyber-border text-cyber-accent"
@ -71,12 +72,14 @@
</form> </form>
</div> </div>
</td> </td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% if can_edit_contacts %}
<!-- Ajouter --> <!-- Ajouter -->
<div class="card p-4 mt-4"> <div class="card p-4 mt-4">
<h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un contact</h4> <h4 class="text-sm font-bold text-cyber-accent mb-3">Ajouter un contact</h4>
@ -98,4 +101,5 @@
<button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button> <button type="submit" class="btn-primary px-4 py-1 text-sm">Ajouter</button>
</form> </form>
</div> </div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -28,7 +28,7 @@
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for s in servers %} {% 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 %}"> <tr class="{% if s.licence_support == 'obsolete' %}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 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 font-mono text-cyber-accent">{{ s.hostname }}</td>
<td class="p-1 text-center">{{ s.domaine or '-' }}</td> <td class="p-1 text-center">{{ s.domaine or '-' }}</td>
@ -36,7 +36,7 @@
<td class="p-1 text-center">{{ s.os_family or '-' }}</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"><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">{{ 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> <td class="p-1 text-center"><span class="badge {% if s.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -51,7 +51,7 @@
<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">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">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">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 class="flex justify-between"><span class="text-gray-500">Etat</span><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.etat }}</span></div>
</div> </div>
</div> </div>
@ -61,7 +61,7 @@
<div class="space-y-1 text-sm"> <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="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="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 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 == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}">{{ s.licence_support }}</span></div>
</div> </div>
</div> </div>
@ -88,6 +88,46 @@
</div> </div>
</div> </div>
<!-- Correspondance prod ↔ hors-prod -->
{% if links and (links.as_prod or links.as_nonprod) %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Correspondance Prod ↔ Hors-Prod</h4>
{% if links.as_prod %}
<div class="text-xs mb-2">
<div class="text-cyber-green font-bold mb-1">Ce serveur est un PROD — hors-prod liés :</div>
<ul class="ml-2 space-y-1">
{% for l in links.as_prod %}
<li class="flex gap-2 items-center">
<span class="font-mono text-cyber-accent">{{ l.hostname }}</span>
<span class="badge badge-yellow" style="font-size:9px">{{ l.env_name or l.environment_code or '?' }}</span>
<span class="text-gray-600" style="font-size:9px">{{ l.source }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if links.as_nonprod %}
<div class="text-xs mb-2">
<div class="text-cyber-yellow font-bold mb-1">Ce serveur est un HORS-PROD — prod(s) lié(s) :</div>
<ul class="ml-2 space-y-1">
{% for l in links.as_nonprod %}
<li class="flex gap-2 items-center">
<span class="font-mono text-cyber-accent">{{ l.hostname }}</span>
<span class="badge badge-green" style="font-size:9px">{{ l.env_name or 'Production' }}</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<a href="/patching/correspondance?search={{ s.hostname }}" class="text-xs text-cyber-accent hover:underline">Gérer dans le builder →</a>
</div>
{% else %}
<div class="mb-4">
<h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Correspondance</h4>
<p class="text-xs text-gray-500">Aucune correspondance prod ↔ hors-prod définie. <a href="/patching/correspondance?search={{ s.hostname }}" class="text-cyber-accent hover:underline">Créer →</a></p>
</div>
{% endif %}
{% if s.mode_operatoire %} {% if s.mode_operatoire %}
<div class="mb-4"> <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> <h4 class="text-xs text-cyber-accent font-bold uppercase mb-2 border-b border-cyber-border pb-1">Mode operatoire</h4>

View File

@ -58,7 +58,7 @@
<div> <div>
<label class="text-xs text-gray-500">Etat</label> <label class="text-xs text-gray-500">Etat</label>
<select name="etat" class="w-full"> <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 %} {% for e in ['production','implementation','stock','obsolete'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select> </select>
</div> </div>
<div> <div>
@ -67,6 +67,13 @@
{% for o in ['secops','ipop','editeur','tiers','na','a_definir'] %}<option value="{{ o }}" {% if o == s.patch_os_owner %}selected{% endif %}>{{ o }}</option>{% endfor %} {% 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> </select>
</div> </div>
<div>
<label class="text-xs text-gray-500">Solution applicative (iTop) <span class="text-gray-600">— modifie aussi iTop</span></label>
<select name="application_id" class="w-full">
<option value="">-- Aucune --</option>
{% for a in applications %}<option value="{{ a.id }}" {% if a.id == s.application_id %}selected{% endif %}>{{ a.nom_court }}</option>{% endfor %}
</select>
</div>
<div> <div>
<label class="text-xs text-gray-500">Responsable</label> <label class="text-xs text-gray-500">Responsable</label>
<input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full"> <input type="text" name="responsable_nom" value="{{ s.responsable_nom or '' }}" class="w-full">

View File

@ -36,7 +36,7 @@
{% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %} {% for t in ['tier0','tier1','tier2','tier3'] %}<option value="{{ t }}" {% if filters.tier == t %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select> </select>
<select name="etat" onchange="this.form.submit()"><option value="">Etat</option> <select name="etat" onchange="this.form.submit()"><option value="">Etat</option>
{% for e in ['en_production','en_implementation','en_decommissionnement','decommissionne','eteint','eol'] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ e.replace("en_","En ").replace("_"," ").title() }}</option>{% endfor %} {% for e,l in [('production','Production'),('implementation','Implementation'),('stock','Stock'),('obsolete','Obsolete')] %}<option value="{{ e }}" {% if filters.etat == e %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select> </select>
<select name="os" onchange="this.form.submit()"><option value="">OS</option> <select name="os" onchange="this.form.submit()"><option value="">OS</option>
<option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option> <option value="linux" {% if filters.os == 'linux' %}selected{% endif %}>Linux</option>
@ -47,10 +47,14 @@
<option value="ipop" {% if filters.owner == 'ipop' %}selected{% endif %}>ipop</option> <option value="ipop" {% if filters.owner == 'ipop' %}selected{% endif %}>ipop</option>
<option value="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option> <option value="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option>
</select> </select>
<select name="application" onchange="this.form.submit()" style="max-width:200px"><option value="">Solution app.</option>
{% for a in applications_list %}<option value="{{ a.application_name }}" {% if filters.application == a.application_name %}selected{% endif %}>{{ a.application_name }} ({{ a.c }})</option>{% endfor %}
</select>
<button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button> <button type="submit" class="btn-primary px-3 py-1 text-sm">Filtrer</button>
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a> <a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form> </form>
{% if can_edit_servers %}
<!-- Actions groupées --> <!-- Actions groupées -->
<div id="bulk-bar" class="card p-3 mb-2 flex gap-3 items-center flex-wrap" style="display:none"> <div id="bulk-bar" class="card p-3 mb-2 flex gap-3 items-center flex-wrap" style="display:none">
<span class="text-xs text-gray-400" id="bulk-count">0 sélectionné(s)</span> <span class="text-xs text-gray-400" id="bulk-count">0 sélectionné(s)</span>
@ -71,15 +75,16 @@
<button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button> <button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button>
</form> </form>
</div> </div>
{% endif %}
<script> <script>
const bulkValues = { const bulkValues = {
domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}], domain_code: [{% for d in domains_list %}{v:"{{ d.code }}", l:"{{ d.name }}"},{% endfor %}],
env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}], env_code: [{% for e in envs_list %}{v:"{{ e.code }}", l:"{{ e.name }}"},{% endfor %}],
tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}], tier: [{v:"tier0",l:"tier0"},{v:"tier1",l:"tier1"},{v:"tier2",l:"tier2"},{v:"tier3",l:"tier3"}],
etat: [{v:"en_production",l:"En production"},{v:"en_implementation",l:"En implémentation"},{v:"en_decommissionnement",l:"En décommissionnement"},{v:"decommissionne",l:"Décommissionné"},{v:"eteint",l:"Éteint"},{v:"eol",l:"EOL"}], etat: [{v:"production",l:"Production"},{v:"implementation",l:"Implementation"},{v:"stock",l:"Stock"},{v:"obsolete",l:"Obsolete"}],
patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}], patch_os_owner: [{v:"secops",l:"secops"},{v:"ipop",l:"ipop"},{v:"na",l:"na"}],
licence_support: [{v:"active",l:"active"},{v:"eol",l:"eol"},{v:"els",l:"els"}], licence_support: [{v:"active",l:"active"},{v:"obsolete",l:"obsolete"},{v:"els",l:"els"}],
}; };
document.getElementById('bulk-field').addEventListener('change', function() { document.getElementById('bulk-field').addEventListener('change', function() {
const sel = document.getElementById('bulk-value'); const sel = document.getElementById('bulk-value');
@ -103,7 +108,7 @@ function updateBulk() {
<div id="server-table" class="card overflow-x-auto"> <div id="server-table" class="card overflow-x-auto">
<table class="w-full table-cyber"> <table class="w-full table-cyber">
<thead><tr> <thead><tr>
<th class="p-2 w-8"><input type="checkbox" id="check-all"></th> {% if can_edit_servers %}<th class="p-2 w-8"><input type="checkbox" id="check-all"></th>{% endif %}
<th class="text-left p-2"><a href="{{ sort_url('hostname') }}" class="hover:text-cyber-accent">Hostname {{ sort_icon('hostname') }}</a></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('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('env') }}" class="hover:text-cyber-accent">Env {{ sort_icon('env') }}</a></th>
@ -114,24 +119,43 @@ function updateBulk() {
<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('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('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"><a href="{{ sort_url('owner') }}" class="hover:text-cyber-accent">Owner {{ sort_icon('owner') }}</a></th>
<th class="p-2 text-left">Solution applicative</th>
<th class="p-2 text-left">Équivalent(s)</th>
<th class="p-2">Actions</th> <th class="p-2">Actions</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for s in servers %} {% 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()"> <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 }}" onchange="updateBulk()"></td> {% if can_edit_servers %}<td class="p-2" onclick="event.stopPropagation()"><input type="checkbox" name="srv" value="{{ s.id }}" onchange="updateBulk()"></td>{% endif %}
<td class="p-2 font-mono text-sm text-cyber-accent">{{ s.hostname }}</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 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 %}"title="{{ s.environnement or '' }}">{{ (s.environnement or '-')[:6] }}</span></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 %}"title="{{ s.environnement or '' }}">{{ (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"><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">{{ 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 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.licence_support == 'active' %}badge-green{% elif s.licence_support == 'obsolete' %}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.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 %}"title="{{ s.etat or '' }}">{{ (s.etat or '')[:8] }}</span></td> <td class="p-2 text-center"><span class="badge {% if s.etat == 'production' %}badge-green{% elif s.etat == 'obsolete' %}badge-red{% else %}badge-yellow{% endif %}"title="{{ s.etat or '' }}">{{ (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 text-xs">{{ s.patch_os_owner or '-' }}</td>
<td class="p-2 text-xs text-gray-300" title="{{ s.application_name or '' }}">{{ (s.application_name or '-')[:35] }}</td>
<td class="p-2 text-xs" onclick="event.stopPropagation()" style="max-width:220px">
{% set link = links.get(s.id, {}) %}
{% if link.as_prod %}
<span class="text-cyber-green" style="font-size:10px">→ non-prod :</span>
{% for l in link.as_prod %}<span class="font-mono text-gray-300" title="{{ l.env_name or '' }}">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% elif link.as_nonprod %}
<span class="text-cyber-yellow" style="font-size:10px">→ prod :</span>
{% for l in link.as_nonprod %}<span class="font-mono text-gray-300">{{ l.hostname }}{% if not loop.last %}, {% endif %}</span>{% endfor %}
{% else %}
<span class="text-gray-600"></span>
{% endif %}
</td>
<td class="p-2 text-center" onclick="event.stopPropagation()"> <td class="p-2 text-center" onclick="event.stopPropagation()">
{% if can_edit_servers %}
<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> <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>
{% else %}
<span class="text-xs text-gray-600"></span>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}