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:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.claude
|
||||
tests
|
||||
*.md
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
+13
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
Generated
+1144
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user