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:
+431
@@ -0,0 +1,431 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #111827;
|
||||
--muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--border-strong: #d1d5db;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--danger: #dc2626;
|
||||
--danger-bg: #fef2f2;
|
||||
--row-hover: #f9fafb;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button { font-family: inherit; font-size: inherit; }
|
||||
input, select { font-family: inherit; font-size: inherit; }
|
||||
|
||||
/* Top bar */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: -0.01em;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
main {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 64px 16px 32px;
|
||||
}
|
||||
.hero.compact {
|
||||
text-align: left;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
.hero.compact .hero-title,
|
||||
.hero.compact .hero-sub { display: none; }
|
||||
|
||||
.hero-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.hero-sub {
|
||||
margin: 0 0 28px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.hero.compact .search-row { max-width: 100%; }
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
transition: border-color 0.1s, box-shadow 0.1s;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s, border-color 0.1s, color 0.1s;
|
||||
}
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-strong);
|
||||
color: var(--fg);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--row-hover); }
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
.btn-sm { padding: 6px 10px; font-size: 13px; }
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-link-danger { color: var(--danger); }
|
||||
|
||||
/* Results */
|
||||
.results { margin-top: 8px; }
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--bg);
|
||||
}
|
||||
.table th, .table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
background: var(--row-hover);
|
||||
}
|
||||
.table tbody tr:hover { background: var(--row-hover); }
|
||||
.actions-col { width: 1%; white-space: nowrap; text-align: right; }
|
||||
.actions-cell { text-align: right; white-space: nowrap; }
|
||||
|
||||
.results-empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 24, 39, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 48px 16px;
|
||||
z-index: 50;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
.modal-card-wide { max-width: 720px; }
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.modal-close:hover { color: var(--fg); }
|
||||
|
||||
/* Form */
|
||||
.form { padding: 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.field span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field input, .field select {
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
background: var(--bg);
|
||||
}
|
||||
.field input:focus, .field select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
.field-row { display: flex; gap: 12px; }
|
||||
.field-row .field { flex: 1; }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
/* Banners */
|
||||
.banner {
|
||||
margin: 0 20px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.banner-error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
/* Inline confirm */
|
||||
.confirm-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab:hover { color: var(--fg); }
|
||||
.tab-active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Manage body */
|
||||
.manage-body { padding: 16px 20px 20px; }
|
||||
.manage-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.manage-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.manage-row:last-child { border-bottom: none; }
|
||||
.manage-row.add-row { background: var(--row-hover); }
|
||||
.manage-row input, .manage-row select {
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
}
|
||||
.manage-row .grow { display: flex; gap: 8px; }
|
||||
.manage-row .grow > * { flex: 1; }
|
||||
.manage-row .label { color: var(--fg); }
|
||||
.manage-row .label .muted { color: var(--muted); margin-right: 6px; }
|
||||
.manage-empty {
|
||||
padding: 20px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Detail page */
|
||||
.detail { padding: 8px 0 24px; max-width: 720px; }
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.back-link:hover { color: var(--accent); }
|
||||
|
||||
.detail-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
word-break: break-all;
|
||||
}
|
||||
.detail-subtitle {
|
||||
margin: 0 0 24px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: 0;
|
||||
margin: 0 0 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.detail-list dt,
|
||||
.detail-list dd {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.detail-list dt {
|
||||
background: var(--row-hover);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
align-content: center;
|
||||
}
|
||||
.detail-list dt:nth-last-of-type(1),
|
||||
.detail-list dd:nth-last-of-type(1) { border-bottom: none; }
|
||||
.detail-list dd { word-break: break-word; }
|
||||
.detail-list .empty { color: var(--muted); }
|
||||
|
||||
.detail-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* Hostname link in results */
|
||||
.host-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
.host-link:hover { text-decoration: underline; }
|
||||
|
||||
/* Interfaces section on detail page */
|
||||
.interfaces { margin: 0 0 24px; }
|
||||
.interfaces-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.interfaces-header h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.iface-edit-input {
|
||||
width: 100%;
|
||||
padding: 5px 7px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
}
|
||||
.iface-edit-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
.iface-error {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
[hidden] { display: none !important; }
|
||||
+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();
|
||||
@@ -0,0 +1,120 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Infrastructure</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" data-nav>Infrastructure</a>
|
||||
<div class="topbar-actions">
|
||||
<button id="new-host-btn" class="btn btn-primary">+ New host</button>
|
||||
<button id="manage-btn" class="btn btn-ghost">Manage</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main">
|
||||
<section id="hero" class="hero">
|
||||
<h1 class="hero-title">Find a host</h1>
|
||||
<p class="hero-sub">Search by hostname, hardware ID, or asset ID.</p>
|
||||
<form id="lookup-form" class="search-row" autocomplete="off">
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Hostname, hardware ID, or asset ID…"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
/>
|
||||
<button type="submit" id="lookup-btn" class="btn btn-primary">Lookup</button>
|
||||
</form>
|
||||
<div id="hero-empty-state" class="hero-empty"></div>
|
||||
</section>
|
||||
|
||||
<section id="results" class="results" hidden>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Hardware ID</th>
|
||||
<th>Asset ID</th>
|
||||
<th class="actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="results-body"></tbody>
|
||||
</table>
|
||||
<div id="results-empty" class="results-empty" hidden>No hosts match.</div>
|
||||
</section>
|
||||
|
||||
<section id="detail" class="detail" hidden></section>
|
||||
</main>
|
||||
|
||||
<!-- Host modal -->
|
||||
<div id="host-modal" class="modal" hidden>
|
||||
<div class="modal-card">
|
||||
<div class="modal-header">
|
||||
<h2 id="host-modal-title">New host</h2>
|
||||
<button class="modal-close" data-close-modal="host-modal">×</button>
|
||||
</div>
|
||||
<div id="host-error" class="banner banner-error" hidden></div>
|
||||
<form id="host-form" class="form">
|
||||
<label class="field">
|
||||
<span>Hostname</span>
|
||||
<input name="hostname" type="text" required maxlength="253" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Hardware ID</span>
|
||||
<input name="hardware_id" type="text" required maxlength="100" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Asset ID</span>
|
||||
<input name="asset_id" type="text" required maxlength="100" autocomplete="off" />
|
||||
</label>
|
||||
<div class="field-row">
|
||||
<label class="field">
|
||||
<span>Site</span>
|
||||
<select name="site_id" required></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Room</span>
|
||||
<select name="room_id" required></select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Position</span>
|
||||
<input name="position" type="text" maxlength="100" placeholder="e.g. R3-U12" autocomplete="off" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Server Type</span>
|
||||
<select name="server_type_id" required></select>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-ghost" data-close-modal="host-modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage modal -->
|
||||
<div id="manage-modal" class="modal" hidden>
|
||||
<div class="modal-card modal-card-wide">
|
||||
<div class="modal-header">
|
||||
<h2>Manage</h2>
|
||||
<button class="modal-close" data-close-modal="manage-modal">×</button>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab tab-active" data-tab="sites">Sites</button>
|
||||
<button class="tab" data-tab="rooms">Rooms</button>
|
||||
<button class="tab" data-tab="server-types">Server Types</button>
|
||||
</div>
|
||||
<div id="manage-body" class="manage-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user