Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+3 -1
View File
@@ -3,5 +3,7 @@ import { parseActivityQuery } from '$lib/activity.js';
import { activityHistory } from '$lib/server/activity.js';
export function GET({ url }: { url: URL }) {
return json(activityHistory(parseActivityQuery(url.searchParams)), { headers: { 'Cache-Control': 'no-store' } });
return json(activityHistory(parseActivityQuery(url.searchParams)), {
headers: { 'Cache-Control': 'no-store' }
});
}
+14 -7
View File
@@ -5,13 +5,20 @@ import { db } from '$lib/server/db/index.js';
export function GET({ request, params }: { request: Request; params: { sha256: string } }) {
const host = authenticateAgent(request);
if (!/^[a-f0-9]{64}$/.test(params.sha256)) error(404, 'File not found.');
const row = db().prepare(`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
const row = db()
.prepare(
`SELECT b.content,b.size FROM file_blobs b WHERE b.sha256=? AND EXISTS (
SELECT 1 FROM file_blob_references r JOIN ec_host_operations o ON o.id=r.owner_id
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`).get(params.sha256, host.id) as
{ content: Buffer; size: number } | undefined;
WHERE r.sha256=b.sha256 AND r.owner_type='operation' AND o.host_id=?)`
)
.get(params.sha256, host.id) as { content: Buffer; size: number } | undefined;
if (!row) error(404, 'File not found.');
return new Response(new Uint8Array(row.content), { headers: {
'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size),
'Cache-Control': 'private, no-store', ETag: `"${params.sha256}"`
} });
return new Response(new Uint8Array(row.content), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(row.size),
'Cache-Control': 'private, no-store',
ETag: `"${params.sha256}"`
}
});
}
+7 -2
View File
@@ -5,8 +5,13 @@ export async function POST({ request }: { request: Request }) {
const host = authenticateAgent(request);
try {
const body = await objectBody(request);
if (typeof body.credential !== 'string') throw new Error('Persist a randomly generated replacement credential before requesting rotation.');
if (typeof body.credential !== 'string')
throw new Error(
'Persist a randomly generated replacement credential before requesting rotation.'
);
rotateCredential(host.id, body.credential);
return json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
} catch (cause) {
return hostApiError(cause);
}
}
@@ -5,10 +5,15 @@ import { objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
/** The uninstall worker retains only this narrowly scoped credential after removing the agent. */
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
requireAgentTransport(request);
const token = request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1] ?? '';
try { return json(receiveDecommissionReceipt(params.id, token, await objectBody(request, 8192)), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) {
if (cause instanceof HostDeletionError) return json({ error: cause.message }, { status: cause.status });
const token =
request.headers.get('authorization')?.match(/^Bearer ([A-Za-z0-9_-]+)$/i)?.[1] ?? '';
try {
return json(receiveDecommissionReceipt(params.id, token, await objectBody(request, 8192)), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
if (cause instanceof HostDeletionError)
return json({ error: cause.message }, { status: cause.status });
throw cause;
}
}
+8 -2
View File
@@ -3,6 +3,12 @@ import { enrollHost } from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireAgentTransport } from '$lib/server/hosts/http.js';
export async function POST({ request }: { request: Request }) {
requireAgentTransport(request);
try { const body = await objectBody(request); return json(enrollHost(body.token, body.inventory), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
try {
const body = await objectBody(request);
return json(enrollHost(body.token, body.inventory), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return hostApiError(cause);
}
}
+15 -4
View File
@@ -1,5 +1,9 @@
import { json } from '@sveltejs/kit';
import { heartbeat, pendingCreationCancellations, shouldDeferPlutoniumPreparation } from '$lib/server/hosts/index.js';
import {
heartbeat,
pendingCreationCancellations,
shouldDeferPlutoniumPreparation
} from '$lib/server/hosts/index.js';
import { authenticateAgent, hostApiError, objectBody } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { reconcileHostLibraryCleanup } from '$lib/server/game-files/index.js';
@@ -16,8 +20,15 @@ export async function POST({ request }: { request: Request }) {
}
reconcileHostPreparation(host.id);
reconcileHostLibraryCleanup(host.id);
return json({ host, cancelledCreations: pendingCreationCancellations(host.id), heartbeatSeconds: 15,
return json({
host,
cancelledCreations: pendingCreationCancellations(host.id),
heartbeatSeconds: 15,
deferPlutoniumPreparation: shouldDeferPlutoniumPreparation(host.id),
observationKeepDays: fleetSettings().observationKeepDays, serverTime: new Date().toISOString() });
} catch (cause) { return hostApiError(cause); }
observationKeepDays: fleetSettings().observationKeepDays,
serverTime: new Date().toISOString()
});
} catch (cause) {
return hostApiError(cause);
}
}
+8 -3
View File
@@ -3,13 +3,18 @@ import { authenticateAgent } from '$lib/server/hosts/http.js';
import { requireImportOwner, setImportManifest } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(requireImportOwner(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
try {
return json(requireImportOwner(authenticateAgent(request).id, params.id));
} catch (cause) {
return gameFileError(cause);
}
}
export async function PUT({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request);
const body = await readGameJson(request, 64 * 1024 * 1024);
return json(setImportManifest(host.id, params.id, body.files));
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
@@ -5,11 +5,17 @@ import { gameFileError } from '$lib/server/game-files/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const result = requestImportPublication(authenticateAgent(request).id, params.id);
return json(result, { status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200 });
return json(result, {
status: result.status === 'verifying' || result.status === 'pending' ? 202 : 200
});
} catch (cause) {
return gameFileError(cause);
}
catch (cause) { return gameFileError(cause); }
}
export function GET({ request, params }: { request: Request; params: { id: string } }) {
try { return json(importPublicationStatus(authenticateAgent(request).id, params.id)); }
catch (cause) { return gameFileError(cause); }
try {
return json(importPublicationStatus(authenticateAgent(request).id, params.id));
} catch (cause) {
return gameFileError(cause);
}
}
@@ -2,10 +2,18 @@ import { json } from '@sveltejs/kit';
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { uploadImportFile } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
export async function PUT({
request,
params
}: {
request: Request;
params: { id: string; path: string };
}) {
try {
const host = authenticateAgent(request);
await uploadImportFile(host.id, params.id, params.path, request.body);
return json({ uploaded: true });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
+5 -1
View File
@@ -1,2 +1,6 @@
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET() { return new Response(await agentReleaseAsset('install.sh'), { headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' } }); }
export async function GET() {
return new Response(await agentReleaseAsset('install.sh'), {
headers: { 'Content-Type': 'text/x-shellscript', 'Cache-Control': 'no-cache' }
});
}
@@ -1,7 +1,17 @@
import { authenticateAgent } from '$lib/server/hosts/http.js';
import { gameFileError, serveGameFile } from '$lib/server/game-files/http.js';
export async function GET({ request, params }: { request: Request; params: { id: string; path: string } }) {
try { authenticateAgent(request); return await serveGameFile(request, params.id, params.path); }
catch (cause) { return gameFileError(cause); }
export async function GET({
request,
params
}: {
request: Request;
params: { id: string; path: string };
}) {
try {
authenticateAgent(request);
return await serveGameFile(request, params.id, params.path);
} catch (cause) {
return gameFileError(cause);
}
}
export const HEAD = GET;
@@ -6,8 +6,18 @@ export function GET({ request, params }: { request: Request; params: { id: strin
try {
authenticateAgent(request);
const version = getLibraryVersion(params.id);
if (!version || version.state !== 'ready') return json({ error: 'Published library not found.' }, { status: 404 });
if (version.filesRemoved || version.filesRemoving) return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
return json({ id: version.id, game: version.game, files: version.files, sizeBytes: version.sizeBytes, manifestSha256: version.manifestSha256 });
} catch (cause) { return gameFileError(cause); }
if (!version || version.state !== 'ready')
return json({ error: 'Published library not found.' }, { status: 404 });
if (version.filesRemoved || version.filesRemoving)
return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
return json({
id: version.id,
game: version.game,
files: version.files,
sizeBytes: version.sizeBytes,
manifestSha256: version.manifestSha256
});
} catch (cause) {
return gameFileError(cause);
}
}
@@ -4,8 +4,11 @@ import { updateHostLibraryCaches } from '$lib/server/game-files/index.js';
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
try {
const host = authenticateAgent(request); const body = await readGameJson(request);
const host = authenticateAgent(request);
const body = await readGameJson(request);
updateHostLibraryCaches(host.id, [{ ...body, libraryVersionId: params.id }]);
return json({ ok: true });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
+8 -1
View File
@@ -4,7 +4,14 @@ import { hostOperationsResponse } from '$lib/server/hosts/delivery.js';
export async function GET({ request }: { request: Request }) {
const host = authenticateAgent(request);
const params = new URL(request.url).searchParams;
const lane = params.get('lane') === 'health' ? 'health' : params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
const lane =
params.get('lane') === 'health'
? 'health'
: params.get('lane') === 'read'
? 'read'
: params.get('lane') === 'write'
? 'write'
: 'all';
let operations = pendingHostOperationRows(host.id, lane, false);
if (!operations.length && params.get('wait') === '20') {
await waitForHostWork(host.id, request.signal);
@@ -6,12 +6,22 @@ export async function POST({ request, params }: { request: Request; params: { id
const host = authenticateAgent(request);
try {
const body = await objectBody(request, 128 * 1024 * 1024);
const operation = reportHostOperationStatus(host.id, params.id, { state: body.state, result: body.result, error: body.error });
if (['completed', 'failed', 'unknown'].includes(operation.state)
&& (operation.kind.startsWith('instance.') && operation.kind !== 'instance.inspect' || operation.kind === 'files.stage')) {
void kickProvisioning().catch(error => console.error('[agent] provisioning wake failed:', error));
const operation = reportHostOperationStatus(host.id, params.id, {
state: body.state,
result: body.result,
error: body.error
});
if (
['completed', 'failed', 'unknown'].includes(operation.state) &&
((operation.kind.startsWith('instance.') && operation.kind !== 'instance.inspect') ||
operation.kind === 'files.stage')
) {
void kickProvisioning().catch((error) =>
console.error('[agent] provisioning wake failed:', error)
);
}
return json({ operation: { id: operation.id, state: operation.state } });
} catch (cause) {
return hostApiError(cause);
}
catch (cause) { return hostApiError(cause); }
}
@@ -4,6 +4,9 @@ import { claimRemovalAuthorization } from '$lib/server/autoscaling/authorization
export async function POST({ request, params }: { request: Request; params: { id: string } }) {
const host = authenticateAgent(request);
try { return json(claimRemovalAuthorization(host.id, params.id, await objectBody(request, 4096))); }
catch (cause) { return hostApiError(cause); }
try {
return json(claimRemovalAuthorization(host.id, params.id, await objectBody(request, 4096)));
} catch (cause) {
return hostApiError(cause);
}
}
+13 -2
View File
@@ -1,6 +1,17 @@
import { error } from '@sveltejs/kit';
import { agentReleaseAsset } from '$lib/server/hosts/releases.js';
export async function GET({ params }: { params: { file: string } }) {
if (!['ec-agent.py','ec-agent-update.py','manifest.json'].includes(params.file)) error(404, 'Release asset not found.');
return new Response(await agentReleaseAsset(params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'), { headers: { 'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain', 'Cache-Control': 'no-cache' } });
if (!['ec-agent.py', 'ec-agent-update.py', 'manifest.json'].includes(params.file))
error(404, 'Release asset not found.');
return new Response(
await agentReleaseAsset(
params.file as 'ec-agent.py' | 'ec-agent-update.py' | 'manifest.json'
),
{
headers: {
'Content-Type': params.file.endsWith('.json') ? 'application/json' : 'text/plain',
'Cache-Control': 'no-cache'
}
}
);
}
+5 -3
View File
@@ -3,8 +3,10 @@ import { authenticatePassword, revokeSession } from '$lib/server/auth/index.js';
import { authBody, authError, sessionCookie, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try {
const result = await authenticatePassword(await authBody(request),getClientAddress());
const result = await authenticatePassword(await authBody(request), getClientAddress());
revokeSession(cookies.get(sessionCookie(url)));
return sessionResponse(result,cookies,url);
} catch (cause) { return authError(cause); }
return sessionResponse(result, cookies, url);
} catch (cause) {
return authError(cause);
}
};
+3 -3
View File
@@ -3,7 +3,7 @@ import { recordAudit, revokeSession } from '$lib/server/auth/index.js';
import { sessionCookie } from '$lib/server/auth/http.js';
export const POST: RequestHandler = ({ cookies, url, locals }) => {
revokeSession(cookies.get(sessionCookie(url)));
cookies.delete(sessionCookie(url),{path:'/'});
recordAudit(locals.user?.id??null,'logout',locals.user?.id??null);
return json({ ok:true });
cookies.delete(sessionCookie(url), { path: '/' });
recordAudit(locals.user?.id ?? null, 'logout', locals.user?.id ?? null);
return json({ ok: true });
};
+2 -1
View File
@@ -1,2 +1,3 @@
import { json, type RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ locals }) => json({ user:locals.user },{headers:{'Cache-Control':'no-store'}});
export const GET: RequestHandler = ({ locals }) =>
json({ user: locals.user }, { headers: { 'Cache-Control': 'no-store' } });
+9 -3
View File
@@ -4,8 +4,14 @@ import { authBody, authError, requireUser, sessionResponse } from '$lib/server/a
export const POST: RequestHandler = async ({ request, cookies, locals, url, getClientAddress }) => {
try {
const user = requireUser(locals.user);
consumeAuthAttempt(getClientAddress(),user.id,'password');
consumeAuthAttempt(getClientAddress(), user.id, 'password');
const body = await authBody(request);
return sessionResponse(await changePassword(user.id,body.currentPassword,body.newPassword),cookies,url);
} catch (cause) { return authError(cause); }
return sessionResponse(
await changePassword(user.id, body.currentPassword, body.newPassword),
cookies,
url
);
} catch (cause) {
return authError(cause);
}
};
+9 -2
View File
@@ -2,6 +2,13 @@ import type { RequestHandler } from './$types';
import { bootstrapAdmin } from '$lib/server/auth/index.js';
import { authBody, authError, sessionResponse } from '$lib/server/auth/http.js';
export const POST: RequestHandler = async ({ request, cookies, url, getClientAddress }) => {
try { return sessionResponse(await bootstrapAdmin(await authBody(request),getClientAddress()),cookies,url); }
catch (cause) { return authError(cause); }
try {
return sessionResponse(
await bootstrapAdmin(await authBody(request), getClientAddress()),
cookies,
url
);
} catch (cause) {
return authError(cause);
}
};
+33 -8
View File
@@ -1,19 +1,44 @@
import { json } from '@sveltejs/kit';
import { saveScaling, deleteScaling, scalingReport, setScalingGlobalPause, ScalingError } from '$lib/server/autoscaling/index.js';
import {
saveScaling,
deleteScaling,
scalingReport,
setScalingGlobalPause,
ScalingError
} from '$lib/server/autoscaling/index.js';
export function GET() { return json(scalingReport()); }
export function GET() {
return json(scalingReport());
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScalingError('Provide a policy action.');
if (body.id !== undefined && typeof body.id !== 'string') throw new ScalingError('Invalid policy identity.');
if (body.action === 'save') return json({ policy: saveScaling(body.settings, body.id, body.revision) });
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new ScalingError('Provide a policy action.');
if (body.id !== undefined && typeof body.id !== 'string')
throw new ScalingError('Invalid policy identity.');
if (body.action === 'save')
return json({ policy: saveScaling(body.settings, body.id, body.revision) });
if (body.action === 'delete') {
if (typeof body.id !== 'string' || !body.id || !Number.isInteger(body.revision) || body.revision < 1) throw new ScalingError('Provide the policy identity and current revision.');
if (
typeof body.id !== 'string' ||
!body.id ||
!Number.isInteger(body.revision) ||
body.revision < 1
)
throw new ScalingError('Provide the policy identity and current revision.');
deleteScaling(body.id, body.revision);
return json({ ok: true });
}
if (body.action === 'global-pause' && typeof body.paused === 'boolean') { setScalingGlobalPause(body.paused); return json({ ok: true }); }
if (body.action === 'global-pause' && typeof body.paused === 'boolean') {
setScalingGlobalPause(body.paused);
return json({ ok: true });
}
throw new ScalingError('Choose a supported policy action.');
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof ScalingError ? error.status : 400 }); }
} catch (error) {
return json(
{ error: (error as Error).message },
{ status: error instanceof ScalingError ? error.status : 400 }
);
}
}
+64 -25
View File
@@ -1,8 +1,5 @@
import { json } from '@sveltejs/kit';
import {
openBeta,
BetaError
} from '$lib/server/fleet/beta.js';
import { openBeta, BetaError } from '$lib/server/fleet/beta.js';
import { betaScriptMetadata } from '$lib/server/fleet/library-metadata.js';
import { db } from '$lib/server/db/index.js';
import type { BetaServerRow } from '$lib/server/fleet/beta.js';
@@ -14,16 +11,29 @@ import { parseBetaInstanceCounts } from '$lib/server/fleet/beta-selection.js';
export async function GET({ url }: { url: URL }) {
const state = url.searchParams.get('state');
if (state && !['running','ready-promote','promoted','rolled-back'].includes(state)) return json({ error: 'Invalid beta state.' }, { status: 400 });
if (state && !['running', 'ready-promote', 'promoted', 'rolled-back'].includes(state))
return json({ error: 'Invalid beta state.' }, { status: 400 });
const { betas, ...pagination } = betaListPage(url.searchParams);
const ids = JSON.stringify(betas.map(beta => beta.id));
const bundles = betaScriptMetadata(betas.map(beta => beta.id));
const plans = new Map((db().prepare(`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
const ids = JSON.stringify(betas.map((beta) => beta.id));
const bundles = betaScriptMetadata(betas.map((beta) => beta.id));
const plans = new Map(
(
db()
.prepare(
`SELECT p.*, COALESCE((SELECT started_at FROM beta_revisions r
WHERE r.beta_id=p.beta_id ORDER BY revision DESC LIMIT 1), b.opened_at) started_at
FROM beta_test_plans p JOIN betas b ON b.id=p.beta_id
WHERE p.beta_id IN (SELECT value FROM json_each(?))`).all(ids) as Array<StoredPlan & { started_at: string }>).map(plan => [plan.beta_id, plan]));
const members = db().prepare('SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server').all(ids) as BetaServerRow[];
const rows = betas.map(b => {
WHERE p.beta_id IN (SELECT value FROM json_each(?))`
)
.all(ids) as Array<StoredPlan & { started_at: string }>
).map((plan) => [plan.beta_id, plan])
);
const members = db()
.prepare(
'SELECT * FROM beta_servers WHERE beta_id IN (SELECT value FROM json_each(?)) ORDER BY server'
)
.all(ids) as BetaServerRow[];
const rows = betas.map((b) => {
const scripts = bundles.get(b.id) ?? [];
const test = plans.get(b.id);
const plan = test ? parseBetaPlan(JSON.parse(test.plan)) : null;
@@ -31,9 +41,11 @@ export async function GET({ url }: { url: URL }) {
...b,
testOutcome: test?.outcome ?? null,
testReason: test?.reason ?? null,
deadlineAt: plan ? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString() : null,
deadlineAt: plan
? new Date(Date.parse(test!.started_at) + plan.timeoutHours * 3600000).toISOString()
: null,
scripts,
servers: members.filter(member => member.beta_id === b.id)
servers: members.filter((member) => member.beta_id === b.id)
};
});
return json({ betas: rows, ...pagination });
@@ -54,15 +66,18 @@ export async function POST({ request }: { request: Request }) {
notes?: string;
testPlan?: BetaPlan;
};
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ ok: false, error: 'Expected a JSON object' }, { status: 400 });
if (!body || typeof body !== 'object' || Array.isArray(body))
return json({ ok: false, error: 'Expected a JSON object' }, { status: 400 });
// Normalize into the openBeta shape. Both fields feed the same list.
const rawEntries: Array<{ scriptId?: number; scriptVersionId?: number }> = [];
if (Array.isArray(body.artifacts)) {
for (const a of body.artifacts) rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
for (const a of body.artifacts)
rawEntries.push({ scriptId: a?.id, scriptVersionId: a?.versionId });
}
if (Array.isArray(body.scripts)) {
for (const s of body.scripts) rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
for (const s of body.scripts)
rawEntries.push({ scriptId: s?.scriptId, scriptVersionId: s?.scriptVersionId });
}
if (rawEntries.length === 0) {
return json(
@@ -71,17 +86,35 @@ export async function POST({ request }: { request: Request }) {
);
}
if ((body.servers !== undefined) === (body.instancesPerGame !== undefined)) {
return json({ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' }, { status: 400 });
return json(
{ ok: false, error: 'Provide either instancesPerGame or servers, exclusively' },
{ status: 400 }
);
}
if (body.instancesPerGame !== undefined) {
try { body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame); }
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
} else if (!Array.isArray(body.servers) || !body.servers.length || !body.servers.every(name => typeof name === 'string' && name.trim())) {
return json({ ok: false, error: 'servers must contain at least one server name' }, { status: 400 });
try {
body.instancesPerGame = parseBetaInstanceCounts(body.instancesPerGame);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
} else if (
!Array.isArray(body.servers) ||
!body.servers.length ||
!body.servers.every((name) => typeof name === 'string' && name.trim())
) {
return json(
{ ok: false, error: 'servers must contain at least one server name' },
{ status: 400 }
);
}
const normalized: Array<{ scriptId: number; scriptVersionId: number }> = [];
for (const s of rawEntries) {
if (!Number.isSafeInteger(s.scriptId) || !Number.isSafeInteger(s.scriptVersionId) || s.scriptId! < 1 || s.scriptVersionId! < 1) {
if (
!Number.isSafeInteger(s.scriptId) ||
!Number.isSafeInteger(s.scriptVersionId) ||
s.scriptId! < 1 ||
s.scriptVersionId! < 1
) {
return json(
{ ok: false, error: 'each entry needs scriptId/id and scriptVersionId/versionId' },
{ status: 400 }
@@ -92,8 +125,11 @@ export async function POST({ request }: { request: Request }) {
try {
let testPlan: BetaPlan | undefined;
try { if (body.testPlan !== undefined) testPlan = parseBetaPlan(body.testPlan); }
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
try {
if (body.testPlan !== undefined) testPlan = parseBetaPlan(body.testPlan);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
const result = await openBeta({
testPlan,
scripts: normalized,
@@ -107,7 +143,10 @@ export async function POST({ request }: { request: Request }) {
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind, details: err.details },
{ status: 409 }
);
}
throw err;
}
+40 -7
View File
@@ -21,12 +21,45 @@ export async function GET({ params }: { params: { id: string } }) {
const servers = listBetaServers(id);
const { events, nextBefore: eventsNextBefore } = betaActivityPage(id);
const campaign = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1").get(id) as { id: string } | undefined;
const promotion = campaign ? { ...campaign,
pending: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')").get(campaign.id) as { n: number }).n,
failed: (db().prepare("SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'").get(campaign.id) as { n: number }).n } : null;
const rollback = db().prepare("SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1")
const campaign = db()
.prepare(
"SELECT id FROM deployments WHERE beta_id = ? AND action = 'promote' ORDER BY started_at DESC LIMIT 1"
)
.get(id) as { id: string } | undefined;
return json({ beta, scripts, servers, events, eventsNextBefore, test: testView(id), promotion, rollback: rollback ?? null,
revision: currentBetaRevision(id), revisions: betaRevisionHistory(id) });
const promotion = campaign
? {
...campaign,
pending: (
db()
.prepare(
"SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state NOT IN ('done','failed','cancelled','skipped')"
)
.get(campaign.id) as { n: number }
).n,
failed: (
db()
.prepare(
"SELECT COUNT(*) n FROM deployment_servers WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state = 'failed'"
)
.get(campaign.id) as { n: number }
).n
}
: null;
const rollback = db()
.prepare(
"SELECT id FROM deployments WHERE beta_id = ? AND action = 'rollback' ORDER BY started_at DESC LIMIT 1"
)
.get(id) as { id: string } | undefined;
return json({
beta,
scripts,
servers,
events,
eventsNextBefore,
test: testView(id),
promotion,
rollback: rollback ?? null,
revision: currentBetaRevision(id),
revisions: betaRevisionHistory(id)
});
}
+2 -1
View File
@@ -4,7 +4,8 @@ import { betaActivityPage } from '$lib/server/fleet/beta-pages.js';
export function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!Number.isSafeInteger(id) || id < 1)
return json({ error: 'Invalid beta ID.' }, { status: 400 });
if (!getBeta(id)) return json({ error: 'Beta not found.' }, { status: 404 });
return json(betaActivityPage(id, url.searchParams));
}
+15 -4
View File
@@ -9,8 +9,12 @@ function parseId(raw: string): number | null {
export async function GET({ params }: { params: { id: string } }) {
const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'Invalid beta ID.' }, { status: 400 });
try { return json({ preview: previewBetaPromotion(id) }, { headers: { 'Cache-Control': 'no-store' } }); }
catch (err) {
try {
return json(
{ preview: previewBetaPromotion(id) },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (err) {
if (err instanceof BetaError) return json({ error: err.message }, { status: 409 });
throw err;
}
@@ -20,13 +24,20 @@ export async function POST({ params, request }: { params: { id: string }; reques
const id = parseId(params.id);
if (id === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const body = await request.json().catch(() => null);
if (typeof body?.previewToken !== 'string') return json({ ok: false, error: 'Review promotion targets before confirming.' }, { status: 400 });
if (typeof body?.previewToken !== 'string')
return json(
{ ok: false, error: 'Review promotion targets before confirming.' },
{ status: 400 }
);
try {
const result = await promoteBeta(id, body.previewToken);
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind, details: err.details },
{ status: 409 }
);
}
throw err;
}
+51 -13
View File
@@ -17,31 +17,67 @@ export async function POST({ params, request }: { params: { id: string }; reques
const betaId = parseId(params.id);
if (betaId === null) return json({ ok: false, error: 'bad id' }, { status: 400 });
const beta = getBeta(betaId);
if (!beta || !['running', 'ready-promote'].includes(beta.state)) return json({ ok: false, error: 'Beta is closed or missing' }, { status: 409 });
if (storedPlan(betaId)) return json({ ok: false, error: 'Use the test plan retry action to collect fresh evidence' }, { status: 409 });
if (!beta || !['running', 'ready-promote'].includes(beta.state))
return json({ ok: false, error: 'Beta is closed or missing' }, { status: 409 });
if (storedPlan(betaId))
return json(
{ ok: false, error: 'Use the test plan retry action to collect fresh evidence' },
{ status: 409 }
);
const body = (await request.json().catch(() => ({}))) as { server?: string };
if (!body.server) return json({ ok: false, error: 'server is required' }, { status: 400 });
const row = db()
.prepare('SELECT stage, deployed_at, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?')
.prepare(
'SELECT stage, deployed_at, restarted_at, healthy_at, server_deleted_at FROM beta_servers WHERE beta_id = ? AND server = ?'
)
.get(betaId, body.server) as
| { stage: string; deployed_at: string | null; restarted_at: string | null; healthy_at: string | null; server_deleted_at: string | null }
| {
stage: string;
deployed_at: string | null;
restarted_at: string | null;
healthy_at: string | null;
server_deleted_at: string | null;
}
| undefined;
if (!row) return json({ ok: false, error: 'no such beta_server' }, { status: 404 });
if (!isActiveBetaServer(betaId, body.server)) return json({ ok: false, error: 'This beta server was deleted; open a new beta for its replacement.' }, { status: 409 });
if (row.stage !== 'failed') return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
if (stagedFileChanges('beta', String(betaId), body.server).some(change => change.state === 'applying')) {
return json({ ok: false, error: 'A file write was interrupted. Inspect the server and roll back before opening a fresh beta.' }, { status: 409 });
if (!isActiveBetaServer(betaId, body.server))
return json(
{
ok: false,
error: 'This beta server was deleted; open a new beta for its replacement.'
},
{ status: 409 }
);
if (row.stage !== 'failed')
return json({ ok: false, error: `stage is ${row.stage}, not failed` }, { status: 409 });
if (
stagedFileChanges('beta', String(betaId), body.server).some(
(change) => change.state === 'applying'
)
) {
return json(
{
ok: false,
error: 'A file write was interrupted. Inspect the server and roll back before opening a fresh beta.'
},
{ status: 409 }
);
}
// Preparation may have failed before any bytes were queued (for example,
// an overlay without a baseline). Retrying must prepare its payload first.
if (!row.deployed_at && !row.restarted_at) {
try { await deployBetaServers(betaId, [body.server]); }
catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 409 }); }
const prepared = db().prepare('SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?')
try {
await deployBetaServers(betaId, [body.server]);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 409 });
}
const prepared = db()
.prepare('SELECT stage, error FROM beta_servers WHERE beta_id = ? AND server = ?')
.get(betaId, body.server) as { stage: string; error: string | null };
if (prepared.stage === 'failed') return json({ ok: false, error: prepared.error }, { status: 409 });
if (prepared.stage === 'failed')
return json({ ok: false, error: prepared.error }, { status: 409 });
}
// Best-effort resume point: if we already got past restart, go back to
@@ -50,7 +86,9 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (!row.restarted_at) releaseRestartRequest(body.server, { kind: 'beta', id: String(betaId) });
const now = new Date().toISOString();
db()
.prepare('UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?')
.prepare(
'UPDATE beta_servers SET stage = ?, error = NULL, updated_at = ? WHERE beta_id = ? AND server = ?'
)
.run(nextStage, now, betaId, body.server);
emit({ type: 'beta-server-stage', betaId, server: body.server, stage: nextStage, error: null });
return json({ ok: true, stage: nextStage });
+23 -8
View File
@@ -5,24 +5,39 @@ import { kick } from '$lib/server/pipeline/worker.js';
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
if (!Number.isSafeInteger(id) || id < 1)
return json({ ok: false, error: 'Invalid beta ID' }, { status: 400 });
try {
const form = await request.formData();
const expectedRevision = Number(form.get('expectedRevision'));
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) throw new Error('A reviewed revision is required.');
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1)
throw new Error('A reviewed revision is required.');
const uploads: BetaRevisionUpload[] = [];
for (const [key, value] of form) {
if (!key.startsWith('file:')) continue;
const scriptId = Number(key.slice(5));
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File)) throw new Error('Invalid replacement file.');
uploads.push({ scriptId, content: Buffer.from(await value.arrayBuffer()),
releaseVersion: form.has(`release:${scriptId}`) ? String(form.get(`release:${scriptId}`)) : undefined });
if (!Number.isSafeInteger(scriptId) || scriptId < 1 || !(value instanceof File))
throw new Error('Invalid replacement file.');
uploads.push({
scriptId,
content: Buffer.from(await value.arrayBuffer()),
releaseVersion: form.has(`release:${scriptId}`)
? String(form.get(`release:${scriptId}`))
: undefined
});
}
const result = await reviseBeta(id, expectedRevision, uploads, String(form.get('notes') ?? ''));
const result = await reviseBeta(
id,
expectedRevision,
uploads,
String(form.get('notes') ?? '')
);
void kick();
return json({ ok: true, result });
} catch (error) {
return json({ ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 });
return json(
{ ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 }
);
}
}
@@ -2,16 +2,35 @@ import { blobContent } from '$lib/server/db/file-blobs.js';
import { db } from '$lib/server/db/index.js';
/** Current files can be reviewed before the first revision is archived. */
export async function GET({ params }: { params: { id: string; revision: string; scriptId: string } }) {
export async function GET({
params
}: {
params: { id: string; revision: string; scriptId: string };
}) {
const values = [params.id, params.revision, params.scriptId].map(Number);
if (values.some(value => !Number.isSafeInteger(value) || value < 1)) return new Response('Invalid file', { status: 400 });
if (values.some((value) => !Number.isSafeInteger(value) || value < 1))
return new Response('Invalid file', { status: 400 });
const [id, revision, scriptId] = values;
const file = db().prepare(`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`)
.get(id, revision, scriptId) as { content: Buffer } | undefined
?? (revision === 1 ? db().prepare(`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`)
.get(id, scriptId, id) as { content: Buffer } | undefined : undefined);
const file =
(db()
.prepare(
`SELECT ${blobContent('content_hash')} AS content FROM beta_revision_files WHERE beta_id = ? AND revision = ? AND script_id = ?`
)
.get(id, revision, scriptId) as { content: Buffer } | undefined) ??
(revision === 1
? (db()
.prepare(
`SELECT ${blobContent('v.content_hash')} AS content FROM beta_scripts bs JOIN script_versions v ON v.id = bs.script_version_id
WHERE bs.beta_id = ? AND bs.script_id = ? AND NOT EXISTS (SELECT 1 FROM beta_revisions WHERE beta_id = ?)`
)
.get(id, scriptId, id) as { content: Buffer } | undefined)
: undefined);
if (!file?.content) return new Response('File not found', { status: 404 });
return new Response(new Uint8Array(file.content), { headers: { 'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment', 'Cache-Control': 'no-store' } });
return new Response(new Uint8Array(file.content), {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Disposition': 'attachment',
'Cache-Control': 'no-store'
}
});
}
@@ -24,7 +24,10 @@ export async function POST({ params }: { params: { id: string } }) {
return json({ ok: true, result });
} catch (err) {
if (err instanceof BetaError) {
return json({ ok: false, error: err.message, kind: err.kind, details: err.details }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind, details: err.details },
{ status: 409 }
);
}
throw err;
}
+99 -26
View File
@@ -3,78 +3,151 @@ import { releaseRestartRequest } from '$lib/server/provisioning/index.js';
import { db } from '$lib/server/db/index.js';
import { deployBetaServers, getBeta } from '$lib/server/fleet/beta.js';
import { stagedFileChanges } from '$lib/server/fleet/staged-files.js';
import { activity, currentRun, evidence, readMetrics, saveMetrics, setOutcome, storedPlan } from '$lib/server/fleet/beta-tests.js';
import {
activity,
currentRun,
evidence,
readMetrics,
saveMetrics,
setOutcome,
storedPlan
} from '$lib/server/fleet/beta-tests.js';
import { betaState, plannedBetaTick } from '$lib/server/pipeline/beta-plan-runner.js';
import { parseBetaPlan } from '$lib/beta-plan.js';
import { kick } from '$lib/server/pipeline/worker.js';
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid beta ID' }, { status: 400 });
if (!Number.isSafeInteger(id) || id < 1)
return json({ error: 'Invalid beta ID' }, { status: 400 });
await plannedBetaTick();
const beta = getBeta(id);
const stored = storedPlan(id);
if (!beta || !stored) return json({ error: 'Test plan not found' }, { status: 404 });
if (!['running', 'ready-promote'].includes(beta.state)) return json({ error: 'Beta is closed' }, { status: 409 });
if (!['running', 'ready-promote'].includes(beta.state))
return json({ error: 'Beta is closed' }, { status: 409 });
try {
const body = await request.json();
if (db().prepare('SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL').get(id)) {
return json({ ok: false, error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.' }, { status: 409 });
if (
db()
.prepare(
'SELECT 1 FROM beta_servers WHERE beta_id = ? AND server_deleted_at IS NOT NULL'
)
.get(id)
) {
return json(
{
ok: false,
error: 'A beta server was deleted. Roll back this beta and open a new one to collect fresh evidence.'
},
{ status: 409 }
);
}
const plan = parseBetaPlan(JSON.parse(stored.plan));
if (body.action === 'sign-off') {
if (!['active', 'passed'].includes(stored.outcome)) throw new Error('Resume or retry the test before signing off');
const requirement = plan.requirements.find(r => r.id === body.requirementId && r.kind === 'manual');
if (!['active', 'passed'].includes(stored.outcome))
throw new Error('Resume or retry the test before signing off');
const requirement = plan.requirements.find(
(r) => r.id === body.requirementId && r.kind === 'manual'
);
const run = typeof body.server === 'string' ? currentRun(id, body.server) : undefined;
if (!requirement || !run?.observing_at) throw new Error('Checklist item or active server run not found');
if (typeof body.note !== 'string' || !body.note.trim() || body.note.length > 1000) throw new Error('Add a tester name and observation (up to 1000 characters)');
if (!requirement || !run?.observing_at)
throw new Error('Checklist item or active server run not found');
if (typeof body.note !== 'string' || !body.note.trim() || body.note.length > 1000)
throw new Error('Add a tester name and observation (up to 1000 characters)');
const metrics = readMetrics(run);
metrics.manual[requirement.id] = body.note.trim();
saveMetrics(run, metrics);
evidence(run, `manual:${requirement.id}:${Date.now()}`, 'manual-sign-off', { requirement: requirement.label, note: body.note.trim() });
evidence(run, `manual:${requirement.id}:${Date.now()}`, 'manual-sign-off', {
requirement: requirement.label,
note: body.note.trim()
});
activity(id, `${body.server}: ${requirement.label} signed off: ${body.note.trim()}`);
} else if (body.action === 'pause') {
setOutcome(id, 'paused', 'Paused by operator; deployment and evidence evaluation held');
betaState(id, false);
} else if (body.action === 'resume') {
if (stored.outcome !== 'paused') throw new Error('Only paused plans can resume; failed or expired plans need a fresh attempt');
const runs = db().prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL').all(id) as Parameters<typeof readMetrics>[0][];
if (stored.outcome !== 'paused')
throw new Error(
'Only paused plans can resume; failed or expired plans need a fresh attempt'
);
const runs = db()
.prepare('SELECT * FROM beta_test_runs WHERE beta_id = ? AND retired_at IS NULL')
.all(id) as Parameters<typeof readMetrics>[0][];
for (const run of runs) {
const metrics = readMetrics(run);
delete metrics.match;
delete metrics.lastSample;
delete metrics.wasHealthy;
metrics.streaks = {};
for (const r of plan.requirements) if (r.kind === 'healthy-minutes') metrics.values[r.id] = 0;
for (const r of plan.requirements)
if (r.kind === 'healthy-minutes') metrics.values[r.id] = 0;
saveMetrics(run, metrics);
}
setOutcome(id, 'active', null);
} else if (body.action === 'retry') {
// Fresh attempts keep history and the current revision's deadline.
if (stored.outcome === 'inconclusive') throw new Error('The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline');
if (stagedFileChanges('beta', String(id)).some(change => change.state === 'applying')) {
throw new Error('A file write was interrupted. Inspect the server and roll back before opening a fresh beta');
if (stored.outcome === 'inconclusive')
throw new Error(
'The testing window ended. Update the files to start a new revision, or roll back and open a new beta with a longer deadline'
);
if (
stagedFileChanges('beta', String(id)).some((change) => change.state === 'applying')
) {
throw new Error(
'A file write was interrupted. Inspect the server and roll back before opening a fresh beta'
);
}
const prepare = db().prepare("SELECT server FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL AND restarted_at IS NULL")
const prepare = db()
.prepare(
"SELECT server FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL AND restarted_at IS NULL"
)
.all(id) as { server: string }[];
if (prepare.length) {
await deployBetaServers(id, prepare.map(row => row.server));
const failed = db().prepare("SELECT error FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL").get(id) as { error: string | null } | undefined;
await deployBetaServers(
id,
prepare.map((row) => row.server)
);
const failed = db()
.prepare(
"SELECT error FROM beta_servers WHERE beta_id = ? AND stage = 'failed' AND deployed_at IS NULL"
)
.get(id) as { error: string | null } | undefined;
if (failed) throw new Error(failed.error ?? 'Could not prepare beta files');
}
db().transaction(() => {
const servers = db().prepare('SELECT server FROM beta_servers WHERE beta_id = ?').all(id) as { server: string }[];
for (const { server } of servers) releaseRestartRequest(server, { kind: 'beta', id: String(id) });
db().prepare('UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL').run(new Date().toISOString(), id);
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error NOT LIKE 'deploy:%'").run(id);
const servers = db()
.prepare('SELECT server FROM beta_servers WHERE beta_id = ?')
.all(id) as { server: string }[];
for (const { server } of servers)
releaseRestartRequest(server, { kind: 'beta', id: String(id) });
db()
.prepare(
'UPDATE beta_test_runs SET retired_at = ? WHERE beta_id = ? AND retired_at IS NULL'
)
.run(new Date().toISOString(), id);
db()
.prepare(
"UPDATE beta_servers SET stage = 'awaiting-empty', error = NULL, restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error NOT LIKE 'deploy:%'"
)
.run(id);
// SQL NULL does not satisfy NOT LIKE.
db().prepare("UPDATE beta_servers SET stage = 'awaiting-empty', restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error IS NULL").run(id);
db()
.prepare(
"UPDATE beta_servers SET stage = 'awaiting-empty', restarted_at = NULL, healthy_at = NULL WHERE beta_id = ? AND deployed_at IS NOT NULL AND error IS NULL"
)
.run(id);
betaState(id, false);
setOutcome(id, 'active', null);
activity(id, 'Fresh test attempt requested; deployed servers will restart when empty');
activity(
id,
'Fresh test attempt requested; deployed servers will restart when empty'
);
})();
} else throw new Error('Unknown test action');
void kick();
return json({ ok: true });
} catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
}
+20 -6
View File
@@ -2,17 +2,31 @@ import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
import { builtInPlans, parseBetaPlan } from '$lib/beta-plan.js';
export function GET() {
const saved = db().prepare('SELECT name, plan FROM beta_test_presets ORDER BY name').all() as Array<{ name: string; plan: string }>;
return json({ presets: [...builtInPlans, ...saved.map(p => ({ name: p.name, plan: JSON.parse(p.plan) }))] });
const saved = db()
.prepare('SELECT name, plan FROM beta_test_presets ORDER BY name')
.all() as Array<{ name: string; plan: string }>;
return json({
presets: [
...builtInPlans,
...saved.map((p) => ({ name: p.name, plan: JSON.parse(p.plan) }))
]
});
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80) throw new Error('Preset name must be 1–80 characters');
if (builtInPlans.some(p => p.name === body.name.trim())) throw new Error('Choose a name other than a built-in preset');
if (typeof body.name !== 'string' || !body.name.trim() || body.name.length > 80)
throw new Error('Preset name must be 1–80 characters');
if (builtInPlans.some((p) => p.name === body.name.trim()))
throw new Error('Choose a name other than a built-in preset');
const plan = parseBetaPlan(body.plan);
db().prepare('INSERT INTO beta_test_presets (name, plan) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET plan = excluded.plan')
db()
.prepare(
'INSERT INTO beta_test_presets (name, plan) VALUES (?, ?) ON CONFLICT(name) DO UPDATE SET plan = excluded.plan'
)
.run(body.name.trim(), JSON.stringify(plan));
return json({ ok: true });
} catch (error) { return json({ ok: false, error: (error as Error).message }, { status: 400 }); }
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
}
+5 -2
View File
@@ -1,12 +1,15 @@
import { json } from '@sveltejs/kit';
import { listBlueprints, saveBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function GET() { return json({ blueprints: listBlueprints() }); }
export function GET() {
return json({ blueprints: listBlueprints() });
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and a launch recipe.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection name and a launch recipe.');
return json({ blueprint: saveBlueprint(body) }, { status: 201 });
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
+8 -3
View File
@@ -2,13 +2,18 @@ import { json } from '@sveltejs/kit';
import { deleteBlueprint, updateBlueprint } from '$lib/server/fleet/launch-recipes.js';
export function DELETE({ params }: { params: { id: string } }) {
return deleteBlueprint(params.id) ? json({ ok: true }) : json({ error: 'Collection not found.' }, { status: 404 });
return deleteBlueprint(params.id)
? json({ ok: true })
: json({ error: 'Collection not found.' }, { status: 404 });
}
export async function PUT({ params, request }: { params: { id: string }; request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection name and map selections.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection name and map selections.');
return json({ blueprint: updateBlueprint(params.id, body) });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
+40 -15
View File
@@ -9,8 +9,14 @@ export async function GET({ url }: { url: URL }) {
const query = (url.searchParams.get('q') ?? '').slice(0, 100).trim().toLowerCase();
const action = url.searchParams.get('action');
const status = url.searchParams.get('status');
const limit = Math.min(500, Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25)));
const offset = Math.min(1_000_000, Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0)));
const limit = Math.min(
500,
Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25))
);
const offset = Math.min(
1_000_000,
Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0))
);
const attention = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state IN ('failed','held','manual'))`;
const pending = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state='pending')`;
const active = `(${attention} OR ${pending})`;
@@ -20,14 +26,32 @@ export async function GET({ url }: { url: URL }) {
WHEN EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id)
AND NOT EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.restart_state != 'done') THEN 'complete'
ELSE 'inactive' END`;
const names = query ? (db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as { server: string }[])
.map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(query)) : [];
const conditions = ['(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))'];
const names = query
? (
db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as {
server: string;
}[]
)
.map((row) => row.server)
.filter((name) => `${name} ${serverTitle(name)}`.toLowerCase().includes(query))
: [];
const conditions = [
'(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))'
];
const parameters: (string | null)[] = [server, server];
if (activeOnly) conditions.push(active);
else if (historyOnly) conditions.push(`NOT ${active}`);
if (action && ['promote', 'rollback', 'drift'].includes(action)) { conditions.push('d.action=?'); parameters.push(action); }
if (status && ['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)) { conditions.push(`(${outcome})=?`); parameters.push(status); }
if (action && ['promote', 'rollback', 'drift'].includes(action)) {
conditions.push('d.action=?');
parameters.push(action);
}
if (
status &&
['attention', 'progress', 'complete', 'rolled-back', 'inactive'].includes(status)
) {
conditions.push(`(${outcome})=?`);
parameters.push(status);
}
if (query) {
conditions.push(`(instr(lower(d.id), ?) > 0
OR instr(lower(CASE WHEN d.action='drift' THEN 'maintenance repair' ELSE d.action END), ?) > 0
@@ -38,7 +62,9 @@ export async function GET({ url }: { url: URL }) {
parameters.push(query, query, query, query, query, ...names);
}
const where = conditions.join(' AND ');
const { total } = db().prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`).get(...parameters) as { total: number };
const { total } = db()
.prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`)
.get(...parameters) as { total: number };
const deployments = db()
.prepare(
@@ -70,7 +96,7 @@ export async function GET({ url }: { url: URL }) {
prior_version: number | null;
}>;
const ids = deployments.map(deployment => deployment.id);
const ids = deployments.map((deployment) => deployment.id);
if (!ids.length) return json({ deployments: [], total, limit, offset });
const placeholders = ids.map(() => '?').join(',');
const serversByDep = db()
@@ -122,12 +148,11 @@ export async function GET({ url }: { url: URL }) {
scriptsByDep.set(s.deployment_id, arr);
}
const rows = deployments
.map((d) => ({
...d,
servers: grouped.get(d.id) ?? [],
scripts: scriptsByDep.get(d.id) ?? []
}));
const rows = deployments.map((d) => ({
...d,
servers: grouped.get(d.id) ?? [],
scripts: scriptsByDep.get(d.id) ?? []
}));
return json({ deployments: rows, total, limit, offset });
}
+24 -7
View File
@@ -3,9 +3,8 @@ import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { id: string } }) {
const dep = db()
.prepare('SELECT * FROM deployments WHERE id = ?')
.get(params.id) as Record<string, unknown> | undefined;
const dep = db().prepare('SELECT * FROM deployments WHERE id = ?').get(params.id) as
Record<string, unknown> | undefined;
if (!dep) throw error(404, 'deployment not found');
const servers = db()
@@ -40,8 +39,26 @@ export async function GET({ params }: { params: { id: string } }) {
)
.all(params.id);
const events = db().prepare('SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id').all(params.id);
const watchdog = db().prepare('SELECT incident_id FROM watchdog_actions WHERE campaign_id = ? LIMIT 1').get(params.id) as { incident_id: string } | undefined;
const fileContentsExpired = !!db().prepare("SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?").get(params.id);
return json({ fileContentsExpired, deployment: dep, servers, files, scripts, events, watchdogIncidentId: watchdog?.incident_id ?? null });
const events = db()
.prepare(
'SELECT id, created_at, message FROM campaign_events WHERE deployment_id = ? ORDER BY id'
)
.all(params.id);
const watchdog = db()
.prepare('SELECT incident_id FROM watchdog_actions WHERE campaign_id = ? LIMIT 1')
.get(params.id) as { incident_id: string } | undefined;
const fileContentsExpired = !!db()
.prepare(
"SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?"
)
.get(params.id);
return json({
fileContentsExpired,
deployment: dep,
servers,
files,
scripts,
events,
watchdogIncidentId: watchdog?.incident_id ?? null
});
}
@@ -6,8 +6,17 @@ import { db } from '$lib/server/db/index.js';
// so the DiffViewer can render a unified diff without a second query.
export async function GET({ params }: { params: { id: string; fileId: string } }) {
if (db().prepare("SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?").get(params.id)) {
return json({ error: 'File contents expired under the retention policy.' }, { status: 410 });
if (
db()
.prepare(
"SELECT 1 FROM retention_expirations WHERE category='file-content' AND record_id=?"
)
.get(params.id)
) {
return json(
{ error: 'File contents expired under the retention policy.' },
{ status: 410 }
);
}
const row = db()
.prepare(
@@ -27,7 +36,13 @@ export async function GET({ params }: { params: { id: string; fileId: string } }
}
| undefined;
if (!row) throw error(404, 'file not found');
if (row.snapshot_pending) return json({ error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.' }, { status: 409 });
if (row.snapshot_pending)
return json(
{
error: 'The rollback snapshot is not available yet. It is captured immediately before file activation.'
},
{ status: 409 }
);
return json({
verb: row.verb,
@@ -5,21 +5,30 @@ import { kick } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
const changed = db().transaction(() => {
const rows = db().prepare(`SELECT ds.server, d.state FROM deployment_servers ds
const rows = db()
.prepare(
`SELECT ds.server, d.state FROM deployment_servers ds
LEFT JOIN server_deployments d ON d.id = ds.provisioning_id
WHERE ds.deployment_id = ? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('held','manual')`)
WHERE ds.deployment_id = ? AND ds.server_deleted_at IS NULL AND ds.restart_state IN ('held','manual')`
)
.all(params.id) as { server: string; state: string | null }[];
for (const row of rows) {
if (row.state && ['failed','interrupted','cancelled'].includes(row.state)) {
if (row.state && ['failed', 'interrupted', 'cancelled'].includes(row.state)) {
releaseRestartRequest(row.server, { kind: 'campaign', id: params.id });
}
}
const result = db().prepare(`UPDATE deployment_servers SET restart_state = 'pending', error = NULL
WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('held','manual')`).run(params.id);
if (result.changes) db().prepare('UPDATE deployments SET finished_at = NULL WHERE id = ?').run(params.id);
const result = db()
.prepare(
`UPDATE deployment_servers SET restart_state = 'pending', error = NULL
WHERE deployment_id = ? AND server_deleted_at IS NULL AND restart_state IN ('held','manual')`
)
.run(params.id);
if (result.changes)
db().prepare('UPDATE deployments SET finished_at = NULL WHERE id = ?').run(params.id);
return result.changes;
})();
if (!changed) return json({ error: 'This campaign has no held changes to resume.' }, { status: 409 });
void kick().catch(err => console.error('[campaign] activation kick failed:', err));
if (!changed)
return json({ error: 'This campaign has no held changes to resume.' }, { status: 409 });
void kick().catch((err) => console.error('[campaign] activation kick failed:', err));
return json({ ok: true }, { status: 202 });
}
+3 -1
View File
@@ -3,5 +3,7 @@ import { parseCapacityQuery } from '$lib/capacity.js';
import { capacityPage } from '$lib/server/hosts/capacity-page.js';
export async function GET({ url }: { url: URL }) {
return json(await capacityPage(parseCapacityQuery(url.searchParams)), { headers: { 'cache-control': 'no-store' } });
return json(await capacityPage(parseCapacityQuery(url.searchParams)), {
headers: { 'cache-control': 'no-store' }
});
}
+14 -4
View File
@@ -4,11 +4,21 @@ import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scrip
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid config ID.' }, { status: 400 });
if (!Number.isSafeInteger(id) || id <= 0)
return json({ error: 'Invalid config ID.' }, { status: 400 });
const script = getScript(id);
if (!script || !['config', 'baseline'].includes(script.kind)) return json({ error: 'Config not found.' }, { status: 404 });
if (!script || !['config', 'baseline'].includes(script.kind))
return json({ error: 'Config not found.' }, { status: 404 });
const requested = url.searchParams.get('version');
const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested));
if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 });
return json({ text: getVersionContent(version.id).toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } });
if (!version || version.script_id !== id)
return json({ error: 'Config version not found.' }, { status: 404 });
return json(
{
text: getVersionContent(version.id).toString('utf8'),
versionId: version.id,
version: version.version
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
+70 -15
View File
@@ -1,44 +1,87 @@
import { getVersionContent } from '$lib/server/fleet/scripts.js';
import { json } from '@sveltejs/kit';
import { getServer, listServers } from '$lib/server/fleet/servers.js';
import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js';
import {
getBaselineForScope,
getScript,
getVersion,
listPromotedOverlaysForScope,
type ScriptRow
} from '$lib/server/fleet/scripts.js';
import { inspectConfig, type ConfigLayer } from '$lib/server/fleet/config-inspect.js';
import type { ConfigInspection } from '$lib/config-workbench.js';
function inspect(serverName: string, draft?: { artifactId: number; text: string }): ConfigInspection {
function inspect(
serverName: string,
draft?: { artifactId: number; text: string }
): ConfigInspection {
const server = getServer(serverName);
if (!server || server.destroyed_at) throw new Error('Server not found.');
const artifact = draft ? getScript(draft.artifactId) : undefined;
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind))) throw new Error('Config not found.');
if (artifact && (artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])) {
if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind)))
throw new Error('Config not found.');
if (
artifact &&
(artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])
) {
throw new Error('This config does not apply to the selected server.');
}
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game') throw new Error('Server composition requires a game baseline.');
const baseline = artifact?.kind === 'baseline' ? artifact : server.game ? getBaselineForScope('game', server.game) : undefined;
if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game')
throw new Error('Server composition requires a game baseline.');
const baseline =
artifact?.kind === 'baseline'
? artifact
: server.game
? getBaselineForScope('game', server.game)
: undefined;
function layer(row: ScriptRow): ConfigLayer | null {
const isDraft = row.id === draft?.artifactId;
const version = row.current_version_id ? getVersion(row.current_version_id) : undefined;
if (!isDraft && !version) return null;
return {
source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft },
source: {
id: row.id,
name: row.name,
version: isDraft ? null : version!.version,
kind: row.kind as 'baseline' | 'config',
draft: isDraft
},
text: isDraft ? draft!.text : getVersionContent(version!.id).toString('utf8')
};
}
const base = baseline ? layer(baseline) : null;
if (!base) return { text: '', sources: [], settings: [], warnings: [], reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.' };
if (!base)
return {
text: '',
sources: [],
settings: [],
warnings: [],
reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.'
};
const overlays = [
...(server.game ? listPromotedOverlaysForScope('game', server.game) : []),
...(server.map ? listPromotedOverlaysForScope('map', server.map) : [])
].filter(row => row.id !== artifact?.id);
].filter((row) => row.id !== artifact?.id);
if (artifact?.kind === 'config') overlays.push(artifact);
return inspectConfig(base, overlays.map(layer).filter((l): l is ConfigLayer => l !== null), server.game, server.map, server.map_slug);
return inspectConfig(
base,
overlays.map(layer).filter((l): l is ConfigLayer => l !== null),
server.game,
server.map,
server.map_slug
);
}
export async function GET({ url }: { url: URL }) {
const servers = listServers().filter(s => !s.destroyed_at).map(({ name, game, map }) => ({ name, game, map }));
const servers = listServers()
.filter((s) => !s.destroyed_at)
.map(({ name, game, map }) => ({ name, game, map }));
try {
const server = url.searchParams.get('server');
return json({ servers, inspection: server ? inspect(server) : null }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{ servers, inspection: server ? inspect(server) : null },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
@@ -46,12 +89,24 @@ export async function GET({ url }: { url: URL }) {
export async function POST({ request }: { request: Request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Invalid preview request.' }, { status: 400 }); }
if (!body || typeof body.server !== 'string' || !Number.isSafeInteger(body.artifactId) || typeof body.text !== 'string') {
try {
body = await request.json();
} catch {
return json({ error: 'Invalid preview request.' }, { status: 400 });
}
if (
!body ||
typeof body.server !== 'string' ||
!Number.isSafeInteger(body.artifactId) ||
typeof body.text !== 'string'
) {
return json({ error: 'Server, config ID and text are required.' }, { status: 400 });
}
try {
return json({ inspection: inspect(body.server, body), promoted: inspect(body.server) }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{ inspection: inspect(body.server, body), promoted: inspect(body.server) },
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
@@ -2,18 +2,26 @@ import { json } from '@sveltejs/kit';
import { listCollections, queueCollection } from '$lib/server/fleet/collections.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function GET() { return json({ collections: listCollections() }); }
export function GET() {
return json({ collections: listCollections() });
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a collection preview and its server keys.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a collection preview and its server keys.');
const result = queueCollection(body);
// The durable queue is committed. Let the response and pending I/O run
// before provisioning loads launch files or starts checking removals.
if (result.collectionId) setImmediate(() => {
void kickProvisioning().catch(err => console.error('[collection] worker kick failed:', err));
});
if (result.collectionId)
setImmediate(() => {
void kickProvisioning().catch((err) =>
console.error('[collection] worker kick failed:', err)
);
});
return json(result, { status: result.collectionId ? 202 : 200 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
@@ -8,7 +8,12 @@ export function POST({ params }: { params: { id: string } }) {
return json({ error: 'Collection not found.' }, { status: 404 });
}
if (!cancelCollection(params.id)) {
return json({ error: 'This collection has no work that can still be cancelled. Refresh to see its current state.' }, { status: 409 });
return json(
{
error: 'This collection has no work that can still be cancelled. Refresh to see its current state.'
},
{ status: 409 }
);
}
void kickProvisioning();
return json({ ok: true });
@@ -5,5 +5,7 @@ export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
return json(previewCollection(body?.blueprintId, body?.mode, body));
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
+5 -4
View File
@@ -4,11 +4,12 @@ import { db } from '$lib/server/db/index.js';
import { getDeployment } from '$lib/server/provisioning/index.js';
export async function GET({ params, url }: { params: { id: string }; url?: URL }) {
const lifecycle = getDeployment(params.id, { events: url?.searchParams.get('events') === 'recent' ? 'recent' : 'all' });
const lifecycle = getDeployment(params.id, {
events: url?.searchParams.get('events') === 'recent' ? 'recent' : 'all'
});
if (lifecycle) return json(lifecycle);
const dep = db()
.prepare('SELECT * FROM deployments WHERE id = ?')
.get(params.id) as Record<string, unknown> | undefined;
const dep = db().prepare('SELECT * FROM deployments WHERE id = ?').get(params.id) as
Record<string, unknown> | undefined;
if (!dep) throw error(404, 'deployment not found');
const servers = db()
@@ -1,10 +1,20 @@
import { json } from '@sveltejs/kit';
import { cancelProvisioningJob, rebalanceCancellationBlocker } from '$lib/server/provisioning/cancellation.js';
import {
cancelProvisioningJob,
rebalanceCancellationBlocker
} from '$lib/server/provisioning/cancellation.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
if (!cancelProvisioningJob(params.id)) return json({ error: rebalanceCancellationBlocker(params.id)
?? 'This operation can no longer be cancelled. Refresh to see its current state.' }, { status: 409 });
if (!cancelProvisioningJob(params.id))
return json(
{
error:
rebalanceCancellationBlocker(params.id) ??
'This operation can no longer be cancelled. Refresh to see its current state.'
},
{ status: 409 }
);
void kickProvisioning();
return json({ ok: true });
}
@@ -1,9 +1,17 @@
import { json } from '@sveltejs/kit';
import { resumeVerification, resumeCleanup, resumeRebuild } from '$lib/server/provisioning/index.js';
import {
resumeVerification,
resumeCleanup,
resumeRebuild
} from '$lib/server/provisioning/index.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
if (!resumeVerification(params.id) && !resumeCleanup(params.id) && !resumeRebuild(params.id)) return json({ error: 'This operation cannot resume. Refresh and inspect its server.' }, { status: 409 });
void kick().catch(err => console.error('[deployment] verification kick failed:', err));
if (!resumeVerification(params.id) && !resumeCleanup(params.id) && !resumeRebuild(params.id))
return json(
{ error: 'This operation cannot resume. Refresh and inspect its server.' },
{ status: 409 }
);
void kick().catch((err) => console.error('[deployment] verification kick failed:', err));
return json({ ok: true }, { status: 202 });
}
@@ -8,7 +8,10 @@ export function GET() {
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!Array.isArray(body?.servers) || !body.servers.every((name: unknown) => typeof name === 'string')) {
if (
!Array.isArray(body?.servers) ||
!body.servers.every((name: unknown) => typeof name === 'string')
) {
return json({ error: 'A reviewed server list is required.' }, { status: 400 });
}
let result: ReturnType<typeof queueHouseParty>;
@@ -18,6 +21,6 @@ export async function POST({ request }: { request: Request }) {
return json({ ok: false, error: (err as Error).message }, { status: 409 });
}
if (!result.ok) return json(result, { status: 409 });
void kick().catch(err => console.error('[house-party] worker kick failed:', err));
void kick().catch((err) => console.error('[house-party] worker kick failed:', err));
return json(result, { status: 202 });
}
@@ -4,8 +4,12 @@ import { previewCreation } from '$lib/server/provisioning/index.js';
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Provide a launch selection.');
if (body.blueprintId !== undefined) throw new Error('Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Provide a launch selection.');
if (body.blueprintId !== undefined)
throw new Error(
'Collections use target counts. Use /api/deployment-collections/preview to preview only missing servers.'
);
return json(previewCreation(body));
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
+21 -4
View File
@@ -4,7 +4,11 @@ import { previewDrift } from '$lib/server/fleet/drift.js';
// GET returns the current drift plan for the whole fleet without touching disk.
export async function GET() {
const plan = await previewDrift();
const entries = plan.files.map(file => ({ ...file, size: file.afterSize ?? file.beforeSize ?? 0, sha256: file.afterSha256 ?? file.beforeSha256 ?? '' }));
const entries = plan.files.map((file) => ({
...file,
size: file.afterSize ?? file.beforeSize ?? 0,
sha256: file.afterSha256 ?? file.beforeSha256 ?? ''
}));
const byServer = new Map<
string,
Array<{ relPath: string; size: number; sha256: string; kind: string; driftKind: string }>
@@ -21,7 +25,8 @@ export async function GET() {
byServer.set(e.server, arr);
}
return json({
totalDrift: entries.length, scannedAt: plan.scannedAt,
totalDrift: entries.length,
scannedAt: plan.scannedAt,
servers: [...byServer.entries()].map(([server, files]) => ({ server, files }))
});
}
@@ -29,12 +34,24 @@ export async function GET() {
// Legacy previews remain readable. Watchdog owns all new reconciliation requests.
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.every((server: unknown) => typeof server === 'string')))) {
if (
!body ||
(body.servers !== undefined &&
(!Array.isArray(body.servers) ||
!body.servers.every((server: unknown) => typeof server === 'string')))
) {
return json({ ok: false, error: 'Provide the server IDs to review.' }, { status: 400 });
}
try {
if (body.dryRun === true) return json({ ok: true, plan: await previewDrift(body.servers) });
return json({ ok: false, error: 'Drift reconciliation has moved to Watchdog. Scan there to queue a rebuild and campaign.', watchdogUrl: '/watchdog' }, { status: 410 });
return json(
{
ok: false,
error: 'Drift reconciliation has moved to Watchdog. Scan there to queue a rebuild and campaign.',
watchdogUrl: '/watchdog'
},
{ status: 410 }
);
} catch (error) {
return json({ ok: false, error: (error as Error).message }, { status: 400 });
}
+9 -4
View File
@@ -6,9 +6,14 @@ const encoder = new TextEncoder();
export async function GET() {
return eventStreamResponse({
accepts: event => event.type === 'server-state' || event.type === 'deployment-opened' ||
event.type === 'server-changed' || event.type === 'sweep-tick' ||
event.type === 'fleet-invalidated' || event.type === 'beta-changed',
encode: event => encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
accepts: (event) =>
event.type === 'server-state' ||
event.type === 'deployment-opened' ||
event.type === 'server-changed' ||
event.type === 'sweep-tick' ||
event.type === 'fleet-invalidated' ||
event.type === 'beta-changed',
encode: (event) =>
encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)
});
}
+12 -5
View File
@@ -14,21 +14,28 @@ const frames = new WeakMap<PipelineEvent, Uint8Array>();
export async function GET() {
const observation = observationReader();
const capacity = playerCapacityReader();
const observe = (server: ReturnType<typeof listServers>[number]) => statusEntry(server, observation, capacity(server.name));
const observe = (server: ReturnType<typeof listServers>[number]) =>
statusEntry(server, observation, capacity(server.name));
return eventStreamResponse({
accepts: event => event.type === 'server-status' || event.type === 'server-telemetry',
accepts: (event) => event.type === 'server-status' || event.type === 'server-telemetry',
encode(event) {
let frame = frames.get(event);
if (!frame) {
frame = encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`);
frame = encoder.encode(
`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`
);
frames.set(event, frame);
}
return frame;
},
initialFrames: [
encoder.encode(`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map(server => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`),
encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`)
encoder.encode(
`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map((server) => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`
),
encoder.encode(
`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`
)
]
});
}
+13 -4
View File
@@ -3,8 +3,17 @@ import { createLibraryUpload, libraryOverview } from '$lib/server/game-files/ind
import { gameFileError, readGameJson } from '$lib/server/game-files/http.js';
import { requireManagementOrigin } from '$lib/server/hosts/http.js';
export function GET() { return json(libraryOverview()); }
export async function POST({ request }: { request: Request }) {
try { requireManagementOrigin(request); return json({ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) }, { status: 202 }); }
catch (cause) { return gameFileError(cause); }
export function GET() {
return json(libraryOverview());
}
export async function POST({ request }: { request: Request }) {
try {
requireManagementOrigin(request);
return json(
{ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) },
{ status: 202 }
);
} catch (cause) {
return gameFileError(cause);
}
}
+3 -1
View File
@@ -2,5 +2,7 @@ import { json } from '@sveltejs/kit';
import { getLibraryVersion } from '$lib/server/game-files/index.js';
export function GET({ params }: { params: { id: string } }) {
const version = getLibraryVersion(params.id);
return version ? json({ version }) : json({ error: 'Library version not found.' }, { status: 404 });
return version
? json({ version })
: json({ error: 'Library version not found.' }, { status: 404 });
}
@@ -8,5 +8,7 @@ export async function POST({ request, params }: { request: Request; params: { id
const body = await readGameJson(request);
if (typeof body.hostId !== 'string') throw new Error('Choose a target host.');
return json({ operation: queueLibraryDownload(body.hostId, params.id) }, { status: 202 });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
@@ -3,6 +3,11 @@ import { browserUploadStatus } from '$lib/server/game-files/index.js';
import { gameFileError } from '$lib/server/game-files/http.js';
export async function GET({ params }: { params: { id: string } }) {
try { return json(await browserUploadStatus(params.id), { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return gameFileError(cause); }
try {
return json(await browserUploadStatus(params.id), {
headers: { 'Cache-Control': 'no-store' }
});
} catch (cause) {
return gameFileError(cause);
}
}
@@ -7,5 +7,7 @@ export async function POST({ request, params }: { request: Request; params: { id
try {
requireManagementOrigin(request);
return json(await requestBrowserPublication(params.id), { status: 202 });
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
@@ -5,13 +5,26 @@ import { requireManagementOrigin } from '$lib/server/hosts/http.js';
function integerHeader(request: Request, name: string): number {
const value = request.headers.get(name);
if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error(`Provide a valid ${name} header.`);
if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)))
throw new Error(`Provide a valid ${name} header.`);
return Number(value);
}
export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) {
export async function PUT({
request,
params
}: {
request: Request;
params: { id: string; path: string };
}) {
try {
requireManagementOrigin(request);
const chunk = { offset: integerHeader(request, 'X-Upload-Offset'), size: integerHeader(request, 'X-Upload-Length'), sha256: request.headers.get('X-Content-SHA256') ?? '' };
const chunk = {
offset: integerHeader(request, 'X-Upload-Offset'),
size: integerHeader(request, 'X-Upload-Length'),
sha256: request.headers.get('X-Content-SHA256') ?? ''
};
return json(await uploadBrowserFile(params.id, params.path, chunk, request.body));
} catch (cause) { return gameFileError(cause); }
} catch (cause) {
return gameFileError(cause);
}
}
+30 -4
View File
@@ -1,15 +1,41 @@
import { json } from '@sveltejs/kit';
import { createEnrollment, createHost } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import {
hostApiError,
installerCommand,
objectBody,
requireManagementOrigin
} from '$lib/server/hosts/http.js';
import { hostOverview } from '$lib/server/hosts/overview.js';
import { config } from '$lib/server/config.js';
export function GET({ request }: { request: Request }) { requireManagementOrigin(request); return json({ hosts: hostOverview(), serverNow: Date.now(), defaultRuntimeImage: config.defaultRuntimeImage }, { headers: { 'Cache-Control': 'no-store' } }); }
export function GET({ request }: { request: Request }) {
requireManagementOrigin(request);
return json(
{
hosts: hostOverview(),
serverNow: Date.now(),
defaultRuntimeImage: config.defaultRuntimeImage
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
export async function POST({ request }: { request: Request }) {
requireManagementOrigin(request);
try {
const host = createHost(await objectBody(request));
const enrollment = createEnrollment(host.id);
return json({ host, enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { status: 201, headers: { 'Cache-Control': 'no-store' } });
} catch (cause) { return hostApiError(cause); }
return json(
{
host,
enrollment: {
...enrollment,
command: installerCommand(request.url, enrollment.token)
}
},
{ status: 201, headers: { 'Cache-Control': 'no-store' } }
);
} catch (cause) {
return hostApiError(cause);
}
}
+36 -9
View File
@@ -1,6 +1,12 @@
import { capacityCalibration } from '$lib/server/hosts/calibration.js';
import { error, json } from '@sveltejs/kit';
import { deleteHost, getHost, hostDeletionStatus, HostDeletionError, updateHost } from '$lib/server/hosts/index.js';
import {
deleteHost,
getHost,
hostDeletionStatus,
HostDeletionError,
updateHost
} from '$lib/server/hosts/index.js';
import { hostApiError, objectBody, requireManagementOrigin } from '$lib/server/hosts/http.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
import { config } from '$lib/server/config.js';
@@ -9,26 +15,47 @@ import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
type Event = { request: Request; params: { id: string } };
export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
return json({ host: presentHostPreparation(host), instances: hostInstances(host.id), serverNow: Date.now(), calibration: capacityCalibration(host.id), defaultRuntimeImage: config.defaultRuntimeImage, deletion: hostDeletionStatus(host.id) });
requireManagementOrigin(request);
const host = getHost(params.id);
if (!host) error(404, 'Host not found.');
return json({
host: presentHostPreparation(host),
instances: hostInstances(host.id),
serverNow: Date.now(),
calibration: capacityCalibration(host.id),
defaultRuntimeImage: config.defaultRuntimeImage,
deletion: hostDeletionStatus(host.id)
});
}
export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request);
try {
const body = await objectBody(request);
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining') throw new Error('Choose Enable or Drain host. Hosts disable automatically after draining.');
if (body.mode !== undefined && body.mode !== 'active' && body.mode !== 'draining')
throw new Error(
'Choose Enable or Drain host. Hosts disable automatically after draining.'
);
updateHost(params.id, body);
if (body.mode === 'active') reconcileHostPreparation(params.id);
if (body.mode === 'draining') void kickProvisioning().catch(cause => console.error('[host-drain]', cause));
if (body.mode === 'draining')
void kickProvisioning().catch((cause) => console.error('[host-drain]', cause));
return json({ host: presentHostPreparation(getHost(params.id)!) });
} catch (cause) { return hostApiError(cause); }
} catch (cause) {
return hostApiError(cause);
}
}
export function DELETE({ request, params }: Event) {
requireManagementOrigin(request);
try { const result = deleteHost(params.id); return json(result, { status: result.deleted ? 200 : 202 }); }
catch (cause) {
if (cause instanceof HostDeletionError) return json({ error: cause.message, blockers: cause.blockers }, { status: cause.status });
try {
const result = deleteHost(params.id);
return json(result, { status: result.deleted ? 200 : 202 });
} catch (cause) {
if (cause instanceof HostDeletionError)
return json(
{ error: cause.message, blockers: cause.blockers },
{ status: cause.status }
);
return hostApiError(cause);
}
}
@@ -4,9 +4,19 @@ import { hostApiError, requireManagementOrigin } from '$lib/server/hosts/http.js
type Event = { request: Request; params: { id: string } };
export function DELETE({ request, params }: Event) {
requireManagementOrigin(request);
try { revokeCredentials(params.id); return json({ ok: true }); } catch (cause) { return hostApiError(cause); }
try {
revokeCredentials(params.id);
return json({ ok: true });
} catch (cause) {
return hostApiError(cause);
}
}
export function POST({ request, params }: Event) {
requireManagementOrigin(request);
try { const operation = queueHostOperation(params.id, 'credentials.rotate', {}); return json({ operationId: operation.id }, { status: 202 }); } catch (cause) { return hostApiError(cause); }
try {
const operation = queueHostOperation(params.id, 'credentials.rotate', {});
return json({ operationId: operation.id }, { status: 202 });
} catch (cause) {
return hostApiError(cause);
}
}
@@ -3,6 +3,18 @@ import { createEnrollment } from '$lib/server/hosts/index.js';
import { hostApiError, installerCommand, requireManagementOrigin } from '$lib/server/hosts/http.js';
export function POST({ request, params }: { request: Request; params: { id: string } }) {
requireManagementOrigin(request);
try { const enrollment = createEnrollment(params.id); return json({ enrollment: { ...enrollment, command: installerCommand(request.url, enrollment.token) } }, { headers: { 'Cache-Control': 'no-store' } }); }
catch (cause) { return hostApiError(cause); }
try {
const enrollment = createEnrollment(params.id);
return json(
{
enrollment: {
...enrollment,
command: installerCommand(request.url, enrollment.token)
}
},
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (cause) {
return hostApiError(cause);
}
}
@@ -5,8 +5,15 @@ export async function POST({ request, params }: { request: Request; params: { id
requireManagementOrigin(request);
try {
const body = await objectBody(request);
if (body.kind !== 'runtime.pull' || typeof body.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)) throw new Error('Provide a runtime.pull request and a valid container image.');
if (
body.kind !== 'runtime.pull' ||
typeof body.image !== 'string' ||
!/^[a-zA-Z0-9][a-zA-Z0-9./_:@-]{0,511}$/.test(body.image)
)
throw new Error('Provide a runtime.pull request and a valid container image.');
const operation = queueHostOperation(params.id, 'runtime.pull', { image: body.image });
return json({ operationId: operation.id }, { status: 202 });
} catch (cause) { return hostApiError(cause); }
} catch (cause) {
return hostApiError(cause);
}
}
+5 -2
View File
@@ -11,7 +11,10 @@ export function GET({ request, params }: { request: Request; params: { id: strin
const phase = search.get('phase') ?? 'all';
const page = Number(search.get('page') ?? '1');
if (scope !== 'host' && scope !== 'fleet') error(400, 'Invalid sample scope.');
if (phase !== 'all' && phase !== 'startup' && phase !== 'running') error(400, 'Invalid sample phase.');
if (phase !== 'all' && phase !== 'startup' && phase !== 'running')
error(400, 'Invalid sample phase.');
if (!Number.isSafeInteger(page) || page < 1) error(400, 'Invalid sample page.');
return json(capacitySamples(params.id, { scope, phase, page }), { headers: { 'Cache-Control': 'no-store' } });
return json(capacitySamples(params.id, { scope, phase, page }), {
headers: { 'Cache-Control': 'no-store' }
});
}
+5 -2
View File
@@ -12,7 +12,10 @@ export function GET({ request }: { request: Request }) {
if (!isHostOperationFilter(filter)) error(400, 'Invalid operation history filter.');
const scope = hostOperationScope(params);
for (const key of ['host', 'server', 'operation'] as const) {
if (params.has(key) && params.get(key) !== scope[key]) error(400, 'Invalid operation history scope.');
if (params.has(key) && params.get(key) !== scope[key])
error(400, 'Invalid operation history scope.');
}
return json(listHostOperationHistory(page, filter, scope), { headers: { 'Cache-Control': 'no-store' } });
return json(listHostOperationHistory(page, filter, scope), {
headers: { 'Cache-Control': 'no-store' }
});
}
+38 -13
View File
@@ -12,20 +12,45 @@ export async function GET({ url }: { url: URL }) {
const players = params.get('players');
const rawLimit = params.get('limit');
const rawOffset = params.get('offset');
const integer = (value: string | null, fallback: number) => value === null ? fallback
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : NaN;
const integer = (value: string | null, fallback: number) =>
value === null
? fallback
: /^\d+$/.test(value) && Number.isSafeInteger(Number(value))
? Number(value)
: NaN;
const limit = integer(rawLimit, 50);
const offset = integer(rawOffset, 0);
if ((game !== null && game !== 't5' && game !== 't6')
|| (map !== null && map.length > 1024) || (q !== null && q.length > 100)
|| (metric !== null && !isLeaderboardMetric(metric))
|| (players !== null && !/^[1-4]$/.test(players))
|| !Number.isFinite(limit) || limit < 1 || limit > 100
|| !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) {
return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 });
if (
(game !== null && game !== 't5' && game !== 't6') ||
(map !== null && map.length > 1024) ||
(q !== null && q.length > 100) ||
(metric !== null && !isLeaderboardMetric(metric)) ||
(players !== null && !/^[1-4]$/.test(players)) ||
!Number.isFinite(limit) ||
limit < 1 ||
limit > 100 ||
!Number.isFinite(offset) ||
offset < 0 ||
offset > MAX_LEADERBOARD_OFFSET
) {
return json(
{
ok: false,
kind: 'bad-request',
error: 'Use game t5 or t6, a supported metric, players 1–4, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.'
},
{ status: 400 }
);
}
return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
players: players === null ? undefined : Number(players) as LeaderboardPlayerCount,
q: q ?? undefined, limit, offset }));
return json(
await leaderboardReport({
game: game ?? undefined,
map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
players: players === null ? undefined : (Number(players) as LeaderboardPlayerCount),
q: q ?? undefined,
limit,
offset
})
);
}
+60 -18
View File
@@ -2,50 +2,92 @@ import { json } from '@sveltejs/kit';
import { maintenanceOverview } from '$lib/server/maintenance/index.js';
import { pauseMaintenance, setMaintenancePolicy } from '$lib/server/maintenance/policies.js';
import { watchdogTick } from '$lib/server/maintenance/recovery.js';
import { cancelMaintenance, checkPlutonium, queueUpdates, retryMaintenance, saveMaintenanceSchedule } from '$lib/server/maintenance/runtime.js';
import {
cancelMaintenance,
checkPlutonium,
queueUpdates,
retryMaintenance,
saveMaintenanceSchedule
} from '$lib/server/maintenance/runtime.js';
import { validTimeZone } from '$lib/time-zone.js';
import { kick } from '$lib/server/pipeline/worker.js';
export function GET(event?: { url: URL }) {
const job = event?.url.searchParams.get('job');
if (job !== undefined && job !== null && (!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))) return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
if (
job !== undefined &&
job !== null &&
(!/^[1-9]\d*$/.test(job) || !Number.isSafeInteger(Number(job)))
)
return json({ error: 'Choose a valid maintenance request.' }, { status: 400 });
return json(maintenanceOverview(job ? Number(job) : null));
}
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ error: 'Invalid request.' }, { status: 400 });
let notice = '';
if (!body || typeof body !== 'object' || Array.isArray(body))
return json({ error: 'Invalid request.' }, { status: 400 });
let notice: string;
try {
switch (body.action) {
case 'pause':
if (typeof body.paused !== 'boolean') throw new Error('Provide a paused boolean.');
pauseMaintenance(body.paused);
notice = body.paused ? 'Maintenance paused. Submitted operations continue recovery.' : 'Maintenance resumed.';
notice = body.paused
? 'Maintenance paused. Submitted operations continue recovery.'
: 'Maintenance resumed.';
break;
case 'policy':
if (!['health', 'drift', 'updates'].includes(body.policy) || typeof body.enabled !== 'boolean') throw new Error('Choose a policy and an enabled boolean.');
setMaintenancePolicy(body.policy, body.enabled); notice = 'Policy saved.';
if (
!['health', 'drift', 'updates'].includes(body.policy) ||
typeof body.enabled !== 'boolean'
)
throw new Error('Choose a policy and an enabled boolean.');
setMaintenancePolicy(body.policy, body.enabled);
notice = 'Policy saved.';
break;
case 'scan':
await Promise.all([watchdogTick(true), checkPlutonium()]); notice = 'Checks finished.';
await Promise.all([watchdogTick(true), checkPlutonium()]);
notice = 'Checks finished.';
break;
case 'update':
if (body.servers !== undefined && (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((v: unknown) => typeof v !== 'string'))) throw new Error('Choose existing fleet servers.');
if (
body.servers !== undefined &&
(!Array.isArray(body.servers) ||
!body.servers.length ||
body.servers.some((v: unknown) => typeof v !== 'string'))
)
throw new Error('Choose existing fleet servers.');
notice = `${queueUpdates(body.servers)} update restart(s) queued. Occupied servers wait until empty.`;
break;
case 'schedule':
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !validTimeZone(body.timeZone)) throw new Error('Provide enabled, a time, and a valid timezone.');
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone); notice = 'Schedule saved. Existing requests are retained.';
if (
typeof body.enabled !== 'boolean' ||
typeof body.time !== 'string' ||
!validTimeZone(body.timeZone)
)
throw new Error('Provide enabled, a time, and a valid timezone.');
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
notice = 'Schedule saved. Existing requests are retained.';
break;
case 'cancel':
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id)) return json({ error: 'Only unsubmitted restarts can be cancelled.' }, { status: 409 });
notice = 'Queued restart cancelled.'; break;
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id))
return json(
{ error: 'Only unsubmitted restarts can be cancelled.' },
{ status: 409 }
);
notice = 'Queued restart cancelled.';
break;
case 'retry':
if (!Number.isSafeInteger(body.id)) throw new Error('Choose a maintenance activity.');
notice = `${retryMaintenance(body.id)} restart(s) queued for verification.`; break;
default: throw new Error('Unknown maintenance action.');
if (!Number.isSafeInteger(body.id))
throw new Error('Choose a maintenance activity.');
notice = `${retryMaintenance(body.id)} restart(s) queued for verification.`;
break;
default:
throw new Error('Unknown maintenance action.');
}
} catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
void kick().catch(error => console.error('[maintenance] worker kick failed:', error));
} catch (error) {
return json({ error: (error as Error).message }, { status: 400 });
}
void kick().catch((error) => console.error('[maintenance] worker kick failed:', error));
return json({ ...maintenanceOverview(), notice });
}
+6 -3
View File
@@ -4,13 +4,16 @@ import { kick } from '$lib/server/pipeline/worker.js';
export function GET({ params }: { params: { id: string } }) {
const detail = watchdogDetail(params.id);
return detail ? json(detail) : json({ error: 'Maintenance finding not found.' }, { status: 404 });
return detail
? json(detail)
: json({ error: 'Maintenance finding not found.' }, { status: 404 });
}
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const body = await request.json().catch(() => null);
if (!body || !['retry','dismiss'].includes(body.action)) return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
if (!body || !['retry', 'dismiss'].includes(body.action))
return json({ error: 'Choose retry or dismiss.' }, { status: 400 });
const result = updateWatchdogIncident(params.id, body.action);
if (!result.ok) return json({ error: result.error }, { status: result.status });
void kick().catch(err => console.error('[maintenance] worker kick failed:', err));
void kick().catch((err) => console.error('[maintenance] worker kick failed:', err));
return json(watchdogDetail(params.id));
}
+5 -2
View File
@@ -8,7 +8,10 @@ export function GET({ url }: { url: URL }) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
}
let query;
try { query = readMatchQuery(url.searchParams); }
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
try {
query = readMatchQuery(url.searchParams);
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(undefined, limit, query));
}
@@ -5,15 +5,24 @@ import { ScriptError } from '$lib/server/fleet/scripts.js';
export async function POST({ request }: { request: Request }) {
let input;
try { input = await request.json(); } catch { return json({ error: 'Invalid JSON body.' }, { status: 400 }); }
if (!input || typeof input !== 'object' || Array.isArray(input)) return json({ error: 'Import details are required.' }, { status: 400 });
if ((input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
(input.scopeValue != null && typeof input.scopeValue !== 'string')) {
try {
input = await request.json();
} catch {
return json({ error: 'Invalid JSON body.' }, { status: 400 });
}
if (!input || typeof input !== 'object' || Array.isArray(input))
return json({ error: 'Import details are required.' }, { status: 400 });
if (
(input.scopeType != null && input.scopeType !== 'game' && input.scopeType !== 'map') ||
(input.scopeValue != null && typeof input.scopeValue !== 'string')
) {
return json({ error: 'Invalid plugin scope.' }, { status: 400 });
}
try { return json({ ok: true, ...await importPluginRelease(input) }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
try {
return json({ ok: true, ...(await importPluginRelease(input)) });
} catch (error) {
if (error instanceof GithubReleaseError)
return json({ error: error.message }, { status: error.status });
if (error instanceof ScriptError) return json({ error: error.message }, { status: 409 });
throw error;
}
@@ -2,9 +2,11 @@ import { json } from '@sveltejs/kit';
import { getGithubRelease, GithubReleaseError } from '$lib/server/integrations/github-releases.js';
export async function GET({ url }: { url: URL }) {
try { return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
try {
return json({ release: await getGithubRelease(url.searchParams.get('url') ?? '') });
} catch (error) {
if (error instanceof GithubReleaseError)
return json({ error: error.message }, { status: error.status });
throw error;
}
}
+10 -5
View File
@@ -8,10 +8,13 @@ export async function GET({ url }: { url: URL }) {
const raw = url.searchParams.get('pluginId');
if (raw !== null) {
const id = Number(raw);
if (!Number.isSafeInteger(id) || id < 1) return json({ error: 'Invalid plugin id.' }, { status: 400 });
try { return json({ updates: [await checkPluginUpdate(id)] }); }
catch (error) {
if (error instanceof GithubReleaseError) return json({ error: error.message }, { status: error.status });
if (!Number.isSafeInteger(id) || id < 1)
return json({ error: 'Invalid plugin id.' }, { status: 400 });
try {
return json({ updates: [await checkPluginUpdate(id)] });
} catch (error) {
if (error instanceof GithubReleaseError)
return json({ error: error.message }, { status: error.status });
throw error;
}
}
@@ -19,7 +22,9 @@ export async function GET({ url }: { url: URL }) {
const updates: PluginUpdate[] = [];
// Bound outbound concurrency for large fleets; repository requests share a five-minute cache.
for (let i = 0; i < plugins.length; i += 4) {
updates.push(...await Promise.all(plugins.slice(i, i + 4).map(p => checkPluginUpdate(p.id))));
updates.push(
...(await Promise.all(plugins.slice(i, i + 4).map((p) => checkPluginUpdate(p.id))))
);
}
return json({ updates });
}
+39 -10
View File
@@ -1,28 +1,57 @@
import { json } from '@sveltejs/kit';
import { validTimeZone } from '$lib/time-zone.js';
import { cancelMaintenance, checkPlutonium, plutoniumStatus, queueMaintenance, saveMaintenanceSchedule } from '$lib/server/fleet/plutonium.js';
import {
cancelMaintenance,
checkPlutonium,
plutoniumStatus,
queueMaintenance,
saveMaintenanceSchedule
} from '$lib/server/fleet/plutonium.js';
export function GET() { return json(plutoniumStatus()); }
export function GET() {
return json(plutoniumStatus());
}
export async function POST({ request }: { request: Request }) {
let body;
try { body = await request.json(); } catch { return json({ error: 'Invalid JSON.' }, { status: 400 }); }
if (!body || typeof body !== 'object') return json({ error: 'Invalid request.' }, { status: 400 });
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON.' }, { status: 400 });
}
if (!body || typeof body !== 'object')
return json({ error: 'Invalid request.' }, { status: 400 });
if (body.action === 'check') {
await checkPlutonium();
} else if (body.action === 'schedule') {
if (typeof body.enabled !== 'boolean' || typeof body.time !== 'string' || !/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) || !validTimeZone(body.timeZone)) {
return json({ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' }, { status: 400 });
if (
typeof body.enabled !== 'boolean' ||
typeof body.time !== 'string' ||
!/^([01]\d|2[0-3]):[0-5]\d$/.test(body.time) ||
!validTimeZone(body.timeZone)
) {
return json(
{ error: 'Provide enabled, a valid time (HH:mm), and a valid timezone.' },
{ status: 400 }
);
}
saveMaintenanceSchedule(body.enabled, body.time, body.timeZone);
} else if (body.action === 'restart') {
if (!Array.isArray(body.servers) || !body.servers.length || body.servers.some((s: unknown) => typeof s !== 'string')) {
if (
!Array.isArray(body.servers) ||
!body.servers.length ||
body.servers.some((s: unknown) => typeof s !== 'string')
) {
return json({ error: 'Choose servers to restart.' }, { status: 400 });
}
try { queueMaintenance(body.servers); }
catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
try {
queueMaintenance(body.servers);
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
} else if (body.action === 'cancel') {
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id)) return json({ error: 'Only queued restarts can be cancelled.' }, { status: 409 });
if (!Number.isSafeInteger(body.id) || !cancelMaintenance(body.id))
return json({ error: 'Only queued restarts can be cancelled.' }, { status: 409 });
} else return json({ error: 'Unknown action.' }, { status: 400 });
return json(plutoniumStatus());
}
+38 -16
View File
@@ -1,37 +1,59 @@
import { json } from '@sveltejs/kit';
import { deploymentListPage } from '$lib/server/provisioning/history.js';
import { requestLifecycle } from '$lib/server/provisioning/index.js';
import { provisioningActions, runtimeActions, type ProvisioningAction, type RuntimeAction } from '$lib/provisioning.js';
import {
provisioningActions,
runtimeActions,
type ProvisioningAction,
type RuntimeAction
} from '$lib/provisioning.js';
import { kickProvisioning } from '$lib/server/pipeline/worker.js';
import { provisioningListSources, provisioningListStatuses } from '$lib/provisioning-list.js';
export function GET({ url }: { url: URL }) {
const params = url.searchParams;
const action = params.get('action');
const status = params.get('status') as typeof provisioningListStatuses[number];
const source = params.get('source') as typeof provisioningListSources[number];
return json(deploymentListPage({
view: params.get('view') === 'history' ? 'history' : 'active',
action: provisioningActions.includes(action as ProvisioningAction) ? action as ProvisioningAction : undefined,
status: provisioningListStatuses.includes(status) ? status : undefined,
source: provisioningListSources.includes(source) ? source : undefined,
server: params.get('server') ?? '',
limit: Number(params.get('limit') ?? 25),
offset: Number(params.get('offset') ?? 0)
}), { headers: { 'Cache-Control': 'no-store' } });
const status = params.get('status') as (typeof provisioningListStatuses)[number];
const source = params.get('source') as (typeof provisioningListSources)[number];
return json(
deploymentListPage({
view: params.get('view') === 'history' ? 'history' : 'active',
action: provisioningActions.includes(action as ProvisioningAction)
? (action as ProvisioningAction)
: undefined,
status: provisioningListStatuses.includes(status) ? status : undefined,
source: provisioningListSources.includes(source) ? source : undefined,
server: params.get('server') ?? '',
limit: Number(params.get('limit') ?? 25),
offset: Number(params.get('offset') ?? 0)
}),
{ headers: { 'Cache-Control': 'no-store' } }
);
}
export async function POST({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (!body || typeof body.server !== 'string' || !body.server || !runtimeActions.includes(body.action)) {
return json({ error: 'server and a valid lifecycle action are required.' }, { status: 400 });
if (
!body ||
typeof body.server !== 'string' ||
!body.server ||
!runtimeActions.includes(body.action)
) {
return json(
{ error: 'server and a valid lifecycle action are required.' },
{ status: 400 }
);
}
try {
const result = requestLifecycle(body.server, body.action as RuntimeAction);
if (!result.ok) return json({ error: result.error }, { status: result.status });
setImmediate(() => {
void kickProvisioning().catch(err => console.error('[provisioning] worker kick failed:', err));
void kickProvisioning().catch((err) =>
console.error('[provisioning] worker kick failed:', err)
);
});
return json(result, { status: 202 });
} catch (err) { return json({ error: (err as Error).message }, { status: 400 }); }
} catch (err) {
return json({ error: (err as Error).message }, { status: 400 });
}
}
@@ -4,7 +4,11 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
const cleanupId = cancelCreation(params.id);
if (!cleanupId) return json({ error: 'This creation can no longer be removed. Refresh its current state.' }, { status: 409 });
if (!cleanupId)
return json(
{ error: 'This creation can no longer be removed. Refresh its current state.' },
{ status: 409 }
);
void kickProvisioning();
return json({ ok: true, cleanupId });
}
@@ -12,13 +12,22 @@ function integer(params: URLSearchParams, key: string): number | undefined {
}
export function GET({ params, url }: { params: { id: string }; url: URL }) {
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ?').get(params.id)) error(404, 'Provisioning operation not found.');
if (!db().prepare('SELECT 1 FROM server_deployments WHERE id = ?').get(params.id))
error(404, 'Provisioning operation not found.');
const order = url.searchParams.get('order') ?? 'newest';
if (order !== 'newest' && order !== 'oldest') error(400, 'Choose newest or oldest activity order.');
if (order !== 'newest' && order !== 'oldest')
error(400, 'Choose newest or oldest activity order.');
const query = url.searchParams.get('query') ?? '';
if (query.length > 500) error(400, 'Activity search must be 500 characters or fewer.');
const limit = integer(url.searchParams, 'limit');
if (limit === 0) error(400, 'Activity limit must be at least 1.');
return json(deploymentActivity(params.id, { order, query, limit,
cursor: integer(url.searchParams, 'cursor'), watermark: integer(url.searchParams, 'watermark') }));
return json(
deploymentActivity(params.id, {
order,
query,
limit,
cursor: integer(url.searchParams, 'cursor'),
watermark: integer(url.searchParams, 'watermark')
})
);
}
@@ -5,10 +5,16 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') return json({ error: 'Administrator access is required.' }, { status: 403 });
if (!locals.user || locals.user.role !== 'admin')
return json({ error: 'Administrator access is required.' }, { status: 403 });
let body: Record<string, unknown>;
try { body = await authBody(request); } catch (error) { return authError(error); }
if (body.confirmed !== true) return json({ error: 'Confirm the admin override before continuing.' }, { status: 400 });
try {
body = await authBody(request);
} catch (error) {
return authError(error);
}
if (body.confirmed !== true)
return json({ error: 'Confirm the admin override before continuing.' }, { status: 400 });
const result = enableSafetyOverride(params.id, locals.user);
if (!result.ok) return json({ error: result.error }, { status: 409 });
void kickProvisioning();
@@ -5,6 +5,8 @@ import { kickProvisioning } from '$lib/server/pipeline/worker.js';
export function POST({ params }: { params: { id: string } }) {
const result = requestRetryWithRebuild(params.id);
if (!result.ok) return json({ error: result.error }, { status: result.status });
void kickProvisioning().catch(err => console.error('[provisioning] rebuild retry kick failed:', err));
void kickProvisioning().catch((err) =>
console.error('[provisioning] rebuild retry kick failed:', err)
);
return json(result, { status: 202 });
}
+26 -6
View File
@@ -1,14 +1,34 @@
import { json } from '@sveltejs/kit';
import { RebalanceError, previewRebalance, rebalanceReport, startRebalance, stopRebalance } from '$lib/server/rebalancing/index.js';
import {
RebalanceError,
previewRebalance,
rebalanceReport,
startRebalance,
stopRebalance
} from '$lib/server/rebalancing/index.js';
export function GET() { return json(rebalanceReport()); }
export function GET() {
return json(rebalanceReport());
}
export async function POST({ request }: { request: Request }) {
try {
const body = await request.json();
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new RebalanceError('Provide a rebalance action.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new RebalanceError('Provide a rebalance action.');
if (body.action === 'preview') return json({ preview: previewRebalance(body.settings) });
if (body.action === 'start' && typeof body.previewId === 'string' && body.previewId.length <= 100) return json({ run: startRebalance(body.previewId) });
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100) return json({ run: stopRebalance(body.id) });
if (
body.action === 'start' &&
typeof body.previewId === 'string' &&
body.previewId.length <= 100
)
return json({ run: startRebalance(body.previewId) });
if (body.action === 'stop' && typeof body.id === 'string' && body.id.length <= 100)
return json({ run: stopRebalance(body.id) });
throw new RebalanceError('Choose a supported rebalance action.');
} catch (error) { return json({ error: (error as Error).message }, { status: error instanceof RebalanceError ? error.status : 400 }); }
} catch (error) {
return json(
{ error: (error as Error).message },
{ status: error instanceof RebalanceError ? error.status : 400 }
);
}
}
+9 -5
View File
@@ -16,7 +16,10 @@ function scopeTypeOrNull(v: FormDataEntryValue | null): ScopeType | null {
throw new ScriptError(`invalid scope_type: ${s}`, 'invalid-scope');
}
function kindOrDefault(v: FormDataEntryValue | string | null, fallback: ArtifactKind = 'script'): ArtifactKind {
function kindOrDefault(
v: FormDataEntryValue | string | null,
fallback: ArtifactKind = 'script'
): ArtifactKind {
if (v === null || v === undefined || v === '') return fallback;
const s = String(v);
if (!isArtifactKind(s)) throw new ScriptError(`invalid kind: ${s}`, 'invalid-kind');
@@ -66,9 +69,8 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
// Target rel path: for script/plugin, default to the upload filename.
// For config/baseline, createScript() defaults to config.configTargetFile.
const targetRelPath = kind === 'script' || kind === 'plugin'
? uploadedFilename ?? undefined
: undefined;
const targetRelPath =
kind === 'script' || kind === 'plugin' ? (uploadedFilename ?? undefined) : undefined;
try {
const { script, version } = createScript({
@@ -78,7 +80,9 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
scopeValue,
content,
notes,
releaseVersion: form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined,
releaseVersion: form.has('releaseVersion')
? String(form.get('releaseVersion'))
: undefined,
kind
});
return json({ ok: true, script, versionId: version.id });
+17 -7
View File
@@ -29,21 +29,31 @@ export async function GET({ params }: { params: { id: string } }) {
sha256: v.sha256,
size: v.size,
notes: v.notes,
source: script.kind === 'plugin'
? db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as PluginSource | undefined ?? null
: null,
source:
script.kind === 'plugin'
? ((db().prepare('SELECT * FROM plugin_imports WHERE version_id = ?').get(v.id) as
PluginSource | undefined) ?? null)
: null,
state: v.state,
uploaded_at: v.uploaded_at
}));
const matched = script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
: [];
const matched =
script.scope_type && script.scope_value
? serversMatchingScope(script.scope_type as ScopeType, script.scope_value)
: [];
const runningBeta = findRunningBetaForScript(id);
return json({
script,
versions,
installedVersions: script.kind === 'script' || script.kind === 'plugin' ? db().prepare(`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`).all(id, script.target_rel_path) : [],
installedVersions:
script.kind === 'script' || script.kind === 'plugin'
? db()
.prepare(
`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
)
.all(id, script.target_rel_path)
: [],
matchedServers: matched,
runningBetaId: runningBeta?.id ?? null
});
+36 -7
View File
@@ -1,23 +1,52 @@
import { json } from '@sveltejs/kit';
import { getScript, setScope, serversMatchingScope, ScriptError, type ScopeType } from '$lib/server/fleet/scripts.js';
import {
getScript,
setScope,
serversMatchingScope,
ScriptError,
type ScopeType
} from '$lib/server/fleet/scripts.js';
import { unsetScopeAndDetach } from '$lib/server/fleet/beta.js';
import { db } from '$lib/server/db/index.js';
export async function GET({ params, url }: { params: { id: string }; url: URL }) {
const id = Number(params.id);
if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid file ID.' }, { status: 400 });
if (!Number.isSafeInteger(id) || id <= 0)
return json({ error: 'Invalid file ID.' }, { status: 400 });
const script = getScript(id);
if (!script) return json({ error: 'File not found.' }, { status: 404 });
const type = url.searchParams.get('type');
const value = url.searchParams.get('value');
if (type !== 'none' && type !== 'game' && type !== 'map' || type !== 'none' && !value) return json({ error: 'Choose a complete scope.' }, { status: 400 });
if ((type !== 'none' && type !== 'game' && type !== 'map') || (type !== 'none' && !value))
return json({ error: 'Choose a complete scope.' }, { status: 400 });
const before = serversMatchingScope(script.scope_type, script.scope_value);
const after = type === 'none' ? [] : serversMatchingScope(type as ScopeType, value);
const composed = script.kind === 'config' || script.kind === 'baseline';
const tracked = composed ? [] : (db().prepare(`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`).all(script.target_rel_path, script.id) as Array<{ server: string }>).map(row => row.server);
return json({ before, after, added: after.filter(name => !before.includes(name)), removed: before.filter(name => !after.includes(name)),
affected: composed ? [...new Set([...before, ...after])].sort() : type === 'none' ? tracked : [...new Set([...before, ...after])].sort(),
composed, detached: type === 'none' ? tracked : [] }, { headers: { 'Cache-Control': 'no-store' } });
const tracked = composed
? []
: (
db()
.prepare(
`SELECT DISTINCT server FROM server_scripts WHERE rel_path = ? AND script_version_id IN (SELECT id FROM script_versions WHERE script_id = ?) ORDER BY server`
)
.all(script.target_rel_path, script.id) as Array<{ server: string }>
).map((row) => row.server);
return json(
{
before,
after,
added: after.filter((name) => !before.includes(name)),
removed: before.filter((name) => !after.includes(name)),
affected: composed
? [...new Set([...before, ...after])].sort()
: type === 'none'
? tracked
: [...new Set([...before, ...after])].sort(),
composed,
detached: type === 'none' ? tracked : []
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
function parseId(raw: string): number | null {
@@ -18,27 +18,44 @@ export async function POST({ params, request }: { params: { id: string }; reques
const script = getScript(scriptId);
if (!script) return json({ ok: false, error: 'not found' }, { status: 404 });
if ((script.kind === 'config' || script.kind === 'baseline') && findRunningBetaForScript(scriptId)) {
return json({ ok: false, error: 'Finish the active beta before saving a config version.' }, { status: 409 });
if (
(script.kind === 'config' || script.kind === 'baseline') &&
findRunningBetaForScript(scriptId)
) {
return json(
{ ok: false, error: 'Finish the active beta before saving a config version.' },
{ status: 409 }
);
}
const form = await request.formData();
const notes = form.get('notes') ? String(form.get('notes')) : undefined;
const file = form.get('content');
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
if (!(file instanceof File))
return json({ ok: false, error: 'content file is required' }, { status: 400 });
const content = Buffer.from(await file.arrayBuffer());
if (script.kind === 'script' && form.get('reuseExisting') === 'true') {
if (!content.length) return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
if (!content.length)
return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
if (findRunningBetaForScript(scriptId)) {
return json({ ok: false, error: 'Finish the active beta before uploading a version.' }, { status: 409 });
return json(
{ ok: false, error: 'Finish the active beta before uploading a version.' },
{ status: 409 }
);
}
const sha = createHash('sha256').update(content).digest('hex');
const existing = db().prepare('SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1').get(scriptId, sha);
const existing = db()
.prepare(
'SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1'
)
.get(scriptId, sha);
if (existing) return json({ ok: true, version: existing, reused: true });
}
try {
const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined;
const releaseVersion = form.has('releaseVersion')
? String(form.get('releaseVersion'))
: undefined;
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
return json({
ok: true,
+9 -3
View File
@@ -9,9 +9,15 @@ export async function POST({ request }: { request: Request }) {
if (!(file instanceof File) || file.size === 0) {
return json({ error: 'Choose a non-empty script file.' }, { status: 400 });
}
const sha256 = createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex');
const duplicates = db().prepare(`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
const sha256 = createHash('sha256')
.update(Buffer.from(await file.arrayBuffer()))
.digest('hex');
const duplicates = db()
.prepare(
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
FROM script_versions v JOIN scripts s ON s.id = v.script_id
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`).all(sha256);
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`
)
.all(sha256);
return json({ sha256, duplicates });
}
+2 -1
View File
@@ -17,7 +17,8 @@ export async function PUT({ request }: { request: Request }) {
export async function DELETE({ request }: { request: Request }) {
const body = await request.json().catch(() => null);
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128) return json({ error: 'Invalid key ID' }, { status: 400 });
if (typeof body?.id !== 'string' || !body.id.trim() || body.id.length > 128)
return json({ error: 'Invalid key ID' }, { status: 400 });
try {
return json({ ok: removeServerKey(body.id) });
} catch (err) {
+45 -9
View File
@@ -4,11 +4,18 @@ import { json } from '@sveltejs/kit';
import { queueCreateServer } from '$lib/server/provisioning/index.js';
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
export function GET() { return json(fleetSnapshot()); }
export function GET() {
return json(fleetSnapshot());
}
export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as {
hostId?: string; libraryId?: string; image?: string; bindAddress?: string; publicAddress?: string; publicPort?: number;
hostId?: string;
libraryId?: string;
image?: string;
bindAddress?: string;
publicAddress?: string;
publicPort?: number;
game?: string;
gameMode?: string;
mapSlug?: string;
@@ -16,9 +23,21 @@ export async function POST({ request }: { request: Request }) {
expectedName?: string;
previewId?: string;
};
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string') || (body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))) {
if (
!body ||
(body.game !== undefined && body.game !== 't5' && body.game !== 't6') ||
typeof body.gameMode !== 'string' ||
typeof body.mapSlug !== 'string' ||
(body.plutoKey !== undefined && typeof body.plutoKey !== 'string') ||
(body.expectedName !== undefined && typeof body.expectedName !== 'string') ||
(body.previewId !== undefined && (typeof body.previewId !== 'string' || !body.previewId))
) {
return json(
{ ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
{
ok: false,
error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings',
kind: 'bad-request'
},
{ status: 400 }
);
}
@@ -29,17 +48,34 @@ export async function POST({ request }: { request: Request }) {
plutoKey: body.plutoKey,
expectedName: body.expectedName,
previewId: body.previewId,
createdBy: 'ui', hostId: body.hostId, libraryId: body.libraryId, image: body.image, bindAddress: body.bindAddress, publicAddress: body.publicAddress, publicPort: body.publicPort
createdBy: 'ui',
hostId: body.hostId,
libraryId: body.libraryId,
image: body.image,
bindAddress: body.bindAddress,
publicAddress: body.publicAddress,
publicPort: body.publicPort
});
if (!result.ok) {
const status = result.kind === 'bad-request' ? 400 : result.kind === 'unconfigured' ? 503
: result.kind === 'conflict' || result.kind === 'unknown-map' || result.kind === 'no-slot' || result.kind === 'no-port' ? 409
: 500;
const status =
result.kind === 'bad-request'
? 400
: result.kind === 'unconfigured'
? 503
: result.kind === 'conflict' ||
result.kind === 'unknown-map' ||
result.kind === 'no-slot' ||
result.kind === 'no-port'
? 409
: 500;
return json({ ok: false, error: result.message, kind: result.kind }, { status });
}
// Return the durable job ID before the worker's synchronous admission work.
setImmediate(() => {
void kickProvisioning().catch((err) => console.error('[create] worker kick failed:', err));
});
return json({ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId }, { status: 202 });
return json(
{ ok: true, name: result.name, port: result.port, deploymentId: result.deploymentId },
{ status: 202 }
);
}
+8 -2
View File
@@ -10,12 +10,18 @@ export function GET({ params }: { params: { name: string } }) {
export async function DELETE({ params }: { params: { name: string } }) {
const name = params.name;
if (!name) {
return json({ ok: false, error: 'server name required', kind: 'bad-request' }, { status: 400 });
return json(
{ ok: false, error: 'server name required', kind: 'bad-request' },
{ status: 400 }
);
}
const result = await destroyServer(name);
if (!result.ok) {
const status = result.kind === 'not-found' ? 404 : result.kind === 'conflict' ? 409 : 500;
return json({ ok: false, error: result.message, kind: result.kind, warnings: result.warnings }, { status });
return json(
{ ok: false, error: result.message, kind: result.kind, warnings: result.warnings },
{ status }
);
}
setImmediate(() => {
void kickProvisioning().catch((err) => console.error('[destroy] worker kick failed:', err));
@@ -10,10 +10,11 @@ export async function GET({ params }: { params: { name: string } }) {
return json({ ok: false, reason: 'Server not found' }, { status: 404, headers });
}
const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
if (!path)
return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
try {
return json({ ok: true, ...await collectServerChat(server) }, { headers });
return json({ ok: true, ...(await collectServerChat(server)) }, { headers });
} catch (error) {
console.error('Could not read server chat', error);
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
@@ -8,7 +8,10 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
}
let query;
try { query = readMatchQuery(url.searchParams); }
catch { return json({ error: 'Invalid match history filters.' }, { status: 400 }); }
try {
query = readMatchQuery(url.searchParams);
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(params.name, limit, query));
}
@@ -26,7 +26,12 @@ export const POST: RequestHandler = async ({ params }) => {
// Keep the diagnostic response, but never persist old-process evidence
// across a lifecycle request or replacement registration.
const current = getServer(name);
if (!current || current.created_at !== row.created_at || epoch !== latestGameSubmission(name) || isProvisioning(name)) {
if (
!current ||
current.created_at !== row.created_at ||
epoch !== latestGameSubmission(name) ||
isProvisioning(name)
) {
return json(result);
}
const now = new Date().toISOString();
@@ -6,6 +6,13 @@ import { redactRconAddresses } from '$lib/server/integrations/rcon-diagnostics.j
export function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server || server.destroyed_at) return json({ error: 'No such server.' }, { status: 404 });
return json({ replySnippet: server.last_error && server.last_reply_snippet
? redactRconAddresses(server.last_reply_snippet) : null }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{
replySnippet:
server.last_error && server.last_reply_snippet
? redactRconAddresses(server.last_reply_snippet)
: null
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
@@ -6,16 +6,34 @@ import { db } from '$lib/server/db/index.js';
export async function GET({ params }: { params: { name: string } }) {
const server = getServer(params.name);
if (!server) return json({ ok: false, error: 'no such server' }, { status: 404 });
const metadata = db().prepare(`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
const metadata = db()
.prepare(
`SELECT i.rel_path, s.kind, s.id AS artifactId, v.version, v.release_version AS releaseVersion FROM server_scripts i
JOIN script_versions v ON v.id = i.script_version_id JOIN scripts s ON s.id = v.script_id
WHERE i.server = ? AND i.sha256 = v.sha256`).all(params.name) as Array<{ rel_path: string; kind: string; artifactId: number; version: number; releaseVersion: string | null }>;
const versions = new Map(metadata.map(row => [row.rel_path, row]));
WHERE i.server = ? AND i.sha256 = v.sha256`
)
.all(params.name) as Array<{
rel_path: string;
kind: string;
artifactId: number;
version: number;
releaseVersion: string | null;
}>;
const versions = new Map(metadata.map((row) => [row.rel_path, row]));
const scripts = listServerScripts(params.name).map((r) => ({
relPath: r.rel_path,
artifactId: versions.get(r.rel_path)?.artifactId ?? null,
version: versions.get(r.rel_path)?.version ?? null,
releaseVersion: versions.get(r.rel_path)?.releaseVersion ?? null,
kind: versions.get(r.rel_path)?.kind ?? (/\.cfg$/i.test(r.rel_path) ? 'config' : /\.dll$/i.test(r.rel_path) ? 'plugin' : /\.gsc$/i.test(r.rel_path) ? 'script' : 'file'),
kind:
versions.get(r.rel_path)?.kind ??
(/\.cfg$/i.test(r.rel_path)
? 'config'
: /\.dll$/i.test(r.rel_path)
? 'plugin'
: /\.gsc$/i.test(r.rel_path)
? 'script'
: 'file'),
scriptVersionId: r.script_version_id,
sha256: r.sha256,
size: r.size,
@@ -3,8 +3,12 @@ import { getServer } from '$lib/server/fleet/servers.js';
import { listTelemetryRequests, telemetryRetention } from '$lib/server/fleet/telemetry-requests.js';
export async function GET({ params }: { params: { name: string } }) {
if (!getServer(params.name)) return json({ ok: false, error: 'no such server' }, { status: 404 });
return json({ ok: true, requests: listTelemetryRequests(params.name), retention: telemetryRetention() }, {
headers: { 'Cache-Control': 'no-store' }
});
if (!getServer(params.name))
return json({ ok: false, error: 'no such server' }, { status: 404 });
return json(
{ ok: true, requests: listTelemetryRequests(params.name), retention: telemetryRetention() },
{
headers: { 'Cache-Control': 'no-store' }
}
);
}
+27 -8
View File
@@ -2,22 +2,41 @@ import { json } from '@sveltejs/kit';
import { validateFleetSettings, concurrencyKeys, retentionKeys } from '$lib/fleet-settings.js';
import { fleetSettings, saveFleetSettings } from '$lib/server/settings.js';
export function GET() { return json(fleetSettings()); }
export function GET() {
return json(fleetSettings());
}
export async function PUT({ request }: { request: Request }) {
let settings;
try { settings = validateFleetSettings(await request.json()); }
catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
try {
settings = validateFleetSettings(await request.json());
} catch (error) {
return json({ error: (error as Error).message }, { status: 400 });
}
return json(saveFleetSettings(settings));
}
export async function PATCH({ request }: { request: Request }) {
try {
const { section, values } = await request.json();
const keys = section === 'Concurrency' ? concurrencyKeys : section === 'Retention' ? retentionKeys : null;
if (!keys || !values || typeof values !== 'object' || Array.isArray(values)
|| Object.keys(values).length !== keys.length || keys.some(key => !Object.hasOwn(values, key))
|| Object.keys(values).some(key => !keys.includes(key as typeof keys[number]))) throw new Error('Provide all settings for one section.');
const keys =
section === 'Concurrency'
? concurrencyKeys
: section === 'Retention'
? retentionKeys
: null;
if (
!keys ||
!values ||
typeof values !== 'object' ||
Array.isArray(values) ||
Object.keys(values).length !== keys.length ||
keys.some((key) => !Object.hasOwn(values, key)) ||
Object.keys(values).some((key) => !keys.includes(key as (typeof keys)[number]))
)
throw new Error('Provide all settings for one section.');
return json(saveFleetSettings({ ...fleetSettings(), ...values }));
} catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
} catch (error) {
return json({ error: (error as Error).message }, { status: 400 });
}
}
+23 -4
View File
@@ -1,11 +1,30 @@
import { json } from '@sveltejs/kit';
import { retentionPreview, retentionControls, sampleRetentionPreview, setRetentionControl } from '$lib/server/retention.js';
import {
retentionPreview,
retentionControls,
sampleRetentionPreview,
setRetentionControl
} from '$lib/server/retention.js';
import { gameFileRetentionPreview } from '$lib/server/game-files/index.js';
export function GET() {
return json({ policies: [...retentionPreview(),...sampleRetentionPreview(),...gameFileRetentionPreview()], controls: retentionControls() }, { headers: { 'Cache-Control': 'no-store' } });
return json(
{
policies: [
...retentionPreview(),
...sampleRetentionPreview(),
...gameFileRetentionPreview()
],
controls: retentionControls()
},
{ headers: { 'Cache-Control': 'no-store' } }
);
}
export async function POST({ request }: { request: Request }) {
try { setRetentionControl(await request.json()); return json({ ok: true }); }
catch (error) { return json({ error: (error as Error).message }, { status: 400 }); }
try {
setRetentionControl(await request.json());
return json({ ok: true });
} catch (error) {
return json({ error: (error as Error).message }, { status: 400 });
}
}
@@ -1,10 +1,26 @@
import { json } from '@sveltejs/kit';
import { validateFleetSettings } from '$lib/fleet-settings.js';
import { retentionPreview,retentionControls,sampleRetentionPreview } from '$lib/server/retention.js';
import {
retentionPreview,
retentionControls,
sampleRetentionPreview
} from '$lib/server/retention.js';
import { gameFileRetentionPreview } from '$lib/server/game-files/index.js';
export async function POST({ request }: { request: Request }) {
try {
const settings = validateFleetSettings(await request.json());
return json({ policies:[...retentionPreview(settings),...sampleRetentionPreview(settings),...gameFileRetentionPreview(settings)],controls:retentionControls() },{ headers:{'Cache-Control':'no-store'} });
} catch (error) { return json({error:(error as Error).message},{status:400}); }
return json(
{
policies: [
...retentionPreview(settings),
...sampleRetentionPreview(settings),
...gameFileRetentionPreview(settings)
],
controls: retentionControls()
},
{ headers: { 'Cache-Control': 'no-store' } }
);
} catch (error) {
return json({ error: (error as Error).message }, { status: 400 });
}
}
+87 -14
View File
@@ -9,22 +9,95 @@ import { listLibraries } from '$lib/server/game-files/index.js';
export async function GET() {
const servers = listServers();
const readyServers = servers.filter(server => serverObservation(server).health === 'online').length;
const rconServers = servers.filter(server => !server.last_error && freshStatusTimestamp(server.last_seen_at, Date.now()) && server.container_state === 'running').length;
const readyServers = servers.filter(
(server) => serverObservation(server).health === 'online'
).length;
const rconServers = servers.filter(
(server) =>
!server.last_error &&
freshStatusTimestamp(server.last_seen_at, Date.now()) &&
server.container_state === 'running'
).length;
const savedKeys = listServerKeys().length;
const hosts = listHosts();
const readyHosts = hosts.filter(host => host.readiness === 'ready');
const connected = hosts.filter(host => host.connected);
const versions = listLibraries().flatMap(game => game.versions.filter(version => version.id === game.latestVersionId));
const cached = versions.filter(version => version.caches.some(cache => cache.status === 'ready' && readyHosts.some(host => host.id === cache.hostId)));
const runtimes = connected.filter(host => host.inventory.runtimeImages?.length);
const readyHosts = hosts.filter((host) => host.readiness === 'ready');
const connected = hosts.filter((host) => host.connected);
const versions = listLibraries().flatMap((game) =>
game.versions.filter((version) => version.id === game.latestVersionId)
);
const cached = versions.filter((version) =>
version.caches.some(
(cache) =>
cache.status === 'ready' && readyHosts.some((host) => host.id === cache.hostId)
)
);
const runtimes = connected.filter((host) => host.inventory.runtimeImages?.length);
const checks: SetupCheck[] = [
{ id: 'hosts', title: 'Host agents', state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured', detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`, href: '/hosts', action: hosts.length ? 'Review hosts' : 'Add host' },
{ id: 'runtime', title: 'Plutainer runtime', state: runtimes.length ? 'ready' : 'attention', detail: runtimes.length ? `${runtimes.length} connected hosts report cached runtime images.` : 'Hosts prepare the configured Plutainer runtime automatically.', href: '/hosts', action: 'View host setup' },
{ id: 'game-files', title: 'Game files', state: cached.length ? 'ready' : 'attention', detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`, href: '/game-files', action: 'Manage game files' },
{ id: 'rcon', title: 'Agent-local game connection', state: rconServers ? 'ready' : 'unverified', detail: rconServers ? `${rconServers} servers have fresh RCON responses.` : 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.', href: servers.length ? '/' : '/hosts', action: servers.length ? 'Review fleet' : 'Review hosts' },
{ id: 'keys', title: 'Saved server keys', state: savedKeys ? 'ready' : 'attention', detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`, href: '/server-keys', action: 'Manage keys' },
{ id: 'first-server', title: 'First server ready', state: readyServers ? 'ready' : 'attention', detail: readyServers ? `${readyServers} of ${servers.length} servers are responding.` : servers.length ? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.' : 'Choose a host to launch your first server with the latest game files.', href: servers.length ? '/' : '/provisioning', action: servers.length ? 'Review fleet' : 'Create a server' }
{
id: 'hosts',
title: 'Host agents',
state: readyHosts.length ? 'ready' : hosts.length ? 'attention' : 'unconfigured',
detail: `${connected.length} connected, ${readyHosts.length} deployment-ready, ${hosts.length} enrolled or awaiting enrollment.`,
href: '/hosts',
action: hosts.length ? 'Review hosts' : 'Add host'
},
{
id: 'runtime',
title: 'Plutainer runtime',
state: runtimes.length ? 'ready' : 'attention',
detail: runtimes.length
? `${runtimes.length} connected hosts report cached runtime images.`
: 'Hosts prepare the configured Plutainer runtime automatically.',
href: '/hosts',
action: 'View host setup'
},
{
id: 'game-files',
title: 'Game files',
state: cached.length ? 'ready' : 'attention',
detail: `${versions.length} games have uploaded files; ${cached.length} have their latest files verified on a ready host.`,
href: '/game-files',
action: 'Manage game files'
},
{
id: 'rcon',
title: 'Agent-local game connection',
state: rconServers ? 'ready' : 'unverified',
detail: rconServers
? `${rconServers} servers have fresh RCON responses.`
: 'The host agent checks RCON locally after deployment. Controller access to game ports is not required.',
href: servers.length ? '/' : '/hosts',
action: servers.length ? 'Review fleet' : 'Review hosts'
},
{
id: 'keys',
title: 'Saved server keys',
state: savedKeys ? 'ready' : 'attention',
detail: `${savedKeys} saved ${savedKeys === 1 ? 'key' : 'keys'}. Validity is checked with Plutonium at startup.`,
href: '/server-keys',
action: 'Manage keys'
},
{
id: 'first-server',
title: 'First server ready',
state: readyServers ? 'ready' : 'attention',
detail: readyServers
? `${readyServers} of ${servers.length} servers are responding.`
: servers.length
? 'Registered servers are awaiting fresh healthy reports. Inspect their host and diagnostics.'
: 'Choose a host to launch your first server with the latest game files.',
href: servers.length ? '/' : '/provisioning',
action: servers.length ? 'Review fleet' : 'Create a server'
}
];
return json({ checkedAt: new Date().toISOString(), checks, readyServers, totalServers: servers.length, savedKeys } satisfies SystemStatus, { headers: { 'cache-control': 'no-store' } });
return json(
{
checkedAt: new Date().toISOString(),
checks,
readyServers,
totalServers: servers.length,
savedKeys
} satisfies SystemStatus,
{ headers: { 'cache-control': 'no-store' } }
);
}
+26 -12
View File
@@ -13,21 +13,28 @@ export async function POST({ request }: { request: Request }) {
if (!authentication.ok) {
// Unauthenticated bodies are never parsed, attributed to a server, or
// retained in telemetry diagnostics; they cannot affect game evidence.
return json({ ok: false, error: authentication.error }, {
status: authentication.status,
headers: { 'Cache-Control': 'no-store' }
});
return json(
{ ok: false, error: authentication.error },
{
status: authentication.status,
headers: { 'Cache-Control': 'no-store' }
}
);
}
const receivedAt = new Date().toISOString();
const contentType = request.headers.get('content-type');
let value: unknown;
let malformed = false;
try { value = await request.json(); }
catch { malformed = true; }
try {
value = await request.json();
} catch {
malformed = true;
}
function log(status: number, error: string | null): void {
try { recordTelemetryRequest({ value, receivedAt, status, error, contentType }); }
catch (error) {
try {
recordTelemetryRequest({ value, receivedAt, status, error, contentType });
} catch (error) {
// Diagnostics must not turn a saved report into a failed request.
console.error('Could not record telemetry request diagnostics', error);
}
@@ -42,11 +49,15 @@ export async function POST({ request }: { request: Request }) {
}
if (malformed) return reject(400, 'Malformed JSON');
let telemetry: GameTelemetry;
try { telemetry = { ...parseGameEvent(value), receivedAt }; }
catch (error) { return reject(400, (error as Error).message); }
try {
telemetry = { ...parseGameEvent(value), receivedAt };
} catch (error) {
return reject(400, (error as Error).message);
}
try {
const server = getServer(telemetry.server);
if (!server || server.destroyed_at) return reject(404, 'Server is not registered or has been destroyed');
if (!server || server.destroyed_at)
return reject(404, 'Server is not registered or has been destroyed');
db().transaction(() => {
telemetry = saveTelemetry(telemetry);
recordGameEvent(telemetry);
@@ -54,7 +65,10 @@ export async function POST({ request }: { request: Request }) {
emit({ type: 'server-telemetry', entries: [telemetry] });
} catch (error) {
console.error('Could not process telemetry', error);
log(500, `Could not process telemetry: ${error instanceof Error ? error.message : String(error)}`);
log(
500,
`Could not process telemetry: ${error instanceof Error ? error.message : String(error)}`
);
return json({ ok: false, error: 'Could not process telemetry' }, { status: 500 });
}
log(200, null);
+9 -3
View File
@@ -1,8 +1,14 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { createUser, listUsers } from '$lib/server/auth/index.js';
import { authBody, authError, requireUser } from '$lib/server/auth/http.js';
export const GET: RequestHandler = () => json({ users:listUsers() });
export const GET: RequestHandler = () => json({ users: listUsers() });
export const POST: RequestHandler = async ({ request, locals }) => {
try { return json({ user:await createUser(requireUser(locals.user).id,await authBody(request)) },{status:201}); }
catch (cause) { return authError(cause); }
try {
return json(
{ user: await createUser(requireUser(locals.user).id, await authBody(request)) },
{ status: 201 }
);
} catch (cause) {
return authError(cause);
}
};

Some files were not shown because too many files have changed in this diff Show More