- loadVlanDatabase() interroge GET /api/vlans (table networks de la CMDB) au lieu du fichier vlan_database.js, désormais supprimé - Repli sur la base par défaut intégrée en mode file:// ou serveur injoignable - Lien vers le générateur de mots de passe dans la navigation Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2547 lines
95 KiB
HTML
2547 lines
95 KiB
HTML
<!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="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>
|
||
<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="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.
|
||
// Source de vérité : la table `networks` de la CMDB (SQL), exposée par le
|
||
// portail via GET /api/vlans. Chaque réseau CMDB portant un numéro de VLAN
|
||
// devient une entrée (nom + couleur). Remplace l'ancien vlan_database.js.
|
||
// Replis (mode file:// ou serveur injoignable) : base par défaut intégrée.
|
||
async function loadVlanDatabase() {
|
||
if (!location.href.startsWith('file://')) {
|
||
try {
|
||
const res = await fetch('/api/vlans', { cache: 'no-store' });
|
||
if (res.ok) {
|
||
const rows = await res.json();
|
||
vlanDatabase = {};
|
||
rows.forEach(r => {
|
||
if (r.vlan === null || r.vlan === undefined || r.vlan === '') return;
|
||
vlanDatabase[String(r.vlan)] = {
|
||
name: r.name || `VLAN_${r.vlan}`,
|
||
color: r.color || '#8B5CF6'
|
||
};
|
||
});
|
||
console.log(`✓ ${Object.keys(vlanDatabase).length} VLAN(s) chargés depuis la CMDB (table networks)`);
|
||
return;
|
||
}
|
||
console.warn('CMDB /api/vlans a répondu', res.status, '→ base VLAN par défaut');
|
||
} catch (error) {
|
||
console.warn('CMDB injoignable, base VLAN par défaut :', error);
|
||
}
|
||
}
|
||
// Mode file:// ou serveur indisponible : base par défaut intégrée
|
||
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, ' ');
|
||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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', async () => {
|
||
// Attendre les VLANs (table networks CMDB) avant toute analyse : une conf
|
||
// reçue via le hash s'analyse aussitôt et a besoin des noms/couleurs.
|
||
await 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>
|