diff --git a/CHANGELOG.md b/CHANGELOG.md index c70cdf8..f375852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/cmd/rollcall/web/app.js b/cmd/rollcall/web/app.js index c6cb483..87aeefc 100644 --- a/cmd/rollcall/web/app.js +++ b/cmd/rollcall/web/app.js @@ -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 'none'; + max = max || Math.max(...rows.map(r => r[1]), 1); + return `
${rows.map(([l, n, t, g, rmax, shown]) => `${l}
${esc(shown ?? String(n))}`).join('')}
`; +} +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 = `
${states.map(s => ``).join('')}
`; + const week = Date.now() - 7 * 864e5; + const fleet = `${bar}
${states.map(s => `
${by[s]}${pill(s)}
`).join('')}
${active.length}active jobs
+
+
BY RACK CONTROLLER
${hbars(countBy(M, m => m.rack_controller || 'unassigned').map(([k, n]) => [esc(k), n]))}
+
${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 ? `${failed.length}` : '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)}` : '—'; })()]])}
`; + + // attention + const attention = kv([ + ['Rack controllers', offline.length ? `${offline.map(c => esc(c.name)).join(', ')} offline show` : `${S.controllers.length} online`], + ['External IPAM', !S.ipam || !S.ipam.provider ? 'built-in only' : ipamLast && ipamLast.error ? `${esc(ipamLast.error.slice(0, 80))}` : `${esc(S.ipam.provider)} in sync${ipamLast && ipamLast.conflicts && ipamLast.conflicts.length ? ` ${ipamLast.conflicts.length} conflicts` : ''} ${ipamLast && ipamLast.at ? ago(ipamLast.at) + ' ago' : ''}`], + ['BMC not configured', bmcBad ? `${bmcBad} show` : 'none'], + ['Stateless nodes silent > 15 min', stale ? `${stale}` : 'none'], + ['Failed jobs (last 100)', failed.length ? `${failed.length} show` : 'none'], + ['OS images', warnImgs.length ? warnImgs.map(i => `${esc(i.name)}: ${esc(i.warning)}`).join('
') : `${S.os.length} current`], + ]); + + // 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 => [`${esc(s.cidr)}`, 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 = `
${topoSVG({ compact: true })}
+
+
ADDRESSES IN USE
${hbars(subRows)}
+
${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 ? `${esc(maxPct[0].cidr)} ${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']])}
`; + + // capacity + const capacity = `
+
${sum(M, m => m.cpus)}cores
${human(sum(M, m => m.memory_bytes))}memory
+
${human(sum(M, m => m.storage_bytes))}storage
${sum(M, m => m.gpus)}GPUs
${sum(M, m => m.ib_hcas)}IB HCAs
+
${kv([['Ready to deploy', ready.length ? `${ready.length} machines · ${sum(ready, m => m.cpus)} cores · ${human(sum(ready, m => m.memory_bytes))}` : 'none free'], + ['In use', `${M.filter(m => /deployed|booted|stateless/.test(m.state)).length} machines`]])}
`; + + // 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) + `
${S.os.length} images in the catalog${warnImgs.length ? `, ${warnImgs.length} need attention` : ''} · images
`; + + // 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 ? `
${tags.map(([t, n]) => `${esc(t)}${n}`).join('')}
` : 'no tags'; + + // 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 => [`${ago(m.deployed_at)} ago`, `${esc(nameOf(m))}`, esc(`${m.os || ''} ${m.os_version || ''}`), `${esc(m.mgmt_ip || '')}`])); + 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 [`${ago(e.ts)}`, `${esc(m ? nameOf(m) : e.machine_id)}`, esc(e.event), `${esc(e.detail.slice(0, 60))}`]; })); + return ` -

Overview

${S.machines.length} machines
-
-
${S.machines.length}
machines
- ${states.map(s => `
${by[s]}
${pill(s)}
`).join('')} -
${active.length}
active jobs
-
-
-

Active jobs

${jobsTable(active, true)}
-

Attention

${kv([ - ['Rack controllers offline', (() => { const off = S.controllers.filter(c => !c.online); return off.length ? `${off.map(c => esc(c.name)).join(', ')} show` : `none (${S.controllers.length})`; })()], - ['BMC not configured', bmcBad ? `${bmcBad} show` : 'none'], - ['Stateless nodes silent > 15 min', stale ? `${stale}` : 'none'], - ['Failed jobs (last 100)', (() => { const n = S.jobs.filter(j => j.state === 'failed').length; return n ? `${n} show` : 'none'; })()], - ['OS images', warnImgs.length ? warnImgs.map(i => `${esc(i.name)}: ${esc(i.warning)}`).join('
') : `${S.os.length} ok`], - ['Profiles', S.profiles.length ? S.profiles.map(p => `${esc(p.name)} (${S.machines.filter(m => m.profile === p.name).length})`).join(', ') : 'none'], - ])}
-

Recent events

${table(['When', 'Machine', 'Event', 'Detail'], S.events.slice(0, 25).map(e => { const m = S.machines.find(x => x.id === e.machine_id); return [`${ago(e.ts)}`, `${esc(m ? nameOf(m) : e.machine_id)}`, esc(e.event), `${esc(e.detail.slice(0, 80))}`]; }))}
+

Overview

${M.length} machines · ${S.controllers.length} rack controllers · ${S.subnets.length} subnets${S.ipam && S.ipam.provider ? ' · ' + esc(S.ipam.provider) : ''}
+
+

Fleet

${fleet}
+

Attention

${attention}
+

Network details

${network}
+

Capacity

${capacity}
+

Operating systems

${osCard}
+

Hardware

${hw}

BMC drivers

${bmc}
+

Locations racks

${hbars(locRows)}
+

Tags

${tagCard}
+

${active.length ? 'Active jobs' : 'Recent deployments'}

${activity}
+

Recent events

${events}
`; } -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 => [`${esc(l.name)}`, l.machines || 0, `${esc(layoutSummary(l))}`, esc(l.comment || ''), ` `])); return `

Network

fabrics, VLANs and subnets (MAAS-style); rack controllers serve DHCP/PXE on their VLANs
+

Topology

${topoSVG()}
+
VLAN with DHCP/PXEVLANstorage / IBrack ↔ region (mTLS)rack offlinerack serves/sits on the VLANmachine addressclick a bus for its addresses, a machine for its page

Rack controllers

${ctl}

Add one next to a segment: rollcall controller join-code -name NAME -addr REGION:443 here, then on that host ./rollcall rack join CODE -url http://ITS-ADDRESS and sh install-rack.sh. It talks to the region over mutual TLS.

Subnets

${sub}

Interface layouts

${lay}

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.

`; @@ -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 => `