Files
rollcall/cmd/rollcall/web/app.js
T
Ilker ManapandClaude Fable 5.1 cb7b6fd19e
CI / go (push) Successful in 35s
CI / agent (push) Successful in 4s
CI / site (push) Successful in 3s
Physical location: building / room / rack / unit / asset tag
- machines.building/room/rack/rack_unit/asset_tag, racks table (height, comment)
- API: POST /machines/{id}/location, POST /locations/import (CSV or JSON, matched by
  id/serial/mac/node_name, ?dry=1), GET/POST/DELETE /racks, ?building/?room/?rack filters
- CLI: rollcall location ..., location import, location racks/rack, list -rack/-room/-building
- UI: location column and building > room > rack chips, bulk Set location…, Racks page with
  elevation views and CSV import dialog, location in machine detail
- .Node.Building/Room/Rack/Unit/AssetTag in overlay templates and tag match rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 15:32:26 +01:00

547 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// Rollcall web UI: a fleet view first (filters, selection, bulk actions), machine detail second.
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const human = n => { n = Number(n || 0); const u = ['B', 'K', 'M', 'G', 'T', 'P']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i <= 2 ? n.toFixed(0) : n.toFixed(1)) + u[i]; };
const ago = ts => { if (!ts) return '-'; const s = (Date.now() - new Date(ts)) / 1000; if (s < 90) return `${s | 0}s`; if (s < 5400) return `${(s / 60) | 0}m`; if (s < 172800) return `${(s / 3600) | 0}h`; return `${(s / 86400) | 0}d`; };
const when = ts => ts ? new Date(ts).toLocaleString() : '';
const pill = (s, extra = '') => `<span class="pill ${esc(s)}">${esc(s)}${extra}</span>`;
const nameOf = m => m.node_name || m.hostname || m.id;
// ---------------------------------------------------------------- state
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: [], 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;
async function api(method, path, body) {
const r = await fetch(path, { method, headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
if (r.status === 401) { askToken(); throw new Error('unauthorized'); }
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || r.statusText);
return data;
}
function askToken() { const d = $('#login'); if (!d.open) d.showModal(); }
$('#login form').addEventListener('submit', () => { token = $('#token').value.trim(); try { localStorage.setItem('rollcall.token', token); } catch (e) { /* ignore */ } $('#token').value = ''; refresh(true); });
$('#logout').onclick = () => { token = ''; try { localStorage.removeItem('rollcall.token'); } catch (e) { /* ignore */ } askToken(); };
let toastTimer = null;
function toast(msg, bad) { const t = $('#toast'); t.textContent = msg; t.className = bad ? 'bad' : ''; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, bad ? 9000 : 5000); }
// dialog(title, [{name,label,type:'select'|'text'|'checkbox'|'textarea', options:[[v,label]], value, placeholder}], note) -> values | null
function dialog(title, fields, note, okLabel = 'OK') {
return new Promise(resolve => {
const d = $('#dlg');
$('#dlg-title').textContent = title; $('#dlg-note').textContent = note || ''; $('#dlg-ok').textContent = okLabel;
$('#dlg-body').innerHTML = fields.map(f => {
let input;
if (f.type === 'select') input = `<select name="${esc(f.name)}">${(f.options || []).map(([v, l]) => `<option value="${esc(v)}"${v === f.value ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select>`;
else if (f.type === 'checkbox') input = `<input type="checkbox" name="${esc(f.name)}"${f.value ? ' checked' : ''} style="width:auto">`;
else if (f.type === 'textarea') input = `<textarea name="${esc(f.name)}" rows="${f.rows || 8}" placeholder="${esc(f.placeholder || '')}">${esc(f.value || '')}</textarea>`;
else input = `<input type="text" name="${esc(f.name)}" value="${esc(f.value || '')}" placeholder="${esc(f.placeholder || '')}"${f.list ? ` list="${esc(f.list)}"` : ''}>`;
return `<label>${esc(f.label)}</label><div>${input}${f.help ? `<div class="muted" style="font-size:11.5px">${esc(f.help)}</div>` : ''}</div>`;
}).join('');
const form = $('#dlg-form');
const done = ok => { d.close(); form.onsubmit = null; $('#dlg-cancel').onclick = null; if (!ok) return resolve(null); const fd = new FormData(form); const out = {}; for (const f of fields) out[f.name] = f.type === 'checkbox' ? form.elements[f.name].checked : (fd.get(f.name) ?? ''); resolve(out); };
form.onsubmit = ev => { ev.preventDefault(); done(true); };
$('#dlg-cancel').onclick = () => done(false);
d.oncancel = () => done(false);
d.showModal();
const first = $('#dlg-body input, #dlg-body select, #dlg-body textarea'); if (first) first.focus();
});
}
// ---------------------------------------------------------------- data
async function refresh(full) {
if (!token) return askToken();
try {
const [machines, tags, profiles, os, racks] = await Promise.all([api('GET', '/api/v1/machines'), api('GET', '/api/v1/tags'), api('GET', '/api/v1/profiles'), api('GET', '/api/v1/os'), api('GET', '/api/v1/racks')]);
Object.assign(S, { machines, tags, profiles, os, racks, loaded: true });
$('#n-racks').textContent = racks.length || '';
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');
$('#n-machines').textContent = machines.length;
const active = S.jobs.filter(j => j.state === 'pending' || j.state === 'running').length;
$('#n-jobs').textContent = active ? `${active} active` : '';
$('#status').textContent = `updated ${new Date().toLocaleTimeString()}`;
render();
} catch (e) { if (e.message !== 'unauthorized') { $('#status').textContent = e.message; $('#status').className = 'bad'; } }
}
function schedule() {
clearInterval(timer);
timer = setInterval(() => {
if (document.visibilityState !== 'visible' || $('#dlg').open) return;
const r = route();
const busy = S.machines.some(m => m.job);
if (r.page === 'machine') { const m = S.machines.find(x => x.id === r.id); if (m && m.job) loadDetail(r.id); else if (Date.now() % 20000 < 5000) loadDetail(r.id); return; }
if (r.page === 'overview' || r.page === 'jobs' || busy) refresh(); else if (Date.now() % 20000 < 5000) refresh();
}, 5000);
}
// ---------------------------------------------------------------- routing
function route() {
const h = location.hash.replace(/^#\/?/, '').split('/');
if (h[0] === 'machine' && h[1]) return { page: 'machine', id: h[1], tab: h[2] || 'overview' };
return { page: h[0] || 'overview', arg: h[1] };
}
window.addEventListener('hashchange', () => { S.sel.clear(); render(); });
function nav(page) { location.hash = '#/' + page; }
const detailCache = {};
async function loadDetail(id) {
try { detailCache[id] = await api('GET', '/api/v1/machines/' + encodeURIComponent(id)); if (route().page === 'machine' && route().id === id) render(); } catch (e) { if (e.message !== 'unauthorized') toast(e.message, true); }
}
function render() {
if (!S.loaded) return;
const r = route();
$$('.side a[data-nav]').forEach(a => a.classList.toggle('active', a.dataset.nav === r.page || (r.page === 'machine' && a.dataset.nav === 'machines')));
const main = $('#main');
switch (r.page) {
case 'machines': main.innerHTML = pageMachines(); wireMachines(); break;
case 'machine': if (!detailCache[r.id]) { main.innerHTML = '<p class="muted">loading…</p>'; loadDetail(r.id); } else { main.innerHTML = pageMachine(detailCache[r.id], r.tab); wireMachine(detailCache[r.id]); } break;
case 'jobs': main.innerHTML = pageJobs(); wireJobs(); break;
case 'racks': main.innerHTML = pageRacks(); wireRacks(); break;
case 'tags': main.innerHTML = pageTags(); wireTags(); break;
case 'profiles': main.innerHTML = pageProfiles(); wireProfiles(); break;
case 'images': main.innerHTML = pageImages(); break;
default: main.innerHTML = pageOverview(); wireOverview();
}
}
// ---------------------------------------------------------------- overview
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 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 warnImgs = S.os.filter(i => i.warning);
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([
['BMC not configured', bmcBad ? `<span class="bad">${bmcBad}</span> <a href="#/machines">show</a>` : '<span class="ok">none</span>'],
['Stateless nodes silent &gt; 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>`;
}
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); }); }
// ---------------------------------------------------------------- machines list
function filtered() {
const q = S.q.toLowerCase();
const L = S.loc;
let ms = S.machines.filter(m => (!S.states.size || S.states.has(m.state)) && (!S.tagf.size || [...S.tagf].every(t => (m.tags || []).includes(t)))
&& (!L.building || (m.location || {}).building === L.building) && (!L.room || (m.location || {}).room === L.room) && (!L.rack || (m.location || {}).rack === L.rack)
&& (!q || JSON.stringify([m.id, m.state, m.manufacturer, m.product, m.serial, m.macs, m.hostname, m.node_name, m.os, m.profile, m.tags, m.mgmt_ip, m.bmc && m.bmc.address, locStr(m.location), (m.location || {}).asset_tag]).toLowerCase().includes(q)));
const locKey = m => { const l = m.location || {}; return `${l.building}|${l.room}|${l.rack}|${String(l.unit || 0).padStart(3, '0')}`; };
const key = { name: m => nameOf(m), state: m => m.state, role: m => m.profile || m.os || '', model: m => (m.manufacturer || '') + (m.product || ''), cpu: m => Number(m.cpus) || 0, ram: m => m.memory_bytes, seen: m => m.last_seen || '', loc: locKey }[S.sort] || (m => nameOf(m));
ms.sort((a, b) => { const x = key(a), y = key(b); return (x > y ? 1 : x < y ? -1 : 0) * S.dir; });
return ms;
}
function hwSummary(m) {
const parts = [`<b>${esc(m.cpus || '?')}</b>c`, `<b>${human(m.memory_bytes)}</b>`];
if (m.disks) parts.push(`<b>${m.disks | 0}</b>×disk ${human(m.storage_bytes)}`);
if (m.gpus) parts.push(`<b>${m.gpus}</b> GPU`);
if (m.ib_hcas) parts.push(`<b>${m.ib_hcas}</b> IB`);
return `<span class="hw">${parts.join(' · ')}</span>`;
}
function tagPills(m, editable) {
return (m.tags || []).map(t => { const auto = (m.auto_tags || []).includes(t) && !(m.manual_tags || []).includes(t); return `<span class="pill tag${auto ? ' auto' : ''}" title="${auto ? 'auto (match rule)' : 'manual'}">${esc(t)}${editable && !auto ? `<a href="#" data-untag="${esc(t)}" title="remove">×</a>` : ''}</span>`; }).join(' ');
}
function stateCell(m) {
if (m.job) { const p = m.job.progress || {}; return `${pill(m.state)}<div class="bar" title="${esc(p.msg || '')}"><span style="width:${Math.round(p.pct || 0)}%"></span></div>`; }
return pill(m.state);
}
function pageMachines() {
const r = route(); if (r.arg && !S.states.size) S.states.add(r.arg);
const ms = filtered();
const by = {}; for (const m of S.machines) by[m.state] = (by[m.state] || 0) + 1;
const tagCount = {}; for (const m of S.machines) for (const t of m.tags || []) tagCount[t] = (tagCount[t] || 0) + 1;
const locChips = locationChips();
const pages = Math.max(1, Math.ceil(ms.length / S.per)); if (S.page >= pages) S.page = pages - 1;
const slice = ms.slice(S.page * S.per, (S.page + 1) * S.per);
const allSel = slice.length && slice.every(m => S.sel.has(m.id));
const th = (k, l, cls = '') => `<th class="sort ${cls}" data-sort="${k}">${l}${S.sort === k ? (S.dir > 0 ? ' ▲' : ' ▼') : ''}</th>`;
return `
<div class="page-head"><h1>Machines</h1><span class="sub">${ms.length} of ${S.machines.length}</span>
<div class="right"><input id="q" type="search" placeholder="search name, serial, MAC, model, IP, tag…" value="${esc(S.q)}"><button class="btn" id="refresh">Refresh</button></div></div>
<div class="chips" style="margin-bottom:8px"><span class="muted">State:</span>${Object.keys(by).sort().map(s => `<span class="chip${S.states.has(s) ? ' on' : ''}" data-state="${esc(s)}">${esc(s)}<span class="n">${by[s]}</span></span>`).join('')}
${Object.keys(tagCount).length ? `<span class="muted" style="margin-left:10px">Tag:</span>${Object.keys(tagCount).sort().map(t => `<span class="chip${S.tagf.has(t) ? ' on' : ''}" data-tag="${esc(t)}">${esc(t)}<span class="n">${tagCount[t]}</span></span>`).join('')}` : ''}
${(S.states.size || S.tagf.size || S.q || L.building || L.room || L.rack) ? '<button class="btn ghost sm" id="clear">clear</button>' : ''}</div>
${locChips}
${S.sel.size ? bulkBar() : ''}
${S.machines.length ? '' : '<div class="empty">No machines yet. PXE-boot an unknown server: it commissions itself and appears here.</div>'}
<table id="mt"><thead><tr><th class="check"><input type="checkbox" id="selall"${allSel ? ' checked' : ''}></th>${th('name', 'Machine')}${th('state', 'State')}${th('loc', 'Location')}${th('role', 'Profile / OS')}<th>Tags</th>${th('model', 'Model')}${th('cpu', 'Hardware')}<th>BMC</th>${th('seen', 'Seen')}</tr></thead>
<tbody>${slice.map(m => `<tr class="row${S.sel.has(m.id) ? ' selected' : ''}" data-id="${esc(m.id)}">
<td class="check"><input type="checkbox" data-sel="${esc(m.id)}"${S.sel.has(m.id) ? ' checked' : ''}></td>
<td><b>${esc(nameOf(m))}</b><br><span class="muted mono">${esc(m.mgmt_ip || m.id)}</span></td>
<td>${stateCell(m)}</td>
<td>${m.location && (m.location.rack || m.location.room || m.location.building) ? `${esc([m.location.building, m.location.room].filter(Boolean).join(' / '))}<br><b>${esc(m.location.rack || '')}</b>${m.location.unit ? ` <span class="muted">U${m.location.unit}</span>` : ''}` : '<span class="muted">—</span>'}</td>
<td>${m.profile ? esc(m.profile) : m.os ? `${esc(m.os)}<br><span class="muted mono">${esc(m.os_version || '')}</span>` : '<span class="muted">—</span>'}</td>
<td>${tagPills(m)}</td>
<td>${esc(((m.manufacturer || '') + ' ' + (m.product || '')).trim().slice(0, 34))}<br><span class="muted mono">${esc(m.serial || '')}</span></td>
<td>${hwSummary(m)}</td>
<td><span class="dot ${m.bmc ? (m.bmc.configured ? 'ok' : 'bad') : ''}"></span><span class="mono">${esc(m.bmc && m.bmc.address || '—')}</span></td>
<td class="muted" title="${esc(when(m.last_seen))}">${ago(m.last_seen)}</td></tr>`).join('')}</tbody></table>
<div class="pager">${pages > 1 ? `<button class="btn sm" id="prev"${S.page === 0 ? ' disabled' : ''}>‹</button> page ${S.page + 1} / ${pages} <button class="btn sm" id="next"${S.page >= pages - 1 ? ' disabled' : ''}>›</button>` : ''}
<select id="per">${[25, 50, 100, 250].map(n => `<option${n === S.per ? ' selected' : ''}>${n}</option>`).join('')}</select> per page</div>`;
}
// chips for building > room > rack, narrowing as you pick
function locationChips() {
const L = S.loc;
const has = S.machines.some(m => m.location && (m.location.building || m.location.room || m.location.rack));
if (!has) return '';
const cnt = (f, pred) => { const c = {}; for (const m of S.machines) { const l = m.location || {}; if (pred(l) && l[f]) c[l[f]] = (c[l[f]] || 0) + 1; } return c; };
const b = cnt('building', () => true), r = cnt('room', l => !L.building || l.building === L.building), k = cnt('rack', l => (!L.building || l.building === L.building) && (!L.room || l.room === L.room));
const grp = (label, f, c) => Object.keys(c).length ? `<span class="muted">${label}:</span>${Object.keys(c).sort().map(v => `<span class="chip${L[f] === v ? ' on' : ''}" data-loc="${f}" data-v="${esc(v)}">${esc(v)}<span class="n">${c[v]}</span></span>`).join('')}` : '';
return `<div class="chips" style="margin-bottom:8px">${grp('Building', 'building', b)} ${grp('Room', 'room', r)} ${grp('Rack', 'rack', k)}</div>`;
}
function bulkBar() {
const n = S.sel.size;
return `<div class="bulk"><b>${n} selected</b>
${menuBtn('Power', [['status', 'Status'], ['on', 'Power on'], ['off', 'Power off'], ['soft', 'Soft off'], ['cycle', 'Power cycle'], ['reset', 'Reset']], 'bpower')}
<button class="btn primary" id="bdeploy">Deploy OS…</button>
<button class="btn" id="bassign">Assign profile…</button>
<button class="btn" id="btag">Tag…</button>
<button class="btn" id="bloc">Set location…</button>
${menuBtn('More', [['recommission', 'Re-commission (next boot)'], ['recommission-now', 'Re-commission now (BMC)'], ['release', 'Release (wipe disk)…'], ['unassign', 'Unassign profile'], ['hr'], ['bootdev-pxe', 'Boot device: PXE'], ['bootdev-disk', 'Boot device: disk']], 'bmore')}
<button class="btn ghost" id="bclear">Clear selection</button></div>`;
}
function menuBtn(label, items, id) { return `<span class="menu" id="${id}"><button class="btn" type="button" data-menu>${esc(label)} ▾</button><div class="list">${items.map(([v, l]) => v === 'hr' ? '<hr>' : `<button data-item="${esc(v)}"${/release|delete/.test(v) ? ' class="danger"' : ''}>${esc(l)}</button>`).join('')}</div></span>`; }
function wireMenus(root = document) {
$$('[data-menu]', root).forEach(b => b.onclick = ev => { ev.stopPropagation(); const m = b.parentElement; const was = m.classList.contains('open'); $$('.menu.open').forEach(x => x.classList.remove('open')); if (!was) m.classList.add('open'); });
document.onclick = () => $$('.menu.open').forEach(x => x.classList.remove('open'));
}
function wireMachines() {
$('#q').oninput = ev => { S.q = ev.target.value; S.page = 0; render(); $('#q').focus(); const v = $('#q').value; $('#q').setSelectionRange(v.length, v.length); };
$('#refresh').onclick = () => refresh(true);
$$('[data-state]').forEach(c => c.onclick = () => { const s = c.dataset.state; S.states.has(s) ? S.states.delete(s) : S.states.add(s); S.page = 0; location.hash = '#/machines'; render(); });
$$('[data-tag]').forEach(c => c.onclick = () => { const t = c.dataset.tag; S.tagf.has(t) ? S.tagf.delete(t) : S.tagf.add(t); S.page = 0; render(); });
const clr = $('#clear'); if (clr) clr.onclick = () => { S.states.clear(); S.tagf.clear(); S.q = ''; S.loc = { building: '', room: '', rack: '' }; location.hash = '#/machines'; render(); };
$$('[data-loc]').forEach(c => c.onclick = () => { const f = c.dataset.loc, v = c.dataset.v; if (S.loc[f] === v) { S.loc[f] = ''; if (f === 'building') { S.loc.room = ''; S.loc.rack = ''; } if (f === 'room') S.loc.rack = ''; } else { S.loc[f] = v; if (f === 'building') { S.loc.room = ''; S.loc.rack = ''; } if (f === 'room') S.loc.rack = ''; } S.page = 0; render(); });
$$('th.sort').forEach(h => h.onclick = () => { if (S.sort === h.dataset.sort) S.dir = -S.dir; else { S.sort = h.dataset.sort; S.dir = 1; } render(); });
$$('tr.row').forEach(tr => tr.onclick = ev => { if (ev.target.closest('input,a')) return; location.hash = '#/machine/' + tr.dataset.id; });
$$('[data-sel]').forEach(cb => cb.onchange = () => { cb.checked ? S.sel.add(cb.dataset.sel) : S.sel.delete(cb.dataset.sel); render(); });
$('#selall').onchange = ev => { const ids = filtered().slice(S.page * S.per, (S.page + 1) * S.per).map(m => m.id); ids.forEach(id => ev.target.checked ? S.sel.add(id) : S.sel.delete(id)); render(); };
const prev = $('#prev'), next = $('#next'); if (prev) prev.onclick = () => { S.page--; render(); }; if (next) next.onclick = () => { S.page++; render(); };
$('#per').onchange = ev => { S.per = Number(ev.target.value); S.page = 0; render(); };
if (S.sel.size) {
wireMenus();
const ids = () => [...S.sel];
$$('#bpower [data-item]').forEach(b => b.onclick = () => act.power(ids(), b.dataset.item));
$('#bdeploy').onclick = () => act.deploy(ids());
$('#bassign').onclick = () => act.assign(ids());
$('#btag').onclick = () => act.tag(ids());
$('#bloc').onclick = () => act.location(ids());
$$('#bmore [data-item]').forEach(b => b.onclick = () => act.more(ids(), b.dataset.item));
$('#bclear').onclick = () => { S.sel.clear(); render(); };
}
}
// ---------------------------------------------------------------- actions (single or many machines)
async function each(ids, label, fn) {
const out = [];
for (const id of ids) {
const m = S.machines.find(x => x.id === id);
try { const r = await fn(id, m); out.push(`${m ? nameOf(m) : id}: ${r}`); } catch (e) { out.push(`${m ? nameOf(m) : id}: ERROR ${e.message}`); }
}
toast(`${label}\n${out.join('\n')}`, out.some(l => l.includes('ERROR')));
const r = route(); if (r.page === 'machine') await loadDetail(r.id);
await refresh(true);
}
const act = {
power: (ids, action) => each(ids, `power ${action}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/power`, { action }); return r.output || (r.ok ? 'ok' : 'failed'); }),
bootdev: (ids, device) => each(ids, `bootdev ${device}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/bootdev`, { device }); return r.output || (r.ok ? 'ok' : 'failed'); }),
async deploy(ids) {
const one = ids.length === 1 ? S.machines.find(m => m.id === ids[0]) : null;
const detail = one ? (detailCache[one.id] || await api('GET', '/api/v1/machines/' + one.id)) : null;
const disks = detail ? (((detail.inventory || {}).storage || {}).disks || []) : [];
const imgs = S.os.filter(i => i.current);
if (!imgs.length) return toast('No OS image has a current version. Import or sync one first (OS images).', true);
const fields = [{ name: 'os', label: 'OS image', type: 'select', options: imgs.map(i => [i.name, `${i.name} (${i.current.version}, needs ${human(i.current.raw_size)})`]) }];
if (one) fields.push({ name: 'disk', label: 'Target disk', type: 'select', options: [['', 'auto: smallest disk that fits'], ...disks.map(d => [d.path || '/dev/' + d.name, `${d.name} ${human(d.size)} ${d.model || ''}`])] },
{ name: 'hostname', label: 'Hostname', value: one.node_name || '', placeholder: 'auto (next free name)' });
fields.push({ name: 'upgrade', label: 'Upgrade packages on first boot', type: 'checkbox' }, { name: 'boot', label: 'Power-cycle via BMC now', type: 'checkbox', value: true });
const v = await dialog(`Deploy OS to ${one ? nameOf(one) : ids.length + ' machines'}`, fields, 'The target disk is wiped. Tags with cloud-config fragments are applied automatically.', 'Deploy');
if (!v) return;
await each(ids, `deploy ${v.os}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/deploy`, { os: v.os, disk: v.disk || '', hostname: v.hostname || '', package_upgrade: v.upgrade, boot: v.boot }); return `job ${r.job} → ${r.disk} as ${r.hostname}${r.ipmi ? ' · ' + r.ipmi.split('\n').pop() : ''}`; });
},
async assign(ids) {
if (!S.profiles.length) return toast('No profiles defined yet (Profiles page).', true);
const one = ids.length === 1 ? S.machines.find(m => m.id === ids[0]) : null;
const fields = [{ name: 'profile', label: 'Profile', type: 'select', options: S.profiles.map(p => [p.name, `${p.name} (${p.image})`]), value: one && one.profile }];
if (one) fields.push({ name: 'name', label: 'Node name', value: one.node_name || '', placeholder: 'auto' });
fields.push({ name: 'boot', label: 'PXE boot via BMC now', type: 'checkbox', value: true });
const v = await dialog(`Assign stateless profile to ${one ? nameOf(one) : ids.length + ' machines'}`, fields, 'The node boots the profile image from RAM; the disk is not touched.', 'Assign');
if (!v) return;
await each(ids, `assign ${v.profile}`, async id => { const r = await api('POST', `/api/v1/machines/${id}/assign`, { profile: v.profile, name: v.name || '', boot: v.boot }); return `${r.name} (${r.mgmt_ip || 'no pool'})`; });
},
async tag(ids) {
const v = await dialog(`Tags for ${ids.length} machine${ids.length > 1 ? 's' : ''}`, [{ name: 'add', label: 'Add', placeholder: 'gpu rack-3', help: 'space separated; new names create a manual tag' }, { name: 'remove', label: 'Remove', placeholder: 'staging' }], 'Auto tags (from match rules) cannot be removed here.', 'Apply');
if (!v) return;
const add = v.add.trim().split(/\s+/).filter(Boolean), remove = v.remove.trim().split(/\s+/).filter(Boolean);
await each(ids, 'tags', async id => { const r = await api('POST', `/api/v1/machines/${id}/tags`, { add, remove }); return r.effective.join(' ') || 'none'; });
},
async location(ids) {
const one = ids.length === 1 ? S.machines.find(m => m.id === ids[0]) : null;
const cur = (one && one.location) || {};
const vals = f => [...new Set(S.machines.map(m => (m.location || {})[f]).filter(Boolean))].sort();
const dl = f => { let d = $('#dl-' + f); if (!d) { d = document.createElement('datalist'); d.id = 'dl-' + f; document.body.appendChild(d); } d.innerHTML = vals(f).map(v => `<option value="${esc(v)}">`).join(''); return d.id; };
const fields = [
{ name: 'building', label: 'Building', value: cur.building || S.loc.building || '', list: dl('building') },
{ name: 'room', label: 'Room', value: cur.room || S.loc.room || '', list: dl('room') },
{ name: 'rack', label: 'Rack', value: cur.rack || S.loc.rack || '', list: dl('rack') },
];
if (one) fields.push({ name: 'unit', label: 'Rack unit (lowest U)', value: cur.unit || '', placeholder: 'e.g. 24' }, { name: 'asset_tag', label: 'Asset tag', value: cur.asset_tag || '' });
else fields.push({ name: 'unit_start', label: 'First rack unit', placeholder: 'optional: U of the first selected machine', help: 'machines are stacked upwards from here in selection order; leave empty to keep units' });
const v = await dialog(`Location of ${one ? nameOf(one) : ids.length + ' machines'}`, fields, 'Empty fields clear the value. Racks are created on first use (42U); adjust height on the Racks page.', 'Save');
if (!v) return;
let u = one ? null : (v.unit_start ? Number(v.unit_start) : null);
await each(ids, 'location', async id => {
const body = { building: v.building.trim(), room: v.room.trim(), rack: v.rack.trim() };
if (one) { body.unit = Number(v.unit) || 0; body.asset_tag = v.asset_tag.trim(); } else if (u) { body.unit = u; u += 1; }
const r = await api('POST', `/api/v1/machines/${id}/location`, body); return locStr(r.location) || 'cleared';
});
},
async importLocations() {
const v = await dialog('Import locations (CSV)', [{ name: 'csv', label: 'CSV', type: 'textarea', rows: 10, placeholder: 'serial,building,room,rack,unit,asset_tag\nABC123,DC1,Hall A,R12,24,A-0042\n…' , help: 'identify rows by id, serial, mac or node_name; other columns: building, room, rack, unit, asset_tag' }, { name: 'dry', label: 'Dry run (show matches only)', type: 'checkbox', value: true }], '', 'Import');
if (!v) return;
try {
const r = await fetch('/api/v1/locations/import' + (v.dry ? '?dry=1' : ''), { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'text/csv' }, body: v.csv });
const d = await r.json(); if (!r.ok) throw new Error(d.error || r.statusText);
toast(`${v.dry ? 'DRY RUN: ' : ''}${d.matched.length} matched, ${d.unmatched.length} unmatched\n` + d.matched.slice(0, 12).map(m => `${m.name || m.id}: ${locStr(m.location)}`).join('\n') + (d.unmatched.length ? '\nunmatched rows: ' + d.unmatched.map(u => u.row).join(', ') : ''), d.unmatched.length > 0);
if (!v.dry) refresh(true);
} catch (e) { toast(e.message, true); }
},
async more(ids, item) {
const n = ids.length;
switch (item) {
case 'recommission': if (confirm(`Re-commission ${n} machine(s) on their next PXE boot?`)) await each(ids, 'recommission', async id => (await api('POST', `/api/v1/machines/${id}/recommission`)).state); break;
case 'recommission-now': if (confirm(`Re-commission ${n} machine(s) NOW: bootdev pxe + power cycle through the BMC?`)) await each(ids, 'recommission now', async id => { const r = await api('POST', `/api/v1/machines/${id}/recommission?now=1`); return r.ipmi || r.state; }); break;
case 'release': { const v = await dialog(`Release ${n} machine(s)`, [{ name: 'wipe', label: 'Wipe', type: 'select', options: [['quick', 'quick: signatures + first/last 64 MiB'], ['full', 'full: discard or zero the whole disk']] }, { name: 'boot', label: 'Power-cycle via BMC now', type: 'checkbox', value: true }], 'The OS disk is wiped and the machine returns to ready.', 'Release'); if (v) await each(ids, 'release', async id => `job ${(await api('POST', `/api/v1/machines/${id}/release`, { wipe: v.wipe, boot: v.boot })).job}`); break; }
case 'unassign': if (confirm(`Remove the stateless profile from ${n} machine(s)? Names and IPs are released.`)) await each(ids, 'unassign', async id => (await api('POST', `/api/v1/machines/${id}/unassign`)).state); break;
case 'bootdev-pxe': await act.bootdev(ids, 'pxe'); break;
case 'bootdev-disk': await act.bootdev(ids, 'disk'); break;
case 'cancel-job': await each(ids, 'cancel job', async id => `job ${(await api('POST', `/api/v1/machines/${id}/jobs/cancel`)).job} cancelled`); break;
case 'delete': if (confirm(`Delete ${n} machine record(s)? They are commissioned again as new machines on their next PXE boot.`)) { await each(ids, 'delete', async id => { await api('DELETE', `/api/v1/machines/${id}`); delete detailCache[id]; return 'deleted'; }); S.sel.clear(); nav('machines'); } break;
}
},
};
// ---------------------------------------------------------------- machine detail
function card(title, inner, wide) { return `<div class="card${wide ? ' wide' : ''}"><h3>${title}</h3>${inner}</div>`; }
function kv(pairs) { return '<dl class="kv">' + pairs.filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('') + '</dl>'; }
function table(cols, rows) { return rows.length ? `<table><thead><tr>${cols.map(c => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr>${r.map(c => `<td>${c ?? ''}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<span class="muted">none</span>'; }
function jobsTable(jobs, withMachine) {
return table([...(withMachine ? ['Machine'] : []), 'Job', 'Kind', 'State', 'Target', 'Progress', 'Started', 'Finished', 'Error'], jobs.map(j => {
const sp = j.spec || {}, p = j.progress || {}; const m = S.machines.find(x => x.id === j.machine_id);
const prog = (j.state === 'running' || j.state === 'pending') ? `<div class="bar"><span style="width:${Math.round(p.pct || 0)}%"></span></div><span class="muted">${esc(p.phase || '')} ${Math.round(p.pct || 0)}% ${esc((p.msg || '').slice(0, 60))}</span>` : (p.details && p.details.seconds ? `<span class="muted">${p.details.seconds}s</span>` : '');
return [...(withMachine ? [`<a href="#/machine/${esc(j.machine_id)}">${esc(m ? nameOf(m) : j.machine_id)}</a>`] : []), `<span class="mono">${esc(j.id)}</span>`, esc(j.kind), pill(j.state), `${esc(sp.os || '')}${sp.version ? ' <span class="muted mono">' + esc(sp.version) + '</span>' : ''}${sp.disk ? ' → <span class="mono">' + esc(sp.disk) + '</span>' : ''}`, prog, `<span class="muted" title="${esc(when(j.started || j.created))}">${ago(j.started || j.created)}</span>`, `<span class="muted">${esc(when(j.finished))}</span>`, j.error ? `<span class="bad">${esc(j.error)}</span>` : ''];
}));
}
function pageMachine(d, tab) {
const s = d.summary, inv = d.inventory || {}, sys = inv.system || {}, cpu = inv.cpu || {}, mem = inv.memory || {}, st = inv.storage || {}, b = d.bmc || {};
const m = S.machines.find(x => x.id === s.id) || s;
const tabs = ['overview', 'hardware', 'network', 'bmc', 'jobs', 'events', 'raw'];
let body = '';
if (tab === 'overview') {
const job = s.job || (d.jobs || [])[0]; const p = (job && job.progress) || {}; const active = job && (job.state === 'pending' || job.state === 'running');
body = `<div class="grid">
${card('Identity', kv([['Machine id', `<span class="mono">${esc(s.id)}</span>`], ['Model', esc([sys['system-manufacturer'], sys['system-product-name']].filter(Boolean).join(' '))], ['Serial', `<span class="mono">${esc(sys['system-serial-number'])}</span>`], ['UUID', `<span class="mono">${esc(sys['system-uuid'])}</span>`], ['BIOS', esc([sys['bios-vendor'], sys['bios-version'], sys['bios-release-date']].filter(Boolean).join(' '))], ['Chassis / asset', esc([sys['chassis-type'], sys['chassis-asset-tag']].filter(Boolean).join(' / '))], ['First seen', esc(when(s.first_seen))], ['Last seen', esc(when(s.last_seen))], ['Hardware', `${esc(cpu['Model name'])}<br>${hwSummary(m)}`]]))}
${card('Role', kv([
['State', pill(s.state)],
['Node name', esc(s.node_name)], ['Management IP', s.mgmt_ip ? `<span class="mono">${esc(s.mgmt_ip)}</span>` : ''], ['IB IP', s.ib_ip ? `<span class="mono">${esc(s.ib_ip)}</span>` : ''],
['Stateless profile', s.profile ? `${esc(s.profile)} · booted ${esc(s.booted_at ? when(s.booted_at) : 'not yet')}` : ''],
['OS on disk', s.os ? `${esc(s.os)} <span class="muted mono">${esc(s.os_version || '')}</span> · deployed ${esc(when(s.deployed_at))}` : ''],
['Phoned home', s.deployed_at ? (s.booted_at ? `<span class="ok">${esc(when(s.booted_at))}</span>` : '<span class="muted">not yet</span>') : ''],
['Location', `${esc(locStr(s.location)) || '<span class="muted">unknown</span>'}${s.location && s.location.asset_tag ? ` · asset <span class="mono">${esc(s.location.asset_tag)}</span>` : ''} <button class="btn sm" id="d-loc">edit</button>`],
['Tags', `<span id="d-tags">${tagPills(s, true) || '<span class="muted">none</span>'}</span> <button class="btn sm" id="d-tag">edit</button>`],
['BMC', b.address ? `<span class="dot ${b.configured ? 'ok' : 'bad'}"></span><span class="mono">${esc(b.address)}</span> ${esc(b.user || '')} · ${d.has_bmc_secret ? '<span class="ok">credentials stored</span>' : '<span class="bad">no credentials</span>'}` : '<span class="bad">none</span>'],
['Current job', job ? `<span class="mono">${esc(job.id)}</span> ${esc(job.kind)} ${pill(job.state)}${active ? `<div class="bar" style="margin-top:4px"><span style="width:${Math.round(p.pct || 0)}%"></span></div><span class="muted">${esc(p.phase || '')} ${Math.round(p.pct || 0)}% ${esc(p.msg || '')}</span>` : ''}${job.error ? `<br><span class="bad">${esc(job.error)}</span>` : ''}` : ''],
]))}
${card(`Storage · ${st.disk_count ?? 0} disks · ${human(st.total_bytes)}`, table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', 'Health'], (st.disks || []).map(x => { const sm = (st.smart || {})[x.name] || {}; return [esc(x.name), human(x.size), esc(x.tran), x.rota === false ? 'ssd' : 'hdd', esc(x.model), `<span class="mono">${esc(x.serial)}</span>`, sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; })))}
${card(`Network · ${(inv.network || []).length} NICs`, table(['If', 'MAC', 'Speed', 'Link', 'Switch port'], (inv.network || []).map(n => { const l = ((inv.lldp || {})[n.name]); const e = l ? (Array.isArray(l) ? l[0] : l) : null; const sw = e ? `${Object.keys(e.chassis || {})[0] || ''} ${((e.port || {}).id || {}).value || ''}` : ''; return [esc(n.name), `<span class="mono">${esc(n.mac)}</span>`, esc(n.speed), n.link === true ? '<span class="ok">up</span>' : n.link === false ? '<span class="muted">down</span>' : '', esc(sw)]; })))}
${card('Recent events', table(['When', 'Event', 'Detail'], (d.events || []).slice(-8).reverse().map(e => [`<span class="muted" title="${esc(when(e.ts))}">${ago(e.ts)}</span>`, esc(e.event), `<span class="mono muted">${esc(e.detail.slice(0, 90))}</span>`])), true)}
</div>`;
} else if (tab === 'hardware') {
body = `<div class="grid">
${card('CPU', kv([['Model', esc(cpu['Model name'])], ['Sockets × cores × threads', `${esc(cpu['Socket(s)'])} × ${esc(cpu['Core(s) per socket'])} × ${esc(cpu['Thread(s) per core'])} = ${esc(cpu['CPU(s)'])}`], ['NUMA nodes', esc(cpu['NUMA node(s)'])], ['Max MHz', esc(cpu['CPU max MHz'])], ['L3', esc(cpu['L3 cache'])], ['Virtualization', esc(cpu['Virtualization'])], ['Features', esc((cpu.features || []).join(' '))]]))}
${card(`Memory ${human(mem.total_bytes)} · ${mem.dimms_populated ?? '?'}/${mem.dimm_slots ?? '?'} DIMMs`, table(['Slot', 'Size', 'Type', 'Speed', 'Vendor', 'Part', 'Serial'], (mem.dimms || []).filter(x => x.populated).map(x => [esc(x.locator), esc(x.size), esc(x.type), esc(x.configured_speed || x.speed), esc(x.manufacturer), `<span class="mono">${esc(x.part_number)}</span>`, `<span class="mono">${esc(x.serial)}</span>`])))}
${card(`Storage · ${st.disk_count ?? 0} disks · ${human(st.total_bytes)}`, table(['Dev', 'Size', 'Bus', 'Kind', 'Model', 'Serial', 'FW', 'Hours', 'Health'], (st.disks || []).map(x => { const sm = (st.smart || {})[x.name] || {}; return [esc(x.name), human(x.size), esc(x.tran), x.rota === false ? 'ssd' : 'hdd', esc(x.model), `<span class="mono">${esc(x.serial)}</span>`, esc(sm.firmware), esc(sm.power_on_hours), sm.healthy === true ? '<span class="ok">ok</span>' : sm.healthy === false ? '<span class="bad">FAIL</span>' : '']; })) + ((st.controllers || []).length ? '<p class="muted">Controllers</p>' + table(['PCI', 'Vendor', 'Device', 'Driver'], st.controllers.map(c => [`<span class="mono">${esc(c.slot)}</span>`, esc(c.vendor), esc(c.device), esc(c.driver)])) : ''), true)}
${(inv.gpus || []).length ? card('GPUs / accelerators', table(['PCI', 'Vendor', 'Device', 'Driver'], inv.gpus.map(g => [`<span class="mono">${esc(g.slot)}</span>`, esc(g.vendor), esc(g.device), esc(g.driver)])), true) : ''}
${card('System', kv([['Board', esc([sys['baseboard-manufacturer'], sys['baseboard-product-name'], sys['baseboard-serial-number']].filter(Boolean).join(' / '))], ['SKU', esc(sys['system-sku-number'])], ['Chassis', esc([sys['chassis-type'], sys['chassis-serial-number'], sys['chassis-asset-tag']].filter(Boolean).join(' / '))], ['TPM', esc((inv.tpm || []).join(', ') || 'none')], ['Kernel (commissioning)', esc((inv.kernel || '').split(' (')[0])], ['Agent run', esc(inv.duration_s ? inv.duration_s + ' s' : '')]]))}
${(inv.pci || []).length ? card(`PCI devices · ${inv.pci.length}`, table(['Slot', 'Class', 'Vendor', 'Device', 'Driver'], inv.pci.map(p => [`<span class="mono">${esc(p.slot)}</span>`, esc(p.class), esc(p.vendor), esc(p.device), esc(p.driver)])), true) : ''}
${(inv.errors || []).length ? card('Agent errors', `<span class="bad">${esc(inv.errors.join('\n'))}</span>`, true) : ''}
</div>`;
} else if (tab === 'network') {
const hcas = (inv.infiniband || {}).hcas || [];
const lldpRows = []; for (const [ifn, ents] of Object.entries(inv.lldp || {})) for (const e of (Array.isArray(ents) ? ents : [ents])) { const ch = e.chassis || {}; const name = Object.keys(ch)[0] || '?'; const c = ch[name] || {}; lldpRows.push([esc(ifn), esc(name), esc((c.descr || '').slice(0, 60)), esc((e.port || {}).id && (e.port.id.value || e.port.id)), esc((e.port || {}).descr), esc((e.vlan || {})['vlan-id'] || (e.vlan || {}).value)]); }
body = `<div class="grid">
${card(`NICs · ${(inv.network || []).length}`, table(['If', 'MAC', 'Driver', 'FW', 'Speed', 'Link', 'PCI', 'NUMA', 'VFs', 'IPv4'], (inv.network || []).map(n => [esc(n.name), `<span class="mono">${esc(n.mac)}</span>`, esc(n.driver), esc(n.firmware_version), esc(n.speed), n.link === true ? '<span class="ok">up</span>' : n.link === false ? '<span class="muted">down</span>' : '', `<span class="mono">${esc(n.pci)}</span>`, esc(n.numa_node), esc(n.sriov_totalvfs), esc((n.ipv4 || []).join(' '))])), true)}
${card(`InfiniBand · ${hcas.length} HCAs`, hcas.length ? table(['HCA', 'Port', 'Layer', 'State', 'Rate', 'LID', 'Node GUID', 'FW', 'Board'], hcas.flatMap(h => (h.ports || []).map(p => [esc(h.name), esc(p.port), esc(p.link_layer), esc(p.state), esc(p.rate), esc(p.lid), `<span class="mono">${esc(h.node_guid)}</span>`, esc(h.fw_ver), esc(h.board_id)]))) : '<span class="muted">none</span>', true)}
${card('LLDP neighbours (switch ports)', table(['If', 'Switch', 'Descr', 'Port', 'Port descr', 'VLAN'], lldpRows), true)}
</div>`;
} else if (tab === 'bmc') {
body = `<div class="grid">${card('BMC', kv([['Source', esc(b.source)], ['Address', `<span class="mono">${esc(b.address)}${b.port && b.port !== 623 ? ':' + esc(b.port) : ''}</span>`], ['MAC', `<span class="mono">${esc(b.mac)}</span>`], ['User', esc(b.user)], ['Password', d.has_bmc_secret ? '<span class="ok">stored encrypted on the server</span>' : '<span class="bad">none</span>'], ['Configured', b.configured ? '<span class="ok">yes</span>' : '<span class="bad">no</span>'], ['Verified out-of-band', b.verified_oob === true ? '<span class="ok">yes</span>' : b.verified_oob === false ? '<span class="bad">no</span>' : ''], ['Channel', esc(b.channel)], ['LAN', b.lan ? `<span class="mono">${esc(JSON.stringify(b.lan))}</span>` : ''], ['Firmware', esc((b.mc_info || {})['Firmware Revision'])], ['Manufacturer', esc((b.mc_info || {})['Manufacturer Name'])], ['Errors', (b.errors || []).length ? `<span class="bad">${esc(b.errors.join('; '))}</span>` : '']]))}
${(b.users_before || []).length ? card('User table before commissioning', table(['ID', 'Name', 'Priv', 'IPMI'], b.users_before.map(u => [u.id, esc(u.name), esc(u.priv), u.ipmi_msg ? 'yes' : 'no']))) : ''}
${b.fru ? card('FRU', `<pre class="json">${esc(JSON.stringify(b.fru, null, 1))}</pre>`) : ''}</div>`;
} else if (tab === 'jobs') body = jobsTable(d.jobs || [], false);
else if (tab === 'events') body = table(['Time', 'Event', 'Detail'], (d.events || []).slice().reverse().map(e => [esc(when(e.ts)), esc(e.event), `<span class="mono">${esc(e.detail)}</span>`]));
else body = `<pre class="json">${esc(JSON.stringify(d, null, 1))}</pre>`;
const hasJob = !!s.job;
return `
<div class="d-head"><a href="#/machines" class="btn ghost">← Machines</a><h1>${esc(nameOf(s))}</h1>${pill(s.state)}<span class="id">${esc(s.id)}</span>
<div class="right" style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap">
${menuBtn('Power', [['status', 'Status'], ['on', 'Power on'], ['off', 'Power off'], ['soft', 'Soft off'], ['cycle', 'Power cycle'], ['reset', 'Reset']], 'dpower')}
${menuBtn('Boot device', [['pxe', 'PXE'], ['disk', 'Disk'], ['bios', 'BIOS setup'], ['cdrom', 'CD-ROM']], 'dboot')}
<button class="btn primary" id="ddeploy">Deploy OS…</button>
<button class="btn" id="dassign">Assign profile…</button>
${menuBtn('More', [['recommission', 'Re-commission (next boot)'], ['recommission-now', 'Re-commission now (BMC)'], ['release', 'Release (wipe disk)…'], ['unassign', 'Unassign profile'], ...(hasJob ? [['cancel-job', 'Cancel active job']] : []), ['hr'], ['delete', 'Delete record']], 'dmore')}
</div></div>
<div class="d-meta"><span>${esc([sys['system-manufacturer'], sys['system-product-name']].filter(Boolean).join(' '))}</span><span>S/N <span class="mono">${esc(s.serial || '')}</span></span><span>${hwSummary(m)}</span>${s.mgmt_ip ? `<span class="mono">${esc(s.mgmt_ip)}</span>` : ''}<span>seen ${ago(s.last_seen)} ago</span></div>
<div class="tabs">${tabs.map(t => `<a href="#/machine/${esc(s.id)}/${t}" class="${t === tab ? 'active' : ''}">${t === 'bmc' ? 'BMC' : t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>
${body}`;
}
function wireMachine(d) {
const id = d.summary.id, ids = [id];
wireMenus();
$$('#dpower [data-item]').forEach(b => b.onclick = () => act.power(ids, b.dataset.item));
$$('#dboot [data-item]').forEach(b => b.onclick = () => act.bootdev(ids, b.dataset.item));
$('#ddeploy').onclick = () => act.deploy(ids);
$('#dassign').onclick = () => act.assign(ids);
$$('#dmore [data-item]').forEach(b => b.onclick = () => act.more(ids, b.dataset.item));
const t = $('#d-tag'); if (t) t.onclick = () => act.tag(ids);
const lo = $('#d-loc'); if (lo) lo.onclick = () => act.location(ids);
$$('[data-untag]').forEach(a => a.onclick = ev => { ev.preventDefault(); each(ids, 'untag', async () => (await api('POST', `/api/v1/machines/${id}/tags`, { remove: [a.dataset.untag] })).effective.join(' ')); });
}
// ---------------------------------------------------------------- jobs
function pageJobs() {
const active = S.jobs.filter(j => j.state === 'pending' || j.state === 'running'), rest = S.jobs.filter(j => !(j.state === 'pending' || j.state === 'running'));
return `<div class="page-head"><h1>Jobs</h1><span class="sub">${active.length} active · last ${rest.length} finished</span><div class="right"><button class="btn" id="refresh">Refresh</button></div></div>
<div class="card wide" style="margin-bottom:12px"><h3>Active</h3>${jobsTable(active, true)}</div>
<div class="card wide"><h3>History</h3>${jobsTable(rest, true)}</div>`;
}
function wireJobs() { $('#refresh').onclick = () => refresh(true); }
// ---------------------------------------------------------------- racks
function pageRacks() {
const r = route(); const sel = r.arg ? decodeURIComponent(r.arg) : '';
const placed = S.machines.filter(m => m.location && m.location.rack);
const unplaced = S.machines.length - placed.length;
// racks from definitions plus any rack a machine mentions
const keyOf = (b, rm, n) => `${b}|${rm}|${n}`;
const racks = new Map(S.racks.map(x => [keyOf(x.building, x.room, x.name), { ...x }]));
for (const m of placed) { const l = m.location; const k = keyOf(l.building || '', l.room || '', l.rack); if (!racks.has(k)) racks.set(k, { building: l.building || '', room: l.room || '', name: l.rack, height: 42, machines: 0 }); }
const list = [...racks.values()].sort((a, b) => keyOf(a.building, a.room, a.name).localeCompare(keyOf(b.building, b.room, b.name)));
const buildings = [...new Set(list.map(x => x.building))].sort();
const tree = buildings.map(b => { const rooms = [...new Set(list.filter(x => x.building === b).map(x => x.room))].sort(); return `<span class="chip${sel === b ? ' on' : ''}" data-rsel="${esc(b)}"><b>${esc(b || '(no building)')}</b><span class="n">${list.filter(x => x.building === b).length} racks</span></span>` + rooms.map(rm => `<span class="chip${sel === b + '/' + rm ? ' on' : ''}" data-rsel="${esc(b + '/' + rm)}">${esc(rm || '(no room)')}<span class="n">${list.filter(x => x.building === b && x.room === rm).length}</span></span>`).join(''); }).join('<span class="sep" style="width:12px"></span>');
const shown = list.filter(x => !sel || x.building === sel || x.building + '/' + x.room === sel);
const elevation = rk => {
const ms = placed.filter(m => (m.location.building || '') === rk.building && (m.location.room || '') === rk.room && m.location.rack === rk.name);
const byU = {}; const noU = [];
for (const m of ms) { if (m.location.unit > 0) byU[m.location.unit] = m; else noU.push(m); }
const H = rk.height || 42; let rows = '';
for (let u = H; u >= 1; u--) { const m = byU[u]; rows += `<div class="u">${u}</div>` + (m ? `<div class="slot m ${esc(m.state)}" data-id="${esc(m.id)}" title="${esc(nameOf(m))} · ${esc(m.state)} · ${esc((m.manufacturer || '') + ' ' + (m.product || ''))}">${esc(nameOf(m))} <span class="muted">${esc(m.os || m.profile || '')}</span></div>` : '<div class="slot"></div>'); }
return `<div class="rack"><h4><span>${esc(rk.name)} <span class="muted">${esc([rk.building, rk.room].filter(Boolean).join(' / '))}</span></span><span class="muted">${ms.length} · ${H}U <a href="#" data-rackedit="${esc(keyOf(rk.building, rk.room, rk.name))}" title="height / comment">✎</a></span></h4>
<div class="elev">${rows}</div>${noU.length ? `<div class="muted" style="margin-top:6px">no U: ${noU.map(m => `<a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a>`).join(', ')}</div>` : ''}${rk.comment ? `<div class="muted" style="margin-top:4px">${esc(rk.comment)}</div>` : ''}</div>`;
};
return `<div class="page-head"><h1>Racks</h1><span class="sub">${list.length} racks · ${placed.length} machines placed${unplaced ? ` · <a href="#/machines" id="go-unplaced">${unplaced} without location</a>` : ''}</span>
<div class="right"><button class="btn" id="import-loc">Import CSV…</button><button class="btn" id="new-rack">New rack…</button></div></div>
${list.length ? `<div class="tree">${tree}${sel ? '<button class="btn ghost sm" id="rclear">all</button>' : ''}</div><div class="racks">${shown.map(elevation).join('')}</div>`
: '<div class="empty">No locations yet. Set building / room / rack / U on machines (detail → Location → edit, or select several and “Set location…”), or import a CSV from your asset list.</div>'}`;
}
async function editRack(rk) {
const v = await dialog(rk ? `Rack ${rk.name}` : 'New rack', [{ name: 'building', label: 'Building', value: rk ? rk.building : S.loc.building }, { name: 'room', label: 'Room', value: rk ? rk.room : S.loc.room }, { name: 'name', label: 'Rack', value: rk ? rk.name : '' }, { name: 'height', label: 'Height (U)', value: rk ? rk.height : 42 }, { name: 'comment', label: 'Comment', value: rk ? rk.comment || '' : '' }], '', 'Save');
if (!v) return;
try { await api('POST', '/api/v1/racks', { building: v.building.trim(), room: v.room.trim(), name: v.name.trim(), height: Number(v.height) || 42, comment: v.comment }); toast(`rack ${v.name} saved`); refresh(true); } catch (e) { toast(e.message, true); }
}
function wireRacks() {
$$('[data-rsel]').forEach(c => c.onclick = () => { location.hash = '#/racks/' + encodeURIComponent(c.dataset.rsel); });
const rc = $('#rclear'); if (rc) rc.onclick = () => nav('racks');
$$('.elev .slot.m').forEach(s => s.onclick = () => { location.hash = '#/machine/' + s.dataset.id; });
$$('[data-rackedit]').forEach(a => a.onclick = ev => { ev.preventDefault(); const [b, rm, n] = a.dataset.rackedit.split('|'); editRack(S.racks.find(x => x.building === b && x.room === rm && x.name === n) || { building: b, room: rm, name: n, height: 42 }); });
$('#new-rack').onclick = () => editRack(null);
$('#import-loc').onclick = () => act.importLocations();
const gu = $('#go-unplaced'); if (gu) gu.onclick = () => { S.q = ''; S.loc = { building: '', room: '', rack: '' }; S.states.clear(); S.tagf.clear(); };
}
// ---------------------------------------------------------------- tags
function pageTags() {
return `<div class="page-head"><h1>Tags</h1><span class="sub">single out machines for special treatment: auto rules from the inventory, cloud-init fragments at deployment, overlays for stateless nodes</span><div class="right"><button class="btn primary" id="newtag">New tag…</button></div></div>
${table(['Tag', 'Machines', 'Match rule', 'Cloud-config', 'Overlays', 'Comment', ''], S.tags.map(t => [`<span class="pill tag">${esc(t.name)}</span>`, `<a href="#/machines" data-filtertag="${esc(t.name)}">${t.machines || 0}</a>`, `<span class="mono">${esc(t.match || '')}</span>`, t.cloud_config ? `<span class="muted">${t.cloud_config.split('\n').length} lines</span>` : '', esc((t.overlays || []).join(', ')), esc(t.comment || ''), `<button class="btn sm" data-edit="${esc(t.name)}">edit</button> <button class="btn sm danger" data-rm="${esc(t.name)}">delete</button>`]))}
<p class="muted" style="margin-top:10px">Match rules are Go templates over <code>.Node</code> (GPUs, IBGUIDs, CPUs, Sockets, MemMB, Disks, NVMe, PCIVendors, Manufacturer, Product, OS, Tags), e.g. <code>{{ gt .Node.GPUs 0 }}</code>, <code>{{ has .Node.PCIVendors "15b3" }}</code>, <code>{{ matches "(?i)PowerEdge R7" .Node.Product }}</code>. Rules run at commissioning and whenever a tag is saved.</p>`;
}
async function editTag(t) {
const v = await dialog(t ? `Edit tag ${t.name}` : 'New tag', [
{ name: 'name', label: 'Name', value: t ? t.name : '', placeholder: 'gpu' },
{ name: 'comment', label: 'Comment', value: t ? t.comment : '' },
{ name: 'match', label: 'Match rule', value: t ? t.match : '', placeholder: '{{ gt .Node.GPUs 0 }} (empty = manual only)' },
{ name: 'overlays', label: 'Overlays', value: t ? (t.overlays || []).join(',') : '', placeholder: 'gpu-slurm', help: 'extra overlays for stateless nodes with this tag' },
{ name: 'cloud_config', label: 'Cloud-config', type: 'textarea', value: t ? t.cloud_config : '', placeholder: '#cloud-config\npackages: [nvidia-driver]\nruncmd:\n - [ sh, -c, "..." ]' },
], 'The cloud-config fragment is merged into the user-data of every deployment of a tagged machine. A fragment starting with #! runs as a script.', 'Save');
if (!v) return;
try { await api('POST', '/api/v1/tags', { name: v.name.trim(), comment: v.comment, match: v.match, overlays: v.overlays.split(',').map(s => s.trim()).filter(Boolean), cloud_config: v.cloud_config }); toast(`tag ${v.name} saved; auto tags re-evaluated`); refresh(true); } catch (e) { toast(e.message, true); }
}
function wireTags() {
$('#newtag').onclick = () => editTag(null);
$$('[data-edit]').forEach(b => b.onclick = () => editTag(S.tags.find(t => t.name === b.dataset.edit)));
$$('[data-rm]').forEach(b => b.onclick = async () => { if (confirm(`Delete tag ${b.dataset.rm}? It is removed from every machine.`)) { try { await api('DELETE', '/api/v1/tags/' + b.dataset.rm); refresh(true); } catch (e) { toast(e.message, true); } } });
$$('[data-filtertag]').forEach(a => a.onclick = () => { S.tagf.clear(); S.tagf.add(a.dataset.filtertag); S.states.clear(); });
}
// ---------------------------------------------------------------- profiles / images
function pageProfiles() {
const imgs = (S._images || []);
return `<div class="page-head"><h1>Profiles</h1><span class="sub">stateless (RAM-booted) node definitions</span><div class="right"><button class="btn primary" id="newprofile">New profile…</button></div></div>
${table(['Profile', 'Root image', 'Overlays', 'Name format', 'Kernel append', 'Nodes', ''], S.profiles.map(p => [esc(p.name), esc(p.image), esc((p.overlays || []).join(', ')), `<span class="mono">${esc(p.name_format)}</span>`, `<span class="mono">${esc(p.append || '')}</span>`, S.machines.filter(m => m.profile === p.name).length, `<button class="btn sm" data-edit="${esc(p.name)}">edit</button> <button class="btn sm danger" data-rm="${esc(p.name)}">delete</button>`]))}
<div class="card wide" style="margin-top:12px"><h3>Stateless root images</h3><div id="rootimgs" class="muted">loading…</div></div>`;
}
async function editProfile(p) {
const v = await dialog(p ? `Edit profile ${p.name}` : 'New profile', [
{ name: 'name', label: 'Name', value: p ? p.name : '', placeholder: 'compute' },
{ name: 'image', label: 'Root image', value: p ? p.image : '', placeholder: 'compute-debian13', help: 'directory under images_dir with vmlinuz, initrd.img, filesystem.squashfs' },
{ name: 'overlays', label: 'Overlays', value: p ? (p.overlays || []).join(',') : '', placeholder: 'compute' },
{ name: 'name_format', label: 'Name format', value: p ? p.name_format : 'node%03d' },
{ name: 'append', label: 'Kernel append', value: p ? p.append : '' },
{ name: 'vars', label: 'Vars (JSON)', type: 'textarea', rows: 4, value: p ? JSON.stringify(p.vars || {}, null, 1) : '{}' },
], '', 'Save');
if (!v) return;
let vars; try { vars = JSON.parse(v.vars || '{}'); } catch (e) { return toast('vars: invalid JSON', true); }
try { await api('POST', '/api/v1/profiles', { name: v.name.trim(), image: v.image.trim(), overlays: v.overlays.split(',').map(s => s.trim()).filter(Boolean), name_format: v.name_format, append: v.append, vars }); toast(`profile ${v.name} saved`); refresh(true); } catch (e) { toast(e.message, true); }
}
function wireProfiles() {
$('#newprofile').onclick = () => editProfile(null);
$$('[data-edit]').forEach(b => b.onclick = () => editProfile(S.profiles.find(p => p.name === b.dataset.edit)));
$$('[data-rm]').forEach(b => b.onclick = async () => { if (confirm(`Delete profile ${b.dataset.rm}?`)) { try { await api('DELETE', '/api/v1/profiles/' + b.dataset.rm); refresh(true); } catch (e) { toast(e.message, true); } } });
api('GET', '/api/v1/images').then(imgs => { const el = $('#rootimgs'); if (el) el.innerHTML = table(['Image', 'Complete', 'Squashfs', 'Built'], imgs.map(i => [esc(i.name), i.complete ? '<span class="ok">yes</span>' : '<span class="bad">no</span>', human(i['filesystem.squashfs']), esc(when(i.built))])); }).catch(() => { });
}
function pageImages() {
return `<div class="page-head"><h1>OS images</h1><span class="sub">disk images for deployment · refreshed nightly by <code>rollcall os sync</code> from the vendors' checksum files</span></div>
${table(['OS', 'Current version', 'Family', 'Compressed', 'Disk needed', 'Imported', 'Track', 'Keep', 'Versions', 'Status'], S.os.map(i => { const c = i.current || {}; const s = i.source || {}; return [`<b>${esc(i.name)}</b>${s.description ? `<br><span class="muted">${esc(s.description)}</span>` : ''}`, `<span class="mono">${esc(c.version || '—')}</span>`, esc(s.family || c.family), c.size ? human(c.size) : '', c.raw_size ? human(c.raw_size) : '', c.imported ? `${esc(when(c.imported))} <span class="muted">(${ago(c.imported)})</span>` : '', esc(s.track || ''), s.keep || '', (i.versions || []).map(v => `<span class="mono${v.current ? '' : ' muted'}">${esc(v.version)}</span>`).join('<br>'), i.warning ? `<span class="warn">${esc(i.warning)}</span>` : '<span class="ok">ok</span>']; }))}
<p class="muted" style="margin-top:10px">Pinned sources keep <code>current</code> until <code>rollcall os promote NAME VERSION</code>; a deployment always uses the exact version it started with. Import a local file with <code>rollcall os import NAME FILE</code>.</p>`;
}
// ---------------------------------------------------------------- go
schedule();
refresh(true);