Initial commit: Infrastructure host tracking app
build-and-push / build-and-push (push) Successful in 1m26s
build-and-push / build-and-push (push) Successful in 1m26s
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>
This commit is contained in:
+872
@@ -0,0 +1,872 @@
|
||||
// ── 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();
|
||||
Reference in New Issue
Block a user