feat: overhaul server automation, files editor, and CS2 setup workflows

This commit is contained in:
2026-02-26 21:01:00 +00:00
parent 44c439e2f9
commit 2a3ad5e78f
40 changed files with 4675 additions and 468 deletions
+246 -6
View File
@@ -32,6 +32,21 @@ interface DaemonServerResponse {
status: string;
}
interface DaemonNodeStatusRaw {
version: string;
is_healthy: boolean;
uptime_seconds: number;
active_servers: number;
}
interface DaemonNodeStatsRaw {
cpu_percent: number;
memory_used: number;
memory_total: number;
disk_used: number;
disk_total: number;
}
interface DaemonStatusResponse {
uuid: string;
state: string;
@@ -66,6 +81,13 @@ interface DaemonPlayerListRaw {
max_players: number;
}
interface DaemonBackupResponseRaw {
backup_id: string;
size_bytes: number;
checksum: string;
success: boolean;
}
export interface DaemonConsoleOutput {
uuid: string;
line: string;
@@ -95,9 +117,40 @@ export interface DaemonPlayersResponse {
maxPlayers: number;
}
export interface DaemonBackupResponse {
backupId: string;
sizeBytes: number;
checksum: string;
success: boolean;
}
export interface DaemonNodeStatus {
version: string;
isHealthy: boolean;
uptimeSeconds: number;
activeServers: number;
}
export interface DaemonNodeStats {
cpuPercent: number;
memoryUsed: number;
memoryTotal: number;
diskUsed: number;
diskTotal: number;
}
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
interface DaemonServiceClient extends grpc.Client {
getNodeStatus(
request: EmptyResponse,
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonNodeStatusRaw>,
): void;
streamNodeStats(
request: EmptyResponse,
metadata: grpc.Metadata,
): grpc.ClientReadableStream<DaemonNodeStatsRaw>;
createServer(
request: DaemonCreateServerRequest,
metadata: grpc.Metadata,
@@ -147,6 +200,21 @@ interface DaemonServiceClient extends grpc.Client {
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
createBackup(
request: { server_uuid: string; backup_id: string; cdn_upload_url?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonBackupResponseRaw>,
): void;
restoreBackup(
request: { server_uuid: string; backup_id: string; cdn_download_url?: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
deleteBackup(
request: { server_uuid: string; backup_id: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
getActivePlayers(
request: { uuid: string },
metadata: grpc.Metadata,
@@ -183,27 +251,34 @@ const POWER_ACTIONS: Record<PowerAction, number> = {
kill: 3,
};
const MAX_GRPC_MESSAGE_BYTES = 32 * 1024 * 1024;
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
const trimmed = fqdn.trim();
if (!trimmed) throw new Error('Node FQDN is empty');
let host = trimmed;
if (trimmed.includes('://')) {
try {
const parsed = new URL(trimmed);
const host = parsed.hostname || parsed.host;
host = parsed.hostname || parsed.host;
if (!host) throw new Error('Node FQDN has no hostname');
if (parsed.port) return `${host}:${parsed.port}`;
return `${host}:${grpcPort}`;
} catch {
// Fall through to raw handling below.
}
}
const withoutPath = trimmed.replace(/\/.*$/, '');
const withoutPath = host.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
return /\]:\d+$/.test(withoutPath) ? withoutPath : `${withoutPath}:${grpcPort}`;
const innerHost = withoutPath
.replace(/^\[/, '')
.replace(/\](?::\d+)?$/, '');
return `[${innerHost}]:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) {
const hostOnly = withoutPath.replace(/:\d+$/, '');
return `${hostOnly}:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) return withoutPath;
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
return `${withoutPath}:${grpcPort}`;
}
@@ -219,6 +294,10 @@ function createClient(node: DaemonNodeConnection): DaemonServiceClient {
return new DaemonService(
target,
grpc.credentials.createInsecure(),
{
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
},
) as unknown as DaemonServiceClient;
}
@@ -262,6 +341,46 @@ function callUnary<TResponse>(
});
}
function readFirstStreamMessage<TMessage>(
stream: grpc.ClientReadableStream<TMessage>,
timeoutMs: number,
): Promise<TMessage> {
return new Promise((resolve, reject) => {
let completed = false;
const timeout = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error(`gRPC stream timed out after ${timeoutMs}ms`));
}, timeoutMs);
const onData = (message: TMessage) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
resolve(message);
};
const onError = (error: Error) => {
if (completed) return;
completed = true;
clearTimeout(timeout);
reject(error);
};
const onEnd = () => {
if (completed) return;
completed = true;
clearTimeout(timeout);
reject(new Error('gRPC stream ended before first message'));
};
stream.on('data', onData);
stream.on('error', onError);
stream.on('end', onEnd);
});
}
function toBuffer(data: Uint8Array | Buffer): Buffer {
if (Buffer.isBuffer(data)) return data;
return Buffer.from(data);
@@ -270,6 +389,49 @@ function toBuffer(data: Uint8Array | Buffer): Buffer {
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
const DEFAULT_RPC_TIMEOUT_MS = 20_000;
export async function daemonGetNodeStatus(
node: DaemonNodeConnection,
): Promise<DaemonNodeStatus> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonNodeStatusRaw>(
(callback) => client.getNodeStatus({}, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
version: response.version,
isHealthy: response.is_healthy,
uptimeSeconds: Number(response.uptime_seconds),
activeServers: Number(response.active_servers),
};
} finally {
client.close();
}
}
export async function daemonGetNodeStats(
node: DaemonNodeConnection,
): Promise<DaemonNodeStats> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const stream = client.streamNodeStats({}, getMetadata(node.daemonToken));
const response = await readFirstStreamMessage(stream, DEFAULT_RPC_TIMEOUT_MS);
return {
cpuPercent: Number(response.cpu_percent),
memoryUsed: Number(response.memory_used),
memoryTotal: Number(response.memory_total),
diskUsed: Number(response.disk_used),
diskTotal: Number(response.disk_total),
};
} finally {
client.close();
}
}
export async function daemonCreateServer(
node: DaemonNodeConnection,
request: DaemonCreateServerRequest,
@@ -468,6 +630,84 @@ export async function daemonDeleteFiles(
}
}
export async function daemonCreateBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
): Promise<DaemonBackupResponse> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonBackupResponseRaw>(
(callback) =>
client.createBackup(
{ server_uuid: serverUuid, backup_id: backupId },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
return {
backupId: response.backup_id,
sizeBytes: Number(response.size_bytes),
checksum: response.checksum,
success: response.success,
};
} finally {
client.close();
}
}
export async function daemonRestoreBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
cdnPath?: string | null,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.restoreBackup(
{
server_uuid: serverUuid,
backup_id: backupId,
cdn_download_url: cdnPath ?? '',
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteBackup(
node: DaemonNodeConnection,
serverUuid: string,
backupId: string,
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.deleteBackup(
{ server_uuid: serverUuid, backup_id: backupId },
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonGetActivePlayers(
node: DaemonNodeConnection,
serverUuid: string,