Web UI: network topology drawing; richer Overview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9a11d03567
commit
bcbb07721b
5 files changed
+342
-24
No files matched your search
@@ -53,6 +53,11 @@ publish a tag that has no section here.
|
||||
and a periodic reconcile create, fix and delete only Rollcall-owned records; `dns_name` per address
|
||||
for DNS sync; delegated prefixes (`rollcall ipam prefixes|import`); `rollcall ipam [sync -dry]`,
|
||||
`/api/v1/ipam`.
|
||||
- Web UI: network topology drawing (region, external IPAM, rack controllers, one bus per VLAN with
|
||||
its subnets, machines wired to every VLAN they have an address on; large groups collapse) on the
|
||||
Network page and, compact, on the Overview. Overview redesigned: fleet state bar, per-rack counts,
|
||||
attention list incl. IPAM sync, capacity and free pool, OS, hardware, BMC drivers, locations, tags,
|
||||
recent deployments.
|
||||
- `rollcall version`; CD-built binary carries `git describe`.
|
||||
- Release notes from `CHANGELOG.md`.
|
||||
|
||||
|
||||
+99
-24
@@ -14,7 +14,7 @@ let token = '';
|
||||
try { token = localStorage.getItem('rollcall.token') || ''; } catch (e) { /* storage unavailable */ }
|
||||
// ?token=… on the URL signs in once and is removed from the address bar (for bookmarks/kiosks)
|
||||
{ const u = new URL(location.href); const t = u.searchParams.get('token'); if (t) { token = t; try { localStorage.setItem('rollcall.token', t); } catch (e) { /* ignore */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + u.search + u.hash); } }
|
||||
const S = { machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], subnets: [], controllers: [], layouts: [], fabrics: [], sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false };
|
||||
const S = { machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], subnets: [], controllers: [], layouts: [], fabrics: [], ips: [], ipam: null, sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false };
|
||||
const locStr = l => { if (!l) return ''; const p = [l.building, l.room, l.rack].filter(Boolean).join(' / '); return p + (l.unit ? ` U${l.unit}` : ''); };
|
||||
let timer = null;
|
||||
|
||||
@@ -76,6 +76,9 @@ async function refresh(full) {
|
||||
const page = route().page;
|
||||
if (full || page === 'overview' || page === 'jobs') S.jobs = await api('GET', '/api/v1/jobs');
|
||||
if (full || page === 'overview') S.events = await api('GET', '/api/v1/events');
|
||||
if (full || page === 'overview' || page === 'network') {
|
||||
[S.ips, S.ipam] = await Promise.all([api('GET', '/api/v1/ips'), api('GET', '/api/v1/ipam').catch(() => null)]);
|
||||
}
|
||||
$('#n-machines').textContent = machines.length;
|
||||
const active = S.jobs.filter(j => j.state === 'pending' || j.state === 'running').length;
|
||||
$('#n-jobs').textContent = active ? `${active} active` : '';
|
||||
@@ -127,35 +130,104 @@ function render() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- overview
|
||||
const stateOrder = ['deployed', 'booted', 'stateless', 'ready', 'deploying', 'releasing', 'recommission', 'new', 'deploy-failed', 'release-failed'];
|
||||
function hbars(rows, max, go) { // rows: [[label, n, title?, goValue?]]
|
||||
if (!rows.length) return '<span class="muted">none</span>';
|
||||
max = max || Math.max(...rows.map(r => r[1]), 1);
|
||||
return `<div class="hbar">${rows.map(([l, n, t, g, rmax, shown]) => `<span title="${esc(t || '')}"${g ? ` data-go="${esc(g)}" style="cursor:pointer"` : ''}>${l}</span><div class="bar"><span style="width:${Math.max(n ? 2 : 0, Math.round(n * 100 / (rmax || max)))}%"></span></div><span class="n">${esc(shown ?? String(n))}</span>`).join('')}</div>`;
|
||||
}
|
||||
function countBy(xs, f) { const o = {}; for (const x of xs) { const k = f(x); if (k === undefined || k === null || k === '') continue; o[k] = (o[k] || 0) + 1; } return Object.entries(o).sort((a, b) => b[1] - a[1]); }
|
||||
function pageOverview() {
|
||||
const by = {}; for (const m of S.machines) by[m.state] = (by[m.state] || 0) + 1;
|
||||
const order = ['ready', 'deployed', 'booted', 'stateless', 'deploying', 'releasing', 'recommission', 'new', 'deploy-failed', 'release-failed'];
|
||||
const states = Object.keys(by).sort((a, b) => (order.indexOf(a) + 1 || 99) - (order.indexOf(b) + 1 || 99));
|
||||
const M = S.machines;
|
||||
const by = {}; for (const m of M) by[m.state] = (by[m.state] || 0) + 1;
|
||||
const states = Object.keys(by).sort((a, b) => (stateOrder.indexOf(a) + 1 || 99) - (stateOrder.indexOf(b) + 1 || 99));
|
||||
const active = S.jobs.filter(j => j.state === 'pending' || j.state === 'running');
|
||||
const bmcBad = S.machines.filter(m => !m.bmc || !m.bmc.configured).length;
|
||||
const stale = S.machines.filter(m => (m.state === 'booted' || m.state === 'stateless') && m.last_seen && Date.now() - new Date(m.last_seen) > 15 * 60e3).length;
|
||||
const bmcBad = M.filter(m => !m.bmc || !m.bmc.configured).length;
|
||||
const stale = M.filter(m => (m.state === 'booted' || m.state === 'stateless') && m.last_seen && Date.now() - new Date(m.last_seen) > 15 * 60e3).length;
|
||||
const warnImgs = S.os.filter(i => i.warning);
|
||||
const failed = S.jobs.filter(j => j.state === 'failed');
|
||||
const offline = S.controllers.filter(c => !c.online);
|
||||
const sum = (xs, f) => xs.reduce((a, m) => a + (Number(f(m)) || 0), 0);
|
||||
const ready = M.filter(m => m.state === 'ready');
|
||||
const ipamLast = S.ipam && S.ipam.last_sync;
|
||||
|
||||
// fleet
|
||||
const bar = `<div class="statebar">${states.map(s => `<span class="${stateClass(s)}" style="width:${by[s] * 100 / Math.max(M.length, 1)}%" title="${esc(s)}: ${by[s]}"></span>`).join('')}</div>`;
|
||||
const week = Date.now() - 7 * 864e5;
|
||||
const fleet = `${bar}<div class="big">${states.map(s => `<div data-go="machines/${esc(s)}" style="cursor:pointer"><b>${by[s]}</b><span>${pill(s)}</span></div>`).join('')}<div data-go="jobs" style="cursor:pointer"><b>${active.length}</b><span>active jobs</span></div></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:14px">
|
||||
<div><div class="muted" style="font-size:11.5px;margin-bottom:4px">BY RACK CONTROLLER</div>${hbars(countBy(M, m => m.rack_controller || 'unassigned').map(([k, n]) => [esc(k), n]))}</div>
|
||||
<div>${kv([['New in the last 7 days', String(M.filter(m => new Date(m.first_seen) > week).length)], ['Deployed in the last 7 days', String(M.filter(m => m.deployed_at && new Date(m.deployed_at) > week).length)],
|
||||
['Jobs failed (last 100)', failed.length ? `<span class="bad">${failed.length}</span>` : '0'], ['Oldest ready', (() => { const r = ready.slice().sort((a, b) => new Date(a.last_seen) - new Date(b.last_seen))[0]; return r ? `${esc(nameOf(r))} · ${ago(r.last_seen)}` : '—'; })()]])}</div></div>`;
|
||||
|
||||
// attention
|
||||
const attention = kv([
|
||||
['Rack controllers', offline.length ? `<span class="bad">${offline.map(c => esc(c.name)).join(', ')} offline</span> <a href="#/network">show</a>` : `<span class="ok">${S.controllers.length} online</span>`],
|
||||
['External IPAM', !S.ipam || !S.ipam.provider ? '<span class="muted">built-in only</span>' : ipamLast && ipamLast.error ? `<span class="bad">${esc(ipamLast.error.slice(0, 80))}</span>` : `<span class="ok">${esc(S.ipam.provider)} in sync</span>${ipamLast && ipamLast.conflicts && ipamLast.conflicts.length ? ` <span class="warn">${ipamLast.conflicts.length} conflicts</span>` : ''} <span class="muted">${ipamLast && ipamLast.at ? ago(ipamLast.at) + ' ago' : ''}</span>`],
|
||||
['BMC not configured', bmcBad ? `<span class="bad">${bmcBad}</span> <a href="#/machines">show</a>` : '<span class="ok">none</span>'],
|
||||
['Stateless nodes silent > 15 min', stale ? `<span class="warn">${stale}</span>` : '<span class="ok">none</span>'],
|
||||
['Failed jobs (last 100)', failed.length ? `<span class="bad">${failed.length}</span> <a href="#/jobs">show</a>` : '<span class="ok">none</span>'],
|
||||
['OS images', warnImgs.length ? warnImgs.map(i => `<span class="warn">${esc(i.name)}: ${esc(i.warning)}</span>`).join('<br>') : `<span class="ok">${S.os.length} current</span>`],
|
||||
]);
|
||||
|
||||
// network
|
||||
const subRows = S.subnets.filter(s => s.size).sort((a, b) => b.used / b.size - a.used / a.size).slice(0, 6)
|
||||
.map(s => [`<span class="mono">${esc(s.cidr)}</span>`, s.used, `${s.purpose} · ${s.used} of ${s.size} addresses`, '', s.size, `${s.used}/${s.size}`]);
|
||||
const maxPct = S.subnets.filter(s => s.size).map(s => [s, s.used / s.size]).sort((a, b) => b[1] - a[1])[0];
|
||||
const network = `<div class="topo-wrap">${topoSVG({ compact: true })}</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
|
||||
<div><div class="muted" style="font-size:11.5px;margin-bottom:4px">ADDRESSES IN USE</div>${hbars(subRows)}</div>
|
||||
<div>${kv([['Subnets', `${S.subnets.length} (${S.subnets.filter(s => s.family === 6).length} IPv6)`], ['VLANs with DHCP', String(new Set(S.subnets.filter(s => s.dhcp).map(s => s.vlan_id)).size)],
|
||||
['Fullest', maxPct ? `<span class="mono">${esc(maxPct[0].cidr)}</span> ${Math.round(maxPct[1] * 100)}%` : '—'], ['Addresses', String(S.ips.length)],
|
||||
['IPAM', S.ipam && S.ipam.provider ? `${esc(S.ipam.provider)} · ${esc(S.ipam.dns_zone || '')}` : 'built-in']])}</div></div>`;
|
||||
|
||||
// capacity
|
||||
const capacity = `<div class="big">
|
||||
<div><b>${sum(M, m => m.cpus)}</b><span>cores</span></div><div><b>${human(sum(M, m => m.memory_bytes))}</b><span>memory</span></div>
|
||||
<div><b>${human(sum(M, m => m.storage_bytes))}</b><span>storage</span></div><div><b>${sum(M, m => m.gpus)}</b><span>GPUs</span></div><div><b>${sum(M, m => m.ib_hcas)}</b><span>IB HCAs</span></div></div>
|
||||
<div style="margin-top:12px">${kv([['Ready to deploy', ready.length ? `<a href="#" data-go="machines/ready">${ready.length} machines</a> · ${sum(ready, m => m.cpus)} cores · ${human(sum(ready, m => m.memory_bytes))}` : '<span class="muted">none free</span>'],
|
||||
['In use', `${M.filter(m => /deployed|booted|stateless/.test(m.state)).length} machines`]])}</div>`;
|
||||
|
||||
// OS
|
||||
const osRows = countBy(M.filter(m => m.os || m.profile), m => m.os ? m.os : 'stateless: ' + m.profile).map(([k, n]) => [esc(k), n]);
|
||||
const osCard = hbars(osRows) + `<div class="muted" style="margin-top:8px">${S.os.length} images in the catalog${warnImgs.length ? `, <span class="warn">${warnImgs.length} need attention</span>` : ''} · <a href="#/images">images</a></div>`;
|
||||
|
||||
// hardware + BMC
|
||||
const hw = hbars(countBy(M, m => [m.manufacturer, m.product].filter(Boolean).join(' ').slice(0, 40) || 'unknown').slice(0, 5).map(([k, n]) => [esc(k), n, k]));
|
||||
const bmc = hbars(countBy(M, m => m.bmc && m.bmc.configured ? m.bmc.driver || 'ipmi' : 'not configured').map(([k, n]) => [esc(k), n]));
|
||||
|
||||
// locations
|
||||
const locRows = countBy(M, m => { const l = m.location || {}; return [l.building, l.room, l.rack].filter(Boolean).join(' / ') || 'no location'; }).slice(0, 8).map(([k, n]) => [esc(k), n]);
|
||||
|
||||
// tags
|
||||
const tags = countBy(M.flatMap(m => (m.tags || []).map(t => ({ t }))), x => x.t);
|
||||
const tagCard = tags.length ? `<div class="chips">${tags.map(([t, n]) => `<span class="chip" data-tag="${esc(t)}">${esc(t)}<span class="n">${n}</span></span>`).join('')}</div>` : '<span class="muted">no tags</span>';
|
||||
|
||||
// activity
|
||||
const recent = M.filter(m => m.deployed_at).sort((a, b) => new Date(b.deployed_at) - new Date(a.deployed_at)).slice(0, 8);
|
||||
const activity = active.length ? jobsTable(active, true) : table(['Deployed', 'Machine', 'OS', 'Address'], recent.map(m => [`<span class="muted" title="${esc(when(m.deployed_at))}">${ago(m.deployed_at)} ago</span>`, `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`, esc(`${m.os || ''} ${m.os_version || ''}`), `<span class="mono">${esc(m.mgmt_ip || '')}</span>`]));
|
||||
const events = table(['When', 'Machine', 'Event', 'Detail'], S.events.slice(0, 12).map(e => { const m = M.find(x => x.id === e.machine_id); return [`<span class="muted" title="${esc(when(e.ts))}">${ago(e.ts)}</span>`, `<a href="#/machine/${esc(e.machine_id)}">${esc(m ? nameOf(m) : e.machine_id)}</a>`, esc(e.event), `<span class="mono muted">${esc(e.detail.slice(0, 60))}</span>`]; }));
|
||||
|
||||
return `
|
||||
<div class="page-head"><h1>Overview</h1><span class="sub">${S.machines.length} machines</span></div>
|
||||
<div class="stats">
|
||||
<div class="stat" data-go="machines"><div class="v">${S.machines.length}</div><div class="l">machines</div></div>
|
||||
${states.map(s => `<div class="stat" data-go="machines/${esc(s)}"><div class="v">${by[s]}</div><div class="l">${pill(s)}</div></div>`).join('')}
|
||||
<div class="stat" data-go="jobs"><div class="v">${active.length}</div><div class="l">active jobs</div></div>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="card wide"><h3>Active jobs</h3>${jobsTable(active, true)}</div>
|
||||
<div class="card"><h3>Attention</h3>${kv([
|
||||
['Rack controllers offline', (() => { const off = S.controllers.filter(c => !c.online); return off.length ? `<span class="bad">${off.map(c => esc(c.name)).join(', ')}</span> <a href="#/network">show</a>` : `<span class="ok">none</span> <span class="muted">(${S.controllers.length})</span>`; })()],
|
||||
['BMC not configured', bmcBad ? `<span class="bad">${bmcBad}</span> <a href="#/machines">show</a>` : '<span class="ok">none</span>'],
|
||||
['Stateless nodes silent > 15 min', stale ? `<span class="warn">${stale}</span>` : '<span class="ok">none</span>'],
|
||||
['Failed jobs (last 100)', (() => { const n = S.jobs.filter(j => j.state === 'failed').length; return n ? `<span class="bad">${n}</span> <a href="#/jobs">show</a>` : '<span class="ok">none</span>'; })()],
|
||||
['OS images', warnImgs.length ? warnImgs.map(i => `<span class="warn">${esc(i.name)}: ${esc(i.warning)}</span>`).join('<br>') : `<span class="ok">${S.os.length} ok</span>`],
|
||||
['Profiles', S.profiles.length ? S.profiles.map(p => `${esc(p.name)} (${S.machines.filter(m => m.profile === p.name).length})`).join(', ') : '<span class="muted">none</span>'],
|
||||
])}</div>
|
||||
<div class="card"><h3>Recent events</h3>${table(['When', 'Machine', 'Event', 'Detail'], S.events.slice(0, 25).map(e => { const m = S.machines.find(x => x.id === e.machine_id); return [`<span class="muted" title="${esc(when(e.ts))}">${ago(e.ts)}</span>`, `<a href="#/machine/${esc(e.machine_id)}">${esc(m ? nameOf(m) : e.machine_id)}</a>`, esc(e.event), `<span class="mono muted">${esc(e.detail.slice(0, 80))}</span>`]; }))}</div>
|
||||
<div class="page-head"><h1>Overview</h1><span class="sub">${M.length} machines · ${S.controllers.length} rack controllers · ${S.subnets.length} subnets${S.ipam && S.ipam.provider ? ' · ' + esc(S.ipam.provider) : ''}</span></div>
|
||||
<div class="ov">
|
||||
<div class="card w8"><h3>Fleet</h3>${fleet}</div>
|
||||
<div class="card"><h3>Attention</h3>${attention}</div>
|
||||
<div class="card wide"><h3>Network <a href="#/network" style="font-weight:400;text-transform:none;letter-spacing:0">details</a></h3>${network}</div>
|
||||
<div class="card"><h3>Capacity</h3>${capacity}</div>
|
||||
<div class="card"><h3>Operating systems</h3>${osCard}</div>
|
||||
<div class="card"><h3>Hardware</h3>${hw}<h3 style="margin-top:12px">BMC drivers</h3>${bmc}</div>
|
||||
<div class="card w6"><h3>Locations <a href="#/racks" style="font-weight:400;text-transform:none;letter-spacing:0">racks</a></h3>${hbars(locRows)}</div>
|
||||
<div class="card w6"><h3>Tags</h3>${tagCard}</div>
|
||||
<div class="card w6"><h3>${active.length ? 'Active jobs' : 'Recent deployments'}</h3>${activity}</div>
|
||||
<div class="card w6"><h3>Recent events</h3>${events}</div>
|
||||
</div>`;
|
||||
}
|
||||
function wireOverview() { $$('[data-go]').forEach(el => el.onclick = () => { const [p, st] = el.dataset.go.split('/'); S.states.clear(); if (st) S.states.add(st); nav(p); }); }
|
||||
function wireOverview() {
|
||||
wireTopo();
|
||||
$$('[data-go]').forEach(el => { if (el.closest('.topo') && !el.dataset.go.includes('/')) return; el.onclick = ev => { ev.preventDefault(); const [p, st] = el.dataset.go.split('/'); S.states.clear(); S.tagf.clear(); if (st) S.states.add(st); nav(p); }; });
|
||||
$$('[data-tag]').forEach(el => el.onclick = () => { S.states.clear(); S.tagf.clear(); S.tagf.add(el.dataset.tag); nav('machines'); });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- machines list
|
||||
function filtered() {
|
||||
@@ -628,6 +700,8 @@ function pageNetwork() {
|
||||
const lay = table(['Layout', 'Machines', 'Interfaces', 'Comment', ''], S.layouts.map(l => [`<b>${esc(l.name)}</b>`, l.machines || 0, `<span class="mono">${esc(layoutSummary(l))}</span>`, esc(l.comment || ''), `<button class="btn sm" data-editlayout="${esc(l.name)}">edit</button> <button class="btn sm danger" data-rmlayout="${esc(l.name)}">delete</button>`]));
|
||||
return `<div class="page-head"><h1>Network</h1><span class="sub">fabrics, VLANs and subnets (MAAS-style); rack controllers serve DHCP/PXE on their VLANs</span>
|
||||
<div class="right"><button class="btn primary" id="new-subnet">New subnet…</button><button class="btn" id="new-layout">New layout…</button></div></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Topology</h3><div class="topo-wrap">${topoSVG()}</div>
|
||||
<div class="legend"><span><i></i>VLAN with DHCP/PXE</span><span><i class="plain"></i>VLAN</span><span><i class="dash"></i>storage / IB</span><span><i class="ctl"></i>rack ↔ region (mTLS)</span><span><i class="down"></i>rack offline</span><span><b class="sq"></b>rack serves/sits on the VLAN</span><span><b class="ci"></b>machine address</span><span>click a bus for its addresses, a machine for its page</span></div></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Rack controllers</h3><div class="tw">${ctl}</div><p class="muted" style="margin:8px 0 0">Add one next to a segment: <code>rollcall controller join-code -name NAME -addr REGION:443</code> here, then on that host <code>./rollcall rack join CODE -url http://ITS-ADDRESS</code> and <code>sh install-rack.sh</code>. It talks to the region over mutual TLS.</p></div>
|
||||
<div class="card wide" style="margin-bottom:12px"><h3>Subnets</h3><div class="tw">${sub}</div></div>
|
||||
<div class="card wide"><h3>Interface layouts</h3>${lay}<p class="muted" style="margin:8px 0 0">A layout applies to a machine directly (More → Network layout…) or through a tag (Tags → layout). Without one, the boot NIC gets an address on its subnet.</p></div>`;
|
||||
@@ -668,6 +742,7 @@ async function editLayout(l) {
|
||||
try { await api('POST', '/api/v1/layouts', { name: v.name.trim(), comment: v.comment, interfaces: spec.interfaces || spec }); for (const k in netCache) delete netCache[k]; toast(`layout ${v.name} saved`); refresh(true); } catch (e) { toast(e.message, true); }
|
||||
}
|
||||
function wireNetwork() {
|
||||
wireTopo();
|
||||
let dl = $('#dl-fabrics'); if (!dl) { dl = document.createElement('datalist'); dl.id = 'dl-fabrics'; document.body.appendChild(dl); }
|
||||
dl.innerHTML = S.fabrics.map(f => `<option value="${esc(f.name)}">`).join('');
|
||||
$('#new-subnet').onclick = () => editSubnet(null);
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
</dialog>
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
<script src="topo.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -133,3 +133,46 @@ dialog input { width: 100%; }
|
||||
main { padding: 14px 16px; } .grid { grid-template-columns: 1fr; } input[type=search] { min-width: 0; width: 100%; }
|
||||
table { display: block; overflow-x: auto; } dialog { min-width: 0; width: calc(100vw - 32px); }
|
||||
}
|
||||
|
||||
/* topology */
|
||||
.topo-wrap { overflow-x: auto; }
|
||||
svg.topo { display: block; font: 12px system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
svg.topo.compact { font-size: 11px; }
|
||||
svg.topo text { fill: var(--fg); }
|
||||
svg.topo .t1 { font-weight: 600; } svg.topo .t2, svg.topo .t3, svg.topo .n2 { fill: var(--muted); font-size: 11px; }
|
||||
svg.topo.compact .n2, svg.topo.compact .t3 { font-size: 10px; }
|
||||
svg.topo .seg line { stroke: var(--line2); stroke-width: 3; stroke-linecap: round; }
|
||||
svg.topo .seg.dhcp line { stroke: var(--accent); stroke-width: 4; }
|
||||
svg.topo .seg.p-storage line, svg.topo .seg.p-ib line { stroke-dasharray: 8 5; }
|
||||
svg.topo .seg { cursor: pointer; } svg.topo .seg:hover line { stroke-width: 6; }
|
||||
svg.topo .wire { stroke: var(--line2); stroke-width: 1.5; fill: none; }
|
||||
svg.topo .wire.ctl { stroke: var(--accent); stroke-width: 1.5; }
|
||||
svg.topo .wire.ctl.down { stroke: var(--danger); stroke-dasharray: 5 4; }
|
||||
svg.topo .wire.ext { stroke: var(--muted); stroke-dasharray: 3 3; }
|
||||
svg.topo .tap { fill: var(--panel); stroke: var(--fg); stroke-width: 1.5; }
|
||||
svg.topo .tap.rack { fill: var(--accent); stroke: var(--accent); } svg.topo .wire.rackdrop { stroke: var(--accent); stroke-opacity: .55; stroke-width: 2; }
|
||||
svg.topo .node rect { fill: var(--panel); stroke: var(--line2); stroke-width: 1.2; }
|
||||
svg.topo .node .n1 { font-weight: 600; }
|
||||
svg.topo .region rect { stroke: var(--accent); stroke-width: 2; fill: var(--sel); }
|
||||
svg.topo .ipam rect { stroke-dasharray: 4 3; } svg.topo .ipam.bad rect { stroke: var(--danger); }
|
||||
svg.topo .rack.ok .led { fill: var(--ok); } svg.topo .rack.bad .led { fill: var(--danger); } svg.topo .rack.bad rect { stroke: var(--danger); }
|
||||
svg.topo .mach { cursor: pointer; } svg.topo .mach:hover rect { fill: var(--hover); }
|
||||
svg.topo .mach .stripe { stroke: none; fill: var(--line2); }
|
||||
svg.topo .mach.ok .stripe { fill: var(--ok); } svg.topo .mach.warn .stripe { fill: var(--warn); } svg.topo .mach.bad .stripe { fill: var(--danger); }
|
||||
svg.topo .mach.group rect { stroke-width: 1.2; } svg.topo .mach.group .stack { fill: none; stroke: var(--line2); }
|
||||
svg.topo .mach.group.ok rect:first-child { stroke: var(--ok); } svg.topo .mach.group.warn rect:first-child { stroke: var(--warn); } svg.topo .mach.group.bad rect:first-child { stroke: var(--danger); }
|
||||
.legend { display: flex; gap: 14px; flex-wrap: wrap; color: var(--muted); font-size: 11.5px; margin-top: 8px; align-items: center; }
|
||||
.legend i { display: inline-block; width: 22px; height: 0; border-top: 4px solid var(--accent); vertical-align: middle; margin-right: 5px; border-radius: 2px; }
|
||||
.legend i.drop { border-top: 2px solid var(--accent); opacity: .55; } .legend b.sq { display: inline-block; width: 8px; height: 8px; background: var(--accent); margin-right: 5px; } .legend b.ci { display: inline-block; width: 7px; height: 7px; border: 1.5px solid var(--fg); border-radius: 50%; margin-right: 5px; }
|
||||
.legend i.plain { border-top: 3px solid var(--line2); } .legend i.dash { border-top: 3px dashed var(--line2); } .legend i.ctl { border-top: 2px solid var(--accent); } .legend i.down { border-top: 2px dashed var(--danger); }
|
||||
|
||||
/* overview */
|
||||
.ov { display: grid; grid-template-columns: repeat(12, 1fr); gap: 12px; }
|
||||
.ov > .card { grid-column: span 4; } .ov > .card.w6 { grid-column: span 6; } .ov > .card.w8 { grid-column: span 8; } .ov > .card.wide { grid-column: 1 / -1; }
|
||||
.big { display: grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 10px; }
|
||||
.big div b { display: block; font-size: 20px; font-weight: 600; line-height: 1.15; font-variant-numeric: tabular-nums; } .big div span { color: var(--muted); font-size: 11.5px; }
|
||||
.hbar { display: grid; grid-template-columns: minmax(90px, max-content) 1fr 64px; gap: 4px 10px; align-items: center; font-size: 12.5px; }
|
||||
.hbar .bar { min-width: 0; } .hbar .n { text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.statebar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--pill); margin: 4px 0 10px; }
|
||||
.statebar span { height: 100%; } .statebar .ok { background: var(--ok); } .statebar .warn { background: var(--warn); } .statebar .bad { background: var(--danger); } .statebar .idle { background: var(--line2); }
|
||||
@media (max-width: 1100px) { .ov > .card, .ov > .card.w6, .ov > .card.w8 { grid-column: 1 / -1; } }
|
||||
@@ -0,0 +1,194 @@
|
||||
'use strict';
|
||||
// Network topology drawn from the model: region (and the external IPAM) on
|
||||
// top, rack controllers below, one bus per VLAN with its subnets, machines at
|
||||
// the bottom wired to every bus they have an address on. Plain SVG; colours
|
||||
// come from the page's CSS variables so light and dark both work.
|
||||
|
||||
// ---------------------------------------------------------------- addresses
|
||||
function parseIP(s) {
|
||||
s = String(s || '').split('/')[0].trim();
|
||||
if (!s) return null;
|
||||
if (!s.includes(':')) {
|
||||
const p = s.split('.').map(Number);
|
||||
if (p.length !== 4 || p.some(x => !(x >= 0 && x <= 255))) return null;
|
||||
return { v: 4, n: BigInt(((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]) };
|
||||
}
|
||||
const [head, tail] = s.split('::');
|
||||
const h = head ? head.split(':') : [], t = tail !== undefined ? (tail ? tail.split(':') : []) : null;
|
||||
const groups = t === null ? h : [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
|
||||
if (groups.length !== 8) return null;
|
||||
let n = 0n;
|
||||
for (const g of groups) n = (n << 16n) + BigInt(parseInt(g || '0', 16));
|
||||
return { v: 6, n };
|
||||
}
|
||||
function inCidr(ip, cidr) {
|
||||
const [a, b] = String(cidr).split('/');
|
||||
const x = parseIP(ip), y = parseIP(a);
|
||||
if (!x || !y || x.v !== y.v) return false;
|
||||
const shift = BigInt((x.v === 4 ? 32 : 128) - Number(b));
|
||||
return (x.n >> shift) === (y.n >> shift);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
// topoModel groups subnets into segments (one per VLAN), attaches racks and machines.
|
||||
function topoModel() {
|
||||
const segs = [], byVlan = {};
|
||||
for (const s of S.subnets) {
|
||||
const key = s.vlan_id ? 'v' + s.vlan_id : 'c' + s.cidr;
|
||||
if (!byVlan[key]) { byVlan[key] = { key, fabric: s.fabric, vid: s.vid, mtu: s.mtu, subnets: [], racks: new Set(), dhcp: false, purpose: s.purpose }; segs.push(byVlan[key]); }
|
||||
const g = byVlan[key];
|
||||
g.subnets.push(s);
|
||||
if (s.dhcp) { g.dhcp = true; if (s.primary_rack) g.racks.add(s.primary_rack); if (s.secondary_rack) g.racks.add(s.secondary_rack); }
|
||||
if (s.purpose !== 'provisioning' && g.purpose === 'provisioning' && !s.dhcp) { /* keep the first */ }
|
||||
}
|
||||
segs.sort((a, b) => (b.dhcp - a.dhcp) || String(a.fabric).localeCompare(String(b.fabric)) || a.vid - b.vid);
|
||||
const segOf = ip => segs.findIndex(g => g.subnets.some(s => inCidr(ip, s.cidr)));
|
||||
// rack controllers: the segments they serve DHCP on, plus those their interfaces sit on
|
||||
const racks = S.controllers.map(c => {
|
||||
const at = new Set();
|
||||
segs.forEach((g, i) => { if (g.racks.has(c.name)) at.add(i); });
|
||||
for (const itf of (c.interfaces || [])) for (const a of (itf.addrs || [])) { const i = segOf(a); if (i >= 0) at.add(i); }
|
||||
return { c, at: [...at].sort((a, b) => a - b) };
|
||||
});
|
||||
// machines: every address Rollcall knows for them
|
||||
const ipsBy = {};
|
||||
for (const x of S.ips) if (x.machine_id) (ipsBy[x.machine_id] = ipsBy[x.machine_id] || []).push(x.ip);
|
||||
const ms = S.machines.map(m => {
|
||||
const at = new Set();
|
||||
for (const ip of [...(ipsBy[m.id] || []), m.mgmt_ip, m.ib_ip]) { if (!ip) continue; const i = segOf(ip); if (i >= 0) at.add(i); }
|
||||
return { m, at: [...at].sort((a, b) => a - b) };
|
||||
});
|
||||
return { segs, racks, ms };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
// "10 failed · 10 deploying · 10 ok": worst first, short names, cut to fit
|
||||
function groupLine(by, max) {
|
||||
const rank = { bad: 0, warn: 1, ok: 2, idle: 3 };
|
||||
const short = s => s.replace('deploy-failed', 'failed').replace('release-failed', 'failed').replace('recommission', 'commiss.');
|
||||
const parts = Object.entries(by).sort((a, b) => rank[stateClass(a[0])] - rank[stateClass(b[0])] || b[1] - a[1]).map(([s, n]) => `${n} ${short(s)}`);
|
||||
let out = '';
|
||||
for (const p of parts) { const next = out ? out + ' · ' + p : p; if (next.length > max) return out ? out + ' …' : p.slice(0, max); out = next; }
|
||||
return out;
|
||||
}
|
||||
const stateClass = st => /failed/.test(st) ? 'bad' : /deploying|releasing|recommission|new|commission/.test(st) ? 'warn' : /deployed|booted|ready|stateless/.test(st) ? 'ok' : 'idle';
|
||||
|
||||
// topoSVG({compact}) -> svg markup. compact: overview card (fewer labels, collapsed groups).
|
||||
function topoSVG(opt = {}) {
|
||||
const { segs, racks, ms } = topoModel();
|
||||
if (!segs.length && !racks.length) return '<div class="empty">No subnets or rack controllers yet.</div>';
|
||||
const compact = !!opt.compact;
|
||||
const BW = compact ? 112 : 156, BH = compact ? 40 : 50, GAP = compact ? 12 : 16, M = 24;
|
||||
const collapseAt = compact ? 4 : 8;
|
||||
|
||||
// machine items: one box per machine, or one per group of machines with the same wiring
|
||||
const groups = {};
|
||||
for (const x of ms) { const k = (x.m.rack_controller || '') + '|' + x.at.join(','); (groups[k] = groups[k] || []).push(x); }
|
||||
const rackIdx = name => { const i = racks.findIndex(r => r.c.name === name); return i < 0 ? 999 : i; };
|
||||
let items = [];
|
||||
for (const [k, xs] of Object.entries(groups)) {
|
||||
const rack = k.split('|')[0];
|
||||
if (xs.length >= collapseAt) items.push({ group: xs, at: xs[0].at, rack });
|
||||
else for (const x of xs) items.push({ m: x.m, at: x.at, rack });
|
||||
}
|
||||
items.sort((a, b) => rackIdx(a.rack) - rackIdx(b.rack) || (a.at[0] ?? 99) - (b.at[0] ?? 99) || String(a.m ? nameOf(a.m) : '').localeCompare(String(b.m ? nameOf(b.m) : '')));
|
||||
|
||||
const minW = compact ? 640 : 980;
|
||||
let W = Math.max(minW, M * 2 + items.length * (BW + GAP) - GAP + 260);
|
||||
// x positions of machine items, then racks centred over their machines
|
||||
const itemX = items.map((_, i) => M + 260 + i * (BW + GAP));
|
||||
const RW = compact ? 120 : 150;
|
||||
const rackX = racks.map((r, ri) => {
|
||||
const mine = items.map((it, i) => it.rack === r.c.name ? itemX[i] + BW / 2 : null).filter(v => v !== null);
|
||||
return mine.length ? mine.reduce((a, b) => a + b, 0) / mine.length : null;
|
||||
});
|
||||
let free = M + 260;
|
||||
const order = racks.map((_, i) => i).sort((a, b) => (rackX[a] ?? 1e9) - (rackX[b] ?? 1e9));
|
||||
for (const i of order) { let x = rackX[i] ?? free + RW / 2; x = Math.max(x, free + RW / 2); rackX[i] = x; free = x + RW / 2 + 20; }
|
||||
W = Math.max(W, free + M);
|
||||
|
||||
const yRegion = 14, hTop = compact ? 40 : 48;
|
||||
const yRack = yRegion + hTop + (compact ? 34 : 44);
|
||||
const ySeg0 = yRack + hTop + (compact ? 40 : 52), segGap = compact ? 30 : 44;
|
||||
const segY = segs.map((_, i) => ySeg0 + i * segGap);
|
||||
const yM = (segs.length ? segY[segs.length - 1] : ySeg0) + (compact ? 36 : 48);
|
||||
const H = yM + BH + (items.length ? 16 : 0) + 10;
|
||||
const regionX = racks.length ? racks.map((_, i) => rackX[i]).reduce((a, b) => a + b, 0) / racks.length : W / 2;
|
||||
const regW = compact ? 150 : 190, iw = compact ? 150 : 180;
|
||||
const hasIPAM = !!(S.ipam && S.ipam.provider), ix = regionX + regW / 2 + 50 + iw / 2;
|
||||
if (hasIPAM) W = Math.max(W, ix + iw / 2 + M);
|
||||
|
||||
const o = [];
|
||||
o.push(`<svg class="topo${compact ? ' compact' : ''}" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" style="max-width:100%;height:auto" role="img" aria-label="network topology">`);
|
||||
// segments (buses) with their labels in the left gutter
|
||||
segs.forEach((g, i) => {
|
||||
const y = segY[i];
|
||||
const v4 = g.subnets.filter(s => s.family !== 6), v6 = g.subnets.filter(s => s.family === 6);
|
||||
const used = g.subnets.reduce((a, s) => a + (s.used || 0), 0);
|
||||
const title = g.subnets.map(s => `${s.cidr}${s.name ? ' ' + s.name : ''} · ${s.purpose} · ${s.used}/${s.size || '∞'} used${s.gateway ? ' · gw ' + s.gateway : ''}`).join('\n');
|
||||
o.push(`<g class="seg ${g.dhcp ? 'dhcp' : ''} p-${esc(g.purpose)}" data-ips="${esc(g.subnets[0].cidr)}"><title>${esc(title)}</title>`);
|
||||
o.push(`<line x1="${M + 250}" y1="${y}" x2="${W - M}" y2="${y}"/>`);
|
||||
o.push(`<text class="t1" x="${M}" y="${y - 4}">${esc((g.fabric || 'no fabric') + ' · ' + (g.vid ? 'vlan ' + g.vid : 'untagged'))}</text>`);
|
||||
if (!compact) o.push(`<text class="t2" x="${M}" y="${y + 11}">${esc([...v4, ...v6].map(s => s.cidr).join(' '))}</text>`);
|
||||
o.push(`<text class="t3" x="${W - M}" y="${y - 5}" text-anchor="end">${esc(g.purpose)}${g.dhcp ? ' · DHCP/PXE' : ''}${g.mtu && g.mtu !== 1500 ? ' · mtu ' + g.mtu : ''} · ${used} addr</text>`);
|
||||
o.push('</g>');
|
||||
});
|
||||
// region + external IPAM
|
||||
o.push(`<g class="node region"><title>region: API, database, CA</title><rect x="${regionX - regW / 2}" y="${yRegion}" width="${regW}" height="${hTop}" rx="7"/>
|
||||
<text class="n1" x="${regionX}" y="${yRegion + (compact ? 17 : 20)}" text-anchor="middle">region</text>
|
||||
<text class="n2" x="${regionX}" y="${yRegion + (compact ? 31 : 37)}" text-anchor="middle">${esc(location.host)}</text></g>`);
|
||||
if (hasIPAM) {
|
||||
const last = S.ipam.last_sync || {};
|
||||
const ok = !last.error;
|
||||
o.push(`<line class="wire ext" x1="${regionX + regW / 2}" y1="${yRegion + hTop / 2}" x2="${ix - iw / 2}" y2="${yRegion + hTop / 2}"/>`);
|
||||
o.push(`<g class="node ipam ${ok ? '' : 'bad'}"><title>${esc(`${S.ipam.provider} ${S.ipam.url}\nzone ${S.ipam.dns_zone || '-'}\nlast sync ${last.at ? new Date(last.at).toLocaleString() : 'never'}${last.error ? '\n' + last.error : ''}`)}</title>
|
||||
<rect x="${ix - iw / 2}" y="${yRegion}" width="${iw}" height="${hTop}" rx="7"/>
|
||||
<text class="n1" x="${ix}" y="${yRegion + (compact ? 17 : 20)}" text-anchor="middle">${esc(S.ipam.provider)} IPAM${ok ? '' : ' ⚠'}</text>
|
||||
<text class="n2" x="${ix}" y="${yRegion + (compact ? 31 : 37)}" text-anchor="middle">${esc(S.ipam.dns_zone || S.ipam.url)}</text></g>`);
|
||||
}
|
||||
// rack controllers, wired to the region (mTLS) and to their buses
|
||||
racks.forEach((r, i) => {
|
||||
const x = rackX[i], c = r.c, wx = x - RW / 2 + 22; // bus drop on the box's left, clear of machine drops
|
||||
o.push(`<path class="wire ctl ${c.online ? '' : 'down'}" d="M${regionX},${yRegion + hTop} V${(yRegion + hTop + yRack) / 2} H${x} V${yRack}"/>`);
|
||||
if (r.at.length) {
|
||||
o.push(`<line class="wire rackdrop" x1="${wx}" y1="${yRack + hTop}" x2="${wx}" y2="${segY[r.at[r.at.length - 1]]}"/>`);
|
||||
for (const si of r.at) o.push(`<rect class="tap rack" x="${wx - 4}" y="${segY[si] - 4}" width="8" height="8"/>`);
|
||||
}
|
||||
const addrs = (c.interfaces || []).flatMap(itf => itf.addrs || []).join(', ');
|
||||
o.push(`<g class="node rack ${c.online ? 'ok' : 'bad'}"><title>${esc(`${c.name} (${c.kind}) ${c.online ? 'online' : 'OFFLINE'}\n${c.url}\n${addrs}\n${c.machines} machines`)}</title>
|
||||
<rect x="${x - RW / 2}" y="${yRack}" width="${RW}" height="${hTop}" rx="7"/>
|
||||
<circle class="led" cx="${x - RW / 2 + 12}" cy="${yRack + 13}" r="4"/>
|
||||
<text class="n1" x="${x}" y="${yRack + (compact ? 17 : 20)}" text-anchor="middle">${esc(c.name)}</text>
|
||||
<text class="n2" x="${x}" y="${yRack + (compact ? 31 : 37)}" text-anchor="middle">${compact ? esc(c.kind) : `${esc(c.kind)} · ${c.machines} machines`}</text></g>`);
|
||||
});
|
||||
// machines
|
||||
items.forEach((it, i) => {
|
||||
const x = itemX[i], cx = x + BW / 2;
|
||||
if (it.at.length) {
|
||||
o.push(`<line class="wire" x1="${cx}" y1="${segY[it.at[0]]}" x2="${cx}" y2="${yM}"/>`);
|
||||
for (const si of it.at) o.push(`<circle class="tap" cx="${cx}" cy="${segY[si]}" r="3"/>`);
|
||||
}
|
||||
if (it.group) {
|
||||
const by = {}; for (const x of it.group) by[x.m.state] = (by[x.m.state] || 0) + 1;
|
||||
const worst = Object.keys(by).map(stateClass).sort((a, b) => ['bad', 'warn', 'ok', 'idle'].indexOf(a) - ['bad', 'warn', 'ok', 'idle'].indexOf(b))[0];
|
||||
o.push(`<g class="node mach group ${worst}" data-go="machines"><title>${esc(Object.entries(by).map(([s, n]) => `${n} ${s}`).join('\n'))}</title>
|
||||
<rect x="${x}" y="${yM}" width="${BW}" height="${BH}" rx="6"/><rect class="stack" x="${x + 4}" y="${yM + 4}" width="${BW}" height="${BH}" rx="6"/>
|
||||
<text class="n1" x="${cx}" y="${yM + (compact ? 17 : 21)}" text-anchor="middle">${it.group.length} machines</text>
|
||||
<text class="n2" x="${cx}" y="${yM + (compact ? 31 : 38)}" text-anchor="middle">${esc(groupLine(by, compact ? 18 : 26))}</text></g>`);
|
||||
} else {
|
||||
const m = it.m;
|
||||
o.push(`<g class="node mach ${stateClass(m.state)}" data-mid="${esc(m.id)}"><title>${esc(`${nameOf(m)} · ${m.state}\n${m.mgmt_ip || ''}\n${m.os || m.profile || ''}\n${locStr(m.location)}`)}</title>
|
||||
<rect x="${x}" y="${yM}" width="${BW}" height="${BH}" rx="6"/><rect class="stripe" x="${x}" y="${yM}" width="4" height="${BH}" rx="2"/>
|
||||
<text class="n1" x="${cx + 2}" y="${yM + (compact ? 17 : 21)}" text-anchor="middle">${esc(nameOf(m).slice(0, 18))}</text>
|
||||
<text class="n2" x="${cx + 2}" y="${yM + (compact ? 31 : 38)}" text-anchor="middle">${esc(compact ? m.state : (m.mgmt_ip || m.state))}</text></g>`);
|
||||
}
|
||||
});
|
||||
const loose = ms.filter(x => !x.at.length).length;
|
||||
o.push('</svg>');
|
||||
return o.join('') + (loose && !compact ? `<p class="muted" style="margin:6px 0 0">${loose} machine(s) have no address on a modelled subnet yet (new or commissioning); they are drawn without a link.</p>` : '');
|
||||
}
|
||||
|
||||
function wireTopo(root = document) {
|
||||
$$('.topo [data-mid]', root).forEach(g => g.onclick = () => { location.hash = '#/machine/' + g.dataset.mid; });
|
||||
$$('.topo [data-go]', root).forEach(g => g.onclick = () => nav(g.dataset.go));
|
||||
}
|
||||
Reference in new issue
Block a user