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")
EXCLUSION_REASONS = [
("eol", "Fin de vie (EOL)"),
("obsolete", "Fin de vie (EOL)"),
("creneau_inadequat", "Creneau non adequat"),
("intervention_non_secops", "Intervention non-SecOps prevue"),
("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"
)).fetchall()
perms = get_user_perms(db, user)
ctx = base_context(request, db, user)
ctx.update({
"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],
"search": search, "role_filter": role, "server": server, "server_info": server_info,
"msg": request.query_params.get("msg"),
"can_edit_contacts": can_edit(perms, "servers") or can_edit(perms, "contacts"),
})
return templates.TemplateResponse("contacts.html", ctx)

View File

@ -18,21 +18,21 @@ async def dashboard(request: Request, db=Depends(get_db)):
# Stats generales
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["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["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["decom"] = db.execute(text("SELECT COUNT(*) FROM servers WHERE etat='obsolete'")).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_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_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
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.etat='production') as actifs,
COUNT(*) FILTER (WHERE s.os_family='linux') as linux,
COUNT(*) FILTER (WHERE s.os_family='windows') as windows
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
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'
LEFT JOIN servers s ON s.domain_env_id = de.id AND s.etat = 'production'
GROUP BY d.code, d.name, d.display_order
ORDER BY d.display_order
""")).fetchall()

View File

@ -2,6 +2,7 @@
from fastapi import APIRouter, Request, Depends, Query, Form
from fastapi.responses import HTMLResponse, RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import text
from ..dependencies import get_db, get_current_user
from ..services.server_service import (
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),
tier: str = Query(None), etat: str = Query(None),
os: str = Query(None), owner: str = Query(None),
application: 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, "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)
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", {
"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,
"domains_list": domains_list, "envs_list": envs_list,
"applications_list": applications_list, "filters": filters,
"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>")
tags = get_server_tags(db, s.qid)
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", {
"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(
"SELECT name FROM zones ORDER BY name"
)).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", {
"request": request, "s": s, "domains": domains, "envs": envs, "ips": ips,
"dns_list": [r.name for r in dns_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),
ip_reelle: str = Form(None), ip_connexion: 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)
if not user:
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 = {
"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"))
# 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)
tags = get_server_tags(db, s.qid)
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", {
"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)
if not user:
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:
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)
tags = get_server_tags(db, s.qid) if s else []
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", {
"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"),
})

View File

@ -124,7 +124,7 @@ def get_servers_for_planning(db, year, week_number):
or_clauses.append("z.name = 'DMZ'")
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 ({' OR '.join(or_clauses)})
"""

View File

@ -142,24 +142,24 @@ def _check_server(s):
result["disk_var_mb"] = 3000
result["disk_ok"] = True
# Quand meme exclure les EOL et decom
if s.licence_support == 'eol':
if s.licence_support == 'obsolete':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_reason"] = "obsolete"
result["exclude_detail"] = "Licence EOL"
elif s.etat != 'en_production':
elif s.etat != 'production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
return result
# 1. Eligibilite de base
if s.licence_support == 'eol':
if s.licence_support == 'obsolete':
result["eligible"] = False
result["exclude_reason"] = "eol"
result["exclude_reason"] = "obsolete"
result["exclude_detail"] = "Licence EOL — serveur non supporte"
return result
if s.etat != 'en_production':
if s.etat != 'production':
result["eligible"] = False
result["exclude_reason"] = "non_patchable"
result["exclude_detail"] = f"Etat: {s.etat}"
@ -255,17 +255,17 @@ def _auto_exclude(db, campaign_id):
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'
AND (s.licence_support = 'obsolete'
OR s.etat != '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':
if s.licence_support == 'obsolete':
reason, detail = "obsolete", "Licence EOL — auto-exclu"
elif s.etat != '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"

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"):
where.append("s.tier = :tier"); params["tier"] = filters["tier"]
if filters.get("etat"):
if filters["etat"] == "eol":
where.append("s.licence_support = 'eol'")
if filters["etat"] == "obsolete":
where.append("s.licence_support = 'obsolete'")
else:
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"):
where.append("s.os_family = :os"); params["os"] = filters["os"]
if filters.get("owner"):
where.append("s.patch_os_owner = :owner"); params["owner"] = filters["owner"]
if filters.get("application"):
where.append("s.application_name = :application"); params["application"] = filters["application"]
if filters.get("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,
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.application_name,
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 Stream%' THEN
'CentOS Stream ' || 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'
'CentOS ' || COALESCE((regexp_match(s.os_version, '(\d+[\.\d]*)'))[1], '')
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 Datacenter%' THEN '2022 Datacenter'
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>
{% if s.exclusion_reason %}
<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 %}
</div>
{% endif %}

View File

@ -51,7 +51,7 @@
<th class="p-2">Rôle</th>
<th class="text-left p-2">Scopes</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>
<tbody>
{% 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-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>
{% if can_edit_contacts %}
<td class="p-2 text-center">
<div class="flex gap-1 justify-center">
<button class="btn-sm bg-cyber-border text-cyber-accent"
@ -71,12 +72,14 @@
</form>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can_edit_contacts %}
<!-- Ajouter -->
<div class="card p-4 mt-4">
<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>
</form>
</div>
{% endif %}
{% endblock %}

View File

@ -28,7 +28,7 @@
</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 %}">
<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 font-mono text-cyber-accent">{{ s.hostname }}</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"><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>
<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>
{% endfor %}
</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">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 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>
@ -61,7 +61,7 @@
<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 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>
@ -88,6 +88,46 @@
</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 %}
<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>

View File

@ -58,7 +58,7 @@
<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 %}
{% for e in ['production','implementation','stock','obsolete'] %}<option value="{{ e }}" {% if e == s.etat %}selected{% endif %}>{{ e }}</option>{% endfor %}
</select>
</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 %}
</select>
</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>
<label class="text-xs text-gray-500">Responsable</label>
<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 %}
</select>
<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 name="os" onchange="this.form.submit()"><option value="">OS</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="na" {% if filters.owner == 'na' %}selected{% endif %}>na</option>
</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>
<a href="/servers" class="text-xs text-gray-500 hover:text-gray-300">Reset</a>
</form>
{% if can_edit_servers %}
<!-- Actions groupées -->
<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>
@ -71,15 +75,16 @@
<button type="submit" class="btn-primary px-3 py-1 text-xs">Appliquer</button>
</form>
</div>
{% endif %}
<script>
const bulkValues = {
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 %}],
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"}],
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() {
const sel = document.getElementById('bulk-value');
@ -103,7 +108,7 @@ function updateBulk() {
<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>
{% 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="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>
@ -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('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 text-left">Solution applicative</th>
<th class="p-2 text-left">Équivalent(s)</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 }}" 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 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.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.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.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-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()">
{% 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>
{% else %}
<span class="text-xs text-gray-600"></span>
{% endif %}
</td>
</tr>
{% endfor %}