Files
Infrastructure/public/app.js
T
josh f500db971b
build-and-push / build-and-push (push) Successful in 1m26s
Initial commit: Infrastructure host tracking app
Fastify + node:sqlite single-process app with vanilla JS UI for
looking up hosts by hardware ID, hostname, or asset ID. Includes
per-host network interface tracking, sites/rooms/server-types CRUD,
Docker packaging, and a Gitea Actions workflow that runs tests then
builds and pushes to gitea.thewrightserver.net/josh/infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 17:05:50 -04:00

873 lines
28 KiB
JavaScript

// ── API client ────────────────────────────────────────────────────────────────
const api = {
async req(method, path, body) {
const res = await fetch(`/api${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.error || `HTTP ${res.status}`);
err.status = res.status;
err.details = data.details;
throw err;
}
return data;
},
hosts: {
search: (q) => api.req('GET', `/hosts${q ? `?q=${encodeURIComponent(q)}` : ''}`),
get: (id) => api.req('GET', `/hosts/${id}`),
getByHardwareId: (hwid) => api.req('GET', `/hosts/by-hardware-id/${encodeURIComponent(hwid)}`),
create: (h) => api.req('POST', '/hosts', h),
update: (id, h) => api.req('PUT', `/hosts/${id}`, h),
delete: (id) => api.req('DELETE', `/hosts/${id}`),
},
sites: {
list: () => api.req('GET', '/sites'),
create: (name) => api.req('POST', '/sites', { name }),
update: (id, name) => api.req('PUT', `/sites/${id}`, { name }),
delete: (id) => api.req('DELETE', `/sites/${id}`),
},
rooms: {
list: (siteId) => api.req('GET', `/rooms${siteId ? `?site_id=${siteId}` : ''}`),
create: (site_id, name) => api.req('POST', '/rooms', { site_id, name }),
update: (id, site_id, name) => api.req('PUT', `/rooms/${id}`, { site_id, name }),
delete: (id) => api.req('DELETE', `/rooms/${id}`),
},
serverTypes: {
list: () => api.req('GET', '/server-types'),
create: (name) => api.req('POST', '/server-types', { name }),
update: (id, name) => api.req('PUT', `/server-types/${id}`, { name }),
delete: (id) => api.req('DELETE', `/server-types/${id}`),
},
interfaces: {
list: (hostId) => api.req('GET', `/interfaces?host_id=${hostId}`),
create: (body) => api.req('POST', '/interfaces', body),
update: (id, body) => api.req('PUT', `/interfaces/${id}`, body),
delete: (id) => api.req('DELETE', `/interfaces/${id}`),
},
};
const IFACE_FIELDS = ['name', 'mac_address', 'ip_address', 'subnet', 'link_speed'];
const IFACE_PLACEHOLDERS = {
name: 'eth0',
mac_address: 'aa:bb:cc:dd:ee:ff',
ip_address: '10.0.0.1',
subnet: '10.0.0.0/24',
link_speed: '1000/full',
};
// ── Helpers ───────────────────────────────────────────────────────────────────
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') node.className = v;
else if (k === 'dataset') Object.assign(node.dataset, v);
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
else if (v === true) node.setAttribute(k, '');
else if (v !== false && v != null) node.setAttribute(k, v);
}
for (const c of children.flat()) {
if (c == null || c === false) continue;
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return node;
}
function openModal(id) {
const m = document.getElementById(id);
m.hidden = false;
}
function closeModal(id) {
const m = document.getElementById(id);
m.hidden = true;
}
// ── Routing ───────────────────────────────────────────────────────────────────
const heroEl = $('#hero');
const resultsEl = $('#results');
const detailEl = $('#detail');
const resultsBody = $('#results-body');
const resultsEmpty = $('#results-empty');
const searchInput = $('#search-input');
let currentView = 'search';
let currentHardwareId = null;
function navigate(path, { replace = false } = {}) {
history[replace ? 'replaceState' : 'pushState']({}, '', path);
handleRoute();
}
function handleRoute() {
const m = /^\/hosts\/(.+)$/.exec(location.pathname);
if (m) showDetail(decodeURIComponent(m[1]));
else showSearch();
}
function showSearch() {
currentView = 'search';
currentHardwareId = null;
detailEl.hidden = true;
heroEl.hidden = false;
// Preserve whatever results are already rendered (don't auto-trigger lookup)
const hasInput = searchInput.value.trim().length > 0;
heroEl.classList.toggle('compact', hasInput);
const hasContent = resultsBody.children.length > 0 || !resultsEmpty.hidden;
resultsEl.hidden = !(hasInput && hasContent);
}
async function showDetail(hardwareId) {
currentView = 'detail';
currentHardwareId = hardwareId;
heroEl.hidden = true;
resultsEl.hidden = true;
detailEl.hidden = false;
await renderDetail(hardwareId);
}
window.addEventListener('popstate', handleRoute);
// ── Lookup ────────────────────────────────────────────────────────────────────
async function runLookup() {
if (currentView !== 'search') return;
const q = searchInput.value.trim();
if (!q) return;
heroEl.classList.add('compact');
resultsEl.hidden = false;
resultsBody.replaceChildren();
resultsEmpty.hidden = false;
resultsEmpty.textContent = 'Looking up…';
let rows = [];
try {
rows = await api.hosts.search(q);
} catch (err) {
resultsEmpty.textContent = `Lookup failed: ${err.message}`;
return;
}
if (rows.length === 1) {
// Clear the lookup-in-progress state so back-navigation lands clean
resultsEmpty.hidden = true;
resultsEl.hidden = true;
navigate(`/hosts/${encodeURIComponent(rows[0].hardware_id)}`);
return;
}
if (rows.length === 0) {
resultsEmpty.textContent = 'Host not found.';
return;
}
renderResults(rows);
}
function renderResults(rows) {
resultsBody.replaceChildren();
resultsEmpty.hidden = rows.length > 0;
if (rows.length === 0) {
resultsEmpty.textContent = 'Host not found.';
return;
}
for (const h of rows) {
resultsBody.appendChild(renderHostRow(h));
}
}
function renderHostRow(host) {
const link = el('a', {
class: 'host-link',
href: `/hosts/${encodeURIComponent(host.hardware_id)}`,
'data-nav': '',
}, host.hostname);
const row = el('tr',
{ dataset: { id: host.id } },
el('td', {}, link),
el('td', {}, host.hardware_id),
el('td', {}, host.asset_id),
el('td',
{ class: 'actions-cell' },
el('button', { class: 'btn-link', onclick: () => openHostModal(host) }, 'Edit'),
el('button',
{ class: 'btn-link btn-link-danger', onclick: (e) => beginDelete(e, host) },
'Delete'),
),
);
return row;
}
function beginDelete(e, host) {
const cell = e.target.closest('td');
const original = cell.cloneNode(true);
// re-wire the original's button handlers when we restore it
const restore = () => {
cell.replaceWith(original);
// rebind handlers on the restored cell
const [editBtn, delBtn] = original.querySelectorAll('button');
editBtn.addEventListener('click', () => openHostModal(host));
delBtn.addEventListener('click', (ev) => beginDelete(ev, host));
};
cell.replaceChildren(
el('span', { class: 'confirm-inline' },
'Delete this host?',
el('button', {
class: 'btn-link btn-link-danger',
onclick: async () => {
try {
await api.hosts.delete(host.id);
cell.closest('tr').remove();
if (resultsBody.children.length === 0) {
resultsEmpty.hidden = false;
resultsEmpty.textContent = 'No hosts match.';
}
} catch (err) {
alert(`Delete failed: ${err.message}`);
restore();
}
},
}, 'Yes'),
el('button', { class: 'btn-link', onclick: restore }, 'No'),
),
);
}
$('#lookup-form').addEventListener('submit', (e) => {
e.preventDefault();
runLookup();
});
// ── Detail page ───────────────────────────────────────────────────────────────
async function renderDetail(hardwareId) {
detailEl.replaceChildren(el('div', { class: 'manage-empty' }, 'Loading…'));
let host;
try {
host = await api.hosts.getByHardwareId(hardwareId);
} catch (err) {
detailEl.replaceChildren(
el('a', { class: 'back-link', href: '/', 'data-nav': '' }, '\u2190 Back to search'),
el('div', { class: 'banner banner-error' },
err.status === 404 ? 'Host not found.' : `Failed to load host: ${err.message}`),
);
return;
}
const back = el('a', { class: 'back-link', href: '/', 'data-nav': '' }, '\u2190 Back to search');
const title = el('h1', { class: 'detail-title' }, host.hostname);
const subtitle = el('p', { class: 'detail-subtitle' },
`${host.site_name} \u00b7 ${host.room_name}${host.position ? ` \u00b7 ${host.position}` : ''}`);
const list = el('dl', { class: 'detail-list' },
el('dt', {}, 'Hardware ID'), el('dd', {}, host.hardware_id),
el('dt', {}, 'Asset ID'), el('dd', {}, host.asset_id),
el('dt', {}, 'Site'), el('dd', {}, host.site_name),
el('dt', {}, 'Room'), el('dd', {}, host.room_name),
el('dt', {}, 'Position'), host.position
? el('dd', {}, host.position)
: el('dd', { class: 'empty' }, '\u2014'),
el('dt', {}, 'Server Type'), el('dd', {}, host.server_type),
el('dt', {}, 'Created'), el('dd', {}, host.created_at),
el('dt', {}, 'Updated'), el('dd', {}, host.updated_at),
);
const actions = el('div', { class: 'detail-actions', id: 'detail-actions' },
el('button', { class: 'btn btn-primary', onclick: () => openHostModal(host) }, 'Edit'),
el('button', { class: 'btn btn-ghost', onclick: () => beginDeleteFromDetail(host) }, 'Delete'),
);
const interfacesSection = await renderInterfacesSection(host);
detailEl.replaceChildren(back, title, subtitle, list, interfacesSection, actions);
}
async function renderInterfacesSection(host) {
const tbody = el('tbody', { id: 'interfaces-body' });
const empty = el('div', { class: 'results-empty', id: 'interfaces-empty', hidden: true },
'No interfaces.');
const addBtn = el('button', { class: 'btn btn-ghost btn-sm' }, '+ Add interface');
addBtn.addEventListener('click', () => {
empty.hidden = true;
tbody.appendChild(buildIfaceEditRow(host, null));
});
const section = el('section', { class: 'interfaces' },
el('div', { class: 'interfaces-header' },
el('h2', {}, 'Network'),
addBtn,
),
el('table', { class: 'table' },
el('thead', {},
el('tr', {},
el('th', {}, 'Interface'),
el('th', {}, 'Ethernet'),
el('th', {}, 'IP Address'),
el('th', {}, 'Subnet'),
el('th', {}, 'Link'),
el('th', { class: 'actions-col' }),
),
),
tbody,
),
empty,
);
let ifaces = [];
try {
ifaces = await api.interfaces.list(host.id);
} catch (err) {
section.appendChild(el('div', { class: 'iface-error' },
`Failed to load interfaces: ${err.message}`));
return section;
}
if (ifaces.length === 0) {
empty.hidden = false;
} else {
for (const iface of ifaces) tbody.appendChild(buildIfaceRow(host, iface));
}
return section;
}
function fieldOrDash(value) {
return value ? value : el('span', { class: 'muted' }, '\u2014');
}
function buildIfaceRow(host, iface) {
const row = el('tr', { dataset: { ifaceId: iface.id } });
const cells = IFACE_FIELDS.map((f) => el('td', {}, fieldOrDash(iface[f])));
const actions = el('td', { class: 'actions-cell' });
row.append(...cells, actions);
setIfaceDisplayActions(row, host, iface, actions);
return row;
}
function setIfaceDisplayActions(row, host, iface, actionsCell) {
actionsCell.replaceChildren(
el('button', { class: 'btn-link', onclick: () => enterIfaceEdit(row, host, iface) }, 'Edit'),
el('button', {
class: 'btn-link btn-link-danger',
onclick: () => beginIfaceDelete(row, host, iface, actionsCell),
}, 'Delete'),
);
}
function enterIfaceEdit(row, host, iface) {
const editRow = buildIfaceEditRow(host, iface);
row.replaceWith(editRow);
}
function buildIfaceEditRow(host, iface) {
const isNew = iface == null;
const row = el('tr', { class: 'iface-edit-row' });
const inputs = {};
for (const f of IFACE_FIELDS) {
const input = el('input', {
class: 'iface-edit-input',
type: 'text',
name: f,
placeholder: IFACE_PLACEHOLDERS[f],
value: iface?.[f] ?? '',
});
inputs[f] = input;
row.appendChild(el('td', {}, input));
}
const actions = el('td', { class: 'actions-cell' });
row.appendChild(actions);
let errorRow = null;
const showError = (msg) => {
clearError();
errorRow = el('tr', { class: 'iface-error-row' },
el('td', { colspan: IFACE_FIELDS.length + 1 },
el('div', { class: 'iface-error' }, msg)));
row.parentNode.insertBefore(errorRow, row);
};
const clearError = () => { if (errorRow) { errorRow.remove(); errorRow = null; } };
const cancel = el('button', {
class: 'btn-link',
onclick: () => {
clearError();
if (isNew) {
row.remove();
const tbody = $('#interfaces-body');
if (tbody && tbody.children.length === 0) {
const emptyEl = $('#interfaces-empty');
if (emptyEl) emptyEl.hidden = false;
}
} else {
const display = buildIfaceRow(host, iface);
row.replaceWith(display);
}
},
}, 'Cancel');
const save = el('button', {
class: 'btn-link',
onclick: async () => {
clearError();
const payload = { host_id: host.id };
for (const f of IFACE_FIELDS) payload[f] = inputs[f].value.trim();
if (!payload.name) {
showError('Interface name is required.');
return;
}
try {
const saved = isNew
? await api.interfaces.create(payload)
: await api.interfaces.update(iface.id, payload);
const display = buildIfaceRow(host, saved);
row.replaceWith(display);
const emptyEl = $('#interfaces-empty');
if (emptyEl) emptyEl.hidden = true;
} catch (err) {
const detailMsg = err.details?.length ? `: ${err.details.join('; ')}` : '';
showError(`${err.message}${detailMsg}`);
}
},
}, 'Save');
actions.append(save, cancel);
return row;
}
function beginIfaceDelete(row, host, iface, actionsCell) {
actionsCell.replaceChildren(
el('span', { class: 'confirm-inline' },
'Delete this interface?',
el('button', {
class: 'btn-link btn-link-danger',
onclick: async () => {
try {
await api.interfaces.delete(iface.id);
row.remove();
const tbody = $('#interfaces-body');
if (tbody && tbody.children.length === 0) {
const emptyEl = $('#interfaces-empty');
if (emptyEl) emptyEl.hidden = false;
}
} catch (err) {
alert(`Delete failed: ${err.message}`);
setIfaceDisplayActions(row, host, iface, actionsCell);
}
},
}, 'Yes'),
el('button', {
class: 'btn-link',
onclick: () => setIfaceDisplayActions(row, host, iface, actionsCell),
}, 'No'),
),
);
}
function beginDeleteFromDetail(host) {
const actions = $('#detail-actions');
if (!actions) return;
actions.replaceChildren(
el('span', { class: 'confirm-inline' },
'Delete this host?',
el('button', {
class: 'btn btn-danger btn-sm',
onclick: async () => {
try {
await api.hosts.delete(host.id);
navigate('/');
} catch (err) {
alert(`Delete failed: ${err.message}`);
renderDetail(host.hardware_id);
}
},
}, 'Yes, delete'),
el('button', { class: 'btn btn-ghost btn-sm', onclick: () => renderDetail(host.hardware_id) }, 'Cancel'),
),
);
}
// ── Host modal ────────────────────────────────────────────────────────────────
const hostModal = $('#host-modal');
const hostForm = $('#host-form');
const hostError = $('#host-error');
const hostTitle = $('#host-modal-title');
let hostBeingEdited = null;
let cachedSites = [];
let cachedServerTypes = [];
async function openHostModal(host = null) {
hostBeingEdited = host;
hostTitle.textContent = host ? 'Edit host' : 'New host';
hostError.hidden = true;
hostError.textContent = '';
hostForm.reset();
await loadDropdowns();
if (host) {
hostForm.elements.hostname.value = host.hostname;
hostForm.elements.hardware_id.value = host.hardware_id;
hostForm.elements.asset_id.value = host.asset_id;
hostForm.elements.position.value = host.position ?? '';
hostForm.elements.site_id.value = host.site_id;
await refreshRoomsSelect(host.site_id, host.room_id);
hostForm.elements.server_type_id.value = host.server_type_id;
} else {
if (cachedSites[0]) {
hostForm.elements.site_id.value = cachedSites[0].id;
await refreshRoomsSelect(cachedSites[0].id);
}
if (cachedServerTypes[0]) {
hostForm.elements.server_type_id.value = cachedServerTypes[0].id;
}
}
openModal('host-modal');
setTimeout(() => hostForm.elements.hostname.focus(), 50);
}
async function loadDropdowns() {
const [sites, serverTypes] = await Promise.all([
api.sites.list(),
api.serverTypes.list(),
]);
cachedSites = sites;
cachedServerTypes = serverTypes;
const siteSel = hostForm.elements.site_id;
siteSel.replaceChildren(...sites.map((s) =>
el('option', { value: s.id }, s.name)));
const typeSel = hostForm.elements.server_type_id;
typeSel.replaceChildren(...serverTypes.map((t) =>
el('option', { value: t.id }, t.name)));
}
async function refreshRoomsSelect(siteId, selectedRoomId = null) {
const roomSel = hostForm.elements.room_id;
if (!siteId) {
roomSel.replaceChildren();
return;
}
const rooms = await api.rooms.list(siteId);
roomSel.replaceChildren(...rooms.map((r) =>
el('option', { value: r.id }, r.name)));
if (selectedRoomId != null) roomSel.value = selectedRoomId;
}
hostForm.elements.site_id.addEventListener('change', (e) => {
refreshRoomsSelect(Number(e.target.value));
});
hostForm.addEventListener('submit', async (e) => {
e.preventDefault();
hostError.hidden = true;
const fd = new FormData(hostForm);
const payload = {
hostname: fd.get('hostname').trim(),
hardware_id: fd.get('hardware_id').trim(),
asset_id: fd.get('asset_id').trim(),
room_id: Number(fd.get('room_id')),
position: (fd.get('position') ?? '').trim(),
server_type_id: Number(fd.get('server_type_id')),
};
let saved;
try {
saved = hostBeingEdited
? await api.hosts.update(hostBeingEdited.id, payload)
: await api.hosts.create(payload);
} catch (err) {
hostError.textContent = err.details?.length
? `${err.message}: ${err.details.join('; ')}`
: err.message;
hostError.hidden = false;
return;
}
closeModal('host-modal');
// Always land on the saved host's page. If we were already on detail and the
// hardware_id didn't change, replace so back doesn't bounce.
const url = `/hosts/${encodeURIComponent(saved.hardware_id)}`;
const replace = currentView === 'detail' && currentHardwareId === saved.hardware_id;
navigate(url, { replace });
});
$('#new-host-btn').addEventListener('click', () => openHostModal(null));
$('.brand').addEventListener('click', () => {
searchInput.value = '';
resultsBody.replaceChildren();
resultsEmpty.hidden = true;
resultsEmpty.textContent = 'No hosts match.';
});
// ── Manage modal ──────────────────────────────────────────────────────────────
const manageModal = $('#manage-modal');
const manageBody = $('#manage-body');
let activeTab = 'sites';
$('#manage-btn').addEventListener('click', async () => {
openModal('manage-modal');
await renderManageTab();
});
$$('.tab').forEach((btn) => {
btn.addEventListener('click', async () => {
$$('.tab').forEach((b) => b.classList.toggle('tab-active', b === btn));
activeTab = btn.dataset.tab;
await renderManageTab();
});
});
async function renderManageTab() {
manageBody.replaceChildren(el('div', { class: 'manage-empty' }, 'Loading…'));
if (activeTab === 'sites') await renderSitesTab();
if (activeTab === 'rooms') await renderRoomsTab();
if (activeTab === 'server-types') await renderTypesTab();
}
async function renderSitesTab() {
const items = await api.sites.list();
manageBody.replaceChildren(buildLookupList({
items,
placeholder: 'New site name',
onCreate: (name) => api.sites.create(name),
onUpdate: (id, name) => api.sites.update(id, name),
onDelete: (id) => api.sites.delete(id),
rerender: renderSitesTab,
}));
}
async function renderTypesTab() {
const items = await api.serverTypes.list();
manageBody.replaceChildren(buildLookupList({
items,
placeholder: 'New server type',
onCreate: (name) => api.serverTypes.create(name),
onUpdate: (id, name) => api.serverTypes.update(id, name),
onDelete: (id) => api.serverTypes.delete(id),
rerender: renderTypesTab,
}));
}
function buildLookupList({ items, placeholder, onCreate, onUpdate, onDelete, rerender }) {
const list = el('div', { class: 'manage-list' });
// Add row
const addInput = el('input', { type: 'text', placeholder, maxlength: 100 });
const addBtn = el('button', {
class: 'btn btn-primary btn-sm',
onclick: async () => {
const name = addInput.value.trim();
if (!name) return;
try {
await onCreate(name);
await rerender();
} catch (err) {
alert(err.message);
}
},
}, 'Add');
list.appendChild(el('div', { class: 'manage-row add-row' }, addInput, addBtn));
if (items.length === 0) {
list.appendChild(el('div', { class: 'manage-empty' }, 'No entries yet.'));
return list;
}
for (const item of items) {
list.appendChild(buildLookupRow(item, onUpdate, onDelete, rerender));
}
return list;
}
function buildLookupRow(item, onUpdate, onDelete, rerender) {
const row = el('div', { class: 'manage-row' });
const labelCell = el('div', { class: 'label' }, item.name);
const actions = el('div', {});
const editBtn = el('button', {
class: 'btn-link',
onclick: () => enterEdit(),
}, 'Edit');
const delBtn = el('button', {
class: 'btn-link btn-link-danger',
onclick: () => confirmDelete(),
}, 'Delete');
actions.append(editBtn, delBtn);
row.append(labelCell, actions);
function enterEdit() {
const input = el('input', { type: 'text', value: item.name, maxlength: 100 });
const save = el('button', {
class: 'btn-link',
onclick: async () => {
const name = input.value.trim();
if (!name) return;
try {
await onUpdate(item.id, name);
await rerender();
} catch (err) { alert(err.message); }
},
}, 'Save');
const cancel = el('button', { class: 'btn-link', onclick: rerender }, 'Cancel');
labelCell.replaceChildren(input);
actions.replaceChildren(save, cancel);
}
function confirmDelete() {
actions.replaceChildren(
el('span', { class: 'confirm-inline' },
'Delete?',
el('button', {
class: 'btn-link btn-link-danger',
onclick: async () => {
try {
await onDelete(item.id);
await rerender();
} catch (err) { alert(err.message); rerender(); }
},
}, 'Yes'),
el('button', { class: 'btn-link', onclick: rerender }, 'No'),
),
);
}
return row;
}
async function renderRoomsTab() {
const [rooms, sites] = await Promise.all([api.rooms.list(), api.sites.list()]);
if (sites.length === 0) {
manageBody.replaceChildren(el('div', { class: 'manage-empty' },
'Add a site first.'));
return;
}
const list = el('div', { class: 'manage-list' });
// Add row
const siteSel = el('select', {},
...sites.map((s) => el('option', { value: s.id }, s.name)));
const nameInput = el('input', { type: 'text', placeholder: 'New room name', maxlength: 100 });
const addBtn = el('button', {
class: 'btn btn-primary btn-sm',
onclick: async () => {
const name = nameInput.value.trim();
if (!name) return;
try {
await api.rooms.create(Number(siteSel.value), name);
await renderRoomsTab();
} catch (err) { alert(err.message); }
},
}, 'Add');
list.appendChild(el('div', { class: 'manage-row add-row' },
el('div', { class: 'grow' }, siteSel, nameInput),
addBtn,
));
if (rooms.length === 0) {
list.appendChild(el('div', { class: 'manage-empty' }, 'No rooms yet.'));
manageBody.replaceChildren(list);
return;
}
for (const room of rooms) {
list.appendChild(buildRoomRow(room, sites));
}
manageBody.replaceChildren(list);
}
function buildRoomRow(room, sites) {
const row = el('div', { class: 'manage-row' });
const label = el('div', { class: 'label' },
el('span', { class: 'muted' }, `${room.site_name} ·`),
room.name);
const actions = el('div', {});
const editBtn = el('button', { class: 'btn-link', onclick: enterEdit }, 'Edit');
const delBtn = el('button', { class: 'btn-link btn-link-danger', onclick: confirmDelete }, 'Delete');
actions.append(editBtn, delBtn);
row.append(label, actions);
function enterEdit() {
const siteSel = el('select', {},
...sites.map((s) => el('option', { value: s.id, selected: s.id === room.site_id }, s.name)));
const input = el('input', { type: 'text', value: room.name, maxlength: 100 });
const save = el('button', {
class: 'btn-link',
onclick: async () => {
const name = input.value.trim();
if (!name) return;
try {
await api.rooms.update(room.id, Number(siteSel.value), name);
await renderRoomsTab();
} catch (err) { alert(err.message); }
},
}, 'Save');
const cancel = el('button', { class: 'btn-link', onclick: renderRoomsTab }, 'Cancel');
label.replaceChildren(el('div', { class: 'grow' }, siteSel, input));
actions.replaceChildren(save, cancel);
}
function confirmDelete() {
actions.replaceChildren(
el('span', { class: 'confirm-inline' },
'Delete?',
el('button', {
class: 'btn-link btn-link-danger',
onclick: async () => {
try {
await api.rooms.delete(room.id);
await renderRoomsTab();
} catch (err) { alert(err.message); renderRoomsTab(); }
},
}, 'Yes'),
el('button', { class: 'btn-link', onclick: renderRoomsTab }, 'No'),
),
);
}
return row;
}
// ── Modal close handlers ──────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
const closeId = e.target.dataset?.closeModal;
if (closeId) closeModal(closeId);
// click on backdrop
if (e.target.classList.contains('modal')) {
closeModal(e.target.id);
}
// SPA navigation for in-app links
const link = e.target.closest('a[data-nav]');
if (link && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && e.button === 0) {
e.preventDefault();
navigate(link.getAttribute('href'));
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
$$('.modal').forEach((m) => { if (!m.hidden) m.hidden = true; });
}
});
// ── Bootstrap ─────────────────────────────────────────────────────────────────
handleRoute();