Initial commit: Infrastructure host tracking app
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:
2026-04-19 17:05:50 -04:00
commit f500db971b
26 changed files with 4057 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
data
.git
.gitea
.github
.claude
tests
*.md
.DS_Store
.env
+43
View File
@@ -0,0 +1,43 @@
name: build-and-push
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: gitea.thewrightserver.net
username: josh
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
gitea.thewrightserver.net/josh/infrastructure:latest
gitea.thewrightserver.net/josh/infrastructure:${{ github.sha }}
+5
View File
@@ -0,0 +1,5 @@
node_modules/
data/
*.log
.DS_Store
.env
+13
View File
@@ -0,0 +1,13 @@
FROM node:22-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN mkdir -p /app/data && chown -R app:app /app
USER app
EXPOSE 3000
CMD ["node", "src/server.js"]
+41
View File
@@ -0,0 +1,41 @@
# Infrastructure
A small internal tool for tracking servers. Search hosts by hardware ID, hostname, or asset ID; manage where they live (site / room / position) and what kind of server they are. Browser UI plus a JSON API.
## Run
```sh
npm install
npm start
```
UI on `http://localhost:3000`, API at `http://localhost:3000/api`.
## Test
```sh
npm test
```
## Docker
```sh
docker compose up --build
```
Data persists in the `infrastructure-data` named volume.
## API
| Method | Path | Notes |
|---|---|---|
| GET | `/api/hosts?q=` | Substring search across `hardware_id`, `hostname`, `asset_id`. Capped at 200 results. |
| GET | `/api/hosts/:id` | Fetch one. |
| POST | `/api/hosts` | Create. |
| PUT | `/api/hosts/:id` | Replace. |
| DELETE | `/api/hosts/:id` | Hard delete. |
| `*` | `/api/sites[/:id]` | Site CRUD. |
| `*` | `/api/rooms[/:id]` | Room CRUD (`?site_id=` filter on GET). |
| `*` | `/api/server-types[/:id]` | Server-type CRUD. |
Errors: `{ error, details? }` with status 400 / 404 / 409 / 500.
+14
View File
@@ -0,0 +1,14 @@
services:
infrastructure:
image: gitea.thewrightserver.net/josh/infrastructure:latest
pull_policy: always
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
volumes:
- infrastructure-data:/app/data
environment:
- NODE_ENV=production
volumes:
infrastructure-data:
+1144
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "infrastructure",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test 'tests/**/*.test.js'"
},
"dependencies": {
"@fastify/sensible": "^6.0.1",
"@fastify/static": "^8.0.3",
"fastify": "^5.1.0"
}
}
+431
View File
@@ -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
View File
@@ -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();
+120
View File
@@ -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">&times;</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">&times;</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>
+253
View File
@@ -0,0 +1,253 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE CHECK(length(name) BETWEEN 1 AND 100)
);
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE RESTRICT,
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 100),
UNIQUE(site_id, name)
);
CREATE TABLE IF NOT EXISTS server_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE CHECK(length(name) BETWEEN 1 AND 100)
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hardware_id TEXT NOT NULL UNIQUE,
hostname TEXT NOT NULL UNIQUE,
asset_id TEXT NOT NULL UNIQUE,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE RESTRICT,
position TEXT NOT NULL DEFAULT '',
server_type_id INTEGER NOT NULL REFERENCES server_types(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_hosts_hardware_id ON hosts(hardware_id);
CREATE INDEX IF NOT EXISTS idx_hosts_hostname ON hosts(hostname);
CREATE INDEX IF NOT EXISTS idx_hosts_asset_id ON hosts(asset_id);
CREATE TABLE IF NOT EXISTS interfaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 50),
mac_address TEXT NOT NULL DEFAULT '' CHECK(length(mac_address) <= 17),
ip_address TEXT NOT NULL DEFAULT '' CHECK(length(ip_address) <= 15),
subnet TEXT NOT NULL DEFAULT '' CHECK(length(subnet) <= 18),
link_speed TEXT NOT NULL DEFAULT '' CHECK(length(link_speed) <= 20),
UNIQUE(host_id, name)
);
CREATE INDEX IF NOT EXISTS idx_interfaces_host ON interfaces(host_id);
`;
const HOST_SELECT = `
SELECT h.id, h.hardware_id, h.hostname, h.asset_id, h.position,
h.created_at, h.updated_at,
s.id AS site_id, s.name AS site_name,
r.id AS room_id, r.name AS room_name,
st.id AS server_type_id, st.name AS server_type
FROM hosts h
JOIN rooms r ON r.id = h.room_id
JOIN sites s ON s.id = r.site_id
JOIN server_types st ON st.id = h.server_type_id
`;
export function openDb(path) {
if (path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true });
}
const db = new DatabaseSync(path);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA synchronous = NORMAL');
db.exec(SCHEMA);
return makeApi(db);
}
function makeApi(db) {
const stmts = {
siteList: db.prepare('SELECT * FROM sites ORDER BY name'),
siteGet: db.prepare('SELECT * FROM sites WHERE id = ?'),
siteInsert: db.prepare('INSERT INTO sites (name) VALUES (?)'),
siteUpdate: db.prepare('UPDATE sites SET name = ? WHERE id = ?'),
siteDelete: db.prepare('DELETE FROM sites WHERE id = ?'),
roomList: db.prepare(
`SELECT r.*, s.name AS site_name FROM rooms r
JOIN sites s ON s.id = r.site_id ORDER BY s.name, r.name`,
),
roomListBySite: db.prepare(
`SELECT r.*, s.name AS site_name FROM rooms r
JOIN sites s ON s.id = r.site_id WHERE r.site_id = ? ORDER BY r.name`,
),
roomGet: db.prepare(
`SELECT r.*, s.name AS site_name FROM rooms r
JOIN sites s ON s.id = r.site_id WHERE r.id = ?`,
),
roomInsert: db.prepare('INSERT INTO rooms (site_id, name) VALUES (?, ?)'),
roomUpdate: db.prepare('UPDATE rooms SET site_id = ?, name = ? WHERE id = ?'),
roomDelete: db.prepare('DELETE FROM rooms WHERE id = ?'),
typeList: db.prepare('SELECT * FROM server_types ORDER BY name'),
typeGet: db.prepare('SELECT * FROM server_types WHERE id = ?'),
typeInsert: db.prepare('INSERT INTO server_types (name) VALUES (?)'),
typeUpdate: db.prepare('UPDATE server_types SET name = ? WHERE id = ?'),
typeDelete: db.prepare('DELETE FROM server_types WHERE id = ?'),
hostListAll: db.prepare(`${HOST_SELECT} ORDER BY h.hostname LIMIT 200`),
hostSearch: db.prepare(
`${HOST_SELECT}
WHERE LOWER(h.hardware_id) LIKE :q
OR LOWER(h.hostname) LIKE :q
OR LOWER(h.asset_id) LIKE :q
ORDER BY h.hostname LIMIT 200`,
),
hostGet: db.prepare(`${HOST_SELECT} WHERE h.id = ?`),
hostGetByHwid: db.prepare(`${HOST_SELECT} WHERE h.hardware_id = ?`),
hostInsert: db.prepare(
`INSERT INTO hosts (hardware_id, hostname, asset_id, room_id, position, server_type_id)
VALUES (?, ?, ?, ?, ?, ?)`,
),
hostUpdate: db.prepare(
`UPDATE hosts SET hardware_id = ?, hostname = ?, asset_id = ?,
room_id = ?, position = ?, server_type_id = ?,
updated_at = datetime('now')
WHERE id = ?`,
),
hostDelete: db.prepare('DELETE FROM hosts WHERE id = ?'),
ifaceListByHost: db.prepare(
'SELECT * FROM interfaces WHERE host_id = ? ORDER BY name',
),
ifaceGet: db.prepare('SELECT * FROM interfaces WHERE id = ?'),
ifaceInsert: db.prepare(
`INSERT INTO interfaces (host_id, name, mac_address, ip_address, subnet, link_speed)
VALUES (?, ?, ?, ?, ?, ?)`,
),
ifaceUpdate: db.prepare(
`UPDATE interfaces SET host_id = ?, name = ?, mac_address = ?,
ip_address = ?, subnet = ?, link_speed = ?
WHERE id = ?`,
),
ifaceDelete: db.prepare('DELETE FROM interfaces WHERE id = ?'),
};
return {
raw: db,
close: () => db.close(),
sites: {
list: () => stmts.siteList.all(),
get: (id) => stmts.siteGet.get(id),
create: (name) => {
const { lastInsertRowid } = stmts.siteInsert.run(name);
return stmts.siteGet.get(lastInsertRowid);
},
update: (id, name) => {
const r = stmts.siteUpdate.run(name, id);
return r.changes ? stmts.siteGet.get(id) : null;
},
delete: (id) => stmts.siteDelete.run(id).changes > 0,
},
rooms: {
list: (siteId) => siteId
? stmts.roomListBySite.all(siteId)
: stmts.roomList.all(),
get: (id) => stmts.roomGet.get(id),
create: (siteId, name) => {
const { lastInsertRowid } = stmts.roomInsert.run(siteId, name);
return stmts.roomGet.get(lastInsertRowid);
},
update: (id, siteId, name) => {
const r = stmts.roomUpdate.run(siteId, name, id);
return r.changes ? stmts.roomGet.get(id) : null;
},
delete: (id) => stmts.roomDelete.run(id).changes > 0,
},
serverTypes: {
list: () => stmts.typeList.all(),
get: (id) => stmts.typeGet.get(id),
create: (name) => {
const { lastInsertRowid } = stmts.typeInsert.run(name);
return stmts.typeGet.get(lastInsertRowid);
},
update: (id, name) => {
const r = stmts.typeUpdate.run(name, id);
return r.changes ? stmts.typeGet.get(id) : null;
},
delete: (id) => stmts.typeDelete.run(id).changes > 0,
},
hosts: {
list: () => stmts.hostListAll.all(),
search: (q) => {
const term = `%${q.toLowerCase()}%`;
return stmts.hostSearch.all({ q: term });
},
get: (id) => stmts.hostGet.get(id),
getByHardwareId: (hwid) => stmts.hostGetByHwid.get(hwid),
create: (h) => {
const { lastInsertRowid } = stmts.hostInsert.run(
h.hardware_id, h.hostname, h.asset_id,
h.room_id, h.position ?? '', h.server_type_id,
);
return stmts.hostGet.get(lastInsertRowid);
},
update: (id, h) => {
const r = stmts.hostUpdate.run(
h.hardware_id, h.hostname, h.asset_id,
h.room_id, h.position ?? '', h.server_type_id,
id,
);
return r.changes ? stmts.hostGet.get(id) : null;
},
delete: (id) => stmts.hostDelete.run(id).changes > 0,
},
interfaces: {
listByHost: (hostId) => stmts.ifaceListByHost.all(hostId),
get: (id) => stmts.ifaceGet.get(id),
create: (i) => {
const { lastInsertRowid } = stmts.ifaceInsert.run(
i.host_id, i.name,
i.mac_address ?? '', i.ip_address ?? '',
i.subnet ?? '', i.link_speed ?? '',
);
return stmts.ifaceGet.get(lastInsertRowid);
},
update: (id, i) => {
const r = stmts.ifaceUpdate.run(
i.host_id, i.name,
i.mac_address ?? '', i.ip_address ?? '',
i.subnet ?? '', i.link_speed ?? '',
id,
);
return r.changes ? stmts.ifaceGet.get(id) : null;
},
delete: (id) => stmts.ifaceDelete.run(id).changes > 0,
},
};
}
export function seedIfEmpty(db) {
const { count } = db.raw.prepare('SELECT COUNT(*) AS count FROM sites').get();
if (count > 0) return;
const site = db.sites.create('HQ');
db.rooms.create(site.id, 'Server Room A');
for (const t of ['Web', 'Database', 'Application', 'Storage', 'Network']) {
db.serverTypes.create(t);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function hostsRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: {
querystring: {
type: 'object',
properties: { q: { type: 'string' } },
},
response: {
200: { type: 'array', items: schemas.hostResponse },
},
},
}, async (req) => {
const q = (req.query.q ?? '').trim();
return q ? db.hosts.search(q) : db.hosts.list();
});
fastify.get('/by-hardware-id/:hardwareId', {
schema: {
params: {
type: 'object',
required: ['hardwareId'],
properties: { hardwareId: { type: 'string', minLength: 1 } },
},
response: { 200: schemas.hostResponse, 404: schemas.errorResponse },
},
}, async (req) => {
const host = db.hosts.getByHardwareId(req.params.hardwareId);
if (!host) throw fastify.httpErrors.notFound('host not found');
return host;
});
fastify.get('/:id', {
schema: {
params: schemas.idParam,
response: { 200: schemas.hostResponse, 404: schemas.errorResponse },
},
}, async (req) => {
const host = db.hosts.get(req.params.id);
if (!host) throw fastify.httpErrors.notFound('host not found');
return host;
});
fastify.post('/', {
schema: {
body: schemas.hostBody,
response: { 201: schemas.hostResponse },
},
}, async (req, reply) => {
try {
const host = db.hosts.create(req.body);
reply.code(201);
return host;
} catch (err) {
translateSqliteError(err, fastify);
}
});
fastify.put('/:id', {
schema: {
params: schemas.idParam,
body: schemas.hostBody,
response: { 200: schemas.hostResponse, 404: schemas.errorResponse },
},
}, async (req) => {
try {
const host = db.hosts.update(req.params.id, req.body);
if (!host) throw fastify.httpErrors.notFound('host not found');
return host;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify);
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
const removed = db.hosts.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('host not found');
reply.code(204);
return null;
});
}
+71
View File
@@ -0,0 +1,71 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function interfacesRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: {
querystring: schemas.interfaceQuery,
response: { 200: { type: 'array', items: schemas.interfaceResponse } },
},
}, async (req) => db.interfaces.listByHost(req.query.host_id));
fastify.get('/:id', {
schema: {
params: schemas.idParam,
response: { 200: schemas.interfaceResponse, 404: schemas.errorResponse },
},
}, async (req) => {
const row = db.interfaces.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('interface not found');
return row;
});
fastify.post('/', {
schema: {
body: schemas.interfaceBody,
response: { 201: schemas.interfaceResponse },
},
}, async (req, reply) => {
try {
const row = db.interfaces.create(req.body);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, {
uniqueMessage: 'an interface with that name already exists on this host',
foreignKeyMessage: 'host does not exist',
});
}
});
fastify.put('/:id', {
schema: {
params: schemas.idParam,
body: schemas.interfaceBody,
response: { 200: schemas.interfaceResponse, 404: schemas.errorResponse },
},
}, async (req) => {
try {
const row = db.interfaces.update(req.params.id, req.body);
if (!row) throw fastify.httpErrors.notFound('interface not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, {
uniqueMessage: 'an interface with that name already exists on this host',
foreignKeyMessage: 'host does not exist',
});
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
const removed = db.interfaces.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('interface not found');
reply.code(204);
return null;
});
}
+69
View File
@@ -0,0 +1,69 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function roomsRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: {
querystring: {
type: 'object',
properties: { site_id: { type: 'integer', minimum: 1 } },
},
response: { 200: { type: 'array', items: schemas.roomResponse } },
},
}, async (req) => db.rooms.list(req.query.site_id));
fastify.get('/:id', {
schema: { params: schemas.idParam, response: { 200: schemas.roomResponse } },
}, async (req) => {
const row = db.rooms.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('room not found');
return row;
});
fastify.post('/', {
schema: { body: schemas.roomBody, response: { 201: schemas.roomResponse } },
}, async (req, reply) => {
try {
const row = db.rooms.create(req.body.site_id, req.body.name);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, {
uniqueMessage: 'a room with that name already exists at this site',
foreignKeyMessage: 'site does not exist',
});
}
});
fastify.put('/:id', {
schema: { params: schemas.idParam, body: schemas.roomBody, response: { 200: schemas.roomResponse } },
}, async (req) => {
try {
const row = db.rooms.update(req.params.id, req.body.site_id, req.body.name);
if (!row) throw fastify.httpErrors.notFound('room not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, {
uniqueMessage: 'a room with that name already exists at this site',
foreignKeyMessage: 'site does not exist',
});
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
try {
const removed = db.rooms.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('room not found');
reply.code(204);
return null;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: hosts still reference this room' });
}
});
}
+57
View File
@@ -0,0 +1,57 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function serverTypesRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: { response: { 200: { type: 'array', items: schemas.lookupResponse } } },
}, async () => db.serverTypes.list());
fastify.get('/:id', {
schema: { params: schemas.idParam, response: { 200: schemas.lookupResponse } },
}, async (req) => {
const row = db.serverTypes.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('server type not found');
return row;
});
fastify.post('/', {
schema: { body: schemas.lookupBody, response: { 201: schemas.lookupResponse } },
}, async (req, reply) => {
try {
const row = db.serverTypes.create(req.body.name);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, { uniqueMessage: 'a server type with that name already exists' });
}
});
fastify.put('/:id', {
schema: { params: schemas.idParam, body: schemas.lookupBody, response: { 200: schemas.lookupResponse } },
}, async (req) => {
try {
const row = db.serverTypes.update(req.params.id, req.body.name);
if (!row) throw fastify.httpErrors.notFound('server type not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { uniqueMessage: 'a server type with that name already exists' });
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
try {
const removed = db.serverTypes.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('server type not found');
reply.code(204);
return null;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: hosts still reference this server type' });
}
});
}
+57
View File
@@ -0,0 +1,57 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function sitesRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: { response: { 200: { type: 'array', items: schemas.lookupResponse } } },
}, async () => db.sites.list());
fastify.get('/:id', {
schema: { params: schemas.idParam, response: { 200: schemas.lookupResponse } },
}, async (req) => {
const row = db.sites.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('site not found');
return row;
});
fastify.post('/', {
schema: { body: schemas.lookupBody, response: { 201: schemas.lookupResponse } },
}, async (req, reply) => {
try {
const row = db.sites.create(req.body.name);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, { uniqueMessage: 'a site with that name already exists' });
}
});
fastify.put('/:id', {
schema: { params: schemas.idParam, body: schemas.lookupBody, response: { 200: schemas.lookupResponse } },
}, async (req) => {
try {
const row = db.sites.update(req.params.id, req.body.name);
if (!row) throw fastify.httpErrors.notFound('site not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { uniqueMessage: 'a site with that name already exists' });
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
try {
const removed = db.sites.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('site not found');
reply.code(204);
return null;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: rooms still reference this site' });
}
});
}
+140
View File
@@ -0,0 +1,140 @@
const idParam = {
type: 'object',
required: ['id'],
properties: { id: { type: 'integer', minimum: 1 } },
};
const errorResponse = {
type: 'object',
required: ['error'],
properties: {
error: { type: 'string' },
details: { type: 'array', items: { type: 'string' } },
},
};
const name = { type: 'string', minLength: 1, maxLength: 100 };
const lookupResponse = {
type: 'object',
properties: { id: { type: 'integer' }, name: { type: 'string' } },
};
const roomResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
site_id: { type: 'integer' },
site_name: { type: 'string' },
name: { type: 'string' },
},
};
const hostResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
hardware_id: { type: 'string' },
hostname: { type: 'string' },
asset_id: { type: 'string' },
position: { type: 'string' },
site_id: { type: 'integer' },
site_name: { type: 'string' },
room_id: { type: 'integer' },
room_name: { type: 'string' },
server_type_id: { type: 'integer' },
server_type: { type: 'string' },
created_at: { type: 'string' },
updated_at: { type: 'string' },
},
};
const hostBody = {
type: 'object',
required: ['hardware_id', 'hostname', 'asset_id', 'room_id', 'server_type_id'],
additionalProperties: false,
properties: {
hardware_id: { type: 'string', minLength: 1, maxLength: 100 },
hostname: { type: 'string', minLength: 1, maxLength: 253 },
asset_id: { type: 'string', minLength: 1, maxLength: 100 },
room_id: { type: 'integer', minimum: 1 },
position: { type: 'string', maxLength: 100, default: '' },
server_type_id: { type: 'integer', minimum: 1 },
},
};
const roomBody = {
type: 'object',
required: ['site_id', 'name'],
additionalProperties: false,
properties: {
site_id: { type: 'integer', minimum: 1 },
name,
},
};
const lookupBody = {
type: 'object',
required: ['name'],
additionalProperties: false,
properties: { name },
};
const macPattern = '([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}';
const ipv4Octet = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)';
const ipv4Pattern = `(${ipv4Octet}\\.){3}${ipv4Octet}`;
const cidrPattern = `${ipv4Pattern}/(3[0-2]|[12]?[0-9])`;
const linkPattern = '[0-9]+/(full|half|auto)';
const optionalString = (pattern, maxLength) => ({
type: 'string',
maxLength,
pattern: `^$|^${pattern}$`,
});
const interfaceBody = {
type: 'object',
required: ['host_id', 'name'],
additionalProperties: false,
properties: {
host_id: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1, maxLength: 50 },
mac_address: optionalString(macPattern, 17),
ip_address: optionalString(ipv4Pattern, 15),
subnet: optionalString(cidrPattern, 18),
link_speed: optionalString(linkPattern, 20),
},
};
const interfaceResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
host_id: { type: 'integer' },
name: { type: 'string' },
mac_address: { type: 'string' },
ip_address: { type: 'string' },
subnet: { type: 'string' },
link_speed: { type: 'string' },
},
};
const interfaceQuery = {
type: 'object',
required: ['host_id'],
properties: { host_id: { type: 'integer', minimum: 1 } },
};
export const schemas = {
idParam,
errorResponse,
hostBody,
hostResponse,
roomBody,
roomResponse,
lookupBody,
lookupResponse,
interfaceBody,
interfaceResponse,
interfaceQuery,
};
+81
View File
@@ -0,0 +1,81 @@
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import fastifyStatic from '@fastify/static';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, seedIfEmpty } from './db.js';
import hostsRoutes from './routes/hosts.js';
import sitesRoutes from './routes/sites.js';
import roomsRoutes from './routes/rooms.js';
import serverTypesRoutes from './routes/server-types.js';
import interfacesRoutes from './routes/interfaces.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = join(__dirname, '../public');
const DEFAULT_DB = join(__dirname, '../data/infrastructure.db');
export async function buildApp(opts = {}) {
const dbPath = opts.dbPath ?? process.env.DB_PATH ?? DEFAULT_DB;
const db = openDb(dbPath);
if (opts.seed !== false) seedIfEmpty(db);
const app = Fastify({
logger: opts.logger ?? false,
});
app.decorate('db', db);
app.addHook('onClose', (instance, done) => {
instance.db.close();
done();
});
await app.register(sensible);
app.setErrorHandler((err, req, reply) => {
if (err.validation) {
const details = err.validation.map((v) => `${v.instancePath || '/'} ${v.message}`);
return reply.code(400).send({ error: 'validation failed', details });
}
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({ error: err.message });
}
req.log?.error(err);
return reply.code(500).send({ error: 'internal server error' });
});
app.setNotFoundHandler(async (req, reply) => {
if (req.url.startsWith('/api/')) {
return reply.code(404).send({ error: 'not found' });
}
return reply.sendFile('index.html');
});
await app.register(async (api) => {
await api.register(hostsRoutes, { prefix: '/hosts' });
await api.register(sitesRoutes, { prefix: '/sites' });
await api.register(roomsRoutes, { prefix: '/rooms' });
await api.register(serverTypesRoutes, { prefix: '/server-types' });
await api.register(interfacesRoutes, { prefix: '/interfaces' });
}, { prefix: '/api' });
await app.register(fastifyStatic, {
root: PUBLIC_DIR,
prefix: '/',
});
return app;
}
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
if (isMain) {
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? '0.0.0.0';
const app = await buildApp({ logger: true });
try {
await app.listen({ port, host });
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
+30
View File
@@ -0,0 +1,30 @@
// node:sqlite throws Errors with `code: 'ERR_SQLITE_ERROR'`, an `errcode` (SQLite
// extended result code) and a human message. We pattern-match the message because
// it's stable across SQLite versions and avoids hardcoding numeric constants.
export function translateSqliteError(err, fastify, ctx = {}) {
const msg = err?.message ?? '';
if (err?.code !== 'ERR_SQLITE_ERROR') throw err;
if (/^UNIQUE constraint failed/.test(msg)) {
const field = extractUniqueField(msg) ?? 'value';
throw fastify.httpErrors.conflict(
ctx.uniqueMessage ?? `${field} already exists`,
);
}
if (/^FOREIGN KEY constraint failed/.test(msg)) {
throw fastify.httpErrors.conflict(
ctx.foreignKeyMessage ?? 'referenced record does not exist or is still in use',
);
}
if (/^CHECK constraint failed/.test(msg) || /NOT NULL constraint failed/.test(msg)) {
throw fastify.httpErrors.badRequest(msg);
}
throw err;
}
function extractUniqueField(message) {
// SQLite says e.g. "UNIQUE constraint failed: hosts.hardware_id"
const m = /UNIQUE constraint failed:\s*\S+\.(\S+)/.exec(message);
return m?.[1];
}
+37
View File
@@ -0,0 +1,37 @@
import { buildApp } from '../src/server.js';
export async function newApp() {
return buildApp({ dbPath: ':memory:', seed: false });
}
export async function seedFixtures(app) {
const site = JSON.parse((await app.inject({
method: 'POST', url: '/api/sites', payload: { name: 'HQ' },
})).body);
const room = JSON.parse((await app.inject({
method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'Main' },
})).body);
const type = JSON.parse((await app.inject({
method: 'POST', url: '/api/server-types', payload: { name: 'Web' },
})).body);
return { site, room, type };
}
export function newHostPayload({ room_id, server_type_id }, suffix = '') {
return {
hardware_id: `HW-${suffix || '1'}`,
hostname: `host-${suffix || '1'}`,
asset_id: `AST-${suffix || '1'}`,
room_id,
position: 'R1-U1',
server_type_id,
};
}
export async function seedHost(app, fx, suffix = '') {
const res = await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, suffix),
});
return JSON.parse(res.body);
}
+128
View File
@@ -0,0 +1,128 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp, seedFixtures, newHostPayload } from './helpers.js';
test('hosts: create, get, search, update, delete', async (t) => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
// Create
const create = await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'A'),
});
assert.equal(create.statusCode, 201);
const created = JSON.parse(create.body);
assert.equal(created.hostname, 'host-A');
assert.equal(created.site_name, 'HQ');
assert.equal(created.room_name, 'Main');
assert.equal(created.server_type, 'Web');
// Get one
const get = await app.inject({ method: 'GET', url: `/api/hosts/${created.id}` });
assert.equal(get.statusCode, 200);
assert.equal(JSON.parse(get.body).hostname, 'host-A');
// 404 on missing
const missing = await app.inject({ method: 'GET', url: '/api/hosts/9999' });
assert.equal(missing.statusCode, 404);
// Lookup by hardware_id
const byHwid = await app.inject({ method: 'GET', url: '/api/hosts/by-hardware-id/HW-A' });
assert.equal(byHwid.statusCode, 200);
assert.equal(JSON.parse(byHwid.body).id, created.id);
// 404 by hardware_id
const missingHwid = await app.inject({ method: 'GET', url: '/api/hosts/by-hardware-id/nope' });
assert.equal(missingHwid.statusCode, 404);
// Search by hostname / hardware_id / asset_id
for (const q of ['host', 'HW-A', 'AST-A']) {
const r = await app.inject({ method: 'GET', url: `/api/hosts?q=${encodeURIComponent(q)}` });
assert.equal(r.statusCode, 200);
const rows = JSON.parse(r.body);
assert.equal(rows.length, 1);
assert.equal(rows[0].hostname, 'host-A');
}
// Search is case-insensitive
const ci = await app.inject({ method: 'GET', url: '/api/hosts?q=HOST-a' });
assert.equal(JSON.parse(ci.body).length, 1);
// Update
const upd = await app.inject({
method: 'PUT', url: `/api/hosts/${created.id}`,
payload: { ...newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'A'), position: 'R5-U10' },
});
assert.equal(upd.statusCode, 200);
assert.equal(JSON.parse(upd.body).position, 'R5-U10');
// Delete
const del = await app.inject({ method: 'DELETE', url: `/api/hosts/${created.id}` });
assert.equal(del.statusCode, 204);
const afterDel = await app.inject({ method: 'GET', url: `/api/hosts/${created.id}` });
assert.equal(afterDel.statusCode, 404);
});
test('hosts: duplicate hardware_id returns 409', async (t) => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const a = await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'X'),
});
assert.equal(a.statusCode, 201);
const dup = await app.inject({
method: 'POST', url: '/api/hosts',
payload: { ...newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, 'Y'), hardware_id: 'HW-X' },
});
assert.equal(dup.statusCode, 409);
assert.match(JSON.parse(dup.body).error, /hardware_id/);
});
test('hosts: invalid body returns 400 with details', async (t) => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({
method: 'POST', url: '/api/hosts',
payload: { hostname: '' },
});
assert.equal(r.statusCode, 400);
const body = JSON.parse(r.body);
assert.equal(body.error, 'validation failed');
assert.ok(Array.isArray(body.details) && body.details.length > 0);
});
test('hosts: missing FK returns 409', async (t) => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({
method: 'POST', url: '/api/hosts',
payload: {
hardware_id: 'HW-1', hostname: 'h', asset_id: 'A',
room_id: 999, position: '', server_type_id: 999,
},
});
assert.equal(r.statusCode, 409);
});
test('hosts: list caps at 200', async (t) => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
for (let i = 0; i < 205; i++) {
await app.inject({
method: 'POST', url: '/api/hosts',
payload: newHostPayload({ room_id: fx.room.id, server_type_id: fx.type.id }, String(i).padStart(4, '0')),
});
}
const r = await app.inject({ method: 'GET', url: '/api/hosts' });
assert.equal(JSON.parse(r.body).length, 200);
});
+176
View File
@@ -0,0 +1,176 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp, seedFixtures, seedHost } from './helpers.js';
const validIface = (host_id, name = 'eth0') => ({
host_id,
name,
mac_address: 'aa:bb:cc:dd:ee:ff',
ip_address: '10.0.0.5',
subnet: '10.0.0.0/24',
link_speed: '1000/full',
});
test('interfaces: create, list, get, update, delete', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'A');
const create = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: validIface(host.id, 'eth0'),
});
assert.equal(create.statusCode, 201);
const iface = JSON.parse(create.body);
assert.equal(iface.name, 'eth0');
assert.equal(iface.host_id, host.id);
assert.equal(iface.ip_address, '10.0.0.5');
const list = await app.inject({
method: 'GET', url: `/api/interfaces?host_id=${host.id}`,
});
assert.equal(list.statusCode, 200);
assert.equal(JSON.parse(list.body).length, 1);
const get = await app.inject({ method: 'GET', url: `/api/interfaces/${iface.id}` });
assert.equal(get.statusCode, 200);
assert.equal(JSON.parse(get.body).name, 'eth0');
const upd = await app.inject({
method: 'PUT', url: `/api/interfaces/${iface.id}`,
payload: { ...validIface(host.id, 'eth0'), ip_address: '10.0.0.99' },
});
assert.equal(upd.statusCode, 200);
assert.equal(JSON.parse(upd.body).ip_address, '10.0.0.99');
const del = await app.inject({ method: 'DELETE', url: `/api/interfaces/${iface.id}` });
assert.equal(del.statusCode, 204);
const after404 = await app.inject({ method: 'GET', url: `/api/interfaces/${iface.id}` });
assert.equal(after404.statusCode, 404);
});
test('interfaces: optional fields may be empty strings', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'B');
const r = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
assert.equal(r.statusCode, 201);
const iface = JSON.parse(r.body);
assert.equal(iface.mac_address, '');
assert.equal(iface.ip_address, '');
assert.equal(iface.subnet, '');
assert.equal(iface.link_speed, '');
});
test('interfaces: duplicate (host_id, name) returns 409', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'C');
const a = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
assert.equal(a.statusCode, 201);
const dup = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
assert.equal(dup.statusCode, 409);
assert.match(JSON.parse(dup.body).error, /already exists/);
});
test('interfaces: same name allowed on different hosts', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const h1 = await seedHost(app, fx, 'D1');
const h2 = await seedHost(app, fx, 'D2');
const a = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: h1.id, name: 'eth0' },
});
const b = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: h2.id, name: 'eth0' },
});
assert.equal(a.statusCode, 201);
assert.equal(b.statusCode, 201);
});
test('interfaces: missing host FK returns 409', async () => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: 9999, name: 'eth0' },
});
assert.equal(r.statusCode, 409);
});
test('interfaces: invalid formats return 400 with details', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'E');
const cases = [
{ mac_address: 'zz:zz:zz:zz:zz:zz' },
{ ip_address: '999.0.0.1' },
{ subnet: '10.0.0.0/99' },
{ link_speed: '1000/weird' },
];
for (const extra of cases) {
const r = await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0', ...extra },
});
assert.equal(r.statusCode, 400, `expected 400 for ${JSON.stringify(extra)}`);
const body = JSON.parse(r.body);
assert.equal(body.error, 'validation failed');
assert.ok(Array.isArray(body.details) && body.details.length > 0);
}
});
test('interfaces: deleting the host cascades', async () => {
const app = await newApp();
after(() => app.close());
const fx = await seedFixtures(app);
const host = await seedHost(app, fx, 'F');
await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth0' },
});
await app.inject({
method: 'POST', url: '/api/interfaces',
payload: { host_id: host.id, name: 'eth1' },
});
const del = await app.inject({ method: 'DELETE', url: `/api/hosts/${host.id}` });
assert.equal(del.statusCode, 204);
const list = await app.inject({
method: 'GET', url: `/api/interfaces?host_id=${host.id}`,
});
assert.equal(list.statusCode, 200);
assert.equal(JSON.parse(list.body).length, 0);
});
test('interfaces: list requires host_id', async () => {
const app = await newApp();
after(() => app.close());
const r = await app.inject({ method: 'GET', url: '/api/interfaces' });
assert.equal(r.statusCode, 400);
});
+57
View File
@@ -0,0 +1,57 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp } from './helpers.js';
test('rooms: CRUD with site filter', async (t) => {
const app = await newApp();
after(() => app.close());
const s1 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S1' } })).body);
const s2 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S2' } })).body);
await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'A' } });
await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'B' } });
await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s2.id, name: 'C' } });
const all = JSON.parse((await app.inject({ method: 'GET', url: '/api/rooms' })).body);
assert.equal(all.length, 3);
const onlyS1 = JSON.parse((await app.inject({ method: 'GET', url: `/api/rooms?site_id=${s1.id}` })).body);
assert.equal(onlyS1.length, 2);
assert.ok(onlyS1.every((r) => r.site_id === s1.id));
});
test('rooms: same name allowed in different sites, blocked in same site', async (t) => {
const app = await newApp();
after(() => app.close());
const s1 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S1' } })).body);
const s2 = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S2' } })).body);
const a = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'X' } });
assert.equal(a.statusCode, 201);
const b = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s2.id, name: 'X' } });
assert.equal(b.statusCode, 201);
const dup = await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: s1.id, name: 'X' } });
assert.equal(dup.statusCode, 409);
});
test('rooms: room with hosts cannot be deleted', async (t) => {
const app = await newApp();
after(() => app.close());
const site = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S' } })).body);
const room = JSON.parse((await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'R' } })).body);
const type = JSON.parse((await app.inject({ method: 'POST', url: '/api/server-types', payload: { name: 'T' } })).body);
await app.inject({
method: 'POST', url: '/api/hosts',
payload: {
hardware_id: 'HW', hostname: 'h', asset_id: 'A',
room_id: room.id, position: '', server_type_id: type.id,
},
});
const r = await app.inject({ method: 'DELETE', url: `/api/rooms/${room.id}` });
assert.equal(r.statusCode, 409);
});
+47
View File
@@ -0,0 +1,47 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp } from './helpers.js';
test('server-types: full CRUD', async (t) => {
const app = await newApp();
after(() => app.close());
const created = await app.inject({
method: 'POST', url: '/api/server-types', payload: { name: 'Database' },
});
assert.equal(created.statusCode, 201);
const t1 = JSON.parse(created.body);
const dup = await app.inject({
method: 'POST', url: '/api/server-types', payload: { name: 'Database' },
});
assert.equal(dup.statusCode, 409);
const upd = await app.inject({
method: 'PUT', url: `/api/server-types/${t1.id}`, payload: { name: 'DB' },
});
assert.equal(JSON.parse(upd.body).name, 'DB');
const del = await app.inject({ method: 'DELETE', url: `/api/server-types/${t1.id}` });
assert.equal(del.statusCode, 204);
});
test('server-types: cannot delete one referenced by a host (409)', async (t) => {
const app = await newApp();
after(() => app.close());
const site = JSON.parse((await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'S' } })).body);
const room = JSON.parse((await app.inject({ method: 'POST', url: '/api/rooms', payload: { site_id: site.id, name: 'R' } })).body);
const type = JSON.parse((await app.inject({ method: 'POST', url: '/api/server-types', payload: { name: 'Web' } })).body);
await app.inject({
method: 'POST', url: '/api/hosts',
payload: {
hardware_id: 'HW', hostname: 'h', asset_id: 'A',
room_id: room.id, position: '', server_type_id: type.id,
},
});
const r = await app.inject({ method: 'DELETE', url: `/api/server-types/${type.id}` });
assert.equal(r.statusCode, 409);
});
+54
View File
@@ -0,0 +1,54 @@
import { test, after } from 'node:test';
import assert from 'node:assert/strict';
import { newApp } from './helpers.js';
test('sites: full CRUD', async (t) => {
const app = await newApp();
after(() => app.close());
const empty = await app.inject({ method: 'GET', url: '/api/sites' });
assert.deepEqual(JSON.parse(empty.body), []);
const created = await app.inject({
method: 'POST', url: '/api/sites', payload: { name: 'DC1' },
});
assert.equal(created.statusCode, 201);
const site = JSON.parse(created.body);
assert.equal(site.name, 'DC1');
const list = await app.inject({ method: 'GET', url: '/api/sites' });
assert.equal(JSON.parse(list.body).length, 1);
const upd = await app.inject({
method: 'PUT', url: `/api/sites/${site.id}`, payload: { name: 'DC2' },
});
assert.equal(upd.statusCode, 200);
assert.equal(JSON.parse(upd.body).name, 'DC2');
const del = await app.inject({ method: 'DELETE', url: `/api/sites/${site.id}` });
assert.equal(del.statusCode, 204);
});
test('sites: duplicate name returns 409', async (t) => {
const app = await newApp();
after(() => app.close());
await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'A' } });
const dup = await app.inject({ method: 'POST', url: '/api/sites', payload: { name: 'A' } });
assert.equal(dup.statusCode, 409);
});
test('sites: cannot delete site that has rooms (409)', async (t) => {
const app = await newApp();
after(() => app.close());
const site = JSON.parse((await app.inject({
method: 'POST', url: '/api/sites', payload: { name: 'S' },
})).body);
await app.inject({
method: 'POST', url: '/api/rooms',
payload: { site_id: site.id, name: 'R' },
});
const r = await app.inject({ method: 'DELETE', url: `/api/sites/${site.id}` });
assert.equal(r.statusCode, 409);
});