Dashboard_Admin_Sys/switch-config/switch_vlan_mapper.html
Guillaume DENIS 2fb3621053 refactor: rename directories to kebab-case and clean up project structure
Rename CMDB -> cmdb (submodule), GENERATEUR_CERTIFICATES -> cert-generator, GENERATEUR_SWITCH -> switch-config

Remove deleted CMDB files, switch config examples, proxy.sh, and pycache artifacts
Update all internal path references across HTML, JS, and Python files
2026-07-04 17:55:56 +02:00

2561 lines
95 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

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

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

<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Switch VLAN Mapper</title>
<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;
--warn:#e8b34b;
--ok:#4ecfa5;
--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)}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-thumb{background:#33363e;border-radius:6px}
::-webkit-scrollbar-track{background:transparent}
body {
font-family: system-ui,-apple-system,'Segoe UI', sans-serif;
background: var(--page);
color: var(--ink);
-webkit-font-smoothing:antialiased;
display: flex;
}
.app-main { flex: 1; min-width: 0; padding: 20px; }
.container { max-width: 1400px; margin: 0 auto; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 2rem; box-shadow: var(--shadow); }
/* sidebar switcher projets */
aside.appside{width:250px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);
padding:22px 14px;position:sticky;top:0;height:100vh;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
.appside .brand{display:flex;align-items:center;gap:10px;padding:0 10px 22px}
.appside .brand-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;color:#fff}
.appside .brand b{font-size:15px;letter-spacing:.4px}
.appside .brand small{display:block;font-size:10px;color:var(--muted);font-weight:400;letter-spacing:1px;text-transform:uppercase}
.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(--muted);transition:background .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}
@media(max-width:760px){body{display:block}aside.appside{width:auto;height:auto;position:static;border-right:none;border-bottom:1px solid var(--border)}}
.header { margin-bottom: 2rem; }
.header h1 { font-size: 28px; font-weight: 700; letter-spacing:-.2px; margin-bottom: 0.5rem; }
.header p { font-size: 14px; color: var(--muted); }
.upload-section {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
margin-bottom: 2rem;
}
.upload-section input[type="file"] { display: none; }
.upload-label {
display: inline-block;
padding: 10px 18px;
background: var(--accent);
color: white;
border: none;
border-radius: 9px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: background 0.2s;
box-shadow:0 2px 12px rgba(91,156,245,.25);
}
.upload-label:hover { background: #4a8ce8; }
textarea {
width: 100%;
height: 120px;
padding: 12px;
background: var(--surface-3);
color: var(--ink);
border: 1px solid var(--border-strong);
border-radius: 9px;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 12px;
margin: 12px 0;
resize: vertical;
}
textarea::placeholder{color:var(--muted)}
textarea:focus,input:focus,select:focus{outline:none;border-color:var(--accent)}
.vlan-mapping h2 {
color: var(--ink);
}
.vlan-mapping-content {
display: none;
animation: slideDown 0.3s ease-out;
max-height: 1000px;
}
.vlan-mapping-content.expanded {
display: block;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 1000px;
}
}
.vlan-mapping { margin-bottom: 2rem; }
.vlan-mapping h2 { font-size: 18px; font-weight: 600; margin-bottom: 1rem; }
.vlan-inputs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 1rem;
}
.vlan-input-group {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 9px;
padding: 12px;
}
.vlan-input-group label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.vlan-color-swatch {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid rgba(255,255,255,0.25);
cursor: pointer;
}
.vlan-input-group input[type="text"] {
width: 100%;
padding: 8px;
background: var(--surface-3);
color: var(--ink);
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 13px;
font-family: monospace;
}
.vlan-input-group input[type="color"] {
width: 40px;
height: 32px;
background: var(--surface-3);
border: 1px solid var(--border-strong);
border-radius: 6px;
cursor: pointer;
}
.vlan-color-input-row {
display: flex;
gap: 6px;
margin-top: 6px;
}
.vlan-color-input-row input[type="text"] {
flex: 1;
}
.table-section { margin-bottom: 2rem; }
.table-section h2 { font-size: 18px; font-weight: 600; margin-bottom: 1rem; }
.table-wrapper {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.5rem;
overflow: hidden;
width: 100%;
box-sizing: border-box;
}
.switch-grid {
display: grid;
gap: 6px;
margin-right: 1.5rem;
}
/* Port management (M-GE) : cellule à droite de la grille */
.vlan-cell.mgmt-cell { background: #334155; color: #fff; border: 1px solid #1e293b; cursor: pointer; font-weight: 700; font-size: 8px; }
.vlan-cell.mgmt-cell:hover { background: #475569; }
.port-label.mgmt-label { background: var(--surface-3); color: var(--ink-2); font-weight: 700; font-size: 8px; }
.port-label {
text-align: center;
font-weight: 600;
padding: 4px 2px;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 3px;
background: var(--surface-2);
word-break: break-word;
overflow: hidden;
font-size: clamp(9px, 1.5vw, 12px);
min-height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.vlan-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font-weight: 600;
text-align: center;
padding: 2px;
cursor: pointer;
border: 1px solid rgba(0,0,0,0.35);
transition: transform 0.1s;
word-break: break-word;
overflow: hidden;
font-size: clamp(8px, 1.2vw, 11px);
}
@media (max-width: 768px) {
.vlan-cell {
font-size: clamp(7px, 1vw, 9px);
}
.port-label {
font-size: clamp(8px, 1vw, 10px);
}
}
.vlan-cell:hover { transform: scale(1.05); box-shadow: 0 2px 8px rgba(0,0,0,0.45); }
.vlan-cell.no-vlan {
background: var(--surface-2);
color: var(--muted);
border: 1px dashed var(--border-strong);
}
.vlan-cell.trunk {
background: rgba(232,179,75,.16);
color: var(--warn);
border: 2px solid var(--warn);
font-weight: 700;
font-size: clamp(8px, 1.2vw, 11px);
}
.vlan-cell.fiber {
background: rgba(91,156,245,.15);
color: #8ab8f8;
border: 2px solid var(--accent);
font-weight: 600;
}
.vlan-cell.fiber.trunk {
background: rgba(232,179,75,.16);
color: var(--warn);
border: 2px solid var(--warn);
}
.switch-block {
margin-bottom: 2.5rem;
}
.switch-block h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 0.75rem;
display: flex;
align-items: center;
gap: 8px;
}
.switch-block .action-buttons {
margin-top: 1rem;
}
.vlan-cell.dimmed { opacity: 0.15; }
.vlan-cell.match { box-shadow: 0 0 0 2px #fff; z-index: 1; }
/* FortyGigE (QSFP+/QSFP28) : motif rayé par-dessus le fond (indépendant de la couleur VLAN) */
.vlan-cell.fortygig { position: relative; }
.vlan-cell.fortygig::before {
content: ''; position: absolute; inset: 0; pointer-events: none;
background-image: repeating-linear-gradient(45deg, rgba(0,0,0,0.30) 0 3px, transparent 3px 9px);
}
.vlan-cell.breakout { position: relative; }
.breakout-badge {
position: absolute; bottom: 1px; right: 2px; font-size: 8px; font-weight: 700;
background: rgba(0,0,0,0.65); color: #fff; padding: 0 3px; border-radius: 6px; line-height: 1.4; z-index: 4;
}
.switch-info { font-size: 12px; color: var(--ink-2); background: var(--surface-2); border-left: 3px solid #7c5cf5; padding: 6px 10px; margin: 4px 0 10px; border-radius: 3px; line-height: 1.5; }
.filter-bar {
display: flex;
align-items: center;
gap: 12px;
background: var(--accent-soft);
border: 1px solid var(--accent);
border-radius: 9px;
padding: 8px 14px;
margin-bottom: 1rem;
font-size: 14px;
}
.collapse-content { display: none; }
.collapse-content.expanded { display: block; }
.badge-count {
font-size: 12px;
font-weight: 600;
background: var(--danger);
color: white;
border-radius: 10px;
padding: 1px 8px;
}
.badge-count.zero { background: #2f9e63; }
.vlan-input-group.active {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.summary-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.summary-table th, .summary-table td {
border: 1px solid var(--grid);
padding: 6px 10px;
text-align: left;
}
.summary-table td { color: var(--ink-2); }
.summary-table th { background: var(--surface-2); font-weight: 600; color: var(--muted); }
.summary-swatch {
display: inline-block;
width: 12px; height: 12px;
border-radius: 3px;
margin-right: 6px;
vertical-align: middle;
border: 1px solid rgba(255,255,255,0.25);
}
.anomaly-item {
display: flex;
align-items: baseline;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
margin-bottom: 4px;
font-size: 13px;
}
.anomaly-item.warn { background: rgba(232,179,75,.14); color: var(--warn); }
.anomaly-item.info { background: var(--surface-2); color: var(--ink-2); }
.anomaly-item .aport { font-weight: 600; font-family: monospace; white-space: nowrap; }
.port-detail-panel {
position: fixed;
top: 0; right: 0;
width: 340px;
max-width: 90vw;
height: 100vh;
background: var(--surface);
border-left: 1px solid var(--border-strong);
box-shadow: -2px 0 12px rgba(0,0,0,0.5);
padding: 1.25rem;
overflow-y: auto;
z-index: 1000;
}
.port-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--grid);
}
.port-detail-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid var(--grid);
font-size: 13px;
}
.port-detail-row .k { color: var(--muted); }
.port-detail-row .v { font-weight: 600; text-align: right; word-break: break-word; }
.port-detail-raw {
margin-top: 1rem;
font-family: monospace;
font-size: 11px;
background: #101216;
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
white-space: pre-wrap;
color: var(--ink-2);
}
.action-buttons {
display: flex;
gap: 8px;
margin-top: 1.5rem;
flex-wrap: wrap;
}
button {
padding: 10px 18px;
border: 1px solid var(--border-strong);
background: var(--surface-2);
color: var(--ink);
border-radius: 9px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
font-family: inherit;
transition: all 0.2s;
}
button:hover { background: var(--surface-3); border-color: var(--muted); }
button:active { transform: scale(0.98); }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 2rem;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
text-align: center;
transition: border-color .15s, transform .15s;
}
.stat-card:hover { border-color: var(--border-strong); transform: translateY(-1px); }
.stat-card .number { font-size: 24px; font-weight: 700; color: var(--ink); }
.stat-card .label { font-size: 12px; color: var(--muted); margin-top: 4px; }
.message {
padding: 12px 16px;
border-radius: 9px;
margin-bottom: 1rem;
font-size: 13px;
border: 1px solid;
}
.error-message {
background: rgba(230,103,103,.12);
color: var(--danger);
border-color: var(--danger);
}
.success-message {
background: rgba(78,207,165,.12);
color: var(--ok);
border-color: var(--ok);
}
.vlan-db-section {
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 9px;
padding: 1rem;
margin-bottom: 1rem;
font-size: 12px;
}
.vlan-db-section p {
color: var(--muted);
margin-bottom: 0.5rem;
}
.add-vlan-group {
display: flex;
gap: 8px;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.add-vlan-group input {
padding: 6px 8px;
background: var(--surface-3);
color: var(--ink);
border: 1px solid var(--border-strong);
border-radius: 6px;
font-size: 12px;
}
.add-vlan-group input::placeholder{color:var(--muted)}
.add-vlan-group input[type="number"] {
width: 80px;
}
.add-vlan-group input[type="text"] {
flex: 1;
min-width: 100px;
}
.add-vlan-group input[type="color"] {
width: 50px;
}
.add-vlan-group button {
padding: 6px 12px;
font-size: 12px;
}
/* ---------- Édition groupée + export config ---------- */
.edit-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 1rem; }
.selection-info { font-size: 13px; color: var(--ink); font-weight: 600; }
.range-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
.range-row input[type="text"] { flex: 1; min-width: 150px; padding: 8px; background: var(--surface-3); color: var(--ink); border: 1px solid var(--border-strong); border-radius: 6px; font-family: monospace; font-size: 13px; }
.range-row input[type="text"]::placeholder{color:var(--muted)}
.range-row select { padding: 8px; background: var(--surface-3); color: var(--ink); border: 1px solid var(--border-strong); border-radius: 6px; font-size: 13px; }
.edit-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin: 1rem 0; }
.more-options-toggle { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; font-size: 13px; font-weight: 600; color: var(--ink-2); padding: 8px 12px; margin: 0.5rem 0; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 6px; }
.more-options-toggle:hover { background: var(--surface-3); }
/* --- Aperçu diff (modal) --- */
.diff-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.diff-modal { background: var(--surface); border: 1px solid var(--border-strong); border-radius: 16px; width: min(720px, 92vw); max-height: 85vh; display: flex; flex-direction: column; box-shadow: var(--shadow); }
.diff-modal-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--grid); font-size: 15px; }
.diff-modal-body { padding: 14px 18px; overflow-y: auto; }
.diff-modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 18px; border-top: 1px solid var(--grid); }
.diff-port { margin-bottom: 14px; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
.diff-port-name { background: var(--surface-2); padding: 6px 10px; font-weight: 600; font-size: 13px; border-bottom: 1px solid var(--grid); }
.diff-row { display: grid; grid-template-columns: 130px 1fr auto 1fr; gap: 8px; align-items: center; padding: 5px 10px; font-size: 12px; }
.diff-row + .diff-row { border-top: 1px dashed var(--grid); }
.diff-row .k { color: var(--muted); font-weight: 500; }
.diff-row .old { color: var(--danger); text-decoration: line-through; word-break: break-word; }
.diff-row .arrow { color: var(--muted); }
.diff-row .new { color: var(--ok); font-weight: 600; word-break: break-word; }
.diff-empty { color: var(--muted); font-style: italic; padding: 8px 0; }
.edit-field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; font-weight: 500; }
.edit-field input, .edit-field select { width: 100%; padding: 8px; background: var(--surface-3); color: var(--ink); border: 1px solid var(--border-strong); border-radius: 6px; font-size: 13px; }
.edit-field input::placeholder{color:var(--muted)}
.vlan-cell.selected { outline: 3px solid var(--accent); outline-offset: 1px; z-index: 2; }
.vlan-cell.source { outline: 3px solid #ff9800; outline-offset: 1px; box-shadow: 0 0 8px 2px rgba(255,152,0,0.8); z-index: 3; }
#switchesContainer.source-mode-on .vlan-cell { cursor: pointer; }
#switchesContainer.source-mode-on .vlan-cell:hover { outline: 3px dashed #ff9800; outline-offset: 1px; }
.vlan-cell.modified { position: relative; }
.vlan-cell.modified::after { content: ''; position: absolute; top: 2px; right: 2px; width: 7px; height: 7px; border-radius: 50%; background: #ff9800; border: 1px solid #fff; }
#switchesContainer.edit-mode-on .vlan-cell { cursor: crosshair; }
#switchesContainer.edit-mode-on { user-select: none; -webkit-user-select: none; }
.export-mode-btns { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.export-mode-btns button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.config-output {
width: 100%; height: 240px; padding: 12px;
border: 1px solid var(--border-strong); border-radius: 9px;
font-family: 'Monaco', 'Courier New', monospace; font-size: 12px;
margin: 12px 0; resize: vertical;
background: #101216; color: #d4d4d4;
}
</style>
<script src="vlan_database.js" defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
</head>
<body>
<aside class="appside">
<div class="brand"><div class="brand-mark"></div><div><b>Outils Infra</b><small>portail</small></div></div>
<div class="psw">
<div class="psw-title">Projets</div>
<a href="../cmdb/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 class="active" href="switch_vlan_mapper.html"><div class="psw-ico">🔌</div><div class="psw-txt"><b>Configuration switch</b><small>VLANs, export config</small></div></a>
</div>
<div class="psw" style="margin-top:auto;padding-top:14px;border-top:1px solid var(--border)">
<a href="../cmdb/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 id="dbStatus" style="font-size:11px;color:var(--muted);padding:4px 8px;word-break:break-word;line-height:1.5;margin-top:6px">● Base CMDB</div>
</div>
</aside>
<main class="app-main">
<div class="container">
<div class="header">
<h1>🔌 Switch VLAN Mapper</h1>
<p>Visualisez les VLANs de votre switch • Adapté automatiquement au nombre de ports • Exportez pour Excel avec couleurs</p>
</div>
<div id="message"></div>
<div class="upload-section">
<label class="upload-label">
📤 Importer fichier config
<input type="file" id="fileInput" accept=".txt,.conf,.cfg">
</label>
<p style="margin-top: 8px; font-size: 13px; color: var(--muted);">Ou copiez/collez une configuration ci-dessous</p>
<textarea id="configInput" placeholder="Collez votre configuration de switch ici..."></textarea>
<button onclick="parseConfig()" style="background: var(--accent); color: white; border: none;">Analyser configuration</button>
</div>
<div id="content" style="display: none;">
<div class="stats" id="stats"></div>
<div class="vlan-mapping">
<h2 onclick="toggleVlanMapping()" style="cursor: pointer; user-select: none; display: flex; align-items: center; gap: 8px;">
🎨 Noms et couleurs des VLANs
<span id="vlanToggleIcon" style="font-size: 12px; transition: transform 0.3s;"></span>
</h2>
<div id="vlanMappingContent" class="vlan-mapping-content">
<div class="vlan-db-section">
<p><strong>💾 Base de données VLAN</strong></p>
<p style="font-size: 11px; color: var(--muted);">Modifiez les noms et couleurs, ajoutez des VLANs, ou importez une DB personnalisée.</p>
<div class="add-vlan-group">
<input type="number" id="newVlanId" placeholder="N° VLAN" style="width: 80px;">
<input type="text" id="newVlanName" placeholder="Nom du VLAN">
<input type="color" id="newVlanColor" value="#8B5CF6" title="Cliquez pour changer la couleur">
<button onclick="addCustomVlan()">+ Ajouter VLAN</button>
<button onclick="downloadDatabase()">📥 Télécharger DB</button>
</div>
<div style="margin-top: 0.5rem;">
<label style="font-size: 11px; color: var(--muted); display: flex; align-items: center; gap: 6px; cursor: pointer;">
📤 Importer DB personnalisée
<input type="file" id="dbFileInput" accept=".txt" style="display: none;">
</label>
</div>
</div>
<div class="vlan-inputs" id="vlanMappings"></div>
</div>
</div>
<div id="filterBar" class="filter-bar" style="display:none;">
<span id="filterBarText"></span>
<button onclick="clearVlanFilter()" style="padding:4px 10px; font-size:12px;">✖ Effacer le filtre</button>
</div>
<div class="table-section">
<h2 id="tableTitle">Tableau visuel du switch</h2>
<p style="font-size:12px; color:var(--muted); margin-bottom:0.75rem;">💡 Clic sur une case = détail du port • Clic sur un VLAN dans la légende = filtrer</p>
<div id="switchesContainer"></div>
</div>
<div class="table-section">
<h2 onclick="toggleSection('editContent','editIcon')" style="cursor:pointer; user-select:none; display:flex; align-items:center; gap:8px;">
✏️ Éditer les interfaces
<span id="editIcon" style="font-size:12px; transition:transform 0.3s;"></span>
</h2>
<div id="editContent" class="collapse-content">
<p style="font-size:12px; color:var(--muted); margin-bottom:0.75rem;">💡 Sélectionnez des ports une fois la sélection activée : <b>clic</b> sur une case, <b>clic-glissé</b> pour peindre plusieurs cases, <b>Shift+clic</b> pour une plage, ou via le champ de plage. Définissez les changements puis appliquez. Les ports modifiés portent un point orange.</p>
<div class="edit-toolbar">
<button id="editModeBtn" onclick="toggleEditMode()">🖱️ Activer la sélection par clic</button>
<span class="selection-info" id="selectionInfo">0 interface sélectionnée</span>
<button onclick="clearSelection()">✖ Vider la sélection</button>
</div>
<div class="range-row">
<select id="editStackSelect"></select>
<input type="text" id="editRangeInput" placeholder="Plage de ports, ex : 1-5, 10, 12-14">
<button onclick="selectRangeForEdit()"> Sélectionner la plage</button>
</div>
<div class="more-options-toggle" onclick="toggleSection('moreOptionsContent','moreOptionsIcon')">
⚙️ More Options
<span id="moreOptionsIcon" style="font-size:11px; transition:transform 0.3s;"></span>
</div>
<div id="moreOptionsContent" class="collapse-content">
<div class="edit-fields">
<div class="edit-field"><label>VLAN d'accès</label><input type="number" id="editVlan" placeholder="ex : 20"></div>
<div class="edit-field"><label>Description</label><input type="text" id="editDesc" placeholder="vide = nom du VLAN"></div>
<div class="edit-field"><label>Type de port</label><select id="editType"><option value="">Ne pas changer</option><option value="access">Access</option><option value="trunk">Trunk</option></select></div>
<div class="edit-field"><label>Trunk permit vlan</label><input type="text" id="editTrunkPermit" placeholder="ex : 10 20 30"></div>
<div class="edit-field"><label>802.1X</label><select id="editDot1x"><option value="">Ne pas changer</option><option value="on">Activer</option><option value="off" selected>Désactiver</option></select></div>
<div class="edit-field"><label>MAC-auth</label><select id="editMacAuth"><option value="">Ne pas changer</option><option value="on">Activer</option><option value="off">Désactiver</option></select></div>
<div class="edit-field"><label>Port-security</label><input type="text" id="editPortSec" placeholder="vide = ne pas configurer — ex : userlogin-secure — 'none' = retirer"></div>
<div class="edit-field"><label>Duplex</label><select id="editDuplex"><option value="">Ne pas changer</option><option value="auto">auto</option><option value="full">full</option><option value="half">half</option></select></div>
</div>
</div>
<div class="range-row">
<button id="sourceModeBtn" onclick="toggleSourceMode()">🎯 Choisir l'interface modèle (clic)</button>
<span class="selection-info" id="sourceInfo" style="flex:1; min-width:200px;">Aucune interface modèle</span>
<button onclick="applyFromSource()">📎 Baser la sélection sur cette interface</button>
</div>
<div class="action-buttons">
<button onclick="previewBulkEdit()" style="background:#7c5cf5; color:#fff; border:none;">👁️ Aperçu des changements</button>
<button onclick="applyBulkEdit()" style="background:#2f9e63; color:#fff; border:none;">✔ Appliquer aux interfaces sélectionnées</button>
<button onclick="resetSelectedToOriginal()" style="background:#e66767; color:#fff; border:none;">↩️ Annuler les modifs (config de base)</button>
</div>
</div>
</div>
<div class="table-section">
<h2 onclick="toggleSection('exportContent','exportIcon')" style="cursor:pointer; user-select:none; display:flex; align-items:center; gap:8px;">
💾 Exporter la configuration
<span id="exportIcon" style="font-size:12px; transition:transform 0.3s;"></span>
</h2>
<div id="exportContent" class="collapse-content">
<div class="export-mode-btns">
<button id="expModifiedBtn" class="active" onclick="setExportMode('modified')">Interfaces modifiées</button>
<button id="expRangeBtn" onclick="setExportMode('range')">Plage d'interfaces</button>
</div>
<div id="exportRangeRow" class="range-row" style="display:none;">
<select id="exportStackSelect"></select>
<input type="text" id="exportRangeInput" placeholder="Plage, ex : 1-24 ou 1-5, 10">
</div>
<div class="action-buttons" style="margin-top:0;">
<button onclick="generateExport()" style="background:var(--accent); color:#fff; border:none;">⚙️ Générer la configuration</button>
<button onclick="copyConfig()">📋 Copier</button>
<button onclick="downloadConfig()">📥 Télécharger .txt</button>
</div>
<textarea id="configOutput" class="config-output" placeholder="La configuration générée apparaîtra ici..." readonly></textarea>
<div id="ticketActions" class="action-buttons" style="margin-top:10px; display:none;">
<button id="ticketOpenBtn" onclick="openMapperTicket()" style="background:var(--accent); color:#fff; border:none;">🎫 Ouvrir le ticket ↗</button>
<button id="ticketCopyBtn" onclick="copyTicketConfig()">📋 Copier la description (ticket + config)</button>
</div>
</div>
</div>
<div class="action-buttons">
<button onclick="reset()">🔄 Réinitialiser</button>
</div>
</div>
</div>
</main>
<div id="portDetailPanel" class="port-detail-panel" style="display:none;">
<div class="port-detail-header">
<strong id="portDetailTitle">Port</strong>
<button onclick="closePortDetail()" style="padding:2px 8px;"></button>
</div>
<div id="portDetailBody"></div>
</div>
<div id="diffPreviewOverlay" class="diff-overlay" style="display:none;" onclick="if(event.target===this)closeDiffPreview()">
<div class="diff-modal">
<div class="diff-modal-header">
<strong>👁️ Aperçu des changements</strong>
<button onclick="closeDiffPreview()" style="padding:2px 8px;"></button>
</div>
<div id="diffPreviewBody" class="diff-modal-body"></div>
<div class="diff-modal-footer">
<button onclick="closeDiffPreview()">Annuler</button>
<button id="diffConfirmBtn" onclick="confirmDiffPreview()" style="background:#2f9e63; color:#fff; border:none;">✔ Confirmer et appliquer</button>
</div>
</div>
</div>
<script>
let configData = {};
let vlanDatabase = {};
let vlanMappings = {};
let switches = {}; // { stackId: { ports: {portNum: portObj}, maxPort, numCols } }
let mgmtPorts = []; // interfaces M-GigabitEthernet (management out-of-band)
let activeVlanFilter = null; // VLAN actuellement filtré (surlignage)
let numCols = 24; // Nombre de colonnes par défaut (fallback)
let editMode = false; // Sélection par clic active
let sourceMode = false; // Choix de l'interface modèle par clic actif
let sourceKey = null; // clé "stack:portNum" de l'interface modèle choisie
let dragging = false; // Sélection glissée en cours (clic maintenu)
let dragAdd = true; // true = le glissé ajoute, false = retire
let lastSelectedKey = null; // Dernier port cliqué (pour Shift+clic = plage)
let selectedPorts = new Set(); // clés "stack:portNum" sélectionnées pour l'édition
let exportMode = 'modified'; // 'modified' | 'range'
// Base de données VLAN par défaut (incluse directement)
const DEFAULT_VLAN_DATABASE = `1 = Native = #8B5CF6
10 = Management = #06B6D4
20 = Voice = #EC4899
30 = Video = #F97316
40 = Servers = #10B981
50 = Workstations = #3B82F6
60 = Guests = #F59E0B
70 = IoT = #EF4444
80 = Security = #6366F1
90 = Monitoring = #14B8A6
92 = DMZ = #D946EF
100 = VPN = #EA580C
200 = VLAN200 = #16A34A
300 = VLAN300 = #0EA5E9`;
// Charger la base de données VLAN au démarrage
function loadVlanDatabase() {
// 1⃣ Chercher d'abord dans localStorage (changements sauvegardés par l'utilisateur)
const savedDbText = localStorage.getItem('vlanDatabaseText');
if (savedDbText) {
parseVlanDatabase(savedDbText);
console.log('✓ Base de données chargée depuis le stockage local');
return;
}
// 2⃣ Vérifier si vlan_database.js a été chargé (fonctionne en mode file://)
if (typeof VLAN_DATABASE_FILE !== 'undefined') {
parseVlanDatabase(VLAN_DATABASE_FILE);
localStorage.setItem('vlanDatabaseText', VLAN_DATABASE_FILE);
console.log('✓ Base de données chargée depuis vlan_database.js');
return;
}
// 3⃣ Essayer de charger depuis le fichier externe (serveur HTTP uniquement)
if (!location.href.startsWith('file://')) {
const xhr = new XMLHttpRequest();
xhr.open('GET', 'vlan_database.txt', true);
xhr.onload = function() {
if (xhr.status === 200) {
parseVlanDatabase(xhr.responseText);
localStorage.setItem('vlanDatabaseText', xhr.responseText);
console.log('✓ Base de données chargée depuis vlan_database.txt');
} else {
loadDefaultDatabase();
}
};
xhr.onerror = function() {
loadDefaultDatabase();
};
try {
xhr.send();
return;
} catch (error) {
loadDefaultDatabase();
}
} else {
// Mode file:// et aucun vlan_database.js trouvé - utiliser DB par défaut
loadDefaultDatabase();
}
}
function loadDefaultDatabase() {
parseVlanDatabase(DEFAULT_VLAN_DATABASE);
localStorage.setItem('vlanDatabaseText', DEFAULT_VLAN_DATABASE);
console.log(' Utilisation de la base de données par défaut');
}
function parseVlanDatabase(text) {
vlanDatabase = {};
const lines = text.split('\n').filter(line => line.trim());
lines.forEach(line => {
// Format: VLAN_NUMBER = VLAN_NAME = COLOR_HEX
const parts = line.split('=').map(p => p.trim());
if (parts.length === 3) {
const vlanId = parts[0];
const vlanName = parts[1];
const color = parts[2];
vlanDatabase[vlanId] = { name: vlanName, color: color };
}
});
}
function parseConfig() {
const configText = document.getElementById('configInput').value;
if (!configText.trim()) {
showMessage('Veuillez entrer une configuration', 'error');
return;
}
try {
const lines = configText.split('\n');
const ports = {};
let currentInterface = null;
let maxPortNum = 0;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.includes('---- More ----') || trimmed === '') continue;
if (trimmed.startsWith('interface ')) {
currentInterface = trimmed.substring(10).trim();
ports[currentInterface] = {
iface: currentInterface,
vlan: null, description: '', type: 'access', portNum: null,
dot1x: false, macAuth: false, portSecurity: null, duplex: null,
trunkPermit: null, rawLines: []
};
} else if (currentInterface && trimmed === '#') {
currentInterface = null;
} else if (currentInterface) {
const p = ports[currentInterface];
p.rawLines.push(trimmed);
if (trimmed.startsWith('description ')) {
p.description = trimmed.substring(12).trim();
} else if (trimmed.startsWith('port access vlan ')) {
p.vlan = trimmed.substring(17).trim();
p.type = 'access';
} else if (trimmed.startsWith('port link-type trunk')) {
p.type = 'trunk';
} else if (trimmed.startsWith('port trunk permit vlan ')) {
p.trunkPermit = trimmed.substring(23).trim();
} else if (trimmed.startsWith('duplex ')) {
p.duplex = trimmed.substring(7).trim();
} else if (trimmed.includes('dot1x mandatory-domain') || trimmed.includes('dot1x re-authenticate')) {
p.dot1x = true;
} else if (trimmed.startsWith('mac-authentication')) {
p.macAuth = true;
} else if (trimmed.startsWith('port-security port-mode ')) {
p.portSecurity = trimmed.substring(24).trim();
}
}
}
// Extraire membre de stack, numéro de port et type pour chaque interface physique
switches = {};
mgmtPorts = [];
Object.keys(ports).forEach(iface => {
// Port de management out-of-band : collecté à part, affiché à gauche
if (/^M-GigabitEthernet/i.test(iface)) {
const po = ports[iface];
po.isMgmt = true;
const mm = iface.match(/(\d+)\/\d+\/(\d+)/);
po.stack = mm ? mm[1] : '0';
po.portNum = mm ? parseInt(mm[2]) : 0;
snapshotOriginalPort(po);
mgmtPorts.push(po);
return;
}
if (!/GigabitEthernet|FortyGigE|HundredGigE/i.test(iface)) return;
const isFortyGig = /FortyGigE/i.test(iface); // QSFP+ / QSFP28
const isHundredGig = /HundredGigE/i.test(iface); // QSFP28
// Optique / SFP-QSFP : Ten-Gig, FortyGigE, HundredGigE
const isFiber = /Ten-?GigabitEthernet|FortyGigE|HundredGigE/i.test(iface);
// Port breakout (split QSFP) : suffixe :1 :2 :3 :4
const breakoutMatch = iface.match(/:(\d+)\s*$/);
const isBreakout = !!breakoutMatch;
// Format empilé : <stack>/<slot>/<port> ex: 1/0/1 ou 2/0/48 (le suffixe :N est ignoré)
const stackMatch = iface.match(/(\d+)\/\d+\/(\d+)/);
let stack, portNum;
if (stackMatch) {
stack = stackMatch[1];
portNum = parseInt(stackMatch[2]);
} else {
// Format non empilé : dernier nombre (avant un éventuel :N) = numéro de port
const m = iface.match(/(\d+)(?::\d+)?\s*$/);
if (!m) return;
stack = '1';
portNum = parseInt(m[1]);
}
const po = ports[iface];
po.stack = stack;
po.portNum = portNum;
po.isFiber = isFiber;
po.isFortyGig = isFortyGig;
po.isHundredGig = isHundredGig;
po.isBreakout = isBreakout;
if (isBreakout) po.breakoutIndex = parseInt(breakoutMatch[1]);
snapshotOriginalPort(po); // sauvegarde config de base pour reset
maxPortNum = Math.max(maxPortNum, portNum);
if (!switches[stack]) switches[stack] = { ports: {}, maxPort: 0, numCols: 0, breakouts: {} };
const sw = switches[stack];
// Registre des sous-interfaces breakout (pour les infos du switch)
if (isBreakout) {
if (!sw.breakouts[portNum]) sw.breakouts[portNum] = [];
sw.breakouts[portNum].push(iface);
}
// Ne pas écraser un port déjà pris (sous-interfaces breakout partagent le même port physique)
if (!sw.ports[portNum]) sw.ports[portNum] = po;
sw.maxPort = Math.max(sw.maxPort, portNum);
});
// Post-traitement breakout (générique, tous les switchs) :
// un port physique QSFP (ex : FortyGigE1/0/53) peut être découpé en 4 interfaces
// logiques Ten-GigabitEthernet1/0/53:1..4. On rattache les sous-interfaces au port physique.
Object.keys(switches).forEach(sid => {
const sw = switches[sid];
Object.keys(sw.breakouts).forEach(pnStr => {
const pn = Number(pnStr);
const subs = sw.breakouts[pnStr].map(n => ports[n]).filter(Boolean)
.sort((a, b) => (a.breakoutIndex || 0) - (b.breakoutIndex || 0));
if (!subs.length) return;
// Parent physique explicite (FortyGigE / HundredGigE) au même n° de port ?
const parentName = Object.keys(ports).find(n => {
const pp = ports[n];
return pp && pp.stack === sid && pp.portNum === pn && !pp.isBreakout &&
(pp.isFortyGig || pp.isHundredGig);
});
const cellPort = parentName ? ports[parentName] : (sw.ports[pn] || subs[0]);
if (!cellPort) return;
cellPort.isBreakoutParent = true;
cellPort.breakoutSubs = subs;
cellPort.breakoutCount = subs.length;
// Débit déduit : sous-interfaces Ten-Gig => N × 10G
const subSpeed = subs.every(s => /Ten-?GigabitEthernet/i.test(s.iface)) ? '10G' : 'mixte';
cellPort.breakoutInfo = `${subs.length} × ${subSpeed}`;
// Sans parent explicite, un découpage QSFP reste un port haut débit -> motif FortyGigE
if (!parentName) cellPort.isFortyGig = true;
cellPort.isFiber = true;
sw.ports[pn] = cellPort; // la cellule de la grille représente le port physique
});
});
// Calculer le nombre de colonnes par switch (ports max / 2, arrondi vers le haut)
Object.values(switches).forEach(sw => {
sw.numCols = Math.max(1, Math.ceil(sw.maxPort / 2));
});
numCols = Object.values(switches)[0] ? Object.values(switches)[0].numCols : 24;
const stackCount = Object.keys(switches).length;
configData = ports;
initializeVlanMappings();
renderUI();
showMessage(`✓ Configuration analysée ! ${stackCount} switch${stackCount > 1 ? 's empilés' : ''} détecté${stackCount > 1 ? 's' : ''}`, 'success');
} catch (error) {
showMessage('✗ Erreur lors de l\'analyse : ' + error.message, 'error');
}
}
function initializeVlanMappings() {
vlanMappings = {};
Object.values(configData).forEach(port => {
if (port.vlan && !vlanMappings[port.vlan]) {
if (vlanDatabase[port.vlan]) {
vlanMappings[port.vlan] = {
name: vlanDatabase[port.vlan].name,
color: vlanDatabase[port.vlan].color
};
} else {
vlanMappings[port.vlan] = {
name: `VLAN_${port.vlan}`,
color: generateUniqueColor(port.vlan)
};
}
}
});
}
function generateUniqueColor(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = ((hash << 5) - hash) + text.charCodeAt(i);
hash = hash & hash;
}
const hue = Math.abs(hash % 360);
const saturation = 70;
const lightness = 50;
const c = (lightness / 100) < 0.5 ? 2 * (lightness / 100) * (saturation / 100) : 2 * (1 - lightness / 100) * (saturation / 100);
const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = (lightness / 100) - c / 2;
let r, g, b;
if (hue < 60) { r = c; g = x; b = 0; }
else if (hue < 120) { r = x; g = c; b = 0; }
else if (hue < 180) { r = 0; g = c; b = x; }
else if (hue < 240) { r = 0; g = x; b = c; }
else if (hue < 300) { r = x; g = 0; b = c; }
else { r = c; g = 0; b = x; }
const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
return '#' + toHex(r) + toHex(g) + toHex(b);
}
function addCustomVlan() {
const vlanId = document.getElementById('newVlanId').value.trim();
const vlanName = document.getElementById('newVlanName').value.trim();
const vlanColor = document.getElementById('newVlanColor').value;
if (!vlanId || !vlanName) {
showMessage('Veuillez remplir le numéro et le nom du VLAN', 'error');
return;
}
vlanDatabase[vlanId] = { name: vlanName, color: vlanColor };
vlanMappings[vlanId] = { name: vlanName, color: vlanColor };
// Sauvegarder dans localStorage
saveVlanDatabaseToLocalStorage();
document.getElementById('newVlanId').value = '';
document.getElementById('newVlanName').value = '';
document.getElementById('newVlanColor').value = '#8B5CF6';
renderVlanMappings();
renderSwitches();
showMessage(`✓ VLAN ${vlanId} ajouté et sauvegardé`, 'success');
}
function downloadDatabase() {
let content = '';
Object.keys(vlanDatabase).sort((a, b) => parseInt(a) - parseInt(b)).forEach(vlanId => {
const vlan = vlanDatabase[vlanId];
content += `${vlanId} = ${vlan.name} = ${vlan.color}\n`;
});
const blob = new Blob([content], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'vlan_database.txt';
a.click();
window.URL.revokeObjectURL(url);
showMessage('✓ Base de données téléchargée', 'success');
}
function saveVlanDatabaseToLocalStorage() {
let content = '';
Object.keys(vlanDatabase).sort((a, b) => parseInt(a) - parseInt(b)).forEach(vlanId => {
const vlan = vlanDatabase[vlanId];
content += `${vlanId} = ${vlan.name} = ${vlan.color}\n`;
});
localStorage.setItem('vlanDatabaseText', content);
}
function toggleVlanMapping() {
const content = document.getElementById('vlanMappingContent');
const icon = document.getElementById('vlanToggleIcon');
content.classList.toggle('expanded');
// Rotation de l'icône
if (content.classList.contains('expanded')) {
icon.style.transform = 'rotate(180deg)';
} else {
icon.style.transform = 'rotate(0deg)';
}
}
function renderUI() {
document.getElementById('content').style.display = 'block';
// Repartir sur une sélection vierge à chaque nouvelle analyse
selectedPorts.clear();
lastSelectedKey = null;
dragging = false;
editMode = false;
sourceMode = false;
sourceKey = null;
renderStats();
renderVlanMappings();
renderSwitches();
populateStackSelects();
updateSelectionInfo();
updateEditModeButton();
updateSourceModeButton();
updateSourceInfo();
}
function renderStats() {
const stats = document.getElementById('stats');
const totalPorts = Object.keys(configData).length;
const vlanPorts = Object.values(configData).filter(p => p.vlan).length;
const trunks = Object.values(configData).filter(p => p.type === 'trunk').length;
const uniqueVlans = new Set(Object.values(configData).map(p => p.vlan).filter(Boolean)).size;
stats.innerHTML = `
<div class="stat-card"><div class="number">${totalPorts}</div><div class="label">Ports total</div></div>
<div class="stat-card"><div class="number">${vlanPorts}</div><div class="label">Ports avec VLAN</div></div>
<div class="stat-card"><div class="number">${uniqueVlans}</div><div class="label">VLANs uniques</div></div>
<div class="stat-card"><div class="number">${trunks}</div><div class="label">Trunks</div></div>
`;
}
function renderVlanMappings() {
const container = document.getElementById('vlanMappings');
container.innerHTML = '';
Object.keys(vlanMappings).sort((a, b) => parseInt(a) - parseInt(b)).forEach((vlanId) => {
const group = document.createElement('div');
group.className = 'vlan-input-group' + (activeVlanFilter === vlanId ? ' active' : '');
const vlan = vlanMappings[vlanId];
const color = vlan.color;
const count = countPortsForVlan(vlanId);
const label = document.createElement('label');
label.innerHTML = `<span class="vlan-color-swatch" style="background: ${color};"></span>VLAN ${vlanId} <span style="color:var(--muted);font-weight:400;">(${count})</span>`;
label.style.cursor = 'pointer';
label.title = 'Cliquez pour filtrer les ports de ce VLAN';
label.onclick = () => toggleVlanFilter(vlanId);
const input = document.createElement('input');
input.type = 'text';
input.value = vlan.name;
input.onchange = () => {
vlanMappings[vlanId].name = input.value;
renderSwitches();
};
const colorRow = document.createElement('div');
colorRow.className = 'vlan-color-input-row';
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = color;
colorInput.onchange = () => {
vlanMappings[vlanId].color = colorInput.value;
vlanDatabase[vlanId].color = colorInput.value;
renderSwitches();
renderVlanMappings();
};
const hexInput = document.createElement('input');
hexInput.type = 'text';
hexInput.value = color;
hexInput.readOnly = true;
hexInput.style.fontSize = '11px';
colorRow.appendChild(colorInput);
colorRow.appendChild(hexInput);
group.appendChild(label);
group.appendChild(input);
group.appendChild(colorRow);
container.appendChild(group);
});
}
function getPortData(stack, portNum) {
const sw = switches[stack];
if (!sw) return null;
return sw.ports[portNum] || null;
}
// Rendre un tableau par switch empilé, l'un en dessous de l'autre
function renderSwitches() {
const container = document.getElementById('switchesContainer');
container.innerHTML = '';
const stackIds = Object.keys(switches).sort((a, b) => parseInt(a) - parseInt(b));
const multi = stackIds.length > 1;
stackIds.forEach((stack, idx) => {
const sw = switches[stack];
const cols = sw.numCols;
const maxPort = cols * 2;
const block = document.createElement('div');
block.className = 'switch-block';
const title = document.createElement('h3');
const label = multi ? `Switch ${stack} (empilé)` : 'Switch';
title.innerHTML = `🔌 ${label} <span style="font-weight:400;color:var(--muted);font-size:13px;">— ${cols} colonnes × 4 lignes = ${maxPort} ports</span>`;
block.appendChild(title);
// Infos complémentaires : ports breakout (split QSFP) + interfaces FortyGigE
const infoParts = [];
const bkeys = Object.keys(sw.breakouts || {}).map(Number).sort((a, b) => a - b);
if (bkeys.length) {
const details = bkeys.map(pn => {
const cp = sw.ports[pn];
const phys = cp ? cp.iface.replace(/:\d+\s*$/, '') : ('port ' + pn);
const spd = cp && cp.breakoutInfo ? cp.breakoutInfo : (sw.breakouts[pn].length + ' sous-interfaces');
return `${phys} = ${spd}`;
}).join(' · ');
infoParts.push(`🔀 Breakout QSFP (1 port physique découpé en interfaces logiques) : ${details}`);
}
const fgCount = Object.values(sw.ports).filter(p => p.isFortyGig).length;
if (fgCount) infoParts.push(`▦ ${fgCount} interface(s) FortyGigE (QSFP+/QSFP28) — motif rayé`);
if (infoParts.length) {
const info = document.createElement('div');
info.className = 'switch-info';
info.innerHTML = infoParts.join('<br>');
block.appendChild(info);
}
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper';
const grid = document.createElement('div');
grid.className = 'switch-grid';
grid.id = `grid-${stack}`;
grid.dataset.stack = stack;
grid.style.gridAutoFlow = 'row';
// Colonne management (M-GE) : complètement à droite, dans la même grille, 1er switch
const hasMgmt = (idx === 0 && mgmtPorts.length > 0);
const totalCols = cols + (hasMgmt ? 1 : 0);
grid.dataset.numCols = totalCols;
// Ligne 1: numéros de ports impairs (+ légende M-GE : icône + n° d'interface)
for (let col = 0; col < cols; col++) grid.appendChild(createPortLabel(stack, col * 2 + 1));
if (hasMgmt) grid.appendChild(mgmtLabelCell('🛠 ' + mgmtPorts[0].portNum));
// Ligne 2: cellules VLAN ports impairs (+ 1er port management)
for (let col = 0; col < cols; col++) grid.appendChild(createVlanCell(stack, col * 2 + 1));
if (hasMgmt) grid.appendChild(createMgmtCell(mgmtPorts[0]));
// Ligne 3: cellules VLAN ports pairs (+ 2e port management ou vide)
for (let col = 0; col < cols; col++) grid.appendChild(createVlanCell(stack, col * 2 + 2));
if (hasMgmt) grid.appendChild(mgmtPorts[1] ? createMgmtCell(mgmtPorts[1]) : emptyGridCell());
// Ligne 4: numéros de ports pairs (+ légende 2e port management si présent)
for (let col = 0; col < cols; col++) grid.appendChild(createPortLabel(stack, col * 2 + 2));
if (hasMgmt) grid.appendChild(mgmtLabelCell(mgmtPorts[1] ? '🛠 ' + mgmtPorts[1].portNum : ''));
wrapper.appendChild(grid);
block.appendChild(wrapper);
// Boutons d'action propres à CE switch (copier un tableau, pas les deux)
const actions = document.createElement('div');
actions.className = 'action-buttons';
actions.innerHTML = `
<button onclick="copyTableWithColorsToClipboard('${stack}')" style="background: #2f9e63; color: white; border: none;">📋 Copier avec couleurs (Excel)</button>
<button onclick="copyTableToClipboard('${stack}')">📋 Copier tableau (texte seul)</button>
<button onclick="copyTableAsImage('${stack}')">🖼️ Copier l'image</button>
<button onclick="downloadTableAsImage('${stack}')">📥 Télécharger l'image</button>
`;
block.appendChild(actions);
container.appendChild(block);
});
setTimeout(adjustAllGrids, 0);
}
// Étiquette de la colonne management (entête / pied) dans la grille
function mgmtLabelCell(text) {
const el = document.createElement('div');
el.className = 'port-label mgmt-label';
el.textContent = text;
return el;
}
// Cellule d'un port management (M-GE) dans la grille (à droite)
function createMgmtCell(p) {
const cell = document.createElement('div');
cell.className = 'vlan-cell mgmt-cell';
cell.textContent = 'M-GE';
cell.title = mgmtTooltip(p);
cell.dataset.iface = p.iface;
cell.dataset.config = (p.rawLines || []).join('\n');
cell.onclick = () => showMgmtDetail(p);
return cell;
}
// Cellule vide (remplissage de grille)
function emptyGridCell() {
const cell = document.createElement('div');
cell.className = 'vlan-cell no-vlan';
cell.textContent = '';
return cell;
}
function mgmtTooltip(p) {
return [
p.iface,
'Interface de management out-of-band (OOB)',
'• Port RJ-45 dédié à l\'administration (SSH/Telnet/NMS)',
'• Isolé du plan de données (VPN de gestion)',
'• Up par défaut, auto-négociation vitesse/duplex',
'• Lié au MPU actif en IRF',
p.description ? '• Description : ' + p.description : ''
].filter(Boolean).join('\n');
}
function showMgmtDetail(p) {
const info = [
['Interface', p.iface],
['Rôle', 'Management out-of-band (OOB)'],
['Connecteur', 'RJ-45 dédié'],
['Usage', 'Administration : SSH / Telnet / NMS'],
['Plan réseau', 'Isolé du trafic de données (VPN de gestion)'],
['État par défaut', 'Up — auto vitesse/duplex'],
['IRF', 'Traité par le MPU actif uniquement'],
['Description', p.description || '—']
];
const rows = info.map(([k, v]) =>
`<div class="port-detail-row"><span class="k">${escapeHtml(k)}</span><span class="v">${escapeHtml(v)}</span></div>`
).join('');
document.getElementById('portDetailTitle').textContent = p.iface;
document.getElementById('portDetailBody').innerHTML =
rows + (p.rawLines && p.rawLines.length ? `<div class="port-detail-raw">${escapeHtml(p.rawLines.join('\n'))}</div>` : '');
document.getElementById('portDetailPanel').style.display = 'block';
}
function createPortLabel(stack, portNum) {
const sw = switches[stack];
const label = document.createElement('div');
label.className = 'port-label';
// N'afficher un numéro que si le port existe (évite les labels fantômes de la fibre)
label.textContent = (sw && sw.ports[portNum]) ? portNum : '';
return label;
}
function adjustAllGrids() {
document.querySelectorAll('.switch-grid').forEach(adjustGridSize);
}
function adjustGridSize(grid) {
const wrapper = grid.parentElement;
if (!wrapper) return;
const cols = parseInt(grid.dataset.numCols) || numCols;
const availableWidth = wrapper.clientWidth;
const gridMarginRight = 24; // 1.5rem
const widthForGrid = availableWidth - gridMarginRight;
const gap = 6;
const totalGapWidth = gap * (cols - 1);
const cellWidth = Math.floor((widthForGrid - totalGapWidth) / cols);
grid.style.gridTemplateColumns = `repeat(${cols}, ${cellWidth}px)`;
grid.style.gap = `${gap}px`;
}
function createVlanCell(stack, portNum) {
const cell = document.createElement('div');
const port = getPortData(stack, portNum);
if (!port) {
cell.className = 'vlan-cell no-vlan';
cell.textContent = '—';
cell.title = `Port ${portNum}`;
} else if (port.type === 'trunk') {
// Trunk (cuivre ou fibre) : icône lien
cell.className = 'vlan-cell trunk' + (port.isFiber ? ' fiber' : '');
cell.textContent = '🔗 TRUNK';
cell.title = `${port.isFiber ? 'Fibre' : 'Port'} ${portNum} - TRUNK${port.description ? '\n' + port.description : ''}`;
} else if (port.vlan && vlanMappings[port.vlan]) {
// VLAN d'accès configuré (cuivre OU fibre) : cellule colorée, pas d'icône SFP
cell.className = 'vlan-cell';
const vlan = vlanMappings[port.vlan];
cell.style.background = vlan.color;
cell.style.color = '#fff';
cell.textContent = vlan.name;
cell.title = `${port.isFiber ? 'Fibre' : 'Port'} ${portNum}\n${vlan.name}${port.description ? '\n' + port.description : ''}`;
} else if (port.isFiber) {
// Fibre SANS VLAN : icône SFP
cell.className = 'vlan-cell fiber';
const info = port.description ? port.description : 'SFP';
cell.textContent = '🔆 ' + info;
cell.title = `Fibre ${portNum}${port.description ? '\n' + port.description : ''}`;
} else {
cell.className = 'vlan-cell no-vlan';
cell.textContent = '—';
cell.title = `Port ${portNum}`;
}
// Surlignage / atténuation selon le filtre VLAN actif
if (port && activeVlanFilter !== null) {
if (port.vlan === activeVlanFilter) cell.classList.add('match');
else cell.classList.add('dimmed');
}
// État de sélection / modification pour l'édition groupée
if (port) {
const k = stack + ':' + portNum;
if (selectedPorts.has(k)) cell.classList.add('selected');
if (k === sourceKey) cell.classList.add('source');
if (port.modified) cell.classList.add('modified');
if (port.isFortyGig) cell.classList.add('fortygig'); // motif d'arrière-plan distinct
if (port.isBreakout || port.isBreakoutParent) cell.classList.add('breakout');
// Config de l'interface stockée sur la cellule (pour la note Excel à la copie)
cell.dataset.iface = port.iface;
let cfgText = (port.rawLines || []).join('\n');
if (port.isBreakoutParent && port.breakoutSubs) {
cfgText += (cfgText ? '\n' : '') + port.breakoutSubs
.map(s => `[${s.iface}]\n` + (s.rawLines || []).join('\n')).join('\n');
}
cell.dataset.config = cfgText;
// Port physique découpé (breakout QSFP) : badge « ×N »
if (port.isBreakoutParent && port.breakoutCount) {
const badge = document.createElement('span');
badge.className = 'breakout-badge';
badge.textContent = '×' + port.breakoutCount;
cell.appendChild(badge);
}
// Début d'interaction : choix modèle / plage Shift / début glissé
cell.onmousedown = (e) => {
if (sourceMode) { pickSource(stack, portNum); return; }
if (!editMode) return; // hors édition : clic = détail (voir onclick)
e.preventDefault(); // empêche la sélection de texte pendant le glissé
if (e.shiftKey && lastSelectedKey) {
selectRangeBetween(lastSelectedKey, k);
lastSelectedKey = k;
return;
}
// Glissé « peinture » : on ajoute si le port n'est pas déjà pris, sinon on retire
dragging = true;
dragAdd = !selectedPorts.has(k);
setCellSelected(stack, portNum, cell, dragAdd);
lastSelectedKey = k;
};
// Survol pendant un glissé : applique le même mode (ajout/retrait)
cell.onmouseenter = () => {
if (!dragging || !editMode) return;
setCellSelected(stack, portNum, cell, dragAdd);
lastSelectedKey = k;
};
// Clic simple hors édition : panneau de détail
cell.onclick = () => {
if (sourceMode || editMode) return; // géré par onmousedown
showPortDetail(stack, portNum);
};
}
return cell;
}
/* ---------- Feature: Filtre VLAN ---------- */
function countPortsForVlan(vlanId) {
let n = 0;
Object.values(switches).forEach(sw => {
Object.values(sw.ports).forEach(p => { if (p.vlan === vlanId) n++; });
});
return n;
}
function toggleVlanFilter(vlanId) {
activeVlanFilter = (activeVlanFilter === vlanId) ? null : vlanId;
const bar = document.getElementById('filterBar');
if (activeVlanFilter !== null) {
const name = vlanMappings[vlanId] ? vlanMappings[vlanId].name : '';
document.getElementById('filterBarText').textContent =
`🔎 Filtre actif : VLAN ${vlanId} ${name ? '(' + name + ')' : ''} — ${countPortsForVlan(vlanId)} port(s)`;
bar.style.display = 'flex';
} else {
bar.style.display = 'none';
}
renderSwitches();
renderVlanMappings();
}
function clearVlanFilter() {
activeVlanFilter = null;
document.getElementById('filterBar').style.display = 'none';
renderSwitches();
renderVlanMappings();
}
/* ---------- Feature: Collapse générique ---------- */
function toggleSection(contentId, iconId) {
const content = document.getElementById(contentId);
const icon = document.getElementById(iconId);
content.classList.toggle('expanded');
if (icon) icon.style.transform = content.classList.contains('expanded') ? 'rotate(180deg)' : 'rotate(0deg)';
}
/* ---------- Feature: Détail port ---------- */
function showPortDetail(stack, portNum) {
const port = getPortData(stack, portNum);
if (!port) return;
const rows = [];
const add = (k, v) => { if (v !== null && v !== undefined && v !== '') rows.push(`<div class="port-detail-row"><span class="k">${k}</span><span class="v">${v}</span></div>`); };
let typeLabel = port.isFortyGig ? 'FortyGigE (QSFP+/QSFP28)'
: port.isHundredGig ? 'HundredGigE (QSFP28)'
: port.isFiber ? 'Fibre (SFP)' : 'Cuivre';
if (port.type === 'trunk') typeLabel += ' — TRUNK';
let vlanLabel = '—';
if (port.vlan) {
const nm = vlanMappings[port.vlan] ? vlanMappings[port.vlan].name : '';
vlanLabel = `${port.vlan}${nm ? ' (' + nm + ')' : ''}`;
} else if (port.type === 'trunk') {
vlanLabel = port.trunkPermit ? ('permit ' + port.trunkPermit) : 'all';
}
add('Interface', port.iface);
add('Switch / Port', `${stack} / ${portNum}`);
add('Type', typeLabel);
add('VLAN', vlanLabel);
add('Description', port.description || '—');
add('802.1X', port.dot1x ? '✅ activé' : '—');
add('MAC-auth', port.macAuth ? '✅ activé' : '—');
add('Port-security', port.portSecurity || '—');
add('Duplex', port.duplex || 'auto');
// Section breakout : port physique QSFP découpé en interfaces logiques
let breakoutHtml = '';
if (port.isBreakoutParent && port.breakoutSubs && port.breakoutSubs.length) {
add('Découpage (breakout)', `🔀 ${port.breakoutInfo} — port physique découpé en interfaces logiques`);
const items = port.breakoutSubs.map(s => {
let v = s.description || '—';
if (s.vlan && vlanMappings[s.vlan]) v += ` — VLAN ${s.vlan} (${vlanMappings[s.vlan].name})`;
else if (s.type === 'trunk') v += ' — TRUNK' + (s.trunkPermit ? ' permit ' + s.trunkPermit : '');
return `<div class="port-detail-row"><span class="k">${escapeHtml(s.iface)}</span><span class="v">${escapeHtml(v)}</span></div>`;
}).join('');
breakoutHtml = `<div style="margin-top:8px;font-weight:600;font-size:12px;color:var(--ink-2);">Sous-interfaces (${port.breakoutSubs.length})</div>` + items;
}
document.getElementById('portDetailTitle').textContent = port.iface;
document.getElementById('portDetailBody').innerHTML =
rows.join('') + breakoutHtml +
(port.rawLines && port.rawLines.length ? `<div class="port-detail-raw">${escapeHtml(port.rawLines.join('\n'))}</div>` : '');
document.getElementById('portDetailPanel').style.display = 'block';
}
function closePortDetail() {
document.getElementById('portDetailPanel').style.display = 'none';
}
// Contenu d'une cellule pour le presse-papier : icône seule pour SFP/trunk, sinon le texte
function cellClipboardText(cell) {
if (!cell) return '—';
if (cell.classList.contains('trunk')) return '🔗'; // trunk (cuivre ou fibre)
if (cell.classList.contains('fiber')) return '🔆'; // SFP non-trunk
return cell.textContent.trim();
}
// Attribut title (note au survol) contenant la config de l'interface, pour la copie
function excelNoteAttr(cell) {
const iface = cell && cell.dataset ? (cell.dataset.iface || '') : '';
const cfg = cell && cell.dataset ? (cell.dataset.config || '') : '';
if (!iface && !cfg) return '';
const text = (iface ? iface : '') + (cfg ? '\n' + cfg : '');
const esc = escapeHtml(text).replace(/\n/g, '&#10;');
return ` title="${esc}"`;
}
function copyTableWithColorsToClipboard(stack) {
const grid = document.getElementById('grid-' + stack);
const cols = parseInt(grid.dataset.numCols) || numCols;
const rows = 4;
const cells = Array.from(grid.querySelectorAll('div'));
let html = '<table style="border-collapse: collapse; border: 1px solid #000;">';
for (let row = 0; row < rows; row++) {
html += '<tr>';
for (let col = 0; col < cols; col++) {
const idx = row * cols + col;
const cell = cells[idx];
const isPortLabel = (row === 0 || row === 3);
// Étiquettes de ports : texte brut ; cellules de données : icône seule pour SFP/trunk
const cellText = isPortLabel ? (cell ? cell.textContent.trim() : '') : cellClipboardText(cell);
const note = excelNoteAttr(cell); // note (config) au survol
const isMgmt = cell && (cell.classList.contains('mgmt-cell') || cell.classList.contains('mgmt-label'));
if (isPortLabel) {
const lblSize = isMgmt ? ' font-size: 8pt;' : '';
html += `<td style="border: 1px solid #999; padding: 8px; text-align: center; font-weight: bold; background: #f0f0f0;${lblSize}">${cellText}</td>`;
} else {
const isNoVlan = cell.classList.contains('no-vlan');
const isTrunk = cell.classList.contains('trunk');
const isFiber = cell.classList.contains('fiber');
const fs = isMgmt ? '8pt' : '6pt'; // management en taille 8
if (isNoVlan) {
html += `<td${note} style="border: 1px solid #999; padding: 2px; text-align: center; font-size: ${fs}; background: #f5f5f5; color: #999;"></td>`;
} else if (isTrunk) {
html += `<td${note} style="border: 1px solid #999; padding: 2px; text-align: center; font-size: ${fs}; background: #fff3cd; color: #856404;">${cellText}</td>`;
} else if (isFiber) {
html += `<td${note} style="border: 1px solid #999; padding: 2px; text-align: center; font-size: ${fs}; background: #e0f2fe; color: #075985;">${cellText}</td>`;
} else {
const bgColor = window.getComputedStyle(cell).backgroundColor;
html += `<td${note} style="border: 1px solid #999; padding: 2px; text-align: center; font-size: ${fs}; background: ${bgColor}; color: white;">${cellText}</td>`;
}
}
}
html += '</tr>';
}
html += '</table>';
const blob = new Blob([html], { type: 'text/html' });
const data = [new ClipboardItem({ 'text/html': blob })];
navigator.clipboard.write(data).then(() => {
showMessage('✓ Tableau avec couleurs copié ! Collez directement dans Excel', 'success');
}).catch(() => {
showMessage('✗ Format couleur non supporté, copie en texte', 'error');
copyTableToClipboard(stack);
});
}
function copyTableToClipboard(stack) {
const grid = document.getElementById('grid-' + stack);
const cols = parseInt(grid.dataset.numCols) || numCols;
const rows = 4;
const cells = Array.from(grid.querySelectorAll('div'));
let text = '';
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const idx = row * cols + col;
const cell = cells[idx];
const isPortLabel = (row === 0 || row === 3);
text += isPortLabel ? (cell ? cell.textContent.trim() : '') : cellClipboardText(cell);
text += (col < cols - 1 ? '\t' : '');
}
text += '\n';
}
navigator.clipboard.writeText(text).then(() => {
showMessage('✓ Tableau copié ! Collez dans Excel', 'success');
}).catch(() => {
showMessage('✗ Erreur lors de la copie', 'error');
});
}
function copyTableAsImage(stack) {
const grid = document.getElementById('grid-' + stack);
showMessage('⏳ Génération de l\'image...', 'success');
html2canvas(grid, {
scale: 2,
backgroundColor: '#ffffff',
logging: false,
useCORS: true,
allowTaint: true,
width: grid.scrollWidth,
height: grid.scrollHeight,
windowWidth: grid.scrollWidth
}).then(canvas => {
canvas.toBlob(blob => {
const item = new ClipboardItem({ 'image/png': blob });
navigator.clipboard.write([item]).then(() => {
showMessage('✓ Image du tableau copiée !', 'success');
}).catch(() => {
showMessage('✗ Erreur lors de la copie', 'error');
});
});
}).catch(() => {
showMessage('✗ Erreur lors de la génération de l\'image', 'error');
});
}
function downloadTableAsImage(stack) {
const grid = document.getElementById('grid-' + stack);
showMessage('⏳ Génération de l\'image...', 'success');
html2canvas(grid, {
scale: 2,
backgroundColor: '#ffffff',
logging: false,
useCORS: true,
allowTaint: true,
width: grid.scrollWidth,
height: grid.scrollHeight,
windowWidth: grid.scrollWidth
}).then(canvas => {
const link = document.createElement('a');
link.href = canvas.toDataURL('image/png');
link.download = `switch${stack}_vlan_table_${new Date().toISOString().split('T')[0]}.png`;
link.click();
showMessage('✓ Image téléchargée !', 'success');
}).catch(() => {
showMessage('✗ Erreur lors de la génération de l\'image', 'error');
});
}
/* ================= Feature: Édition groupée + export config ================= */
function populateStackSelects() {
const stackIds = Object.keys(switches).sort((a, b) => parseInt(a) - parseInt(b));
const multi = stackIds.length > 1;
['editStackSelect', 'exportStackSelect'].forEach(id => {
const sel = document.getElementById(id);
if (!sel) return;
sel.innerHTML = stackIds.map(s =>
`<option value="${s}">${multi ? 'Switch ' + s : 'Switch'}</option>`
).join('');
});
populateSourceIfaceSelect();
}
function populateSourceIfaceSelect() {
// L'interface modèle se choisit désormais par clic : on valide juste qu'elle
// existe toujours après un rechargement, puis on rafraîchit le libellé.
if (sourceKey) {
const [s, p] = sourceKey.split(':');
if (!getPortData(s, parseInt(p))) sourceKey = null;
}
updateSourceInfo();
}
function updateSourceInfo() {
const info = document.getElementById('sourceInfo');
if (!info) return;
if (sourceKey) {
const [s, p] = sourceKey.split(':');
const src = getPortData(s, parseInt(p));
info.textContent = src ? `Interface modèle : ${src.iface}` : 'Aucune interface modèle';
} else {
info.textContent = 'Aucune interface modèle';
}
}
function updateSourceModeButton() {
const btn = document.getElementById('sourceModeBtn');
const container = document.getElementById('switchesContainer');
if (!btn) return;
btn.textContent = sourceMode ? '🛑 Annuler le choix' : '🎯 Choisir l\'interface modèle (clic)';
btn.style.background = sourceMode ? '#ff9800' : '';
btn.style.color = sourceMode ? '#fff' : '';
if (container) container.classList.toggle('source-mode-on', sourceMode);
}
function toggleSourceMode() {
sourceMode = !sourceMode;
if (sourceMode) { editMode = false; updateEditModeButton(); closePortDetail(); }
updateSourceModeButton();
}
function pickSource(stack, portNum) {
sourceKey = stack + ':' + portNum;
sourceMode = false;
updateSourceModeButton();
updateSourceInfo();
renderSwitches();
const src = getPortData(stack, parseInt(portNum));
showMessage(`🎯 Interface modèle : ${src ? src.iface : sourceKey}`, 'success');
}
function updateEditModeButton() {
const btn = document.getElementById('editModeBtn');
const container = document.getElementById('switchesContainer');
if (!btn) return;
btn.textContent = editMode ? '🛑 Désactiver la sélection' : '🖱️ Activer la sélection par clic';
btn.style.background = editMode ? 'var(--accent)' : '';
btn.style.color = editMode ? '#fff' : '';
if (container) container.classList.toggle('edit-mode-on', editMode);
}
function toggleEditMode() {
editMode = !editMode;
if (editMode) { sourceMode = false; updateSourceModeButton(); closePortDetail(); }
updateEditModeButton();
}
function updateSelectionInfo() {
const info = document.getElementById('selectionInfo');
if (info) {
const n = selectedPorts.size;
info.textContent = `${n} interface${n > 1 ? 's' : ''} sélectionnée${n > 1 ? 's' : ''}`;
}
}
function toggleSelect(stack, portNum, cell) {
const k = stack + ':' + portNum;
setCellSelected(stack, portNum, cell, !selectedPorts.has(k));
lastSelectedKey = k;
}
// Force l'état de sélection d'un port (on = ajouter, off = retirer)
function setCellSelected(stack, portNum, cell, on) {
const k = stack + ':' + portNum;
if (on) {
if (selectedPorts.has(k)) return;
selectedPorts.add(k);
if (cell) cell.classList.add('selected');
} else {
if (!selectedPorts.has(k)) return;
selectedPorts.delete(k);
if (cell) cell.classList.remove('selected');
}
updateSelectionInfo();
}
// Shift+clic : sélectionne tous les ports entre deux clés (même switch)
function selectRangeBetween(k1, k2) {
const [s1, p1] = k1.split(':');
const [s2, p2] = k2.split(':');
if (s1 !== s2) { // switchs différents : on prend juste la cible
setCellSelected(s2, parseInt(p2), null, true);
renderSwitches();
return;
}
const sw = switches[s1];
if (!sw) return;
let a = parseInt(p1), b = parseInt(p2);
if (a > b) [a, b] = [b, a];
for (let n = a; n <= b; n++) {
if (sw.ports[n]) setCellSelected(s1, n, null, true);
}
renderSwitches(); // rafraîchit les contours de sélection
}
function clearSelection() {
selectedPorts.clear();
lastSelectedKey = null;
document.querySelectorAll('.vlan-cell.selected').forEach(c => c.classList.remove('selected'));
updateSelectionInfo();
}
// Analyse une plage "1-5, 10, 12-14" -> [1,2,3,4,5,10,12,13,14]
function parsePortRange(str) {
const out = [];
(str || '').split(',').forEach(tok => {
tok = tok.trim();
if (!tok) return;
const m = tok.match(/^(\d+)\s*-\s*(\d+)$/);
if (m) {
let a = parseInt(m[1]), b = parseInt(m[2]);
if (a > b) [a, b] = [b, a];
for (let i = a; i <= b; i++) out.push(i);
} else if (/^\d+$/.test(tok)) {
out.push(parseInt(tok));
}
});
return out;
}
function selectRangeForEdit() {
if (!editMode) { editMode = true; updateEditModeButton(); }
const stack = document.getElementById('editStackSelect').value;
const sw = switches[stack];
if (!sw) { showMessage('Aucun switch sélectionné', 'error'); return; }
const nums = parsePortRange(document.getElementById('editRangeInput').value);
if (!nums.length) { showMessage('Plage invalide (ex : 1-5, 10)', 'error'); return; }
let added = 0;
nums.forEach(n => {
if (sw.ports[n]) { selectedPorts.add(stack + ':' + n); added++; }
});
renderSwitches();
updateSelectionInfo();
showMessage(`✓ ${added} interface(s) ajoutée(s) à la sélection`, 'success');
}
// Applique un jeu d'édits à un port : met à jour champs structurés ET rawLines
function applyEditToPort(port, edits) {
let lines = (port.rawLines || []).slice();
const setLine = (prefix, full) => {
const i = lines.findIndex(l => l.startsWith(prefix));
if (i >= 0) lines[i] = full; else lines.push(full);
};
const removeLines = (prefixes) => {
lines = lines.filter(l => !prefixes.some(p => l.startsWith(p)));
};
if (edits.type === 'access') {
removeLines(['port link-type', 'port trunk permit vlan']);
port.type = 'access';
port.trunkPermit = null;
} else if (edits.type === 'trunk') {
removeLines(['port access vlan']);
setLine('port link-type', 'port link-type trunk');
port.type = 'trunk';
port.vlan = null;
}
if (edits.vlan !== '' && edits.vlan != null && port.type !== 'trunk') {
setLine('port access vlan', 'port access vlan ' + edits.vlan);
port.vlan = String(edits.vlan);
port.type = 'access';
ensureVlanMapping(port.vlan);
}
if (edits.trunkPermit && port.type === 'trunk') {
setLine('port trunk permit vlan', 'port trunk permit vlan ' + edits.trunkPermit);
port.trunkPermit = edits.trunkPermit;
}
// 802.1X
if (edits.dot1x === 'on') {
removeLines(['dot1x ', 'undo dot1x ']);
const dom = getReferenceDot1xDomain();
lines.push('undo dot1x handshake', 'dot1x mandatory-domain ' + dom, 'undo dot1x multicast-trigger', 'dot1x re-authenticate');
port.dot1x = true;
} else if (edits.dot1x === 'off') {
removeLines(['dot1x ', 'undo dot1x ']);
port.dot1x = false;
}
// MAC-authentication
if (edits.macAuth === 'on') {
setLine('mac-authentication', 'mac-authentication');
port.macAuth = true;
} else if (edits.macAuth === 'off') {
removeLines(['mac-authentication']);
port.macAuth = false;
}
// Port-security
if (edits.portSec !== '' && edits.portSec != null) {
if (/^(none|off|no|undo)$/i.test(edits.portSec)) {
removeLines(['port-security port-mode']);
port.portSecurity = null;
} else {
setLine('port-security port-mode', 'port-security port-mode ' + edits.portSec);
port.portSecurity = edits.portSec;
}
}
// Duplex
if (edits.duplex) {
setLine('duplex ', 'duplex ' + edits.duplex);
port.duplex = edits.duplex;
}
if (edits.description !== '' && edits.description != null) {
setLine('description', 'description ' + edits.description);
port.description = edits.description;
}
// Description vide → nom du VLAN
if ((!port.description || port.description === '') && port.vlan && vlanMappings[port.vlan]) {
const nm = vlanMappings[port.vlan].name;
setLine('description', 'description ' + nm);
port.description = nm;
}
port.rawLines = lines;
port.modified = true;
}
function getReferenceDot1xDomain() {
let dom = null;
Object.values(switches).forEach(sw => Object.values(sw.ports).forEach(p => {
(p.rawLines || []).forEach(l => {
const m = l.match(/^dot1x mandatory-domain\s+(.+)$/);
if (m && !dom) dom = m[1].trim();
});
}));
return dom || 'radius';
}
// Sauvegarde une copie des champs éditables tels qu'analysés (config de base)
function snapshotOriginalPort(p) {
p._original = {
rawLines: (p.rawLines || []).slice(),
type: p.type, vlan: p.vlan, description: p.description,
trunkPermit: p.trunkPermit, dot1x: p.dot1x, macAuth: p.macAuth,
portSecurity: p.portSecurity, duplex: p.duplex
};
}
// Restaure un port à sa config de base
function restorePortOriginal(p) {
const o = p._original;
if (!o) return;
p.rawLines = o.rawLines.slice();
p.type = o.type;
p.vlan = o.vlan;
p.description = o.description;
p.trunkPermit = o.trunkPermit;
p.dot1x = o.dot1x;
p.macAuth = o.macAuth;
p.portSecurity = o.portSecurity;
p.duplex = o.duplex;
p.modified = false;
}
// Annule les modifications : sélection si présente, sinon tous les ports modifiés
function resetSelectedToOriginal() {
let targets = [];
if (selectedPorts.size > 0) {
selectedPorts.forEach(k => {
const [s, p] = k.split(':');
const d = getPortData(s, parseInt(p));
if (d) targets.push(d);
});
} else {
Object.values(switches).forEach(sw =>
Object.values(sw.ports).forEach(p => { if (p.modified) targets.push(p); }));
}
const toReset = targets.filter(p => p._original && p.modified);
if (toReset.length === 0) { showMessage('Aucune modification à annuler', 'error'); return; }
const scope = selectedPorts.size > 0 ? 'la sélection' : 'toutes les interfaces modifiées';
if (!confirm(`Annuler les modifications de ${toReset.length} interface(s) (${scope}) et revenir à la configuration de base ?`)) return;
toReset.forEach(restorePortOriginal);
renderStats();
renderVlanMappings();
renderSwitches();
showMessage(`✓ ${toReset.length} interface(s) réinitialisée(s) à la config de base`, 'success');
}
// Aligner la sélection sur une interface de référence existante
function applyFromSource() {
if (selectedPorts.size === 0) { showMessage('Sélectionnez au moins une interface cible', 'error'); return; }
const srcKey = sourceKey;
if (!srcKey) { showMessage('Choisissez une interface modèle (clic)', 'error'); return; }
const [ss, sp] = srcKey.split(':');
const src = getPortData(ss, parseInt(sp));
if (!src) { showMessage('Interface source introuvable', 'error'); return; }
let count = 0;
selectedPorts.forEach(k => {
if (k === srcKey) return; // ne pas écraser la source par elle-même
const [st, pn] = k.split(':');
const dst = getPortData(st, parseInt(pn));
if (!dst) return;
dst.rawLines = (src.rawLines || []).slice();
dst.type = src.type;
dst.vlan = src.vlan;
dst.description = src.description;
dst.trunkPermit = src.trunkPermit;
dst.dot1x = src.dot1x;
dst.macAuth = src.macAuth;
dst.portSecurity = src.portSecurity;
dst.duplex = src.duplex;
if (dst.vlan) ensureVlanMapping(dst.vlan);
dst.modified = true;
count++;
});
renderStats();
renderVlanMappings();
renderSwitches();
showMessage(`✓ ${count} interface(s) alignée(s) sur ${src.iface}`, 'success');
}
function ensureVlanMapping(vlanId) {
if (vlanMappings[vlanId]) return;
if (vlanDatabase[vlanId]) {
vlanMappings[vlanId] = { name: vlanDatabase[vlanId].name, color: vlanDatabase[vlanId].color };
} else {
vlanMappings[vlanId] = { name: `VLAN_${vlanId}`, color: generateUniqueColor(vlanId) };
}
}
// Ouvre le panneau « More Options » s'il est replié (pour que les champs soient visibles)
function ensureMoreOptionsOpen() {
const c = document.getElementById('moreOptionsContent');
if (c && !c.classList.contains('expanded')) toggleSection('moreOptionsContent', 'moreOptionsIcon');
}
// Lit les champs d'édition ; renvoie null (+ message) si sélection vide ou rien à changer
function gatherEdits() {
if (selectedPorts.size === 0) {
showMessage('Sélectionnez d\'abord au moins une interface (bouton « Activer la sélection par clic », puis cliquez des ports)', 'error');
return null;
}
const edits = {
vlan: document.getElementById('editVlan').value.trim(),
description: document.getElementById('editDesc').value,
type: document.getElementById('editType').value,
trunkPermit: document.getElementById('editTrunkPermit').value.trim(),
dot1x: document.getElementById('editDot1x').value,
macAuth: document.getElementById('editMacAuth').value,
portSec: document.getElementById('editPortSec').value.trim(),
duplex: document.getElementById('editDuplex').value
};
if (edits.vlan === '' && edits.description === '' && edits.type === '' && edits.trunkPermit === '' &&
edits.dot1x === '' && edits.macAuth === '' && edits.portSec === '' && edits.duplex === '') {
ensureMoreOptionsOpen(); // les champs sont peut-être cachés → on les affiche
showMessage('Renseignez au moins un champ dans « More Options » (déplié ci-dessous)', 'error');
return null;
}
return edits;
}
// Représentation lisible d'une valeur de champ port
function fmtVal(field, v) {
if (v === null || v === undefined || v === '') return '—';
if (field === 'dot1x' || field === 'macAuth') return v ? 'activé' : 'désactivé';
return String(v);
}
// Compare deux ports (avant / après édition simulée) sur les champs structurés
function diffPortFields(before, after) {
const fields = [
['type', 'Type de port'],
['vlan', "VLAN d'accès"],
['trunkPermit', 'Trunk permit'],
['description', 'Description'],
['dot1x', '802.1X'],
['macAuth', 'MAC-auth'],
['portSecurity', 'Port-security'],
['duplex', 'Duplex']
];
const out = [];
fields.forEach(([key, label]) => {
const a = before[key], b = after[key];
const na = (a === undefined || a === '') ? null : a;
const nb = (b === undefined || b === '') ? null : b;
if (na !== nb) out.push({ label, old: fmtVal(key, a), new: fmtVal(key, b) });
});
return out;
}
// Simule l'édition sans muter l'état réel ; renvoie [{iface, diffs:[...]}]
function computeBulkDiff(edits) {
const knownVlans = new Set(Object.keys(vlanMappings)); // pour annuler les effets de bord
const rows = [];
selectedPorts.forEach(k => {
const [stack, portNum] = k.split(':');
const port = getPortData(stack, parseInt(portNum));
if (!port) return;
const clone = JSON.parse(JSON.stringify(port));
applyEditToPort(clone, edits); // mute le clone uniquement (= état après édition)
// Comparaison par rapport à la configuration D'ORIGINE (pas à l'état courant)
const base = port._original || port;
const diffs = diffPortFields(base, clone);
if (diffs.length) rows.push({ iface: port.iface, diffs });
});
// Annule les mappings VLAN éventuellement créés par la simulation
Object.keys(vlanMappings).forEach(id => { if (!knownVlans.has(id)) delete vlanMappings[id]; });
return rows;
}
function previewBulkEdit() {
const edits = gatherEdits();
if (!edits) return;
const rows = computeBulkDiff(edits);
const body = document.getElementById('diffPreviewBody');
const confirmBtn = document.getElementById('diffConfirmBtn');
if (rows.length === 0) {
body.innerHTML = '<div class="diff-empty">Aucune différence par rapport à la configuration d\'origine sur les interfaces sélectionnées.</div>';
confirmBtn.style.display = 'none';
} else {
const html = rows.map(r => {
const lines = r.diffs.map(d =>
`<div class="diff-row"><span class="k">${d.label}</span><span class="old">${escapeHtml(d.old)}</span><span class="arrow"></span><span class="new">${escapeHtml(d.new)}</span></div>`
).join('');
return `<div class="diff-port"><div class="diff-port-name">${escapeHtml(r.iface)}</div>${lines}</div>`;
}).join('');
body.innerHTML = `<p style="font-size:12px;color:var(--muted);margin:0 0 10px;">${rows.length} interface(s) modifiée(s) par rapport à la configuration d'origine :</p>${html}`;
confirmBtn.style.display = '';
}
document.getElementById('diffPreviewOverlay').style.display = 'flex';
}
function closeDiffPreview() {
document.getElementById('diffPreviewOverlay').style.display = 'none';
}
function confirmDiffPreview() {
closeDiffPreview();
applyBulkEdit();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function applyBulkEdit() {
const edits = gatherEdits();
if (!edits) return;
let count = 0;
selectedPorts.forEach(k => {
const [stack, portNum] = k.split(':');
const port = getPortData(stack, parseInt(portNum));
if (port) { applyEditToPort(port, edits); count++; }
});
// Rafraîchir tout ce qui dépend des ports
renderStats();
renderVlanMappings();
renderSwitches();
// Réinitialiser les champs d'édition
document.getElementById('editVlan').value = '';
document.getElementById('editDesc').value = '';
document.getElementById('editType').value = '';
document.getElementById('editTrunkPermit').value = '';
document.getElementById('editDot1x').value = 'off'; // défaut : Désactiver
document.getElementById('editMacAuth').value = '';
document.getElementById('editPortSec').value = '';
document.getElementById('editDuplex').value = '';
showMessage(`✓ ${count} interface(s) modifiée(s)`, 'success');
}
/* ---------- Export configuration ---------- */
function setExportMode(mode) {
exportMode = mode;
document.getElementById('expModifiedBtn').classList.toggle('active', mode === 'modified');
document.getElementById('expRangeBtn').classList.toggle('active', mode === 'range');
document.getElementById('exportRangeRow').style.display = (mode === 'range') ? 'flex' : 'none';
}
// Reconstruit un bloc d'interface au format Comware
// Base d'un nom d'interface = tout sauf le numéro de port final
// ex : "GigabitEthernet1/0/48" -> "GigabitEthernet1/0/"
function ifaceBase(iface) {
return iface.replace(/\d+$/, '');
}
// Signature de config d'un port (jeu de lignes normalisé) pour détecter les configs identiques
function configSignature(port) {
return (port.rawLines || []).slice().sort().join('\n');
}
function generateConfigText(portObjs) {
let out = '#\n';
let i = 0;
while (i < portObjs.length) {
const p = portObjs[i];
const base = ifaceBase(p.iface);
const sig = configSignature(p);
// Étend la plage tant que : même switch, même type/slot, port contigu, config identique
let j = i;
while (j + 1 < portObjs.length) {
const q = portObjs[j + 1];
if (q.stack === p.stack &&
ifaceBase(q.iface) === base &&
q.portNum === portObjs[j].portNum + 1 &&
configSignature(q) === sig) {
j++;
} else break;
}
if (j > i) {
// Plusieurs interfaces contiguës identiques -> interface range (config plus courte)
out += 'interface range ' + p.iface + ' to ' + portObjs[j].iface + '\n';
} else {
out += 'interface ' + p.iface + '\n';
}
(p.rawLines || []).forEach(l => { out += ' ' + l + '\n'; });
out += '#\n';
i = j + 1;
}
return out;
}
function collectPortsSorted(predicate) {
const list = [];
Object.keys(switches).sort((a, b) => parseInt(a) - parseInt(b)).forEach(stack => {
const sw = switches[stack];
Object.keys(sw.ports).map(Number).sort((a, b) => a - b).forEach(portNum => {
const p = sw.ports[portNum];
if (predicate(p, stack, portNum)) list.push(p);
});
});
return list;
}
function generateExport() {
let ports;
if (exportMode === 'modified') {
ports = collectPortsSorted(p => p.modified);
if (!ports.length) {
showMessage('Aucune interface modifiée à exporter', 'error');
document.getElementById('configOutput').value = '';
return;
}
} else {
const stack = document.getElementById('exportStackSelect').value;
const sw = switches[stack];
if (!sw) { showMessage('Aucun switch sélectionné', 'error'); return; }
const nums = parsePortRange(document.getElementById('exportRangeInput').value);
if (!nums.length) { showMessage('Plage invalide (ex : 1-24)', 'error'); return; }
const numSet = new Set(nums);
ports = collectPortsSorted((p, s, n) => s === stack && numSet.has(n));
if (!ports.length) { showMessage('Aucune interface trouvée dans cette plage', 'error'); return; }
}
document.getElementById('configOutput').value = generateConfigText(ports);
renderMapperTicket();
showMessage(`✓ Configuration générée (${ports.length} interface(s))`, 'success');
}
function copyConfig() {
const text = document.getElementById('configOutput').value;
if (!text.trim()) { showMessage('Générez d\'abord la configuration', 'error'); return; }
navigator.clipboard.writeText(text).then(() => {
showMessage('✓ Configuration copiée', 'success');
}).catch(() => showMessage('✗ Erreur lors de la copie', 'error'));
}
function downloadConfig() {
const text = document.getElementById('configOutput').value;
if (!text.trim()) { showMessage('Générez d\'abord la configuration', 'error'); return; }
const blob = new Blob([text], { type: 'text/plain' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `config_${exportMode}_${new Date().toISOString().split('T')[0]}.txt`;
a.click();
window.URL.revokeObjectURL(url);
showMessage('✓ Configuration téléchargée', 'success');
}
function reset() {
document.getElementById('configInput').value = '';
document.getElementById('content').style.display = 'none';
document.getElementById('message').innerHTML = '';
configData = {};
vlanMappings = {};
switches = {};
mgmtPorts = [];
numCols = 24;
activeVlanFilter = null;
editMode = false;
selectedPorts.clear();
const co = document.getElementById('configOutput');
if (co) co.value = '';
document.getElementById('filterBar').style.display = 'none';
closePortDetail();
}
function showMessage(text, type) {
const messageDiv = document.getElementById('message');
messageDiv.className = 'message ' + type + '-message';
messageDiv.textContent = text;
messageDiv.style.display = 'block';
setTimeout(() => {
messageDiv.style.display = 'none';
}, 4000);
}
document.getElementById('fileInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
document.getElementById('configInput').value = event.target.result;
};
reader.readAsText(file);
}
});
// Gestionnaire pour importer une DB personnalisée
document.addEventListener('DOMContentLoaded', () => {
const dbFileInput = document.getElementById('dbFileInput');
if (dbFileInput) {
dbFileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
parseVlanDatabase(event.target.result);
// Sauvegarder dans localStorage
localStorage.setItem('vlanDatabaseText', event.target.result);
renderVlanMappings();
renderSwitches();
showMessage('✓ Base de données personnalisée importée et sauvegardée', 'success');
};
reader.readAsText(file);
}
});
}
});
// Fin d'un glissé de sélection dès que le bouton souris est relâché (n'importe où)
document.addEventListener('mouseup', () => { dragging = false; });
// Échap : ferme l'aperçu s'il est ouvert, sinon désélectionne tout
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const ov = document.getElementById('diffPreviewOverlay');
if (ov && ov.style.display !== 'none') { closeDiffPreview(); return; }
clearSelection();
});
// Réajuster la taille des grids quand la fenêtre est redimensionnée
window.addEventListener('resize', () => {
adjustAllGrids();
});
// Charger la base de données au démarrage
window.addEventListener('load', () => {
loadVlanDatabase();
syncCmdbDbStatus(); // version de la base CMDB (partagée via localStorage)
// Config transmise par la CMDB via l'URL (#cfg=...) : pré-remplir et analyser
loadConfigFromHash();
// Réajuster une fois après le chargement complet
setTimeout(() => {
adjustAllGrids();
}, 100);
});
// Handoff CMDB → générateur : la conf switch arrive encodée dans le hash
// (#cfg=<encodeURIComponent>). On remplit la zone de texte puis on lance
// l'analyse automatiquement, comme si l'utilisateur avait collé la conf.
let mapperMachineName = ''; // nom de la machine (switch) transmis par la CMDB
let mapperHashTicket = null; // ticket transmis par la CMDB via l'URL (prioritaire sur localStorage)
function loadConfigFromHash() {
const h = window.location.hash || '';
const nameM = h.match(/[#&]name=([^&]+)/);
if (nameM) { try { mapperMachineName = decodeURIComponent(nameM[1]); } catch (e) {} }
const urlM = h.match(/[#&]tkurl=([^&]+)/);
const descM = h.match(/[#&]tkdesc=([^&]+)/);
if (urlM || descM) {
mapperHashTicket = { url: '', description: '' };
if (urlM) { try { mapperHashTicket.url = decodeURIComponent(urlM[1]); } catch (e) {} }
if (descM) { try { mapperHashTicket.description = decodeURIComponent(descM[1]); } catch (e) {} }
}
const m = h.match(/[#&]cfg=([^&]+)/);
if (!m) { renderMapperTicket(); return; }
let cfg = '';
try { cfg = decodeURIComponent(m[1]); } catch (e) { renderMapperTicket(); return; }
if (!cfg.trim()) { renderMapperTicket(); return; }
const ta = document.getElementById('configInput');
if (ta) ta.value = cfg;
// Nettoie le hash pour éviter de ré-analyser au rafraîchissement
history.replaceState(null, '', window.location.pathname + window.location.search);
parseConfig();
renderMapperTicket();
}
/* Version de la base CMDB (publiée par la CMDB dans localStorage). */
function syncCmdbDbStatus(){
const el = document.getElementById('dbStatus'); if(!el) return;
try{ const s = localStorage.getItem('cmdb_db_status'); if(s) el.textContent = s; }catch(e){}
}
/* --- Tickets partagés par la CMDB (localStorage 'cmdb_tickets') --- */
function getCmdbTicket(key) {
// Ticket switch transmis par la CMDB via l'URL = prioritaire (toujours fiable).
if (key === 'switch' && mapperHashTicket) return mapperHashTicket;
try { const m = JSON.parse(localStorage.getItem('cmdb_tickets') || '{}'); return m[key] || null; }
catch (e) { return null; }
}
function renderMapperTicket() {
const row = document.getElementById('ticketActions'); if (!row) return;
const tk = getCmdbTicket('switch');
const hasOpen = !!(tk && tk.url);
const hasDesc = !!(tk && tk.description);
const ob = document.getElementById('ticketOpenBtn'); if (ob) ob.style.display = hasOpen ? '' : 'none';
const cb = document.getElementById('ticketCopyBtn'); if (cb) cb.style.display = hasDesc ? '' : 'none';
row.style.display = (hasOpen || hasDesc) ? 'flex' : 'none';
}
function openMapperTicket() {
const tk = getCmdbTicket('switch');
if (tk && tk.url) window.open(tk.url, '_blank');
else showMessage('Aucun lien de ticket configuré (Préférences CMDB → Lien vers ticket).', 'error');
}
function copyTicketConfig() {
const tk = getCmdbTicket('switch');
const cfg = document.getElementById('configOutput').value.trim() || document.getElementById('configInput').value.trim();
const txt = (tk && tk.description)
? tk.description.replace(/\{name\}/g, mapperMachineName || '').replace(/\{\{\s*config\s*\}\}/g, cfg)
: cfg;
if (!txt.trim()) { showMessage('Rien à copier — générez d\'abord la configuration.', 'error'); return; }
navigator.clipboard.writeText(txt)
.then(() => showMessage('✓ Description du ticket copiée (configuration incluse)', 'success'))
.catch(() => window.prompt('Copiez la description du ticket :', txt));
}
</script>
</body>
</html>