Noctame/preferences.html
Guillaume DENIS 572e88120e style(email-template): arrondit davantage banner et bloc de code
- banner: border-radius 14 -> 18px
- bloc de code: fond et border-radius (14px) portés par le <pre> pour un
  arrondi fiable en aperçu, padding réduit (16x18 -> 12x14)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:39:45 +02:00

4494 lines
238 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Préférences — Outils Infra</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script>
<style>
:root{
--page:#0c0d10;
--surface:#15171c;
--surface-2:#1d2026;
--surface-3:#262a32;
--ink:#f2f3f5;
--ink-2:#b8bcc4;
--muted:#7c8189;
--border:rgba(255,255,255,0.07);
--border-strong:rgba(255,255,255,0.14);
--grid:#23262d;
--accent:#5b9cf5;
--accent-soft:rgba(91,156,245,0.14);
--danger:#e66767;
--radius:12px;
--shadow:0 10px 30px rgba(0,0,0,.35);
}
*{box-sizing:border-box;margin:0;padding:0}
html{scrollbar-color:#33363e var(--page)}
body{
background:var(--page);color:var(--ink);
font-family:system-ui,-apple-system,"Segoe UI",sans-serif;
font-size:14px;display:flex;min-height:100vh;
-webkit-font-smoothing:antialiased;
}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#33363e;border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
/* ---------- sidebar ---------- */
aside{
width:232px;flex-shrink:0;background:var(--surface);
border-right:1px solid var(--border);padding:22px 14px;
display:flex;flex-direction:column;gap:3px;position:sticky;top:0;height:100vh;
}
.logo{display:flex;align-items:center;gap:10px;padding:0 10px 22px}
.logo-mark{
width:32px;height:32px;border-radius:9px;flex-shrink:0;
background:linear-gradient(135deg,var(--accent),#7c5cf5);
display:flex;align-items:center;justify-content:center;font-weight:800;font-size:15px;
}
.logo b{font-size:15px;letter-spacing:.4px}
.logo small{display:block;font-size:10px;color:var(--muted);font-weight:400;letter-spacing:1px;text-transform:uppercase}
.nav-btn{
display:flex;align-items:center;gap:11px;width:100%;
background:none;border:none;color:var(--ink-2);cursor:pointer;
padding:10px 12px;border-radius:9px;font-size:13.5px;text-align:left;
transition:background .12s,color .12s;font-family:inherit;
}
.nav-btn svg{width:17px;height:17px;flex-shrink:0;opacity:.8}
.nav-btn:hover{background:var(--surface-2);color:var(--ink)}
.nav-sep{height:1px;background:var(--border);margin:8px 4px 10px}
/* switcher projets */
.psw{display:flex;flex-direction:column;gap:3px}
.psw-title{font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px;font-weight:600;padding:0 12px 7px}
.psw a{display:flex;align-items:center;gap:10px;text-decoration:none;padding:8px 10px;border-radius:9px;color:var(--ink-2);transition:background .12s,color .12s}
.psw a:hover{background:var(--surface-2)}
.psw a.active{background:var(--accent-soft)}
.psw a.active b{color:var(--accent)}
.psw-ico{width:30px;height:30px;border-radius:8px;background:var(--surface-2);display:flex;align-items:center;justify-content:center;font-size:15px;flex-shrink:0}
.psw a.active .psw-ico{background:rgba(91,156,245,.22)}
.psw-txt{min-width:0}
.psw-txt b{display:block;font-size:13px;color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.psw-txt small{display:block;font-size:10.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.nav-btn.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
.nav-btn.active svg{opacity:1}
.sidebar-footer{margin-top:auto;display:flex;flex-direction:column;gap:7px;padding-top:14px;border-top:1px solid var(--border)}
.db-status{font-size:11px;color:var(--muted);padding:4px 8px;word-break:break-word;line-height:1.5}
/* ---------- main ---------- */
main{flex:1;padding:30px 36px;max-width:2074px;min-width:0}
.page{display:none;animation:fadein .18s ease}
.page.active{display:block}
@keyframes fadein{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.page-head{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:22px;gap:16px;flex-wrap:wrap}
.page-head h2{font-size:21px;font-weight:700;letter-spacing:-.2px}
.page-head p{color:var(--muted);font-size:13px;margin-top:5px}
.crumb{font-size:13px;color:var(--muted);margin-bottom:6px}
.crumb a{color:var(--accent);cursor:pointer;text-decoration:none}
.crumb a:hover{text-decoration:underline}
/* ---------- buttons ---------- */
.btn{
background:var(--surface-2);color:var(--ink);border:1px solid var(--border-strong);
border-radius:9px;padding:8px 15px;font-size:13px;cursor:pointer;font-family:inherit;
transition:background .12s,border-color .12s,transform .08s;
display:inline-flex;align-items:center;gap:7px;
}
.btn:hover{background:var(--surface-3);border-color:var(--muted)}
.btn:active{transform:scale(.98)}
.btn.primary{
background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600;
box-shadow:0 2px 12px rgba(91,156,245,.25);
}
.btn.primary:hover{background:#4a8ce8}
.btn.danger{color:var(--danger)}
.btn.small{padding:5px 10px;font-size:12px;border-radius:7px}
.btn.ghost{background:none;border:none;color:var(--muted);box-shadow:none}
.btn.ghost:hover{color:var(--ink);background:var(--surface-3)}
.link{color:var(--accent);cursor:pointer;font-weight:500}
.link:hover{text-decoration:underline}
/* ---------- stat tiles ---------- */
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:24px}
.tile{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:17px 19px;position:relative;
transition:border-color .15s,transform .15s;
}
.tile:hover{border-color:var(--border-strong);transform:translateY(-1px)}
.tile .label{font-size:11.5px;color:var(--muted);margin-bottom:7px;text-transform:uppercase;letter-spacing:.7px;font-weight:600}
.tile .value{font-size:30px;font-weight:700;line-height:1.05;letter-spacing:-.5px}
.tile .sub{font-size:12px;color:var(--ink-2);margin-top:7px}
.tile .t-icon{position:absolute;top:15px;right:15px;opacity:.35}
.tile .t-icon svg{width:20px;height:20px}
/* ---------- cards / tables ---------- */
.card{
background:var(--surface);border:1px solid var(--border);
border-radius:var(--radius);padding:20px;margin-bottom:20px;
}
.card h3{font-size:13px;font-weight:600;margin-bottom:14px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse}
th{
text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.8px;
color:var(--muted);font-weight:600;padding:8px 12px;border-bottom:1px solid var(--grid);
}
td{padding:11px 12px;border-bottom:1px solid var(--grid);color:var(--ink-2);vertical-align:middle;font-size:13px}
tr:last-child td{border-bottom:none}
tbody tr{transition:background .1s}
tr:hover td{background:rgba(255,255,255,0.025)}
td .name{color:var(--ink);font-weight:600}
.empty{color:var(--muted);text-align:center;padding:44px 0;font-size:13px}
.empty .big{font-size:30px;display:block;margin-bottom:10px;opacity:.5}
.empty .btn{margin-top:14px}
/* ---------- chips / dots / badges ---------- */
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;vertical-align:-1px;flex-shrink:0}
.chip{
display:inline-flex;align-items:center;gap:6px;background:var(--surface-2);
border:1px solid var(--border);border-radius:20px;padding:2px 10px;
font-size:12px;margin:2px 5px 2px 0;white-space:nowrap;
}
.chip .mac{color:var(--muted);font-size:10.5px;font-variant-numeric:tabular-nums}
.badge{
display:inline-block;padding:2.5px 10px;border-radius:6px;font-size:11px;
font-weight:600;letter-spacing:.3px;
}
.count-pill{
background:var(--surface-3);border-radius:12px;padding:1px 9px;
font-size:11px;color:var(--ink-2);font-weight:600;
}
/* Infos manquantes : alerte rouge légère quand il manque des infos */
.nav-btn.has-missing{background:rgba(230,103,103,.12);color:#e66767}
.nav-btn.has-missing:hover{background:rgba(230,103,103,.18)}
.nav-btn.has-missing .count-pill{background:rgba(230,103,103,.22);color:#e66767}
/* ---------- toolbar / inputs ---------- */
.toolbar{display:flex;gap:9px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
.filter-chips{display:flex;gap:7px;flex-wrap:wrap}
.fchip{
display:inline-flex;align-items:center;background:var(--surface-2);color:var(--ink-2);
border:1px solid var(--border-strong);border-radius:20px;padding:5px 12px;font-size:12.5px;
cursor:pointer;font-family:inherit;transition:background .12s,border-color .12s,color .12s;
}
.fchip:hover{border-color:var(--muted);color:var(--ink)}
.fchip.on{background:var(--accent-soft);border-color:var(--accent);color:var(--accent);font-weight:600}
input,select{
background:var(--surface-2);border:1px solid var(--border-strong);color:var(--ink);
border-radius:9px;padding:8px 11px;font-size:13px;font-family:inherit;height:36px;
transition:border-color .12s;
}
input::placeholder{color:var(--muted)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}
input[type=color]{padding:3px;width:48px;cursor:pointer}
input[type=number]{width:100%}
input:disabled{opacity:.45;cursor:not-allowed}
input[type=checkbox]{
-webkit-appearance:none;appearance:none;width:18px;height:18px;min-width:18px;
padding:0;border-radius:5px;border:1.5px solid var(--border-strong);background:var(--surface-2);
cursor:pointer;position:relative;transition:background .12s,border-color .12s;flex-shrink:0;
}
input[type=checkbox]:checked{background:var(--accent);border-color:var(--accent)}
input[type=checkbox]:checked::after{
content:'';position:absolute;left:5px;top:1.5px;width:4px;height:9px;
border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg);
}
textarea{
background:var(--surface-2);border:1px solid var(--border-strong);color:var(--ink);
border-radius:9px;padding:9px 11px;font-size:13px;font-family:inherit;width:100%;
min-height:66px;resize:vertical;line-height:1.5;
}
textarea::placeholder{color:var(--muted)}
.os-chips{display:flex;flex-wrap:wrap;gap:5px;margin-top:2px}
.ac-list{
position:absolute;left:0;right:0;top:100%;margin-top:4px;z-index:20;
background:var(--surface-2);border:1px solid var(--border-strong);border-radius:9px;
box-shadow:var(--shadow);overflow:hidden;max-height:220px;overflow-y:auto;
}
.ac-item{padding:8px 12px;font-size:13px;color:var(--ink-2);cursor:pointer;display:flex;align-items:center;gap:7px}
.ac-item:hover{background:var(--accent-soft);color:var(--ink)}
.ac-item b{color:var(--ink);font-weight:600}
.ac-sub{margin-left:auto;color:var(--muted);font-size:11.5px;font-variant-numeric:tabular-nums;padding-left:10px}
.mode-switch{display:inline-flex;background:var(--surface-2);border:1px solid var(--border-strong);border-radius:9px;padding:2px;height:36px;flex-shrink:0}
.mode-switch .ms-opt{border:none;background:none;color:var(--muted);font-size:11px;font-weight:700;padding:0 10px;border-radius:7px;cursor:pointer;font-family:inherit;transition:background .12s,color .12s}
.mode-switch .ms-opt.active{background:var(--accent);color:#fff}
/* ---------- modal ---------- */
.overlay{
display:none;position:fixed;inset:0;background:rgba(0,0,0,.65);
z-index:50;align-items:flex-start;justify-content:center;padding:6vh 20px;
backdrop-filter:blur(3px);
}
.overlay.open{display:flex}
.modal{
background:var(--surface);border:1px solid var(--border-strong);border-radius:16px;
width:100%;max-width:580px;max-height:86vh;overflow-y:auto;padding:26px;
box-shadow:var(--shadow);animation:pop .16s ease;
}
@keyframes pop{from{opacity:0;transform:translateY(10px) scale(.98)}to{opacity:1;transform:none}}
.modal h3{font-size:17px;margin-bottom:20px;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.field{margin-bottom:15px}
.field label{display:block;font-size:11.5px;color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
.field input,.field select{width:100%}
.field input[type=checkbox],label input[type=checkbox]{width:18px;height:18px;min-width:18px}
.field-row{display:flex;gap:12px}
.field-row .field{flex:1}
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:22px}
.ip-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.ip-row input{flex:1;min-width:0}
.ip-row select{flex:1;min-width:0}
.hint{font-size:11px;color:var(--muted);margin-top:5px;line-height:1.5}
.detail-grid{display:grid;grid-template-columns:130px 1fr;gap:9px 14px;font-size:13px;margin-bottom:6px}
.detail-grid dt{color:var(--muted)}
.detail-grid dd{color:var(--ink-2)}
/* Description repliée : 3 lignes max, déroulée au survol (« Voir plus ») */
.desc-box{white-space:pre-wrap;overflow:hidden;transition:max-height .18s ease}
.desc-box.clamped{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;position:relative;cursor:pointer}
.desc-box.clamped::after{content:'Voir plus';position:absolute;right:0;bottom:0;padding:0 2px 0 24px;
background:linear-gradient(90deg,rgba(21,23,28,0),var(--surface) 55%);color:var(--accent);font-size:11px;font-weight:600}
.desc-box.clamped:hover{-webkit-line-clamp:100}
.desc-box.clamped:hover::after{content:''}
.detail-section{margin-top:18px;padding-top:16px;border-top:1px solid var(--grid)}
.detail-section h4{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px;margin-bottom:10px}
.hosted-item{
display:flex;align-items:center;justify-content:space-between;gap:10px;
background:var(--surface-2);border:1px solid var(--border);border-radius:9px;
padding:8px 12px;margin-bottom:7px;cursor:pointer;transition:border-color .12s;
}
.hosted-item:hover{border-color:var(--accent)}
.hosted-item .hn{font-weight:600;color:var(--ink)}
.hosted-item .hi{font-size:11.5px;color:var(--muted)}
/* ---------- salles : liste ---------- */
.rooms-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px}
.room-tile{
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:20px;cursor:pointer;transition:border-color .15s,transform .15s;position:relative;
}
.room-tile:hover{border-color:var(--accent);transform:translateY(-2px)}
.room-tile .r-icon{
width:42px;height:42px;border-radius:11px;background:var(--accent-soft);
display:flex;align-items:center;justify-content:center;margin-bottom:14px;color:var(--accent);
}
.room-tile h3{font-size:15px;font-weight:600;margin-bottom:6px;color:var(--ink)}
.room-tile .r-meta{font-size:12.5px;color:var(--muted);line-height:1.6}
.room-tile .r-actions{position:absolute;top:12px;right:12px;display:flex;gap:2px;opacity:0;transition:opacity .12s}
.room-tile:hover .r-actions{opacity:1}
.room-tile .r-arrow{position:absolute;bottom:18px;right:18px;color:var(--muted)}
/* ---------- salle : vue baies ---------- */
.room-tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.room-tab{
background:var(--surface-2);border:1px solid var(--border-strong);color:var(--ink-2);
border-radius:8px;padding:7px 13px;font-size:13px;cursor:pointer;font-family:inherit;
transition:background .12s,color .12s,border-color .12s;
}
.room-tab:hover{background:var(--surface-3);color:var(--ink)}
.room-tab.on{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
.room-tab.add{color:var(--muted);font-weight:700}
.racks-row{display:flex;gap:28px;flex-wrap:wrap;align-items:flex-start}
.rack{
background:linear-gradient(180deg,var(--surface-2),var(--surface));
border:1px solid var(--border-strong);border-radius:12px;padding:14px;
box-shadow:0 4px 16px rgba(0,0,0,.25);
}
.rack-title{
font-size:12.5px;font-weight:700;margin-bottom:4px;display:flex;
justify-content:space-between;gap:10px;align-items:center;color:var(--ink);
}
.rack-sub{font-size:11px;color:var(--muted);margin-bottom:12px}
.rack-grid{display:flex;gap:7px}
.u-col{display:flex;flex-direction:column}
.u-col div{
height:22px;font-size:9px;color:var(--muted);display:flex;
align-items:center;justify-content:flex-end;padding-right:3px;width:20px;
font-variant-numeric:tabular-nums;
}
.slot-col{
position:relative;width:200px;border:1px solid var(--grid);border-radius:5px;
background:
repeating-linear-gradient(to bottom,transparent 0,transparent 21px,var(--grid) 21px,var(--grid) 22px),
linear-gradient(180deg,#101216,#0d0e12);
}
.rack-machine{
position:absolute;left:3px;right:3px;border-radius:5px;cursor:pointer;
display:flex;align-items:center;gap:6px;
padding:0 9px;font-size:11px;font-weight:600;
border:1px solid rgba(0,0,0,.3);overflow:hidden;white-space:nowrap;
transition:filter .1s,transform .1s;
}
.rack-machine:hover{filter:brightness(1.12);transform:scale(1.01)}
.rack-machine.dim{opacity:.14;filter:saturate(.3)}
.rack-machine.hit{
outline:2px solid #fff;z-index:5;
box-shadow:0 0 0 3px rgba(91,156,245,.55),0 0 14px rgba(91,156,245,.6);
}
.rack-machine .rm-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
/* points réseau à droite, remplis de droite vers gauche */
.rack-machine .net-dots{display:flex;flex-direction:row-reverse;align-items:center;gap:5px;flex:0 0 auto;margin-left:auto;overflow:hidden}
.rack-machine .net-dot{
width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--c);
border:1px solid rgba(255,255,255,.85);box-shadow:0 0 0 1px rgba(0,0,0,.35);
}
.type-counts{display:flex;gap:8px;flex-wrap:wrap}
.type-counts{display:flex;gap:8px;flex-wrap:wrap}
/* ---------- carte de survol serveur ---------- */
[data-mid]{cursor:pointer}
#hoverCard,#userCard{
position:fixed;z-index:90;pointer-events:none;min-width:200px;max-width:290px;
background:var(--surface);border:1px solid var(--border-strong);border-radius:12px;
padding:12px 13px;box-shadow:var(--shadow);font-size:12.5px;
display:none;animation:fadein .1s ease;
}
#userCard{z-index:120;max-width:320px;max-height:88vh;overflow:hidden}
#userCard .hc-head{font-weight:700;color:var(--ink);font-size:13.5px;margin-bottom:9px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
#userCard .hc-row{display:flex;gap:8px;padding:2px 0;color:var(--ink-2)}
#userCard .hc-mut{color:var(--muted)}
[data-user]{cursor:help;text-decoration:underline dotted var(--muted);text-underline-offset:3px}
#toastWrap{position:fixed;bottom:20px;right:20px;z-index:200;display:flex;flex-direction:column;gap:8px;max-width:380px}
.toast{
background:var(--surface);border:1px solid var(--border-strong);border-left:4px solid var(--accent);
border-radius:10px;padding:12px 15px;font-size:13px;color:var(--ink-2);box-shadow:var(--shadow);
animation:fadein .15s ease;line-height:1.45;
}
.toast.warn{border-left-color:var(--danger);color:var(--ink)}
.toast.info{border-left-color:var(--accent)}
#hoverCard .hc-head{font-weight:700;color:var(--ink);font-size:13.5px;margin-bottom:9px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
#hoverCard .hc-sec{margin-top:9px;padding-top:9px;border-top:1px solid var(--grid)}
#hoverCard .hc-t{font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:6px;font-weight:600}
#hoverCard .hc-row{display:flex;align-items:center;gap:6px;padding:2px 0;color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#hoverCard .hc-row .hc-mut{color:var(--muted);font-size:11px;margin-left:auto;padding-left:8px}
#hoverCard .hc-mut{color:var(--muted)}
#hoverCard .hc-desc{margin-top:9px;padding-top:9px;border-top:1px solid var(--grid);color:var(--ink-2);white-space:pre-wrap;line-height:1.5;font-size:12px}
#hoverCard .hc-mini{display:flex;gap:8px;align-items:flex-start;margin-top:2px}
#hoverCard .hc-rack{position:relative;width:120px;border:1px solid var(--grid);border-radius:4px;background:#0d0e12;flex-shrink:0}
#hoverCard .hc-slot{position:absolute;left:1px;right:1px;border-radius:2px}
#hoverCard .hc-uax{display:flex;flex-direction:column;justify-content:space-between;font-size:8px;color:var(--muted);font-variant-numeric:tabular-nums}
/* ---------- Préférences : catégories dépliantes ---------- */
.settings-cat{margin-bottom:14px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);overflow:hidden}
.cat-head{
display:flex;align-items:center;gap:12px;width:100%;background:none;border:none;cursor:pointer;
padding:16px 20px;font-family:inherit;color:var(--ink);text-align:left;transition:background .12s;
}
.cat-head:hover{background:var(--surface-2)}
.cat-ico{width:32px;height:32px;border-radius:9px;background:var(--accent-soft);color:var(--accent);
display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.cat-txt{flex:1;min-width:0}
.cat-txt b{display:block;font-size:15px;font-weight:700;letter-spacing:-.2px}
.cat-txt small{display:block;font-size:12px;color:var(--muted);margin-top:2px}
.cat-chev{width:18px;height:18px;flex-shrink:0;color:var(--muted);transition:transform .18s ease}
.settings-cat.collapsed .cat-chev{transform:rotate(-90deg)}
.cat-body{padding:0 16px 6px;border-top:1px solid var(--border)}
.settings-cat.collapsed .cat-body{display:none}
/* dans une catégorie, les cartes perdent leur fond/bordure (la catégorie les porte) */
.cat-body > .card{background:var(--surface-2);border-color:var(--border);margin:14px 0}
.cat-body > .card:first-child{margin-top:16px}
</style>
</head>
<body>
<aside>
<div class="logo">
<div class="logo-mark"></div>
<div><b>Outils Infra</b><small>portail</small></div>
</div>
<div class="psw">
<div class="psw-title">Projets</div>
<a href="cmdb.html"><div class="psw-ico"></div><div class="psw-txt"><b>CMDB</b><small>Machines, réseaux, salles</small></div></a>
<a href="../cert-generator/dashboard.html"><div class="psw-ico">🔐</div><div class="psw-txt"><b>Générateur certificats</b><small>Clés RSA, CSR, SSL</small></div></a>
<a href="../switch-config/switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
<a href="../password-generator/password_generator.html"><div class="psw-ico">🔑</div><div class="psw-txt"><b>Mots de passe</b><small>Générateur &amp; envoi</small></div></a>
</div>
<div class="psw" style="margin-top:auto;padding-top:14px;border-top:1px solid var(--border)">
<a class="active" href="preferences.html"><div class="psw-ico"></div><div class="psw-txt"><b>Préférences</b><small>Types, tickets, réglages</small></div></a>
<div class="db-status" id="dbStatus" style="margin-top:8px">chargement…</div>
</div>
</aside>
<main>
<!-- ==================== DASHBOARD ==================== -->
<div class="page active" id="page-dashboard">
<div class="page-head"><div><h2>Dashboard</h2><p>Vue d'ensemble de l'infrastructure</p></div></div>
<div class="tiles" id="dashTiles"></div>
<div class="card">
<h3>Machines par statut</h3>
<div id="dashStatus"></div>
</div>
<div class="card">
<h3>Occupation des baies</h3>
<div id="dashRacks"></div>
</div>
</div>
<!-- ==================== RESEAUX ==================== -->
<div class="page" id="page-networks">
<div class="page-head">
<div><h2>Réseaux</h2><p>Cliquez sur un réseau pour voir ses IPs</p></div>
<button class="btn primary" onclick="openNetworkModal()"> Nouveau réseau</button>
</div>
<div class="card" id="networksTable"></div>
</div>
<!-- ==================== MACHINES ==================== -->
<div class="page" id="page-machines">
<div class="page-head">
<div><h2>Machines</h2><p>Cliquez sur un nom pour ouvrir la fiche détaillée</p></div>
<button class="btn primary" onclick="openMachineModal()"> Nouvelle machine</button>
</div>
<div class="toolbar">
<input id="fName" placeholder="🔍 Nom / OS / user…" oninput="renderMachines()" style="width:180px">
<select id="fStatus" onchange="renderMachines()"><option value="">Tous les statuts</option></select>
<select id="fNet" onchange="renderMachines()"><option value="">Tous les réseaux</option></select>
<input id="fIp" placeholder="Filtre IP…" oninput="renderMachines()" style="width:130px">
<input id="fMac" placeholder="Filtre MAC…" oninput="renderMachines()" style="width:140px">
<button class="btn ghost small" onclick="resetFilters()">Réinitialiser</button>
<span class="count-pill" id="machineCount"></span>
</div>
<div class="toolbar" style="margin-top:-6px">
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;font-weight:600;margin-right:2px">Types :</span>
<div id="fTypeChips" class="filter-chips"></div>
</div>
<div class="card table-wrap" id="machinesTable"></div>
</div>
<!-- ==================== SALLES ==================== -->
<div class="page" id="page-rooms">
<div id="roomsContainer"></div>
</div>
<!-- ==================== INFOS MANQUANTES ==================== -->
<div class="page" id="page-missing">
<div class="page-head">
<div><h2>Informations manquantes</h2><p>Champs vides ou incomplets détectés dans l'inventaire</p></div>
</div>
<div class="toolbar">
<select id="fMissCat" onchange="renderMissing()">
<option value="">Toutes catégories</option>
<option value="Machine">Machine</option>
<option value="Matériel">Matériel</option>
<option value="IP">IP</option>
<option value="Réseau">Réseau</option>
</select>
<span class="count-pill" id="missingCount"></span>
</div>
<div class="card table-wrap" id="missingTable"></div>
</div>
<!-- ==================== PREFERENCES ==================== -->
<div class="page" id="page-settings">
<div class="page-head">
<div><h2>Préférences</h2><p>Types de machine, catalogue OS et réglages de base</p></div>
</div>
<!-- ===== Catégorie : Inventaire & matériel ===== -->
<div class="settings-cat" data-cat="inventaire">
<button class="cat-head" onclick="toggleCat('inventaire')">
<span class="cat-ico">🗄️</span>
<span class="cat-txt"><b>Inventaire &amp; matériel</b><small>Baies, types & statuts de machine, catalogues OS / matériel</small></span>
<svg class="cat-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="cat-body">
<div class="card">
<h3>Général</h3>
<div class="field-row" style="max-width:420px">
<div class="field" style="margin:0"><label>Hauteur de baie par défaut (U)</label>
<input type="number" id="pref_rack_u" min="1" max="60" onchange="setSetting('rack_default_u', this.value)"></div>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Types de machine</h3>
<button class="btn primary small" onclick="openTypeModal()"> Nouveau type</button>
</div>
<div class="table-wrap" id="typesContainer"></div>
<div class="hint" style="margin-top:10px">« Virtuel » = hébergé sur une autre machine. « Rackable » = peut occuper des U dans une baie. « User » = permet d'assigner un utilisateur (laptop, optiplex…).</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Statuts de machine</h3>
<button class="btn primary small" onclick="openStatusModal()"> Nouveau statut</button>
</div>
<div class="table-wrap" id="statusesContainer"></div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Catalogue OS</h3>
<button class="btn primary small" onclick="openOsModal()"> Nouvel OS</button>
</div>
<div id="osContainer"></div>
</div>
<div class="card">
<h3>Modèles de serveur</h3>
<div class="toolbar" style="margin-bottom:12px">
<input id="newBrand" placeholder="Marque (Dell, HP, Lenovo…)" style="width:200px" onkeydown="if(event.key==='Enter')addModel()">
<input id="newModel" placeholder="Modèle (PowerEdge R740…)" style="width:230px" onkeydown="if(event.key==='Enter')addModel()">
<button class="btn primary small" onclick="addModel()"> Ajouter</button>
</div>
<div class="os-chips" id="modelsList"></div>
</div>
<div class="card">
<h3>CPU</h3>
<div class="toolbar" style="margin-bottom:12px">
<input id="newCpu" placeholder="Intel Xeon Silver 4210, AMD EPYC 7302…" style="width:280px" onkeydown="if(event.key==='Enter')addCpu()">
<input id="newCpuCores" type="number" min="1" placeholder="Cores / CPU" style="width:110px" onkeydown="if(event.key==='Enter')addCpu()">
<input id="newCpuMhz" type="number" min="1" placeholder="MHz" style="width:90px" onkeydown="if(event.key==='Enter')addCpu()">
<button class="btn primary small" onclick="addCpu()"> Ajouter</button>
</div>
<div class="os-chips" id="cpusList"></div>
<div class="hint" style="margin-top:8px">Sur un serveur : choisir le CPU + nombre de sockets → total de cores calculé automatiquement.</div>
</div>
</div>
</div>
<!-- ===== Catégorie : Sauvegardes & données ===== -->
<div class="settings-cat" data-cat="donnees">
<button class="cat-head" onclick="toggleCat('donnees')">
<span class="cat-ico">💾</span>
<span class="cat-txt"><b>Sauvegardes &amp; données</b><small>Snapshots de la base, import / export CSV</small></span>
<svg class="cat-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="cat-body">
<div class="card" id="snapCard">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div><h3 style="margin:0">Sauvegardes (snapshots)</h3></div>
<button class="btn primary small" onclick="createSnapshot()"> Sauvegarder maintenant</button>
</div>
<div id="snapBody"></div>
<div class="hint" style="margin-top:10px">Copies horodatées de la base sur le serveur (dossier <code>snapshots/</code>).
Un snapshot automatique est créé après une modification, au plus une fois par heure. La restauration remplace la base
actuelle et crée d'abord un snapshot de sécurité de l'état courant.</div>
</div>
<div class="card" id="csvCard">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<div><h3 style="margin:0">Import / Export CSV</h3></div>
<div style="display:flex;gap:8px">
<button class="btn small" onclick="exportCsv()">⬇ Exporter en CSV</button>
<button class="btn primary small" onclick="document.getElementById('csvInput').click()">⬆ Importer un CSV</button>
<input id="csvInput" type="file" accept=".csv,text/csv" style="display:none"
onchange="if(this.files[0]) importCsv(this.files[0]); this.value='';">
</div>
</div>
<div class="hint">Exporte l'inventaire (machines, réseaux, IP/MAC, salles/baies) en un fichier CSV — une ligne par IP.
À l'import, les réseaux, salles, baies et machines existants sont mis à jour par correspondance de <b>nom</b>,
les nouveaux sont créés ; les IP d'une machine importée remplacent les siennes. Utile pour sauvegarder ou
migrer les données hors de la base.</div>
</div>
</div>
</div>
<!-- ===== Catégorie : Intégrations ===== -->
<div class="settings-cat" data-cat="integrations">
<button class="cat-head" onclick="toggleCat('integrations')">
<span class="cat-ico">🔗</span>
<span class="cat-txt"><b>Intégrations</b><small>Active Directory, liens vers tickets</small></span>
<svg class="cat-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="cat-body">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Lien vers ticket</h3>
<button class="btn primary small" onclick="openTicketModal()"> Nouveau ticket</button>
</div>
<div class="table-wrap" id="ticketsContainer"></div>
<div class="hint" style="margin-top:10px">Lien + modèle de description à copier pour créer un ticket (ex : changement de configuration switch). <code>{name}</code> est remplacé par le nom de la machine.</div>
</div>
<div class="card">
<h3>Active Directory (recherche d'utilisateurs)</h3>
<div class="field" style="max-width:420px"><label>Source</label>
<select id="ad_mode" onchange="saveAdSettings()">
<option value="off">Désactivé (saisie libre)</option>
<option value="ldap">Active Directory / LDAP</option>
<option value="local">Liste locale d'utilisateurs</option>
</select>
</div>
<div id="adLdapFields">
<div class="field-row">
<div class="field"><label>Label</label><input id="ad_label" placeholder="Nom de mon LDAP" onchange="saveAdSettings()"></div>
<div class="field"><label>Host</label><input id="ad_host" placeholder="ad.exemple.com" onchange="saveAdSettings()"></div>
<div class="field" style="max-width:100px"><label>Port</label><input type="number" id="ad_port" placeholder="389" onchange="saveAdSettings()"></div>
</div>
<div class="field-row">
<div class="field"><label>Méthode</label>
<select id="ad_method" onchange="saveAdSettings()">
<option value="plain">plain</option>
<option value="start_tls">start_tls</option>
<option value="ssl">ssl</option>
</select></div>
<div class="field"><label>UID (attribut identifiant)</label><input id="ad_uid" placeholder="userPrincipalName" onchange="saveAdSettings()"></div>
<div class="field" style="max-width:190px;display:flex;align-items:flex-end">
<label style="display:flex;align-items:center;gap:8px;margin:0 0 9px;text-transform:none;letter-spacing:0;font-size:13px;color:var(--ink-2);cursor:pointer">
<input type="checkbox" id="ad_verify" onchange="saveAdSettings()"> Vérifier certificats</label></div>
</div>
<div class="field"><label>Base DN</label><input id="ad_base" placeholder="OU=Users,DC=exemple,DC=com" onchange="saveAdSettings()"></div>
<div class="field-row">
<div class="field"><label>Bind DN (compte de lecture, optionnel)</label><input id="ad_bind_dn" placeholder="CN=svc-cmdb,OU=Services,DC=exemple,DC=com" onchange="saveAdSettings()"></div>
<div class="field"><label>Mot de passe bind</label><input type="password" id="ad_bind_pw" autocomplete="off" onchange="saveAdSettings()"></div>
</div>
<div class="field"><label>Filtre utilisateur (optionnel, {query} remplacé)</label>
<input id="ad_filter" placeholder="(|(cn=*{query}*)(displayName=*{query}*)(userPrincipalName=*{query}*))" onchange="saveAdSettings()"></div>
<div class="field" id="bridgeUrlField"><label>URL du connecteur (mode file:// uniquement)</label>
<input id="ad_bridge" placeholder="http://127.0.0.1:8389" onchange="saveAdSettings()"></div>
<div class="hint"><b>Recommandé :</b> lancer <b>python3 cmdb_server.py</b> (remplace python -m http.server) —
il sert la page sur http://127.0.0.1:8000 et interroge l'AD directement, aucun connecteur à configurer.
Prérequis : <b>pip install ldap3</b>. Si la page est ouverte en file://, utilisez le connecteur séparé ci-dessous.
Le mot de passe bind est stocké dans la base locale.</div>
<div style="margin-top:10px">
<button class="btn small" onclick="downloadBridge()">⬇ Télécharger le connecteur (file://)</button>
<button class="btn small" onclick="testAd()">Tester la connexion</button>
<span id="adTestResult" style="font-size:12px;margin-left:8px"></span>
</div>
<div class="field" style="margin-top:14px"><label>Test de recherche — username, nom ou prénom</label>
<div class="ip-row" style="margin:0">
<input id="ad_test_q" placeholder="ex : dupont, jdupont, Jean…" style="flex:1" onkeydown="if(event.key==='Enter')runAdTest()">
<button class="btn small" onclick="runAdTest()">Rechercher</button>
</div>
<div id="adTestList" style="margin-top:10px"></div>
</div>
</div>
<div id="adLocalFields">
<div class="field"><label>Utilisateurs (un par ligne)</label>
<textarea id="ad_users" style="min-height:110px" placeholder="Jean Dupont&#10;Marie Martin&#10;jdupont@societe.fr" onchange="saveAdSettings()"></textarea></div>
</div>
</div>
</div>
</div>
<!-- ===== Catégorie : Générateur de mots de passe ===== -->
<div class="settings-cat" data-cat="pwgen" id="pwgen">
<button class="cat-head" onclick="toggleCat('pwgen')">
<span class="cat-ico">🔑</span>
<span class="cat-txt"><b>Générateur de mots de passe</b><small>Longueurs par défaut, préfixe, templates de génération & email, SMTP</small></span>
<svg class="cat-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="cat-body">
<div class="card">
<h3>Général</h3>
<div class="field-row" style="max-width:640px">
<div class="field"><label>Longueur par défaut (mot de passe)</label>
<input type="number" id="pg_pw_len" min="4" max="128" onchange="savePwgenGeneral()"></div>
<div class="field"><label>Longueur par défaut (temporaire)</label>
<input type="number" id="pg_tmp_len" min="4" max="64" onchange="savePwgenGeneral()"></div>
<div class="field"><label>Préfixe par défaut (temporaire)</label>
<input id="pg_tmp_prefix" placeholder="ex : toto" onchange="savePwgenGeneral()"></div>
</div>
<div class="hint">Le préfixe est ajouté devant la partie aléatoire du mot de passe temporaire (ex : <code>toto</code><code>totoXk4fq2Pm</code>).</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Templates de génération</h3>
<button class="btn primary small" onclick="openPgGenTplModal()"> Nouveau template</button>
</div>
<div id="pgGenTplTable"></div>
<div class="hint" style="margin-top:10px">Préréglages de génération : classes de caractères, longueur et préfixe.
Exemple : « Simple Password » = minuscules + chiffres, pas de majuscules ni de caractères spéciaux, 6 caractères.</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
<h3 style="margin:0">Templates email — mot de passe temporaire</h3>
<button class="btn primary small" onclick="openPgEmailTplModal()"> Nouveau template</button>
</div>
<div id="pgEmailTplTable"></div>
<div class="hint" style="margin-top:10px">
Placeholders : <code>{{ prenom }}</code>, <code>{{ nom }}</code>, <code>{{ displayName }}</code>,
<code>{{ username }}</code>, <code>{{ mail }}</code>, <code>{{ tmp_password }}</code>.
Deux formats : <b>Texte brut</b> ou <b>HTML enrichi</b> (bandeau, image, listes, bloc de code, liens)
avec aperçu en direct. Voir le template « Exemple enrichi (HTML) ».
</div>
</div>
<div class="card">
<h3>Serveur SMTP (envoi des mails)</h3>
<div class="field-row">
<div class="field"><label>Hôte</label><input id="pg_smtp_host" placeholder="smtp.exemple.com" onchange="savePwgenSmtp()"></div>
<div class="field" style="max-width:100px"><label>Port</label><input type="number" id="pg_smtp_port" placeholder="587" onchange="savePwgenSmtp()"></div>
<div class="field" style="max-width:160px"><label>Sécurité</label>
<select id="pg_smtp_security" onchange="savePwgenSmtp()">
<option value="none">Aucune</option>
<option value="starttls">STARTTLS</option>
<option value="ssl">SSL/TLS</option>
</select></div>
</div>
<div class="field-row">
<div class="field"><label>Utilisateur (optionnel)</label><input id="pg_smtp_user" autocomplete="off" onchange="savePwgenSmtp()"></div>
<div class="field"><label>Mot de passe (optionnel)</label><input type="password" id="pg_smtp_pass" autocomplete="off" onchange="savePwgenSmtp()"></div>
</div>
<div class="field-row">
<div class="field"><label>Adresse expéditeur</label><input id="pg_smtp_from" placeholder="it@exemple.com" onchange="savePwgenSmtp()"></div>
<div class="field"><label>Nom expéditeur (optionnel)</label><input id="pg_smtp_from_name" placeholder="Support IT" onchange="savePwgenSmtp()"></div>
</div>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:6px">
<input id="pg_smtp_test_to" placeholder="destinataire@exemple.com" style="width:260px">
<button class="btn small" onclick="sendPwgenTestMail()">Envoyer un mail de test</button>
<span id="pgSmtpTestResult" style="font-size:12px"></span>
</div>
<div class="hint" style="margin-top:10px">Réglages partagés avec le <a href="../password-generator/password_generator.html" style="color:var(--accent)">générateur de mots de passe</a>, stockés dans la base. L'envoi passe par le serveur du portail.</div>
</div>
</div>
</div>
</div>
</main>
<div id="toastWrap"></div>
<!-- ==================== MODALS ==================== -->
<div class="overlay" id="overlay">
<div class="modal" id="modalContent"></div>
</div>
<script>
"use strict";
/* ============================================================
CMDB — single-file app. SQLite (sql.js/WASM) stocké :
- auto-sauvegarde dans IndexedDB (toujours)
- lecture/écriture directe d'un fichier .db via File System
Access API (Chrome/Edge) ou export/import manuel.
============================================================ */
let SQL, db, fileHandle = null;
let currentRoom = null; // salle ouverte (vue baies)
/* Types de machine — configurables (page Préférences), chargés depuis la DB.
virtual = hébergée sur une autre machine (pas de baie)
rackable = peut occuper des U dans une baie (physique)
assign_user = peut se voir assigner un utilisateur (laptop, optiplex…)
hosts = liste (CSV) des clés de types virtuels que ce type peut héberger
is_switch = switch réseau : accepte une conf VLAN + export vers le générateur */
let TYPES = {};
const TYPE_SEED = [
//key, label, color, virtual, rackable, assign_user, in_ad, hosts, is_switch
['vm', 'VM', '#5b9cf5', 1, 0, 0, 0, '', 0],
['lxc', 'LXC', '#4ecfa5', 1, 0, 0, 0, '', 0],
['docker', 'Docker', '#e8b34b', 1, 0, 0, 0, '', 0],
['baremetal', 'Serveur baremetal', '#a58ff0', 0, 1, 0, 0, 'vm,lxc,docker', 0],
['switch', 'Switch', '#63cdda', 0, 1, 0, 0, '', 1],
['laptop', 'Laptop', '#e884ae', 0, 0, 1, 1, '', 0],
['optiplex', 'Optiplex', '#ec8a5f', 0, 0, 1, 1, '', 0],
];
function seedTypes(){
if(all('SELECT COUNT(*) c FROM types')[0].c) return;
TYPE_SEED.forEach((t,i)=> db.run(
'INSERT INTO types(key,label,color,virtual,rackable,assign_user,in_ad,hosts,is_switch,sort) VALUES (?,?,?,?,?,?,?,?,?,?)',
[...t, i]));
}
/* Ajoute le type « switch » aux bases existantes (créées avant cette fonctionnalité). */
function seedSwitchType(){
if(all("SELECT 1 FROM types WHERE is_switch=1").length) return;
if(all("SELECT 1 FROM types WHERE key='switch'").length) return;
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM types')[0].s;
db.run("INSERT INTO types(key,label,color,virtual,rackable,assign_user,in_ad,hosts,is_switch,sort) VALUES ('switch','Switch','#63cdda',0,1,0,0,'',1,?)",[sort]);
}
function loadTypes(){
TYPES = {};
for(const r of all('SELECT * FROM types ORDER BY sort, label'))
TYPES[r.key] = { key:r.key, label:r.label, color:r.color,
virtual:!!r.virtual, rackable:!!r.rackable, assign_user:!!r.assign_user, in_ad:!!r.in_ad,
hosts:(r.hosts||'').split(',').map(s=>s.trim()).filter(Boolean), is_switch:!!r.is_switch };
}
/* Statuts de machine — configurables (page Préférences) */
let STATUSES = {};
const STATUS_SEED = [
['production','En production','#0ca30c'],
['staging', 'Pré-production','#5b9cf5'],
['test', 'En test', '#e8b34b'],
['archived', 'Archivé', '#7c8189'],
['to_delete', 'À supprimer', '#d03b3b'],
];
function seedStatuses(){
if(all('SELECT COUNT(*) c FROM statuses')[0].c) return;
STATUS_SEED.forEach((s,i)=> db.run('INSERT INTO statuses(key,label,color,sort) VALUES (?,?,?,?)',[...s,i]));
}
function loadStatuses(){
STATUSES = {};
for(const r of all('SELECT * FROM statuses ORDER BY sort, label'))
STATUSES[r.key] = { key:r.key, label:r.label, color:r.color };
}
/* Liens vers ticket — modèles configurables (page Préférences).
Extensible : « Ticket switch » seedé, d'autres types possibles ensuite.
Placeholder {name} remplacé par le nom de la machine à la copie. */
function ensureTicket(key, label, desc){
if(all('SELECT 1 FROM tickets WHERE key=?',[key]).length) return;
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM tickets')[0].s;
db.run('INSERT INTO tickets(key,label,url,description,sort) VALUES (?,?,?,?,?)',[key,label,'',desc||'',sort]);
}
function seedTickets(){
ensureTicket('switch','Ticket switch', `Changement de configuration switch {name}
Contexte :
VLAN(s) concerné(s) :
Interfaces :
Action demandée :
Fenêtre de maintenance :
Configuration :
{{ config }}`);
ensureTicket('certificat','Ticket certificat','');
}
/* Partage les tickets (url + description) avec les générateurs (switch, certificat)
via localStorage — canal commun entre les pages du même origin/file://. */
function syncTicketsToLocalStorage(){
try{
const map = {};
for(const t of all('SELECT key,label,url,description FROM tickets WHERE key IS NOT NULL AND key<>""'))
map[t.key] = { label:t.label, url:t.url||'', description:t.description||'' };
localStorage.setItem('cmdb_tickets', JSON.stringify(map));
}catch(e){ /* localStorage indisponible : ignore */ }
}
function statusBadge(k){
const s = STATUSES[k];
if(!s) return '';
return `<span class="badge" style="background:${s.color}22;color:${s.color};border:1px solid ${s.color}55">${esc(s.label)}</span>`;
}
/* Palette flat pastel pour attribution auto des couleurs réseau */
const PASTELS = ['#74b9ff','#55efc4','#ffeaa7','#fab1a0','#a29bfe','#fd79a8',
'#81ecec','#fdcb6e','#b8e994','#e77f67','#cf9ff5','#78e08f',
'#f8a5c2','#63cdda','#f5cd79','#ea8685'];
/* Catalogue OS par défaut (seed au 1er lancement, éditable ensuite) */
const OS_SEED = {
'Debian': ['13 (Trixie)','12 (Bookworm)','11 (Bullseye)','10 (Buster)'],
'Ubuntu Server': ['24.04 LTS','22.04 LTS','20.04 LTS','24.10'],
'Ubuntu': ['24.04 LTS','22.04 LTS','20.04 LTS'],
'Proxmox VE': ['8.2','8.1','8.0','7.4'],
'Proxmox Backup Server': ['3.2','3.1','2.4'],
'Windows Server': ['2025','2022','2019','2016','2012 R2'],
'Windows': ['11','10'],
'Red Hat Enterprise Linux': ['9','8','7'],
'Rocky Linux': ['9','8'],
'AlmaLinux': ['9','8'],
'CentOS Stream': ['9','8'],
'Fedora Server': ['41','40','39'],
'openSUSE Leap': ['15.6','15.5'],
'SUSE Linux Enterprise': ['15 SP6','15 SP5'],
'Alpine Linux': ['3.20','3.19','3.18'],
'Arch Linux': ['rolling'],
'VMware ESXi': ['8.0','7.0','6.7'],
'TrueNAS SCALE': ['24.10','24.04','23.10'],
'TrueNAS CORE': ['13.0'],
'pfSense': ['2.7.2','2.7.0'],
'OPNsense': ['24.7','24.1'],
'FreeBSD': ['14.1','13.3'],
'macOS': ['15 Sequoia','14 Sonoma','13 Ventura'],
'Synology DSM': ['7.2','7.1'],
'Unraid': ['6.12','6.11'],
'Raspberry Pi OS': ['12 (Bookworm)','11 (Bullseye)'],
'Autre': [],
};
/* ---------------- validation réseau / IP ---------------- */
function ipToInt(s){
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec((s||'').trim());
if(!m) return null;
const p = m.slice(1).map(Number);
if(p.some(o => o > 255)) return null;
return (((p[0]<<24)>>>0) + (p[1]<<16) + (p[2]<<8) + p[3]) >>> 0;
}
function validIp(s){ return ipToInt(s) !== null; }
function parseCidr(s){
const m = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\/(\d{1,2})$/.exec((s||'').trim());
if(!m) return null;
const base = ipToInt(m[1]), bits = Number(m[2]);
if(base === null || bits > 32) return null;
const mask = bits === 0 ? 0 : (0xFFFFFFFF << (32-bits)) >>> 0;
return { base:(base & mask) >>> 0, bits, mask };
}
function ipInCidr(ip, cidr){
const c = parseCidr(cidr), i = ipToInt(ip);
return c && i !== null && ((i & c.mask) >>> 0) === c.base;
}
function findNetworkForIp(ip){
if(ipToInt(ip) === null) return null;
return all('SELECT id, subnet FROM networks WHERE subnet IS NOT NULL')
.find(n => ipInCidr(ip, n.subnet)) || null;
}
function ensureColumn(table, col, def){
const cols = all(`PRAGMA table_info(${table})`).map(c => c.name);
if(!cols.includes(col)) db.run(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
}
function seedOs(){
if(all('SELECT COUNT(*) c FROM os_catalog')[0].c) return;
for(const [name, vers] of Object.entries(OS_SEED)){
db.run('INSERT INTO os_catalog(name) VALUES (?)',[name]);
const oid = all('SELECT last_insert_rowid() i')[0].i;
vers.forEach(v => db.run('INSERT INTO os_versions(os_id,version) VALUES (?,?)',[oid,v]));
}
}
function getSetting(k, def){ const r = all('SELECT value FROM settings WHERE key=?',[k]); return r.length ? r[0].value : def; }
function setSetting(k, v){ run('INSERT INTO settings(key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',[k,String(v)]); }
function slug(s){ return s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g,'').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'') || 'type'; }
function migrateSchema(){
ensureColumn('machines','description','TEXT');
ensureColumn('machines','assigned_user','TEXT');
ensureColumn('machines','status','TEXT');
ensureColumn('networks','description','TEXT');
ensureColumn('networks','vlan','INTEGER');
ensureColumn('ips','dhcp','INTEGER DEFAULT 0');
// câble de sécurité (types assignables user)
ensureColumn('machines','cable_number','TEXT');
ensureColumn('machines','cable_code','TEXT');
// matériel (serveurs physiques rackables)
ensureColumn('machines','model','TEXT');
ensureColumn('machines','service_tag','TEXT');
ensureColumn('machines','service_tag_url','TEXT');
ensureColumn('machines','cpu','TEXT');
ensureColumn('machines','cpu_cores','INTEGER');
ensureColumn('machines','cpu_mhz','INTEGER');
ensureColumn('machines','ram_gb','INTEGER');
ensureColumn('machines','support_start','TEXT');
ensureColumn('machines','support_end','TEXT');
ensureColumn('machines','idrac_ip','TEXT');
ensureColumn('machines','cpu_sockets','INTEGER');
ensureColumn('hw_cpus','cores','INTEGER'); // cores par CPU
ensureColumn('hw_cpus','mhz','INTEGER');
ensureColumn('hw_models','brand','TEXT'); // marque séparée du modèle
ensureColumn('machines','brand','TEXT');
ensureColumn('machines','no_warranty','INTEGER'); // hors garantie
ensureColumn('types','in_ad','INTEGER'); // type présent dans l'AD (postes)
db.run("UPDATE types SET in_ad=1 WHERE key IN ('laptop','optiplex') AND in_ad IS NULL");
// capacité d'hébergement (liste de clés de types virtuels hébergeables) + type switch
ensureColumn('types','hosts','TEXT');
ensureColumn('types','is_switch','INTEGER DEFAULT 0');
// un hôte historique (baremetal) héberge par défaut tous les types virtuels connus
db.run("UPDATE types SET hosts='vm,lxc,docker' WHERE key='baremetal' AND hosts IS NULL");
ensureColumn('machines','switch_config','TEXT'); // conf collée pour les switchs
ensureColumn('machines','stack_parent','INTEGER'); // membre d'un stack → id du switch principal
ensureColumn('machines','stack_index','INTEGER'); // n° de membre dans le stack (2,3,…)
ensureColumn('machines','cert_ref','TEXT'); // certificat mappé (dossier/CN côté générateur)
// seedSwitchType() est appelé APRÈS seedTypes() (voir init) : sinon la ligne
// « switch » ferait croire à seedTypes que la table est déjà peuplée.
// purge des lignes IP fantômes : statiques sans adresse (ip vide/NULL et pas DHCP)
db.run("DELETE FROM ips WHERE COALESCE(dhcp,0)=0 AND (ip IS NULL OR TRIM(ip)='')");
}
/* ---------------- Toasts ---------------- */
function showToast(msg, type='info', ms=5000){
const wrap = document.getElementById('toastWrap');
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
wrap.appendChild(t);
setTimeout(()=>{ t.style.transition='opacity .3s'; t.style.opacity='0'; setTimeout(()=>t.remove(),300); }, ms);
}
/* ---------------- IndexedDB helpers ---------------- */
function idbOpen(){
return new Promise((res,rej)=>{
const r = indexedDB.open('cmdb-store',1);
r.onupgradeneeded = ()=> r.result.createObjectStore('kv');
r.onsuccess = ()=> res(r.result);
r.onerror = ()=> rej(r.error);
});
}
async function idbGet(key){
const d = await idbOpen();
return new Promise((res,rej)=>{
const t = d.transaction('kv','readonly').objectStore('kv').get(key);
t.onsuccess = ()=> res(t.result); t.onerror = ()=> rej(t.error);
});
}
async function idbSet(key,val){
const d = await idbOpen();
return new Promise((res,rej)=>{
const t = d.transaction('kv','readwrite').objectStore('kv').put(val,key);
t.onsuccess = ()=> res(); t.onerror = ()=> rej(t.error);
});
}
/* ---------------- DB core ---------------- */
const SCHEMA = `
CREATE TABLE IF NOT EXISTS networks(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, subnet TEXT, gateway TEXT, color TEXT DEFAULT '#74b9ff', vlan INTEGER, description TEXT);
CREATE TABLE IF NOT EXISTS os_catalog(
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS os_versions(
id INTEGER PRIMARY KEY AUTOINCREMENT, os_id INTEGER NOT NULL, version TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS types(
key TEXT PRIMARY KEY, label TEXT NOT NULL, color TEXT DEFAULT '#5b9cf5',
virtual INTEGER DEFAULT 0, rackable INTEGER DEFAULT 0, assign_user INTEGER DEFAULT 0, in_ad INTEGER DEFAULT 0,
hosts TEXT, is_switch INTEGER DEFAULT 0, sort INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS statuses(
key TEXT PRIMARY KEY, label TEXT NOT NULL, color TEXT DEFAULT '#5b9cf5', sort INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS tickets(
id INTEGER PRIMARY KEY AUTOINCREMENT, key TEXT, label TEXT NOT NULL, url TEXT, description TEXT, sort INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS hw_models(
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS hw_cpus(
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE IF NOT EXISTS rooms(
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS racks(
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL, name TEXT NOT NULL, height_u INTEGER NOT NULL DEFAULT 42);
CREATE TABLE IF NOT EXISTS machines(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, type TEXT NOT NULL,
host_id INTEGER, size_u INTEGER, rack_id INTEGER, position_u INTEGER,
os TEXT, os_version TEXT, description TEXT);
CREATE TABLE IF NOT EXISTS ips(
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id INTEGER NOT NULL, network_id INTEGER, ip TEXT, mac TEXT, dhcp INTEGER DEFAULT 0);
`;
/* Cache de statements préparés : évite de recompiler le même SQL à chaque appel
(gros gain, des centaines de requêtes par rendu). Invalidé automatiquement
quand la connexion `db` est remplacée (identité comparée). Réentrance sur le
même SQL gérée par un statement temporaire. */
let _stmtCache = new Map();
function all(sql, params=[]){
// PRAGMA (schéma) : jamais caché — le résultat change après un ALTER TABLE.
if(/^\s*PRAGMA/i.test(sql)){
const st = db.prepare(sql); st.bind(params);
const out = []; while(st.step()) out.push(st.getAsObject()); st.free(); return out;
}
let e = _stmtCache.get(sql);
if(!e || e.db !== db){ e = { db, st: db.prepare(sql), busy:false }; _stmtCache.set(sql, e); }
if(e.busy){ // appel imbriqué avec le même SQL
const st = db.prepare(sql); st.bind(params);
const out = []; while(st.step()) out.push(st.getAsObject()); st.free(); return out;
}
const st = e.st; e.busy = true;
try{
st.reset(); st.bind(params);
const out = [];
while(st.step()) out.push(st.getAsObject());
return out; // pas de free : statement réutilisé
} finally { e.busy = false; }
}
function run(sql, params=[]){ db.run(sql, params); persist(); }
/* sql.js export() ferme/rouvre la connexion et libère TOUS les statements :
on vide donc le cache juste après (les statements cachés sont devenus invalides).
L'export a lieu entre une mutation et le rendu, jamais pendant un rendu — le
gain du cache (réutilisation intra-rendu) est préservé. */
function dbExport(){ const b = db.export(); _stmtCache.clear(); return b; }
/* ============================================================
Persistance
- serveur (page servie en http via cmdb_server.py) : base SQLite
PARTAGÉE, multi-utilisateur, concurrence optimiste + auto-refresh
- hors serveur (file://) : IndexedDB local + fichier .db optionnel
============================================================ */
let SERVER_MODE = location.protocol.startsWith('http'); // désactivé au boot si /db absent (ex: python -m http.server)
let dbVersion = 0, pushing = false, pushPending = false, applyingRemote = false;
async function persist(){
if(applyingRemote) return; // écriture issue d'un rechargement distant → ne pas re-pousser
if(SERVER_MODE){ schedulePush(); return; }
const bytes = dbExport();
await idbSet('db', bytes);
if(fileHandle){
try{
const w = await fileHandle.createWritable();
await w.write(bytes); await w.close();
}catch(e){ console.warn('Écriture fichier échouée', e); }
}
updateStatus();
}
function schedulePush(){ if(pushing){ pushPending = true; return; } doPush(); }
async function doPush(){
pushing = true; pushPending = false;
const bytes = dbExport();
try{
const res = await fetch('/db', {
method:'POST',
headers:{ 'Content-Type':'application/octet-stream', 'X-Base-Version':String(dbVersion) },
body: bytes,
});
if(res.status === 409){
const latest = new Uint8Array(await res.arrayBuffer());
dbVersion = +(res.headers.get('X-DB-Version') || dbVersion);
applyServerDb(latest);
showToast('⚠ Conflit : un autre utilisateur a modifié la base au même moment. '
+ 'Les données ont été rechargées — refaites votre dernière modification.', 'warn', 8000);
pushPending = false; // notre écriture est abandonnée (rejetée)
}else if(res.ok){
dbVersion = +(res.headers.get('X-DB-Version') || dbVersion + 1);
}else{
showToast('⚠ Échec d\'enregistrement (HTTP '+res.status+')', 'warn');
}
}catch(e){
showToast('⚠ Serveur injoignable — modifications non enregistrées', 'warn');
}
pushing = false;
updateStatus();
if(pushPending) doPush();
}
/* Remplace la base locale par celle du serveur (rechargement distant) */
function applyServerDb(bytes){
applyingRemote = true;
try{
if(db) db.close();
db = bytes && bytes.length ? new SQL.Database(bytes) : new SQL.Database();
db.run(SCHEMA);
loadTypes(); loadStatuses();
renderAll();
} finally { applyingRemote = false; }
}
/* Poll léger : recharge automatiquement si un autre utilisateur a écrit */
function startSync(){
setInterval(async ()=>{
if(pushing || applyingRemote) return;
try{
const r = await fetch('/db/version', { cache:'no-store' });
const { version } = await r.json();
if(version !== dbVersion){
const r2 = await fetch('/db', { cache:'no-store' });
const buf = new Uint8Array(await r2.arrayBuffer());
dbVersion = +(r2.headers.get('X-DB-Version') || version);
applyServerDb(buf);
showToast('↻ Base mise à jour par un autre utilisateur', 'info', 3000);
}
}catch(e){ /* serveur momentanément indispo */ }
}, 4000);
}
function updateStatus(){
const el = document.getElementById('dbStatus');
if(SERVER_MODE){
el.textContent = pushing ? '⇅ Enregistrement…' : '● Base partagée (v' + dbVersion + ')';
}else{
el.textContent = fileHandle
? '● Fichier : ' + fileHandle.name + ' (auto)'
: '● IndexedDB (auto) — exporter pour un fichier .db';
}
// Publie le statut/version DB pour les autres pages (switch, cert)
try{ localStorage.setItem('cmdb_db_status', el.textContent); }catch(e){}
}
async function boot(){
SQL = await initSqlJs({ locateFile: f => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/${f}` });
let initial = null;
if(SERVER_MODE){
try{
const r = await fetch('/db', { cache:'no-store' });
if(!r.ok || !r.headers.get('X-DB-Version')) throw new Error('pas de /db');
initial = new Uint8Array(await r.arrayBuffer());
dbVersion = +(r.headers.get('X-DB-Version') || 0);
}catch(e){
// servi par un serveur statique (python -m http.server) → mode local
SERVER_MODE = false;
}
}
if(!SERVER_MODE && !initial){
const saved = await idbGet('db');
if(saved) initial = new Uint8Array(saved);
}
const fresh = !(initial && initial.length);
db = fresh ? new SQL.Database() : new SQL.Database(initial);
db.run(SCHEMA);
migrateSchema(); // migration bases existantes
seedOs(); seedTypes(); seedSwitchType(); loadTypes(); seedStatuses(); loadStatuses(); seedTickets(); syncTicketsToLocalStorage();
// serveur : ne pousser que si base vide (init) — sinon on garderait la version
if(!SERVER_MODE || fresh) await persist();
pgSeedExampleOnce(); // ajoute l'exemple de template HTML dans la base partagée (une fois)
renderAll();
updateStatus();
nav('settings'); // page dédiée Préférences : n'affiche que les réglages
focusHashCat(); // deep-link #pwgen (ou autre) : déplie + défile vers la catégorie
if(SERVER_MODE) startSync();
}
/* ---------------- fichier .db ---------------- */
async function openDbFile(){
if(window.showOpenFilePicker){
try{
[fileHandle] = await showOpenFilePicker({
types:[{ description:'SQLite', accept:{'application/octet-stream':['.db','.sqlite','.sqlite3']} }]
});
const f = await fileHandle.getFile();
loadBytes(new Uint8Array(await f.arrayBuffer()));
}catch(e){ /* annulé */ }
}else{
document.getElementById('fileInput').click();
}
}
async function importFromInput(inp){
const f = inp.files[0]; if(!f) return;
loadBytes(new Uint8Array(await f.arrayBuffer()));
inp.value = '';
}
function loadBytes(bytes){
db.close();
db = new SQL.Database(bytes);
db.run(SCHEMA);
migrateSchema();
seedOs(); seedTypes(); seedSwitchType(); loadTypes(); seedStatuses(); loadStatuses(); seedTickets(); syncTicketsToLocalStorage();
persist();
renderAll();
}
function exportDb(){
const bytes = dbExport();
if(fileHandle){ persist(); return; }
const blob = new Blob([bytes], {type:'application/octet-stream'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'cmdb.db';
a.click(); URL.revokeObjectURL(a.href);
}
/* ---------------- utils ---------------- */
function esc(s){ return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function currentPage(){
const el = document.querySelector('.page.active');
return el ? el.id.replace('page-','') : 'dashboard';
}
function renderPage(page){
switch(page){
case 'networks': renderNetworks(); break;
case 'machines': renderMachines(); break;
case 'rooms': renderRooms(); break;
case 'settings': renderSettings(); break;
case 'missing': renderMissing(); break;
default: renderDashboard();
}
}
function nav(page){
if(page === 'rooms') currentRoom = null;
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.page === page));
document.getElementById('page-'+page).classList.add('active');
renderPage(page); // rendu à la navigation → renderAll n'a plus à tout rendre
}
function closeModal(){ document.getElementById('overlay').classList.remove('open'); }
function openModal(html){
document.getElementById('modalContent').innerHTML = html;
document.getElementById('overlay').classList.add('open');
}
function typeBadge(t){
const m = TYPES[t] || {label:t, color:'#7c8189'};
return `<span class="badge" style="background:${m.color}22;color:${m.color};border:1px solid ${m.color}44">${esc(m.label)}</span>`;
}
/* Encre lisible (noir/blanc) selon luminance du fond */
function inkFor(hex){
const [r,g,b] = [1,3,5].map(i => parseInt(hex.slice(i,i+2),16)/255);
return (0.299*r + 0.587*g + 0.114*b) > 0.55 ? '#15171c' : '#fff';
}
function machineColor(m){
// couleur = type de machine (baies + mini-baie survol)
return (TYPES[m.type]||{}).color || '#7c8189';
}
/* Prochaine couleur pastel non utilisée */
function nextPastel(){
const used = all('SELECT color FROM networks').map(r => (r.color||'').toLowerCase());
return PASTELS.find(c => !used.includes(c))
|| PASTELS[all('SELECT COUNT(*) c FROM networks')[0].c % PASTELS.length];
}
/* Filtre type = multi-sélection par chips (état conservé entre re-rendus) */
let fTypeSel = new Set();
function fillTypeFilter(){
const box = document.getElementById('fTypeChips'); if(!box) return;
const keys = Object.keys(TYPES);
for(const k of [...fTypeSel]) if(!keys.includes(k)) fTypeSel.delete(k); // type supprimé
box.innerHTML = Object.entries(TYPES).map(([k,v])=>{
const on = fTypeSel.has(k);
return `<button type="button" class="fchip${on?' on':''}" onclick="toggleTypeFilter('${k}')">
<span class="dot" style="background:${v.color};margin-right:6px"></span>${esc(v.label)}</button>`;
}).join('');
}
function toggleTypeFilter(k){
if(fTypeSel.has(k)) fTypeSel.delete(k); else fTypeSel.add(k);
fillTypeFilter(); renderMachines();
}
function fillStatusFilter(){
const sel = document.getElementById('fStatus');
const cur = sel.value;
sel.innerHTML = '<option value="">Tous les statuts</option>' +
Object.values(STATUSES).map(s => `<option value="${s.key}">${esc(s.label)}</option>`).join('');
sel.value = cur;
}
function fillNetFilter(){
const sel = document.getElementById('fNet');
const cur = sel.value;
sel.innerHTML = '<option value="">Tous les réseaux</option>' +
all('SELECT id,name FROM networks ORDER BY name')
.map(n => `<option value="${n.id}">${esc(n.name)}</option>`).join('');
sel.value = cur;
}
function resetFilters(){
['fName','fIp','fMac'].forEach(i => document.getElementById(i).value = '');
['fStatus','fNet'].forEach(i => document.getElementById(i).value = '');
fTypeSel.clear(); fillTypeFilter();
renderMachines();
}
/* Ne rend que la page visible (+ filtres + badge « Infos manquantes »).
Les autres pages sont rendues à la volée par nav() quand on y va. */
function renderAll(){
fillTypeFilter(); fillStatusFilter(); fillNetFilter();
const p = currentPage();
renderMissing(); // maintient le badge rouge même hors page
if(p !== 'missing') renderPage(p);
}
/* ============================================================
PREFERENCES — général + types + OS
============================================================ */
/* ============================================================
INFOS MANQUANTES — audit de l'inventaire
============================================================ */
function collectMissing(){
const rows = [];
const g = ipsGrouped();
const netBadge = '<span class="badge" style="background:#5b9cf522;color:#5b9cf5;border:1px solid #5b9cf544">Réseau</span>';
for(const m of all('SELECT * FROM machines ORDER BY name COLLATE NOCASE')){
const td = TYPES[m.type] || {};
const badge = typeBadge(m.type);
const openM = `machineDetail(${m.id})`;
const editM = `openMachineModal(${m.id})`;
const add = (cat, msg, act) => rows.push({ name:m.name, cat, badge, msg, act });
if(td.virtual && !m.host_id) add('Machine','Hôte non défini — où est hébergée la VM/LXC ?', editM);
if(!td.virtual && td.rackable){
if(m.size_u == null) add('Machine','Nombre de U non défini', editM);
if(!m.rack_id) add('Machine','Non placée en baie', editM);
/* matériel : une ligne listant tous les champs vides — pas pour les switchs */
if(!td.is_switch){
const hwMiss = [];
if(!m.brand && !m.model) hwMiss.push('marque/modèle');
if(!m.service_tag) hwMiss.push('service tag');
if(!m.cpu) hwMiss.push('CPU');
if(m.cpu_sockets==null) hwMiss.push('sockets');
if(m.cpu_cores==null) hwMiss.push('cores');
if(m.cpu_mhz==null) hwMiss.push('MHz');
if(m.ram_gb==null) hwMiss.push('RAM');
if(!m.no_warranty){
if(!m.support_start) hwMiss.push('début de support');
if(!m.support_end) hwMiss.push('fin de support');
}
if(!m.idrac_ip) hwMiss.push('IP iDRAC');
if(hwMiss.length) add('Matériel','Manque : '+hwMiss.join(', '), editM);
}
}
if(td.assign_user && !m.assigned_user) add('Machine','Utilisateur non assigné', editM);
if(!m.os && !td.is_switch) add('Machine','OS non défini', editM); // switch : pas d'OS
if(!m.status) add('Machine','Statut non défini', editM);
const mIps = g[m.id] || [];
if(!mIps.length) add('Machine','Aucune adresse IP', editM);
for(const i of mIps){
const ref = i.dhcp ? `DHCP${i.net_name?' / '+i.net_name:''}` : (i.ip||'?');
if(!i.network_id) add('IP', `IP ${ref} sans réseau associé`, editM);
// switch : pas de MAC attendue
if(!i.dhcp && !i.mac && !td.is_switch) add('IP', `Adresse MAC manquante pour ${i.ip}${i.net_name?' ('+i.net_name+')':''}`, editM);
}
}
for(const n of all('SELECT * FROM networks ORDER BY name COLLATE NOCASE')){
const act = `networkDetail(${n.id})`;
const add = msg => rows.push({ name:n.name, cat:'Réseau', badge:netBadge, msg, act });
if(!n.subnet) add('Subnet manquant');
}
return rows;
}
function renderMissing(){
const el = document.getElementById('missingTable'); if(!el) return;
let rows = collectMissing();
const total = rows.length;
const badge = document.getElementById('missingBadge');
if(badge){ badge.textContent = total; badge.style.display = total ? '' : 'none'; }
const missBtn = document.querySelector('.nav-btn[data-page="missing"]');
if(missBtn) missBtn.classList.toggle('has-missing', total > 0);
// Page masquée : le badge suffit, inutile de construire le tableau.
const pm = document.getElementById('page-missing');
if(pm && !pm.classList.contains('active')) return;
const cat = document.getElementById('fMissCat').value;
if(cat) rows = rows.filter(r => r.cat === cat);
document.getElementById('missingCount').textContent = rows.length + ' élément(s)';
el.innerHTML = rows.length ? `<table>
<tr><th>Élément</th><th>Catégorie</th><th>Information manquante</th><th style="width:90px"></th></tr>
${rows.map(r=>`<tr>
<td><span class="name link" onclick="${r.act}">${esc(r.name)}</span></td>
<td>${r.badge}</td>
<td style="color:var(--ink-2)">⚠ ${esc(r.msg)}</td>
<td style="text-align:right"><button class="btn small" onclick="${r.act}">Corriger</button></td>
</tr>`).join('')}
</table>` : `<div class="empty"><span class="big">✅</span>Aucune information manquante. Inventaire complet !</div>`;
}
/* ============================================================
PREFERENCES — général + types + OS
============================================================ */
function renderSettings(){
const el = document.getElementById('pref_rack_u');
if(el) el.value = getSetting('rack_default_u', 42);
renderTypesConfig();
renderStatusesConfig();
renderTicketsConfig();
renderOsCatalog();
renderHwCatalogs();
renderAdSettings();
renderSnapshots();
renderPwgenSettings();
applyCatState();
}
/* ---------------- Catégories dépliantes (état mémorisé) ---------------- */
function catState(){ try{ return JSON.parse(localStorage.getItem('pref_cats')||'{}'); }catch(e){ return {}; } }
function toggleCat(id){
const cat = document.querySelector('.settings-cat[data-cat="'+id+'"]');
if(!cat) return;
const collapsed = cat.classList.toggle('collapsed');
const st = catState(); st[id] = collapsed;
try{ localStorage.setItem('pref_cats', JSON.stringify(st)); }catch(e){}
}
function applyCatState(){
const st = catState();
document.querySelectorAll('.settings-cat').forEach(cat => {
// repliées par défaut ; l'état mémorisé (ouvert/fermé) prime s'il existe
const collapsed = (cat.dataset.cat in st) ? !!st[cat.dataset.cat] : true;
cat.classList.toggle('collapsed', collapsed);
});
}
/* deep-link : #<catégorie> déplie la catégorie ciblée et défile jusqu'à elle */
function focusHashCat(){
const id = (location.hash || '').replace('#','');
if(!id) return;
const cat = document.querySelector('.settings-cat[data-cat="'+id+'"]');
if(!cat) return;
cat.classList.remove('collapsed');
setTimeout(() => cat.scrollIntoView({ behavior:'smooth', block:'start' }), 120);
}
window.addEventListener('hashchange', focusHashCat);
/* ==========================================================================
Réglages du générateur de mots de passe (stockés dans settings['pwgen_prefs']).
Édités ici (préférences globales), lus par la page password-generator.
========================================================================== */
const PWGEN_KEY = 'pwgen_prefs';
const PG_DEF_EMAIL_TPL = {
id: 'default', format: 'text', label: 'Mot de passe temporaire',
subject: 'Votre mot de passe temporaire',
body: 'Bonjour {{ prenom }},\n\nVoici ton mot de passe temporaire : {{ tmp_password }}\n\nMerci de le changer dès ta première connexion.\n\nLe support IT',
};
/* Exemple de template HTML enrichi : banner, image (data URI), listes, bloc de code… */
const PG_EXAMPLE_HTML_TPL = {
id: 'example_html', format: 'html', label: 'Exemple enrichi (HTML)',
subject: 'Bienvenue {{ prenom }} — vos accès',
body: [
':::banner #5b9cf5',
'Bienvenue chez ACME, {{ prenom }} 👋',
':::',
'',
'![Logo ACME](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxODAiIGhlaWdodD0iNDgiPjxyZWN0IHdpZHRoPSIxODAiIGhlaWdodD0iNDgiIHJ4PSI4IiBmaWxsPSIjNWI5Y2Y1Ii8+PHRleHQgeD0iOTAiIHk9IjMxIiBmb250LWZhbWlseT0iQXJpYWwsc2Fucy1zZXJpZiIgZm9udC1zaXplPSIyMCIgZmlsbD0iI2ZmZmZmZiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC13ZWlnaHQ9ImJvbGQiPkFDTUUgwrcgSVQ8L3RleHQ+PC9zdmc+)',
'',
'## Vos identifiants',
'',
'Bonjour **{{ displayName }}**, votre compte a bien été créé. Voici vos informations de connexion :',
'',
'- Nom dutilisateur : `{{ username }}`',
'- Adresse email : `{{ mail }}`',
'- Mot de passe temporaire : {{ tmp_password }}',
'',
'## Première connexion',
'',
'1. Rendez-vous sur [le portail](https://portail.exemple.com)',
'2. Connectez-vous avec les identifiants ci-dessus',
'3. Changez votre mot de passe immédiatement',
'',
'### Exemple en ligne de commande',
'',
'```',
'ssh {{ username }}@serveur.exemple.com',
'passwd # changez votre mot de passe',
'```',
'',
'## Accès au bureau à distance',
'',
'Le fichier de connexion RDP est joint à ce mail : ouvrez-le pour lancer directement la session.',
'',
'Voir aussi [la documentation daccès distant](https://portail.exemple.com/vpn).',
'',
'---',
'',
'Besoin daide ? Contactez le **support IT**.',
].join('\n'),
attachments: [{ name: 'connexion.rdp', url: '/password-generator/files/connexion.rdp' }],
};
/* ---- Rendu email HTML enrichi (markdown léger → HTML « email-safe »).
Fonction partagée à l'identique avec la page password-generator. Les
{{ placeholders }} sont substitués en dernier (valeurs échappées). ---- */
function emailEsc(s){ return String(s == null ? '' : s).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
/* URL sûre en attribut : whitelist de scheme + échappe les guillemets.
Neutralise javascript:, data:text/html, vbscript:… → « # » si scheme non autorisé.
(emailEsc a déjà neutralisé & < > ; il reste les " qui casseraient l'attribut.) */
function emailUrl(u){
const s = String(u == null ? '' : u).trim();
const sch = /^([a-z][a-z0-9+.\-]*):/i.exec(s);
const ok = !sch || /^(https?|mailto|tel)$/i.test(sch[1]) || /^data:image\//i.test(s);
return (ok ? s : '#').replace(/"/g, '&quot;');
}
function emailFillVars(html, ctx){
return html.replace(/\{\{\s*(\w+)\s*\}\}/g, (m, k) => {
if(!(k in ctx)) return m;
const v = emailEsc(ctx[k]);
return k === 'tmp_password'
? '<strong><code style="font-family:ui-monospace,Consolas,monospace;background:#f2f2f2;color:#222;padding:2px 6px;border-radius:4px;font-size:14px">' + v + '</code></strong>'
: v;
});
}
function renderEmailHtml(src, ctx){
const lines = emailEsc(src).split('\n');
const inline = t => t
.replace(/`([^`]+)`/g, '<code style="font-family:ui-monospace,Consolas,monospace;background:#f2f2f2;color:#222;padding:1px 5px;border-radius:4px">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (mm, txt, url) => '<a href="' + emailUrl(url) + '" style="color:#2f6fd8;text-decoration:underline">' + txt + '</a>');
let html = '', i = 0, m;
while(i < lines.length){
const line = lines[i];
if(m = line.match(/^:::\s*banner\s*(#[0-9a-fA-F]{3,8})?\s*$/)){
const color = m[1] || '#5b9cf5', buf = [];
for(i++; i < lines.length && !/^:::\s*$/.test(lines[i]); i++) buf.push(lines[i]);
i++;
html += '<div style="background:' + color + ';color:#fff;padding:24px 28px;border-radius:18px;font-size:20px;font-weight:700;line-height:1.35;margin:0 0 18px">' + inline(buf.join('<br>')) + '</div>';
continue;
}
if(/^```/.test(line)){
const buf = [];
for(i++; i < lines.length && !/^```/.test(lines[i]); i++) buf.push(lines[i]);
i++;
html += '<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 16px"><tr><td style="padding:0"><pre style="margin:0;background:#1d2026;color:#e6e6e6;padding:12px 14px;border-radius:14px;overflow-x:auto;font-family:ui-monospace,Consolas,monospace;font-size:13px;line-height:1.55"><code>' + buf.join('\n') + '</code></pre></td></tr></table>';
continue;
}
if(m = line.match(/^!\[([^\]]*)\]\(([^)]+)\)\s*$/)){
html += '<div style="margin:0 0 16px"><img src="' + emailUrl(m[2]) + '" alt="' + m[1].replace(/"/g, '&quot;') + '" style="max-width:100%;height:auto;border-radius:8px;display:block"></div>';
i++; continue;
}
if(/^---+\s*$/.test(line)){ html += '<hr style="border:0;border-top:1px solid #e2e2e2;margin:20px 0">'; i++; continue; }
if(m = line.match(/^(#{1,3})\s+(.*)$/)){
const lvl = m[1].length, sz = lvl === 1 ? '22px' : lvl === 2 ? '18px' : '15px';
html += '<h' + lvl + ' style="font-size:' + sz + ';font-weight:700;color:#1a1a1a;margin:16px 0 12px">' + inline(m[2]) + '</h' + lvl + '>';
i++; continue;
}
if(/^[-*]\s+/.test(line)){
const items = [];
for(; i < lines.length && /^[-*]\s+/.test(lines[i]); i++) items.push(inline(lines[i].replace(/^[-*]\s+/, '')));
html += '<ul style="margin:0 0 16px;padding-left:22px;color:#333">' + items.map(it => '<li style="margin:0 0 6px;line-height:1.5">' + it + '</li>').join('') + '</ul>';
continue;
}
if(/^\d+\.\s+/.test(line)){
const items = [];
for(; i < lines.length && /^\d+\.\s+/.test(lines[i]); i++) items.push(inline(lines[i].replace(/^\d+\.\s+/, '')));
html += '<ol style="margin:0 0 16px;padding-left:22px;color:#333">' + items.map(it => '<li style="margin:0 0 6px;line-height:1.5">' + it + '</li>').join('') + '</ol>';
continue;
}
if(/^\s*$/.test(line)){ i++; continue; }
const buf = [lines[i]]; i++; // consomme au moins la ligne courante (jamais de boucle infinie)
for(; i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,3}\s|[-*]\s|\d+\.\s|```|:::|---+\s*$|!\[)/.test(lines[i]); i++) buf.push(lines[i]);
html += '<p style="margin:0 0 16px;line-height:1.6;color:#333;font-size:14px">' + inline(buf.join('<br>')) + '</p>';
}
// conteneur neutre, sans fond : le mail adopte le fond par défaut du client
return emailFillVars('<div style="max-width:600px;margin:0 auto;padding:8px 4px;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;font-size:14px;color:#333;line-height:1.6">' + html + '</div>', ctx);
}
const PG_DEF_GEN_PRESET = { id:'simple', label:'Simple Password', len:6, prefix:'', lower:true, upper:false, digits:true, symbols:false };
function pgDefProfile(o){ return { len:16, prefix:'', lower:true, upper:true, digits:true, symbols:true, preset:'', ...o }; }
function pgNormalize(p){
p = p || {};
p.smtp = { host:'', port:'', security:'starttls', user:'', pass:'', from:'', from_name:'', ...(p.smtp||{}) };
if(!Array.isArray(p.templates) || !p.templates.length) p.templates = [ {...PG_DEF_EMAIL_TPL} ];
p.templates.forEach(t => { if(!t.format) t.format = 'text'; if(!Array.isArray(t.attachments)) t.attachments = []; }); // schéma
// ajoute l'exemple HTML une seule fois (marqueur persistant : la suppression tient)
if(!p._seeded_html_example){ p.templates.push({...PG_EXAMPLE_HTML_TPL}); p._seeded_html_example = true; }
if(!Array.isArray(p.gen_presets)) p.gen_presets = [ {...PG_DEF_GEN_PRESET} ];
if(!p.profiles || typeof p.profiles !== 'object')
p.profiles = { password: pgDefProfile({len:16}), temp: pgDefProfile({len:8, symbols:false}) };
p.profiles.password = pgDefProfile(p.profiles.password);
p.profiles.temp = pgDefProfile(p.profiles.temp);
if(p.pp_words == null) p.pp_words = 4;
return p;
}
function pgPrefs(){ try{ return pgNormalize(JSON.parse(getSetting(PWGEN_KEY,'{}'))); }catch(e){ return pgNormalize({}); } }
function pgSave(p){ setSetting(PWGEN_KEY, JSON.stringify(p)); } // run() → persist() (base partagée)
/* Persiste l'exemple HTML une seule fois dans la base partagée : le générateur
(qui lit la même base) le voit alors sans avoir à normaliser lui-même. */
function pgSeedExampleOnce(){
let raw; try{ raw = JSON.parse(getSetting(PWGEN_KEY, '{}')); }catch(e){ raw = {}; }
if(raw && raw._seeded_html_example) return; // déjà fait (ou exemple supprimé) → on ne touche pas
pgSave(pgNormalize(raw)); // pgNormalize ajoute l'exemple + le marqueur
}
function renderPwgenSettings(){
const p = pgPrefs();
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value = v; };
set('pg_pw_len', p.profiles.password.len);
set('pg_tmp_len', p.profiles.temp.len);
set('pg_tmp_prefix', p.profiles.temp.prefix);
set('pg_smtp_host', p.smtp.host); set('pg_smtp_port', p.smtp.port);
set('pg_smtp_security', p.smtp.security); set('pg_smtp_user', p.smtp.user);
set('pg_smtp_pass', p.smtp.pass); set('pg_smtp_from', p.smtp.from); set('pg_smtp_from_name', p.smtp.from_name);
renderPgGenTplTable(); renderPgEmailTplTable();
}
function savePwgenGeneral(){
const p = pgPrefs();
p.profiles.password.len = Math.max(4, Math.min(128, parseInt(document.getElementById('pg_pw_len').value) || 16));
p.profiles.password.preset = '';
p.profiles.temp.len = Math.max(4, Math.min(64, parseInt(document.getElementById('pg_tmp_len').value) || 8));
p.profiles.temp.prefix = document.getElementById('pg_tmp_prefix').value.trim();
p.profiles.temp.preset = '';
pgSave(p);
showToast('Réglages du générateur enregistrés', 'info', 2500);
}
function savePwgenSmtp(){
const p = pgPrefs();
p.smtp = {
host: document.getElementById('pg_smtp_host').value.trim(),
port: document.getElementById('pg_smtp_port').value.trim(),
security: document.getElementById('pg_smtp_security').value,
user: document.getElementById('pg_smtp_user').value.trim(),
pass: document.getElementById('pg_smtp_pass').value,
from: document.getElementById('pg_smtp_from').value.trim(),
from_name: document.getElementById('pg_smtp_from_name').value.trim(),
};
pgSave(p);
}
async function sendPwgenTestMail(){
const to = document.getElementById('pg_smtp_test_to').value.trim();
const out = document.getElementById('pgSmtpTestResult');
if(!to || !to.includes('@')){ out.textContent = 'destinataire invalide'; out.style.color = 'var(--danger)'; return; }
savePwgenSmtp();
out.textContent = '… envoi'; out.style.color = 'var(--muted)';
try{
const res = await fetch('/mail/send', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ smtp: pgPrefs().smtp, to,
subject:'Test SMTP — portail Infra',
body:'Mail de test envoyé depuis les préférences du portail Infra.' }),
});
const data = await res.json();
if(!data.ok) throw new Error(data.error || 'échec');
out.textContent = '✓ envoyé'; out.style.color = 'var(--ok, #4ecfa5)';
}catch(e){ out.textContent = '✗ ' + String(e.message || e); out.style.color = 'var(--danger)'; }
}
/* ----- templates de génération (CRUD) ----- */
function pgPresetDesc(t){
const parts = [];
if(t.lower) parts.push('minuscules');
if(t.upper) parts.push('majuscules');
if(t.digits) parts.push('chiffres');
if(t.symbols) parts.push('symboles');
let s = (parts.join(' + ') || 'minuscules') + ' · ' + t.len + ' car.';
if(t.prefix) s += ' · préfixe « ' + t.prefix + ' »';
return s;
}
function renderPgGenTplTable(){
const el = document.getElementById('pgGenTplTable'); if(!el) return;
const list = pgPrefs().gen_presets;
if(!list.length){ el.innerHTML = '<div class="hint">Aucun template.</div>'; return; }
el.innerHTML = '<div class="table-wrap"><table><thead><tr><th>Nom</th><th>Réglages</th><th style="width:110px"></th></tr></thead><tbody>' +
list.map((t,i) => '<tr><td class="name">'+esc(t.label)+'</td><td>'+esc(pgPresetDesc(t))+'</td>'+
'<td style="text-align:right;white-space:nowrap"><button class="btn ghost small" onclick="openPgGenTplModal('+i+')">✎</button>'+
'<button class="btn ghost small danger" onclick="deletePgGenTpl('+i+')">🗑</button></td></tr>').join('') +
'</tbody></table></div>';
}
function openPgGenTplModal(idx){
const list = pgPrefs().gen_presets;
const t = idx !== undefined ? list[idx] : { label:'', len:8, prefix:'', lower:true, upper:true, digits:true, symbols:false };
const chk = v => v ? ' checked' : '';
openModal(
'<h3>'+(idx!==undefined?'Modifier le template de génération':'Nouveau template de génération')+'</h3>'+
'<div class="field"><label>Nom</label><input id="pgt_label" value="'+esc(t.label)+'" placeholder="ex : Simple Password"></div>'+
'<div class="field-row">'+
'<div class="field"><label>Caractères aléatoires</label><input type="number" id="pgt_len" min="1" max="128" value="'+(parseInt(t.len)||8)+'"></div>'+
'<div class="field"><label>Préfixe (optionnel)</label><input id="pgt_prefix" value="'+esc(t.prefix)+'" placeholder="ex : toto"></div></div>'+
'<div class="field"><label>Classes de caractères</label>'+
'<div style="display:flex;gap:20px;flex-wrap:wrap;margin-top:4px">'+
'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-2);text-transform:none;letter-spacing:0"><input type="checkbox" id="pgt_lower"'+chk(t.lower)+'> Minuscules</label>'+
'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-2);text-transform:none;letter-spacing:0"><input type="checkbox" id="pgt_upper"'+chk(t.upper)+'> Majuscules</label>'+
'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-2);text-transform:none;letter-spacing:0"><input type="checkbox" id="pgt_digits"'+chk(t.digits)+'> Chiffres</label>'+
'<label style="display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-2);text-transform:none;letter-spacing:0"><input type="checkbox" id="pgt_symbols"'+chk(t.symbols)+'> Symboles</label></div></div>'+
'<div class="modal-actions"><button class="btn" onclick="closeModal()">Annuler</button>'+
'<button class="btn primary" onclick="savePgGenTpl('+(idx!==undefined?idx:-1)+')">Enregistrer</button></div>');
}
function savePgGenTpl(idx){
const label = document.getElementById('pgt_label').value.trim();
if(!label){ showToast('Nom du template requis','warn'); return; }
const data = {
label,
len: Math.max(1, Math.min(128, parseInt(document.getElementById('pgt_len').value)||8)),
prefix: document.getElementById('pgt_prefix').value.trim(),
lower: document.getElementById('pgt_lower').checked,
upper: document.getElementById('pgt_upper').checked,
digits: document.getElementById('pgt_digits').checked,
symbols: document.getElementById('pgt_symbols').checked,
};
if(!data.lower && !data.upper && !data.digits && !data.symbols){ showToast('Cochez au moins une classe','warn'); return; }
const p = pgPrefs();
if(idx >= 0) Object.assign(p.gen_presets[idx], data);
else p.gen_presets.push({ id:'gen_'+Date.now().toString(36), ...data });
pgSave(p); closeModal(); renderPgGenTplTable();
}
function deletePgGenTpl(idx){
const p = pgPrefs(); const t = p.gen_presets[idx];
if(!t || !confirm('Supprimer le template « '+t.label+' » ?')) return;
for(const k of ['password','temp']) if(p.profiles[k].preset === t.id) p.profiles[k].preset = '';
p.gen_presets.splice(idx,1);
pgSave(p); renderPgGenTplTable();
}
/* ----- templates email (CRUD) ----- */
/* contexte d'exemple pour l'aperçu en direct de l'éditeur */
const PG_SAMPLE_CTX = { prenom:'Marie', nom:'Dupont', displayName:'Marie Dupont',
username:'mdupont', mail:'marie.dupont@exemple.com', tmp_password:'Xk4-fq2Pm!' };
/* fragments insérables depuis la barre d'outils de l'éditeur HTML */
const PG_SNIPPETS = {
banner: ':::banner #5b9cf5\nTitre du bandeau\n:::\n',
heading: '## Titre de section\n',
list: '- premier élément\n- deuxième élément\n- troisième élément\n',
ordered: '1. première étape\n2. deuxième étape\n3. troisième étape\n',
code: '```\ncommande --exemple\n```\n',
link: '[texte du lien](https://exemple.com)',
rule: '\n---\n',
};
function renderPgEmailTplTable(){
const el = document.getElementById('pgEmailTplTable'); if(!el) return;
const list = pgPrefs().templates;
if(!list.length){ el.innerHTML = '<div class="hint">Aucun template.</div>'; return; }
const fmtBadge = f => f === 'html'
? '<span class="badge" style="background:#5b9cf522;color:#5b9cf5;border:1px solid #5b9cf544">HTML</span>'
: '<span class="badge" style="background:#7c818922;color:#7c8189;border:1px solid #7c818944">Texte</span>';
el.innerHTML = '<div class="table-wrap"><table><thead><tr><th>Nom</th><th>Format</th><th>Objet</th><th style="width:110px"></th></tr></thead><tbody>' +
list.map((t,i) => '<tr><td class="name">'+esc(t.label)+'</td><td>'+fmtBadge(t.format)+'</td><td>'+esc(t.subject)+'</td>'+
'<td style="text-align:right;white-space:nowrap"><button class="btn ghost small" onclick="openPgEmailTplModal('+i+')">✎</button>'+
'<button class="btn ghost small danger" onclick="deletePgEmailTpl('+i+')">🗑</button></td></tr>').join('') +
'</tbody></table></div>';
}
let PG_EDIT_ATTACH = []; // pièces jointes du template en cours d'édition [{name, url}]
function openPgEmailTplModal(idx){
const list = pgPrefs().templates;
const t = idx !== undefined ? list[idx] : { label:'', subject:'', body:'', format:'html' };
PG_EDIT_ATTACH = Array.isArray(t.attachments) ? t.attachments.slice() : [];
const isHtml = (t.format || 'text') === 'html';
const tbBtn = (type, txt) => '<button type="button" class="btn small" onclick="pgInsertSnippet(\''+type+'\')">'+txt+'</button>';
openModal(
'<h3>'+(idx!==undefined?'Modifier le template email':'Nouveau template email')+'</h3>'+
'<div class="field-row">'+
'<div class="field" style="flex:2"><label>Nom</label><input id="pge_label" value="'+esc(t.label)+'" placeholder="ex : Compte stagiaire"></div>'+
'<div class="field" style="max-width:150px"><label>Format</label>'+
'<select id="pge_format" onchange="pgEmailPreview()">'+
'<option value="text"'+(isHtml?'':' selected')+'>Texte brut</option>'+
'<option value="html"'+(isHtml?' selected':'')+'>HTML enrichi</option>'+
'</select></div>'+
'</div>'+
'<div class="field"><label>Objet</label><input id="pge_subject" value="'+esc(t.subject)+'" placeholder="Votre mot de passe temporaire" oninput="pgEmailPreview()"></div>'+
'<div class="field"><label>Corps du message</label>'+
'<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">'+
tbBtn('banner','▬ Bandeau')+tbBtn('heading','H Titre')+tbBtn('list','• Liste')+
tbBtn('ordered','1. Numérotée')+tbBtn('code','&lt;/&gt; Code')+tbBtn('link','🔗 Lien')+tbBtn('rule','— Séparateur')+
'<button type="button" class="btn small" onclick="document.getElementById(\'pge_img\').click()">🖼 Image…</button>'+
'<input type="file" id="pge_img" accept="image/*" style="display:none" onchange="pgImportImage(this)">'+
'</div>'+
'<textarea id="pge_body" style="min-height:170px;font-family:ui-monospace,Consolas,monospace;font-size:12.5px" oninput="pgEmailPreview()" placeholder="Bonjour {{ prenom }}, voici ton mot de passe temporaire : {{ tmp_password }}">'+esc(t.body)+'</textarea>'+
'<div class="hint">Placeholders : {{ prenom }}, {{ nom }}, {{ displayName }}, {{ username }}, {{ mail }}, {{ tmp_password }}. '+
'Syntaxe HTML : <code>:::banner #couleur … :::</code>, <code>![alt](url)</code>, <code># titre</code>, <code>- liste</code>, <code>```code```</code>, <code>**gras**</code>, <code>[lien](url)</code>.</div></div>'+
'<div class="field"><label>Pièces jointes</label>'+
'<div id="pge_attach"></div>'+
'<button type="button" class="btn small" style="margin-top:8px" onclick="document.getElementById(\'pge_file\').click()">📎 Ajouter un fichier…</button>'+
'<input type="file" id="pge_file" style="display:none" onchange="pgImportFile(this)">'+
'<div class="hint">Fichiers joints au mail (ex : un <code>.rdp</code> de connexion). Max 10 Mo par fichier.</div></div>'+
'<div class="field"><label>Aperçu du mail</label>'+
'<div id="pge_preview" style="background:#ffffff;border:1px solid var(--border);border-radius:10px;padding:12px;max-height:320px;overflow:auto"></div></div>'+
'<div class="modal-actions"><button class="btn" onclick="closeModal()">Annuler</button>'+
'<button class="btn primary" onclick="savePgEmailTpl('+(idx!==undefined?idx:-1)+')">Enregistrer</button></div>');
pgRenderAttach();
pgEmailPreview();
}
/* liste des pièces jointes du template en édition (chips + suppression) */
function pgRenderAttach(){
const el = document.getElementById('pge_attach'); if(!el) return;
if(!PG_EDIT_ATTACH.length){ el.innerHTML = '<div class="hint" style="margin:0">Aucune pièce jointe.</div>'; return; }
el.innerHTML = '<div style="display:flex;flex-direction:column;gap:6px">' + PG_EDIT_ATTACH.map((a,i) =>
'<div style="display:flex;align-items:center;gap:8px;background:var(--surface-3);border:1px solid var(--border);border-radius:8px;padding:6px 10px">'+
'<span>📎</span><span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(a.name)+'</span>'+
'<button type="button" class="btn ghost small danger" onclick="pgRemoveAttach('+i+')" title="Retirer">✕</button></div>').join('') + '</div>';
}
function pgRemoveAttach(i){ PG_EDIT_ATTACH.splice(i,1); pgRenderAttach(); pgEmailPreview(); }
/* aperçu en direct : rend le corps selon le format choisi, avec un contexte d'exemple */
function pgEmailPreview(){
const box = document.getElementById('pge_preview'); if(!box) return;
const fmt = document.getElementById('pge_format').value;
const body = document.getElementById('pge_body').value;
const subject = document.getElementById('pge_subject').value;
const subLine = '<div style="font-family:-apple-system,Segoe UI,Arial,sans-serif;font-size:13px;color:#555;border-bottom:1px solid #e2e2e2;padding-bottom:8px;margin-bottom:8px"><b>Objet :</b> '+
emailFillVars(emailEsc(subject), PG_SAMPLE_CTX)+'</div>';
const rendered = fmt === 'html'
? renderEmailHtml(body, PG_SAMPLE_CTX)
: '<div style="font-family:Arial,sans-serif;font-size:14px;color:#333;white-space:pre-wrap">'+emailFillVars(emailEsc(body), PG_SAMPLE_CTX)+'</div>';
const attachLine = PG_EDIT_ATTACH.length
? '<div style="font-family:-apple-system,Segoe UI,Arial,sans-serif;font-size:12.5px;color:#555;border-top:1px solid #e2e2e2;margin-top:10px;padding-top:8px">📎 '+
PG_EDIT_ATTACH.length+' pièce(s) jointe(s) : '+PG_EDIT_ATTACH.map(a=>emailEsc(a.name)).join(', ')+'</div>'
: '';
box.innerHTML = subLine + rendered + attachLine;
}
/* insère un fragment de syntaxe à la position du curseur dans le textarea */
function pgInsertSnippet(type){
const ta = document.getElementById('pge_body');
const snip = PG_SNIPPETS[type] || '';
const s = ta.selectionStart, e = ta.selectionEnd;
ta.value = ta.value.slice(0, s) + snip + ta.value.slice(e);
const pos = s + snip.length;
ta.focus(); ta.setSelectionRange(pos, pos);
document.getElementById('pge_format').value = 'html'; // un fragment implique le format HTML
pgEmailPreview();
}
/* insère la référence markdown de l'image dans le corps, au curseur */
function pgInsertImageMd(url, alt){
const ta = document.getElementById('pge_body');
const md = '\n![' + alt + '](' + url + ')\n';
const s = ta.selectionStart;
ta.value = ta.value.slice(0, s) + md + ta.value.slice(ta.selectionEnd);
document.getElementById('pge_format').value = 'html';
pgEmailPreview();
}
/* importe une image : envoyée au serveur qui la stocke dans password-generator/images/
(le template ne référence qu'une URL, pas de gros data URI dans la base). En mode
fichier (pas de serveur) on retombe sur une intégration data URI. */
async function pgImportImage(input){
const f = input.files && input.files[0]; if(!f) return;
input.value = '';
if(f.size > 2 * 1024 * 1024){ showToast('Image trop lourde (max 2 Mo)', 'warn'); return; }
const alt = f.name.replace(/\.[^.]+$/, '');
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1] || '');
r.onerror = rej;
r.readAsDataURL(f);
});
try{
const res = await fetch('/pwgen/upload-image', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ name:f.name, b64 }),
});
const data = await res.json();
if(!data.ok) throw new Error(data.error || 'échec');
pgInsertImageMd(data.url, alt);
showToast('Image importée : ' + data.name, 'info', 2500);
}catch(e){
// pas de serveur (file://) → repli data URI intégré
pgInsertImageMd('data:' + (f.type || 'image/png') + ';base64,' + b64, alt);
showToast('Image intégrée localement (serveur indisponible)', 'info', 3000);
}
}
/* importe un fichier (rdp, pdf…) : stocké côté serveur dans password-generator/files/
et ajouté à la liste des pièces jointes du template (rien n'est inséré dans le
corps). À l'envoi, le serveur joint ces fichiers au mail. Nécessite le serveur. */
async function pgImportFile(input){
const f = input.files && input.files[0]; if(!f) return;
input.value = '';
if(f.size > 10 * 1024 * 1024){ showToast('Fichier trop lourd (max 10 Mo)', 'warn'); return; }
const b64 = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(String(r.result).split(',')[1] || '');
r.onerror = rej;
r.readAsDataURL(f);
});
try{
const res = await fetch('/pwgen/upload-file', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ name:f.name, b64 }),
});
const data = await res.json();
if(!data.ok) throw new Error(data.error || 'échec');
PG_EDIT_ATTACH.push({ name: f.name, url: data.url });
pgRenderAttach();
pgEmailPreview();
showToast('Pièce jointe ajoutée : ' + f.name, 'info', 2500);
}catch(e){
showToast('Pièce jointe impossible : serveur requis (envoi indisponible en mode fichier)', 'warn');
}
}
function savePgEmailTpl(idx){
const label = document.getElementById('pge_label').value.trim();
if(!label){ showToast('Nom du template requis','warn'); return; }
const subject = document.getElementById('pge_subject').value.trim();
const body = document.getElementById('pge_body').value;
const format = document.getElementById('pge_format').value === 'html' ? 'html' : 'text';
const attachments = PG_EDIT_ATTACH.slice();
const p = pgPrefs();
if(idx >= 0) Object.assign(p.templates[idx], { label, subject, body, format, attachments });
else p.templates.push({ id:'tpl_'+Date.now().toString(36), label, subject, body, format, attachments });
pgSave(p); closeModal(); renderPgEmailTplTable();
}
function deletePgEmailTpl(idx){
const p = pgPrefs(); const t = p.templates[idx];
if(!t || !confirm('Supprimer le template « '+t.label+' » ?')) return;
p.templates.splice(idx,1);
if(!p.templates.length) p.templates = [ {...PG_DEF_EMAIL_TPL} ];
pgSave(p); renderPgEmailTplTable();
}
/* ---------------- Snapshots de la base (serveur) ---------------- */
function snapDate(s){
const d = s.date, t = s.time;
return `${d.slice(6,8)}/${d.slice(4,6)}/${d.slice(0,4)} ${t.slice(0,2)}:${t.slice(2,4)}`;
}
function snapSize(n){
return n >= 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko';
}
async function renderSnapshots(){
const card = document.getElementById('snapCard');
const body = document.getElementById('snapBody');
if(!card || !body) return;
const btn = card.querySelector('button');
if(!SERVER_MODE){
if(btn) btn.disabled = true;
body.innerHTML = '<div class="hint">Les snapshots nécessitent le serveur (<b>python3 server.py</b>). '
+ 'En mode fichier local, utilisez l\'export <code>.db</code>.</div>';
return;
}
if(btn) btn.disabled = false;
body.innerHTML = '<div class="hint">Chargement…</div>';
try{
const r = await fetch('/db/snapshots', { cache:'no-store' });
const { snapshots } = await r.json();
if(!snapshots.length){ body.innerHTML = '<div class="hint">Aucune sauvegarde pour le moment.</div>'; return; }
const rows = snapshots.map(s => `
<tr>
<td>${snapDate(s)}</td>
<td>${s.auto ? '<span class="badge">auto</span>' : esc(s.label || 'manuel')}</td>
<td>v${s.version}</td>
<td>${snapSize(s.size)}</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn small" title="Télécharger" onclick="downloadSnapshot('${esc(s.name)}')">⬇</button>
<button class="btn small" title="Restaurer" onclick="restoreSnapshot('${esc(s.name)}')">↺</button>
<button class="btn small danger" title="Supprimer" onclick="deleteSnapshot('${esc(s.name)}')">✕</button>
</td>
</tr>`).join('');
body.innerHTML = `<div class="table-wrap"><table>
<thead><tr><th>Date</th><th>Type</th><th>Version</th><th>Taille</th><th></th></tr></thead>
<tbody>${rows}</tbody></table></div>`;
}catch(e){
body.innerHTML = '<div class="hint">Erreur de chargement des sauvegardes.</div>';
}
}
async function createSnapshot(){
const label = (prompt('Nom de la sauvegarde (optionnel) :', '') || '').trim();
try{
const r = await fetch('/db/snapshot', {
method:'POST', headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ label }),
});
if(!r.ok){ const j = await r.json().catch(()=>({})); showToast('⚠ '+(j.error||'Échec de la sauvegarde'), 'warn'); return; }
showToast('✓ Sauvegarde créée', 'info', 3000);
renderSnapshots();
}catch(e){ showToast('⚠ Serveur injoignable', 'warn'); }
}
function downloadSnapshot(name){
const a = document.createElement('a');
a.href = '/db/snapshot?name=' + encodeURIComponent(name);
a.download = name; a.click();
}
async function deleteSnapshot(name){
if(!confirm('Supprimer cette sauvegarde ?')) return;
try{
const r = await fetch('/db/snapshot/delete', {
method:'POST', headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ name }),
});
if(!r.ok){ showToast('⚠ Suppression échouée', 'warn'); return; }
renderSnapshots();
}catch(e){ showToast('⚠ Serveur injoignable', 'warn'); }
}
async function restoreSnapshot(name){
if(!confirm('Restaurer cette sauvegarde ?\n\nLa base actuelle sera remplacée par cette version. '
+ 'Un snapshot de sécurité de l\'état courant est créé automatiquement avant.')) return;
try{
const r = await fetch('/db/restore', {
method:'POST', headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ name }),
});
if(!r.ok){ const j = await r.json().catch(()=>({})); showToast('⚠ '+(j.error||'Restauration échouée'), 'warn'); return; }
const buf = new Uint8Array(await r.arrayBuffer());
dbVersion = +(r.headers.get('X-DB-Version') || dbVersion);
applyServerDb(buf); // recharge la base restaurée sans re-pousser
showToast('✓ Base restaurée depuis la sauvegarde', 'info', 4000);
renderSnapshots();
}catch(e){ showToast('⚠ Serveur injoignable', 'warn'); }
}
/* ---------------- Import / Export CSV ---------------- */
const CSV_COLS = ['name','type','status','os','os_version','assigned_user','host','room','rack',
'position_u','size_u','model','service_tag','service_tag_url','cpu','cpu_sockets','cpu_cores','cpu_mhz',
'ram_gb','support_start','support_end','idrac_ip','cable_number','cable_code','description',
'network','subnet','vlan','gateway','ip','mac','dhcp'];
function csvCell(v){
if(v === null || v === undefined) return '';
const s = String(v);
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
function csvNum(v){ v = (v || '').trim(); return v === '' || !Number.isFinite(+v) ? null : +v; }
function exportCsv(){
const machines = all(`SELECT m.*, ro.name AS _room, rk.name AS _rack, h.name AS _host
FROM machines m
LEFT JOIN racks rk ON m.rack_id = rk.id
LEFT JOIN rooms ro ON rk.room_id = ro.id
LEFT JOIN machines h ON m.host_id = h.id
ORDER BY m.name`);
const lines = [CSV_COLS.join(',')];
for(const m of machines){
const ips = all(`SELECT i.ip, i.mac, i.dhcp, n.name AS net, n.subnet, n.vlan, n.gateway
FROM ips i LEFT JOIN networks n ON i.network_id = n.id
WHERE i.machine_id = ? ORDER BY i.id`, [m.id]);
const base = {
name:m.name, type:m.type, status:m.status, os:m.os, os_version:m.os_version,
assigned_user:m.assigned_user, host:m._host, room:m._room, rack:m._rack,
position_u:m.position_u, size_u:m.size_u, model:m.model, service_tag:m.service_tag,
service_tag_url:m.service_tag_url, cpu:m.cpu, cpu_sockets:m.cpu_sockets, cpu_cores:m.cpu_cores,
cpu_mhz:m.cpu_mhz, ram_gb:m.ram_gb, support_start:m.support_start, support_end:m.support_end,
idrac_ip:m.idrac_ip, cable_number:m.cable_number, cable_code:m.cable_code, description:m.description,
network:'', subnet:'', vlan:'', gateway:'', ip:'', mac:'', dhcp:'',
};
const emit = extra => lines.push(CSV_COLS.map(c => csvCell(c in extra ? extra[c] : base[c])).join(','));
if(ips.length){
for(const p of ips)
emit({ network:p.net, subnet:p.subnet, vlan:p.vlan, gateway:p.gateway, ip:p.ip, mac:p.mac, dhcp:p.dhcp });
}else{
emit({});
}
}
// réseaux non référencés par une IP : exportés seuls pour ne rien perdre
const used = new Set(all('SELECT DISTINCT n.name nm FROM networks n JOIN ips i ON i.network_id = n.id').map(r => r.nm));
for(const n of all('SELECT name, subnet, vlan, gateway FROM networks ORDER BY name')){
if(used.has(n.name)) continue;
const row = {}; CSV_COLS.forEach(c => row[c] = '');
row.network = n.name; row.subnet = n.subnet; row.vlan = n.vlan; row.gateway = n.gateway;
lines.push(CSV_COLS.map(c => csvCell(row[c])).join(','));
}
const blob = new Blob(['' + lines.join('\r\n')], { type:'text/csv;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'cmdb-inventaire-' + new Date().toISOString().slice(0, 10) + '.csv';
a.click(); URL.revokeObjectURL(a.href);
}
/* Parseur CSV conforme RFC 4180 (guillemets, virgules et retours ligne échappés) */
function parseCsv(text){
text = text.replace(/^/, '');
const rows = []; let row = [], field = '', inQ = false, i = 0;
while(i < text.length){
const ch = text[i];
if(inQ){
if(ch === '"'){
if(text[i+1] === '"'){ field += '"'; i += 2; continue; }
inQ = false; i++; continue;
}
field += ch; i++; continue;
}
if(ch === '"'){ inQ = true; i++; continue; }
if(ch === ','){ row.push(field); field = ''; i++; continue; }
if(ch === '\r'){ i++; continue; }
if(ch === '\n'){ row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
field += ch; i++;
}
if(field.length || row.length){ row.push(field); rows.push(row); }
return rows;
}
async function importCsv(file){
let text;
try{ text = await file.text(); }
catch(e){ showToast('⚠ Lecture du fichier impossible', 'warn'); return; }
const raw = parseCsv(text).filter(r => r.length && r.some(c => c !== ''));
if(raw.length < 2){ showToast('⚠ CSV vide ou sans données', 'warn'); return; }
const header = raw[0].map(h => h.trim());
if(!header.includes('name') && !header.includes('network')){
showToast('⚠ En-tête non reconnu (colonnes « name » / « network » attendues)', 'warn', 7000); return;
}
const data = raw.slice(1).map(r => { const o = {}; header.forEach((h, i) => o[h] = (r[i] ?? '').trim()); return o; });
if(!confirm(`Importer ${data.length} ligne(s) ?\n\n`
+ `Réseaux, salles, baies et machines existants seront mis à jour (par nom), les nouveaux créés. `
+ `Les IP d'une machine importée remplacent les siennes.`)) return;
db.run('BEGIN');
try{
const netId = {}, roomId = {}, rackId = {}, machineId = {}, hostPending = [];
const lastId = () => all('SELECT last_insert_rowid() i')[0].i;
const getNet = (name, subnet, vlan, gw) => {
name = (name || '').trim(); if(!name) return null;
if(netId[name]) return netId[name];
const ex = all('SELECT id FROM networks WHERE name=?', [name]);
let id;
if(ex.length){
id = ex[0].id;
db.run(`UPDATE networks SET subnet=COALESCE(NULLIF(?,''),subnet),
vlan=COALESCE(?,vlan), gateway=COALESCE(NULLIF(?,''),gateway) WHERE id=?`,
[subnet || '', csvNum(vlan), gw || '', id]);
}else{
const color = PASTELS[Object.keys(netId).length % PASTELS.length];
db.run('INSERT INTO networks(name,subnet,vlan,gateway,color) VALUES (?,?,?,?,?)',
[name, subnet || null, csvNum(vlan), gw || null, color]);
id = lastId();
}
return (netId[name] = id);
};
const getRoom = name => {
name = (name || '').trim(); if(!name) return null;
if(roomId[name]) return roomId[name];
const ex = all('SELECT id FROM rooms WHERE name=?', [name]);
let id; if(ex.length){ id = ex[0].id; } else { db.run('INSERT INTO rooms(name) VALUES (?)', [name]); id = lastId(); }
return (roomId[name] = id);
};
const getRack = (roomName, rackName) => {
rackName = (rackName || '').trim(); if(!rackName) return null;
const rid = getRoom(roomName || 'Salle'), key = rid + '/' + rackName;
if(rackId[key]) return rackId[key];
const ex = all('SELECT id FROM racks WHERE room_id=? AND name=?', [rid, rackName]);
let id; if(ex.length){ id = ex[0].id; } else { db.run('INSERT INTO racks(room_id,name,height_u) VALUES (?,?,42)', [rid, rackName]); id = lastId(); }
return (rackId[key] = id);
};
const ensureType = key => {
key = (key || '').trim(); if(!key || all('SELECT 1 FROM types WHERE key=?', [key]).length) return;
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM types')[0].s;
db.run(`INSERT INTO types(key,label,color,virtual,rackable,assign_user,in_ad,hosts,is_switch,sort)
VALUES (?,?,?,0,1,0,0,'',0,?)`, [key, key, '#5b9cf5', sort]);
};
const ensureStatus = key => {
key = (key || '').trim(); if(!key || all('SELECT 1 FROM statuses WHERE key=?', [key]).length) return;
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM statuses')[0].s;
db.run('INSERT INTO statuses(key,label,color,sort) VALUES (?,?,?,?)', [key, key, '#7c8189', sort]);
};
const ensureOs = (name, ver) => {
name = (name || '').trim(); if(!name) return;
const r = all('SELECT id FROM os_catalog WHERE name=?', [name]);
let oid; if(r.length){ oid = r[0].id; } else { db.run('INSERT INTO os_catalog(name) VALUES (?)', [name]); oid = lastId(); }
ver = (ver || '').trim();
if(ver && !all('SELECT 1 FROM os_versions WHERE os_id=? AND version=?', [oid, ver]).length)
db.run('INSERT INTO os_versions(os_id,version) VALUES (?,?)', [oid, ver]);
};
for(const d of data){
if(d.network) getNet(d.network, d.subnet, d.vlan, d.gateway); // réseau (même sans machine)
const name = (d.name || '').trim();
if(!name) continue; // ligne réseau-seule
let mid = machineId[name];
if(mid === undefined){
ensureType(d.type); ensureStatus(d.status); ensureOs(d.os, d.os_version);
const cols = {
type: d.type || 'baremetal', status: d.status || null, os: d.os || null, os_version: d.os_version || null,
assigned_user: d.assigned_user || null, size_u: csvNum(d.size_u), rack_id: getRack(d.room, d.rack),
position_u: csvNum(d.position_u), model: d.model || null, service_tag: d.service_tag || null,
service_tag_url: d.service_tag_url || null, cpu: d.cpu || null, cpu_sockets: csvNum(d.cpu_sockets),
cpu_cores: csvNum(d.cpu_cores), cpu_mhz: csvNum(d.cpu_mhz), ram_gb: csvNum(d.ram_gb),
support_start: d.support_start || null, support_end: d.support_end || null, idrac_ip: d.idrac_ip || null,
cable_number: d.cable_number || null, cable_code: d.cable_code || null, description: d.description || null,
};
const ex = all('SELECT id FROM machines WHERE name=?', [name]);
if(ex.length){
mid = ex[0].id;
db.run('UPDATE machines SET ' + Object.keys(cols).map(k => k + '=?').join(',') + ' WHERE id=?',
[...Object.values(cols), mid]);
db.run('DELETE FROM ips WHERE machine_id=?', [mid]); // les IP importées remplacent les siennes
}else{
const keys = ['name', ...Object.keys(cols)];
db.run('INSERT INTO machines(' + keys.join(',') + ') VALUES (' + keys.map(() => '?').join(',') + ')',
[name, ...Object.values(cols)]);
mid = lastId();
}
machineId[name] = mid;
if((d.host || '').trim()) hostPending.push({ mid, host: (d.host || '').trim() });
}
if((d.ip || '').trim() || (d.mac || '').trim()){
const nid = d.network ? getNet(d.network, d.subnet, d.vlan, d.gateway) : null;
db.run('INSERT INTO ips(machine_id,network_id,ip,mac,dhcp) VALUES (?,?,?,?,?)',
[mid, nid, (d.ip || '').trim() || null, (d.mac || '').trim() || null,
/^(1|true|oui|yes|vrai)$/i.test((d.dhcp || '').trim()) ? 1 : 0]);
}
}
for(const h of hostPending){
const hid = machineId[h.host] || (all('SELECT id FROM machines WHERE name=?', [h.host])[0] || {}).id;
if(hid && hid !== h.mid) db.run('UPDATE machines SET host_id=? WHERE id=?', [hid, h.mid]);
}
db.run('COMMIT');
}catch(e){
try{ db.run('ROLLBACK'); }catch(_){}
showToast('⚠ Import échoué : ' + e.message, 'warn', 9000);
console.error(e);
return;
}
loadTypes(); loadStatuses();
persist();
renderAll();
showToast('✓ Import CSV terminé', 'info', 4000);
}
/* ---------------- Active Directory / annuaire ---------------- */
const AD_FIELDS = ['ad_label','ad_host','ad_port','ad_uid','ad_method','ad_base','ad_bind_dn','ad_bind_pw','ad_filter','ad_bridge','ad_users'];
function renderAdSettings(){
const el = document.getElementById('ad_mode'); if(!el) return;
const mode = getSetting('ad_mode','off');
el.value = mode;
const defs = { ad_port:'389', ad_uid:'userPrincipalName', ad_method:'start_tls', ad_bridge:'http://127.0.0.1:8389' };
AD_FIELDS.forEach(k => { const f = document.getElementById(k); if(f) f.value = getSetting(k, defs[k]||''); });
document.getElementById('ad_verify').checked = getSetting('ad_verify','0') === '1';
document.getElementById('adLdapFields').style.display = mode==='ldap' ? '' : 'none';
document.getElementById('adLocalFields').style.display = mode==='local' ? '' : 'none';
// servie en http(s) par cmdb_server.py → connecteur séparé inutile
const bf = document.getElementById('bridgeUrlField');
if(bf) bf.style.display = location.protocol.startsWith('http') ? 'none' : '';
}
function saveAdSettings(){
setSetting('ad_mode', document.getElementById('ad_mode').value);
AD_FIELDS.forEach(k => { const f = document.getElementById(k); if(f) setSetting(k, f.value.trim()); });
setSetting('ad_verify', document.getElementById('ad_verify').checked ? '1' : '0');
renderAdSettings();
}
function adConfig(){
return {
label: getSetting('ad_label',''),
host: getSetting('ad_host',''),
port: parseInt(getSetting('ad_port','389')) || 389,
uid: getSetting('ad_uid','userPrincipalName'),
method: getSetting('ad_method','start_tls'), // plain | start_tls | ssl
verify_certificates: getSetting('ad_verify','0') === '1',
base: getSetting('ad_base',''),
bind_dn: getSetting('ad_bind_dn',''),
bind_password: getSetting('ad_bind_pw',''),
filter: getSetting('ad_filter',''),
};
}
/* Normalise en items { label, sub } — label = valeur stockée, sub = username/mail */
function adItems(list){
if(!Array.isArray(list)) list = list?.value || list?.users || list?.results || [];
return list.map(u => {
if(typeof u === 'string') return { label:u, sub:'' };
const label = u.displayName || u.name || u.cn
|| ((u.givenName||'') + ' ' + (u.sn||u.surname||'')).trim()
|| u.sAMAccountName || u.username || u.mail || u.userPrincipalName || '';
const extras = [];
const user = u.sAMAccountName || u.username;
const mail = u.mail || u.userPrincipalName;
if(user && user !== label) extras.push(user);
if(mail && mail !== label && mail !== user) extras.push(mail);
return { label, sub: extras.join(' · ') };
}).filter(x => x.label);
}
/* Recherche brute → tableau d'objets (ou chaînes en local) */
async function adSearchRaw(query, limit=8){
const mode = getSetting('ad_mode','off');
if(mode === 'local'){
const toks = query.toLowerCase().split(/\s+/).filter(Boolean);
return getSetting('ad_users','').split('\n').map(s=>s.trim()).filter(Boolean)
.filter(u => { const l = u.toLowerCase(); return toks.every(t => l.includes(t)); }).slice(0,limit);
}
if(mode === 'ldap'){
const cfg = adConfig();
if(!cfg.host) throw new Error('Host LDAP non configuré');
/* Servie par cmdb_server.py (http/https) → endpoint même origine, zéro config.
Ouverte en file:// → connecteur séparé (URL configurée). */
const base = location.protocol.startsWith('http') ? '' : getSetting('ad_bridge','http://127.0.0.1:8389');
let res;
try{
res = await fetch(base + '/ldap/search', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ query, config: cfg, limit }),
});
}catch(e){
throw new Error(base ? 'Connecteur injoignable — lancez cmdb_ldap_bridge.py'
: 'Endpoint LDAP absent — servez la page avec cmdb_server.py');
}
const data = await res.json();
if(!res.ok || data.error) throw new Error(data.error || 'HTTP '+res.status);
return Array.isArray(data) ? data : (data.value || data.users || data.results || []);
}
return [];
}
async function adLookup(query, limit=8){ return adItems(await adSearchRaw(query, limit)).slice(0,limit); }
/* Poste AD (objectClass=computer) recherché par nom machine — avec cache */
const _adCompCache = {};
async function adComputerInfo(name){
const key = name.toLowerCase();
if(key in _adCompCache) return _adCompCache[key];
const mode = getSetting('ad_mode','off');
if(mode !== 'ldap'){ _adCompCache[key] = null; return null; } // postes : LDAP uniquement
const cfg = adConfig();
if(!cfg.host) throw new Error('Host LDAP non configuré');
const base = location.protocol.startsWith('http') ? '' : getSetting('ad_bridge','http://127.0.0.1:8389');
let res;
try{
res = await fetch(base + '/ldap/computer', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ query:name, config:cfg }),
});
}catch(e){
throw new Error(base ? 'Connecteur injoignable' : 'Endpoint LDAP absent — servez la page avec cmdb_server.py');
}
const data = await res.json();
if(!res.ok || data.error) throw new Error(data.error || 'HTTP '+res.status);
const c = Array.isArray(data) ? data[0] : data;
_adCompCache[key] = c || null;
return _adCompCache[key];
}
function adComputerHtml(c){
const row = (l,v) => v ? `<div class="hc-row"><span class="hc-mut" style="min-width:74px">${l}</span><span>${esc(v)}</span></div>` : '';
const st = c.enabled === false
? ' <span class="badge" style="background:#d03b3b22;color:#d03b3b;border:1px solid #d03b3b55">Désactivé</span>'
: (c.enabled === true ? ' <span class="badge" style="background:#0ca30c22;color:#0ca30c;border:1px solid #0ca30c55">Actif</span>' : '');
const groups = (c.groups && c.groups.length)
? `<div class="hc-row" style="display:block"><span class="hc-mut">Groupes (${c.groups.length})</span>
<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:4px">${c.groups.map(g=>`<span class="chip" style="font-size:10.5px;padding:1px 7px">${esc(g)}</span>`).join('')}</div></div>`
: '';
return `<div class="hc-t">Active Directory (poste)${st}</div>`
+ row('Nom', c.name) + row('DNS', c.dns)
+ row('OS', [c.os, c.os_version].filter(Boolean).join(' '))
+ row('Emplacement', c.location) + row('Gestionnaire', c.managed_by)
+ row('Description', c.description) + groups;
}
/* Fiche AD complète pour un nom (meilleure correspondance) — avec cache */
const _adInfoCache = {};
async function adUserInfo(name){
const key = name.toLowerCase();
if(key in _adInfoCache) return _adInfoCache[key];
const raw = await adSearchRaw(name, 5);
const exact = raw.find(u => typeof u!=='string' &&
(u.displayName||u.name||u.cn||'').toLowerCase() === key);
const u = exact || raw[0];
let info = null;
if(u){
info = typeof u === 'string' ? { displayName:u } : {
displayName: u.displayName||u.name||u.cn||name,
username: u.username||u.sAMAccountName,
mail: u.mail||u.userPrincipalName,
title:u.title, department:u.department, company:u.company,
office:u.office, phone:u.phone, mobile:u.mobile,
manager:u.manager, description:u.description, employee_id:u.employee_id,
division:u.division, city:u.city, country:u.country,
enabled:u.enabled, groups:u.groups||[],
};
}
_adInfoCache[key] = info;
return info;
}
/* Génère le connecteur Python (pont HTTP local → LDAP) */
function downloadBridge(){
const py = `#!/usr/bin/env python3
# Connecteur LDAP pour la CMDB — recherche d'utilisateurs uniquement.
# Dependances : pip install ldap3
# Lancement : python3 cmdb_ldap_bridge.py (ecoute sur 127.0.0.1:8389)
# La page envoie la config LDAP a chaque requete ; rien n'est stocke ici.
import json, ssl
from http.server import HTTPServer, BaseHTTPRequestHandler
from ldap3 import Server, Connection, Tls, SUBTREE, ALL_ATTRIBUTES
from ldap3.utils.conv import escape_filter_chars
PORT = 8389
class H(BaseHTTPRequestHandler):
def _hdr(self, code):
self.send_response(code)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.send_header('Content-Type', 'application/json')
self.end_headers()
def do_OPTIONS(self):
self._hdr(204)
def log_message(self, *a):
pass
def do_POST(self):
try:
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
q = (body.get('query') or '').strip()
c = body.get('config') or {}
limit = int(body.get('limit') or 25)
use_ssl = c.get('method') == 'ssl'
tls = Tls(validate=ssl.CERT_REQUIRED if c.get('verify_certificates') else ssl.CERT_NONE)
srv = Server(c['host'], port=int(c.get('port') or (636 if use_ssl else 389)),
use_ssl=use_ssl, tls=tls, get_info=None, connect_timeout=8)
conn = Connection(srv, user=c.get('bind_dn') or None,
password=c.get('bind_password') or None,
auto_referrals=False, receive_timeout=15)
if c.get('method') == 'start_tls':
conn.open(); conn.start_tls()
if not conn.bind():
raise RuntimeError('Bind LDAP refuse : ' + str(conn.result))
def val(d, k):
for kk in d:
if kk.lower() == k.lower():
v = d[kk]
if isinstance(v, list):
v = v[0] if v else ''
return str(v) if v else ''
return ''
def vals(d, k):
for kk in d:
if kk.lower() == k.lower():
v = d[kk]
return [str(x) for x in (v if isinstance(v, list) else [v]) if x]
return []
def cn_of(dn):
if not dn:
return ''
head = dn.split(',')[0]
return head.split('=', 1)[1] if '=' in head else head
if self.path.endswith('/computer'):
eq = escape_filter_chars(q.rstrip('$'))
cflt = ('(&(objectClass=computer)(|(cn=*%s*)(name=*%s*)'
'(dNSHostName=*%s*)(sAMAccountName=*%s*)))' % (eq, eq, eq, eq))
conn.search(c.get('base') or '', cflt, SUBTREE, attributes=ALL_ATTRIBUTES, size_limit=5)
cout = []
for e in conn.entries:
d = e.entry_attributes_as_dict
uac = val(d, 'userAccountControl')
cout.append({
'name': val(d, 'cn') or val(d, 'name'),
'dns': val(d, 'dNSHostName'),
'os': val(d, 'operatingSystem'),
'os_version': val(d, 'operatingSystemVersion'),
'description': val(d, 'description'),
'location': val(d, 'location'),
'managed_by': cn_of(val(d, 'managedBy')),
'enabled': (not (int(uac) & 2)) if uac.isdigit() else None,
'groups': [cn_of(x) for x in vals(d, 'memberOf')][:20],
})
self._hdr(200)
self.wfile.write(json.dumps(cout).encode())
return
uid = c.get('uid') or 'userPrincipalName'
raw = c.get('filter') or ''
if raw:
flt = raw.replace('{query}', escape_filter_chars(q))
else:
search_attrs = ['displayName', 'cn', 'sAMAccountName', 'givenName', 'sn',
'userPrincipalName', 'mail']
tokens = [escape_filter_chars(t) for t in q.split() if t] or ['']
parts = []
for t in tokens:
ors = ''.join('(%s=*%s*)' % (a, t) for a in search_attrs)
parts.append('(|%s)' % ors)
flt = '(&%s)' % ''.join(parts) if len(parts) > 1 else parts[0]
person = ('(|(objectClass=user)(objectClass=inetOrgPerson)'
'(objectClass=organizationalPerson)(objectClass=person))')
base = c.get('base') or ''
ok = conn.search(base, '(&%s%s)' % (person, flt),
SUBTREE, attributes=ALL_ATTRIBUTES, size_limit=limit)
r = conn.result or {}
if not ok and r.get('result') not in (0, 4):
raise RuntimeError('Recherche LDAP echouee : %s (base=%s)' % (r.get('description') or r, base or 'vide'))
out, seen = [], set()
for e in conn.entries:
d = e.entry_attributes_as_dict
name = val(d, 'displayName') or val(d, 'cn') \\
or (val(d, 'givenName') + ' ' + val(d, 'sn')).strip()
username = val(d, 'sAMAccountName') or val(d, uid) or val(d, 'userPrincipalName')
key = (name, username)
if not (name or username) or key in seen:
continue
seen.add(key)
uac = val(d, 'userAccountControl')
enabled = (not (int(uac) & 2)) if uac.isdigit() else None
out.append({
'displayName': name or username, 'username': username, 'mail': val(d, 'mail'),
'title': val(d, 'title'), 'department': val(d, 'department'),
'company': val(d, 'company'), 'office': val(d, 'physicalDeliveryOfficeName'),
'phone': val(d, 'telephoneNumber'), 'mobile': val(d, 'mobile') or val(d, 'ipPhone'),
'manager': cn_of(val(d, 'manager')), 'description': val(d, 'description'),
'employee_id': val(d, 'employeeID') or val(d, 'employeeNumber'),
'division': val(d, 'division'), 'city': val(d, 'l'),
'country': val(d, 'co') or val(d, 'c'), 'enabled': enabled,
'groups': [cn_of(x) for x in vals(d, 'memberOf')][:15],
})
out.sort(key=lambda u: u['displayName'].lower())
self._hdr(200)
self.wfile.write(json.dumps(out).encode())
except Exception as ex:
self._hdr(500)
self.wfile.write(json.dumps({'error': str(ex)}).encode())
print('Connecteur LDAP CMDB : http://127.0.0.1:%d (Ctrl+C pour arreter)' % PORT)
HTTPServer(('127.0.0.1', PORT), H).serve_forever()
`;
const blob = new Blob([py], {type:'text/x-python'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = 'cmdb_ldap_bridge.py';
a.click(); URL.revokeObjectURL(a.href);
}
async function testAd(){
const out = document.getElementById('adTestResult');
out.textContent = '…'; out.style.color = 'var(--muted)';
try{
const r = await adLookup('a');
out.textContent = `✓ OK — ${r.length} résultat(s) pour « a »`;
out.style.color = 'var(--good)';
}catch(e){
out.textContent = '✗ ' + e.message;
out.style.color = 'var(--danger)';
}
}
/* Test « montre tout » : cherche par username/nom/prénom et liste les résultats */
async function runAdTest(){
const q = document.getElementById('ad_test_q').value.trim();
const box = document.getElementById('adTestList');
if(!q){ box.innerHTML = ''; return; }
box.innerHTML = '<span style="color:var(--muted);font-size:12px">Recherche…</span>';
try{
const items = await adLookup(q, 100);
box.innerHTML = items.length ? `<div class="table-wrap"><table>
<tr><th>Nom</th><th>Username / mail</th></tr>
${items.map(it=>`<tr>
<td><span class="name link" onclick="document.getElementById('ad_test_q').blur()">${esc(it.label)}</span></td>
<td style="color:var(--muted)">${esc(it.sub||'—')}</td>
</tr>`).join('')}
</table></div><div class="hint" style="margin-top:6px">${items.length} résultat(s) pour « ${esc(q)} »</div>`
: '<span style="color:var(--muted);font-size:13px">Aucun résultat pour « '+esc(q)+' ».</span>';
}catch(e){
box.innerHTML = `<span style="color:var(--danger);font-size:13px">✗ ${esc(e.message)}</span>`;
}
}
/* Catalogues simples (modèles serveur, CPU) : liste de chips + ajout/suppression */
const CATALOG_COL = { hw_models:'model', hw_cpus:'cpu' }; // colonne machines liée
function renderHwCatalogs(){
// Modèles : marque + modèle
const mEl = document.getElementById('modelsList');
if(mEl){
const rows = all('SELECT * FROM hw_models ORDER BY brand COLLATE NOCASE, name COLLATE NOCASE');
mEl.innerHTML = rows.length ? rows.map(r=>{
const used = all('SELECT COUNT(*) c FROM machines WHERE model=? AND COALESCE(brand,\'\')=COALESCE(?,\'\')',[r.name,r.brand||''])[0].c;
return `<span class="chip">${r.brand?`<b style="color:var(--ink)">${esc(r.brand)}</b> `:''}${esc(r.name)}${used?` <b style="color:var(--ink)">${used}</b>`:''}
<span class="link" style="color:var(--muted)" onclick="deleteCatalogItem('hw_models',${r.id})">✕</span></span>`;
}).join('') : '<span style="color:var(--muted);font-size:13px">— vide —</span>';
}
// CPU
const cEl = document.getElementById('cpusList');
if(cEl){
const rows = all('SELECT * FROM hw_cpus ORDER BY name COLLATE NOCASE');
cEl.innerHTML = rows.length ? rows.map(r=>{
const used = all('SELECT COUNT(*) c FROM machines WHERE cpu=?',[r.name])[0].c;
return `<span class="chip">${esc(r.name)}<span class="mac">${r.cores?r.cores+'c':'?c'} · ${r.mhz?r.mhz+' MHz':'? MHz'}</span>${used?` <b style="color:var(--ink)">${used}</b>`:''}
<span class="link" style="color:var(--muted)" onclick="deleteCatalogItem('hw_cpus',${r.id})">✕</span></span>`;
}).join('') : '<span style="color:var(--muted);font-size:13px">— vide —</span>';
}
}
function addModel(){
const brand = document.getElementById('newBrand').value.trim();
const name = document.getElementById('newModel').value.trim();
if(!name){ alert('Modèle requis'); return; }
if(all('SELECT 1 FROM hw_models WHERE name=? AND COALESCE(brand,\'\')=?',[name,brand]).length){ alert('Existe déjà.'); return; }
run('INSERT INTO hw_models(brand,name) VALUES (?,?)',[brand||null,name]);
document.getElementById('newBrand').value=''; document.getElementById('newModel').value='';
renderHwCatalogs();
}
function addCpu(){
const name = document.getElementById('newCpu').value.trim();
if(!name) return;
if(all('SELECT 1 FROM hw_cpus WHERE name=?',[name]).length){ alert('Existe déjà.'); return; }
const cores = parseInt(document.getElementById('newCpuCores').value) || null;
const mhz = parseInt(document.getElementById('newCpuMhz').value) || null;
run('INSERT INTO hw_cpus(name,cores,mhz) VALUES (?,?,?)',[name,cores,mhz]);
['newCpu','newCpuCores','newCpuMhz'].forEach(i=>document.getElementById(i).value='');
renderHwCatalogs();
}
function addCatalogItem(table, inputId){
const inp = document.getElementById(inputId);
const name = inp.value.trim();
if(!name) return;
if(all(`SELECT 1 FROM ${table} WHERE name=?`,[name]).length){ alert('Existe déjà.'); return; }
run(`INSERT INTO ${table}(name) VALUES (?)`,[name]);
inp.value = '';
renderHwCatalogs();
}
function deleteCatalogItem(table, id){
const r = all(`SELECT name FROM ${table} WHERE id=?`,[id])[0];
const used = all(`SELECT COUNT(*) c FROM machines WHERE ${CATALOG_COL[table]}=?`,[r.name])[0].c;
if(!confirm(used ? `${used} machine(s) référencent « ${r.name} ». La valeur restera inscrite sur ces machines. Supprimer du catalogue ?` : `Supprimer « ${r.name} » ?`)) return;
run(`DELETE FROM ${table} WHERE id=?`,[id]);
renderHwCatalogs();
}
function renderStatusesConfig(){
const el = document.getElementById('statusesContainer'); if(!el) return;
el.innerHTML = `<table>
<tr><th>Statut</th><th>Machines</th><th style="width:120px"></th></tr>
${all('SELECT * FROM statuses ORDER BY sort, label').map(s=>{
const used = all('SELECT COUNT(*) c FROM machines WHERE status=?',[s.key])[0].c;
return `<tr>
<td><span class="dot" style="background:${esc(s.color)}"></span><span class="name">${esc(s.label)}</span></td>
<td>${used}</td>
<td style="text-align:right">
<button class="btn small" onclick="openStatusModal('${s.key}')">Éditer</button>
<button class="btn small danger" onclick="deleteStatus('${s.key}')">✕</button>
</td></tr>`;
}).join('')}
</table>`;
}
function openStatusModal(key){
const s = key ? all('SELECT * FROM statuses WHERE key=?',[key])[0] : {label:'',color:'#5b9cf5'};
openModal(`
<h3>${key?'Éditer':'Nouveau'} statut</h3>
<div class="field-row">
<div class="field"><label>Nom</label><input id="s_label" value="${esc(s.label)}" placeholder="En production, Archivé…"></div>
<div class="field" style="max-width:90px"><label>Couleur</label><input type="color" id="s_color" value="${esc(s.color)}"></div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveStatus(${key?`'${key}'`:'null'})">Enregistrer</button>
</div>`);
}
function saveStatus(key){
const label = document.getElementById('s_label').value.trim();
if(!label){ alert('Nom requis'); return; }
const color = document.getElementById('s_color').value;
if(key){
run('UPDATE statuses SET label=?,color=? WHERE key=?',[label,color,key]);
}else{
let k = slug(label), base = k, i = 2;
while(all('SELECT 1 FROM statuses WHERE key=?',[k]).length){ k = base+'-'+(i++); }
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM statuses')[0].s;
run('INSERT INTO statuses(key,label,color,sort) VALUES (?,?,?,?)',[k,label,color,sort]);
}
loadStatuses(); closeModal(); renderAll();
}
function deleteStatus(key){
const used = all('SELECT COUNT(*) c FROM machines WHERE status=?',[key])[0].c;
if(used && !confirm(`${used} machine(s) ont ce statut. Il sera retiré de ces machines. Supprimer ?`)) return;
run('UPDATE machines SET status=NULL WHERE status=?',[key]);
run('DELETE FROM statuses WHERE key=?',[key]);
loadStatuses(); renderAll();
}
function renderTypesConfig(){
const el = document.getElementById('typesContainer'); if(!el) return;
const yn = b => b ? '<span style="color:var(--accent);font-weight:700">✓</span>' : '<span style="color:var(--muted)">—</span>';
el.innerHTML = `<table>
<tr><th>Type</th><th>Virtuel</th><th>Rackable</th><th>User</th><th>AD</th><th>Héberge</th><th>Machines</th><th style="width:120px"></th></tr>
${all('SELECT * FROM types ORDER BY sort, label').map(t=>{
const used = all('SELECT COUNT(*) c FROM machines WHERE type=?',[t.key])[0].c;
const hostKeys = (t.hosts||'').split(',').map(s=>s.trim()).filter(Boolean);
const hostLbls = hostKeys.map(k=>TYPES[k]?TYPES[k].label:k).join(', ');
return `<tr>
<td><span class="dot" style="background:${esc(t.color)}"></span><span class="name">${esc(t.label)}</span>
${t.is_switch?'<span title="Switch réseau">🔌</span>':''}
<span style="color:var(--muted);font-size:11px">${esc(t.key)}</span></td>
<td>${yn(t.virtual)}</td><td>${yn(t.rackable)}</td><td>${yn(t.assign_user)}</td><td>${yn(t.in_ad)}</td>
<td style="font-size:12px;color:var(--ink-2)">${hostLbls?esc(hostLbls):'<span style="color:var(--muted)">—</span>'}</td>
<td>${used}</td>
<td style="text-align:right">
<button class="btn small" onclick="openTypeModal('${t.key}')">Éditer</button>
<button class="btn small danger" onclick="deleteType('${t.key}')">✕</button>
</td></tr>`;
}).join('')}
</table>`;
}
function openTypeModal(key){
const t = key ? all('SELECT * FROM types WHERE key=?',[key])[0]
: {key:'',label:'',color:'#5b9cf5',virtual:0,rackable:0,assign_user:0,in_ad:0,hosts:'',is_switch:0};
const cb = (id,on,lbl) => `<label style="display:flex;align-items:center;gap:9px;font-size:13px;color:var(--ink-2);text-transform:none;letter-spacing:0;font-weight:400;margin-bottom:10px;cursor:pointer">
<input type="checkbox" id="${id}" ${on?'checked':''}> ${lbl}</label>`;
// Types virtuels hébergeables (on ne peut pas s'héberger soi-même)
const hostSet = new Set((t.hosts||'').split(',').map(s=>s.trim()).filter(Boolean));
const virtTypes = all('SELECT key,label FROM types WHERE virtual=1 AND key<>? ORDER BY sort,label',[t.key||'']);
const hostCbs = virtTypes.length
? virtTypes.map(v=>`<label style="display:inline-flex;align-items:center;gap:7px;font-size:13px;color:var(--ink-2);text-transform:none;letter-spacing:0;font-weight:400;margin:0 14px 8px 0;cursor:pointer">
<input type="checkbox" class="t_host" value="${esc(v.key)}" ${hostSet.has(v.key)?'checked':''}> ${esc(v.label)}</label>`).join('')
: '<span style="color:var(--muted);font-size:12px">Aucun type virtuel défini.</span>';
openModal(`
<h3>${key?'Éditer':'Nouveau'} type de machine</h3>
<div class="field-row">
<div class="field"><label>Nom</label><input id="t_label" value="${esc(t.label)}" placeholder="Serveur baremetal, VM…"></div>
<div class="field" style="max-width:90px"><label>Couleur</label><input type="color" id="t_color" value="${esc(t.color)}"></div>
</div>
${cb('t_virtual',t.virtual,'Virtuel — hébergé sur une autre machine')}
${cb('t_rackable',t.rackable,'Rackable — occupe des U dans une baie')}
${cb('t_user',t.assign_user,'Assignable à un utilisateur (laptop, optiplex…)')}
${cb('t_inad',t.in_ad,'Présent dans l\'Active Directory (poste — infos AD au survol)')}
${cb('t_switch',t.is_switch,'Switch réseau — accepte une conf VLAN + export vers le générateur')}
<div class="field" style="margin-top:4px">
<label>Peut héberger</label>
<div style="display:flex;flex-wrap:wrap;margin-top:4px">${hostCbs}</div>
</div>
<div class="hint">Un type virtuel ne peut pas être rackable. « Peut héberger » filtre les hôtes proposés pour une machine virtuelle.</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveType(${key?`'${key}'`:'null'})">Enregistrer</button>
</div>`);
}
function saveType(key){
const label = document.getElementById('t_label').value.trim();
if(!label){ alert('Nom requis'); return; }
const color = document.getElementById('t_color').value;
let virtual = document.getElementById('t_virtual').checked ? 1 : 0;
let rackable = document.getElementById('t_rackable').checked ? 1 : 0;
const assign_user = document.getElementById('t_user').checked ? 1 : 0;
const in_ad = document.getElementById('t_inad').checked ? 1 : 0;
const is_switch = document.getElementById('t_switch').checked ? 1 : 0;
const hosts = [...document.querySelectorAll('.t_host:checked')].map(c=>c.value).join(',');
if(virtual) rackable = 0;
if(key){
run('UPDATE types SET label=?,color=?,virtual=?,rackable=?,assign_user=?,in_ad=?,hosts=?,is_switch=? WHERE key=?',
[label,color,virtual,rackable,assign_user,in_ad,hosts,is_switch,key]);
}else{
let k = slug(label), base = k, i = 2;
while(all('SELECT 1 FROM types WHERE key=?',[k]).length){ k = base+'-'+(i++); }
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM types')[0].s;
run('INSERT INTO types(key,label,color,virtual,rackable,assign_user,in_ad,hosts,is_switch,sort) VALUES (?,?,?,?,?,?,?,?,?,?)',
[k,label,color,virtual,rackable,assign_user,in_ad,hosts,is_switch,sort]);
}
loadTypes(); closeModal(); renderAll();
}
function deleteType(key){
const used = all('SELECT COUNT(*) c FROM machines WHERE type=?',[key])[0].c;
if(used){ alert(`Impossible : ${used} machine(s) utilisent ce type. Réaffectez-les d'abord.`); return; }
if(all('SELECT COUNT(*) c FROM types')[0].c <= 1){ alert('Au moins un type est requis.'); return; }
if(!confirm('Supprimer ce type ?')) return;
run('DELETE FROM types WHERE key=?',[key]);
loadTypes(); renderAll();
}
/* ============================================================
LIENS VERS TICKET
============================================================ */
function renderTicketsConfig(){
const el = document.getElementById('ticketsContainer'); if(!el) return;
const rows = all('SELECT * FROM tickets ORDER BY sort, label');
if(!rows.length){ el.innerHTML = `<div class="empty" style="padding:16px 0;color:var(--muted)">Aucun ticket. <button class="btn primary small" onclick="openTicketModal()"> Nouveau ticket</button></div>`; return; }
el.innerHTML = `<table>
<tr><th>Ticket</th><th>Lien</th><th>Description</th><th style="width:120px"></th></tr>
${rows.map(t=>`<tr>
<td><span class="name">${esc(t.label)}</span>${t.key?` <span style="color:var(--muted);font-size:11px">${esc(t.key)}</span>`:''}</td>
<td>${t.url?`<a class="link" href="${esc(t.url)}" target="_blank" rel="noopener">${esc(t.url.length>40?t.url.slice(0,40)+'…':t.url)} ↗</a>`:'<span style="color:var(--muted)">—</span>'}</td>
<td style="color:var(--ink-2);font-size:12px">${t.description?esc(t.description.split('\n')[0].slice(0,50))+(t.description.length>50?'…':''):'<span style="color:var(--muted)">—</span>'}</td>
<td style="text-align:right">
<button class="btn small" onclick="openTicketModal(${t.id})">Éditer</button>
<button class="btn small danger" onclick="deleteTicket(${t.id})">✕</button>
</td></tr>`).join('')}
</table>`;
}
function openTicketModal(id){
const t = id ? all('SELECT * FROM tickets WHERE id=?',[id])[0] : {id:null,key:'',label:'',url:'',description:''};
openModal(`
<h3>${id?'Éditer':'Nouveau'} ticket</h3>
<div class="field-row">
<div class="field"><label>Nom</label><input id="tk_label" value="${esc(t.label)}" placeholder="Ticket switch"></div>
<div class="field" style="max-width:140px"><label>Clé (optionnel)</label><input id="tk_key" value="${esc(t.key)}" placeholder="switch"></div>
</div>
<div class="field"><label>Lien</label><input id="tk_url" value="${esc(t.url)}" placeholder="https://itsm.exemple.com/nouveau-ticket"></div>
<div class="field"><label>Description à copier <span style="text-transform:none;font-weight:400">({name} = nom de la machine)</span></label>
<textarea id="tk_desc" style="min-height:140px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px" placeholder="Changement de configuration switch {name}…">${esc(t.description)}</textarea></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveTicket(${id||'null'})">Enregistrer</button>
</div>`);
}
function saveTicket(id){
const label = document.getElementById('tk_label').value.trim();
if(!label){ alert('Nom requis'); return; }
let url = document.getElementById('tk_url').value.trim();
if(url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
const key = document.getElementById('tk_key').value.trim() || null;
const desc = document.getElementById('tk_desc').value;
if(id){
run('UPDATE tickets SET key=?,label=?,url=?,description=? WHERE id=?',[key,label,url,desc,id]);
}else{
const sort = all('SELECT COALESCE(MAX(sort),-1)+1 s FROM tickets')[0].s;
run('INSERT INTO tickets(key,label,url,description,sort) VALUES (?,?,?,?,?)',[key,label,url,desc,sort]);
}
syncTicketsToLocalStorage();
closeModal(); renderTicketsConfig();
}
function deleteTicket(id){
if(!confirm('Supprimer ce ticket ?')) return;
run('DELETE FROM tickets WHERE id=?',[id]);
syncTicketsToLocalStorage();
renderTicketsConfig();
}
/* Ticket lié à un contexte (ex : key='switch') → bloc action dans le détail machine. */
function ticketByKey(key){ return all('SELECT * FROM tickets WHERE key=? ORDER BY sort LIMIT 1',[key])[0] || null; }
/* {name} → nom machine ; {{ config }} → configuration switch (espaces tolérés). */
function fillTicketDesc(tpl, machineName, config){
return (tpl||'')
.replace(/\{name\}/g, machineName||'')
.replace(/\{\{\s*config\s*\}\}/g, config||'');
}
async function copyTicketDesc(ticketId, machineId){
const t = all('SELECT description FROM tickets WHERE id=?',[ticketId])[0];
const mm = machineId ? all('SELECT name,switch_config FROM machines WHERE id=?',[machineId])[0] : null;
const txt = fillTicketDesc(t ? t.description : '', mm ? mm.name : '', mm ? (mm.switch_config||'') : '');
try{ await navigator.clipboard.writeText(txt); showToast('Description du ticket copiée', 'success'); }
catch(e){
// repli : sélection manuelle via prompt
window.prompt('Copiez la description du ticket :', txt);
}
}
/* ============================================================
CATALOGUE OS
============================================================ */
function osMap(){
const rows = all('SELECT o.id, o.name, v.version FROM os_catalog o LEFT JOIN os_versions v ON v.os_id=o.id ORDER BY o.name COLLATE NOCASE, v.version');
const m = {};
for(const r of rows){
m[r.name] = m[r.name] || { id:r.id, versions:[] };
if(r.version) m[r.name].versions.push(r.version);
}
return m;
}
function renderOsCatalog(){
const el = document.getElementById('osContainer');
if(!el) return;
const map = osMap();
const names = Object.keys(map);
if(!names.length){ el.innerHTML = `<div class="empty" style="padding:20px 0"><span class="big">💿</span>Aucun OS. <br><button class="btn primary" onclick="openOsModal()"> Ajouter un OS</button></div>`; return; }
el.innerHTML = `<div class="table-wrap"><table>
<tr><th>Système d'exploitation</th><th>Versions</th><th style="width:130px"></th></tr>
${names.map(name=>{
const o = map[name];
const used = all('SELECT COUNT(*) c FROM machines WHERE os=?',[name])[0].c;
const chips = o.versions.length
? `<div class="os-chips">${o.versions.map(v=>`<span class="chip">${esc(v)}</span>`).join('')}</div>`
: '<span style="color:var(--muted)">— aucune version —</span>';
return `<tr>
<td><span class="name">${esc(name)}</span>${used?` <span class="count-pill" title="${used} machine(s)">${used}</span>`:''}</td>
<td>${chips}</td>
<td style="text-align:right">
<button class="btn small" onclick="openOsModal(${o.id})">Éditer</button>
<button class="btn small danger" onclick="deleteOs(${o.id})">✕</button>
</td></tr>`;
}).join('')}
</table></div>`;
}
function openOsModal(id){
const o = id ? all('SELECT * FROM os_catalog WHERE id=?',[id])[0] : {name:''};
const vers = id ? all('SELECT version FROM os_versions WHERE os_id=? ORDER BY version',[id]).map(r=>r.version) : [];
openModal(`
<h3>${id?'Éditer':'Nouvel'} OS</h3>
<div class="field"><label>Nom de l'OS</label><input id="f_os_name" value="${esc(o.name)}" placeholder="Debian, Windows Server…"></div>
<div class="field">
<label>Versions</label>
<div id="verRows"></div>
<button class="btn small" onclick="addVerRow()"> Ajouter une version</button>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveOs(${id||'null'})">Enregistrer</button>
</div>`);
if(vers.length) vers.forEach(v => addVerRow(v)); else addVerRow();
}
function addVerRow(val=''){
const row = document.createElement('div');
row.className = 'ip-row';
row.innerHTML = `<input class="ver-in" value="${esc(val)}" placeholder="12, 24.04 LTS, 2022…">
<button class="btn ghost small" onclick="this.parentElement.remove()">✕</button>`;
document.getElementById('verRows').appendChild(row);
}
function saveOs(id){
const name = document.getElementById('f_os_name').value.trim();
if(!name){ alert('Nom requis'); return; }
const dup = all('SELECT id FROM os_catalog WHERE name=? AND id<>?',[name,id||0]);
if(dup.length){ alert('Cet OS existe déjà.'); return; }
const oldName = id ? all('SELECT name FROM os_catalog WHERE id=?',[id])[0].name : null;
// db.run direct : persist()→db.export() remet last_insert_rowid() à 0 (voir saveMachine)
if(id){
db.run('UPDATE os_catalog SET name=? WHERE id=?',[name,id]);
if(oldName && oldName !== name) db.run('UPDATE machines SET os=? WHERE os=?',[name,oldName]); // garder les machines liées
}else{
db.run('INSERT INTO os_catalog(name) VALUES (?)',[name]);
id = all('SELECT last_insert_rowid() i')[0].i;
}
db.run('DELETE FROM os_versions WHERE os_id=?',[id]);
const seen = new Set();
document.querySelectorAll('#verRows .ver-in').forEach(inp=>{
const v = inp.value.trim();
if(v && !seen.has(v)){ seen.add(v); db.run('INSERT INTO os_versions(os_id,version) VALUES (?,?)',[id,v]); }
});
persist();
closeModal(); renderOsCatalog();
}
function deleteOs(id){
const name = all('SELECT name FROM os_catalog WHERE id=?',[id])[0].name;
const used = all('SELECT COUNT(*) c FROM machines WHERE os=?',[name])[0].c;
if(!confirm(used ? `${used} machine(s) utilisent "${name}". L'OS restera inscrit sur ces machines mais sortira du catalogue. Supprimer ?` : `Supprimer l'OS "${name}" du catalogue ?`)) return;
run('DELETE FROM os_versions WHERE os_id=?',[id]);
run('DELETE FROM os_catalog WHERE id=?',[id]);
renderOsCatalog();
}
/* ============================================================
DASHBOARD
============================================================ */
const ICONS = {
server:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>',
net:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/></svg>',
room:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><path d="M9 6h6M9 10h6M9 14h6"/></svg>',
rack:'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M4 9h16M4 15h16"/></svg>',
};
function renderDashboard(){
const nMachines = all('SELECT COUNT(*) c FROM machines')[0].c;
const vKeys = Object.keys(TYPES).filter(k => TYPES[k].virtual);
const nVirt = vKeys.length ? all(`SELECT COUNT(*) c FROM machines WHERE type IN (${vKeys.map(()=>'?').join(',')})`, vKeys)[0].c : 0;
const nPhys = nMachines - nVirt;
const nNets = all('SELECT COUNT(*) c FROM networks')[0].c;
const nRooms = all('SELECT COUNT(*) c FROM rooms')[0].c;
const nRacks = all('SELECT COUNT(*) c FROM racks')[0].c;
const nIps = all('SELECT COUNT(*) c FROM ips')[0].c;
document.getElementById('dashTiles').innerHTML = [
['Machines', nMachines, nPhys+' physiques · '+nVirt+' virtuelles', ICONS.server],
['Réseaux', nNets, nIps+' IPs allouées', ICONS.net],
['Salles', nRooms, nRacks+' baies au total', ICONS.room],
['Baies', nRacks, '', ICONS.rack],
].map(([l,v,s,ic]) => `<div class="tile">
<div class="t-icon">${ic}</div>
<div class="label">${l}</div><div class="value">${v}</div>
${s?`<div class="sub">${s}</div>`:''}</div>`).join('');
/* barre horizontale générique */
const bar = (label, valText, pct, color) => `<div style="display:flex;align-items:center;gap:10px;margin:7px 0">
<span style="width:150px;flex-shrink:0;font-size:12px;color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${label}</span>
<div style="flex:1;height:16px;background:var(--surface-3);border-radius:8px;overflow:hidden">
<div style="height:100%;width:${Math.max(0,Math.min(100,pct))}%;background:${color};border-radius:8px"></div></div>
<span style="width:64px;text-align:right;font-size:12px;font-weight:600;color:var(--ink);white-space:nowrap">${valText}</span>
</div>`;
/* Machines par statut */
const st = all('SELECT status, COUNT(*) c FROM machines GROUP BY status ORDER BY c DESC');
const maxSt = Math.max(1, ...st.map(r=>r.c));
document.getElementById('dashStatus').innerHTML = nMachines ? st.map(r=>{
const s = STATUSES[r.status] || { label:'Sans statut', color:'#7c8189' };
return bar(esc(s.label), r.c, r.c/maxSt*100, s.color);
}).join('') : `<div class="empty">Aucune machine.</div>`;
/* Occupation des baies (U occupés / hauteur) */
const racks = all(`SELECT r.name, r.height_u, ro.name room,
(SELECT COALESCE(SUM(size_u),0) FROM machines WHERE rack_id=r.id AND position_u IS NOT NULL) used
FROM racks r JOIN rooms ro ON ro.id=r.room_id ORDER BY ro.name, r.name`);
document.getElementById('dashRacks').innerHTML = racks.length ? racks.map(r=>{
const pct = r.height_u ? r.used/r.height_u*100 : 0;
const color = pct>=90 ? '#e66767' : pct>=70 ? '#e8b34b' : '#0ca30c';
return bar(esc(r.room+' / '+r.name), `${r.used}/${r.height_u} U`, pct, color);
}).join('') : `<div class="empty">Aucune baie. Créez-en dans « Salles serveur ».</div>`;
}
/* ============================================================
RESEAUX
============================================================ */
let netSort = { col:'name', dir:1 };
function sortNet(col){
if(netSort.col === col) netSort.dir *= -1; else { netSort.col = col; netSort.dir = 1; }
renderNetworks();
}
function renderNetworks(){
const nets = all(`SELECT n.*, (SELECT COUNT(*) FROM ips WHERE network_id=n.id) nb FROM networks n`);
const k = netSort.col;
nets.sort((a,b)=>{
let av, bv;
if(k==='vlan'){ av=a.vlan??-1; bv=b.vlan??-1; }
else if(k==='nb'){ av=a.nb; bv=b.nb; }
else if(k==='subnet'){ av=(parseCidr(a.subnet)||{base:-1}).base; bv=(parseCidr(b.subnet)||{base:-1}).base; }
else if(k==='gateway'){ av=ipToInt(a.gateway)??-1; bv=ipToInt(b.gateway)??-1; }
else { av=(a.name||'').toLowerCase(); bv=(b.name||'').toLowerCase(); }
return (av<bv?-1:av>bv?1:0) * netSort.dir;
});
const arw = c => netSort.col===c ? (netSort.dir>0?' ▲':' ▼') : '';
const th = (c,l,extra='') => `<th style="cursor:pointer;user-select:none" ${extra} onclick="sortNet('${c}')">${l}${arw(c)}</th>`;
document.getElementById('networksTable').innerHTML = nets.length ? `<table>
<tr>${th('name','Nom')}${th('vlan','VLAN')}${th('subnet','Subnet')}${th('gateway','Gateway')}${th('nb','IPs')}<th style="width:130px"></th></tr>
${nets.map(n=>`<tr>
<td><span class="dot" style="background:${esc(n.color)}"></span><span class="link" onclick="networkDetail(${n.id})">${esc(n.name)}</span></td>
<td>${n.vlan!=null?`<span class="count-pill">${n.vlan}</span>`:'<span style="color:var(--muted)">—</span>'}</td>
<td>${esc(n.subnet)}</td><td>${esc(n.gateway)}</td>
<td><span class="count-pill">${n.nb}</span></td>
<td style="text-align:right">
<button class="btn small" onclick="openNetworkModal(${n.id})">Éditer</button>
<button class="btn small danger" onclick="deleteNetwork(${n.id})">✕</button>
</td></tr>`).join('')}
</table>` : `<div class="empty"><span class="big">🌐</span>Aucun réseau.<br>
<button class="btn primary" onclick="openNetworkModal()"> Créer un réseau</button></div>`;
}
/* Fiche réseau : toutes les IPs avec machine + MAC */
function networkDetail(id){
const n = all('SELECT * FROM networks WHERE id=?',[id])[0];
if(!n) return;
const rows = all(`SELECT i.ip, i.mac, i.dhcp, m.id mid, m.name mname, m.type mtype
FROM ips i JOIN machines m ON m.id = i.machine_id
WHERE i.network_id=? ORDER BY i.dhcp, i.ip`,[id]);
openModal(`
<h3><span class="dot" style="background:${esc(n.color)};width:14px;height:14px"></span>${esc(n.name)}</h3>
<div class="detail-grid">
<dt>VLAN</dt><dd>${n.vlan!=null?esc(n.vlan):'—'}</dd>
<dt>Subnet</dt><dd>${esc(n.subnet)||'—'}</dd>
<dt>Gateway</dt><dd>${esc(n.gateway)||'—'}</dd>
<dt>IPs allouées</dt><dd>${rows.length}</dd>
${n.description?`<dt>Description</dt><dd style="white-space:pre-wrap">${esc(n.description)}</dd>`:''}
</div>
<div class="detail-section">
<h4>Adresses IP</h4>
${rows.length ? `<table>
<tr><th>IP</th><th>Machine</th><th>Type</th><th>MAC</th></tr>
${rows.map(r=>`<tr>
<td style="font-variant-numeric:tabular-nums">${r.dhcp?'<span class="badge" style="background:#5b9cf522;color:#5b9cf5;border:1px solid #5b9cf555">DHCP</span>':esc(r.ip)}</td>
<td><span class="link" onclick="machineDetail(${r.mid})">${esc(r.mname)}</span></td>
<td>${typeBadge(r.mtype)}</td>
<td style="font-variant-numeric:tabular-nums;color:var(--muted)">${esc(r.mac)||'—'}</td>
</tr>`).join('')}
</table>` : `<div class="empty" style="padding:16px 0">Aucune IP sur ce réseau.</div>`}
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Fermer</button>
<button class="btn primary" onclick="openNetworkModal(${id})">Éditer</button>
</div>`);
}
function openNetworkModal(id){
const n = id ? all('SELECT * FROM networks WHERE id=?',[id])[0]
: {name:'',subnet:'',gateway:'',color:nextPastel(),vlan:'',description:''};
openModal(`
<h3>${id?'Éditer':'Nouveau'} réseau</h3>
<div class="field"><label>Nom</label><input id="f_name" value="${esc(n.name)}" placeholder="LAN, DMZ, Management…"></div>
<div class="field-row">
<div class="field"><label>Subnet</label><input id="f_subnet" value="${esc(n.subnet)}" placeholder="192.168.1.0/24"></div>
<div class="field"><label>Gateway</label>
<div id="gwBox" class="ip-row" style="margin:0" data-mode="${(n.gateway||!id)?'gw':'none'}">
<div class="mode-switch">
<button type="button" class="ms-opt ms-gw-on" onclick="setGwMode('gw')">Gateway</button>
<button type="button" class="ms-opt ms-gw-off" onclick="setGwMode('none')">Aucune</button>
</div>
<input id="f_gateway" value="${esc(n.gateway)}" placeholder="192.168.1.1" style="flex:1">
</div>
</div>
</div>
<div class="field"><label>VLAN</label>
<div id="vlanBox" class="ip-row" style="margin:0" data-mode="${n.vlan!=null?'vlan':'none'}">
<div class="mode-switch">
<button type="button" class="ms-opt ms-vl-on" onclick="setVlanMode('vlan')">VLAN</button>
<button type="button" class="ms-opt ms-vl-off" onclick="setVlanMode('none')">Aucun</button>
</div>
<input type="number" id="f_vlan" min="1" max="4094" value="${n.vlan??''}" placeholder="14094" style="flex:1">
</div>
</div>
<div class="field"><label>Couleur ${id?'':'(attribuée automatiquement)'}</label><input type="color" id="f_color" value="${esc(n.color)}"></div>
<div class="field"><label>Description</label><textarea id="f_desc" placeholder="VLAN, usage, notes…">${esc(n.description)}</textarea></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveNetwork(${id||'null'})">Enregistrer</button>
</div>`);
setVlanMode(n.vlan!=null ? 'vlan' : 'none');
setGwMode((n.gateway||!id) ? 'gw' : 'none');
}
function setGwMode(mode){
const box = document.getElementById('gwBox');
box.dataset.mode = mode;
box.querySelector('.ms-gw-on').classList.toggle('active', mode==='gw');
box.querySelector('.ms-gw-off').classList.toggle('active', mode!=='gw');
const inp = document.getElementById('f_gateway');
inp.style.display = mode==='gw' ? '' : 'none';
if(mode!=='gw'){ inp.value=''; inp.style.borderColor=''; }
}
function setVlanMode(mode){
const box = document.getElementById('vlanBox');
box.dataset.mode = mode;
box.querySelector('.ms-vl-on').classList.toggle('active', mode==='vlan');
box.querySelector('.ms-vl-off').classList.toggle('active', mode!=='vlan');
const inp = document.getElementById('f_vlan');
inp.style.display = mode==='vlan' ? '' : 'none';
if(mode!=='vlan'){ inp.value=''; inp.style.borderColor=''; }
}
function saveNetwork(id){
const name = document.getElementById('f_name').value.trim();
if(!name){ alert('Nom requis'); return; }
const subnet = document.getElementById('f_subnet').value.trim();
const gwMode = document.getElementById('gwBox').dataset.mode;
const gateway = gwMode === 'gw' ? document.getElementById('f_gateway').value.trim() : '';
if(subnet && !parseCidr(subnet)){ alert('Subnet invalide. Format attendu : 192.168.1.0/24'); return; }
if(gateway && !validIp(gateway)){ alert('Gateway invalide. Format attendu : 192.168.1.1'); return; }
if(subnet && gateway && !ipInCidr(gateway, subnet)){ alert(`La gateway ${gateway} n'appartient pas au subnet ${subnet}.`); return; }
const vlanMode = document.getElementById('vlanBox').dataset.mode;
const vlanRaw = document.getElementById('f_vlan').value.trim();
let vlan = null;
if(vlanMode === 'vlan' && vlanRaw !== ''){
vlan = parseInt(vlanRaw,10);
if(isNaN(vlan) || vlan < 1 || vlan > 4094){ alert('VLAN invalide. Valeur attendue : 1 à 4094.'); return; }
}
const desc = document.getElementById('f_desc').value.trim();
const p = [name, subnet, gateway, document.getElementById('f_color').value, vlan, desc];
if(id) run('UPDATE networks SET name=?,subnet=?,gateway=?,color=?,vlan=?,description=? WHERE id=?',[...p,id]);
else run('INSERT INTO networks(name,subnet,gateway,color,vlan,description) VALUES (?,?,?,?,?,?)',p);
closeModal(); renderAll();
}
function deleteNetwork(id){
const nb = all('SELECT COUNT(*) c FROM ips WHERE network_id=?',[id])[0].c;
if(!confirm(nb ? `${nb} IP(s) liée(s) à ce réseau seront détachées. Supprimer ?` : 'Supprimer ce réseau ?')) return;
run('UPDATE ips SET network_id=NULL WHERE network_id=?',[id]);
run('DELETE FROM networks WHERE id=?',[id]);
renderAll();
}
/* ============================================================
MACHINES
============================================================ */
function ipsGrouped(){
const rows = all(`SELECT i.*, n.color, n.name net_name FROM ips i LEFT JOIN networks n ON n.id=i.network_id
WHERE i.dhcp=1 OR (i.ip IS NOT NULL AND TRIM(i.ip)<>'')`);
const g = {};
for(const r of rows){ (g[r.machine_id] = g[r.machine_id] || []).push(r); }
return g;
}
function renderMachines(){
const q = (document.getElementById('fName').value||'').toLowerCase();
const sf = document.getElementById('fStatus').value;
const nf = document.getElementById('fNet').value;
const qIp = (document.getElementById('fIp').value||'').toLowerCase();
const qMac= (document.getElementById('fMac').value||'').toLowerCase();
let ms = all(`SELECT m.*, h.name host_name, r.name rack_name, ro.name room_name
FROM machines m
LEFT JOIN machines h ON h.id = m.host_id
LEFT JOIN racks r ON r.id = m.rack_id
LEFT JOIN rooms ro ON ro.id = r.room_id
ORDER BY m.name`);
const g = ipsGrouped();
/* Filtres combinables (ET logique) */
ms = ms.filter(m => {
const mIps = g[m.id]||[];
if(fTypeSel.size && !fTypeSel.has(m.type)) return false;
if(sf && m.status !== sf) return false;
if(nf && !mIps.some(i => String(i.network_id) === nf)) return false;
if(qIp && !mIps.some(i => (i.ip||'').toLowerCase().includes(qIp))) return false;
if(qMac && !mIps.some(i => (i.mac||'').toLowerCase().includes(qMac))) return false;
if(q && !(m.name.toLowerCase().includes(q) || (m.os||'').toLowerCase().includes(q) || (m.assigned_user||'').toLowerCase().includes(q))) return false;
return true;
});
document.getElementById('machineCount').textContent = ms.length + ' machine(s)';
document.getElementById('machinesTable').innerHTML = ms.length ? `<table>
<tr><th>Nom</th><th>Type</th><th>Statut</th><th>Hébergement</th><th>IPs / MAC</th><th>OS</th><th style="width:130px"></th></tr>
${ms.map(m=>{
const hosted = all('SELECT COUNT(*) c FROM machines WHERE host_id=?',[m.id])[0].c;
const td = TYPES[m.type] || {};
const loc = td.virtual
? (m.host_name ? '↳ sur <span class="link" onclick="machineDetail('+m.host_id+')">'+esc(m.host_name)+'</span>' : '<span style="color:var(--muted)">hôte non défini</span>')
: td.rackable
? (m.rack_name ? `${esc(m.room_name||'')} / ${esc(m.rack_name)} · U${m.position_u}${m.size_u>1?'U'+(m.position_u+m.size_u-1):''}` :
(m.size_u ? m.size_u+'U — non racké' : '<span style="color:var(--muted)">—</span>'))
: (m.assigned_user ? '👤 <span data-user="'+esc(m.assigned_user)+'">'+esc(m.assigned_user)+'</span>' : '<span style="color:var(--muted)">—</span>');
const ipHtml = (g[m.id]||[]).map(i =>
`<span class="chip"><span class="dot" style="background:${esc(i.color||'#7c8189')}"></span>${i.dhcp?`<span style="color:var(--muted)">DHCP</span>${i.net_name?' '+esc(i.net_name):''}`:esc(i.ip)}${i.mac?` <span class="mac">${esc(i.mac)}</span>`:''}</span>`
).join('') || '<span style="color:var(--muted)">—</span>';
return `<tr>
<td><span class="name link" style="color:var(--ink)" data-mid="${m.id}" onclick="machineDetail(${m.id})">${esc(m.name)}</span>
${hosted?` <span class="count-pill" title="${hosted} machine(s) hébergée(s)">⌂ ${hosted}</span>`:''}</td>
<td>${typeBadge(m.type)}</td>
<td>${statusBadge(m.status)||'<span style="color:var(--muted)">—</span>'}</td>
<td>${loc}</td>
<td>${ipHtml}</td>
<td>${esc(m.os||'')}${m.os_version?' <span style="color:var(--muted)">'+esc(m.os_version)+'</span>':''}</td>
<td>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button class="btn small" title="Éditer" onclick="openMachineModal(${m.id})">✎</button>
<button class="btn small" title="Dupliquer" onclick="openMachineModal(${m.id}, true)">⧉</button>
<button class="btn small danger" title="Supprimer" onclick="deleteMachine(${m.id})">✕</button>
</div>
</td></tr>`;
}).join('')}
</table>` : `<div class="empty"><span class="big">🖥</span>Aucune machine ne correspond aux filtres.</div>`;
}
/* Fiche machine : infos + tout ce qu'elle héberge */
/* ============================================================
SWITCH — analyse VLAN d'une conf collée + passerelle vers le
générateur (switch-config/switch_vlan_mapper.html).
Algo aligné sur parseConfig() du générateur (H3C/Comware) :
« port access vlan N » = accès, « port link-type trunk » = trunk.
============================================================ */
// Base VLAN (id = nom = couleur) — miroir de switch-config/vlan_database.js
const SWITCH_VLAN_DB_TEXT = `92 = DMZ = #8b5cf6
105 = DEV1 = #26d953
14 = DEV3CC = #ec4899
16 = USR3CC = #f43f5e
15 = DEV2 = #f97316
13 = USR2 = #fb923c
18 = ULAB1 = #22c55e
192 = TELCO0 = #2dd4bf
17 = MGTSW = #6366f1
8 = PROD1 = #4ade80
104 = USR1 = #06b6d4
7 = ULAB2 = #EAB308
6 = TLAB2 = #FACC15`;
let _switchVlanDb = null;
function switchVlanDb(){
if(_switchVlanDb) return _switchVlanDb;
_switchVlanDb = {};
SWITCH_VLAN_DB_TEXT.split('\n').forEach(l=>{
const p = l.split('=').map(s=>s.trim());
if(p.length===3) _switchVlanDb[p[0]] = { name:p[1], color:p[2] };
});
return _switchVlanDb;
}
/* Extrait l'usage VLAN d'une conf : ports d'accès par VLAN + nb de trunks. */
function parseSwitchVlans(text){
const lines = (text||'').split('\n');
const vlans = {}; let trunk = 0, ports = 0; let cur = null;
const finalize = ()=>{
if(cur && cur.phys){
ports++;
if(cur.type==='trunk') trunk++;
else if(cur.vlan){ (vlans[cur.vlan] = vlans[cur.vlan] || {count:0}).count++; }
}
cur = null;
};
for(const line of lines){
const t = line.trim();
if(t.startsWith('interface ')){
finalize();
const name = t.substring(10).trim();
cur = { type:'access', vlan:null,
phys: /GigabitEthernet|FortyGigE|HundredGigE/i.test(name) && !/Vlan-interface/i.test(name) };
}else if(t === '#'){ finalize(); }
else if(cur){
if(t.startsWith('port access vlan ')){ cur.vlan = t.substring(17).trim(); cur.type='access'; }
else if(t.startsWith('port link-type trunk')){ cur.type='trunk'; }
}
}
finalize();
return { vlans, trunk, ports };
}
/* Rendu des VLAN détectés en pastilles (nom + couleur depuis la base). */
function switchVlanChips(text){
const r = parseSwitchVlans(text);
const ids = Object.keys(r.vlans).sort((a,b)=>(+a)-(+b));
if(!ids.length && !r.trunk)
return '<span style="color:var(--muted);font-size:12px">Aucun VLAN détecté. Collez une configuration d\'interfaces.</span>';
const db = switchVlanDb();
let html = ids.map(id=>{
const info = db[id] || { name:'VLAN '+id, color:'#7c8189' };
const c = r.vlans[id].count;
return `<span class="chip"><span class="dot" style="background:${esc(info.color)}"></span>VLAN ${esc(id)} · ${esc(info.name)} <span style="color:var(--muted)">${c} port${c>1?'s':''}</span></span>`;
}).join(' ');
if(r.trunk) html += ` <span class="chip"><span class="dot" style="background:#94a3b8"></span>${r.trunk} trunk${r.trunk>1?'s':''}</span>`;
return html;
}
/* Membres de stack présents dans la conf : n° de membre distincts (1,2,3…)
déduits des interfaces <membre>/<slot>/<port>. Un seul membre = pas un stack. */
function parseSwitchStacks(text){
// Le n° de membre du stack = 1er champ de la forme <membre>/<slot>/<port>.
// On ne filtre PAS par type d'interface (GE/XGE/FGE/abréviations…) : seule la
// forme compte. Vlan-interfaceN, NULL0, LoopBack… n'ont pas cette forme.
const set = new Set();
(text||'').split('\n').forEach(line=>{
const t = line.trim();
if(!t.startsWith('interface ')) return;
const name = t.substring(10).trim();
if(/^M-/i.test(name)) return; // port de management out-of-band (membre 0)
const m = name.match(/(\d+)\/\d+\/\d+/);
if(m) set.add(parseInt(m[1]));
});
return [...set].sort((a,b)=>a-b);
}
/* Options <select> de baie (avec sélection courante). */
function rackOptionsHtml(cur){
return '<option value="">— aucune baie —</option>' + (window._racks||[]).map(r=>
`<option value="${r.id}" ${String(cur)===String(r.id)?'selected':''}>${esc(r.room)} / ${esc(r.name)} (${r.height_u}U)</option>`).join('');
}
/* Aperçu VLAN en direct dans l'éditeur de machine + champs de stack. */
function renderSwitchVlanPreview(){
const ta = document.getElementById('f_switch_config');
const box = document.getElementById('switchVlanPreview');
if(!ta || !box) return;
box.innerHTML = switchVlanChips(ta.value);
renderStackFields(ta.value);
}
/* Stack détecté : chaque membre au-delà du 1er devient une COPIE complète du switch
principal (mêmes infos), seuls le nom et l'emplacement en baie changent.
Le nom est éditable, pré-rempli avec celui du switch principal (« le précédent »). */
function renderStackFields(text){
const wrap = document.getElementById('stackFields'); if(!wrap) return;
// Un membre de stack ne génère pas ses propres sous-membres.
if(window._isStackMember){ wrap.innerHTML = ''; wrap.style.display = 'none'; return; }
const stacks = parseSwitchStacks(text);
if(stacks.length <= 1){ wrap.innerHTML = ''; wrap.style.display = 'none'; return; }
wrap.style.display = '';
const members = window._stackMembers || {};
const extra = stacks.slice(1);
const primaryName = (document.getElementById('f_name')?.value || '').trim();
wrap.innerHTML = `<div class="hint" style="margin-top:10px">Stack de ${stacks.length} switchs détecté. Chaque membre supplémentaire est créé comme une copie du switch (mêmes infos) — indiquez son nom et sa position en baie.</div>`
+ extra.map(sid=>{
const ex = members[sid] || {};
const nameVal = ex.name != null ? ex.name : primaryName;
return `<div class="field-row" data-stack="${sid}" style="margin-top:8px">
<div class="field"><label>Switch #${sid} — Nom</label><input class="stk_name" value="${esc(nameVal)}" placeholder="${esc(primaryName||'Nom du switch')}"></div>
<div class="field"><label>Baie</label><select class="stk_rack">${rackOptionsHtml(ex.rack_id)}</select></div>
<div class="field" style="max-width:130px"><label>Position (U bas)</label><input type="number" min="1" class="stk_pos" value="${ex.position_u??''}" placeholder="ex: 11"></div>
</div>`;
}).join('');
}
/* Ouvre le générateur de switch avec la conf pré-remplie (handoff via #cfg + #name). */
function openSwitchGenerator(cfg, name){
if(!cfg || !cfg.trim()){ alert('Aucune configuration à transmettre. Renseignez la configuration du switch.'); return; }
let url = '../switch-config/switch_vlan_mapper.html#cfg=' + encodeURIComponent(cfg);
if(name) url += '&name=' + encodeURIComponent(name);
// Transmet aussi le ticket switch (fiable même si localStorage n'est pas partagé sous file://)
const tk = ticketByKey('switch');
if(tk){
if(tk.url) url += '&tkurl=' + encodeURIComponent(tk.url);
if(tk.description) url += '&tkdesc=' + encodeURIComponent(tk.description);
}
window.open(url, '_blank');
}
/* Même chose, en récupérant la conf enregistrée de la machine. */
function openSwitchGeneratorFor(id){
const r = all('SELECT name,switch_config FROM machines WHERE id=?',[id])[0];
openSwitchGenerator(r ? r.switch_config : '', r ? r.name : '');
}
/* ============================================================
CERTIFICATS — index partagé par le générateur (localStorage 'cert_index')
============================================================ */
function certIndex(){ try{ return JSON.parse(localStorage.getItem('cert_index')||'[]'); }catch(e){ return []; } }
/* Ouvre le générateur de certificats, filtré sur un dossier si fourni. */
function openCertDashboard(dir){
const url = '../cert-generator/dashboard.html' + (dir ? ('#find=' + encodeURIComponent(dir)) : '');
window.open(url, '_blank');
}
/* Certificats dont un SAN IP correspond à une IP de la machine. */
function certsMatchingIps(ips){
if(!ips || !ips.length) return [];
const set = new Set(ips.filter(Boolean));
return certIndex().filter(c => (c.ip||[]).some(ip => set.has(ip)));
}
function machineDetail(id){
const m = all(`SELECT m.*, h.name host_name, r.name rack_name, ro.name room_name
FROM machines m
LEFT JOIN machines h ON h.id = m.host_id
LEFT JOIN racks r ON r.id = m.rack_id
LEFT JOIN rooms ro ON ro.id = r.room_id
WHERE m.id=?`,[id])[0];
if(!m) return;
const g = ipsGrouped();
const mIps = g[id]||[];
const hosted = all('SELECT * FROM machines WHERE host_id=? ORDER BY name',[id]);
const td = TYPES[m.type] || {};
const virt = td.virtual;
const canMigrate = td.virtual || td.rackable;
const ipHtml = mIps.map(i =>
`<span class="chip"><span class="dot" style="background:${esc(i.color||'#7c8189')}"></span>${i.dhcp?'<span style="color:var(--muted)">DHCP</span>':esc(i.ip)}${i.net_name?` <span style="color:var(--muted)">(${esc(i.net_name)})</span>`:''}${i.mac?` <span class="mac">${esc(i.mac)}</span>`:''}</span>`
).join('') || '—';
openModal(`
<h3>${esc(m.name)} ${typeBadge(m.type)} ${statusBadge(m.status)}</h3>
<div class="detail-grid">
${td.is_switch?'':`<dt>OS</dt><dd>${esc(m.os)||'—'}${m.os_version?' '+esc(m.os_version):''}</dd>`}
${virt
? `<dt>Hébergée sur</dt><dd>${m.host_id?`<span class="link" onclick="machineDetail(${m.host_id})">${esc(m.host_name)}</span>`:'—'}</dd>`
: td.rackable
? `<dt>Taille</dt><dd>${m.size_u?m.size_u+'U':'—'}</dd>
<dt>Emplacement</dt><dd>${m.rack_name?`${esc(m.room_name||'')} / ${esc(m.rack_name)} · U${m.position_u}${m.size_u>1?'U'+(m.position_u+m.size_u-1):''}`:'non racké'}</dd>`
: ''}
${td.assign_user?`<dt>Utilisateur</dt><dd>${m.assigned_user?`👤 <span data-user="${esc(m.assigned_user)}">${esc(m.assigned_user)}</span>`:'—'}</dd>`:''}
${td.assign_user && m.cable_number?`<dt>Câble sécurité</dt><dd>🔒 ${esc(m.cable_number)}${m.cable_code?`
<span id="cableCode" style="font-variant-numeric:tabular-nums;margin-left:8px;color:var(--ink)">••••</span>
<button class="btn small" style="margin-left:6px" title="Rester appuyé pour voir le code"
onmousedown="showCable(${m.id},true)" onmouseup="showCable(${m.id},false)" onmouseleave="showCable(${m.id},false)"
ontouchstart="event.preventDefault();showCable(${m.id},true)" ontouchend="showCable(${m.id},false)"
oncontextmenu="return false">👁 Voir</button>`:''}</dd>`:''}
<dt>IPs</dt><dd>${ipHtml}</dd>
${m.description?`<dt>Description</dt><dd><div class="desc-box" id="mdDesc">${esc(m.description)}</div></dd>`:''}
</div>
${td.is_switch?(()=>{ const tk = ticketByKey('switch'); return `
<div class="detail-section">
<h4>VLANs (switch)</h4>
${m.switch_config && m.switch_config.trim()
? `<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">${switchVlanChips(m.switch_config)}</div>
<button class="btn primary" onclick="openSwitchGeneratorFor(${id})">⚙️ Générer une nouvelle configuration</button>`
: `<div style="color:var(--muted);font-size:13px;margin-bottom:10px">Aucune configuration enregistrée. Ajoutez-la via Éditer.</div>
<button class="btn primary" onclick="window.open('../switch-config/switch_vlan_mapper.html','_blank')">⚙️ Générer une nouvelle configuration</button>`}
${tk?`<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<span style="font-size:12px;color:var(--muted)">${esc(tk.label)} :</span>
${tk.url?`<a class="btn small" href="${esc(tk.url)}" target="_blank" rel="noopener">🎫 Ouvrir le ticket ↗</a>`:''}
${tk.description?`<button class="btn small" onclick="copyTicketDesc(${tk.id},${id})">📋 Copier la description</button>`:''}
</div>`:''}
</div>`; })():''}
${(()=>{
const mappedList = (m.cert_ref||'').split('\n').map(s=>s.trim()).filter(Boolean);
const mappedSet = new Set(mappedList);
const machIps = mIps.map(i=>i.ip).filter(Boolean);
const matches = certsMatchingIps(machIps).filter(c=>!mappedSet.has(c.dir));
if(!mappedList.length && !matches.length) return '';
const q = s => String(s||'').replace(/'/g,"\\'");
return `<div class="detail-section">
<h4>Certificat${(mappedList.length+matches.length)>1?'s':''} SSL</h4>
${mappedList.length?`<div style="margin-bottom:${matches.length?'10px':'0'}">Mappé${mappedList.length>1?'s':''} : ${mappedList.map(mm=>`<span class="link" onclick="openCertDashboard('${q(mm)}')">${esc(mm)} ↗</span>`).join(' · ')}</div>`:''}
${matches.length?`<div style="font-size:12px;color:var(--muted);margin-bottom:6px">Correspond à une IP de la machine :</div>
${matches.map(c=>`<div class="hosted-item" onclick="openCertDashboard('${q(c.dir)}')">
<div><div class="hn">${esc(c.name||c.dir)}</div>
<div class="hi">${esc((c.ip||[]).join(', '))}${(c.dns&&c.dns.length)?' · '+esc(c.dns.join(', ')):''}</div></div>
<span style="color:var(--accent)">↗</span></div>`).join('')}`:''}
</div>`;
})()}
${(!virt && td.rackable && (m.brand||m.model||m.service_tag||m.cpu||m.ram_gb||m.support_start||m.support_end||m.idrac_ip||m.no_warranty))?`
<div class="detail-section">
<h4>Matériel</h4>
<div class="detail-grid">
${(m.brand||m.model)?`<dt>Modèle</dt><dd>${esc([m.brand,m.model].filter(Boolean).join(' '))}</dd>`:''}
${m.service_tag?`<dt>Service tag</dt><dd>${m.service_tag_url
?`<a class="link" href="${esc(m.service_tag_url)}" target="_blank" rel="noopener">${esc(m.service_tag)} ↗</a>`
:esc(m.service_tag)}</dd>`:''}
${m.cpu||m.cpu_cores||m.cpu_mhz?`<dt>CPU</dt><dd>${esc(m.cpu||'?')}${m.cpu_sockets?` · ${m.cpu_sockets}×`:''}${m.cpu_cores?` · ${m.cpu_cores} cores`:''}${m.cpu_mhz?` · ${m.cpu_mhz} MHz`:''}</dd>`:''}
${m.ram_gb?`<dt>RAM</dt><dd>${m.ram_gb} Go</dd>`:''}
${m.no_warranty
?`<dt>Support</dt><dd><span class="badge" style="background:#7c818922;color:#898781;border:1px solid #7c818955">Hors garantie</span></dd>`
:(m.support_start||m.support_end?`<dt>Support</dt><dd>${esc(m.support_start||'?')}${esc(m.support_end||'?')}${
m.support_end && m.support_end < new Date().toISOString().slice(0,10)
?' <span class="badge" style="background:#d03b3b22;color:#d03b3b;border:1px solid #d03b3b55">Expiré</span>':''}</dd>`:'')}
${m.idrac_ip?`<dt>iDRAC</dt><dd style="font-variant-numeric:tabular-nums">${esc(m.idrac_ip)}</dd>`:''}
</div>
</div>`:''}
<div class="detail-section">
<h4>Héberge (${hosted.length})</h4>
${hosted.length ? hosted.map(h=>{
const hIps = (g[h.id]||[]).map(i=>i.dhcp?'DHCP':i.ip).filter(Boolean).join(', ');
return `<div class="hosted-item" onclick="machineDetail(${h.id})">
<div><div class="hn">${esc(h.name)}</div>
<div class="hi">${esc(h.os||'')}${h.os_version?' '+esc(h.os_version):''}${hIps?' · '+esc(hIps):''}</div></div>
${typeBadge(h.type)}</div>`;
}).join('') : `<div style="color:var(--muted);font-size:13px">Aucune machine hébergée.</div>`}
</div>
${(td.in_ad && getSetting('ad_mode','off')==='ldap')?`
<div class="detail-section">
<h4>Active Directory (poste)</h4>
<div id="mdAd" style="color:var(--muted);font-size:13px">Recherche annuaire…</div>
</div>`:''}
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Fermer</button>
${canMigrate?`<button class="btn" onclick="openMigrateModal(${id})">⇄ Migrer</button>`:''}
<button class="btn" onclick="openMachineModal(${id}, true)"> Dupliquer</button>
<button class="btn primary" onclick="openMachineModal(${id})">Éditer</button>
</div>`);
// Description : replier seulement si elle déborde (> 3 lignes)
const dbx = document.getElementById('mdDesc');
if(dbx){ dbx.classList.add('clamped'); if(dbx.scrollHeight - dbx.clientHeight < 4) dbx.classList.remove('clamped'); }
// section AD-poste (asynchrone)
if(td.in_ad && getSetting('ad_mode','off')==='ldap'){
adComputerInfo(m.name)
.then(c=>{ const el=document.getElementById('mdAd'); if(!el) return;
el.innerHTML = c ? adComputerHtml(c) : '<span style="color:var(--muted)">Poste introuvable dans l\'AD</span>'; el.style.color=''; })
.catch(e=>{ const el=document.getElementById('mdAd'); if(el){ el.innerHTML='<span style="color:var(--danger)">'+esc(e.message)+'</span>'; } });
}
}
/* Code câble : visible uniquement pendant l'appui sur le bouton */
function showCable(id, show){
const el = document.getElementById('cableCode');
if(!el) return;
if(show){
const r = all('SELECT cable_code FROM machines WHERE id=?',[id])[0];
el.textContent = r && r.cable_code ? r.cable_code : '—';
}else{
el.textContent = '••••';
}
}
/* Migration : VM → change d'hôte ; physique rackable → change de baie/position */
function openMigrateModal(id){
const m = all('SELECT * FROM machines WHERE id=?',[id])[0];
if(!m) return;
const td = TYPES[m.type] || {};
let body;
if(td.virtual){
// seuls les hôtes capables d'héberger ce type virtuel (+ l'hôte courant conservé)
const hosts = all('SELECT id,name,type FROM machines WHERE id<>? ORDER BY name',[id])
.filter(h => (TYPES[h.type]?.hosts||[]).includes(m.type) || m.host_id===h.id);
body = `<div class="field"><label>Nouvel hôte</label>
<select id="mig_host"><option value="">— aucun (détacher) —</option>
${hosts.map(h=>{
const incompat = !(TYPES[h.type]?.hosts||[]).includes(m.type);
return `<option value="${h.id}" ${m.host_id===h.id?'selected':''}>${esc(h.name)} (${TYPES[h.type]?.label||h.type})${incompat?' — incompatible':''}</option>`;
}).join('')}
</select></div>`;
}else{
const racks = all(`SELECT r.id, r.name, r.height_u, ro.name room FROM racks r JOIN rooms ro ON ro.id=r.room_id ORDER BY ro.name, r.name`);
body = `<div class="field-row">
<div class="field"><label>Baie</label><select id="mig_rack">
<option value="">— hors baie —</option>
${racks.map(r=>`<option value="${r.id}" ${m.rack_id===r.id?'selected':''}>${esc(r.room)} / ${esc(r.name)} (${r.height_u}U)</option>`).join('')}
</select></div>
<div class="field" style="max-width:150px"><label>Position (U bas)</label><input type="number" id="mig_pos" min="1" value="${m.position_u||''}" placeholder="ex: 12"></div>
</div>
<div class="hint">Taille : ${m.size_u?m.size_u+'U':'non définie (requise pour racker)'}. Conflit et dépassement de hauteur vérifiés.</div>`;
}
openModal(`
<h3>⇄ Migrer « ${esc(m.name)} »</h3>
${body}
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveMigrate(${id})">Migrer</button>
</div>`);
}
function saveMigrate(id){
const m = all('SELECT * FROM machines WHERE id=?',[id])[0];
const td = TYPES[m.type] || {};
if(td.virtual){
run('UPDATE machines SET host_id=? WHERE id=?',[document.getElementById('mig_host').value||null, id]);
}else{
const rack_id = document.getElementById('mig_rack').value || null;
const posRaw = document.getElementById('mig_pos').value;
const size = m.size_u;
let position_u = null;
if(rack_id && posRaw !== ''){
if(size == null){ alert('Taille (U) requise pour placer la machine en baie. Renseignez-la via Éditer.'); return; }
position_u = parseInt(posRaw);
const rack = all('SELECT height_u FROM racks WHERE id=?',[rack_id])[0];
if(position_u < 1 || position_u + size - 1 > rack.height_u){
alert(`Hors baie : positions valides 1 à ${rack.height_u - size + 1} pour ${size}U.`); return;
}
const c = rackConflict(rack_id, position_u, size, id);
if(c){ alert(`Conflit avec « ${c.name} » (U${c.position_u}U${c.position_u+c.size_u-1}).`); return; }
}
run('UPDATE machines SET rack_id=?, position_u=? WHERE id=?',[rack_id, rack_id?position_u:null, id]);
}
closeModal(); renderAll();
}
function openMachineModal(id, asTemplate=false){
const m = id ? {...all('SELECT * FROM machines WHERE id=?',[id])[0]}
: {name:'',type:Object.keys(TYPES)[0]||'vm',status:Object.keys(STATUSES)[0]||'',host_id:null,size_u:1,rack_id:null,position_u:null,os:'',os_version:'',assigned_user:'',description:''};
if(asTemplate){ // duplication : modèle → nouvelle machine
m.name = (m.name||'') + ' - copie';
m.rack_id = null; m.position_u = null; // à re-placer (évite conflit de baie)
}
const isNew = !id || asTemplate;
const mIps = id ? all('SELECT * FROM ips WHERE machine_id=?',[id]) : [];
const hosts = all('SELECT id,name,type FROM machines WHERE id<>? ORDER BY name',[isNew ? 0 : id]);
window._allHosts = hosts; // pour rebuildHostOptions (filtrage par capacité d'hébergement)
const racks = all(`SELECT r.id, r.name, r.height_u, ro.name room FROM racks r JOIN rooms ro ON ro.id=r.room_id ORDER BY ro.name, r.name`);
window._racks = racks; // pour rackOptionsHtml (positions des membres de stack)
// nom + position déjà enregistrés des membres de stack (édition d'un switch existant)
window._stackMembers = {};
if(id && !asTemplate){
for(const r of all('SELECT stack_index, name, rack_id, position_u FROM machines WHERE stack_parent=?',[id]))
window._stackMembers[r.stack_index] = { name:r.name, rack_id:r.rack_id, position_u:r.position_u };
}
// Cette machine est-elle elle-même un membre de stack ? (→ pas de sous-membres)
window._isStackMember = !!(id && !asTemplate && (all('SELECT stack_parent FROM machines WHERE id=?',[id])[0]||{}).stack_parent);
const nets = all('SELECT id,name FROM networks ORDER BY name');
const typeOpts = Object.entries(TYPES).map(([k,v]) =>
`<option value="${k}" ${m.type===k?'selected':''}>${v.label}</option>`).join('');
const hostOpts = '<option value="">— choisir hôte —</option>' + hosts.map(h =>
`<option value="${h.id}" ${m.host_id===h.id?'selected':''}>${esc(h.name)} (${TYPES[h.type]?.label||h.type})</option>`).join('');
const rackOpts = '<option value="">— aucune baie —</option>' + racks.map(r =>
`<option value="${r.id}" ${m.rack_id===r.id?'selected':''}>${esc(r.room)} / ${esc(r.name)} (${r.height_u}U)</option>`).join('');
const statusOpts = '<option value="">— aucun —</option>' + Object.values(STATUSES).map(s =>
`<option value="${s.key}" ${m.status===s.key?'selected':''}>${esc(s.label)}</option>`).join('');
window._netOpts = nets; // pour addIpRow
const om = osMap();
window._osMap = om;
const osNames = Object.keys(om);
let osOpts = '<option value="">— aucun —</option>';
osNames.forEach(nm => osOpts += `<option value="${esc(nm)}" ${m.os===nm?'selected':''}>${esc(nm)}</option>`);
if(m.os && !osNames.includes(m.os)) osOpts += `<option value="${esc(m.os)}" selected>${esc(m.os)} (hors catalogue)</option>`;
const catalogOpts = (table, cur) => {
const names = all(`SELECT name FROM ${table} ORDER BY name COLLATE NOCASE`).map(r=>r.name);
let o = '<option value="">— aucun —</option>';
names.forEach(nm => o += `<option value="${esc(nm)}" ${cur===nm?'selected':''}>${esc(nm)}</option>`);
if(cur && !names.includes(cur)) o += `<option value="${esc(cur)}" selected>${esc(cur)} (hors catalogue)</option>`;
return o;
};
const cpuOpts = catalogOpts('hw_cpus', m.cpu);
window._cpuMap = {};
all('SELECT name,cores,mhz FROM hw_cpus').forEach(r => window._cpuMap[r.name] = r);
// marque → modèles (dépendant)
window._modelsByBrand = {};
all('SELECT brand,name FROM hw_models ORDER BY name COLLATE NOCASE').forEach(r=>{
const b = r.brand || '';
(window._modelsByBrand[b] = window._modelsByBrand[b] || []).push(r.name);
});
const brands = Object.keys(window._modelsByBrand).filter(Boolean).sort();
let brandOpts = '<option value="">— aucune —</option>';
brands.forEach(b => brandOpts += `<option value="${esc(b)}" ${m.brand===b?'selected':''}>${esc(b)}</option>`);
if(m.brand && !brands.includes(m.brand)) brandOpts += `<option value="${esc(m.brand)}" selected>${esc(m.brand)} (hors catalogue)</option>`;
openModal(`
<h3>${asTemplate?'Dupliquer → nouvelle machine':(id?'Éditer':'Nouvelle')+' machine'}</h3>
<div class="field-row">
<div class="field"><label>Nom</label><input id="f_name" value="${esc(m.name)}" placeholder="srv-web-01"></div>
<div class="field"><label>Type</label>
<select id="f_type" onchange="toggleMachineFields()">${typeOpts}</select></div>
<div class="field"><label>Statut</label><select id="f_status">${statusOpts}</select></div>
</div>
<div id="virtFields" class="field">
<label>Hébergée sur</label>
<select id="f_host">${hostOpts}</select>
</div>
<div id="physFields">
<div class="field-row">
<div class="field"><label>Taille (U)</label><input type="number" id="f_size" min="1" value="${m.size_u??''}" placeholder="optionnel"></div>
<div class="field"><label>Baie</label><select id="f_rack">${rackOpts}</select></div>
<div class="field"><label>Position (U bas)</label><input type="number" id="f_pos" min="1" value="${m.position_u||''}" placeholder="ex: 12"></div>
</div>
<div class="hint">Position = numéro d'unité la plus basse occupée (U1 = bas de baie). Laisser vide = non racké.</div>
<div id="hwFields">
<div class="field-row" style="margin-top:14px">
<div class="field"><label>Marque</label><select id="f_brand" onchange="fillModels()">${brandOpts}</select></div>
<div class="field"><label>Modèle</label><select id="f_model"></select></div>
<div class="field"><label>IP iDRAC</label><input id="f_idrac" value="${esc(m.idrac_ip)}" placeholder="192.168.0.120"></div>
</div>
<div class="field-row">
<div class="field"><label>Service tag</label><input id="f_stag" value="${esc(m.service_tag)}" placeholder="ABC1234"></div>
<div class="field"><label>Lien service tag (constructeur)</label><input id="f_stag_url" value="${esc(m.service_tag_url)}" placeholder="https://www.dell.com/support/…"></div>
</div>
<div class="field-row">
<div class="field" style="flex:2"><label>CPU</label><select id="f_cpu" onchange="cpuAutofill()">${cpuOpts}</select></div>
<div class="field"><label>Sockets</label><input type="number" id="f_sockets" min="1" value="${m.cpu_sockets??''}" placeholder="2" oninput="cpuAutofill()"></div>
<div class="field"><label>RAM (Go)</label><input type="number" id="f_ram" min="1" value="${m.ram_gb??''}" placeholder="64"></div>
</div>
<div class="field-row">
<div class="field"><label>Cores (total, auto)</label><input type="number" id="f_cores" min="1" value="${m.cpu_cores??''}" placeholder="auto"></div>
<div class="field"><label>MHz (auto)</label><input type="number" id="f_mhz" min="1" value="${m.cpu_mhz??''}" placeholder="auto"></div>
</div>
<div class="field-row" style="align-items:flex-end">
<div class="field"><label>Début de support</label><input type="date" id="f_sup_start" value="${esc(m.support_start)}"></div>
<div class="field"><label>Fin de support</label><input type="date" id="f_sup_end" value="${esc(m.support_end)}"></div>
<div class="field" style="max-width:170px">
<label style="display:flex;align-items:center;gap:8px;text-transform:none;letter-spacing:0;font-size:13px;color:var(--ink-2);cursor:pointer;margin-bottom:9px">
<input type="checkbox" id="f_nowar" ${m.no_warranty?'checked':''}> Hors garantie</label></div>
</div>
</div>
</div>
<div id="switchFields" class="field">
<label>Configuration switch <span style="text-transform:none;font-weight:400">(collez la conf, ex : « display current-configuration interface »)</span></label>
<textarea id="f_switch_config" placeholder="Collez ici la configuration du switch (VLANs, interfaces…)" style="min-height:120px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px" oninput="renderSwitchVlanPreview()">${esc(m.switch_config)}</textarea>
<div id="switchVlanPreview" style="margin-top:8px"></div>
<div id="stackFields" style="display:none"></div>
</div>
<div class="field-row" style="margin-top:14px" id="osFields">
<div class="field"><label>OS</label><select id="f_os" onchange="fillOsVersions()">${osOpts}</select></div>
<div class="field"><label>Version OS</label><select id="f_osv"></select></div>
</div>
<div class="field">
<label>Certificats SSL <span style="text-transform:none;font-weight:400">(dossier/CN côté générateur)</span></label>
<div id="certRows"></div>
<datalist id="certList">${certIndex().map(c=>`<option value="${esc(c.dir)}">${esc(c.name||'')}</option>`).join('')}</datalist>
</div>
<div id="userField">
<div class="field" style="position:relative"><label>Utilisateur assigné</label>
<input id="f_user" value="${esc(m.assigned_user)}" placeholder="Nom de l'utilisateur" autocomplete="off"
oninput="adUserSearch(this.value)" onblur="setTimeout(()=>hideUserSug(),180)">
<div id="userSug" class="ac-list" style="display:none"></div>
</div>
<div class="field-row">
<div class="field"><label>N° câble de sécurité</label><input id="f_cable" value="${esc(m.cable_number)}" placeholder="CAB-042"></div>
<div class="field"><label>Code du câble</label><input type="password" id="f_cable_code" value="${esc(m.cable_code)}" placeholder="****" autocomplete="off"></div>
</div>
</div>
<div class="field">
<label>Adresses IP <span style="text-transform:none;font-weight:400">(le réseau est auto-sélectionné d'après le subnet)</span></label>
<div id="ipRows"></div>
<button class="btn small" onclick="addIpRow()"> Ajouter une IP</button>
</div>
<div class="field"><label>Description</label><textarea id="f_desc" placeholder="Rôle, notes, matériel…">${esc(m.description)}</textarea></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveMachine(${isNew ? 'null' : id})">${asTemplate?'Créer la copie':'Enregistrer'}</button>
</div>`);
if(mIps.length) mIps.forEach(i => addIpRow(i.ip, i.network_id, i.mac, i.dhcp));
else addIpRow();
const certVals = (m.cert_ref||'').split('\n').map(s=>s.trim()).filter(Boolean);
if(certVals.length) certVals.forEach(v=>addCertRow(v)); else addCertRow('');
toggleMachineFields();
fillOsVersions(m.os_version);
fillModels(m.model);
}
/* modèles filtrés par la marque choisie (+ conserve le modèle courant hors catalogue) */
function fillModels(preselect){
const brand = document.getElementById('f_brand').value;
const sel = document.getElementById('f_model');
const cur = preselect !== undefined ? preselect : sel.value;
const names = ((window._modelsByBrand||{})[brand] || []).slice();
if(cur && !names.includes(cur)) names.unshift(cur);
sel.innerHTML = '<option value="">— aucun —</option>' +
names.map(n => `<option value="${esc(n)}" ${n===cur?'selected':''}>${esc(n)}</option>`).join('');
}
/* CPU du catalogue + sockets → cores total et MHz auto */
function cpuAutofill(){
const entry = (window._cpuMap||{})[document.getElementById('f_cpu').value];
if(!entry) return; // hors catalogue → saisie manuelle
const sockets = parseInt(document.getElementById('f_sockets').value) || null;
if(entry.cores && sockets) document.getElementById('f_cores').value = entry.cores * sockets;
if(entry.mhz) document.getElementById('f_mhz').value = entry.mhz;
}
/* Recherche AD temps réel sur le champ utilisateur (debounce 250 ms) */
let adTimer = null, adSeq = 0;
function adUserSearch(q){
clearTimeout(adTimer);
if(getSetting('ad_mode','off') === 'off' || q.trim().length < 2){ hideUserSug(); return; }
adTimer = setTimeout(async ()=>{
const seq = ++adSeq;
let items = [];
try{ items = await adLookup(q.trim()); }
catch(e){ items = [{ label:'⚠ Erreur annuaire : ' + e.message, err:true }]; }
if(seq !== adSeq) return; // réponse obsolète (frappe plus récente)
const box = document.getElementById('userSug');
if(!box){ return; }
if(!items.length){
box.innerHTML = `<div class="ac-item" style="color:var(--muted);cursor:default">Aucun utilisateur trouvé</div>`;
box.style.display = ''; return;
}
window._adItems = items;
box.innerHTML = items.map((it,i) => it.err
? `<div class="ac-item" style="color:var(--danger);cursor:default">${esc(it.label)}</div>`
: `<div class="ac-item" onmousedown="pickUser(${i})">👤 <b>${esc(it.label)}</b>${it.sub?`<span class="ac-sub">${esc(it.sub)}</span>`:''}</div>`).join('');
box.style.display = '';
}, 250);
}
function pickUser(i){
const it = (window._adItems||[])[i];
if(it && !it.err) document.getElementById('f_user').value = it.label;
hideUserSug();
}
function hideUserSug(){
const box = document.getElementById('userSug');
if(box) box.style.display = 'none';
}
/* peuple le select de version d'après l'OS choisi */
function fillOsVersions(preselect){
const os = document.getElementById('f_os').value;
const sel = document.getElementById('f_osv');
const vers = ((window._osMap[os]||{}).versions || []).slice();
if(preselect && !vers.includes(preselect)) vers.unshift(preselect);
sel.innerHTML = '<option value="">— version —</option>' +
vers.map(v => `<option value="${esc(v)}" ${v===preselect?'selected':''}>${esc(v)}</option>`).join('');
sel.disabled = !os;
}
function toggleMachineFields(){
const t = TYPES[document.getElementById('f_type').value] || {};
document.getElementById('virtFields').style.display = t.virtual ? '' : 'none';
document.getElementById('physFields').style.display = (!t.virtual && t.rackable) ? '' : 'none';
document.getElementById('userField').style.display = t.assign_user ? '' : 'none';
// Switch : garde le placement en baie, masque le matériel (marque/CPU/support…) et l'OS
const hw = document.getElementById('hwFields');
if(hw) hw.style.display = t.is_switch ? 'none' : '';
const osf = document.getElementById('osFields');
if(osf) osf.style.display = t.is_switch ? 'none' : '';
const sw = document.getElementById('switchFields');
if(sw){ sw.style.display = t.is_switch ? '' : 'none'; if(t.is_switch) renderSwitchVlanPreview(); }
if(t.virtual) rebuildHostOptions();
}
/* Filtre le sélecteur d'hôte : seules les machines dont le type peut héberger
le type virtuel choisi sont proposées. L'hôte déjà assigné est conservé même
s'il est devenu incompatible (édition d'une machine existante). */
function rebuildHostOptions(){
const sel = document.getElementById('f_host'); if(!sel) return;
const cur = sel.value;
const tkey = document.getElementById('f_type').value;
const list = (window._allHosts || []).filter(h => (TYPES[h.type]?.hosts||[]).includes(tkey));
if(cur && !list.some(h=>String(h.id)===String(cur))){
const c = (window._allHosts||[]).find(h=>String(h.id)===String(cur));
if(c) list.unshift(c);
}
sel.innerHTML = '<option value="">— choisir hôte —</option>' + list.map(h=>{
const incompat = !(TYPES[h.type]?.hosts||[]).includes(tkey);
return `<option value="${h.id}" ${String(cur)===String(h.id)?'selected':''}>${esc(h.name)} (${TYPES[h.type]?.label||h.type})${incompat?' — incompatible':''}</option>`;
}).join('');
if(!sel.querySelector('option[value=""]').selected && !sel.value) sel.value = cur;
}
/* Ligne de mapping certificat : input + (ajoute une ligne) + ✕ (retire). */
function addCertRow(value=''){
const wrap = document.getElementById('certRows'); if(!wrap) return;
const row = document.createElement('div');
row.className = 'cert-row';
row.style.cssText = 'display:flex;gap:6px;margin-bottom:6px';
row.innerHTML = `<input class="cert-in" list="certList" value="${esc(value)}" placeholder="ex : srv-web-01.gemalto.com" autocomplete="off" style="flex:1">
<button type="button" class="btn small" onclick="addCertRow()" title="Ajouter un certificat"></button>
<button type="button" class="btn small danger" onclick="removeCertRow(this)" title="Retirer">✕</button>`;
wrap.appendChild(row);
}
/* Retire une ligne certificat ; garde toujours au moins une ligne (sinon plus de ). */
function removeCertRow(btn){
const wrap = document.getElementById('certRows');
if(wrap.querySelectorAll('.cert-row').length <= 1) btn.closest('.cert-row').querySelector('.cert-in').value = '';
else btn.closest('.cert-row').remove();
}
function addIpRow(ip='', netId='', mac='', dhcp=0){
const nets = window._netOpts || [];
const row = document.createElement('div');
row.className = 'ip-row';
row.dataset.mode = dhcp ? 'dhcp' : 'static';
row.innerHTML = `
<div class="mode-switch">
<button type="button" class="ms-opt ms-static" onclick="setIpMode(this,'static')">Static</button>
<button type="button" class="ms-opt ms-dhcp" onclick="setIpMode(this,'dhcp')">DHCP</button>
</div>
<input class="ip-ip" value="${esc(ip)}" placeholder="192.168.1.10" oninput="autoNet(this)">
<select class="ip-net"><option value="">— réseau —</option>${
nets.map(n=>`<option value="${n.id}" ${String(netId)===String(n.id)?'selected':''}>${esc(n.name)}</option>`).join('')
}</select>
<input class="ip-mac" value="${esc(mac)}" placeholder="AA:BB:CC:DD:EE:FF" style="max-width:150px">
<button class="btn ghost small" onclick="this.parentElement.remove()">✕</button>`;
document.getElementById('ipRows').appendChild(row);
applyIpMode(row);
}
function setIpMode(btn, mode){ const r = btn.closest('.ip-row'); r.dataset.mode = mode; applyIpMode(r); }
/* DHCP → masque IP + MAC, ne garde que le réseau */
function applyIpMode(row){
const dhcp = row.dataset.mode === 'dhcp';
row.querySelector('.ms-static').classList.toggle('active', !dhcp);
row.querySelector('.ms-dhcp').classList.toggle('active', dhcp);
const ip = row.querySelector('.ip-ip'), mac = row.querySelector('.ip-mac');
ip.style.display = dhcp ? 'none' : '';
mac.style.display = dhcp ? 'none' : '';
if(dhcp){ ip.value = ''; ip.style.borderColor = ''; }
}
/* sélectionne auto le réseau dont le subnet contient l'IP saisie + retour visuel de validité */
function autoNet(inp){
const row = inp.closest('.ip-row');
const v = inp.value.trim();
const net = findNetworkForIp(v);
if(net) row.querySelector('.ip-net').value = String(net.id);
inp.style.borderColor = (!v || validIp(v)) ? '' : 'var(--danger)';
}
function rackConflict(rackId, pos, size, excludeId){
const others = all('SELECT name, position_u, size_u FROM machines WHERE rack_id=? AND position_u IS NOT NULL AND id<>?',
[rackId, excludeId||0]);
return others.find(o => pos <= o.position_u + o.size_u - 1 && o.position_u <= pos + size - 1);
}
function saveMachine(id){
const name = document.getElementById('f_name').value.trim();
if(!name){ alert('Nom requis'); return; }
const type = document.getElementById('f_type').value;
const tdef = TYPES[type] || {};
let host_id = null, size_u = null, rack_id = null, position_u = null;
if(tdef.virtual){
host_id = document.getElementById('f_host').value || null;
}else if(tdef.rackable){
const sizeRaw = document.getElementById('f_size').value.trim();
size_u = sizeRaw === '' ? null : (parseInt(sizeRaw) || 1);
rack_id = document.getElementById('f_rack').value || null;
const posRaw = document.getElementById('f_pos').value;
position_u = rack_id && posRaw !== '' ? parseInt(posRaw) : null;
if(rack_id && position_u !== null){
if(size_u === null){ alert('Taille (U) requise pour placer la machine en baie.'); return; }
const rack = all('SELECT height_u FROM racks WHERE id=?',[rack_id])[0];
if(position_u < 1 || position_u + size_u - 1 > rack.height_u){
alert(`Hors baie : positions valides 1 à ${rack.height_u - size_u + 1} pour ${size_u}U.`); return;
}
const c = rackConflict(rack_id, position_u, size_u, id);
if(c){ alert(`Conflit avec "${c.name}" (U${c.position_u}U${c.position_u + c.size_u - 1}).`); return; }
}
if(!rack_id) position_u = null;
}
const status = document.getElementById('f_status').value || null;
const os = tdef.is_switch ? '' : document.getElementById('f_os').value.trim();
const osv = tdef.is_switch ? '' : document.getElementById('f_osv').value.trim();
const desc = document.getElementById('f_desc').value.trim();
const auser = tdef.assign_user ? (document.getElementById('f_user').value.trim() || null) : null;
const cable = tdef.assign_user ? (document.getElementById('f_cable').value.trim() || null) : null;
const cableCode = tdef.assign_user ? (document.getElementById('f_cable_code').value.trim() || null) : null;
const switchConfig = tdef.is_switch ? (document.getElementById('f_switch_config').value.trim() || null) : null;
const certRef = [...document.querySelectorAll('#certRows .cert-in')].map(i=>i.value.trim()).filter(Boolean).join('\n') || null;
/* matériel — uniquement serveurs physiques rackables */
let brand=null, model=null, stag=null, stagUrl=null, cpu=null, cores=null, mhz=null, ram=null, supS=null, supE=null, idrac=null, sockets=null, noWar=0;
if(!tdef.virtual && tdef.rackable && !tdef.is_switch){
const v = id2 => document.getElementById(id2).value.trim();
noWar = document.getElementById('f_nowar').checked ? 1 : 0;
brand = v('f_brand') || null;
model = v('f_model') || null;
stag = v('f_stag') || null;
stagUrl = v('f_stag_url') || null;
if(stagUrl && !/^https?:\/\//i.test(stagUrl)) stagUrl = 'https://' + stagUrl;
cpu = v('f_cpu') || null;
sockets = v('f_sockets') === '' ? null : parseInt(v('f_sockets'));
cores = v('f_cores') === '' ? null : parseInt(v('f_cores'));
mhz = v('f_mhz') === '' ? null : parseInt(v('f_mhz'));
ram = v('f_ram') === '' ? null : parseInt(v('f_ram'));
supS = v('f_sup_start') || null;
supE = v('f_sup_end') || null;
idrac = v('f_idrac') || null;
if(idrac && !validIp(idrac)){ alert(`IP iDRAC invalide : "${idrac}".`); return; }
if(supS && supE && supE < supS){ alert('La fin de support est antérieure au début de support.'); return; }
}
/* Collecte + validation des IPs AVANT toute écriture */
const ipData = [];
for(const r of document.querySelectorAll('#ipRows .ip-row')){
const netId = r.querySelector('.ip-net').value || null;
if(r.dataset.mode === 'dhcp'){
if(!netId) continue; // DHCP sans réseau → ignoré
ipData.push([netId, '', null, 1]); // ip='' (pas NULL) : compat ancien schéma NOT NULL
continue;
}
const ip = r.querySelector('.ip-ip').value.trim();
if(!ip) continue;
if(!validIp(ip)){ alert(`Adresse IP invalide : "${ip}".\nFormat attendu : 192.168.1.10`); return; }
const mac = r.querySelector('.ip-mac').value.trim().toUpperCase() || null;
if(netId){
const net = all('SELECT name, subnet FROM networks WHERE id=?',[netId])[0];
if(net && net.subnet && !ipInCidr(ip, net.subnet)){
alert(`${ip} n'appartient pas au subnet ${net.subnet} du réseau « ${net.name} ».`); return;
}
}
ipData.push([netId, ip, mac, 0]);
}
/* db.run direct (pas run()) : persist()→db.export() ferme/rouvre la connexion
et remet last_insert_rowid() à 0. On lit l'id AVANT tout persist, puis on
enchaîne les écritures et on persiste une seule fois à la fin. */
/* --- Membres de stack (switch) : collecte + validation AVANT toute écriture --- */
const memberSize = (tdef.is_switch && size_u) ? size_u : 1;
const stackMembers = [];
if(tdef.is_switch && !window._isStackMember){
const stacks = parseSwitchStacks(switchConfig || '');
if(stacks.length > 1){
const rows = document.querySelectorAll('#stackFields [data-stack]');
const stackIds = new Set();
if(id){ stackIds.add(id); all('SELECT id FROM machines WHERE stack_parent=?',[id]).forEach(r=>stackIds.add(r.id)); }
const placed = [];
if(rack_id && position_u != null) placed.push({rack:String(rack_id), pos:position_u, size:size_u||1});
for(const row of rows){
const sid = parseInt(row.dataset.stack);
const mName = (row.querySelector('.stk_name').value.trim()) || name; // défaut = nom du switch principal
const rk = row.querySelector('.stk_rack').value || null;
const pRaw = row.querySelector('.stk_pos').value.trim();
let pos = null;
if(rk){
if(pRaw === ''){ alert(`Position en baie requise pour le switch « ${mName} ».`); return; }
pos = parseInt(pRaw);
const rack = all('SELECT height_u FROM racks WHERE id=?',[rk])[0];
if(!rack || pos < 1 || pos + memberSize - 1 > rack.height_u){
alert(`« ${mName} » hors baie : positions valides 1 à ${(rack?rack.height_u:0) - memberSize + 1} pour ${memberSize}U.`); return;
}
const clash = all('SELECT id,name,position_u,size_u FROM machines WHERE rack_id=? AND position_u IS NOT NULL',[rk])
.find(o => !stackIds.has(o.id) && pos <= o.position_u + o.size_u - 1 && o.position_u <= pos + memberSize - 1);
if(clash){ alert(`« ${mName} » en conflit avec « ${clash.name} » (U${clash.position_u}).`); return; }
if(placed.find(pp => pp.rack === String(rk) && pos <= pp.pos + pp.size - 1 && pp.pos <= pos + memberSize - 1)){
alert(`« ${mName} » en conflit avec un autre membre du stack dans la même baie.`); return;
}
placed.push({rack:String(rk), pos, size:memberSize});
}
stackMembers.push({index:sid, name:mName, rack_id:rk, position_u:pos});
}
}
}
const hw = [cable,cableCode,brand,model,stag,stagUrl,cpu,sockets,cores,mhz,ram,supS,supE,idrac,noWar];
if(id){
db.run(`UPDATE machines SET name=?,type=?,status=?,host_id=?,size_u=?,rack_id=?,position_u=?,os=?,os_version=?,assigned_user=?,description=?,
cable_number=?,cable_code=?,brand=?,model=?,service_tag=?,service_tag_url=?,cpu=?,cpu_sockets=?,cpu_cores=?,cpu_mhz=?,ram_gb=?,support_start=?,support_end=?,idrac_ip=?,no_warranty=?,switch_config=?,cert_ref=? WHERE id=?`,
[name,type,status,host_id,size_u,rack_id,position_u,os,osv,auser,desc,...hw,switchConfig,certRef,id]);
}else{
db.run(`INSERT INTO machines(name,type,status,host_id,size_u,rack_id,position_u,os,os_version,assigned_user,description,
cable_number,cable_code,brand,model,service_tag,service_tag_url,cpu,cpu_sockets,cpu_cores,cpu_mhz,ram_gb,support_start,support_end,idrac_ip,no_warranty,switch_config,cert_ref)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[name,type,status,host_id,size_u,rack_id,position_u,os,osv,auser,desc,...hw,switchConfig,certRef]);
id = all('SELECT last_insert_rowid() i')[0].i;
}
db.run('DELETE FROM ips WHERE machine_id=?',[id]);
const seenIp = new Set();
ipData.forEach(([netId,ip,mac,dhcp])=>{
const key = dhcp ? 'dhcp:'+netId : 'ip:'+ip; // pas de doublon exact
if(seenIp.has(key)) return;
seenIp.add(key);
db.run('INSERT INTO ips(machine_id,network_id,ip,mac,dhcp) VALUES (?,?,?,?,?)',[id,netId,ip,mac,dhcp]);
});
/* Membres de stack : copies complètes du switch principal (nom + baie changent ;
IP recopiées à l'identique sur chaque membre). */
db.run('DELETE FROM ips WHERE machine_id IN (SELECT id FROM machines WHERE stack_parent=?)',[id]);
db.run('DELETE FROM machines WHERE stack_parent=?',[id]);
if(stackMembers.length){
const cols = all('PRAGMA table_info(machines)').map(c=>c.name).filter(c=>c!=='id');
const primary = all('SELECT * FROM machines WHERE id=?',[id])[0] || {};
const primIps = all('SELECT network_id,ip,mac,dhcp FROM ips WHERE machine_id=?',[id]);
stackMembers.forEach(mm=>{
const row = {...primary,
name:mm.name, rack_id:mm.rack_id, position_u:mm.position_u, size_u:memberSize,
host_id:null, stack_parent:id, stack_index:mm.index};
const vals = cols.map(c => row[c] === undefined ? null : row[c]);
db.run(`INSERT INTO machines(${cols.join(',')}) VALUES (${cols.map(()=>'?').join(',')})`, vals);
const mid = all('SELECT last_insert_rowid() i')[0].i;
primIps.forEach(p => db.run('INSERT INTO ips(machine_id,network_id,ip,mac,dhcp) VALUES (?,?,?,?,?)',
[mid, p.network_id, p.ip, p.mac, p.dhcp]));
});
}
persist();
closeModal(); renderAll();
}
function deleteMachine(id){
const kids = all('SELECT COUNT(*) c FROM machines WHERE host_id=?',[id])[0].c;
const stackKids = all('SELECT COUNT(*) c FROM machines WHERE stack_parent=?',[id])[0].c;
const warn = kids ? `${kids} machine(s) virtuelle(s) hébergée(s) ici perdront leur hôte. `
: stackKids ? `${stackKids} membre(s) de stack seront aussi supprimés. ` : '';
if(!confirm((warn||'') + 'Supprimer cette machine ?')) return;
run('UPDATE machines SET host_id=NULL WHERE host_id=?',[id]);
run('DELETE FROM ips WHERE machine_id IN (SELECT id FROM machines WHERE stack_parent=?)',[id]); // IP des membres
run('DELETE FROM machines WHERE stack_parent=?',[id]); // membres du stack
run('DELETE FROM ips WHERE machine_id=?',[id]);
run('DELETE FROM machines WHERE id=?',[id]);
renderAll();
}
/* ============================================================
SALLES / BAIES — navigation à 2 niveaux
============================================================ */
const U_H = 22; // px par unité
function enterRoom(id){ currentRoom = id; renderRooms(); }
function leaveRoom(){ currentRoom = null; renderRooms(); }
function renderRooms(){
const el = document.getElementById('roomsContainer');
if(currentRoom !== null){
const room = all('SELECT * FROM rooms WHERE id=?',[currentRoom])[0];
if(!room){ currentRoom = null; }
else { el.innerHTML = roomView(room); return; }
}
el.innerHTML = roomsListView();
}
/* --- niveau 1 : liste des salles --- */
function roomsListView(){
const rooms = all('SELECT * FROM rooms ORDER BY name');
const head = `
<div class="page-head">
<div><h2>Salles serveur</h2><p>Cliquez sur une salle pour voir ses baies</p></div>
<button class="btn primary" onclick="openRoomModal()"> Nouvelle salle</button>
</div>`;
if(!rooms.length) return head + `<div class="card"><div class="empty"><span class="big">🗄</span>Aucune salle serveur.<br>
<button class="btn primary" onclick="openRoomModal()"> Créer une salle</button></div></div>`;
return head + `<div class="rooms-grid">` + rooms.map(room=>{
const nRacks = all('SELECT COUNT(*) c FROM racks WHERE room_id=?',[room.id])[0].c;
const stats = all(`SELECT COUNT(m.id) nb, COALESCE(SUM(m.size_u),0) used, COALESCE((SELECT SUM(height_u) FROM racks WHERE room_id=?),0) total
FROM machines m JOIN racks r ON r.id=m.rack_id WHERE r.room_id=? AND m.position_u IS NOT NULL`,[room.id,room.id])[0];
return `<div class="room-tile" onclick="enterRoom(${room.id})">
<div class="r-actions">
<button class="btn ghost small" onclick="event.stopPropagation();openRoomModal(${room.id})">✎</button>
<button class="btn ghost small danger" onclick="event.stopPropagation();deleteRoom(${room.id})">✕</button>
</div>
<div class="r-icon">${ICONS.room}</div>
<h3>${esc(room.name)}</h3>
<div class="r-meta">${nRacks} baie(s) · ${stats.nb} machine(s) rackée(s)<br>
${stats.total?`Occupation : ${stats.used}U / ${stats.total}U`:''}</div>
<div class="r-arrow">→</div>
</div>`;
}).join('') + `</div>`;
}
/* --- niveau 2 : vue d'une salle avec ses baies --- */
function roomView(room){
const racks = all('SELECT * FROM racks WHERE room_id=? ORDER BY name',[room.id]);
const g = ipsGrouped();
const racksHtml = racks.map(rack=>{
const machines = all('SELECT * FROM machines WHERE rack_id=? AND position_u IS NOT NULL',[rack.id]);
const usedU = machines.reduce((s,m)=>s+m.size_u,0);
const blocks = machines.map(m=>{
const top = (rack.height_u - (m.position_u + m.size_u - 1)) * U_H;
const h = m.size_u * U_H - 2;
const col = machineColor(m, g);
const ink = inkFor(col);
/* index de recherche : nom, type, statut, os, user, modèle, tag, cpu, ips, macs */
const mIps = g[m.id]||[];
const hay = [m.name, TYPES[m.type]?.label, STATUSES[m.status]?.label, m.os, m.os_version,
m.assigned_user, m.brand, m.model, m.service_tag, m.cpu, m.idrac_ip,
...mIps.map(i=>i.ip), ...mIps.map(i=>i.mac), ...mIps.map(i=>i.net_name)]
.filter(Boolean).join(' ').toLowerCase();
// points lumineux : une pastille par réseau (couleur du réseau)
const netCols = [...new Set(mIps.map(i=>i.color).filter(Boolean))];
const dots = netCols.length
? `<span class="net-dots">${netCols.map(c=>`<span class="net-dot" style="--c:${esc(c)}"></span>`).join('')}</span>`
: '';
return `<div class="rack-machine" data-mid="${m.id}" data-search="${esc(hay)}" onclick="machineDetail(${m.id})"
style="top:${top+1}px;height:${h}px;background:${col};color:${ink}">
<span class="rm-name">${esc(m.name)}</span>${dots}</div>`;
}).join('');
const uLabels = Array.from({length:rack.height_u},(_,k)=>`<div>${rack.height_u-k}</div>`).join('');
return `<div class="rack">
<div class="rack-title">
<span>${esc(rack.name)}</span>
<span>
<button class="btn ghost small" onclick="openRackModal(${room.id},${rack.id})">✎</button>
<button class="btn ghost small danger" onclick="deleteRack(${rack.id})">✕</button>
</span>
</div>
<div class="rack-sub">${rack.height_u}U · ${usedU}U occupés · ${machines.length} machine(s)</div>
<div class="rack-grid">
<div class="u-col">${uLabels}</div>
<div class="slot-col" style="height:${rack.height_u*U_H}px">${blocks}</div>
</div>
</div>`;
}).join('');
// onglets d'accès rapide aux salles (à côté du nom)
const allRooms = all('SELECT * FROM rooms ORDER BY name COLLATE NOCASE');
const tabsHtml = allRooms.map(r =>
`<button class="room-tab${r.id===room.id?' on':''}" onclick="enterRoom(${r.id})">🗄 ${esc(r.name)}</button>`
).join('');
return `
<div class="page-head">
<div>
<div class="crumb"><a onclick="leaveRoom()">Salles serveur</a> / ${esc(room.name)}</div>
<h2>🗄 ${esc(room.name)}</h2>
<p>${racks.length} baie(s) — cliquez sur un serveur pour ouvrir sa fiche</p>
</div>
<span>
<button class="btn" onclick="leaveRoom()">← Retour</button>
<button class="btn primary" onclick="openRackModal(${room.id})"> Nouvelle baie</button>
</span>
</div>
<div class="room-tabs">${tabsHtml}
<button class="room-tab add" onclick="openRoomModal()" title="Nouvelle salle"></button>
</div>
${racks.length?`<div class="toolbar">
<input id="rackSearchIn" placeholder="🔍 Chercher un serveur (nom, IP, MAC, OS, user, modèle…)" style="width:340px"
oninput="rackSearch(this.value)">
<span class="count-pill" id="rackSearchCount" style="display:none"></span>
</div>`:''}
<div class="card">
<div class="racks-row">${racksHtml || '<div class="empty" style="width:100%"><span class="big">🗄</span>Aucune baie dans cette salle.<br><button class="btn primary" onclick="openRackModal('+room.id+')"> Créer une baie</button></div>'}</div>
</div>`;
}
/* Recherche temps réel : multi-mots (ET), surbrillance des blocs correspondants */
function rackSearch(q){
const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
const blocks = document.querySelectorAll('#roomsContainer .rack-machine');
let hits = 0;
blocks.forEach(b=>{
if(!terms.length){ b.classList.remove('hit','dim'); return; }
const hay = b.dataset.search || '';
const ok = terms.every(t => hay.includes(t));
b.classList.toggle('hit', ok);
b.classList.toggle('dim', !ok);
if(ok) hits++;
});
const c = document.getElementById('rackSearchCount');
if(c){
c.style.display = terms.length ? '' : 'none';
c.textContent = hits + ' trouvé(s)';
}
}
function openRoomModal(id){
const r = id ? all('SELECT * FROM rooms WHERE id=?',[id])[0] : {name:''};
openModal(`
<h3>${id?'Éditer':'Nouvelle'} salle serveur</h3>
<div class="field"><label>Nom</label><input id="f_name" value="${esc(r.name)}" placeholder="Salle A, Datacenter Nord…"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveRoom(${id||'null'})">Enregistrer</button>
</div>`);
}
function saveRoom(id){
const name = document.getElementById('f_name').value.trim();
if(!name){ alert('Nom requis'); return; }
if(id) run('UPDATE rooms SET name=? WHERE id=?',[name,id]);
else run('INSERT INTO rooms(name) VALUES (?)',[name]);
closeModal(); renderAll();
}
function deleteRoom(id){
if(!confirm('Supprimer cette salle, ses baies, et déplacer leurs machines hors baie ?')) return;
const racks = all('SELECT id FROM racks WHERE room_id=?',[id]);
for(const r of racks) run('UPDATE machines SET rack_id=NULL, position_u=NULL WHERE rack_id=?',[r.id]);
run('DELETE FROM racks WHERE room_id=?',[id]);
run('DELETE FROM rooms WHERE id=?',[id]);
if(currentRoom === id) currentRoom = null;
renderAll();
}
function openRackModal(roomId, rackId){
const r = rackId ? all('SELECT * FROM racks WHERE id=?',[rackId])[0] : {name:'', height_u:+getSetting('rack_default_u',42)};
openModal(`
<h3>${rackId?'Éditer':'Nouvelle'} baie</h3>
<div class="field-row">
<div class="field"><label>Nom</label><input id="f_name" value="${esc(r.name)}" placeholder="Baie 01"></div>
<div class="field"><label>Hauteur (U)</label><input type="number" id="f_height" min="1" max="60" value="${r.height_u}"></div>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Annuler</button>
<button class="btn primary" onclick="saveRack(${roomId},${rackId||'null'})">Enregistrer</button>
</div>`);
}
function saveRack(roomId, rackId){
const name = document.getElementById('f_name').value.trim();
const h = parseInt(document.getElementById('f_height').value) || 42;
if(!name){ alert('Nom requis'); return; }
if(rackId){
const over = all('SELECT name, position_u, size_u FROM machines WHERE rack_id=? AND position_u IS NOT NULL AND position_u + size_u - 1 > ?',[rackId,h]);
if(over.length){ alert('Impossible : "'+over[0].name+'" dépasse la nouvelle hauteur.'); return; }
run('UPDATE racks SET name=?, height_u=? WHERE id=?',[name,h,rackId]);
}else{
run('INSERT INTO racks(room_id,name,height_u) VALUES (?,?,?)',[roomId,name,h]);
}
closeModal(); renderAll();
}
function deleteRack(id){
if(!confirm('Supprimer cette baie ? Les machines seront déplacées hors baie.')) return;
run('UPDATE machines SET rack_id=NULL, position_u=NULL WHERE rack_id=?',[id]);
run('DELETE FROM racks WHERE id=?',[id]);
renderAll();
}
/* ============================================================
CARTE DE SURVOL — passe la souris sur un nom de serveur
============================================================ */
function machineHoverHtml(id, showRack=true){
const g = ipsGrouped();
const m = all(`SELECT m.*, r.name rack_name, r.height_u, ro.name room_name
FROM machines m LEFT JOIN racks r ON r.id=m.rack_id LEFT JOIN rooms ro ON ro.id=r.room_id
WHERE m.id=?`,[id])[0];
if(!m) return '';
const td = TYPES[m.type] || {};
const hosted = all('SELECT id,name,type FROM machines WHERE host_id=? ORDER BY type,name',[id]);
let html = `<div class="hc-head">${esc(m.name)} ${typeBadge(m.type)} ${statusBadge(m.status)}</div>`;
if(m.assigned_user) html += `<div class="hc-row" style="margin-bottom:2px">👤 ${esc(m.assigned_user)}</div>`;
// IP → MAC compact
const mIps = g[id] || [];
if(mIps.length){
html += `<div class="hc-sec"><div class="hc-t">IP → MAC</div>` +
mIps.map(i => `<div class="hc-row"><span class="dot" style="background:${esc(i.color||'#7c8189')}"></span>${
i.dhcp ? '<span class="hc-mut">DHCP'+(i.net_name?' · '+esc(i.net_name):'')+'</span>' : esc(i.ip)
}<span class="hc-mut">${i.mac?esc(i.mac):'—'}</span></div>`).join('') +
`</div>`;
}
// liste compacte des machines hébergées
if(hosted.length){
html += `<div class="hc-sec"><div class="hc-t">Héberge — ${hosted.length}</div>` +
hosted.map(h => `<div class="hc-row"><span class="dot" style="background:${TYPES[h.type]?.color||'#7c8189'}"></span>${esc(h.name)}<span class="hc-mut">${TYPES[h.type]?.label||h.type}</span></div>`).join('') +
`</div>`;
} else if(td.rackable){
html += `<div class="hc-sec"><div class="hc-mut">Aucune machine hébergée</div></div>`;
}
// mini-baie : position en surbrillance (masquée dans la vue salle)
if(showRack && m.rack_id && m.position_u){
const uh = 9;
const mates = all('SELECT * FROM machines WHERE rack_id=? AND position_u IS NOT NULL',[m.rack_id]);
const blocks = mates.map(x=>{
const top = (m.height_u - (x.position_u + x.size_u - 1)) * uh;
const h = Math.max(x.size_u * uh - 1, 3);
const on = x.id === id;
const col = machineColor(x, g);
return `<div class="hc-slot" title="${esc(x.name)}"
style="top:${top}px;height:${h}px;background:${on?col:col+'40'};${on?'outline:2px solid #fff;box-shadow:0 0 8px '+col:''}"></div>`;
}).join('');
html += `<div class="hc-sec">
<div class="hc-t">${esc(m.room_name||'')} / ${esc(m.rack_name)} · U${m.position_u}${m.size_u>1?'U'+(m.position_u+m.size_u-1):''}</div>
<div class="hc-mini">
<div class="hc-uax" style="height:${m.height_u*uh}px"><span>${m.height_u}</span><span>1</span></div>
<div class="hc-rack" style="height:${m.height_u*uh}px">${blocks}</div>
</div></div>`;
}
// description en bas
if(m.description) html += `<div class="hc-desc">${esc(m.description)}</div>`;
// section Active Directory (poste) — remplie en asynchrone si type marqué "in_ad"
if(td.in_ad && getSetting('ad_mode','off') === 'ldap'){
html += `<div class="hc-sec" id="hcAd"><div class="hc-t">Active Directory (poste)</div><div class="hc-mut" style="font-size:12px">Recherche annuaire…</div></div>`;
}
return html;
}
/* Remplit la section AD-poste de la carte de survol (asynchrone) */
async function loadHoverAd(id, name){
let c = null, err = null;
try{ c = await adComputerInfo(name); }catch(e){ err = e.message; }
if(hoverId !== id) return;
const box = hoverCard.querySelector('#hcAd'); if(!box) return;
if(err) box.innerHTML = `<div class="hc-t">Active Directory (poste)</div><div style="color:var(--danger);font-size:12px">${esc(err)}</div>`;
else if(!c) box.innerHTML = `<div class="hc-t">Active Directory (poste)</div><div class="hc-mut" style="font-size:12px">Poste introuvable dans l'AD</div>`;
else box.innerHTML = adComputerHtml(c);
}
let hoverCard, hoverId = null;
function positionHover(e){
const pad = 16, w = hoverCard.offsetWidth, h = hoverCard.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if(x + w > innerWidth - 8) x = e.clientX - w - pad;
if(y + h > innerHeight - 8) y = innerHeight - h - 8;
if(y < 8) y = 8;
hoverCard.style.left = Math.max(8,x) + 'px';
hoverCard.style.top = y + 'px';
}
function hideHover(){ hoverId = null; hoverCard.style.display = 'none'; }
function initHover(){
hoverCard = document.createElement('div');
hoverCard.id = 'hoverCard';
document.body.appendChild(hoverCard);
document.addEventListener('mouseover', e=>{
const t = e.target.closest('[data-mid]');
if(!t){ if(hoverId!==null) hideHover(); return; }
const id = +t.dataset.mid;
if(id === hoverId){ positionHover(e); return; }
const html = machineHoverHtml(id, !t.classList.contains('rack-machine'));
if(!html){ hideHover(); return; }
hoverId = id;
hoverCard.innerHTML = html;
hoverCard.style.display = 'block';
positionHover(e);
if(hoverCard.querySelector('#hcAd')){ // type "in_ad" → chercher le poste dans l'AD
const mrow = all('SELECT name FROM machines WHERE id=?',[id])[0];
if(mrow) loadHoverAd(id, mrow.name);
}
});
document.addEventListener('mousemove', e=>{
if(hoverId === null) return;
if(e.target.closest('[data-mid]')) positionHover(e);
else hideHover();
});
document.addEventListener('scroll', hideHover, true);
// Entrée dans un champ = valider (bouton principal du modal). Textarea exclu.
document.getElementById('overlay').addEventListener('keydown', e=>{
if(e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && !e.isComposing){
const btn = document.querySelector('#modalContent .modal-actions .btn.primary');
if(btn){ e.preventDefault(); btn.click(); }
}
});
}
/* ---------------- Carte survol : infos AD de l'utilisateur ---------------- */
let userCard, userShown = null, userSeq = 0;
function positionUserCard(e){
const pad = 16, w = userCard.offsetWidth, h = userCard.offsetHeight;
let x = e.clientX + pad, y = e.clientY + pad;
if(x + w > innerWidth - 8) x = e.clientX - w - pad;
if(y + h > innerHeight - 8) y = innerHeight - h - 8;
if(y < 8) y = 8;
userCard.style.left = Math.max(8,x) + 'px';
userCard.style.top = y + 'px';
}
function hideUserCard(){ userShown = null; userSeq++; userCard.style.display = 'none'; }
async function showUserCard(name, e){
userShown = name;
const head = `<div class="hc-head">👤 ${esc(name)}</div>`;
userCard.innerHTML = head + `<div class="hc-mut" style="font-size:12px">Recherche annuaire…</div>`;
userCard.style.display = 'block';
positionUserCard(e);
if(getSetting('ad_mode','off') === 'off'){
userCard.innerHTML = head + `<div class="hc-mut" style="font-size:12px">Annuaire désactivé (Préférences → AD)</div>`;
return;
}
const seq = ++userSeq;
let info = null, err = null;
try{ info = await adUserInfo(name); }catch(ex){ err = ex.message; }
if(seq !== userSeq || userShown !== name) return;
const row = (l,v) => v ? `<div class="hc-row"><span class="hc-mut" style="min-width:74px">${l}</span><span>${esc(v)}</span></div>` : '';
if(err) userCard.innerHTML = head + `<div style="color:var(--danger);font-size:12px">${esc(err)}</div>`;
else if(!info) userCard.innerHTML = head + `<div class="hc-mut" style="font-size:12px">Introuvable dans l'annuaire</div>`;
else{
const statusBadge = info.enabled === false
? ' <span class="badge" style="background:#d03b3b22;color:#d03b3b;border:1px solid #d03b3b55">Désactivé</span>'
: (info.enabled === true ? ' <span class="badge" style="background:#0ca30c22;color:#0ca30c;border:1px solid #0ca30c55">Actif</span>' : '');
const groups = (info.groups && info.groups.length)
? `<div class="hc-row" style="display:block"><span class="hc-mut">Groupes (${info.groups.length})</span>
<div style="margin-top:4px;display:flex;flex-wrap:wrap;gap:4px">${info.groups.map(g=>`<span class="chip" style="font-size:10.5px;padding:1px 7px">${esc(g)}</span>`).join('')}</div></div>`
: '';
userCard.innerHTML =
`<div class="hc-head">👤 ${esc(info.displayName||name)}${statusBadge}</div>` +
row('Login', info.username) + row('Mail', info.mail) + row('Poste', info.title) +
row('Service', info.department) + row('Division', info.division) +
row('Société', info.company) + row('Manager', info.manager) +
row('Bureau', info.office) + row('Ville', info.city) + row('Pays', info.country) +
row('Tél', info.phone) + row('Mobile', info.mobile) +
row('Matricule', info.employee_id) + row('Description', info.description) +
groups;
}
positionUserCard(e);
}
function initUserHover(){
userCard = document.createElement('div');
userCard.id = 'userCard';
document.body.appendChild(userCard);
document.addEventListener('mouseover', e=>{
const t = e.target.closest('[data-user]');
if(!t){ if(userShown!==null) hideUserCard(); return; }
const name = t.dataset.user;
if(name === userShown){ positionUserCard(e); return; }
showUserCard(name, e);
});
document.addEventListener('mousemove', e=>{
if(userShown === null) return;
if(e.target.closest('[data-user]')) positionUserCard(e);
else hideUserCard();
});
document.addEventListener('scroll', hideUserCard, true);
}
initHover();
initUserHover();
boot().catch(e=>{
document.getElementById('dbStatus').textContent = '⚠ Erreur : ' + e.message;
console.error(e);
});
</script>
</body>
</html>