Files
source-gamepanel/apps/api/src/routes/nodes/index.ts
T
hibna c1adb94abb Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.

conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
2026-08-02 21:08:12 +03:00

282 lines
8.4 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq, and } from 'drizzle-orm';
import { randomBytes } from 'crypto';
import { nodes, allocations, servers, games } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import { createAuditLog } from '../../lib/audit.js';
import {
daemonGetNodeStats,
daemonGetNodeStatus,
type DaemonNodeConnection,
} from '../../lib/daemon.js';
import {
NodeParamSchema,
CreateNodeSchema,
UpdateNodeSchema,
CreateAllocationSchema,
} from './schemas.js';
export default async function nodeRoutes(app: FastifyInstance) {
app.addHook('onRequest', app.authenticate);
// GET /api/organizations/:orgId/nodes
app.get('/', async (request) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'node.read');
const nodeList = await app.db
.select()
.from(nodes)
.where(eq(nodes.organizationId, orgId))
.orderBy(nodes.createdAt);
const total = nodeList.length;
return {
data: nodeList,
meta: {
total,
page: 1,
perPage: total,
totalPages: total === 0 ? 0 : 1,
},
};
});
// POST /api/organizations/:orgId/nodes
app.post('/', { schema: CreateNodeSchema }, async (request, reply) => {
const { orgId } = request.params as { orgId: string };
await requirePermission(request, orgId, 'node.manage');
const body = request.body as {
name: string;
fqdn: string;
daemonPort?: number;
grpcPort?: number;
location?: string;
memoryTotal: number;
diskTotal: number;
memoryOveralloc?: number;
diskOveralloc?: number;
};
const daemonToken = randomBytes(32).toString('hex');
const [node] = await app.db
.insert(nodes)
.values({
organizationId: orgId,
...body,
daemonToken,
})
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.create',
metadata: { nodeId: node!.id, name: body.name },
});
return reply.code(201).send(node);
});
// GET /api/organizations/:orgId/nodes/:nodeId
app.get('/:nodeId', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found');
return node;
});
// PATCH /api/organizations/:orgId/nodes/:nodeId
app.patch(
'/:nodeId',
{ schema: { ...NodeParamSchema, ...UpdateNodeSchema } },
async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const body = request.body as Record<string, unknown>;
const [updated] = await app.db
.update(nodes)
.set({ ...body, updatedAt: new Date() })
.where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)))
.returning();
if (!updated) throw AppError.notFound('Node not found');
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.update',
metadata: { nodeId, ...body },
});
return updated;
},
);
// DELETE /api/organizations/:orgId/nodes/:nodeId
app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found');
await app.db.delete(nodes).where(eq(nodes.id, nodeId));
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'node.delete',
metadata: { nodeId, name: node.name },
});
return reply.code(204).send();
});
// GET /api/organizations/:orgId/nodes/:nodeId/servers
app.get('/:nodeId/servers', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const serverList = await app.db
.select({
id: servers.id,
name: servers.name,
status: servers.status,
memoryLimit: servers.memoryLimit,
cpuLimit: servers.cpuLimit,
gameName: games.name,
})
.from(servers)
.leftJoin(games, eq(servers.gameId, games.id))
.where(and(eq(servers.nodeId, nodeId), eq(servers.organizationId, orgId)));
return { data: serverList };
});
// GET /api/organizations/:orgId/nodes/:nodeId/stats
// Returns real-time stats from daemon when available, with DB fallback.
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const node = await app.db.query.nodes.findFirst({
where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)),
});
if (!node) throw AppError.notFound('Node not found');
const serverList = await app.db
.select({ id: servers.id, status: servers.status })
.from(servers)
.where(eq(servers.nodeId, nodeId));
const totalServers = serverList.length;
let activeServers = serverList.filter((s) => s.status === 'running').length;
let cpuPercent = 0;
let memoryUsed = 0;
let memoryTotal = node.memoryTotal;
let diskUsed = 0;
let diskTotal = node.diskTotal;
let uptime = 0;
const daemonNode: DaemonNodeConnection = {
fqdn: node.fqdn,
grpcPort: node.grpcPort,
daemonToken: node.daemonToken,
};
try {
const [liveStats, liveStatus] = await Promise.all([
daemonGetNodeStats(daemonNode),
daemonGetNodeStatus(daemonNode),
]);
cpuPercent = Number.isFinite(liveStats.cpuPercent)
? Math.max(0, Math.min(100, liveStats.cpuPercent))
: 0;
memoryUsed = Math.max(0, liveStats.memoryUsed);
memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal;
diskUsed = Math.max(0, liveStats.diskUsed);
diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal;
uptime = Math.max(0, liveStatus.uptimeSeconds);
if (Number.isFinite(liveStatus.activeServers)) {
activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers));
}
} catch (error) {
request.log.warn(
{ error, nodeId, orgId },
'Failed to fetch live node stats from daemon, returning fallback values',
);
}
return {
cpuPercent,
memoryUsed,
memoryTotal,
diskUsed,
diskTotal,
activeServers,
totalServers,
uptime,
};
});
// === Allocations ===
// GET /api/organizations/:orgId/nodes/:nodeId/allocations
app.get('/:nodeId/allocations', { schema: NodeParamSchema }, async (request) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.read');
const allocs = await app.db
.select()
.from(allocations)
.where(eq(allocations.nodeId, nodeId))
.orderBy(allocations.port);
return { data: allocs };
});
// POST /api/organizations/:orgId/nodes/:nodeId/allocations
app.post(
'/:nodeId/allocations',
{ schema: { ...NodeParamSchema, ...CreateAllocationSchema } },
async (request, reply) => {
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
await requirePermission(request, orgId, 'node.manage');
const { ip, ports } = request.body as { ip: string; ports: number[] };
const values = ports.map((port) => ({
nodeId,
ip,
port,
}));
const created = await app.db
.insert(allocations)
.values(values)
.onConflictDoNothing()
.returning();
await createAuditLog(app.db, request, {
organizationId: orgId,
action: 'allocation.create',
metadata: { nodeId, ip, ports },
});
return reply.code(201).send({ data: created });
},
);
}