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,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];
|
||||
}
|
||||
Reference in New Issue
Block a user