Initial commit: Infrastructure host tracking app
build-and-push / build-and-push (push) Successful in 1m26s

Fastify + node:sqlite single-process app with vanilla JS UI for
looking up hosts by hardware ID, hostname, or asset ID. Includes
per-host network interface tracking, sites/rooms/server-types CRUD,
Docker packaging, and a Gitea Actions workflow that runs tests then
builds and pushes to gitea.thewrightserver.net/josh/infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 17:05:50 -04:00
commit f500db971b
26 changed files with 4057 additions and 0 deletions
+253
View File
@@ -0,0 +1,253 @@
import { DatabaseSync } from 'node:sqlite';
import { mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE CHECK(length(name) BETWEEN 1 AND 100)
);
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE RESTRICT,
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 100),
UNIQUE(site_id, name)
);
CREATE TABLE IF NOT EXISTS server_types (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE CHECK(length(name) BETWEEN 1 AND 100)
);
CREATE TABLE IF NOT EXISTS hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hardware_id TEXT NOT NULL UNIQUE,
hostname TEXT NOT NULL UNIQUE,
asset_id TEXT NOT NULL UNIQUE,
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE RESTRICT,
position TEXT NOT NULL DEFAULT '',
server_type_id INTEGER NOT NULL REFERENCES server_types(id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_hosts_hardware_id ON hosts(hardware_id);
CREATE INDEX IF NOT EXISTS idx_hosts_hostname ON hosts(hostname);
CREATE INDEX IF NOT EXISTS idx_hosts_asset_id ON hosts(asset_id);
CREATE TABLE IF NOT EXISTS interfaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK(length(name) BETWEEN 1 AND 50),
mac_address TEXT NOT NULL DEFAULT '' CHECK(length(mac_address) <= 17),
ip_address TEXT NOT NULL DEFAULT '' CHECK(length(ip_address) <= 15),
subnet TEXT NOT NULL DEFAULT '' CHECK(length(subnet) <= 18),
link_speed TEXT NOT NULL DEFAULT '' CHECK(length(link_speed) <= 20),
UNIQUE(host_id, name)
);
CREATE INDEX IF NOT EXISTS idx_interfaces_host ON interfaces(host_id);
`;
const HOST_SELECT = `
SELECT h.id, h.hardware_id, h.hostname, h.asset_id, h.position,
h.created_at, h.updated_at,
s.id AS site_id, s.name AS site_name,
r.id AS room_id, r.name AS room_name,
st.id AS server_type_id, st.name AS server_type
FROM hosts h
JOIN rooms r ON r.id = h.room_id
JOIN sites s ON s.id = r.site_id
JOIN server_types st ON st.id = h.server_type_id
`;
export function openDb(path) {
if (path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true });
}
const db = new DatabaseSync(path);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA synchronous = NORMAL');
db.exec(SCHEMA);
return makeApi(db);
}
function makeApi(db) {
const stmts = {
siteList: db.prepare('SELECT * FROM sites ORDER BY name'),
siteGet: db.prepare('SELECT * FROM sites WHERE id = ?'),
siteInsert: db.prepare('INSERT INTO sites (name) VALUES (?)'),
siteUpdate: db.prepare('UPDATE sites SET name = ? WHERE id = ?'),
siteDelete: db.prepare('DELETE FROM sites WHERE id = ?'),
roomList: db.prepare(
`SELECT r.*, s.name AS site_name FROM rooms r
JOIN sites s ON s.id = r.site_id ORDER BY s.name, r.name`,
),
roomListBySite: db.prepare(
`SELECT r.*, s.name AS site_name FROM rooms r
JOIN sites s ON s.id = r.site_id WHERE r.site_id = ? ORDER BY r.name`,
),
roomGet: db.prepare(
`SELECT r.*, s.name AS site_name FROM rooms r
JOIN sites s ON s.id = r.site_id WHERE r.id = ?`,
),
roomInsert: db.prepare('INSERT INTO rooms (site_id, name) VALUES (?, ?)'),
roomUpdate: db.prepare('UPDATE rooms SET site_id = ?, name = ? WHERE id = ?'),
roomDelete: db.prepare('DELETE FROM rooms WHERE id = ?'),
typeList: db.prepare('SELECT * FROM server_types ORDER BY name'),
typeGet: db.prepare('SELECT * FROM server_types WHERE id = ?'),
typeInsert: db.prepare('INSERT INTO server_types (name) VALUES (?)'),
typeUpdate: db.prepare('UPDATE server_types SET name = ? WHERE id = ?'),
typeDelete: db.prepare('DELETE FROM server_types WHERE id = ?'),
hostListAll: db.prepare(`${HOST_SELECT} ORDER BY h.hostname LIMIT 200`),
hostSearch: db.prepare(
`${HOST_SELECT}
WHERE LOWER(h.hardware_id) LIKE :q
OR LOWER(h.hostname) LIKE :q
OR LOWER(h.asset_id) LIKE :q
ORDER BY h.hostname LIMIT 200`,
),
hostGet: db.prepare(`${HOST_SELECT} WHERE h.id = ?`),
hostGetByHwid: db.prepare(`${HOST_SELECT} WHERE h.hardware_id = ?`),
hostInsert: db.prepare(
`INSERT INTO hosts (hardware_id, hostname, asset_id, room_id, position, server_type_id)
VALUES (?, ?, ?, ?, ?, ?)`,
),
hostUpdate: db.prepare(
`UPDATE hosts SET hardware_id = ?, hostname = ?, asset_id = ?,
room_id = ?, position = ?, server_type_id = ?,
updated_at = datetime('now')
WHERE id = ?`,
),
hostDelete: db.prepare('DELETE FROM hosts WHERE id = ?'),
ifaceListByHost: db.prepare(
'SELECT * FROM interfaces WHERE host_id = ? ORDER BY name',
),
ifaceGet: db.prepare('SELECT * FROM interfaces WHERE id = ?'),
ifaceInsert: db.prepare(
`INSERT INTO interfaces (host_id, name, mac_address, ip_address, subnet, link_speed)
VALUES (?, ?, ?, ?, ?, ?)`,
),
ifaceUpdate: db.prepare(
`UPDATE interfaces SET host_id = ?, name = ?, mac_address = ?,
ip_address = ?, subnet = ?, link_speed = ?
WHERE id = ?`,
),
ifaceDelete: db.prepare('DELETE FROM interfaces WHERE id = ?'),
};
return {
raw: db,
close: () => db.close(),
sites: {
list: () => stmts.siteList.all(),
get: (id) => stmts.siteGet.get(id),
create: (name) => {
const { lastInsertRowid } = stmts.siteInsert.run(name);
return stmts.siteGet.get(lastInsertRowid);
},
update: (id, name) => {
const r = stmts.siteUpdate.run(name, id);
return r.changes ? stmts.siteGet.get(id) : null;
},
delete: (id) => stmts.siteDelete.run(id).changes > 0,
},
rooms: {
list: (siteId) => siteId
? stmts.roomListBySite.all(siteId)
: stmts.roomList.all(),
get: (id) => stmts.roomGet.get(id),
create: (siteId, name) => {
const { lastInsertRowid } = stmts.roomInsert.run(siteId, name);
return stmts.roomGet.get(lastInsertRowid);
},
update: (id, siteId, name) => {
const r = stmts.roomUpdate.run(siteId, name, id);
return r.changes ? stmts.roomGet.get(id) : null;
},
delete: (id) => stmts.roomDelete.run(id).changes > 0,
},
serverTypes: {
list: () => stmts.typeList.all(),
get: (id) => stmts.typeGet.get(id),
create: (name) => {
const { lastInsertRowid } = stmts.typeInsert.run(name);
return stmts.typeGet.get(lastInsertRowid);
},
update: (id, name) => {
const r = stmts.typeUpdate.run(name, id);
return r.changes ? stmts.typeGet.get(id) : null;
},
delete: (id) => stmts.typeDelete.run(id).changes > 0,
},
hosts: {
list: () => stmts.hostListAll.all(),
search: (q) => {
const term = `%${q.toLowerCase()}%`;
return stmts.hostSearch.all({ q: term });
},
get: (id) => stmts.hostGet.get(id),
getByHardwareId: (hwid) => stmts.hostGetByHwid.get(hwid),
create: (h) => {
const { lastInsertRowid } = stmts.hostInsert.run(
h.hardware_id, h.hostname, h.asset_id,
h.room_id, h.position ?? '', h.server_type_id,
);
return stmts.hostGet.get(lastInsertRowid);
},
update: (id, h) => {
const r = stmts.hostUpdate.run(
h.hardware_id, h.hostname, h.asset_id,
h.room_id, h.position ?? '', h.server_type_id,
id,
);
return r.changes ? stmts.hostGet.get(id) : null;
},
delete: (id) => stmts.hostDelete.run(id).changes > 0,
},
interfaces: {
listByHost: (hostId) => stmts.ifaceListByHost.all(hostId),
get: (id) => stmts.ifaceGet.get(id),
create: (i) => {
const { lastInsertRowid } = stmts.ifaceInsert.run(
i.host_id, i.name,
i.mac_address ?? '', i.ip_address ?? '',
i.subnet ?? '', i.link_speed ?? '',
);
return stmts.ifaceGet.get(lastInsertRowid);
},
update: (id, i) => {
const r = stmts.ifaceUpdate.run(
i.host_id, i.name,
i.mac_address ?? '', i.ip_address ?? '',
i.subnet ?? '', i.link_speed ?? '',
id,
);
return r.changes ? stmts.ifaceGet.get(id) : null;
},
delete: (id) => stmts.ifaceDelete.run(id).changes > 0,
},
};
}
export function seedIfEmpty(db) {
const { count } = db.raw.prepare('SELECT COUNT(*) AS count FROM sites').get();
if (count > 0) return;
const site = db.sites.create('HQ');
db.rooms.create(site.id, 'Server Room A');
for (const t of ['Web', 'Database', 'Application', 'Storage', 'Network']) {
db.serverTypes.create(t);
}
}
+88
View File
@@ -0,0 +1,88 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function hostsRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: {
querystring: {
type: 'object',
properties: { q: { type: 'string' } },
},
response: {
200: { type: 'array', items: schemas.hostResponse },
},
},
}, async (req) => {
const q = (req.query.q ?? '').trim();
return q ? db.hosts.search(q) : db.hosts.list();
});
fastify.get('/by-hardware-id/:hardwareId', {
schema: {
params: {
type: 'object',
required: ['hardwareId'],
properties: { hardwareId: { type: 'string', minLength: 1 } },
},
response: { 200: schemas.hostResponse, 404: schemas.errorResponse },
},
}, async (req) => {
const host = db.hosts.getByHardwareId(req.params.hardwareId);
if (!host) throw fastify.httpErrors.notFound('host not found');
return host;
});
fastify.get('/:id', {
schema: {
params: schemas.idParam,
response: { 200: schemas.hostResponse, 404: schemas.errorResponse },
},
}, async (req) => {
const host = db.hosts.get(req.params.id);
if (!host) throw fastify.httpErrors.notFound('host not found');
return host;
});
fastify.post('/', {
schema: {
body: schemas.hostBody,
response: { 201: schemas.hostResponse },
},
}, async (req, reply) => {
try {
const host = db.hosts.create(req.body);
reply.code(201);
return host;
} catch (err) {
translateSqliteError(err, fastify);
}
});
fastify.put('/:id', {
schema: {
params: schemas.idParam,
body: schemas.hostBody,
response: { 200: schemas.hostResponse, 404: schemas.errorResponse },
},
}, async (req) => {
try {
const host = db.hosts.update(req.params.id, req.body);
if (!host) throw fastify.httpErrors.notFound('host not found');
return host;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify);
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
const removed = db.hosts.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('host not found');
reply.code(204);
return null;
});
}
+71
View File
@@ -0,0 +1,71 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function interfacesRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: {
querystring: schemas.interfaceQuery,
response: { 200: { type: 'array', items: schemas.interfaceResponse } },
},
}, async (req) => db.interfaces.listByHost(req.query.host_id));
fastify.get('/:id', {
schema: {
params: schemas.idParam,
response: { 200: schemas.interfaceResponse, 404: schemas.errorResponse },
},
}, async (req) => {
const row = db.interfaces.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('interface not found');
return row;
});
fastify.post('/', {
schema: {
body: schemas.interfaceBody,
response: { 201: schemas.interfaceResponse },
},
}, async (req, reply) => {
try {
const row = db.interfaces.create(req.body);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, {
uniqueMessage: 'an interface with that name already exists on this host',
foreignKeyMessage: 'host does not exist',
});
}
});
fastify.put('/:id', {
schema: {
params: schemas.idParam,
body: schemas.interfaceBody,
response: { 200: schemas.interfaceResponse, 404: schemas.errorResponse },
},
}, async (req) => {
try {
const row = db.interfaces.update(req.params.id, req.body);
if (!row) throw fastify.httpErrors.notFound('interface not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, {
uniqueMessage: 'an interface with that name already exists on this host',
foreignKeyMessage: 'host does not exist',
});
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
const removed = db.interfaces.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('interface not found');
reply.code(204);
return null;
});
}
+69
View File
@@ -0,0 +1,69 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function roomsRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: {
querystring: {
type: 'object',
properties: { site_id: { type: 'integer', minimum: 1 } },
},
response: { 200: { type: 'array', items: schemas.roomResponse } },
},
}, async (req) => db.rooms.list(req.query.site_id));
fastify.get('/:id', {
schema: { params: schemas.idParam, response: { 200: schemas.roomResponse } },
}, async (req) => {
const row = db.rooms.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('room not found');
return row;
});
fastify.post('/', {
schema: { body: schemas.roomBody, response: { 201: schemas.roomResponse } },
}, async (req, reply) => {
try {
const row = db.rooms.create(req.body.site_id, req.body.name);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, {
uniqueMessage: 'a room with that name already exists at this site',
foreignKeyMessage: 'site does not exist',
});
}
});
fastify.put('/:id', {
schema: { params: schemas.idParam, body: schemas.roomBody, response: { 200: schemas.roomResponse } },
}, async (req) => {
try {
const row = db.rooms.update(req.params.id, req.body.site_id, req.body.name);
if (!row) throw fastify.httpErrors.notFound('room not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, {
uniqueMessage: 'a room with that name already exists at this site',
foreignKeyMessage: 'site does not exist',
});
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
try {
const removed = db.rooms.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('room not found');
reply.code(204);
return null;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: hosts still reference this room' });
}
});
}
+57
View File
@@ -0,0 +1,57 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function serverTypesRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: { response: { 200: { type: 'array', items: schemas.lookupResponse } } },
}, async () => db.serverTypes.list());
fastify.get('/:id', {
schema: { params: schemas.idParam, response: { 200: schemas.lookupResponse } },
}, async (req) => {
const row = db.serverTypes.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('server type not found');
return row;
});
fastify.post('/', {
schema: { body: schemas.lookupBody, response: { 201: schemas.lookupResponse } },
}, async (req, reply) => {
try {
const row = db.serverTypes.create(req.body.name);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, { uniqueMessage: 'a server type with that name already exists' });
}
});
fastify.put('/:id', {
schema: { params: schemas.idParam, body: schemas.lookupBody, response: { 200: schemas.lookupResponse } },
}, async (req) => {
try {
const row = db.serverTypes.update(req.params.id, req.body.name);
if (!row) throw fastify.httpErrors.notFound('server type not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { uniqueMessage: 'a server type with that name already exists' });
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
try {
const removed = db.serverTypes.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('server type not found');
reply.code(204);
return null;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: hosts still reference this server type' });
}
});
}
+57
View File
@@ -0,0 +1,57 @@
import { schemas } from '../schemas.js';
import { translateSqliteError } from '../sqlite-errors.js';
export default async function sitesRoutes(fastify) {
const { db } = fastify;
fastify.get('/', {
schema: { response: { 200: { type: 'array', items: schemas.lookupResponse } } },
}, async () => db.sites.list());
fastify.get('/:id', {
schema: { params: schemas.idParam, response: { 200: schemas.lookupResponse } },
}, async (req) => {
const row = db.sites.get(req.params.id);
if (!row) throw fastify.httpErrors.notFound('site not found');
return row;
});
fastify.post('/', {
schema: { body: schemas.lookupBody, response: { 201: schemas.lookupResponse } },
}, async (req, reply) => {
try {
const row = db.sites.create(req.body.name);
reply.code(201);
return row;
} catch (err) {
translateSqliteError(err, fastify, { uniqueMessage: 'a site with that name already exists' });
}
});
fastify.put('/:id', {
schema: { params: schemas.idParam, body: schemas.lookupBody, response: { 200: schemas.lookupResponse } },
}, async (req) => {
try {
const row = db.sites.update(req.params.id, req.body.name);
if (!row) throw fastify.httpErrors.notFound('site not found');
return row;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { uniqueMessage: 'a site with that name already exists' });
}
});
fastify.delete('/:id', {
schema: { params: schemas.idParam },
}, async (req, reply) => {
try {
const removed = db.sites.delete(req.params.id);
if (!removed) throw fastify.httpErrors.notFound('site not found');
reply.code(204);
return null;
} catch (err) {
if (err.statusCode) throw err;
translateSqliteError(err, fastify, { foreignKeyMessage: 'cannot delete: rooms still reference this site' });
}
});
}
+140
View File
@@ -0,0 +1,140 @@
const idParam = {
type: 'object',
required: ['id'],
properties: { id: { type: 'integer', minimum: 1 } },
};
const errorResponse = {
type: 'object',
required: ['error'],
properties: {
error: { type: 'string' },
details: { type: 'array', items: { type: 'string' } },
},
};
const name = { type: 'string', minLength: 1, maxLength: 100 };
const lookupResponse = {
type: 'object',
properties: { id: { type: 'integer' }, name: { type: 'string' } },
};
const roomResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
site_id: { type: 'integer' },
site_name: { type: 'string' },
name: { type: 'string' },
},
};
const hostResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
hardware_id: { type: 'string' },
hostname: { type: 'string' },
asset_id: { type: 'string' },
position: { type: 'string' },
site_id: { type: 'integer' },
site_name: { type: 'string' },
room_id: { type: 'integer' },
room_name: { type: 'string' },
server_type_id: { type: 'integer' },
server_type: { type: 'string' },
created_at: { type: 'string' },
updated_at: { type: 'string' },
},
};
const hostBody = {
type: 'object',
required: ['hardware_id', 'hostname', 'asset_id', 'room_id', 'server_type_id'],
additionalProperties: false,
properties: {
hardware_id: { type: 'string', minLength: 1, maxLength: 100 },
hostname: { type: 'string', minLength: 1, maxLength: 253 },
asset_id: { type: 'string', minLength: 1, maxLength: 100 },
room_id: { type: 'integer', minimum: 1 },
position: { type: 'string', maxLength: 100, default: '' },
server_type_id: { type: 'integer', minimum: 1 },
},
};
const roomBody = {
type: 'object',
required: ['site_id', 'name'],
additionalProperties: false,
properties: {
site_id: { type: 'integer', minimum: 1 },
name,
},
};
const lookupBody = {
type: 'object',
required: ['name'],
additionalProperties: false,
properties: { name },
};
const macPattern = '([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}';
const ipv4Octet = '(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)';
const ipv4Pattern = `(${ipv4Octet}\\.){3}${ipv4Octet}`;
const cidrPattern = `${ipv4Pattern}/(3[0-2]|[12]?[0-9])`;
const linkPattern = '[0-9]+/(full|half|auto)';
const optionalString = (pattern, maxLength) => ({
type: 'string',
maxLength,
pattern: `^$|^${pattern}$`,
});
const interfaceBody = {
type: 'object',
required: ['host_id', 'name'],
additionalProperties: false,
properties: {
host_id: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1, maxLength: 50 },
mac_address: optionalString(macPattern, 17),
ip_address: optionalString(ipv4Pattern, 15),
subnet: optionalString(cidrPattern, 18),
link_speed: optionalString(linkPattern, 20),
},
};
const interfaceResponse = {
type: 'object',
properties: {
id: { type: 'integer' },
host_id: { type: 'integer' },
name: { type: 'string' },
mac_address: { type: 'string' },
ip_address: { type: 'string' },
subnet: { type: 'string' },
link_speed: { type: 'string' },
},
};
const interfaceQuery = {
type: 'object',
required: ['host_id'],
properties: { host_id: { type: 'integer', minimum: 1 } },
};
export const schemas = {
idParam,
errorResponse,
hostBody,
hostResponse,
roomBody,
roomResponse,
lookupBody,
lookupResponse,
interfaceBody,
interfaceResponse,
interfaceQuery,
};
+81
View File
@@ -0,0 +1,81 @@
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import fastifyStatic from '@fastify/static';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, seedIfEmpty } from './db.js';
import hostsRoutes from './routes/hosts.js';
import sitesRoutes from './routes/sites.js';
import roomsRoutes from './routes/rooms.js';
import serverTypesRoutes from './routes/server-types.js';
import interfacesRoutes from './routes/interfaces.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = join(__dirname, '../public');
const DEFAULT_DB = join(__dirname, '../data/infrastructure.db');
export async function buildApp(opts = {}) {
const dbPath = opts.dbPath ?? process.env.DB_PATH ?? DEFAULT_DB;
const db = openDb(dbPath);
if (opts.seed !== false) seedIfEmpty(db);
const app = Fastify({
logger: opts.logger ?? false,
});
app.decorate('db', db);
app.addHook('onClose', (instance, done) => {
instance.db.close();
done();
});
await app.register(sensible);
app.setErrorHandler((err, req, reply) => {
if (err.validation) {
const details = err.validation.map((v) => `${v.instancePath || '/'} ${v.message}`);
return reply.code(400).send({ error: 'validation failed', details });
}
if (err.statusCode && err.statusCode < 500) {
return reply.code(err.statusCode).send({ error: err.message });
}
req.log?.error(err);
return reply.code(500).send({ error: 'internal server error' });
});
app.setNotFoundHandler(async (req, reply) => {
if (req.url.startsWith('/api/')) {
return reply.code(404).send({ error: 'not found' });
}
return reply.sendFile('index.html');
});
await app.register(async (api) => {
await api.register(hostsRoutes, { prefix: '/hosts' });
await api.register(sitesRoutes, { prefix: '/sites' });
await api.register(roomsRoutes, { prefix: '/rooms' });
await api.register(serverTypesRoutes, { prefix: '/server-types' });
await api.register(interfacesRoutes, { prefix: '/interfaces' });
}, { prefix: '/api' });
await app.register(fastifyStatic, {
root: PUBLIC_DIR,
prefix: '/',
});
return app;
}
const isMain = import.meta.url === pathToFileURL(process.argv[1] ?? '').href;
if (isMain) {
const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? '0.0.0.0';
const app = await buildApp({ logger: true });
try {
await app.listen({ port, host });
} catch (err) {
app.log.error(err);
process.exit(1);
}
}
+30
View File
@@ -0,0 +1,30 @@
// node:sqlite throws Errors with `code: 'ERR_SQLITE_ERROR'`, an `errcode` (SQLite
// extended result code) and a human message. We pattern-match the message because
// it's stable across SQLite versions and avoids hardcoding numeric constants.
export function translateSqliteError(err, fastify, ctx = {}) {
const msg = err?.message ?? '';
if (err?.code !== 'ERR_SQLITE_ERROR') throw err;
if (/^UNIQUE constraint failed/.test(msg)) {
const field = extractUniqueField(msg) ?? 'value';
throw fastify.httpErrors.conflict(
ctx.uniqueMessage ?? `${field} already exists`,
);
}
if (/^FOREIGN KEY constraint failed/.test(msg)) {
throw fastify.httpErrors.conflict(
ctx.foreignKeyMessage ?? 'referenced record does not exist or is still in use',
);
}
if (/^CHECK constraint failed/.test(msg) || /NOT NULL constraint failed/.test(msg)) {
throw fastify.httpErrors.badRequest(msg);
}
throw err;
}
function extractUniqueField(message) {
// SQLite says e.g. "UNIQUE constraint failed: hosts.hardware_id"
const m = /UNIQUE constraint failed:\s*\S+\.(\S+)/.exec(message);
return m?.[1];
}