This commit is contained in:
Randall 2026-03-17 04:36:15 +00:00
commit e492dc4fcc

709
juniper-config-portal.html Normal file
View File

@ -0,0 +1,709 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Juniper Config Generator</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;700;800&display=swap');
:root {
--bg:#0a0e17;--surface:#111827;--surface2:#1a2235;--border:#1e2d45;
--accent:#00d4ff;--accent2:#ff6b35;--green:#00ff88;
--text:#e2e8f0;--muted:#64748b;--danger:#ff4757;
}
*{box-sizing:border-box;margin:0;padding:0;}
body{background:var(--bg);color:var(--text);font-family:'Syne',sans-serif;min-height:100vh;
background-image:radial-gradient(ellipse 80% 50% at 50% -20%,rgba(0,212,255,.08),transparent),
radial-gradient(ellipse 60% 40% at 80% 100%,rgba(255,107,53,.05),transparent);}
header{border-bottom:1px solid var(--border);padding:18px 32px;display:flex;align-items:center;gap:16px;
background:rgba(17,24,39,.8);backdrop-filter:blur(10px);position:sticky;top:0;z-index:100;}
.logo{width:36px;height:36px;background:linear-gradient(135deg,var(--accent),var(--accent2));
border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:800;color:var(--bg);}
header h1{font-size:18px;font-weight:700;letter-spacing:-.02em;}
header span{color:var(--accent);}
.badge{margin-left:auto;font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted);
background:var(--surface2);border:1px solid var(--border);padding:4px 10px;border-radius:20px;}
.container{max-width:1600px;margin:0 auto;padding:32px 48px;}
/* TABS */
.tabs{display:flex;gap:4px;margin-bottom:28px;background:var(--surface);
border:1px solid var(--border);border-radius:10px;padding:4px;width:fit-content;}
.tab-btn{background:transparent;color:var(--muted);padding:8px 20px;border-radius:7px;font-size:13px;font-weight:700;
border:none;cursor:pointer;transition:all .15s;letter-spacing:.02em;}
.tab-btn:hover{color:var(--text);}
.tab-btn.active{background:var(--surface2);color:var(--text);border:1px solid var(--border);}
.tab-btn.active.tab-legacy{color:var(--accent);}
.tab-btn.active.tab-delete{color:var(--danger);}
.tab-btn.active.tab-esi{color:#a78bfa;}
.esi-card{background:var(--surface);border:1px solid rgba(167,139,250,.25);border-radius:12px;overflow:hidden;transition:border-color .2s;margin-bottom:16px;}
.esi-card:hover{border-color:rgba(167,139,250,.5);}
.esi-card .card-header{border-bottom-color:rgba(167,139,250,.15);}
.card-num.esi{background:linear-gradient(135deg,#a78bfa,#7c3aed);}
.toolbar-title.esi span{color:#a78bfa;}
.esi-body{padding:20px;display:flex;flex-direction:column;gap:16px;} /* form top, configs bottom */
.esi-card.collapsed .esi-body{display:none;}
.esi-config-cols{display:grid;grid-template-columns:1fr 1fr;gap:12px;} /* keep side by side */
.config-header.sw1 span{color:#38bdf8;}
.config-header.sw2 span{color:#f472b6;}
.bulk-section.esi-bulk{border-color:rgba(167,139,250,.2);}
.dot.esi{background:#a78bfa;}
.tab-pane{display:none;}
.tab-pane.active{display:block;}
.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:24px;align-items:center;}
.toolbar-title{font-size:22px;font-weight:800;letter-spacing:-.03em;flex:1;}
.toolbar-title span{color:var(--accent);}
.toolbar-title.del span{color:var(--danger);}
button{font-family:'Syne',sans-serif;font-size:13px;font-weight:600;border:none;cursor:pointer;
border-radius:8px;padding:8px 16px;transition:all .15s ease;display:flex;align-items:center;gap:6px;}
.btn-primary{background:var(--accent);color:var(--bg);}
.btn-primary:hover{background:#33dcff;transform:translateY(-1px);}
.btn-red{background:var(--danger);color:#fff;}
.btn-red:hover{background:#ff6670;transform:translateY(-1px);}
.btn-secondary{background:var(--surface2);color:var(--text);border:1px solid var(--border);}
.btn-secondary:hover{border-color:var(--accent);color:var(--accent);}
.btn-danger{background:transparent;color:var(--danger);border:1px solid rgba(255,71,87,.3);}
.btn-danger:hover{background:rgba(255,71,87,.1);}
.btn-add-int{background:transparent;color:var(--green);border:1px dashed rgba(0,255,136,.4);
font-size:12px;padding:6px 12px;width:100%;justify-content:center;margin-top:6px;}
.btn-add-int:hover{background:rgba(0,255,136,.07);border-color:var(--green);}
.btn-add-int.red{color:var(--danger);border-color:rgba(255,71,87,.35);}
.btn-add-int.red:hover{background:rgba(255,71,87,.07);border-color:var(--danger);}
/* LEGACY CARDS */
.servers-grid{display:flex;flex-direction:column;gap:16px;}
.server-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;transition:border-color .2s;}
.server-card:hover{border-color:rgba(0,212,255,.3);}
.card-header{display:flex;align-items:center;gap:12px;padding:14px 20px;background:var(--surface2);
border-bottom:1px solid var(--border);cursor:pointer;user-select:none;}
.card-num{width:28px;height:28px;background:linear-gradient(135deg,var(--accent),#0088cc);border-radius:6px;
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:var(--bg);flex-shrink:0;}
.card-num.del{background:linear-gradient(135deg,var(--danger),#cc3344);}
.card-name{font-weight:700;font-size:15px;flex:1;}
.card-name.empty{color:var(--muted);font-weight:400;font-style:italic;}
.card-meta{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--muted);display:flex;gap:12px;flex-wrap:wrap;}
.card-meta span{color:var(--accent);}
.card-meta span.red{color:var(--danger);}
.chevron{color:var(--muted);transition:transform .2s;font-size:12px;}
.server-card.collapsed .chevron{transform:rotate(-90deg);}
.card-body{padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:20px;}
.server-card.collapsed .card-body{display:none;}
.form-group{display:flex;flex-direction:column;gap:6px;}
.form-group label{font-size:11px;font-weight:600;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;}
input,select{font-family:'JetBrains Mono',monospace;font-size:13px;background:var(--bg);border:1px solid var(--border);
color:var(--text);border-radius:8px;padding:9px 12px;outline:none;transition:border-color .15s;width:100%;}
input:focus,select:focus{border-color:var(--accent);}
input::placeholder{color:var(--muted);}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px;}
.divider{height:1px;background:var(--border);margin:12px 0;}
.section-label{font-size:11px;font-weight:600;color:var(--muted);letter-spacing:.08em;text-transform:uppercase;
margin-bottom:8px;display:flex;align-items:center;justify-content:space-between;}
.int-count{background:var(--surface2);border:1px solid var(--border);color:var(--accent);font-size:10px;padding:2px 7px;border-radius:10px;}
.int-row{display:flex;align-items:center;gap:8px;margin-bottom:6px;}
.int-badge{font-family:'JetBrains Mono',monospace;font-size:10px;font-weight:700;background:rgba(0,212,255,.1);
color:var(--accent);border:1px solid rgba(0,212,255,.2);border-radius:4px;padding:2px 6px;flex-shrink:0;min-width:28px;text-align:center;}
.int-badge.ae{background:rgba(255,107,53,.12);color:var(--accent2);border-color:rgba(255,107,53,.25);}
.int-badge.del-p{background:rgba(255,71,87,.1);color:var(--danger);border-color:rgba(255,71,87,.25);}
.int-badge.del-ae{background:rgba(255,71,87,.06);color:#ff9999;border-color:rgba(255,71,87,.2);}
.int-row input{flex:1;margin:0;}
.btn-rm{background:transparent;color:var(--muted);border:none;padding:4px 6px;font-size:14px;border-radius:4px;flex-shrink:0;transition:color .15s;}
.btn-rm:hover{color:var(--danger);}
.config-output{background:var(--bg);border:1px solid var(--border);border-radius:8px;overflow:hidden;}
.config-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
background:var(--surface2);border-bottom:1px solid var(--border);}
.config-header span{font-size:11px;font-weight:600;color:var(--accent);letter-spacing:.08em;text-transform:uppercase;}
pre{font-family:'JetBrains Mono',monospace;font-size:12px;line-height:1.7;padding:16px;color:#a8c7fa;white-space:pre;overflow-x:auto;}
pre .d{color:#ff7b93;}pre .s{color:#89ddff;}pre .k{color:var(--green);}pre .v{color:#ffcb6b;}pre .c{color:var(--muted);font-style:italic;}
/* DELETE TAB - entry cards */
.del-card{background:var(--surface);border:1px solid rgba(255,71,87,.2);border-radius:12px;overflow:hidden;transition:border-color .2s;margin-bottom:16px;}
.del-card:hover{border-color:rgba(255,71,87,.45);}
.del-card .card-header{border-bottom-color:rgba(255,71,87,.15);}
/* two-col layout for delete card body */
.del-body{padding:20px;display:grid;grid-template-columns:1fr 1fr;gap:20px;}
.del-card.collapsed .del-body{display:none;}
/* col layout inside delete body left */
.del-col-inputs{display:flex;flex-direction:column;gap:0;}
.bulk-section{margin-top:32px;background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden;}
.bulk-section.del-bulk{border-color:rgba(255,71,87,.2);}
.bulk-header{display:flex;align-items:center;gap:12px;padding:16px 20px;background:var(--surface2);border-bottom:1px solid var(--border);}
.bulk-header h2{font-size:16px;font-weight:700;flex:1;}
.dot{width:8px;height:8px;background:var(--green);border-radius:50%;animation:pulse 2s infinite;}
.dot.red{background:var(--danger);}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.bulk-pre-wrap{padding:20px;}
.toast{position:fixed;bottom:24px;right:24px;background:var(--green);color:var(--bg);font-weight:700;font-size:13px;
padding:12px 20px;border-radius:10px;transform:translateY(100px);transition:transform .3s cubic-bezier(.34,1.56,.64,1);z-index:999;}
.toast.show{transform:translateY(0);}
@media(max-width:900px){
.card-body,.del-body{grid-template-columns:1fr;}
.container{padding:16px;}
.form-row{grid-template-columns:1fr;}
}
</style>
</head>
<body>
<header>
<div class="logo">J</div>
<h1>Juniper <span>Config</span> Generator</h1>
<div class="badge">JUNOS / LACP / ae-bundle</div>
</header>
<div class="container">
<!-- TABS -->
<div class="tabs">
<button class="tab-btn tab-legacy active" onclick="switchTab('legacy')">⚡ Legacy</button>
<button class="tab-btn tab-esi" onclick="switchTab('esi')">🔗 ESI</button>
<button class="tab-btn tab-delete" onclick="switchTab('delete')">🗑 Delete Interface</button>
</div>
<!-- ===== TAB: LEGACY ===== -->
<div class="tab-pane active" id="tab-legacy">
<div class="toolbar">
<div class="toolbar-title">Server <span>Entries</span></div>
<button class="btn-secondary" onclick="copyAll()">📋 Copy All</button>
<button class="btn-primary" onclick="addServer()"> Add Server</button>
</div>
<div class="servers-grid" id="serversGrid"></div>
<div class="bulk-section" id="bulkSection" style="display:none">
<div class="bulk-header" style="cursor:pointer" onclick="toggleBulk('bulkBody','bulkChevron')">
<div class="dot"></div>
<h2>Generated Config — All Servers</h2>
<button class="btn-secondary" style="margin-left:auto" onclick="event.stopPropagation();copyAll()">📋 Copy All</button>
<span id="bulkChevron" style="color:var(--muted);font-size:12px;margin-left:8px"></span>
</div>
<div id="bulkBody" style="display:none"><div class="bulk-pre-wrap"><pre id="bulkPre"></pre></div></div>
</div>
</div>
<!-- ===== TAB: DELETE INTERFACE ===== -->
<div class="tab-pane" id="tab-delete">
<div class="toolbar">
<div class="toolbar-title del">Delete <span>Interface</span></div>
<button class="btn-secondary" onclick="copyAllDel()">📋 Copy All</button>
<button class="btn-red" onclick="addDelEntry()"> Add Entry</button>
</div>
<div id="delGrid"></div>
<div class="bulk-section del-bulk" id="delBulkSection" style="display:none">
<div class="bulk-header" style="cursor:pointer" onclick="toggleBulk('delBulkBody','delBulkChevron')">
<div class="dot red"></div>
<h2>Delete Config — All Entries</h2>
<button class="btn-secondary" style="margin-left:auto" onclick="event.stopPropagation();copyAllDel()">📋 Copy All</button>
<span id="delBulkChevron" style="color:var(--muted);font-size:12px;margin-left:8px"></span>
</div>
<div id="delBulkBody" style="display:none"><div class="bulk-pre-wrap"><pre id="delBulkPre"></pre></div></div>
</div>
</div>
<!-- ===== TAB: ESI ===== -->
<div class="tab-pane" id="tab-esi">
<div class="toolbar">
<div class="toolbar-title esi">ESI <span>Multi-Homing</span></div>
<button class="btn-secondary" onclick="copyAllEsi()">📋 Copy All</button>
<button class="btn-primary" style="background:#a78bfa" onclick="addEsiEntry()"> Add Entry</button>
</div>
<div id="esiGrid"></div>
<div class="bulk-section esi-bulk" id="esiBulkSection" style="display:none">
<div class="bulk-header" style="cursor:pointer" onclick="toggleBulk('esiBulkBody','esiBulkChevron')">
<div class="dot esi"></div>
<h2>ESI Config — All Entries</h2>
<button class="btn-secondary" style="margin-left:auto" onclick="event.stopPropagation();copyAllEsi()">📋 Copy All</button>
<span id="esiBulkChevron" style="color:var(--muted);font-size:12px;margin-left:8px"></span>
</div>
<div id="esiBulkBody" style="display:none"><div class="bulk-pre-wrap"><pre id="esiBulkPre"></pre></div></div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ==================== TABS ====================
function switchTab(name){
document.querySelectorAll('.tab-pane').forEach(p=>p.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active');
document.querySelector('.tab-'+name).classList.add('active');
}
// ==================== LEGACY TAB ====================
const DEFAULTS={vlan:'2906',storm:'PROFILE_10G',mode:'access',lacp:'active'};
let servers=[],nextId=1;
function addServer(data={}){
const id=nextId++;
servers.push({id,name:data.name||'',rack:data.rack||'',u:data.u||'',ae:data.ae||'',
vlan:data.vlan||'',storm:data.storm||'',mode:data.mode||'',lacp:data.lacp||'',
interfaces:data.interfaces?[...data.interfaces]:['','']});
renderCards();
setTimeout(()=>{const el=document.querySelector('[data-id="'+id+'"]');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});},50);
}
function removeServer(id){servers=servers.filter(s=>s.id!==id);renderCards();}
function syncServer(id){
const s=servers.find(s=>s.id===id);if(!s)return;
const card=document.querySelector('[data-id="'+id+'"]');if(!card)return;
s.name=card.querySelector('[data-field="name"]')?.value.trim()||'';
s.rack=card.querySelector('[data-field="rack"]')?.value.trim()||'';
s.u=card.querySelector('[data-field="u"]')?.value.trim()||'';
s.ae=card.querySelector('[data-field="ae"]')?.value.trim()||'';
s.vlan=card.querySelector('[data-field="vlan"]')?.value.trim()||'';
s.storm=card.querySelector('[data-field="storm"]')?.value.trim()||'';
s.mode=card.querySelector('[data-field="mode"]')?.value||'';
s.lacp=card.querySelector('[data-field="lacp"]')?.value||'';
card.querySelectorAll('.int-row input').forEach((inp,i)=>{s.interfaces[i]=inp.value.trim();});
}
function addInterface(id){
syncServer(id);const s=servers.find(s=>s.id===id);if(!s)return;
s.interfaces.push('');renderCards();
setTimeout(()=>{const card=document.querySelector('[data-id="'+id+'"]');if(!card)return;
const inputs=card.querySelectorAll('.int-row input');if(inputs.length)inputs[inputs.length-1].focus();},30);
}
function removeInterface(id,idx){
syncServer(id);const s=servers.find(s=>s.id===id);if(!s||s.interfaces.length<=1)return;
s.interfaces.splice(idx,1);renderCards();
}
function updateServer(id){
syncServer(id);const s=servers.find(s=>s.id===id);if(!s)return;
const card=document.querySelector('[data-id="'+id+'"]');
if(card){
const nameEl=card.querySelector('.card-name');
nameEl.textContent=s.name||'(chưa đặt tên)';nameEl.className='card-name'+(s.name?'':' empty');
const metas=card.querySelectorAll('.card-meta span');
if(metas[0])metas[0].textContent=s.rack||'—';
if(metas[1])metas[1].textContent=s.ae||'—';
if(metas[2])metas[2].textContent=s.interfaces.filter(Boolean).length||s.interfaces.length;
const ic=card.querySelector('.int-count');if(ic)ic.textContent=s.interfaces.length+' port'+(s.interfaces.length>1?'s':'');
const pre=card.querySelector('pre');if(pre)pre.innerHTML=hl(gen(s));
}
renderBulk();
}
function toggleCard(id){document.querySelector('[data-id="'+id+'"]')?.classList.toggle('collapsed');}
function gen(s){
const vlan=s.vlan||DEFAULTS.vlan,storm=s.storm||DEFAULTS.storm,mode=s.mode||DEFAULTS.mode,lacp=s.lacp||DEFAULTS.lacp;
const ints=s.interfaces.filter(i=>i.trim());
if(!ints.length||!s.ae)return '# Điền ít nhất 1 interface và AE để sinh config...';
const base=(s.name&&s.rack&&s.u)?`${s.name}_${s.rack}_U${s.u}`:(s.name||'SERVER');
const vlans=(s.vlan||DEFAULTS.vlan).split(/[\s,]+/).map(v=>v.trim()).filter(Boolean);
const L=[];
ints.forEach(i=>L.push('delete interfaces '+i));
L.push('delete interfaces '+s.ae);
ints.forEach((i,n)=>{L.push('set interfaces '+i+' description '+base+'_P'+(n+1));L.push('set interfaces '+i+' ether-options 802.3ad '+s.ae);});
L.push('set interfaces '+s.ae+' description '+base);
L.push('set interfaces '+s.ae+' aggregated-ether-options lacp '+lacp);
L.push('set interfaces '+s.ae+' unit 0 family ethernet-switching interface-mode '+mode);
vlans.forEach(v=>L.push('set interfaces '+s.ae+' unit 0 family ethernet-switching vlan members '+v));
L.push('set interfaces '+s.ae+' unit 0 family ethernet-switching storm-control '+storm);
L.push('set protocols mstp interface '+s.ae+' no-root-port');
ints.forEach(i=>L.push('delete protocols mstp interface '+i));
return L.join('\n');
}
function hl(text){
return text.split('\n').map(line=>{
if(line.startsWith('#'))return '<span class="c">'+line+'</span>';
if(line.startsWith('delete'))return '<span class="d">'+line+'</span>';
if(line.startsWith('set')){const t=line.split(' ');return '<span class="s">'+t[0]+'</span> <span class="k">'+t.slice(1,-1).join(' ')+'</span> <span class="v">'+t[t.length-1]+'</span>';}
return line;
}).join('\n');
}
function buildInts(s){
return s.interfaces.map((v,i)=>`<div class="int-row"><div class="int-badge">P${i+1}</div><input type="text" value="${v}" placeholder="xe-0/0/${i}" oninput="updateServer(${s.id})"><button class="btn-rm" onclick="removeInterface(${s.id},${i})"></button></div>`).join('');
}
function renderCards(){
const grid=document.getElementById('serversGrid'),sy=window.scrollY;
grid.innerHTML='';
servers.forEach((s,idx)=>{
const card=document.createElement('div');card.className='server-card';card.dataset.id=s.id;
card.innerHTML=`
<div class="card-header" onclick="toggleCard(${s.id})">
<div class="card-num">${idx+1}</div>
<div class="card-name ${s.name?'':'empty'}">${s.name||'(chưa đặt tên)'}</div>
<div class="card-meta">Rack:&nbsp;<span>${s.rack||'—'}</span>&nbsp;|&nbsp;AE:&nbsp;<span>${s.ae||'—'}</span>&nbsp;|&nbsp;Ports:&nbsp;<span>${s.interfaces.filter(Boolean).length||s.interfaces.length}</span></div>
<button class="btn-danger" style="margin-left:8px;padding:4px 10px;font-size:11px" onclick="event.stopPropagation();removeServer(${s.id})">✕ Xóa</button>
<div class="chevron"></div>
</div>
<div class="card-body">
<div>
<div class="form-group" style="margin-bottom:12px">
<label>Server Name / Serial</label>
<input type="text" data-field="name" value="${s.name}" placeholder="210235K0UQ6256V00022" oninput="updateServer(${s.id})">
</div>
<div style="display:grid;grid-template-columns:1fr 0.6fr 0.8fr;gap:10px;margin-bottom:12px">
<div class="form-group"><label>Rack</label><input type="text" data-field="rack" value="${s.rack}" placeholder="2BG10" oninput="updateServer(${s.id})"></div>
<div class="form-group"><label>U Position</label><input type="text" data-field="u" value="${s.u}" placeholder="23" oninput="updateServer(${s.id})"></div>
<div class="form-group"><label>AE Interface</label><input type="text" data-field="ae" value="${s.ae}" placeholder="ae38" oninput="updateServer(${s.id})"></div>
</div>
<div class="divider"></div>
<div class="section-label">Physical Interfaces <span class="int-count">${s.interfaces.length} port${s.interfaces.length>1?'s':''}</span></div>
${buildInts(s)}
<button class="btn-add-int" onclick="addInterface(${s.id})"> Thêm interface</button>
<div class="divider"></div>
<div class="form-row" style="margin-bottom:10px">
<div class="form-group"><label>VLAN Members</label><input type="text" data-field="vlan" value="${s.vlan}" placeholder="${DEFAULTS.vlan}" oninput="updateServer(${s.id})"></div>
<div class="form-group"><label>Storm Profile</label><input type="text" data-field="storm" value="${s.storm}" placeholder="${DEFAULTS.storm}" oninput="updateServer(${s.id})"></div>
</div>
<div class="form-row">
<div class="form-group"><label>Interface Mode</label>
<select data-field="mode" onchange="updateServer(${s.id})">
<option value="" ${!s.mode?'selected':''}>— default (${DEFAULTS.mode}) —</option>
<option value="access" ${s.mode==='access'?'selected':''}>access</option>
<option value="trunk" ${s.mode==='trunk'?'selected':''}>trunk</option>
</select>
</div>
<div class="form-group"><label>LACP Mode</label>
<select data-field="lacp" onchange="updateServer(${s.id})">
<option value="" ${!s.lacp?'selected':''}>— default (${DEFAULTS.lacp}) —</option>
<option value="active" ${s.lacp==='active'?'selected':''}>active</option>
<option value="passive" ${s.lacp==='passive'?'selected':''}>passive</option>
</select>
</div>
</div>
</div>
<div>
<div class="config-output">
<div class="config-header"><span>⚡ Generated Config</span>
<button class="btn-secondary" style="padding:4px 12px;font-size:11px" onclick="copySingle(${s.id})">📋 Copy</button>
</div>
<pre>${hl(gen(s))}</pre>
</div>
</div>
</div>`;
grid.appendChild(card);
});
window.scrollTo(0,sy);renderBulk();
}
function renderBulk(){
const bulk=document.getElementById('bulkSection'),pre=document.getElementById('bulkPre');
if(!servers.length){bulk.style.display='none';return;}
bulk.style.display='block';
pre.innerHTML=hl(servers.map((s,i)=>'# ===== Server '+(i+1)+': '+(s.name||'unnamed')+' =====\n'+gen(s)).join('\n\n'));
}
function copySingle(id){const s=servers.find(s=>s.id===id);if(!s)return;navigator.clipboard.writeText(gen(s)).then(()=>toast('✓ Copied!'));}
function copyAll(){if(!servers.length)return;navigator.clipboard.writeText(servers.map((s,i)=>'# ===== Server '+(i+1)+': '+(s.name||'unnamed')+' =====\n'+gen(s)).join('\n\n')).then(()=>toast('✓ All configs copied!'));}
// ==================== DELETE TAB ====================
let delEntries=[],delNextId=1;
function addDelEntry(data={}){
const id=delNextId++;
delEntries.push({
id,
name: data.name||'',
vlan: data.vlan||'',
physicals: data.physicals?[...data.physicals]:[''],
aes: data.aes?[...data.aes]:[''],
});
renderDelCards();
setTimeout(()=>{const el=document.querySelector('[data-del-id="'+id+'"]');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});},50);
}
function removeDelEntry(id){delEntries=delEntries.filter(e=>e.id!==id);renderDelCards();}
function syncDel(id){
const e=delEntries.find(e=>e.id===id);if(!e)return;
const card=document.querySelector('[data-del-id="'+id+'"]');if(!card)return;
e.name=card.querySelector('[data-field="name"]')?.value.trim()||'';
e.vlan=card.querySelector('[data-field="vlan"]')?.value.trim()||'';
e.physicals=[];card.querySelectorAll('.phys-input').forEach(inp=>e.physicals.push(inp.value.trim()));
e.aes=[];card.querySelectorAll('.ae-input').forEach(inp=>e.aes.push(inp.value.trim()));
}
function addDelPhys(id){syncDel(id);const e=delEntries.find(e=>e.id===id);if(!e)return;e.physicals.push('');renderDelCards();
setTimeout(()=>{const card=document.querySelector('[data-del-id="'+id+'"]');if(!card)return;const inp=card.querySelectorAll('.phys-input');if(inp.length)inp[inp.length-1].focus();},30);}
function removeDelPhys(id,idx){syncDel(id);const e=delEntries.find(e=>e.id===id);if(!e||e.physicals.length<=1)return;e.physicals.splice(idx,1);renderDelCards();}
function addDelAe(id){syncDel(id);const e=delEntries.find(e=>e.id===id);if(!e)return;e.aes.push('');renderDelCards();
setTimeout(()=>{const card=document.querySelector('[data-del-id="'+id+'"]');if(!card)return;const inp=card.querySelectorAll('.ae-input');if(inp.length)inp[inp.length-1].focus();},30);}
function removeDelAe(id,idx){syncDel(id);const e=delEntries.find(e=>e.id===id);if(!e||e.aes.length<=1)return;e.aes.splice(idx,1);renderDelCards();}
function updateDel(id){
syncDel(id);const e=delEntries.find(e=>e.id===id);if(!e)return;
const card=document.querySelector('[data-del-id="'+id+'"]');
if(card){
const nm=card.querySelector('.card-name');nm.textContent=e.name||'(chưa đặt tên)';nm.className='card-name'+(e.name?'':' empty');
const metas=card.querySelectorAll('.card-meta span');
if(metas[0])metas[0].textContent=e.physicals.filter(Boolean).length||'—';
if(metas[1])metas[1].textContent=e.aes.filter(Boolean).length||'—';
const pre=card.querySelector('pre');if(pre)pre.innerHTML=hl(genDel(e));
}
renderDelBulk();
}
function toggleDelCard(id){document.querySelector('[data-del-id="'+id+'"]')?.classList.toggle('collapsed');}
function genDel(e){
const physicals=e.physicals.filter(i=>i.trim());
const aes=e.aes.filter(i=>i.trim());
if(!physicals.length&&!aes.length)return '# Điền ít nhất 1 interface để gen config xóa...';
const vlan=e.vlan||'11';
const L=[];
// each physical: delete + set access + set vlan + set mstp
physicals.forEach(i=>{
L.push('delete interfaces '+i);
L.push('set interfaces '+i+' unit 0 family ethernet-switching interface-mode access');
L.push('set interfaces '+i+' unit 0 family ethernet-switching vlan members '+vlan);
L.push('set protocols mstp interface '+i+' no-root-port');
});
// each ae: delete + delete mstp
aes.forEach(i=>{
L.push('delete interfaces '+i);
L.push('delete protocols mstp interface '+i);
});
return L.join('\n');
}
function buildDelPhys(e){
return e.physicals.map((v,i)=>`<div class="int-row"><div class="int-badge del-p">P${i+1}</div><input class="phys-input" type="text" value="${v}" placeholder="xe-0/0/${i}" oninput="updateDel(${e.id})"><button class="btn-rm" onclick="removeDelPhys(${e.id},${i})"></button></div>`).join('');
}
function buildDelAes(e){
return e.aes.map((v,i)=>`<div class="int-row"><div class="int-badge del-ae">AE${i+1}</div><input class="ae-input" type="text" value="${v}" placeholder="ae${i+1}" oninput="updateDel(${e.id})"><button class="btn-rm" onclick="removeDelAe(${e.id},${i})"></button></div>`).join('');
}
function renderDelCards(){
const grid=document.getElementById('delGrid'),sy=window.scrollY;
grid.innerHTML='';
delEntries.forEach((e,idx)=>{
const card=document.createElement('div');card.className='del-card';card.dataset.delId=e.id;
card.innerHTML=`
<div class="card-header" onclick="toggleDelCard(${e.id})">
<div class="card-num del">${idx+1}</div>
<div class="card-name ${e.name?'':'empty'}">${e.name||'(chưa đặt tên)'}</div>
<div class="card-meta">Phys:&nbsp;<span class="red">${e.physicals.filter(Boolean).length||'—'}</span>&nbsp;|&nbsp;AE:&nbsp;<span class="red">${e.aes.filter(Boolean).length||'—'}</span></div>
<button class="btn-danger" style="margin-left:8px;padding:4px 10px;font-size:11px" onclick="event.stopPropagation();removeDelEntry(${e.id})">✕ Xóa</button>
<div class="chevron"></div>
</div>
<div class="del-body">
<div class="del-col-inputs">
<div class="form-row" style="margin-bottom:14px">
<div class="form-group">
<label>Label / Ghi chú</label>
<input type="text" data-field="name" value="${e.name}" placeholder="Server cũ / mô tả" oninput="updateDel(${e.id})">
</div>
<div class="form-group">
<label>VLAN</label>
<input type="text" data-field="vlan" value="${e.vlan}" placeholder="11" oninput="updateDel(${e.id})">
</div>
</div>
<div class="section-label">Physical Interfaces</div>
${buildDelPhys(e)}
<button class="btn-add-int red" onclick="addDelPhys(${e.id})"> Thêm physical int</button>
<div class="divider"></div>
<div class="section-label">AE Interfaces</div>
${buildDelAes(e)}
<button class="btn-add-int red" onclick="addDelAe(${e.id})"> Thêm AE int</button>
</div>
<div>
<div class="config-output">
<div class="config-header"><span>🗑 Delete Config</span>
<button class="btn-secondary" style="padding:4px 12px;font-size:11px" onclick="copySingleDel(${e.id})">📋 Copy</button>
</div>
<pre>${hl(genDel(e))}</pre>
</div>
</div>
</div>`;
grid.appendChild(card);
});
window.scrollTo(0,sy);renderDelBulk();
}
function renderDelBulk(){
const bulk=document.getElementById('delBulkSection'),pre=document.getElementById('delBulkPre');
if(!delEntries.length){bulk.style.display='none';return;}
bulk.style.display='block';
pre.innerHTML=hl(delEntries.map((e,i)=>'# ===== Entry '+(i+1)+': '+(e.name||'unnamed')+' =====\n'+genDel(e)).join('\n\n'));
}
function copySingleDel(id){const e=delEntries.find(e=>e.id===id);if(!e)return;navigator.clipboard.writeText(genDel(e)).then(()=>toast('✓ Copied!'));}
function copyAllDel(){if(!delEntries.length)return;navigator.clipboard.writeText(delEntries.map((e,i)=>'# ===== Entry '+(i+1)+': '+(e.name||'unnamed')+' =====\n'+genDel(e)).join('\n\n')).then(()=>toast('✓ All delete configs copied!'));}
function toggleBulk(bodyId, chevronId){
const body=document.getElementById(bodyId);
const chev=document.getElementById(chevronId);
const collapsed=body.style.display==='none';
body.style.display=collapsed?'':'none';
chev.textContent=collapsed?'▼':'▶';
}
function toast(msg){const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2200);}
// ==================== ESI TAB ====================
const ESI_DEFAULTS={vlan:'',storm:'PROFILE_10G',mode:'access',lacp:'active',adminKey:'1'};
let esiEntries=[],esiNextId=1;
function addEsiEntry(data={}){
const id=esiNextId++;
esiEntries.push({
id,
name: data.name ||'',
rack: data.rack ||'',
u: data.u ||'',
ae: data.ae ||'',
sw1int: data.sw1int ||'',
sw2int: data.sw2int ||'',
systemId:data.systemId||'',
vlan: data.vlan ||'',
storm: data.storm ||'',
mode: data.mode ||'',
lacp: data.lacp ||'',
adminKey:data.adminKey||'',
});
renderEsiCards();
setTimeout(()=>{const el=document.querySelector('[data-esi-id="'+id+'"]');if(el)el.scrollIntoView({behavior:'smooth',block:'start'});},50);
}
function removeEsiEntry(id){esiEntries=esiEntries.filter(e=>e.id!==id);renderEsiCards();}
function syncEsi(id){
const e=esiEntries.find(e=>e.id===id);if(!e)return;
const card=document.querySelector('[data-esi-id="'+id+'"]');if(!card)return;
const g=f=>card.querySelector('[data-field="'+f+'"]')?.value.trim()||'';
e.name=g('name');e.rack=g('rack');e.u=g('u');e.ae=g('ae');
e.sw1int=g('sw1int');e.sw2int=g('sw2int');e.systemId=g('systemId');
e.vlan=g('vlan');e.storm=g('storm');e.mode=g('mode')||'';e.lacp=g('lacp')||'';e.adminKey=g('adminKey');
}
function updateEsi(id){
syncEsi(id);const e=esiEntries.find(e=>e.id===id);if(!e)return;
const card=document.querySelector('[data-esi-id="'+id+'"]');
if(card){
const nm=card.querySelector('.card-name');nm.textContent=e.name||'(chưa đặt tên)';nm.className='card-name'+(e.name?'':' empty');
const metas=card.querySelectorAll('.card-meta span');
if(metas[0])metas[0].textContent=e.rack||'—';
if(metas[1])metas[1].textContent=e.ae||'—';
const pres=card.querySelectorAll('pre');
if(pres[0])pres[0].innerHTML=hl(genEsi(e,1));
if(pres[1])pres[1].innerHTML=hl(genEsi(e,2));
}
renderEsiBulk();
}
function toggleEsiCard(id){document.querySelector('[data-esi-id="'+id+'"]')?.classList.toggle('collapsed');}
function genEsi(e,sw){
const vlan = e.vlan || ESI_DEFAULTS.vlan;
const storm = e.storm || ESI_DEFAULTS.storm;
const mode = e.mode || ESI_DEFAULTS.mode;
const lacp = e.lacp || ESI_DEFAULTS.lacp;
const akey = e.adminKey|| ESI_DEFAULTS.adminKey;
const phyInt = sw===1 ? e.sw1int : e.sw2int;
if(!phyInt||!e.ae) return '# Điền interface và AE để sinh config...';
const base = (e.name&&e.rack&&e.u)?`${e.name}_${e.rack}_U${e.u}`:(e.name||'SERVER');
const vlans = vlan ? vlan.split(/[\s,]+/).map(v=>v.trim()).filter(Boolean) : [];
const L=[];
L.push('delete interfaces '+phyInt);
L.push('delete protocols mstp interface '+phyInt);
L.push('set interfaces '+phyInt+' description '+base+'_P'+sw);
L.push('set interfaces '+phyInt+' ether-options 802.3ad '+e.ae);
L.push('set protocols mstp interface '+e.ae+' no-root-port');
L.push('set interfaces '+e.ae+' description '+base);
if(e.systemId){
L.push('set interfaces '+e.ae+' esi auto-derive lacp');
L.push('set interfaces '+e.ae+' esi all-active');
}
L.push('set interfaces '+e.ae+' aggregated-ether-options lacp '+lacp);
if(e.systemId){
L.push('set interfaces '+e.ae+' aggregated-ether-options lacp system-id '+e.systemId);
}
L.push('set interfaces '+e.ae+' aggregated-ether-options lacp admin-key '+akey);
L.push('set interfaces '+e.ae+' unit 0 family ethernet-switching interface-mode '+mode);
vlans.forEach(v=>L.push('set interfaces '+e.ae+' unit 0 family ethernet-switching vlan members '+v));
L.push('set interfaces '+e.ae+' unit 0 family ethernet-switching storm-control '+storm);
return L.join('\n');
}
function renderEsiCards(){
const grid=document.getElementById('esiGrid'),sy=window.scrollY;
grid.innerHTML='';
esiEntries.forEach((e,idx)=>{
const card=document.createElement('div');card.className='esi-card';card.dataset.esiId=e.id;
card.innerHTML=`
<div class="card-header" onclick="toggleEsiCard(${e.id})">
<div class="card-num esi">${idx+1}</div>
<div class="card-name ${e.name?'':'empty'}">${e.name||'(chưa đặt tên)'}</div>
<div class="card-meta">Rack:&nbsp;<span>${e.rack||'—'}</span>&nbsp;|&nbsp;AE:&nbsp;<span>${e.ae||'—'}</span>&nbsp;|&nbsp;SysID:&nbsp;<span>${e.systemId||'—'}</span></div>
<button class="btn-danger" style="margin-left:8px;padding:4px 10px;font-size:11px" onclick="event.stopPropagation();removeEsiEntry(${e.id})">✕ Xóa</button>
<div class="chevron"></div>
</div>
<div class="esi-body">
<div style="display:grid;grid-template-columns:2fr 1fr 0.6fr 0.8fr;gap:8px;margin-bottom:10px">
<div class="form-group"><label>Server Name / Serial</label><input type="text" data-field="name" value="${e.name}" placeholder="GOG3NE812A0001" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>Rack</label><input type="text" data-field="rack" value="${e.rack}" placeholder="2BO26" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>U</label><input type="text" data-field="u" value="${e.u}" placeholder="7" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>AE</label><input type="text" data-field="ae" value="${e.ae}" placeholder="ae31" oninput="updateEsi(${e.id})"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div style="display:flex;flex-direction:column;gap:10px">
<div class="form-group"><label>SW1 Interface (P1)</label><input type="text" data-field="sw1int" value="${e.sw1int}" placeholder="xe-0/0/37" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>SW2 Interface (P2)</label><input type="text" data-field="sw2int" value="${e.sw2int}" placeholder="xe-0/0/37" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>Storm Profile</label><input type="text" data-field="storm" value="${e.storm}" placeholder="${ESI_DEFAULTS.storm}" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>Interface Mode</label>
<select data-field="mode" onchange="updateEsi(${e.id})">
<option value="" ${!e.mode?'selected':''}>— default (${ESI_DEFAULTS.mode}) —</option>
<option value="access" ${e.mode==='access'?'selected':''}>access</option>
<option value="trunk" ${e.mode==='trunk'?'selected':''}>trunk</option>
</select>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div class="form-group"><label>System ID (LACP)</label><input type="text" data-field="systemId" value="${e.systemId}" placeholder="01:00:00:00:02:15" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>VLAN Members</label><input type="text" data-field="vlan" value="${e.vlan}" placeholder="${ESI_DEFAULTS.vlan||'2906'}" oninput="updateEsi(${e.id})"></div>
<div class="form-group"><label>Admin Key</label><input type="text" data-field="adminKey" value="${e.adminKey}" placeholder="1" oninput="updateEsi(${e.id})"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div class="config-output">
<div class="config-header sw1">
<span>🔵 SW1 Config</span>
<button class="btn-secondary" style="padding:4px 12px;font-size:11px" onclick="copySingleEsi(${e.id},1)">📋 Copy</button>
</div>
<pre>${hl(genEsi(e,1))}</pre>
</div>
<div class="config-output">
<div class="config-header sw2">
<span>🔴 SW2 Config</span>
<button class="btn-secondary" style="padding:4px 12px;font-size:11px" onclick="copySingleEsi(${e.id},2)">📋 Copy</button>
</div>
<pre>${hl(genEsi(e,2))}</pre>
</div>
</div>
</div>`;
grid.appendChild(card);
});
window.scrollTo(0,sy);renderEsiBulk();
}
function renderEsiBulk(){
const bulk=document.getElementById('esiBulkSection'),pre=document.getElementById('esiBulkPre');
if(!esiEntries.length){bulk.style.display='none';return;}
bulk.style.display='block';
pre.innerHTML=hl(esiEntries.map((e,i)=>{
return '# ===== ESI '+(i+1)+': '+(e.name||'unnamed')+' — SW1 =====\n'+genEsi(e,1)+'\n\n'
+ '# ===== ESI '+(i+1)+': '+(e.name||'unnamed')+' — SW2 =====\n'+genEsi(e,2);
}).join('\n\n'));
}
function copySingleEsi(id,sw){
const e=esiEntries.find(e=>e.id===id);if(!e)return;
navigator.clipboard.writeText(genEsi(e,sw)).then(()=>toast('✓ SW'+sw+' copied!'));
}
function copyAllEsi(){
if(!esiEntries.length)return;
const txt=esiEntries.map((e,i)=>{
return '# ===== ESI '+(i+1)+': '+(e.name||'unnamed')+' — SW1 =====\n'+genEsi(e,1)+'\n\n'
+ '# ===== ESI '+(i+1)+': '+(e.name||'unnamed')+' — SW2 =====\n'+genEsi(e,2);
}).join('\n\n');
navigator.clipboard.writeText(txt).then(()=>toast('✓ All ESI configs copied!'));
}
// Sample data
addServer({name:'210235K0UQ6256V00022',rack:'2BG10',u:'23',ae:'ae38',interfaces:['xe-0/0/18','xe-1/0/18']});
addServer({name:'210235K0UQ6256V00021',rack:'2BG26',u:'13',ae:'ae33',interfaces:['xe-0/0/14','xe-1/0/14']});
addDelEntry({name:'Server cũ example',physicals:['xe-0/0/18','xe-1/0/18'],aes:['ae38','ae39']});
addEsiEntry({name:'GOG3NE812A0001',rack:'2BO26',u:'7',ae:'ae31',sw1int:'xe-0/0/37',sw2int:'xe-0/0/37',systemId:'01:00:00:00:02:15',vlan:'100',adminKey:'1'});
</script>
</body>
</html>