- 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>
547 lines
59 KiB
JavaScript
547 lines
59 KiB
JavaScript
'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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 > 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);
|