4056 lines
214 KiB
HTML
4056 lines
214 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>CMDB — Admin</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;
|
||
z-index:40; /* au-dessus du contenu (menu recherche qui déborde), sous le modal (50) */
|
||
}
|
||
.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;
|
||
}
|
||
/* recherche globale (sidebar) */
|
||
.gsearch{position:relative;margin:0 4px 12px}
|
||
.gsearch input{width:100%;background:var(--surface-2);border:1px solid var(--border);border-radius:9px;padding:8px 10px;color:var(--ink);font-size:13px}
|
||
.gsearch input:focus{outline:none;border-color:var(--accent)}
|
||
.gsearch-res{position:absolute;top:calc(100% + 4px);left:0;width:max-content;min-width:300px;max-width:min(560px,70vw);max-height:360px;overflow:auto;
|
||
background:var(--surface);border:1px solid var(--border-strong);border-radius:10px;box-shadow:var(--shadow);z-index:60;display:none}
|
||
.gsr{display:flex;align-items:center;gap:12px;padding:8px 11px;cursor:pointer;font-size:13px}
|
||
.gsr:hover{background:var(--surface-2)}
|
||
.gsr-l{flex:1;min-width:0;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.gsr-s{color:var(--muted);font-size:11px;white-space:nowrap;flex-shrink:0}
|
||
/* 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}
|
||
/* surlignage d'une baie ciblée via un lien */
|
||
.rack.rack-flash{animation:rackflash 1.6s ease}
|
||
@keyframes rackflash{0%,100%{box-shadow:none}15%,60%{box-shadow:0 0 0 2px var(--accent),0 0 20px var(--accent-soft)}}
|
||
/* U ciblé : anneau d'une autre couleur (ambre) */
|
||
.rack-machine.slot-flash{animation:slotflash 1.9s ease;z-index:5}
|
||
@keyframes slotflash{0%,100%{box-shadow:none;outline:0 solid transparent}
|
||
15%,70%{box-shadow:0 0 14px 2px rgba(255,193,7,.9);outline:2px solid #ffc107;outline-offset:1px}}
|
||
|
||
/* ---------- 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}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<aside>
|
||
<div class="logo" onclick="nav('dashboard')" style="cursor:pointer" title="Dashboard">
|
||
<div class="logo-mark">🗄️</div>
|
||
<div><b>Outils Infra</b><small>portail</small></div>
|
||
</div>
|
||
<div class="gsearch">
|
||
<input id="globalSearch" placeholder="🔍 Rechercher…" autocomplete="off"
|
||
oninput="globalSearch(this.value)"
|
||
onblur="setTimeout(()=>{const b=document.getElementById('gsearchResults');if(b)b.style.display='none'},180)"
|
||
onkeydown="if(event.key==='Escape')this.blur()">
|
||
<div id="gsearchResults" class="gsearch-res"></div>
|
||
</div>
|
||
<div class="psw">
|
||
<div class="psw-title">Projets</div>
|
||
<a class="active" 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 & envoi</small></div></a>
|
||
</div>
|
||
<div class="nav-sep"></div>
|
||
<button class="nav-btn" data-page="networks" onclick="nav('networks')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 4 5.7 4 9s-1.5 6.4-4 9c-2.5-2.6-4-5.7-4-9s1.5-6.4 4-9z"/></svg>
|
||
Réseaux</button>
|
||
<button class="nav-btn" data-page="machines" onclick="nav('machines')">
|
||
<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>
|
||
Machines</button>
|
||
<button class="nav-btn" data-page="rooms" onclick="nav('rooms')">
|
||
<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"/><circle cx="12" cy="18.5" r=".5"/></svg>
|
||
Salles serveur</button>
|
||
<button class="nav-btn" data-page="missing" onclick="nav('missing')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.3 3.2 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.2a2 2 0 0 0-3.4 0z"/><path d="M12 9v4M12 17h.01"/></svg>
|
||
Infos manquantes <span class="count-pill" id="missingBadge" style="margin-left:auto;display:none"></span></button>
|
||
<div class="sidebar-footer">
|
||
<div class="psw">
|
||
<a 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>
|
||
<div class="db-status" id="dbStatus">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>Répartition</h3>
|
||
<div style="display:flex;gap:30px;flex-wrap:wrap">
|
||
<div style="flex:1;min-width:270px">
|
||
<div style="font-size:12px;color:var(--muted);margin-bottom:10px;font-weight:600">Physique / Virtuel</div>
|
||
<div id="dashPhysVirt"></div>
|
||
</div>
|
||
<div style="flex:1;min-width:270px">
|
||
<div style="font-size:12px;color:var(--muted);margin-bottom:10px;font-weight:600">Adressage IP</div>
|
||
<div id="dashIp"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Machines par statut</h3>
|
||
<div id="dashStatus"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Systèmes d'exploitation</h3>
|
||
<div id="dashOs"></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>
|
||
|
||
<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" 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 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">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">
|
||
<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>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 Marie Martin jdupont@societe.fr" onchange="saveAdSettings()"></textarea></div>
|
||
</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>
|
||
</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();
|
||
renderAll();
|
||
updateStatus();
|
||
if(/settings/.test(location.hash)) nav('settings'); // deep-link Préférences (lien inter-app)
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||
}
|
||
/* Recherche globale (sidebar) : machines (nom/OS/user/modèle/tag/IP/MAC), réseaux, baies, salles. */
|
||
function globalSearch(q){
|
||
q = (q||'').trim().toLowerCase();
|
||
const box = document.getElementById('gsearchResults'); if(!box) return;
|
||
if(q.length < 2){ box.style.display='none'; box.innerHTML=''; window._gsResults=[]; return; }
|
||
const like = '%'+q+'%';
|
||
const res = [];
|
||
all(`SELECT DISTINCT m.id, m.name, m.assigned_user FROM machines m LEFT JOIN ips i ON i.machine_id=m.id
|
||
WHERE lower(m.name) LIKE ? OR lower(COALESCE(m.os,'')) LIKE ? OR lower(COALESCE(m.assigned_user,'')) LIKE ?
|
||
OR lower(COALESCE(m.model,'')) LIKE ? OR lower(COALESCE(m.service_tag,'')) LIKE ?
|
||
OR lower(COALESCE(i.ip,'')) LIKE ? OR lower(COALESCE(i.mac,'')) LIKE ?
|
||
ORDER BY m.name COLLATE NOCASE LIMIT 12`, Array(7).fill(like))
|
||
.forEach(m => res.push({icon:'🖥', label:m.name, sub:'Machine'+(m.assigned_user?' · 👤 '+m.assigned_user:''), act:()=>machineDetail(m.id)}));
|
||
all(`SELECT id,name,subnet FROM networks WHERE lower(name) LIKE ? OR lower(COALESCE(subnet,'')) LIKE ?
|
||
ORDER BY name COLLATE NOCASE LIMIT 6`, [like, like])
|
||
.forEach(n => res.push({icon:'🌐', label:n.name, sub:'Réseau'+(n.subnet?' · '+n.subnet:''), act:()=>networkDetail(n.id)}));
|
||
all(`SELECT r.id, r.name, ro.id room_id, ro.name room FROM racks r JOIN rooms ro ON ro.id=r.room_id
|
||
WHERE lower(r.name) LIKE ? OR lower(ro.name) LIKE ? ORDER BY ro.name, r.name LIMIT 6`, [like, like])
|
||
.forEach(r => res.push({icon:'🗄', label:r.room+' / '+r.name, sub:'Baie', act:()=>gotoRack(r.room_id, r.id)}));
|
||
all(`SELECT id,name FROM rooms WHERE lower(name) LIKE ? ORDER BY name COLLATE NOCASE LIMIT 5`, [like])
|
||
.forEach(ro => res.push({icon:'🏢', label:ro.name, sub:'Salle', act:()=>gotoRack(ro.id, null)}));
|
||
window._gsResults = res;
|
||
box.innerHTML = res.length
|
||
? res.map((r,i)=>`<div class="gsr" onmousedown="gSearchGo(${i})"><span>${r.icon}</span><span class="gsr-l">${esc(r.label)}</span><span class="gsr-s">${esc(r.sub)}</span></div>`).join('')
|
||
: '<div class="gsr" style="cursor:default;color:var(--muted)">Aucun résultat</div>';
|
||
box.style.display = 'block';
|
||
}
|
||
function gSearchGo(i){
|
||
const r = (window._gsResults||[])[i]; if(!r) return;
|
||
const box = document.getElementById('gsearchResults'); if(box) box.style.display='none';
|
||
const inp = document.getElementById('globalSearch'); if(inp) inp.value='';
|
||
r.act();
|
||
}
|
||
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>';
|
||
|
||
// doublons d'IP statiques — regroupe par IP ; les membres d'un même stack partagent
|
||
// l'IP volontairement (grp = stack_parent, sinon id) et ne comptent PAS comme doublon.
|
||
const byIp = {};
|
||
for(const r of all(`SELECT i.ip, i.machine_id, m.name, COALESCE(m.stack_parent, m.id) grp
|
||
FROM ips i JOIN machines m ON m.id=i.machine_id
|
||
WHERE COALESCE(i.dhcp,0)=0 AND i.ip IS NOT NULL AND TRIM(i.ip)<>''`)){
|
||
(byIp[r.ip] = byIp[r.ip] || []).push(r);
|
||
}
|
||
|
||
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);
|
||
// doublon d'IP avec une machine hors du même stack
|
||
if(!i.dhcp && i.ip){
|
||
const mGrp = String(m.stack_parent != null ? m.stack_parent : m.id);
|
||
const others = (byIp[i.ip] || []).filter(r => String(r.grp) !== mGrp);
|
||
if(others.length){
|
||
const names = [...new Set(others.map(o => o.name))].join(', ');
|
||
add('IP', `IP ${i.ip} en double (aussi sur ${names})`, 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();
|
||
}
|
||
|
||
/* ---------------- 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>',
|
||
};
|
||
|
||
/* Camembert (donut) SVG-free : conic-gradient + trou central (total) + légende. */
|
||
function donutChart(segments){
|
||
const total = segments.reduce((s,x)=>s+x.value,0) || 0;
|
||
let acc = 0;
|
||
const stops = segments.filter(s=>s.value>0).map(s=>{
|
||
const a = acc/(total||1)*100; acc += s.value; const b = acc/(total||1)*100;
|
||
return `${s.color} ${a}% ${b}%`;
|
||
}).join(',') || 'var(--surface-3) 0 100%';
|
||
const legend = segments.map(s=>`<div style="display:flex;align-items:center;gap:8px;font-size:12px;margin:4px 0">
|
||
<span style="width:11px;height:11px;border-radius:3px;background:${s.color};flex-shrink:0"></span>
|
||
<span style="color:var(--ink-2);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(s.label)}</span>
|
||
<b style="color:var(--ink)">${s.value}</b><span style="color:var(--muted);font-size:11px">${total?Math.round(s.value/total*100):0}%</span>
|
||
</div>`).join('');
|
||
return `<div style="display:flex;align-items:center;gap:22px;flex-wrap:wrap">
|
||
<div style="position:relative;width:130px;height:130px;flex-shrink:0">
|
||
<div style="width:100%;height:100%;border-radius:50%;background:conic-gradient(${stops})"></div>
|
||
<div style="position:absolute;inset:22px;border-radius:50%;background:var(--surface);display:grid;place-items:center;text-align:center">
|
||
<div><div style="font-size:22px;font-weight:700;color:var(--ink)">${total}</div><div style="font-size:10px;color:var(--muted)">total</div></div>
|
||
</div>
|
||
</div>
|
||
<div style="flex:1;min-width:150px">${legend}</div>
|
||
</div>`;
|
||
}
|
||
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>`;
|
||
|
||
/* Physique / Virtuel (camembert) */
|
||
document.getElementById('dashPhysVirt').innerHTML = nMachines
|
||
? donutChart([{label:'Physique',value:nPhys,color:'#a58ff0'},{label:'Virtuel',value:nVirt,color:'#5b9cf5'}])
|
||
: `<div class="empty">Aucune machine.</div>`;
|
||
|
||
/* Adressage IP : statique / DHCP (camembert) */
|
||
const ipStat = all("SELECT COALESCE(dhcp,0) d, COUNT(*) c FROM ips WHERE dhcp=1 OR (ip IS NOT NULL AND TRIM(ip)<>'') GROUP BY COALESCE(dhcp,0)");
|
||
const nStatic = (ipStat.find(r=>r.d==0)||{}).c || 0, nDhcp = (ipStat.find(r=>r.d==1)||{}).c || 0;
|
||
document.getElementById('dashIp').innerHTML = (nStatic+nDhcp)
|
||
? donutChart([{label:'Statique',value:nStatic,color:'#4ecfa5'},{label:'DHCP',value:nDhcp,color:'#e8b34b'}])
|
||
: `<div class="empty">Aucune IP.</div>`;
|
||
|
||
/* Systèmes d'exploitation (barres, top 8) */
|
||
const osRows = all("SELECT COALESCE(NULLIF(TRIM(os),''),'(non défini)') os, COUNT(*) c FROM machines GROUP BY os ORDER BY c DESC LIMIT 8");
|
||
const maxOs = Math.max(1, ...osRows.map(r=>r.c));
|
||
const osPal = ['#5b9cf5','#4ecfa5','#e8b34b','#a58ff0','#e884ae','#63cdda','#ec8a5f','#78e08f'];
|
||
document.getElementById('dashOs').innerHTML = nMachines
|
||
? osRows.map((r,i)=>bar(esc(r.os), r.c, r.c/maxOs*100, osPal[i%osPal.length])).join('')
|
||
: `<div class="empty">Aucune machine.</div>`;
|
||
|
||
/* Occupation des baies (U occupés / hauteur) */
|
||
const racks = all(`SELECT r.id, r.name, r.height_u, ro.name room, ro.id room_id,
|
||
(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 `<div onclick="gotoRack(${r.room_id},${r.id})" style="cursor:pointer" title="Ouvrir la baie">${bar(esc(r.room+' / '+r.name), `${r.used}/${r.height_u} U`, pct, color)}</div>`;
|
||
}).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="1–4094" 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, n.subnet net_subnet, n.gateway net_gateway, n.vlan net_vlan, n.description net_desc
|
||
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;
|
||
}
|
||
/* Infobulle réseau (survol d'un tag IP dans le tableau des machines). */
|
||
function netTip(i){
|
||
if(!i.network_id) return 'Aucun réseau associé';
|
||
const p = [i.net_name || ('Réseau #'+i.network_id)];
|
||
if(i.net_subnet) p.push('Subnet : ' + i.net_subnet);
|
||
if(i.net_gateway) p.push('Passerelle : ' + i.net_gateway);
|
||
if(i.net_vlan != null) p.push('VLAN : ' + i.net_vlan);
|
||
if(i.net_desc) p.push(i.net_desc);
|
||
return p.join('\n');
|
||
}
|
||
|
||
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, ro.id room_id
|
||
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 ? `<span class="link" onclick="event.stopPropagation();gotoRack(${m.room_id},${m.rack_id},${m.id})">${esc(m.room_name||'')} / ${esc(m.rack_name)} · U${m.position_u}${m.size_u>1?'–U'+(m.position_u+m.size_u-1):''}</span>` :
|
||
(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" title="${esc(netTip(i))}" style="cursor:help"><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;
|
||
}
|
||
/* Importe la conf switch depuis un fichier (en plus du copier-coller). */
|
||
function importSwitchConfig(){
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file';
|
||
inp.accept = '.txt,.conf,.cfg,.cnf,.log,text/plain';
|
||
inp.onchange = () => {
|
||
const f = inp.files[0]; if(!f) return;
|
||
const r = new FileReader();
|
||
r.onload = () => {
|
||
const ta = document.getElementById('f_switch_config');
|
||
if(ta){ ta.value = r.result; renderSwitchVlanPreview(); }
|
||
};
|
||
r.readAsText(f);
|
||
};
|
||
inp.click();
|
||
}
|
||
/* 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, ro.id room_id
|
||
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?`<span class="link" onclick="gotoRack(${m.room_id},${m.rack_id},${m.id})">${esc(m.room_name||'')} / ${esc(m.rack_name)} · U${m.position_u}${m.size_u>1?'–U'+(m.position_u+m.size_u-1):''}</span>`:'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>`:''}
|
||
${((td.hosts && td.hosts.length) || hosted.length) ? `<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 style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">Configuration switch <span style="text-transform:none;font-weight:400;flex:1">(collez la conf ou importez un fichier)</span>
|
||
<button type="button" class="btn small" onclick="importSwitchConfig()">📁 Importer</button></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(); }
|
||
/* Redirige vers une salle + surligne la baie ; si machineId fourni, surligne aussi
|
||
(d'une autre couleur) le U occupé par la machine. */
|
||
function gotoRack(roomId, rackId, machineId){
|
||
closeModal();
|
||
nav('rooms'); // affiche la page salles (remet currentRoom à null)
|
||
if(roomId!=null){ currentRoom = roomId; renderRooms(); }
|
||
setTimeout(()=>{
|
||
const rackEl = rackId!=null ? document.querySelector('.rack[data-rack-id="'+rackId+'"]') : null;
|
||
if(rackEl){ rackEl.classList.add('rack-flash'); setTimeout(()=>rackEl.classList.remove('rack-flash'),1600); }
|
||
const mEl = machineId!=null ? document.querySelector('.rack-machine[data-mid="'+machineId+'"]') : null;
|
||
if(mEl){
|
||
mEl.scrollIntoView({behavior:'smooth',block:'center'});
|
||
mEl.classList.add('slot-flash'); setTimeout(()=>mEl.classList.remove('slot-flash'),1900);
|
||
}else if(rackEl){ rackEl.scrollIntoView({behavior:'smooth',block:'center'}); }
|
||
}, 90);
|
||
}
|
||
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" data-rack-id="${rack.id}">
|
||
<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.hosts && td.hosts.length){
|
||
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>
|