feat: overhaul server automation, files editor, and CS2 setup workflows
This commit is contained in:
parent
44c439e2f9
commit
2a3ad5e78f
|
|
@ -153,7 +153,7 @@ source-gamepanel/
|
||||||
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
| Game | Docker Image | Default Port | Config Format | Plugin Support |
|
||||||
|------|-------------|-------------|---------------|---------------|
|
|------|-------------|-------------|---------------|---------------|
|
||||||
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual |
|
||||||
| Counter-Strike 2 | `cm2network/csgo` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual |
|
||||||
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
|
||||||
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
|
||||||
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
| Rust | `didstopia/rust-server` | 28015 | — | — |
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,14 @@
|
||||||
"drizzle-orm": "^0.38.0",
|
"drizzle-orm": "^0.38.0",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"fastify-plugin": "^5.0.0",
|
"fastify-plugin": "^5.0.0",
|
||||||
|
"tar-stream": "^3.1.7",
|
||||||
|
"unzipper": "^0.12.3",
|
||||||
"pino-pretty": "^13.0.0",
|
"pino-pretty": "^13.0.0",
|
||||||
"socket.io": "^4.8.0"
|
"socket.io": "^4.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/tar-stream": "^3.1.4",
|
||||||
|
"@types/unzipper": "^0.10.11",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"tsx": "^4.19.0"
|
"tsx": "^4.19.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import daemonNodeRoutes from './routes/nodes/daemon.js';
|
||||||
import nodeRoutes from './routes/nodes/index.js';
|
import nodeRoutes from './routes/nodes/index.js';
|
||||||
import serverRoutes from './routes/servers/index.js';
|
import serverRoutes from './routes/servers/index.js';
|
||||||
import adminRoutes from './routes/admin/index.js';
|
import adminRoutes from './routes/admin/index.js';
|
||||||
|
import gameRoutes from './routes/games/index.js';
|
||||||
import { AppError } from './lib/errors.js';
|
import { AppError } from './lib/errors.js';
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
|
|
@ -87,6 +88,7 @@ app.get('/api/health', async () => {
|
||||||
await app.register(authRoutes, { prefix: '/api/auth' });
|
await app.register(authRoutes, { prefix: '/api/auth' });
|
||||||
await app.register(organizationRoutes, { prefix: '/api/organizations' });
|
await app.register(organizationRoutes, { prefix: '/api/organizations' });
|
||||||
await app.register(adminRoutes, { prefix: '/api/admin' });
|
await app.register(adminRoutes, { prefix: '/api/admin' });
|
||||||
|
await app.register(gameRoutes, { prefix: '/api/games' });
|
||||||
await app.register(daemonNodeRoutes, { prefix: '/api/nodes' });
|
await app.register(daemonNodeRoutes, { prefix: '/api/nodes' });
|
||||||
await app.register(internalRoutes, { prefix: '/api/internal' });
|
await app.register(internalRoutes, { prefix: '/api/internal' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,21 @@ interface DaemonServerResponse {
|
||||||
status: string;
|
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 {
|
interface DaemonStatusResponse {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
|
@ -66,6 +81,13 @@ interface DaemonPlayerListRaw {
|
||||||
max_players: number;
|
max_players: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DaemonBackupResponseRaw {
|
||||||
|
backup_id: string;
|
||||||
|
size_bytes: number;
|
||||||
|
checksum: string;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DaemonConsoleOutput {
|
export interface DaemonConsoleOutput {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
line: string;
|
line: string;
|
||||||
|
|
@ -95,9 +117,40 @@ export interface DaemonPlayersResponse {
|
||||||
maxPlayers: number;
|
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;
|
type UnaryCallback<TResponse> = (error: grpc.ServiceError | null, response: TResponse) => void;
|
||||||
|
|
||||||
interface DaemonServiceClient extends grpc.Client {
|
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(
|
createServer(
|
||||||
request: DaemonCreateServerRequest,
|
request: DaemonCreateServerRequest,
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
|
|
@ -147,6 +200,21 @@ interface DaemonServiceClient extends grpc.Client {
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
callback: UnaryCallback<EmptyResponse>,
|
callback: UnaryCallback<EmptyResponse>,
|
||||||
): void;
|
): 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(
|
getActivePlayers(
|
||||||
request: { uuid: string },
|
request: { uuid: string },
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
|
|
@ -183,27 +251,34 @@ const POWER_ACTIONS: Record<PowerAction, number> = {
|
||||||
kill: 3,
|
kill: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_GRPC_MESSAGE_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
|
function buildGrpcTarget(fqdn: string, grpcPort: number): string {
|
||||||
const trimmed = fqdn.trim();
|
const trimmed = fqdn.trim();
|
||||||
if (!trimmed) throw new Error('Node FQDN is empty');
|
if (!trimmed) throw new Error('Node FQDN is empty');
|
||||||
|
|
||||||
|
let host = trimmed;
|
||||||
if (trimmed.includes('://')) {
|
if (trimmed.includes('://')) {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(trimmed);
|
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 (!host) throw new Error('Node FQDN has no hostname');
|
||||||
if (parsed.port) return `${host}:${parsed.port}`;
|
|
||||||
return `${host}:${grpcPort}`;
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to raw handling below.
|
// Fall through to raw handling below.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const withoutPath = trimmed.replace(/\/.*$/, '');
|
const withoutPath = host.replace(/\/.*$/, '');
|
||||||
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
|
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}`;
|
if (withoutPath.includes(':')) return `[${withoutPath}]:${grpcPort}`;
|
||||||
return `${withoutPath}:${grpcPort}`;
|
return `${withoutPath}:${grpcPort}`;
|
||||||
}
|
}
|
||||||
|
|
@ -219,6 +294,10 @@ function createClient(node: DaemonNodeConnection): DaemonServiceClient {
|
||||||
return new DaemonService(
|
return new DaemonService(
|
||||||
target,
|
target,
|
||||||
grpc.credentials.createInsecure(),
|
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;
|
) 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 {
|
function toBuffer(data: Uint8Array | Buffer): Buffer {
|
||||||
if (Buffer.isBuffer(data)) return data;
|
if (Buffer.isBuffer(data)) return data;
|
||||||
return Buffer.from(data);
|
return Buffer.from(data);
|
||||||
|
|
@ -270,6 +389,49 @@ function toBuffer(data: Uint8Array | Buffer): Buffer {
|
||||||
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
|
const DEFAULT_CONNECT_TIMEOUT_MS = 8_000;
|
||||||
const DEFAULT_RPC_TIMEOUT_MS = 20_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(
|
export async function daemonCreateServer(
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
request: DaemonCreateServerRequest,
|
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(
|
export async function daemonGetActivePlayers(
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
serverUuid: string,
|
serverUuid: string,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,793 @@
|
||||||
|
import { gunzipSync } from 'node:zlib';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import * as tar from 'tar-stream';
|
||||||
|
import type { Headers } from 'tar-stream';
|
||||||
|
import * as unzipper from 'unzipper';
|
||||||
|
import type {
|
||||||
|
GameAutomationRule,
|
||||||
|
ServerAutomationEvent,
|
||||||
|
ServerAutomationAction,
|
||||||
|
ServerAutomationGitHubReleaseExtractAction,
|
||||||
|
ServerAutomationHttpDirectoryExtractAction,
|
||||||
|
} from '@source/shared';
|
||||||
|
import {
|
||||||
|
daemonReadFile,
|
||||||
|
daemonSendCommand,
|
||||||
|
daemonWriteFile,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from './daemon.js';
|
||||||
|
|
||||||
|
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
||||||
|
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
|
||||||
|
|
||||||
|
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
|
||||||
|
cs2: [
|
||||||
|
{
|
||||||
|
id: 'cs2-install-latest-metamod',
|
||||||
|
event: 'server.install.completed',
|
||||||
|
enabled: true,
|
||||||
|
runOncePerServer: true,
|
||||||
|
continueOnError: false,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'install-cs2-metamod',
|
||||||
|
type: 'http_directory_extract',
|
||||||
|
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
|
||||||
|
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
|
||||||
|
destination: '/game/csgo',
|
||||||
|
stripComponents: 0,
|
||||||
|
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cs2-install-latest-counterstrikesharp-runtime',
|
||||||
|
event: 'server.install.completed',
|
||||||
|
enabled: true,
|
||||||
|
runOncePerServer: true,
|
||||||
|
continueOnError: false,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'install-cs2-runtime',
|
||||||
|
type: 'github_release_extract',
|
||||||
|
owner: 'roflmuffin',
|
||||||
|
repo: 'CounterStrikeSharp',
|
||||||
|
assetNamePatterns: [
|
||||||
|
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
|
||||||
|
'^counterstrikesharp-with-runtime.*\\.zip$',
|
||||||
|
],
|
||||||
|
destination: '/game/csgo',
|
||||||
|
stripComponents: 0,
|
||||||
|
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ServerAutomationContext {
|
||||||
|
serverId: string;
|
||||||
|
serverUuid: string;
|
||||||
|
gameSlug: string;
|
||||||
|
event: ServerAutomationEvent;
|
||||||
|
node: DaemonNodeConnection;
|
||||||
|
automationRulesRaw: unknown;
|
||||||
|
force?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerAutomationRunResult {
|
||||||
|
workflowsMatched: number;
|
||||||
|
workflowsExecuted: number;
|
||||||
|
workflowsSkipped: number;
|
||||||
|
workflowsFailed: number;
|
||||||
|
actionFailures: number;
|
||||||
|
failures: ServerAutomationFailure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedFile {
|
||||||
|
path: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerAutomationFailure {
|
||||||
|
level: 'action' | 'workflow';
|
||||||
|
workflowId: string;
|
||||||
|
actionId?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitHubReleaseResponse {
|
||||||
|
tag_name: string;
|
||||||
|
assets: GitHubReleaseAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWorkflowId(value: unknown): string | null {
|
||||||
|
if (!isObject(value)) return null;
|
||||||
|
const id = value.id;
|
||||||
|
if (typeof id !== 'string' || id.trim() === '') return null;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflow(
|
||||||
|
gameSlug: string,
|
||||||
|
workflow: GameAutomationRule,
|
||||||
|
): GameAutomationRule {
|
||||||
|
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
||||||
|
if (workflow.id !== 'cs2-install-latest-counterstrikesharp-runtime') return workflow;
|
||||||
|
|
||||||
|
const normalizedActions = workflow.actions.map((action) => {
|
||||||
|
if (action.type !== 'github_release_extract') return action;
|
||||||
|
if (action.id !== 'install-cs2-runtime') return action;
|
||||||
|
|
||||||
|
const destination = (action.destination ?? '').trim();
|
||||||
|
if (destination !== '' && destination !== '/') return action;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...action,
|
||||||
|
destination: '/game/csgo',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...workflow,
|
||||||
|
actions: normalizedActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
|
||||||
|
const defaults = DEFAULT_GAME_AUTOMATION_RULES[gameSlug.toLowerCase()] ?? [];
|
||||||
|
if (!Array.isArray(raw)) {
|
||||||
|
return defaults.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = raw as GameAutomationRule[];
|
||||||
|
if (defaults.length === 0) {
|
||||||
|
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(
|
||||||
|
raw
|
||||||
|
.map(readWorkflowId)
|
||||||
|
.filter((workflowId): workflowId is string => workflowId !== null),
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id));
|
||||||
|
if (missingDefaults.length === 0) {
|
||||||
|
return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow));
|
||||||
|
}
|
||||||
|
|
||||||
|
function markerPath(event: ServerAutomationEvent, workflowId: string): string {
|
||||||
|
const cleanId = workflowId.trim().replace(/[^a-zA-Z0-9._-]+/g, '-');
|
||||||
|
return `${AUTOMATION_MARKER_ROOT}/${event}/${cleanId}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes('No such file or directory') ||
|
||||||
|
message.includes('NOT_FOUND') ||
|
||||||
|
message.includes('status code 404')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathSegments(path: string): string[] {
|
||||||
|
return path
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.split('/')
|
||||||
|
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinServerPath(base: string, relative: string): string {
|
||||||
|
const baseSegments = normalizePathSegments(base);
|
||||||
|
const relativeSegments = normalizePathSegments(relative);
|
||||||
|
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeArchivePath(path: string, stripComponents = 0): string | null {
|
||||||
|
const segments = normalizePathSegments(path);
|
||||||
|
const stripped = segments.slice(Math.max(0, stripComponents));
|
||||||
|
if (stripped.length === 0) return null;
|
||||||
|
return stripped.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasMarker(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
serverUuid: string,
|
||||||
|
event: ServerAutomationEvent,
|
||||||
|
workflowId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await daemonReadFile(node, serverUuid, markerPath(event, workflowId));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingFileError(error)) return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMarker(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
serverUuid: string,
|
||||||
|
event: ServerAutomationEvent,
|
||||||
|
workflowId: string,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await daemonWriteFile(
|
||||||
|
node,
|
||||||
|
serverUuid,
|
||||||
|
markerPath(event, workflowId),
|
||||||
|
JSON.stringify(payload, null, 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function githubHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'application/vnd.github+json',
|
||||||
|
'User-Agent': 'SourceGamePanel/1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = process.env.GITHUB_TOKEN?.trim();
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileAssetPatterns(patterns: string[]): RegExp[] {
|
||||||
|
const compiled: RegExp[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const tryCompile = (pattern: string) => {
|
||||||
|
const key = pattern.trim();
|
||||||
|
if (!key || seen.has(key)) return;
|
||||||
|
try {
|
||||||
|
compiled.push(new RegExp(key, 'i'));
|
||||||
|
seen.add(key);
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid regex patterns in configuration.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
tryCompile(pattern);
|
||||||
|
|
||||||
|
// Some JSON-stored patterns may be over-escaped (e.g. "\\\\." instead of "\\.").
|
||||||
|
// Collapse double backslashes once and compile a fallback variant.
|
||||||
|
if (pattern.includes('\\\\')) {
|
||||||
|
tryCompile(pattern.replace(/\\\\/g, '\\'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLatestRelease(
|
||||||
|
action: ServerAutomationGitHubReleaseExtractAction,
|
||||||
|
): Promise<GitHubReleaseResponse> {
|
||||||
|
const releaseUrl = `https://api.github.com/repos/${action.owner}/${action.repo}/releases/latest`;
|
||||||
|
const response = await fetch(releaseUrl, {
|
||||||
|
headers: githubHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`GitHub latest release request failed (${action.owner}/${action.repo}): HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const release = (await response.json()) as GitHubReleaseResponse;
|
||||||
|
if (!Array.isArray(release.assets)) {
|
||||||
|
throw new Error(`GitHub release payload has no assets (${action.owner}/${action.repo})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return release;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DirectoryAssetCandidate {
|
||||||
|
name: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumberParts(value: string): number[] {
|
||||||
|
const matches = value.match(/\d+/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
return matches
|
||||||
|
.map((part) => Number.parseInt(part, 10))
|
||||||
|
.filter((num) => Number.isFinite(num));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareNumberPartsDesc(a: number[], b: number[]): number {
|
||||||
|
const maxLength = Math.max(a.length, b.length);
|
||||||
|
for (let i = 0; i < maxLength; i += 1) {
|
||||||
|
const left = a[i] ?? -1;
|
||||||
|
const right = b[i] ?? -1;
|
||||||
|
if (left !== right) {
|
||||||
|
return right - left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLatestDirectoryAsset(candidates: DirectoryAssetCandidate[]): DirectoryAssetCandidate {
|
||||||
|
const sorted = [...candidates].sort((left, right) => {
|
||||||
|
const numberDiff = compareNumberPartsDesc(
|
||||||
|
extractNumberParts(left.name),
|
||||||
|
extractNumberParts(right.name),
|
||||||
|
);
|
||||||
|
if (numberDiff !== 0) return numberDiff;
|
||||||
|
return right.name.localeCompare(left.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted[0] ?? candidates[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDirectoryCandidates(
|
||||||
|
html: string,
|
||||||
|
indexUrl: string,
|
||||||
|
assetPattern: RegExp,
|
||||||
|
): DirectoryAssetCandidate[] {
|
||||||
|
const hrefRegex = /href\s*=\s*(['"])(.*?)\1/gi;
|
||||||
|
const candidates: DirectoryAssetCandidate[] = [];
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null = null;
|
||||||
|
while ((match = hrefRegex.exec(html)) !== null) {
|
||||||
|
const href = (match[2] ?? '').trim();
|
||||||
|
if (!href || href.endsWith('/')) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolvedUrl = new URL(href, indexUrl);
|
||||||
|
const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '');
|
||||||
|
if (!filename || !assetPattern.test(filename)) continue;
|
||||||
|
|
||||||
|
candidates.push({
|
||||||
|
name: filename,
|
||||||
|
downloadUrl: resolvedUrl.toString(),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed links.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLatestDirectoryAsset(
|
||||||
|
action: ServerAutomationHttpDirectoryExtractAction,
|
||||||
|
): Promise<DirectoryAssetCandidate> {
|
||||||
|
let assetPattern: RegExp;
|
||||||
|
try {
|
||||||
|
assetPattern = new RegExp(action.assetNamePattern, 'i');
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Invalid assetNamePattern regex for action ${action.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(action.indexUrl, {
|
||||||
|
headers: { 'User-Agent': 'SourceGamePanel/1.0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Directory listing request failed (${action.indexUrl}): HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const candidates = extractDirectoryCandidates(html, action.indexUrl, assetPattern);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`No matching directory asset for ${action.indexUrl} with pattern: ${action.assetNamePattern}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pickLatestDirectoryAsset(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBinary(url: string, maxBytes: number): Promise<Buffer> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), DEFAULT_DOWNLOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'SourceGamePanel/1.0',
|
||||||
|
},
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed with HTTP ${response.status}: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
||||||
|
if (contentLength > maxBytes) {
|
||||||
|
throw new Error(`Artifact exceeds max size (${contentLength} > ${maxBytes} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
throw new Error('Downloaded artifact is empty');
|
||||||
|
}
|
||||||
|
if (buffer.length > maxBytes) {
|
||||||
|
throw new Error(`Artifact exceeds max size (${buffer.length} > ${maxBytes} bytes)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractZipFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
|
||||||
|
const archive = await unzipper.Open.buffer(buffer);
|
||||||
|
const files: ExtractedFile[] = [];
|
||||||
|
|
||||||
|
for (const entry of archive.files) {
|
||||||
|
if (entry.type !== 'File') continue;
|
||||||
|
|
||||||
|
const normalized = normalizeArchivePath(entry.path, stripComponents);
|
||||||
|
if (!normalized) continue;
|
||||||
|
|
||||||
|
files.push({
|
||||||
|
path: normalized,
|
||||||
|
data: await entry.buffer(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTarFiles(buffer: Buffer, stripComponents = 0): Promise<ExtractedFile[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const extract = tar.extract();
|
||||||
|
const files: ExtractedFile[] = [];
|
||||||
|
|
||||||
|
extract.on('entry', (header: Headers, stream, next) => {
|
||||||
|
const type = header.type ?? 'file';
|
||||||
|
const normalized = normalizeArchivePath(header.name, stripComponents);
|
||||||
|
const isFileType = type === 'file' || type === 'contiguous-file';
|
||||||
|
|
||||||
|
if (!isFileType || !normalized) {
|
||||||
|
stream.resume();
|
||||||
|
stream.on('end', next);
|
||||||
|
stream.on('error', reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
stream.on('data', (chunk: Buffer) => {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
});
|
||||||
|
stream.on('end', () => {
|
||||||
|
files.push({ path: normalized, data: Buffer.concat(chunks) });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
stream.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
extract.on('finish', () => resolve(files));
|
||||||
|
extract.on('error', reject);
|
||||||
|
extract.end(buffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractArtifactFiles(
|
||||||
|
artifact: Buffer,
|
||||||
|
assetName: string,
|
||||||
|
stripComponents = 0,
|
||||||
|
): Promise<ExtractedFile[]> {
|
||||||
|
const name = assetName.toLowerCase();
|
||||||
|
|
||||||
|
if (name.endsWith('.zip')) {
|
||||||
|
return extractZipFiles(artifact, stripComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||||
|
return extractTarFiles(gunzipSync(artifact), stripComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.endsWith('.tar')) {
|
||||||
|
return extractTarFiles(artifact, stripComponents);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeArchivePath(assetName, stripComponents) ?? assetName;
|
||||||
|
return [{ path: normalized, data: artifact }];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeGitHubReleaseExtract(
|
||||||
|
app: FastifyInstance,
|
||||||
|
context: ServerAutomationContext,
|
||||||
|
action: ServerAutomationGitHubReleaseExtractAction,
|
||||||
|
): Promise<void> {
|
||||||
|
const release = await fetchLatestRelease(action);
|
||||||
|
const patterns = compileAssetPatterns(action.assetNamePatterns);
|
||||||
|
|
||||||
|
if (patterns.length === 0) {
|
||||||
|
throw new Error(`No valid asset regex pattern for action ${action.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = release.assets.find((candidate) =>
|
||||||
|
patterns.some((pattern) => pattern.test(candidate.name)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
throw new Error(
|
||||||
|
`No matching release asset for ${action.owner}/${action.repo} with patterns: ${action.assetNamePatterns.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||||
|
const artifact = await downloadBinary(asset.browser_download_url, maxBytes);
|
||||||
|
const files = await extractArtifactFiles(
|
||||||
|
artifact,
|
||||||
|
asset.name,
|
||||||
|
Number(action.stripComponents) || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`Extracted artifact has no files: ${asset.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = action.destination ?? '/';
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const targetPath = joinServerPath(destination, file.path);
|
||||||
|
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.log.info(
|
||||||
|
{
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
gameSlug: context.gameSlug,
|
||||||
|
event: context.event,
|
||||||
|
actionId: action.id,
|
||||||
|
release: release.tag_name,
|
||||||
|
asset: asset.name,
|
||||||
|
filesWritten: files.length,
|
||||||
|
},
|
||||||
|
'Automation action completed: github_release_extract',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeHttpDirectoryExtract(
|
||||||
|
app: FastifyInstance,
|
||||||
|
context: ServerAutomationContext,
|
||||||
|
action: ServerAutomationHttpDirectoryExtractAction,
|
||||||
|
): Promise<void> {
|
||||||
|
const selectedAsset = await resolveLatestDirectoryAsset(action);
|
||||||
|
const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES;
|
||||||
|
const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes);
|
||||||
|
const files = await extractArtifactFiles(
|
||||||
|
artifact,
|
||||||
|
selectedAsset.name,
|
||||||
|
Number(action.stripComponents) || 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`Extracted artifact has no files: ${selectedAsset.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = action.destination ?? '/';
|
||||||
|
for (const file of files) {
|
||||||
|
const targetPath = joinServerPath(destination, file.path);
|
||||||
|
await daemonWriteFile(context.node, context.serverUuid, targetPath, file.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.log.info(
|
||||||
|
{
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
gameSlug: context.gameSlug,
|
||||||
|
event: context.event,
|
||||||
|
actionId: action.id,
|
||||||
|
source: action.indexUrl,
|
||||||
|
asset: selectedAsset.name,
|
||||||
|
filesWritten: files.length,
|
||||||
|
},
|
||||||
|
'Automation action completed: http_directory_extract',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeAction(
|
||||||
|
app: FastifyInstance,
|
||||||
|
context: ServerAutomationContext,
|
||||||
|
action: ServerAutomationAction,
|
||||||
|
): Promise<void> {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'github_release_extract': {
|
||||||
|
await executeGitHubReleaseExtract(app, context, action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'http_directory_extract': {
|
||||||
|
await executeHttpDirectoryExtract(app, context, action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'write_file': {
|
||||||
|
const payload =
|
||||||
|
action.encoding === 'base64'
|
||||||
|
? Buffer.from(action.data, 'base64')
|
||||||
|
: action.data;
|
||||||
|
|
||||||
|
await daemonWriteFile(context.node, context.serverUuid, action.path, payload);
|
||||||
|
app.log.info(
|
||||||
|
{
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
event: context.event,
|
||||||
|
actionId: action.id,
|
||||||
|
path: action.path,
|
||||||
|
},
|
||||||
|
'Automation action completed: write_file',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'send_command': {
|
||||||
|
await daemonSendCommand(context.node, context.serverUuid, action.command);
|
||||||
|
app.log.info(
|
||||||
|
{
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
event: context.event,
|
||||||
|
actionId: action.id,
|
||||||
|
command: action.command,
|
||||||
|
},
|
||||||
|
'Automation action completed: send_command',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const unknownAction = action as { type?: unknown };
|
||||||
|
throw new Error(`Unsupported automation action type: ${String(unknownAction.type)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runServerAutomationEvent(
|
||||||
|
app: FastifyInstance,
|
||||||
|
context: ServerAutomationContext,
|
||||||
|
): Promise<ServerAutomationRunResult> {
|
||||||
|
const workflows = asAutomationRules(context.automationRulesRaw, context.gameSlug)
|
||||||
|
.filter((rule) => isObject(rule))
|
||||||
|
.filter((rule) => rule.event === context.event)
|
||||||
|
.filter((rule) => rule.enabled !== false)
|
||||||
|
.filter((rule) => Array.isArray(rule.actions) && rule.actions.length > 0);
|
||||||
|
|
||||||
|
const result: ServerAutomationRunResult = {
|
||||||
|
workflowsMatched: workflows.length,
|
||||||
|
workflowsExecuted: 0,
|
||||||
|
workflowsSkipped: 0,
|
||||||
|
workflowsFailed: 0,
|
||||||
|
actionFailures: 0,
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (workflows.length === 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const workflow of workflows) {
|
||||||
|
const runOnce = workflow.runOncePerServer !== false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
runOnce &&
|
||||||
|
!context.force &&
|
||||||
|
await hasMarker(context.node, context.serverUuid, context.event, workflow.id)
|
||||||
|
) {
|
||||||
|
result.workflowsSkipped += 1;
|
||||||
|
app.log.info(
|
||||||
|
{
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
gameSlug: context.gameSlug,
|
||||||
|
event: context.event,
|
||||||
|
workflowId: workflow.id,
|
||||||
|
},
|
||||||
|
'Skipping automation workflow (already completed)',
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const action of workflow.actions) {
|
||||||
|
try {
|
||||||
|
await executeAction(app, context, action);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
result.actionFailures += 1;
|
||||||
|
result.failures.push({
|
||||||
|
level: 'action',
|
||||||
|
workflowId: workflow.id,
|
||||||
|
actionId: action.id,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
app.log.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
errorMessage: message,
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
gameSlug: context.gameSlug,
|
||||||
|
event: context.event,
|
||||||
|
workflowId: workflow.id,
|
||||||
|
actionId: action.id,
|
||||||
|
},
|
||||||
|
'Automation action failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (workflow.continueOnError) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runOnce) {
|
||||||
|
await writeMarker(context.node, context.serverUuid, context.event, workflow.id, {
|
||||||
|
workflowId: workflow.id,
|
||||||
|
event: context.event,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.log.info(
|
||||||
|
{
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
gameSlug: context.gameSlug,
|
||||||
|
event: context.event,
|
||||||
|
workflowId: workflow.id,
|
||||||
|
},
|
||||||
|
'Automation workflow completed',
|
||||||
|
);
|
||||||
|
result.workflowsExecuted += 1;
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
result.workflowsFailed += 1;
|
||||||
|
result.failures.push({
|
||||||
|
level: 'workflow',
|
||||||
|
workflowId: workflow.id,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
app.log.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
errorMessage: message,
|
||||||
|
serverId: context.serverId,
|
||||||
|
serverUuid: context.serverUuid,
|
||||||
|
gameSlug: context.gameSlug,
|
||||||
|
event: context.event,
|
||||||
|
workflowId: workflow.id,
|
||||||
|
},
|
||||||
|
'Automation workflow failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||||
stopCommand?: string;
|
stopCommand?: string;
|
||||||
configFiles?: unknown[];
|
configFiles?: unknown[];
|
||||||
environmentVars?: unknown[];
|
environmentVars?: unknown[];
|
||||||
|
automationRules?: unknown[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = await app.db.query.games.findFirst({
|
const existing = await app.db.query.games.findFirst({
|
||||||
|
|
@ -74,6 +75,7 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||||
...body,
|
...body,
|
||||||
configFiles: body.configFiles ?? [],
|
configFiles: body.configFiles ?? [],
|
||||||
environmentVars: body.environmentVars ?? [],
|
environmentVars: body.environmentVars ?? [],
|
||||||
|
automationRules: body.automationRules ?? [],
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export const CreateGameSchema = {
|
||||||
stopCommand: Type.Optional(Type.String()),
|
stopCommand: Type.Optional(Type.String()),
|
||||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||||
|
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ export const UpdateGameSchema = {
|
||||||
stopCommand: Type.Optional(Type.String()),
|
stopCommand: Type.Optional(Type.String()),
|
||||||
configFiles: Type.Optional(Type.Array(Type.Any())),
|
configFiles: Type.Optional(Type.Array(Type.Any())),
|
||||||
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
environmentVars: Type.Optional(Type.Array(Type.Any())),
|
||||||
|
automationRules: Type.Optional(Type.Array(Type.Any())),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { games } from '@source/database';
|
||||||
|
|
||||||
|
export default async function gameRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('onRequest', app.authenticate);
|
||||||
|
|
||||||
|
// GET /api/games
|
||||||
|
app.get('/', async () => {
|
||||||
|
const gameList = await app.db
|
||||||
|
.select()
|
||||||
|
.from(games)
|
||||||
|
.orderBy(games.name);
|
||||||
|
|
||||||
|
return { data: gameList };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq, lte } from 'drizzle-orm';
|
||||||
import { nodes } from '@source/database';
|
import { nodes, scheduledTasks, servers } from '@source/database';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
|
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||||
|
|
||||||
function extractBearerToken(authHeader?: string): string | null {
|
function extractBearerToken(authHeader?: string): string | null {
|
||||||
if (!authHeader) return null;
|
if (!authHeader) return null;
|
||||||
|
|
@ -11,7 +12,10 @@ function extractBearerToken(authHeader?: string): string | null {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireDaemonToken(app: FastifyInstance, request: FastifyRequest): Promise<void> {
|
async function requireDaemonToken(
|
||||||
|
app: FastifyInstance,
|
||||||
|
request: FastifyRequest,
|
||||||
|
): Promise<{ id: string }> {
|
||||||
const token = extractBearerToken(
|
const token = extractBearerToken(
|
||||||
typeof request.headers.authorization === 'string'
|
typeof request.headers.authorization === 'string'
|
||||||
? request.headers.authorization
|
? request.headers.authorization
|
||||||
|
|
@ -30,12 +34,44 @@ async function requireDaemonToken(app: FastifyInstance, request: FastifyRequest)
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
throw AppError.unauthorized('Invalid daemon token', 'DAEMON_AUTH_INVALID');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function internalRoutes(app: FastifyInstance) {
|
export default async function internalRoutes(app: FastifyInstance) {
|
||||||
app.get('/schedules/due', async (request) => {
|
app.get('/schedules/due', async (request) => {
|
||||||
await requireDaemonToken(app, request);
|
const node = await requireDaemonToken(app, request);
|
||||||
return { tasks: [] };
|
const now = new Date();
|
||||||
|
|
||||||
|
const dueTasks = await app.db
|
||||||
|
.select({
|
||||||
|
id: scheduledTasks.id,
|
||||||
|
serverUuid: servers.uuid,
|
||||||
|
action: scheduledTasks.action,
|
||||||
|
payload: scheduledTasks.payload,
|
||||||
|
scheduleType: scheduledTasks.scheduleType,
|
||||||
|
isActive: scheduledTasks.isActive,
|
||||||
|
nextRunAt: scheduledTasks.nextRunAt,
|
||||||
|
})
|
||||||
|
.from(scheduledTasks)
|
||||||
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
|
.where(and(
|
||||||
|
eq(servers.nodeId, node.id),
|
||||||
|
eq(scheduledTasks.isActive, true),
|
||||||
|
lte(scheduledTasks.nextRunAt, now),
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
tasks: dueTasks.map((task) => ({
|
||||||
|
id: task.id,
|
||||||
|
server_uuid: task.serverUuid,
|
||||||
|
action: task.action,
|
||||||
|
payload: task.payload,
|
||||||
|
schedule_type: task.scheduleType,
|
||||||
|
is_active: task.isActive,
|
||||||
|
next_run_at: task.nextRunAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -48,8 +84,41 @@ export default async function internalRoutes(app: FastifyInstance) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
await requireDaemonToken(app, request);
|
const node = await requireDaemonToken(app, request);
|
||||||
const { taskId } = request.params as { taskId: string };
|
const { taskId } = request.params as { taskId: string };
|
||||||
|
|
||||||
|
const [task] = await app.db
|
||||||
|
.select({
|
||||||
|
id: scheduledTasks.id,
|
||||||
|
isActive: scheduledTasks.isActive,
|
||||||
|
scheduleType: scheduledTasks.scheduleType,
|
||||||
|
scheduleData: scheduledTasks.scheduleData,
|
||||||
|
})
|
||||||
|
.from(scheduledTasks)
|
||||||
|
.innerJoin(servers, eq(scheduledTasks.serverId, servers.id))
|
||||||
|
.where(and(
|
||||||
|
eq(scheduledTasks.id, taskId),
|
||||||
|
eq(servers.nodeId, node.id),
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
throw AppError.notFound('Scheduled task not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const nextRunAt = task.isActive
|
||||||
|
? computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await app.db
|
||||||
|
.update(scheduledTasks)
|
||||||
|
.set({
|
||||||
|
lastRunAt: now,
|
||||||
|
nextRunAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(scheduledTasks.id, taskId));
|
||||||
|
|
||||||
return { success: true, taskId };
|
return { success: true, taskId };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ import { nodes, allocations, servers, games } from '@source/database';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
|
import {
|
||||||
|
daemonGetNodeStats,
|
||||||
|
daemonGetNodeStatus,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from '../../lib/daemon.js';
|
||||||
import {
|
import {
|
||||||
NodeParamSchema,
|
NodeParamSchema,
|
||||||
CreateNodeSchema,
|
CreateNodeSchema,
|
||||||
|
|
@ -155,7 +160,7 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
// GET /api/organizations/:orgId/nodes/:nodeId/stats
|
||||||
// Returns basic stats from DB; real-time stats come from daemon via gRPC
|
// Returns real-time stats from daemon when available, with DB fallback.
|
||||||
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
app.get('/:nodeId/stats', { schema: NodeParamSchema }, async (request) => {
|
||||||
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
const { orgId, nodeId } = request.params as { orgId: string; nodeId: string };
|
||||||
await requirePermission(request, orgId, 'node.read');
|
await requirePermission(request, orgId, 'node.read');
|
||||||
|
|
@ -171,17 +176,54 @@ export default async function nodeRoutes(app: FastifyInstance) {
|
||||||
.where(eq(servers.nodeId, nodeId));
|
.where(eq(servers.nodeId, nodeId));
|
||||||
|
|
||||||
const totalServers = serverList.length;
|
const totalServers = serverList.length;
|
||||||
const activeServers = serverList.filter((s) => s.status === 'running').length;
|
let activeServers = serverList.filter((s) => s.status === 'running').length;
|
||||||
|
let cpuPercent = 0;
|
||||||
|
let memoryUsed = 0;
|
||||||
|
let memoryTotal = node.memoryTotal;
|
||||||
|
let diskUsed = 0;
|
||||||
|
let diskTotal = node.diskTotal;
|
||||||
|
let uptime = 0;
|
||||||
|
|
||||||
|
const daemonNode: DaemonNodeConnection = {
|
||||||
|
fqdn: node.fqdn,
|
||||||
|
grpcPort: node.grpcPort,
|
||||||
|
daemonToken: node.daemonToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [liveStats, liveStatus] = await Promise.all([
|
||||||
|
daemonGetNodeStats(daemonNode),
|
||||||
|
daemonGetNodeStatus(daemonNode),
|
||||||
|
]);
|
||||||
|
|
||||||
|
cpuPercent = Number.isFinite(liveStats.cpuPercent)
|
||||||
|
? Math.max(0, Math.min(100, liveStats.cpuPercent))
|
||||||
|
: 0;
|
||||||
|
memoryUsed = Math.max(0, liveStats.memoryUsed);
|
||||||
|
memoryTotal = liveStats.memoryTotal > 0 ? liveStats.memoryTotal : node.memoryTotal;
|
||||||
|
diskUsed = Math.max(0, liveStats.diskUsed);
|
||||||
|
diskTotal = liveStats.diskTotal > 0 ? liveStats.diskTotal : node.diskTotal;
|
||||||
|
uptime = Math.max(0, liveStatus.uptimeSeconds);
|
||||||
|
|
||||||
|
if (Number.isFinite(liveStatus.activeServers)) {
|
||||||
|
activeServers = Math.max(0, Math.min(totalServers, liveStatus.activeServers));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
{ error, nodeId, orgId },
|
||||||
|
'Failed to fetch live node stats from daemon, returning fallback values',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cpuPercent: 0,
|
cpuPercent,
|
||||||
memoryUsed: 0,
|
memoryUsed,
|
||||||
memoryTotal: node.memoryTotal,
|
memoryTotal,
|
||||||
diskUsed: 0,
|
diskUsed,
|
||||||
diskTotal: node.diskTotal,
|
diskTotal,
|
||||||
activeServers,
|
activeServers,
|
||||||
totalServers,
|
totalServers,
|
||||||
uptime: 0,
|
uptime,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import { servers, backups } from '@source/database';
|
import { servers, backups, nodes } from '@source/database';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
|
import {
|
||||||
|
daemonCreateBackup,
|
||||||
|
daemonDeleteBackup,
|
||||||
|
daemonRestoreBackup,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from '../../lib/daemon.js';
|
||||||
|
|
||||||
const ParamSchema = {
|
const ParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
|
|
@ -54,10 +60,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
const body = request.body as { name: string; isLocked?: boolean };
|
const body = request.body as { name: string; isLocked?: boolean };
|
||||||
|
|
||||||
const server = await app.db.query.servers.findFirst({
|
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
|
||||||
});
|
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
|
||||||
|
|
||||||
// Create backup record (pending — daemon will update when complete)
|
// Create backup record (pending — daemon will update when complete)
|
||||||
const [backup] = await app.db
|
const [backup] = await app.db
|
||||||
|
|
@ -69,12 +72,38 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// TODO: Send gRPC CreateBackup to daemon
|
if (!backup) {
|
||||||
// Daemon will:
|
throw new AppError(500, 'Failed to create backup record', 'BACKUP_CREATE_FAILED');
|
||||||
// 1. tar+gz the server directory
|
}
|
||||||
// 2. Upload to @source/cdn
|
|
||||||
// 3. Callback to API with cdnPath, sizeBytes, checksum
|
let completedBackup = backup;
|
||||||
// 4. API updates backup record with completedAt
|
try {
|
||||||
|
const daemonResult = await daemonCreateBackup(
|
||||||
|
serverContext.node,
|
||||||
|
serverContext.serverUuid,
|
||||||
|
backup.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!daemonResult.success) {
|
||||||
|
throw new Error('Daemon returned unsuccessful backup response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await app.db
|
||||||
|
.update(backups)
|
||||||
|
.set({
|
||||||
|
sizeBytes: daemonResult.sizeBytes,
|
||||||
|
checksum: daemonResult.checksum || null,
|
||||||
|
completedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(backups.id, backup.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
completedBackup = updated ?? completedBackup;
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon');
|
||||||
|
await app.db.delete(backups).where(eq(backups.id, backup.id));
|
||||||
|
throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
|
|
@ -83,7 +112,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
metadata: { name: body.name },
|
metadata: { name: body.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(201).send(backup);
|
return reply.code(201).send(completedBackup);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /backups/:backupId/restore — restore a backup
|
// POST /backups/:backupId/restore — restore a backup
|
||||||
|
|
@ -95,10 +124,7 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
};
|
};
|
||||||
await requirePermission(request, orgId, 'backup.restore');
|
await requirePermission(request, orgId, 'backup.restore');
|
||||||
|
|
||||||
const server = await app.db.query.servers.findFirst({
|
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
|
||||||
});
|
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
|
||||||
|
|
||||||
const backup = await app.db.query.backups.findFirst({
|
const backup = await app.db.query.backups.findFirst({
|
||||||
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
|
where: and(eq(backups.id, backupId), eq(backups.serverId, serverId)),
|
||||||
|
|
@ -106,12 +132,20 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
if (!backup) throw AppError.notFound('Backup not found');
|
if (!backup) throw AppError.notFound('Backup not found');
|
||||||
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
|
if (!backup.completedAt) throw AppError.badRequest('Backup is not yet completed');
|
||||||
|
|
||||||
// TODO: Send gRPC RestoreBackup to daemon
|
try {
|
||||||
// Daemon will:
|
await daemonRestoreBackup(
|
||||||
// 1. Stop the server
|
serverContext.node,
|
||||||
// 2. Download backup from @source/cdn
|
serverContext.serverUuid,
|
||||||
// 3. Extract tar.gz over server directory
|
backup.id,
|
||||||
// 4. Start the server
|
backup.cdnPath,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{ error, serverId, backupId },
|
||||||
|
'Failed to restore backup on daemon',
|
||||||
|
);
|
||||||
|
throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
|
|
@ -161,7 +195,17 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
if (!backup) throw AppError.notFound('Backup not found');
|
if (!backup) throw AppError.notFound('Backup not found');
|
||||||
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
|
if (backup.isLocked) throw AppError.badRequest('Cannot delete a locked backup');
|
||||||
|
|
||||||
// TODO: Send gRPC DeleteBackup to daemon to remove from CDN
|
const serverContext = await getServerBackupContext(app, orgId, serverId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{ error, serverId, backupId },
|
||||||
|
'Failed to delete backup on daemon',
|
||||||
|
);
|
||||||
|
throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
await app.db.delete(backups).where(eq(backups.id, backupId));
|
await app.db.delete(backups).where(eq(backups.id, backupId));
|
||||||
|
|
||||||
|
|
@ -175,3 +219,33 @@ export default async function backupRoutes(app: FastifyInstance) {
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getServerBackupContext(
|
||||||
|
app: FastifyInstance,
|
||||||
|
orgId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<{ serverUuid: string; node: DaemonNodeConnection }> {
|
||||||
|
const [server] = await app.db
|
||||||
|
.select({
|
||||||
|
serverUuid: servers.uuid,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw AppError.notFound('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverUuid: server.serverUuid,
|
||||||
|
node: {
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,21 +111,12 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||||
|
|
||||||
// If editableKeys is set, only allow those keys
|
|
||||||
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
|
|
||||||
const allowedKeys = new Set(configFile.editableKeys);
|
|
||||||
const invalidKeys = entries.filter((e) => !allowedKeys.has(e.key));
|
|
||||||
if (invalidKeys.length > 0) {
|
|
||||||
throw AppError.badRequest(
|
|
||||||
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let originalContent: string | undefined;
|
let originalContent: string | undefined;
|
||||||
|
let originalEntries: { key: string; value: string }[] = [];
|
||||||
try {
|
try {
|
||||||
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
const current = await daemonReadFile(node, server.uuid, configFile.path);
|
||||||
originalContent = current.data.toString('utf8');
|
originalContent = current.data.toString('utf8');
|
||||||
|
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingConfigFileError(error)) {
|
if (!isMissingConfigFileError(error)) {
|
||||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write');
|
||||||
|
|
@ -133,6 +124,22 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If editableKeys is set, allow:
|
||||||
|
// 1) explicitly editable keys
|
||||||
|
// 2) keys that already exist in the current file
|
||||||
|
if (configFile.editableKeys && configFile.editableKeys.length > 0) {
|
||||||
|
const allowedKeys = new Set(configFile.editableKeys);
|
||||||
|
const existingKeys = new Set(originalEntries.map((entry) => entry.key));
|
||||||
|
const invalidKeys = entries.filter(
|
||||||
|
(entry) => !allowedKeys.has(entry.key) && !existingKeys.has(entry.key),
|
||||||
|
);
|
||||||
|
if (invalidKeys.length > 0) {
|
||||||
|
throw AppError.badRequest(
|
||||||
|
`Keys not allowed: ${invalidKeys.map((k) => k.key).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = serializeConfig(
|
const content = serializeConfig(
|
||||||
entries,
|
entries,
|
||||||
configFile.parser as ConfigParser,
|
configFile.parser as ConfigParser,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,17 @@ const FileParamSchema = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function decodeBase64Payload(data: string): Buffer {
|
||||||
|
const normalized = data.trim();
|
||||||
|
if (!normalized) return Buffer.alloc(0);
|
||||||
|
|
||||||
|
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(normalized) || normalized.length % 4 !== 0) {
|
||||||
|
throw AppError.badRequest('Invalid base64 payload');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(normalized, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
export default async function fileRoutes(app: FastifyInstance) {
|
export default async function fileRoutes(app: FastifyInstance) {
|
||||||
app.addHook('onRequest', app.authenticate);
|
app.addHook('onRequest', app.authenticate);
|
||||||
|
|
||||||
|
|
@ -56,40 +67,61 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||||
...FileParamSchema,
|
...FileParamSchema,
|
||||||
querystring: Type.Object({
|
querystring: Type.Object({
|
||||||
path: Type.String({ minLength: 1 }),
|
path: Type.String({ minLength: 1 }),
|
||||||
|
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
const { path } = request.query as { path: string };
|
const { path, encoding } = request.query as {
|
||||||
|
path: string;
|
||||||
|
encoding?: 'utf8' | 'base64';
|
||||||
|
};
|
||||||
|
|
||||||
await requirePermission(request, orgId, 'files.read');
|
await requirePermission(request, orgId, 'files.read');
|
||||||
const serverContext = await getServerContext(app, orgId, serverId);
|
const serverContext = await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
|
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
|
||||||
return { data: content.data.toString('utf8') };
|
const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8';
|
||||||
|
|
||||||
|
return {
|
||||||
|
data:
|
||||||
|
requestedEncoding === 'base64'
|
||||||
|
? content.data.toString('base64')
|
||||||
|
: content.data.toString('utf8'),
|
||||||
|
encoding: requestedEncoding,
|
||||||
|
mimeType: content.mimeType,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
'/write',
|
'/write',
|
||||||
{
|
{
|
||||||
|
bodyLimit: 128 * 1024 * 1024,
|
||||||
schema: {
|
schema: {
|
||||||
...FileParamSchema,
|
...FileParamSchema,
|
||||||
body: Type.Object({
|
body: Type.Object({
|
||||||
path: Type.String({ minLength: 1 }),
|
path: Type.String({ minLength: 1 }),
|
||||||
data: Type.String(),
|
data: Type.String(),
|
||||||
|
encoding: Type.Optional(Type.Union([Type.Literal('utf8'), Type.Literal('base64')])),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request) => {
|
async (request) => {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
const { path, data } = request.body as { path: string; data: string };
|
const { path, data, encoding } = request.body as {
|
||||||
|
path: string;
|
||||||
|
data: string;
|
||||||
|
encoding?: 'utf8' | 'base64';
|
||||||
|
};
|
||||||
|
|
||||||
await requirePermission(request, orgId, 'files.write');
|
await requirePermission(request, orgId, 'files.write');
|
||||||
const serverContext = await getServerContext(app, orgId, serverId);
|
const serverContext = await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, data);
|
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
||||||
|
|
||||||
|
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
||||||
return { success: true, path };
|
return { success: true, path };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
import { eq, and, count } from 'drizzle-orm';
|
import { eq, and, count } from 'drizzle-orm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { setTimeout as sleep } from 'timers/promises';
|
import { setTimeout as sleep } from 'timers/promises';
|
||||||
import { servers, allocations, nodes, games } from '@source/database';
|
import { servers, allocations, nodes, games } from '@source/database';
|
||||||
import type { PowerAction } from '@source/shared';
|
import type { GameAutomationRule, PowerAction, ServerAutomationEvent } from '@source/shared';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
|
import { runServerAutomationEvent } from '../../lib/server-automation.js';
|
||||||
import {
|
import {
|
||||||
daemonCreateServer,
|
daemonCreateServer,
|
||||||
daemonDeleteServer,
|
daemonDeleteServer,
|
||||||
daemonGetServerStatus,
|
daemonGetServerStatus,
|
||||||
daemonSetPowerState,
|
daemonSetPowerState,
|
||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
|
type DaemonPortMapping,
|
||||||
} from '../../lib/daemon.js';
|
} from '../../lib/daemon.js';
|
||||||
import {
|
import {
|
||||||
ServerParamSchema,
|
ServerParamSchema,
|
||||||
|
|
@ -82,11 +85,27 @@ function buildDaemonEnvironment(
|
||||||
return environment;
|
return environment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildDaemonPorts(gameSlug: string, allocationPort: number, containerPort: number): DaemonPortMapping[] {
|
||||||
|
const slug = gameSlug.toLowerCase();
|
||||||
|
if (slug === 'cs2' || slug === 'csgo') {
|
||||||
|
return [
|
||||||
|
{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' },
|
||||||
|
{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (slug === 'minecraft-bedrock') {
|
||||||
|
return [{ host_port: allocationPort, container_port: containerPort, protocol: 'udp' }];
|
||||||
|
}
|
||||||
|
return [{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }];
|
||||||
|
}
|
||||||
|
|
||||||
async function syncServerInstallStatus(
|
async function syncServerInstallStatus(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
serverUuid: string,
|
serverUuid: string,
|
||||||
|
gameSlug: string,
|
||||||
|
automationRules: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const maxAttempts = 120;
|
const maxAttempts = 120;
|
||||||
const intervalMs = 5_000;
|
const intervalMs = 5_000;
|
||||||
|
|
@ -116,6 +135,18 @@ async function syncServerInstallStatus(
|
||||||
{ serverId, serverUuid, status: mapped, attempt },
|
{ serverId, serverUuid, status: mapped, attempt },
|
||||||
'Synchronized install status from daemon',
|
'Synchronized install status from daemon',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (mapped === 'running' || mapped === 'stopped') {
|
||||||
|
void runServerAutomationEvent(app, {
|
||||||
|
serverId,
|
||||||
|
serverUuid,
|
||||||
|
gameSlug,
|
||||||
|
event: 'server.install.completed',
|
||||||
|
node,
|
||||||
|
automationRulesRaw: automationRules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.warn(
|
app.log.warn(
|
||||||
|
|
@ -267,13 +298,7 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
cpu_limit: server.cpuLimit,
|
cpu_limit: server.cpuLimit,
|
||||||
startup_command: body.startupOverride ?? game.startupCommand,
|
startup_command: body.startupOverride ?? game.startupCommand,
|
||||||
environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit),
|
environment: buildDaemonEnvironment(game.environmentVars, body.environment, server.memoryLimit),
|
||||||
ports: [
|
ports: buildDaemonPorts(game.slug, allocation.port, game.defaultPort),
|
||||||
{
|
|
||||||
host_port: allocation.port,
|
|
||||||
container_port: game.defaultPort,
|
|
||||||
protocol: 'tcp' as const,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
install_plugin_urls: [],
|
install_plugin_urls: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -281,6 +306,7 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest);
|
const daemonResponse = await daemonCreateServer(nodeConnection, daemonRequest);
|
||||||
const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing';
|
const daemonStatus = mapDaemonStatus(daemonResponse.status) ?? 'installing';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const automationRules = (game as { automationRules?: GameAutomationRule[] }).automationRules ?? [];
|
||||||
|
|
||||||
const [updatedServer] = await app.db
|
const [updatedServer] = await app.db
|
||||||
.update(servers)
|
.update(servers)
|
||||||
|
|
@ -293,7 +319,23 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (daemonStatus === 'installing') {
|
if (daemonStatus === 'installing') {
|
||||||
void syncServerInstallStatus(app, nodeConnection, server.id, server.uuid);
|
void syncServerInstallStatus(
|
||||||
|
app,
|
||||||
|
nodeConnection,
|
||||||
|
server.id,
|
||||||
|
server.uuid,
|
||||||
|
game.slug,
|
||||||
|
automationRules,
|
||||||
|
);
|
||||||
|
} else if (daemonStatus === 'running' || daemonStatus === 'stopped') {
|
||||||
|
void runServerAutomationEvent(app, {
|
||||||
|
serverId: server.id,
|
||||||
|
serverUuid: server.uuid,
|
||||||
|
gameSlug: game.slug,
|
||||||
|
event: 'server.install.completed',
|
||||||
|
node: nodeConnection,
|
||||||
|
automationRulesRaw: automationRules,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
|
|
@ -319,6 +361,83 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/organizations/:orgId/servers/:serverId
|
||||||
|
app.post(
|
||||||
|
'/:serverId/automation/run',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
...ServerParamSchema,
|
||||||
|
body: Type.Object({
|
||||||
|
event: Type.Union([
|
||||||
|
Type.Literal('server.created'),
|
||||||
|
Type.Literal('server.install.completed'),
|
||||||
|
Type.Literal('server.power.started'),
|
||||||
|
Type.Literal('server.power.stopped'),
|
||||||
|
]),
|
||||||
|
force: Type.Optional(Type.Boolean({ default: false })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
|
const { event, force } = request.body as {
|
||||||
|
event: ServerAutomationEvent;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
await requirePermission(request, orgId, 'server.update');
|
||||||
|
|
||||||
|
const [server] = await app.db
|
||||||
|
.select({
|
||||||
|
id: servers.id,
|
||||||
|
uuid: servers.uuid,
|
||||||
|
gameSlug: games.slug,
|
||||||
|
automationRules: games.automationRules,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.innerJoin(games, eq(servers.gameId, games.id))
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||||
|
|
||||||
|
if (!server) throw AppError.notFound('Server not found');
|
||||||
|
|
||||||
|
const result = await runServerAutomationEvent(app, {
|
||||||
|
serverId: server.id,
|
||||||
|
serverUuid: server.uuid,
|
||||||
|
gameSlug: server.gameSlug,
|
||||||
|
event,
|
||||||
|
force: force ?? false,
|
||||||
|
node: {
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
automationRulesRaw: server.automationRules,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'server.automation.run',
|
||||||
|
metadata: {
|
||||||
|
event,
|
||||||
|
force: force ?? false,
|
||||||
|
result,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
event,
|
||||||
|
force: force ?? false,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/organizations/:orgId/servers/:serverId
|
// GET /api/organizations/:orgId/servers/:serverId
|
||||||
app.get('/:serverId', { schema: ServerParamSchema }, async (request) => {
|
app.get('/:serverId', { schema: ServerParamSchema }, async (request) => {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
|
|
@ -503,6 +622,32 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
})
|
})
|
||||||
.where(eq(servers.id, serverId));
|
.where(eq(servers.id, serverId));
|
||||||
|
|
||||||
|
if (action === 'start' || action === 'restart') {
|
||||||
|
const [serverWithGame] = await app.db
|
||||||
|
.select({
|
||||||
|
gameSlug: games.slug,
|
||||||
|
automationRules: games.automationRules,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.innerJoin(games, eq(servers.gameId, games.id))
|
||||||
|
.where(eq(servers.id, serverId));
|
||||||
|
|
||||||
|
if (serverWithGame) {
|
||||||
|
void runServerAutomationEvent(app, {
|
||||||
|
serverId,
|
||||||
|
serverUuid: server.uuid,
|
||||||
|
gameSlug: serverWithGame.gameSlug,
|
||||||
|
event: 'server.power.started',
|
||||||
|
node: {
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
automationRulesRaw: serverWithGame.automationRules,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,24 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import { servers, plugins, serverPlugins, games } from '@source/database';
|
import { servers, plugins, serverPlugins, games, nodes } from '@source/database';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
|
import {
|
||||||
|
daemonDeleteFiles,
|
||||||
|
daemonWriteFile,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from '../../lib/daemon.js';
|
||||||
import {
|
import {
|
||||||
searchSpigetPlugins,
|
searchSpigetPlugins,
|
||||||
getSpigetResource,
|
getSpigetResource,
|
||||||
getSpigetDownloadUrl,
|
getSpigetDownloadUrl,
|
||||||
} from '../../lib/spiget.js';
|
} from '../../lib/spiget.js';
|
||||||
|
|
||||||
|
const PLUGIN_DOWNLOAD_TIMEOUT_MS = 45_000;
|
||||||
|
const PLUGIN_DOWNLOAD_MAX_BYTES = 128 * 1024 * 1024;
|
||||||
|
|
||||||
const ParamSchema = {
|
const ParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
orgId: Type.String({ format: 'uuid' }),
|
orgId: Type.String({ format: 'uuid' }),
|
||||||
|
|
@ -18,6 +26,202 @@ const ParamSchema = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface ServerPluginContext {
|
||||||
|
serverId: string;
|
||||||
|
serverUuid: string;
|
||||||
|
gameId: string;
|
||||||
|
gameSlug: string;
|
||||||
|
gameName: string;
|
||||||
|
node: DaemonNodeConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginArtifactInput {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
downloadUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginInstallDirectory(gameSlug: string): string {
|
||||||
|
const slug = gameSlug.toLowerCase();
|
||||||
|
if (slug === 'cs2' || slug === 'csgo') return '/game/csgo/addons';
|
||||||
|
if (slug === 'rust') return '/oxide/plugins';
|
||||||
|
if (slug === 'minecraft-java') return '/plugins';
|
||||||
|
return '/plugins';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginFileExtension(downloadUrl: string): string {
|
||||||
|
try {
|
||||||
|
const pathname = new URL(downloadUrl).pathname;
|
||||||
|
const match = pathname.match(/\.([a-z0-9]{1,8})$/i);
|
||||||
|
if (match) {
|
||||||
|
return `.${match[1]!.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore URL parse failures and use default extension below.
|
||||||
|
}
|
||||||
|
return '.jar';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginFilePath(gameSlug: string, plugin: PluginArtifactInput): string | null {
|
||||||
|
if (!plugin.downloadUrl) return null;
|
||||||
|
const safeSlug = toSlug(plugin.slug) || 'plugin';
|
||||||
|
const extension = pluginFileExtension(plugin.downloadUrl);
|
||||||
|
const directory = pluginInstallDirectory(gameSlug).replace(/\/+$/, '');
|
||||||
|
return `${directory}/${safeSlug}-${plugin.id.slice(0, 8)}${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getServerPluginContext(
|
||||||
|
app: FastifyInstance,
|
||||||
|
orgId: string,
|
||||||
|
serverId: string,
|
||||||
|
): Promise<ServerPluginContext> {
|
||||||
|
const [row] = await app.db
|
||||||
|
.select({
|
||||||
|
serverId: servers.id,
|
||||||
|
serverUuid: servers.uuid,
|
||||||
|
gameId: servers.gameId,
|
||||||
|
gameSlug: games.slug,
|
||||||
|
gameName: games.name,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.innerJoin(games, eq(servers.gameId, games.id))
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw AppError.notFound('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverId: row.serverId,
|
||||||
|
serverUuid: row.serverUuid,
|
||||||
|
gameId: row.gameId,
|
||||||
|
gameSlug: row.gameSlug,
|
||||||
|
gameName: row.gameName,
|
||||||
|
node: {
|
||||||
|
fqdn: row.nodeFqdn,
|
||||||
|
grpcPort: row.nodeGrpcPort,
|
||||||
|
daemonToken: row.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPluginForGame(
|
||||||
|
app: FastifyInstance,
|
||||||
|
pluginId: string,
|
||||||
|
gameId: string,
|
||||||
|
) {
|
||||||
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
|
where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)),
|
||||||
|
});
|
||||||
|
if (!plugin) {
|
||||||
|
throw AppError.notFound('Plugin not found for this game');
|
||||||
|
}
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadPluginArtifact(downloadUrl: string): Promise<Buffer> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), PLUGIN_DOWNLOAD_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(downloadUrl, {
|
||||||
|
headers: { 'User-Agent': 'GamePanel/1.0' },
|
||||||
|
redirect: 'follow',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new AppError(
|
||||||
|
502,
|
||||||
|
`Plugin download failed with HTTP ${res.status}`,
|
||||||
|
'PLUGIN_DOWNLOAD_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = Number(res.headers.get('content-length') ?? '0');
|
||||||
|
if (contentLength > PLUGIN_DOWNLOAD_MAX_BYTES) {
|
||||||
|
throw new AppError(413, 'Plugin artifact is too large', 'PLUGIN_TOO_LARGE');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (body.length === 0) {
|
||||||
|
throw AppError.badRequest('Plugin download returned empty content');
|
||||||
|
}
|
||||||
|
if (body.length > PLUGIN_DOWNLOAD_MAX_BYTES) {
|
||||||
|
throw new AppError(413, 'Plugin artifact is too large', 'PLUGIN_TOO_LARGE');
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) throw error;
|
||||||
|
throw new AppError(
|
||||||
|
502,
|
||||||
|
'Unable to download plugin artifact',
|
||||||
|
'PLUGIN_DOWNLOAD_FAILED',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installPluginForServer(
|
||||||
|
app: FastifyInstance,
|
||||||
|
context: ServerPluginContext,
|
||||||
|
plugin: PluginArtifactInput & { version: string | null },
|
||||||
|
installedVersion: string | null,
|
||||||
|
) {
|
||||||
|
if (!plugin.downloadUrl) {
|
||||||
|
throw AppError.badRequest('Plugin has no download URL configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await app.db.query.serverPlugins.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(serverPlugins.serverId, context.serverId),
|
||||||
|
eq(serverPlugins.pluginId, plugin.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw AppError.conflict('Plugin is already installed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = await downloadPluginArtifact(plugin.downloadUrl);
|
||||||
|
const installPath = pluginFilePath(context.gameSlug, plugin);
|
||||||
|
if (!installPath) {
|
||||||
|
throw AppError.badRequest('Plugin install path could not be determined');
|
||||||
|
}
|
||||||
|
|
||||||
|
await daemonWriteFile(context.node, context.serverUuid, installPath, artifact);
|
||||||
|
|
||||||
|
const [installed] = await app.db
|
||||||
|
.insert(serverPlugins)
|
||||||
|
.values({
|
||||||
|
serverId: context.serverId,
|
||||||
|
pluginId: plugin.id,
|
||||||
|
installedVersion: installedVersion ?? plugin.version ?? null,
|
||||||
|
isActive: true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
throw new AppError(500, 'Failed to save plugin installation', 'PLUGIN_INSTALL_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { installed, installPath };
|
||||||
|
}
|
||||||
|
|
||||||
export default async function pluginRoutes(app: FastifyInstance) {
|
export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
app.addHook('onRequest', app.authenticate);
|
app.addHook('onRequest', app.authenticate);
|
||||||
|
|
||||||
|
|
@ -25,11 +229,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
app.get('/', { schema: ParamSchema }, async (request) => {
|
app.get('/', { schema: ParamSchema }, async (request) => {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
await requirePermission(request, orgId, 'plugin.read');
|
await requirePermission(request, orgId, 'plugin.read');
|
||||||
|
await getServerPluginContext(app, orgId, serverId);
|
||||||
const server = await app.db.query.servers.findFirst({
|
|
||||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
|
||||||
});
|
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
|
||||||
|
|
||||||
const installed = await app.db
|
const installed = await app.db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -51,6 +251,273 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
return { plugins: installed };
|
return { plugins: installed };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /plugins/marketplace — list game-specific marketplace plugins
|
||||||
|
app.get(
|
||||||
|
'/marketplace',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
...ParamSchema,
|
||||||
|
querystring: Type.Object({
|
||||||
|
q: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
|
const { q } = request.query as { q?: string };
|
||||||
|
await requirePermission(request, orgId, 'plugin.read');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
|
||||||
|
const catalog = await app.db
|
||||||
|
.select({
|
||||||
|
id: plugins.id,
|
||||||
|
name: plugins.name,
|
||||||
|
slug: plugins.slug,
|
||||||
|
description: plugins.description,
|
||||||
|
source: plugins.source,
|
||||||
|
externalId: plugins.externalId,
|
||||||
|
downloadUrl: plugins.downloadUrl,
|
||||||
|
version: plugins.version,
|
||||||
|
updatedAt: plugins.updatedAt,
|
||||||
|
})
|
||||||
|
.from(plugins)
|
||||||
|
.where(eq(plugins.gameId, context.gameId))
|
||||||
|
.orderBy(plugins.name);
|
||||||
|
|
||||||
|
const installedRows = await app.db
|
||||||
|
.select({
|
||||||
|
installId: serverPlugins.id,
|
||||||
|
pluginId: serverPlugins.pluginId,
|
||||||
|
installedVersion: serverPlugins.installedVersion,
|
||||||
|
isActive: serverPlugins.isActive,
|
||||||
|
installedAt: serverPlugins.installedAt,
|
||||||
|
})
|
||||||
|
.from(serverPlugins)
|
||||||
|
.where(eq(serverPlugins.serverId, context.serverId));
|
||||||
|
|
||||||
|
const installedByPluginId = new Map(
|
||||||
|
installedRows.map((row) => [row.pluginId, row]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const needle = q?.trim().toLowerCase();
|
||||||
|
const filtered = needle
|
||||||
|
? catalog.filter((plugin) => {
|
||||||
|
const name = plugin.name.toLowerCase();
|
||||||
|
const description = (plugin.description ?? '').toLowerCase();
|
||||||
|
return name.includes(needle) || description.includes(needle);
|
||||||
|
})
|
||||||
|
: catalog;
|
||||||
|
|
||||||
|
return {
|
||||||
|
game: {
|
||||||
|
id: context.gameId,
|
||||||
|
slug: context.gameSlug,
|
||||||
|
name: context.gameName,
|
||||||
|
},
|
||||||
|
plugins: filtered.map((plugin) => {
|
||||||
|
const installed = installedByPluginId.get(plugin.id);
|
||||||
|
return {
|
||||||
|
...plugin,
|
||||||
|
isInstalled: Boolean(installed),
|
||||||
|
installId: installed?.installId ?? null,
|
||||||
|
installedVersion: installed?.installedVersion ?? null,
|
||||||
|
isActive: installed?.isActive ?? false,
|
||||||
|
installedAt: installed?.installedAt ?? null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// POST /plugins/marketplace — create a game-specific plugin entry
|
||||||
|
app.post(
|
||||||
|
'/marketplace',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
...ParamSchema,
|
||||||
|
body: Type.Object({
|
||||||
|
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
description: Type.Optional(Type.String()),
|
||||||
|
downloadUrl: Type.String({ format: 'uri' }),
|
||||||
|
version: Type.Optional(Type.String({ maxLength: 100 })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
|
const { name, slug, description, downloadUrl, version } = request.body as {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
|
||||||
|
const normalizedSlug = toSlug(slug ?? name);
|
||||||
|
if (!normalizedSlug) {
|
||||||
|
throw AppError.badRequest('Plugin slug is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await app.db.query.plugins.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(plugins.gameId, context.gameId),
|
||||||
|
eq(plugins.slug, normalizedSlug),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await app.db
|
||||||
|
.insert(plugins)
|
||||||
|
.values({
|
||||||
|
gameId: context.gameId,
|
||||||
|
name,
|
||||||
|
slug: normalizedSlug,
|
||||||
|
description: description ?? null,
|
||||||
|
source: 'manual',
|
||||||
|
downloadUrl,
|
||||||
|
version: version ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'plugin.marketplace.create',
|
||||||
|
metadata: { pluginId: created?.id, gameId: context.gameId, name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send(created);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /plugins/marketplace/:pluginId — update a marketplace plugin entry
|
||||||
|
app.patch(
|
||||||
|
'/marketplace/:pluginId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: Type.Object({
|
||||||
|
orgId: Type.String({ format: 'uuid' }),
|
||||||
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
|
pluginId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
body: Type.Object({
|
||||||
|
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
description: Type.Optional(Type.String()),
|
||||||
|
downloadUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||||
|
version: Type.Optional(Type.String({ maxLength: 100 })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { orgId, serverId, pluginId } = request.params as {
|
||||||
|
orgId: string;
|
||||||
|
serverId: string;
|
||||||
|
pluginId: string;
|
||||||
|
};
|
||||||
|
const body = request.body as {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
const existing = await getPluginForGame(app, pluginId, context.gameId);
|
||||||
|
|
||||||
|
const nextSlug = body.slug !== undefined
|
||||||
|
? toSlug(body.slug)
|
||||||
|
: (body.name !== undefined ? toSlug(body.name) : existing.slug);
|
||||||
|
if (!nextSlug) {
|
||||||
|
throw AppError.badRequest('Plugin slug is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicate = await app.db.query.plugins.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(plugins.gameId, context.gameId),
|
||||||
|
eq(plugins.slug, nextSlug),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (duplicate && duplicate.id !== existing.id) {
|
||||||
|
throw AppError.conflict('A plugin with this slug already exists for the game');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await app.db
|
||||||
|
.update(plugins)
|
||||||
|
.set({
|
||||||
|
name: body.name ?? existing.name,
|
||||||
|
slug: nextSlug,
|
||||||
|
description: body.description ?? existing.description,
|
||||||
|
downloadUrl: body.downloadUrl ?? existing.downloadUrl,
|
||||||
|
version: body.version ?? existing.version,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(plugins.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new AppError(500, 'Failed to update plugin', 'PLUGIN_UPDATE_FAILED');
|
||||||
|
}
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'plugin.marketplace.update',
|
||||||
|
metadata: { pluginId: existing.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /plugins/marketplace/:pluginId — remove marketplace plugin entry
|
||||||
|
app.delete(
|
||||||
|
'/marketplace/:pluginId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: Type.Object({
|
||||||
|
orgId: Type.String({ format: 'uuid' }),
|
||||||
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
|
pluginId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { orgId, serverId, pluginId } = request.params as {
|
||||||
|
orgId: string;
|
||||||
|
serverId: string;
|
||||||
|
pluginId: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
||||||
|
|
||||||
|
const installation = await app.db.query.serverPlugins.findFirst({
|
||||||
|
where: eq(serverPlugins.pluginId, plugin.id),
|
||||||
|
});
|
||||||
|
if (installation) {
|
||||||
|
throw AppError.conflict('Plugin is installed on at least one server');
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.db.delete(plugins).where(eq(plugins.id, plugin.id));
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'plugin.marketplace.delete',
|
||||||
|
metadata: { pluginId: plugin.id, name: plugin.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /plugins/search — search Spiget for Minecraft plugins
|
// GET /plugins/search — search Spiget for Minecraft plugins
|
||||||
app.get(
|
app.get(
|
||||||
'/search',
|
'/search',
|
||||||
|
|
@ -68,18 +535,8 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
const { q, page } = request.query as { q: string; page?: number };
|
const { q, page } = request.query as { q: string; page?: number };
|
||||||
await requirePermission(request, orgId, 'plugin.manage');
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
|
||||||
// Verify server exists and is Minecraft
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
const server = await app.db.query.servers.findFirst({
|
if (context.gameSlug !== 'minecraft-java') {
|
||||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
|
||||||
});
|
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
|
||||||
|
|
||||||
const game = await app.db.query.games.findFirst({
|
|
||||||
where: eq(games.id, server.gameId),
|
|
||||||
});
|
|
||||||
if (!game) throw AppError.notFound('Game not found');
|
|
||||||
|
|
||||||
if (game.slug !== 'minecraft-java') {
|
|
||||||
throw AppError.badRequest('Spiget search is only available for Minecraft: Java Edition');
|
throw AppError.badRequest('Spiget search is only available for Minecraft: Java Edition');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,6 +555,51 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /plugins/install/:pluginId — install from game marketplace
|
||||||
|
app.post(
|
||||||
|
'/install/:pluginId',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: Type.Object({
|
||||||
|
orgId: Type.String({ format: 'uuid' }),
|
||||||
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
|
pluginId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { orgId, serverId, pluginId } = request.params as {
|
||||||
|
orgId: string;
|
||||||
|
serverId: string;
|
||||||
|
pluginId: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
const plugin = await getPluginForGame(app, pluginId, context.gameId);
|
||||||
|
|
||||||
|
const { installed, installPath } = await installPluginForServer(
|
||||||
|
app,
|
||||||
|
context,
|
||||||
|
{
|
||||||
|
id: plugin.id,
|
||||||
|
slug: plugin.slug,
|
||||||
|
downloadUrl: plugin.downloadUrl,
|
||||||
|
version: plugin.version,
|
||||||
|
},
|
||||||
|
plugin.version,
|
||||||
|
);
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'plugin.install',
|
||||||
|
metadata: { pluginId: plugin.id, name: plugin.name, source: 'marketplace', installPath },
|
||||||
|
});
|
||||||
|
|
||||||
|
return installed;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// POST /plugins/install/spiget — install a plugin from Spiget
|
// POST /plugins/install/spiget — install a plugin from Spiget
|
||||||
app.post(
|
app.post(
|
||||||
'/install/spiget',
|
'/install/spiget',
|
||||||
|
|
@ -114,24 +616,17 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
const { resourceId } = request.body as { resourceId: number };
|
const { resourceId } = request.body as { resourceId: number };
|
||||||
await requirePermission(request, orgId, 'plugin.manage');
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
|
||||||
const server = await app.db.query.servers.findFirst({
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
if (context.gameSlug !== 'minecraft-java') {
|
||||||
});
|
throw AppError.badRequest('Spiget install is only available for Minecraft: Java Edition');
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
}
|
||||||
|
|
||||||
const game = await app.db.query.games.findFirst({
|
|
||||||
where: eq(games.id, server.gameId),
|
|
||||||
});
|
|
||||||
if (!game) throw AppError.notFound('Game not found');
|
|
||||||
|
|
||||||
// Fetch resource info from Spiget
|
|
||||||
const resource = await getSpigetResource(resourceId);
|
const resource = await getSpigetResource(resourceId);
|
||||||
if (!resource) throw AppError.notFound('Spiget resource not found');
|
if (!resource) throw AppError.notFound('Spiget resource not found');
|
||||||
|
|
||||||
// Create or find plugin entry
|
|
||||||
let plugin = await app.db.query.plugins.findFirst({
|
let plugin = await app.db.query.plugins.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(plugins.gameId, game.id),
|
eq(plugins.gameId, context.gameId),
|
||||||
eq(plugins.externalId, String(resourceId)),
|
eq(plugins.externalId, String(resourceId)),
|
||||||
eq(plugins.source, 'spiget'),
|
eq(plugins.source, 'spiget'),
|
||||||
),
|
),
|
||||||
|
|
@ -141,12 +636,9 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
const [created] = await app.db
|
const [created] = await app.db
|
||||||
.insert(plugins)
|
.insert(plugins)
|
||||||
.values({
|
.values({
|
||||||
gameId: game.id,
|
gameId: context.gameId,
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
slug: resource.name
|
slug: toSlug(resource.name),
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.slice(0, 200),
|
|
||||||
description: resource.tag || null,
|
description: resource.tag || null,
|
||||||
source: 'spiget',
|
source: 'spiget',
|
||||||
externalId: String(resourceId),
|
externalId: String(resourceId),
|
||||||
|
|
@ -157,41 +649,36 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
plugin = created!;
|
plugin = created!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already installed
|
const { installed, installPath } = await installPluginForServer(
|
||||||
const existing = await app.db.query.serverPlugins.findFirst({
|
app,
|
||||||
where: and(
|
context,
|
||||||
eq(serverPlugins.serverId, serverId),
|
{
|
||||||
eq(serverPlugins.pluginId, plugin.id),
|
id: plugin.id,
|
||||||
),
|
slug: plugin.slug,
|
||||||
});
|
downloadUrl: plugin.downloadUrl,
|
||||||
if (existing) throw AppError.conflict('Plugin is already installed');
|
version: plugin.version,
|
||||||
|
},
|
||||||
// Install
|
resource.version ? String(resource.version.id) : plugin.version,
|
||||||
const [installed] = await app.db
|
);
|
||||||
.insert(serverPlugins)
|
|
||||||
.values({
|
|
||||||
serverId,
|
|
||||||
pluginId: plugin.id,
|
|
||||||
installedVersion: resource.version ? String(resource.version.id) : null,
|
|
||||||
isActive: true,
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
// TODO: Send gRPC command to daemon to download the plugin file to /data/plugins/
|
|
||||||
// downloadUrl: getSpigetDownloadUrl(resourceId)
|
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
action: 'plugin.install',
|
action: 'plugin.install',
|
||||||
metadata: { name: resource.name, source: 'spiget', resourceId },
|
metadata: {
|
||||||
|
pluginId: plugin.id,
|
||||||
|
name: resource.name,
|
||||||
|
source: 'spiget',
|
||||||
|
resourceId,
|
||||||
|
installPath,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return installed;
|
return installed;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /plugins/install/manual — install a plugin manually (upload)
|
// POST /plugins/install/manual — register manually uploaded plugin file
|
||||||
app.post(
|
app.post(
|
||||||
'/install/manual',
|
'/install/manual',
|
||||||
{
|
{
|
||||||
|
|
@ -212,21 +699,14 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
await requirePermission(request, orgId, 'plugin.manage');
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
const server = await app.db.query.servers.findFirst({
|
|
||||||
where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)),
|
|
||||||
});
|
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
|
||||||
|
|
||||||
const [plugin] = await app.db
|
const [plugin] = await app.db
|
||||||
.insert(plugins)
|
.insert(plugins)
|
||||||
.values({
|
.values({
|
||||||
gameId: server.gameId,
|
gameId: context.gameId,
|
||||||
name,
|
name,
|
||||||
slug: name
|
slug: toSlug(name),
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.slice(0, 200),
|
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
version: version ?? null,
|
version: version ?? null,
|
||||||
})
|
})
|
||||||
|
|
@ -246,7 +726,7 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
action: 'plugin.install',
|
action: 'plugin.install',
|
||||||
metadata: { name, source: 'manual', fileName },
|
metadata: { pluginId: plugin?.id, name, source: 'manual', fileName },
|
||||||
});
|
});
|
||||||
|
|
||||||
return installed;
|
return installed;
|
||||||
|
|
@ -272,24 +752,50 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
pluginInstallId: string;
|
pluginInstallId: string;
|
||||||
};
|
};
|
||||||
await requirePermission(request, orgId, 'plugin.manage');
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
|
||||||
const installed = await app.db.query.serverPlugins.findFirst({
|
const [installed] = await app.db
|
||||||
where: and(
|
.select({
|
||||||
|
installId: serverPlugins.id,
|
||||||
|
pluginId: serverPlugins.pluginId,
|
||||||
|
pluginSlug: plugins.slug,
|
||||||
|
pluginDownloadUrl: plugins.downloadUrl,
|
||||||
|
})
|
||||||
|
.from(serverPlugins)
|
||||||
|
.innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id))
|
||||||
|
.where(and(
|
||||||
eq(serverPlugins.id, pluginInstallId),
|
eq(serverPlugins.id, pluginInstallId),
|
||||||
eq(serverPlugins.serverId, serverId),
|
eq(serverPlugins.serverId, context.serverId),
|
||||||
),
|
));
|
||||||
|
|
||||||
|
if (!installed) {
|
||||||
|
throw AppError.notFound('Plugin installation not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uninstallPath = pluginFilePath(context.gameSlug, {
|
||||||
|
id: installed.pluginId,
|
||||||
|
slug: installed.pluginSlug,
|
||||||
|
downloadUrl: installed.pluginDownloadUrl,
|
||||||
});
|
});
|
||||||
if (!installed) throw AppError.notFound('Plugin installation not found');
|
|
||||||
|
if (uninstallPath) {
|
||||||
|
try {
|
||||||
|
await daemonDeleteFiles(context.node, context.serverUuid, [uninstallPath]);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
{ error, serverId, pluginInstallId, uninstallPath },
|
||||||
|
'Failed to delete plugin artifact from server filesystem',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await app.db.delete(serverPlugins).where(eq(serverPlugins.id, pluginInstallId));
|
await app.db.delete(serverPlugins).where(eq(serverPlugins.id, pluginInstallId));
|
||||||
|
|
||||||
// TODO: Send gRPC to daemon to delete the plugin file from /data/plugins/
|
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
action: 'plugin.uninstall',
|
action: 'plugin.uninstall',
|
||||||
metadata: { pluginInstallId },
|
metadata: { pluginInstallId, pluginId: installed.pluginId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.code(204).send();
|
return reply.code(204).send();
|
||||||
|
|
@ -315,11 +821,12 @@ export default async function pluginRoutes(app: FastifyInstance) {
|
||||||
pluginInstallId: string;
|
pluginInstallId: string;
|
||||||
};
|
};
|
||||||
await requirePermission(request, orgId, 'plugin.manage');
|
await requirePermission(request, orgId, 'plugin.manage');
|
||||||
|
const context = await getServerPluginContext(app, orgId, serverId);
|
||||||
|
|
||||||
const installed = await app.db.query.serverPlugins.findFirst({
|
const installed = await app.db.query.serverPlugins.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(serverPlugins.id, pluginInstallId),
|
eq(serverPlugins.id, pluginInstallId),
|
||||||
eq(serverPlugins.serverId, serverId),
|
eq(serverPlugins.serverId, context.serverId),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (!installed) throw AppError.notFound('Plugin installation not found');
|
if (!installed) throw AppError.notFound('Plugin installation not found');
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import { servers, scheduledTasks } from '@source/database';
|
import { nodes, servers, scheduledTasks } from '@source/database';
|
||||||
|
import type { PowerAction } from '@source/shared';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { createAuditLog } from '../../lib/audit.js';
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
import { computeNextRun } from '../../lib/schedule-utils.js';
|
import { computeNextRun } from '../../lib/schedule-utils.js';
|
||||||
|
import {
|
||||||
|
daemonSendCommand,
|
||||||
|
daemonSetPowerState,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from '../../lib/daemon.js';
|
||||||
|
|
||||||
const ParamSchema = {
|
const ParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
|
|
@ -194,8 +200,18 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||||
});
|
});
|
||||||
if (!task) throw AppError.notFound('Scheduled task not found');
|
if (!task) throw AppError.notFound('Scheduled task not found');
|
||||||
|
|
||||||
// TODO: Execute task action (send to daemon via gRPC)
|
if (task.action === 'command') {
|
||||||
// For now, just update lastRunAt and nextRunAt
|
const serverContext = await getServerContext(app, orgId, serverId);
|
||||||
|
await daemonSendCommand(serverContext.node, serverContext.serverUuid, task.payload);
|
||||||
|
} else if (task.action === 'power') {
|
||||||
|
const action = task.payload as PowerAction;
|
||||||
|
if (!['start', 'stop', 'restart', 'kill'].includes(action)) {
|
||||||
|
throw AppError.badRequest('Invalid power action in schedule payload');
|
||||||
|
}
|
||||||
|
const serverContext = await getServerContext(app, orgId, serverId);
|
||||||
|
await daemonSetPowerState(serverContext.node, serverContext.serverUuid, action);
|
||||||
|
}
|
||||||
|
|
||||||
const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>);
|
const nextRun = computeNextRun(task.scheduleType, task.scheduleData as Record<string, unknown>);
|
||||||
|
|
||||||
await app.db
|
await app.db
|
||||||
|
|
@ -206,3 +222,32 @@ export default async function scheduleRoutes(app: FastifyInstance) {
|
||||||
return { success: true, triggered: task.name };
|
return { success: true, triggered: task.name };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
|
||||||
|
serverUuid: string;
|
||||||
|
node: DaemonNodeConnection;
|
||||||
|
}> {
|
||||||
|
const [server] = await app.db
|
||||||
|
.select({
|
||||||
|
uuid: servers.uuid,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
throw AppError.notFound('Server not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
serverUuid: server.uuid,
|
||||||
|
node: {
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,7 @@ dependencies = [
|
||||||
"bollard",
|
"bollard",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures",
|
"futures",
|
||||||
|
"libc",
|
||||||
"prost",
|
"prost",
|
||||||
"prost-types",
|
"prost-types",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
# UUID
|
# UUID
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,17 @@ pub fn container_name(server_uuid: &str) -> String {
|
||||||
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
format!("{}{}", CONTAINER_PREFIX, server_uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn container_data_path_for_image(image: &str) -> &'static str {
|
||||||
|
let normalized = image.to_ascii_lowercase();
|
||||||
|
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
|
||||||
|
return "/home/steam/cs2-dedicated";
|
||||||
|
}
|
||||||
|
if normalized.contains("cm2network/csgo") {
|
||||||
|
return "/home/steam/csgo-dedicated";
|
||||||
|
}
|
||||||
|
"/data"
|
||||||
|
}
|
||||||
|
|
||||||
impl DockerManager {
|
impl DockerManager {
|
||||||
async fn run_exec(&self, container_name: &str, cmd: Vec<String>) -> Result<String> {
|
async fn run_exec(&self, container_name: &str, cmd: Vec<String>) -> Result<String> {
|
||||||
let exec = self
|
let exec = self
|
||||||
|
|
@ -100,6 +111,7 @@ impl DockerManager {
|
||||||
/// Create and configure a container for a game server.
|
/// Create and configure a container for a game server.
|
||||||
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
|
pub async fn create_container(&self, spec: &ServerSpec) -> Result<String> {
|
||||||
let name = container_name(&spec.uuid);
|
let name = container_name(&spec.uuid);
|
||||||
|
let data_mount_path = container_data_path_for_image(&spec.docker_image);
|
||||||
|
|
||||||
// Build port bindings
|
// Build port bindings
|
||||||
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||||
|
|
@ -135,8 +147,10 @@ impl DockerManager {
|
||||||
port_bindings: Some(port_bindings),
|
port_bindings: Some(port_bindings),
|
||||||
network_mode: Some(self.network_name().to_string()),
|
network_mode: Some(self.network_name().to_string()),
|
||||||
binds: Some(vec![format!(
|
binds: Some(vec![format!(
|
||||||
"{}:/data",
|
"{}:{}",
|
||||||
spec.data_path.display()
|
spec.data_path.display()
|
||||||
|
,
|
||||||
|
data_mount_path
|
||||||
)]),
|
)]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
@ -147,7 +161,13 @@ impl DockerManager {
|
||||||
env: Some(env),
|
env: Some(env),
|
||||||
exposed_ports: Some(exposed_ports),
|
exposed_ports: Some(exposed_ports),
|
||||||
host_config: Some(host_config),
|
host_config: Some(host_config),
|
||||||
working_dir: Some("/data".to_string()),
|
// Preserve image default working directory when no custom startup command is set.
|
||||||
|
// Some game images rely on their built-in WORKDIR and entrypoint scripts.
|
||||||
|
working_dir: if spec.startup_command.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(data_mount_path.to_string())
|
||||||
|
},
|
||||||
cmd: if spec.startup_command.is_empty() {
|
cmd: if spec.startup_command.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -268,6 +288,36 @@ impl DockerManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read container runtime metadata (image + env vars) from Docker inspect.
|
||||||
|
pub async fn container_runtime_metadata(
|
||||||
|
&self,
|
||||||
|
server_uuid: &str,
|
||||||
|
) -> Result<(String, HashMap<String, String>)> {
|
||||||
|
let name = container_name(server_uuid);
|
||||||
|
let info = self.client().inspect_container(&name, None).await?;
|
||||||
|
|
||||||
|
let image = info
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|cfg| cfg.image.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut env_map = HashMap::new();
|
||||||
|
if let Some(env_vars) = info
|
||||||
|
.config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|cfg| cfg.env.clone())
|
||||||
|
{
|
||||||
|
for entry in env_vars {
|
||||||
|
if let Some((key, value)) = entry.split_once('=') {
|
||||||
|
env_map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((image, env_map))
|
||||||
|
}
|
||||||
|
|
||||||
/// Stream container logs (stdout + stderr). Returns an owned stream.
|
/// Stream container logs (stdout + stderr). Returns an owned stream.
|
||||||
pub fn stream_logs(
|
pub fn stream_logs(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
|
|
|
||||||
|
|
@ -34,36 +34,28 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
|
||||||
for line in response.lines() {
|
for line in response.lines() {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
|
|
||||||
// Parse max players from "players : X humans, Y bots (Z/M max)"
|
// Parse max players from status line variants:
|
||||||
if trimmed.starts_with("players") && trimmed.contains("max") {
|
// "players : X humans, Y bots (Z/M max)"
|
||||||
if let Some(max_str) = trimmed.split('/').last() {
|
// "players : X humans, Y bots (Z max)"
|
||||||
if let Some(num) = max_str.split_whitespace().next() {
|
if trimmed.starts_with("players") {
|
||||||
max_players = num.parse().unwrap_or(0);
|
if let Some(parsed_max) = parse_max_players_from_line(trimmed) {
|
||||||
}
|
max_players = parsed_max;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Player table header: starts with #
|
if trimmed.contains("---------players--------") || trimmed.starts_with("# userid") {
|
||||||
if trimmed.starts_with("# userid") {
|
|
||||||
in_player_section = true;
|
in_player_section = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// End of player section
|
if in_player_section && (trimmed == "#end" || trimmed.starts_with("---------")) {
|
||||||
if in_player_section && (trimmed.is_empty() || trimmed.starts_with('#')) {
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
in_player_section = false;
|
in_player_section = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Parse player lines: "# userid name steamid ..."
|
|
||||||
if in_player_section && trimmed.starts_with('#') {
|
|
||||||
let parts: Vec<&str> = trimmed.splitn(6, char::is_whitespace).collect();
|
|
||||||
if parts.len() >= 4 {
|
|
||||||
let name = parts.get(2).unwrap_or(&"").trim_matches('"').to_string();
|
|
||||||
let steamid = parts.get(3).unwrap_or(&"").to_string();
|
|
||||||
|
|
||||||
|
// Parse player lines for both old and current CS2 status formats.
|
||||||
|
if in_player_section {
|
||||||
|
if let Some((name, steamid)) = parse_player_line(trimmed) {
|
||||||
players.push(Cs2Player {
|
players.push(Cs2Player {
|
||||||
name,
|
name,
|
||||||
steamid,
|
steamid,
|
||||||
|
|
@ -77,6 +69,62 @@ fn parse_status_response(response: &str) -> (Vec<Cs2Player>, u32) {
|
||||||
(players, max_players)
|
(players, max_players)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_max_players_from_line(line: &str) -> Option<u32> {
|
||||||
|
let start = line.find('(')?;
|
||||||
|
let end = line[start + 1..].find(')')? + start + 1;
|
||||||
|
let inside = &line[start + 1..end];
|
||||||
|
|
||||||
|
inside
|
||||||
|
.split(|c: char| !c.is_ascii_digit())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.filter_map(|s| s.parse::<u32>().ok())
|
||||||
|
.max()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_player_line(line: &str) -> Option<(String, String)> {
|
||||||
|
// Skip table/header rows.
|
||||||
|
if line.is_empty()
|
||||||
|
|| line.starts_with("id ")
|
||||||
|
|| line.contains("userid")
|
||||||
|
|| line.contains("steamid")
|
||||||
|
|| line.contains("adr name")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy format: # 2 "Player" STEAM_...
|
||||||
|
if let Some(quote_start) = line.find('"') {
|
||||||
|
let quote_end = line[quote_start + 1..].find('"')? + quote_start + 1;
|
||||||
|
let name = line[quote_start + 1..quote_end].trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rest = line[quote_end + 1..].trim();
|
||||||
|
let steamid = rest.split_whitespace().next()?.to_string();
|
||||||
|
if steamid.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some((name, steamid));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current CS2 format: ... 'PlayerName'
|
||||||
|
let quote_end = line.rfind('\'')?;
|
||||||
|
let before_end = &line[..quote_end];
|
||||||
|
let quote_start = before_end.rfind('\'')?;
|
||||||
|
if quote_start >= quote_end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = line[quote_start + 1..quote_end].trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New status output does not include steamid in player rows.
|
||||||
|
Some((name, String::new()))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -91,7 +139,28 @@ players : 2 humans, 0 bots (16/0 max) (not hibernating)
|
||||||
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
|
# 3 "Player2" STEAM_1:0:67890 00:10 30 0 active 128000
|
||||||
"#;
|
"#;
|
||||||
let (players, max) = parse_status_response(response);
|
let (players, max) = parse_status_response(response);
|
||||||
assert_eq!(max, 0); // simplified parser
|
assert_eq!(max, 16);
|
||||||
assert_eq!(players.len(), 2);
|
assert_eq!(players.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_status_current_cs2_format() {
|
||||||
|
let response = r#"Server: Running [0.0.0.0:27015]
|
||||||
|
players : 1 humans, 2 bots (0 max) (not hibernating) (unreserved)
|
||||||
|
---------players--------
|
||||||
|
id time ping loss state rate adr name
|
||||||
|
65535 [NoChan] 0 0 challenging 0unknown ''
|
||||||
|
1 BOT 0 0 active 0 'Rezan'
|
||||||
|
2 00:21 11 0 active 786432 212.154.6.153:57008 'hibna'
|
||||||
|
3 BOT 0 0 active 0 'Squad'
|
||||||
|
#end
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let (players, max) = parse_status_response(response);
|
||||||
|
assert_eq!(max, 0);
|
||||||
|
assert_eq!(players.len(), 3);
|
||||||
|
assert_eq!(players[0].name, "Rezan");
|
||||||
|
assert_eq!(players[1].name, "hibna");
|
||||||
|
assert_eq!(players[2].name, "Squad");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@ use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::ffi::CString;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
@ -10,6 +15,7 @@ use tracing::{info, error, warn};
|
||||||
|
|
||||||
use crate::server::{ServerManager, PortMap};
|
use crate::server::{ServerManager, PortMap};
|
||||||
use crate::filesystem::FileSystem;
|
use crate::filesystem::FileSystem;
|
||||||
|
use crate::backup::BackupManager;
|
||||||
|
|
||||||
// Import generated protobuf types
|
// Import generated protobuf types
|
||||||
pub mod pb {
|
pub mod pb {
|
||||||
|
|
@ -21,14 +27,28 @@ use pb::*;
|
||||||
|
|
||||||
pub struct DaemonServiceImpl {
|
pub struct DaemonServiceImpl {
|
||||||
server_manager: Arc<ServerManager>,
|
server_manager: Arc<ServerManager>,
|
||||||
|
backup_manager: BackupManager,
|
||||||
daemon_token: String,
|
daemon_token: String,
|
||||||
start_time: Instant,
|
start_time: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonServiceImpl {
|
impl DaemonServiceImpl {
|
||||||
pub fn new(server_manager: Arc<ServerManager>, daemon_token: String) -> Self {
|
pub fn new(
|
||||||
|
server_manager: Arc<ServerManager>,
|
||||||
|
daemon_token: String,
|
||||||
|
backup_root: PathBuf,
|
||||||
|
api_url: String,
|
||||||
|
) -> Self {
|
||||||
|
let backup_manager = BackupManager::new(
|
||||||
|
server_manager.clone(),
|
||||||
|
backup_root,
|
||||||
|
api_url,
|
||||||
|
daemon_token.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
server_manager,
|
server_manager,
|
||||||
|
backup_manager,
|
||||||
daemon_token,
|
daemon_token,
|
||||||
start_time: Instant::now(),
|
start_time: Instant::now(),
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +71,41 @@ impl DaemonServiceImpl {
|
||||||
let data_path = self.server_manager.data_root().join(uuid);
|
let data_path = self.server_manager.data_root().join(uuid);
|
||||||
FileSystem::new(data_path)
|
FileSystem::new(data_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_server_runtime(
|
||||||
|
&self,
|
||||||
|
uuid: &str,
|
||||||
|
) -> Option<(String, HashMap<String, String>)> {
|
||||||
|
if let Ok(spec) = self.server_manager.get_server(uuid).await {
|
||||||
|
return Some((spec.docker_image, spec.environment));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.server_manager
|
||||||
|
.docker()
|
||||||
|
.container_runtime_metadata(uuid)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_value(env: &HashMap<String, String>, keys: &[&str]) -> Option<String> {
|
||||||
|
keys.iter()
|
||||||
|
.find_map(|k| env.get(*k))
|
||||||
|
.map(|v| v.trim().to_string())
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_u16(env: &HashMap<String, String>, keys: &[&str]) -> Option<u16> {
|
||||||
|
Self::env_value(env, keys).and_then(|v| v.parse::<u16>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_i32(env: &HashMap<String, String>, keys: &[&str]) -> Option<i32> {
|
||||||
|
Self::env_value(env, keys).and_then(|v| v.parse::<i32>().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cs2_rcon_password(env: &HashMap<String, String>) -> String {
|
||||||
|
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
|
||||||
|
.unwrap_or_else(|| "changeme".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type GrpcStream<T> = Pin<Box<dyn futures::Stream<Item = Result<T, Status>> + Send>>;
|
type GrpcStream<T> = Pin<Box<dyn futures::Stream<Item = Result<T, Status>> + Send>>;
|
||||||
|
|
@ -88,17 +143,12 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
let (tx, rx) = tokio::sync::mpsc::channel(32);
|
||||||
|
let data_root = self.server_manager.data_root().clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
let mut previous_cpu = read_cpu_sample();
|
||||||
loop {
|
loop {
|
||||||
// Read system stats
|
let stats = read_node_stats(&data_root, &mut previous_cpu);
|
||||||
let stats = NodeStats {
|
|
||||||
cpu_percent: 0.0, // TODO: real system stats
|
|
||||||
memory_used: 0,
|
|
||||||
memory_total: 0,
|
|
||||||
disk_used: 0,
|
|
||||||
disk_total: 0,
|
|
||||||
};
|
|
||||||
if tx.send(Ok(stats)).await.is_err() {
|
if tx.send(Ok(stats)).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -281,6 +331,29 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if let Some((image, env)) = self.get_server_runtime(&req.uuid).await {
|
||||||
|
let image = image.to_lowercase();
|
||||||
|
if image.contains("cs2") || image.contains("csgo") {
|
||||||
|
let host = Self::env_value(&env, &["RCON_HOST"])
|
||||||
|
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
|
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
|
||||||
|
let password = Self::cs2_rcon_password(&env);
|
||||||
|
let address = format!("{}:{}", host, port);
|
||||||
|
|
||||||
|
match crate::game::rcon::RconClient::connect(&address, &password).await {
|
||||||
|
Ok(mut client) => match client.command(&req.command).await {
|
||||||
|
Ok(_) => return Ok(Response::new(Empty {})),
|
||||||
|
Err(e) => {
|
||||||
|
warn!(uuid = %req.uuid, command = %req.command, error = %e, "CS2 RCON command failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
warn!(uuid = %req.uuid, command = %req.command, error = %e, "CS2 RCON connect failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.server_manager
|
self.server_manager
|
||||||
.docker()
|
.docker()
|
||||||
.send_command(&req.uuid, &req.command)
|
.send_command(&req.uuid, &req.command)
|
||||||
|
|
@ -389,8 +462,20 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
request: Request<BackupRequest>,
|
request: Request<BackupRequest>,
|
||||||
) -> Result<Response<BackupResponse>, Status> {
|
) -> Result<Response<BackupResponse>, Status> {
|
||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
// TODO: implement backup creation
|
let req = request.into_inner();
|
||||||
Err(Status::unimplemented("Not yet implemented"))
|
|
||||||
|
let (_path, size_bytes, checksum) = self
|
||||||
|
.backup_manager
|
||||||
|
.create_backup(&req.server_uuid, &req.backup_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("Failed to create backup: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(BackupResponse {
|
||||||
|
backup_id: req.backup_id,
|
||||||
|
size_bytes: size_bytes.min(i64::MAX as u64) as i64,
|
||||||
|
checksum,
|
||||||
|
success: true,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_backup(
|
async fn restore_backup(
|
||||||
|
|
@ -398,8 +483,21 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
request: Request<RestoreBackupRequest>,
|
request: Request<RestoreBackupRequest>,
|
||||||
) -> Result<Response<Empty>, Status> {
|
) -> Result<Response<Empty>, Status> {
|
||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
// TODO: implement backup restoration
|
let req = request.into_inner();
|
||||||
Err(Status::unimplemented("Not yet implemented"))
|
|
||||||
|
let cdn_path = if req.cdn_download_url.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(req.cdn_download_url.as_str())
|
||||||
|
};
|
||||||
|
|
||||||
|
self
|
||||||
|
.backup_manager
|
||||||
|
.restore_backup(&req.server_uuid, &req.backup_id, cdn_path)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("Failed to restore backup: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(Empty {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_backup(
|
async fn delete_backup(
|
||||||
|
|
@ -407,8 +505,15 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
request: Request<BackupIdentifier>,
|
request: Request<BackupIdentifier>,
|
||||||
) -> Result<Response<Empty>, Status> {
|
) -> Result<Response<Empty>, Status> {
|
||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
// TODO: implement backup deletion
|
let req = request.into_inner();
|
||||||
Err(Status::unimplemented("Not yet implemented"))
|
|
||||||
|
self
|
||||||
|
.backup_manager
|
||||||
|
.delete_backup(&req.server_uuid, &req.backup_id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| Status::internal(format!("Failed to delete backup: {e}")))?;
|
||||||
|
|
||||||
|
Ok(Response::new(Empty {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Stats ===
|
// === Stats ===
|
||||||
|
|
@ -504,19 +609,13 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
.and_then(|v| v.parse::<u16>().ok())
|
.and_then(|v| v.parse::<u16>().ok())
|
||||||
.unwrap_or(25575);
|
.unwrap_or(25575);
|
||||||
|
|
||||||
// Try RCON-based player discovery for known games when runtime spec exists.
|
// Try game-specific player discovery using runtime metadata (works even after daemon restart).
|
||||||
if let Ok(spec) = self.server_manager.get_server(&uuid).await {
|
let mut max_from_runtime_env = 0;
|
||||||
let image = spec.docker_image.to_lowercase();
|
if let Some((image, env)) = self.get_server_runtime(&uuid).await {
|
||||||
|
let image = image.to_lowercase();
|
||||||
|
|
||||||
if image.contains("minecraft") {
|
if image.contains("minecraft") {
|
||||||
let password_from_env = spec
|
let password = Self::env_value(&env, &["RCON_PASSWORD", "MCRCON_PASSWORD"])
|
||||||
.environment
|
|
||||||
.get("RCON_PASSWORD")
|
|
||||||
.or_else(|| spec.environment.get("MCRCON_PASSWORD"))
|
|
||||||
.filter(|v| !v.trim().is_empty());
|
|
||||||
|
|
||||||
let password = password_from_env
|
|
||||||
.cloned()
|
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
if rcon_enabled_from_properties {
|
if rcon_enabled_from_properties {
|
||||||
rcon_password_from_properties.clone()
|
rcon_password_from_properties.clone()
|
||||||
|
|
@ -526,16 +625,9 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(password) = password {
|
if let Some(password) = password {
|
||||||
let host = spec
|
let host = Self::env_value(&env, &["RCON_HOST"])
|
||||||
.environment
|
|
||||||
.get("RCON_HOST")
|
|
||||||
.filter(|v| !v.trim().is_empty())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
let port = spec
|
let port = Self::env_u16(&env, &["RCON_PORT"])
|
||||||
.environment
|
|
||||||
.get("RCON_PORT")
|
|
||||||
.and_then(|v| v.parse::<u16>().ok())
|
|
||||||
.unwrap_or(rcon_port_from_properties);
|
.unwrap_or(rcon_port_from_properties);
|
||||||
let address = format!("{}:{}", host, port);
|
let address = format!("{}:{}", host, port);
|
||||||
|
|
||||||
|
|
@ -560,26 +652,16 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if image.contains("csgo") || image.contains("cs2") {
|
} else if image.contains("csgo") || image.contains("cs2") {
|
||||||
if let Some(password) = spec
|
max_from_runtime_env = Self::env_i32(&env, &["CS2_MAXPLAYERS", "SRCDS_MAXPLAYERS"])
|
||||||
.environment
|
.unwrap_or(0);
|
||||||
.get("SRCDS_RCONPW")
|
|
||||||
.or_else(|| spec.environment.get("RCON_PASSWORD"))
|
let host = Self::env_value(&env, &["RCON_HOST"])
|
||||||
.filter(|v| !v.trim().is_empty())
|
|
||||||
{
|
|
||||||
let host = spec
|
|
||||||
.environment
|
|
||||||
.get("RCON_HOST")
|
|
||||||
.filter(|v| !v.trim().is_empty())
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||||
let port = spec
|
let port = Self::env_u16(&env, &["RCON_PORT", "CS2_PORT"]).unwrap_or(27015);
|
||||||
.environment
|
let password = Self::cs2_rcon_password(&env);
|
||||||
.get("RCON_PORT")
|
|
||||||
.and_then(|v| v.parse::<u16>().ok())
|
|
||||||
.unwrap_or(27015);
|
|
||||||
let address = format!("{}:{}", host, port);
|
let address = format!("{}:{}", host, port);
|
||||||
|
|
||||||
match crate::game::cs2::get_players(&address, password).await {
|
match crate::game::cs2::get_players(&address, &password).await {
|
||||||
Ok((players, max)) => {
|
Ok((players, max)) => {
|
||||||
let mapped = players
|
let mapped = players
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -589,9 +671,10 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
connected_at: 0,
|
connected_at: 0,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let max_players = if max > 0 { max as i32 } else { max_from_runtime_env };
|
||||||
return Ok(Response::new(PlayerList {
|
return Ok(Response::new(PlayerList {
|
||||||
players: mapped,
|
players: mapped,
|
||||||
max_players: max as i32,
|
max_players,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -600,7 +683,6 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for restarted daemon / missing runtime spec:
|
// Fallback for restarted daemon / missing runtime spec:
|
||||||
// try querying `rcon-cli list` inside the container and parse output.
|
// try querying `rcon-cli list` inside the container and parse output.
|
||||||
|
|
@ -624,11 +706,146 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
|
|
||||||
Ok(Response::new(PlayerList {
|
Ok(Response::new(PlayerList {
|
||||||
players: vec![],
|
players: vec![],
|
||||||
max_players: max_from_properties,
|
max_players: if max_from_runtime_env > 0 {
|
||||||
|
max_from_runtime_env
|
||||||
|
} else {
|
||||||
|
max_from_properties
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct CpuSample {
|
||||||
|
total: u64,
|
||||||
|
idle: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_node_stats(data_root: &Path, previous_cpu: &mut Option<CpuSample>) -> NodeStats {
|
||||||
|
let current_cpu = read_cpu_sample();
|
||||||
|
let cpu_percent = match (*previous_cpu, current_cpu) {
|
||||||
|
(Some(prev), Some(current)) => calculate_node_cpu_percent(prev, current),
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
*previous_cpu = current_cpu;
|
||||||
|
|
||||||
|
let (memory_used, memory_total) = read_memory_stats().unwrap_or((0, 0));
|
||||||
|
let (disk_used, disk_total) = read_disk_stats(data_root).unwrap_or((0, 0));
|
||||||
|
|
||||||
|
NodeStats {
|
||||||
|
cpu_percent,
|
||||||
|
memory_used,
|
||||||
|
memory_total,
|
||||||
|
disk_used,
|
||||||
|
disk_total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_cpu_sample() -> Option<CpuSample> {
|
||||||
|
let content = std::fs::read_to_string("/proc/stat").ok()?;
|
||||||
|
let line = content.lines().next()?;
|
||||||
|
if !line.starts_with("cpu ") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut values = line
|
||||||
|
.split_whitespace()
|
||||||
|
.skip(1)
|
||||||
|
.filter_map(|value| value.parse::<u64>().ok());
|
||||||
|
|
||||||
|
let user = values.next()?;
|
||||||
|
let nice = values.next()?;
|
||||||
|
let system = values.next()?;
|
||||||
|
let idle = values.next()?;
|
||||||
|
let iowait = values.next().unwrap_or(0);
|
||||||
|
let irq = values.next().unwrap_or(0);
|
||||||
|
let softirq = values.next().unwrap_or(0);
|
||||||
|
let steal = values.next().unwrap_or(0);
|
||||||
|
|
||||||
|
let total_idle = idle.saturating_add(iowait);
|
||||||
|
let total = user
|
||||||
|
.saturating_add(nice)
|
||||||
|
.saturating_add(system)
|
||||||
|
.saturating_add(total_idle)
|
||||||
|
.saturating_add(irq)
|
||||||
|
.saturating_add(softirq)
|
||||||
|
.saturating_add(steal);
|
||||||
|
|
||||||
|
Some(CpuSample {
|
||||||
|
total,
|
||||||
|
idle: total_idle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_node_cpu_percent(previous: CpuSample, current: CpuSample) -> f64 {
|
||||||
|
let total_delta = current.total.saturating_sub(previous.total) as f64;
|
||||||
|
let idle_delta = current.idle.saturating_sub(previous.idle) as f64;
|
||||||
|
if total_delta <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
((total_delta - idle_delta) / total_delta * 100.0).clamp(0.0, 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_memory_stats() -> Option<(i64, i64)> {
|
||||||
|
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
|
||||||
|
let mut total_kib: Option<u64> = None;
|
||||||
|
let mut available_kib: Option<u64> = None;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.starts_with("MemTotal:") {
|
||||||
|
total_kib = line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|value| value.parse::<u64>().ok());
|
||||||
|
} else if line.starts_with("MemAvailable:") {
|
||||||
|
available_kib = line
|
||||||
|
.split_whitespace()
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|value| value.parse::<u64>().ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_kib.is_some() && available_kib.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_bytes = total_kib?.saturating_mul(1024);
|
||||||
|
let available_bytes = available_kib?.saturating_mul(1024);
|
||||||
|
let used_bytes = total_bytes.saturating_sub(available_bytes);
|
||||||
|
|
||||||
|
Some((
|
||||||
|
used_bytes.min(i64::MAX as u64) as i64,
|
||||||
|
total_bytes.min(i64::MAX as u64) as i64,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn read_disk_stats(path: &Path) -> Option<(i64, i64)> {
|
||||||
|
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
|
||||||
|
let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||||
|
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stats) } != 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let block_size = if stats.f_frsize > 0 {
|
||||||
|
stats.f_frsize as u128
|
||||||
|
} else {
|
||||||
|
stats.f_bsize as u128
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = block_size.saturating_mul(stats.f_blocks as u128);
|
||||||
|
let available = block_size.saturating_mul(stats.f_bavail as u128);
|
||||||
|
let used = total.saturating_sub(available);
|
||||||
|
let max = i64::MAX as u128;
|
||||||
|
|
||||||
|
Some((used.min(max) as i64, total.min(max) as i64))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn read_disk_stats(_path: &Path) -> Option<(i64, i64)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Calculate CPU percentage from Docker stats.
|
/// Calculate CPU percentage from Docker stats.
|
||||||
fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
|
fn calculate_cpu_percent(stats: &bollard::container::Stats) -> f64 {
|
||||||
let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64
|
let cpu_delta = stats.cpu_stats.cpu_usage.total_usage as f64
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ use crate::grpc::DaemonServiceImpl;
|
||||||
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
|
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
|
||||||
use crate::server::ServerManager;
|
use crate::server::ServerManager;
|
||||||
|
|
||||||
|
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
|
|
@ -47,6 +49,8 @@ async fn main() -> Result<()> {
|
||||||
let daemon_service = DaemonServiceImpl::new(
|
let daemon_service = DaemonServiceImpl::new(
|
||||||
server_manager.clone(),
|
server_manager.clone(),
|
||||||
config.node_token.clone(),
|
config.node_token.clone(),
|
||||||
|
config.backup_path.clone(),
|
||||||
|
config.api_url.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start gRPC server
|
// Start gRPC server
|
||||||
|
|
@ -73,8 +77,12 @@ async fn main() -> Result<()> {
|
||||||
info!("Scheduler initialized");
|
info!("Scheduler initialized");
|
||||||
|
|
||||||
// Start serving
|
// Start serving
|
||||||
|
let daemon_service = DaemonServiceServer::new(daemon_service)
|
||||||
|
.max_decoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES)
|
||||||
|
.max_encoding_message_size(MAX_GRPC_MESSAGE_SIZE_BYTES);
|
||||||
|
|
||||||
Server::builder()
|
Server::builder()
|
||||||
.add_service(DaemonServiceServer::new(daemon_service))
|
.add_service(daemon_service)
|
||||||
.serve_with_shutdown(addr, async {
|
.serve_with_shutdown(addr, async {
|
||||||
tokio::signal::ctrl_c().await.ok();
|
tokio::signal::ctrl_c().await.ok();
|
||||||
info!("Shutdown signal received");
|
info!("Shutdown signal received");
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ use std::sync::Arc;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{info, error, warn};
|
use tracing::{info, error, warn};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
use crate::config::DaemonConfig;
|
use crate::config::DaemonConfig;
|
||||||
use crate::docker::DockerManager;
|
use crate::docker::DockerManager;
|
||||||
|
|
@ -64,6 +66,15 @@ impl ServerManager {
|
||||||
tokio::fs::create_dir_all(&data_path)
|
tokio::fs::create_dir_all(&data_path)
|
||||||
.await
|
.await
|
||||||
.map_err(DaemonError::Io)?;
|
.map_err(DaemonError::Io)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
// Containers may run with non-root users (e.g. steam uid 1000).
|
||||||
|
// Keep server directory writable to avoid install/start failures.
|
||||||
|
let permissions = std::fs::Permissions::from_mode(0o777);
|
||||||
|
tokio::fs::set_permissions(&data_path, permissions)
|
||||||
|
.await
|
||||||
|
.map_err(DaemonError::Io)?;
|
||||||
|
}
|
||||||
|
|
||||||
let spec = ServerSpec {
|
let spec = ServerSpec {
|
||||||
uuid: uuid.clone(),
|
uuid: uuid.clone(),
|
||||||
|
|
@ -131,23 +142,36 @@ impl ServerManager {
|
||||||
/// Start a server.
|
/// Start a server.
|
||||||
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
pub async fn start_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
||||||
let mut managed = false;
|
let mut managed = false;
|
||||||
|
let mut previous_state: Option<ServerState> = None;
|
||||||
{
|
{
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
if let Some(spec) = servers.get_mut(uuid) {
|
if let Some(spec) = servers.get_mut(uuid) {
|
||||||
|
// Recover from stale transitional state left by a previous failed start attempt.
|
||||||
|
if spec.state == ServerState::Starting {
|
||||||
|
warn!(uuid = %uuid, "Recovering stale starting state");
|
||||||
|
spec.state = ServerState::Stopped;
|
||||||
|
}
|
||||||
if !spec.can_transition_to(&ServerState::Starting) {
|
if !spec.can_transition_to(&ServerState::Starting) {
|
||||||
return Err(DaemonError::InvalidStateTransition {
|
return Err(DaemonError::InvalidStateTransition {
|
||||||
current: spec.state.to_string(),
|
current: spec.state.to_string(),
|
||||||
requested: "starting".to_string(),
|
requested: "starting".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
previous_state = Some(spec.state.clone());
|
||||||
spec.state = ServerState::Starting;
|
spec.state = ServerState::Starting;
|
||||||
managed = true;
|
managed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.docker.start_container(uuid).await.map_err(|e| {
|
if let Err(e) = self.docker.start_container(uuid).await {
|
||||||
DaemonError::Internal(format!("Failed to start container: {}", e))
|
if managed {
|
||||||
})?;
|
let mut servers = self.servers.write().await;
|
||||||
|
if let Some(spec) = servers.get_mut(uuid) {
|
||||||
|
spec.state = previous_state.unwrap_or(ServerState::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(DaemonError::Internal(format!("Failed to start container: {}", e)));
|
||||||
|
}
|
||||||
|
|
||||||
if managed {
|
if managed {
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
|
|
@ -164,23 +188,36 @@ impl ServerManager {
|
||||||
/// Stop a server.
|
/// Stop a server.
|
||||||
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
pub async fn stop_server(&self, uuid: &str) -> Result<(), DaemonError> {
|
||||||
let mut managed = false;
|
let mut managed = false;
|
||||||
|
let mut previous_state: Option<ServerState> = None;
|
||||||
{
|
{
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
if let Some(spec) = servers.get_mut(uuid) {
|
if let Some(spec) = servers.get_mut(uuid) {
|
||||||
|
// Recover from stale transitional state left by a previous failed stop attempt.
|
||||||
|
if spec.state == ServerState::Stopping {
|
||||||
|
warn!(uuid = %uuid, "Recovering stale stopping state");
|
||||||
|
spec.state = ServerState::Running;
|
||||||
|
}
|
||||||
if !spec.can_transition_to(&ServerState::Stopping) {
|
if !spec.can_transition_to(&ServerState::Stopping) {
|
||||||
return Err(DaemonError::InvalidStateTransition {
|
return Err(DaemonError::InvalidStateTransition {
|
||||||
current: spec.state.to_string(),
|
current: spec.state.to_string(),
|
||||||
requested: "stopping".to_string(),
|
requested: "stopping".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
previous_state = Some(spec.state.clone());
|
||||||
spec.state = ServerState::Stopping;
|
spec.state = ServerState::Stopping;
|
||||||
managed = true;
|
managed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.docker.stop_container(uuid, 30).await.map_err(|e| {
|
if let Err(e) = self.docker.stop_container(uuid, 30).await {
|
||||||
DaemonError::Internal(format!("Failed to stop container: {}", e))
|
if managed {
|
||||||
})?;
|
let mut servers = self.servers.write().await;
|
||||||
|
if let Some(spec) = servers.get_mut(uuid) {
|
||||||
|
spec.state = previous_state.unwrap_or(ServerState::Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(DaemonError::Internal(format!("Failed to stop container: {}", e)));
|
||||||
|
}
|
||||||
|
|
||||||
if managed {
|
if managed {
|
||||||
let mut servers = self.servers.write().await;
|
let mut servers = self.servers.write().await;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
const API_BASE = '/api';
|
const RAW_API_BASE = (
|
||||||
|
(import.meta.env.VITE_API_URL as string | undefined) ??
|
||||||
|
(import.meta.env.VITE_API_BASE_URL as string | undefined) ??
|
||||||
|
'/api'
|
||||||
|
).trim();
|
||||||
|
const API_BASE = (RAW_API_BASE || '/api').replace(/\/+$/, '');
|
||||||
|
|
||||||
interface RequestOptions extends RequestInit {
|
interface RequestOptions extends RequestInit {
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
|
|
@ -36,7 +41,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(url, { ...fetchOptions, headers });
|
const res = await fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
credentials: fetchOptions.credentials ?? 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldHandle401WithRefresh =
|
const shouldHandle401WithRefresh =
|
||||||
res.status === 401 &&
|
res.status === 401 &&
|
||||||
|
|
@ -49,7 +58,11 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||||
const refreshed = await refreshToken();
|
const refreshed = await refreshToken();
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
|
headers['Authorization'] = `Bearer ${localStorage.getItem('access_token')}`;
|
||||||
const retry = await fetch(url, { ...fetchOptions, headers });
|
const retry = await fetch(url, {
|
||||||
|
...fetchOptions,
|
||||||
|
credentials: fetchOptions.credentials ?? 'include',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
|
if (!retry.ok) throw new ApiError(retry.status, await retry.json().catch(() => null));
|
||||||
if (retry.status === 204) return undefined as T;
|
if (retry.status === 204) return undefined as T;
|
||||||
return retry.json();
|
return retry.json();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState } from 'react';
|
import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Gamepad2 } from 'lucide-react';
|
import { Plus, Gamepad2 } from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { toast } from 'sonner';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
@ -23,16 +24,55 @@ interface Game {
|
||||||
dockerImage: string;
|
dockerImage: string;
|
||||||
defaultPort: number;
|
defaultPort: number;
|
||||||
startupCommand: string;
|
startupCommand: string;
|
||||||
|
automationRules: unknown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
interface GamesResponse {
|
||||||
data: T[];
|
data: Game[];
|
||||||
meta: { total: number };
|
}
|
||||||
|
|
||||||
|
function extractApiMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||||
|
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||||
|
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||||
|
return maybeMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAutomationRules(value: unknown): string {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAutomationRules(raw: string): { rules: unknown[]; error: string | null } {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return { rules: [], error: 'Automation JSON must be an array.' };
|
||||||
|
}
|
||||||
|
return { rules: parsed, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||||
|
return { rules: [], error: message };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminGamesPage() {
|
export function AdminGamesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [automationOpen, setAutomationOpen] = useState(false);
|
||||||
|
const [selectedGame, setSelectedGame] = useState<Game | null>(null);
|
||||||
|
const [automationJson, setAutomationJson] = useState('[]');
|
||||||
|
const [automationError, setAutomationError] = useState<string | null>(null);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [slug, setSlug] = useState('');
|
const [slug, setSlug] = useState('');
|
||||||
const [dockerImage, setDockerImage] = useState('');
|
const [dockerImage, setDockerImage] = useState('');
|
||||||
|
|
@ -41,7 +81,7 @@ export function AdminGamesPage() {
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['admin-games'],
|
queryKey: ['admin-games'],
|
||||||
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
|
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
|
|
@ -53,11 +93,70 @@ export function AdminGamesPage() {
|
||||||
setSlug('');
|
setSlug('');
|
||||||
setDockerImage('');
|
setDockerImage('');
|
||||||
setStartupCommand('');
|
setStartupCommand('');
|
||||||
|
toast.success('Game created');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to create game'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAutomationMutation = useMutation({
|
||||||
|
mutationFn: ({ gameId, rules }: { gameId: string; rules: unknown[] }) =>
|
||||||
|
api.patch(`/admin/games/${gameId}`, { automationRules: rules }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-games'] });
|
||||||
|
setAutomationOpen(false);
|
||||||
|
setSelectedGame(null);
|
||||||
|
setAutomationError(null);
|
||||||
|
toast.success('Automation rules updated');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to save automation rules'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const games = data?.data ?? [];
|
const games = data?.data ?? [];
|
||||||
|
|
||||||
|
const openAutomationDialog = (game: Game) => {
|
||||||
|
setSelectedGame(game);
|
||||||
|
setAutomationJson(formatAutomationRules(game.automationRules));
|
||||||
|
setAutomationError(null);
|
||||||
|
setAutomationOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveAutomationRules = () => {
|
||||||
|
if (!selectedGame) return;
|
||||||
|
|
||||||
|
const parsed = parseAutomationRules(automationJson);
|
||||||
|
if (parsed.error) {
|
||||||
|
setAutomationError(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutomationError(null);
|
||||||
|
updateAutomationMutation.mutate({
|
||||||
|
gameId: selectedGame.id,
|
||||||
|
rules: parsed.rules,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutomationTabKey = (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (event.key !== 'Tab') return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
const textarea = event.currentTarget;
|
||||||
|
const selectionStart = textarea.selectionStart;
|
||||||
|
const selectionEnd = textarea.selectionEnd;
|
||||||
|
const nextValue = `${automationJson.slice(0, selectionStart)} ${automationJson.slice(selectionEnd)}`;
|
||||||
|
const nextCursor = selectionStart + 2;
|
||||||
|
|
||||||
|
setAutomationJson(nextValue);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(nextCursor, nextCursor);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -142,11 +241,65 @@ export function AdminGamesPage() {
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
<p className="mt-2 font-mono text-xs">{game.dockerImage}</p>
|
||||||
<p>Port: {game.defaultPort}</p>
|
<p>Port: {game.defaultPort}</p>
|
||||||
|
<p>Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => openAutomationDialog(game)}
|
||||||
|
>
|
||||||
|
Manage Automation
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={automationOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setAutomationOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setSelectedGame(null);
|
||||||
|
setAutomationError(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Automation Rules
|
||||||
|
{selectedGame ? ` - ${selectedGame.name}` : ''}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>JSON</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
value={automationJson}
|
||||||
|
onChange={(event) => setAutomationJson(event.target.value)}
|
||||||
|
onKeyDown={handleAutomationTabKey}
|
||||||
|
spellCheck={false}
|
||||||
|
className="min-h-[320px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{automationError && <p className="text-sm text-destructive">{automationError}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={saveAutomationRules}
|
||||||
|
disabled={updateAutomationMutation.isPending || !selectedGame}
|
||||||
|
>
|
||||||
|
{updateAutomationMutation.isPending ? 'Saving...' : 'Save Automation'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,10 +142,10 @@ export function NodeDetailPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const memPercent = stats
|
const memPercent = stats && stats.memoryTotal > 0
|
||||||
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
? Math.round((stats.memoryUsed / stats.memoryTotal) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const diskPercent = stats
|
const diskPercent = stats && stats.diskTotal > 0
|
||||||
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
? Math.round((stats.diskUsed / stats.diskTotal) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,10 @@ function ConfigEditor({
|
||||||
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
||||||
: entries;
|
: entries;
|
||||||
|
|
||||||
|
const entriesToSave = configFile.editableKeys
|
||||||
|
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
||||||
|
: entries;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
|
@ -149,7 +153,7 @@ function ConfigEditor({
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => saveMutation.mutate({ entries })}
|
onClick={() => saveMutation.mutate({ entries: entriesToSave })}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,31 @@
|
||||||
import { useState } from 'react';
|
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Folder,
|
|
||||||
FileText,
|
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Trash2,
|
|
||||||
Plus,
|
|
||||||
Download,
|
Download,
|
||||||
Upload,
|
FileText,
|
||||||
|
Folder,
|
||||||
|
FolderPlus,
|
||||||
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { toast } from 'sonner';
|
||||||
|
import { ApiError, api } from '@/lib/api';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogClose,
|
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
interface FileEntry {
|
interface FileEntry {
|
||||||
|
|
@ -34,23 +36,115 @@ interface FileEntry {
|
||||||
modifiedAt: number;
|
modifiedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileReadResponse {
|
||||||
|
data: string;
|
||||||
|
encoding: 'utf8' | 'base64';
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditingFile {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
originalContent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
file: File;
|
||||||
|
targetPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
const payload = error.data as { message?: string } | null;
|
||||||
|
if (payload?.message) return payload.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error && error.message) return error.message;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRemotePath(basePath: string, relativePath: string): string {
|
||||||
|
const safeSegments = relativePath
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.split('/')
|
||||||
|
.filter((segment) => segment && segment !== '.' && segment !== '..');
|
||||||
|
|
||||||
|
const baseSegments = basePath
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return `/${[...baseSegments, ...safeSegments].join('/')}`.replace(/\/{2,}/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = () => {
|
||||||
|
if (typeof reader.result !== 'string') {
|
||||||
|
reject(new Error('Failed to encode file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commaIndex = reader.result.indexOf(',');
|
||||||
|
resolve(commaIndex >= 0 ? reader.result.slice(commaIndex + 1) : reader.result);
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(reader.error ?? new Error(`Failed to read file: ${file.name}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyBinaryText(content: string): boolean {
|
||||||
|
const sample = content.slice(0, 4096);
|
||||||
|
return sample.includes('\u0000');
|
||||||
|
}
|
||||||
|
|
||||||
export function FilesPage() {
|
export function FilesPage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
const [editingFile, setEditingFile] = useState<{ path: string; content: string } | null>(null);
|
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||||
const [newFileName, setNewFileName] = useState('');
|
|
||||||
const [showNewFile, setShowNewFile] = useState(false);
|
const [showNewFile, setShowNewFile] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [newFileName, setNewFileName] = useState('');
|
||||||
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(null);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<{ done: number; total: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasUnsavedChanges =
|
||||||
|
!!editingFile && editingFile.content !== editingFile.originalContent;
|
||||||
|
const isUploading = !!uploadProgress;
|
||||||
|
|
||||||
const filesQuery = useQuery({
|
const filesQuery = useQuery({
|
||||||
queryKey: ['files', orgId, serverId, currentPath],
|
queryKey: ['files', orgId, serverId, currentPath],
|
||||||
|
enabled: Boolean(orgId && serverId) && !editingFile,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
api.get<{ files: FileEntry[] }>(
|
api.get<{ files: FileEntry[] }>(
|
||||||
`/organizations/${orgId}/servers/${serverId}/files`,
|
`/organizations/${orgId}/servers/${serverId}/files`,
|
||||||
{ path: currentPath },
|
{ path: currentPath },
|
||||||
),
|
),
|
||||||
enabled: !editingFile,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
|
|
@ -59,38 +153,132 @@ export function FilesPage() {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
toast.success('Deleted successfully');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Delete failed'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: ({ path, data }: { path: string; data: string }) =>
|
mutationFn: ({ path, data }: { path: string; data: string }) =>
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
|
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||||
onSuccess: () => {
|
path,
|
||||||
setEditingFile(null);
|
data,
|
||||||
|
encoding: 'utf8',
|
||||||
|
}),
|
||||||
|
onSuccess: (_result, variables) => {
|
||||||
|
setEditingFile((prev) =>
|
||||||
|
prev && prev.path === variables.path
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
content: variables.data,
|
||||||
|
originalContent: variables.data,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
toast.success('File saved');
|
||||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Save failed'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createFileMutation = useMutation({
|
const createFileMutation = useMutation({
|
||||||
mutationFn: ({ path, data }: { path: string; data: string }) =>
|
mutationFn: ({ path, data }: { path: string; data: string }) =>
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, { path, data }),
|
api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||||
|
path,
|
||||||
|
data,
|
||||||
|
encoding: 'utf8',
|
||||||
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowNewFile(false);
|
setShowNewFile(false);
|
||||||
setNewFileName('');
|
setNewFileName('');
|
||||||
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||||
|
toast.success('File created');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to create file'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createFolderMutation = useMutation({
|
||||||
|
mutationFn: async (folderPath: string) => {
|
||||||
|
const markerName = `.gp_create_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
|
const markerPath = `${folderPath.replace(/\/+$/g, '')}/${markerName}`;
|
||||||
|
|
||||||
|
await api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||||
|
path: markerPath,
|
||||||
|
data: '',
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.post(`/organizations/${orgId}/servers/${serverId}/files/delete`, {
|
||||||
|
paths: [markerPath],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setShowNewFolder(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||||
|
toast.success('Folder created');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to create folder'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = filesQuery.data?.files ?? [];
|
||||||
|
const breadcrumbs = currentPath.split('/').filter(Boolean);
|
||||||
|
|
||||||
const openFile = async (file: FileEntry) => {
|
const openFile = async (file: FileEntry) => {
|
||||||
|
if (!orgId || !serverId) return;
|
||||||
|
|
||||||
if (file.isDirectory) {
|
if (file.isDirectory) {
|
||||||
setCurrentPath(file.path);
|
setCurrentPath(file.path);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await api.get<{ data: string }>(
|
|
||||||
|
try {
|
||||||
|
const res = await api.get<FileReadResponse>(
|
||||||
`/organizations/${orgId}/servers/${serverId}/files/read`,
|
`/organizations/${orgId}/servers/${serverId}/files/read`,
|
||||||
{ path: file.path },
|
{ path: file.path, encoding: 'utf8' },
|
||||||
);
|
);
|
||||||
setEditingFile({ path: file.path, content: res.data });
|
|
||||||
|
if (isLikelyBinaryText(res.data)) {
|
||||||
|
toast.error('This file looks binary. Use download instead of editor.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingFile({
|
||||||
|
path: file.path,
|
||||||
|
content: res.data,
|
||||||
|
originalContent: res.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editorRef.current?.focus();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to open file'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCurrentFile = () => {
|
||||||
|
if (!editingFile) return;
|
||||||
|
saveMutation.mutate({ path: editingFile.path, data: editingFile.content });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditor = () => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
const shouldClose = window.confirm(
|
||||||
|
'You have unsaved changes. Close the editor and discard them?',
|
||||||
|
);
|
||||||
|
if (!shouldClose) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingFile(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goUp = () => {
|
const goUp = () => {
|
||||||
|
|
@ -100,28 +288,258 @@ export function FilesPage() {
|
||||||
setCurrentPath('/' + parts.join('/'));
|
setCurrentPath('/' + parts.join('/'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbs = currentPath.split('/').filter(Boolean);
|
const applyEditorContent = (
|
||||||
|
nextValue: string,
|
||||||
|
selectionStart: number,
|
||||||
|
selectionEnd: number,
|
||||||
|
textarea: HTMLTextAreaElement,
|
||||||
|
) => {
|
||||||
|
setEditingFile((prev) => (prev ? { ...prev, content: nextValue } : prev));
|
||||||
|
|
||||||
const files = filesQuery.data?.files ?? [];
|
requestAnimationFrame(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||||
|
|
||||||
|
const lineHeight = Number.parseFloat(getComputedStyle(textarea).lineHeight) || 20;
|
||||||
|
const lineBeforeCursor = textarea.value.slice(0, selectionEnd).split('\n').length - 1;
|
||||||
|
const caretTop = lineBeforeCursor * lineHeight;
|
||||||
|
const viewportTop = textarea.scrollTop;
|
||||||
|
const viewportBottom = viewportTop + textarea.clientHeight;
|
||||||
|
|
||||||
|
if (caretTop < viewportTop + lineHeight) {
|
||||||
|
textarea.scrollTop = Math.max(0, caretTop - lineHeight);
|
||||||
|
} else if (caretTop > viewportBottom - lineHeight * 2) {
|
||||||
|
textarea.scrollTop = Math.max(0, caretTop - textarea.clientHeight + lineHeight * 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!editingFile) return;
|
||||||
|
|
||||||
|
const textarea = event.currentTarget;
|
||||||
|
const value = editingFile.content;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (hasUnsavedChanges && !saveMutation.isPending) {
|
||||||
|
saveCurrentFile();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (start !== end) {
|
||||||
|
const blockStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const rawBlockEnd = value.indexOf('\n', end);
|
||||||
|
const blockEnd = rawBlockEnd === -1 ? value.length : rawBlockEnd;
|
||||||
|
const block = value.slice(blockStart, blockEnd);
|
||||||
|
const lines = block.split('\n');
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
let firstLineRemoved = 0;
|
||||||
|
let totalRemoved = 0;
|
||||||
|
|
||||||
|
const outdented = lines.map((line, index) => {
|
||||||
|
let remove = 0;
|
||||||
|
if (line.startsWith('\t')) remove = 1;
|
||||||
|
else if (line.startsWith(' ')) remove = 2;
|
||||||
|
else if (line.startsWith(' ')) remove = 1;
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
firstLineRemoved = remove;
|
||||||
|
}
|
||||||
|
totalRemoved += remove;
|
||||||
|
return line.slice(remove);
|
||||||
|
});
|
||||||
|
|
||||||
|
const replacement = outdented.join('\n');
|
||||||
|
const nextValue = `${value.slice(0, blockStart)}${replacement}${value.slice(blockEnd)}`;
|
||||||
|
const nextStart = Math.max(blockStart, start - firstLineRemoved);
|
||||||
|
const nextEnd = Math.max(nextStart, end - totalRemoved);
|
||||||
|
applyEditorContent(nextValue, nextStart, nextEnd, textarea);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indented = lines.map((line) => ` ${line}`).join('\n');
|
||||||
|
const nextValue = `${value.slice(0, blockStart)}${indented}${value.slice(blockEnd)}`;
|
||||||
|
const nextStart = start + 2;
|
||||||
|
const nextEnd = end + lines.length * 2;
|
||||||
|
applyEditorContent(nextValue, nextStart, nextEnd, textarea);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const beforeCursor = value.slice(lineStart, start);
|
||||||
|
|
||||||
|
let remove = 0;
|
||||||
|
if (beforeCursor.endsWith('\t')) remove = 1;
|
||||||
|
else if (beforeCursor.endsWith(' ')) remove = 2;
|
||||||
|
else if (beforeCursor.endsWith(' ')) remove = 1;
|
||||||
|
|
||||||
|
if (remove > 0) {
|
||||||
|
const nextValue = `${value.slice(0, start - remove)}${value.slice(end)}`;
|
||||||
|
const nextPos = start - remove;
|
||||||
|
applyEditorContent(nextValue, nextPos, nextPos, textarea);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = `${value.slice(0, start)} ${value.slice(end)}`;
|
||||||
|
const nextPos = start + 2;
|
||||||
|
applyEditorContent(nextValue, nextPos, nextPos, textarea);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||||
|
const currentLine = value.slice(lineStart, start);
|
||||||
|
const leadingWhitespace = currentLine.match(/^\s*/)?.[0] ?? '';
|
||||||
|
const shouldIndentMore = /[{[(]$/.test(currentLine.trimEnd());
|
||||||
|
const insertion = `\n${leadingWhitespace}${shouldIndentMore ? ' ' : ''}`;
|
||||||
|
|
||||||
|
const nextValue = `${value.slice(0, start)}${insertion}${value.slice(end)}`;
|
||||||
|
const nextPos = start + insertion.length;
|
||||||
|
applyEditorContent(nextValue, nextPos, nextPos, textarea);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerUploadFiles = () => {
|
||||||
|
const input = fileInputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.value = '';
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerUploadFolder = () => {
|
||||||
|
const input = folderInputRef.current;
|
||||||
|
if (!input) return;
|
||||||
|
input.value = '';
|
||||||
|
input.setAttribute('webkitdirectory', '');
|
||||||
|
input.setAttribute('directory', '');
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadItems = async (items: UploadItem[]) => {
|
||||||
|
if (!orgId || !serverId || items.length === 0) return;
|
||||||
|
|
||||||
|
setUploadProgress({ done: 0, total: items.length });
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index]!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fileToBase64(item.file);
|
||||||
|
await api.post(`/organizations/${orgId}/servers/${serverId}/files/write`, {
|
||||||
|
path: item.targetPath,
|
||||||
|
data,
|
||||||
|
encoding: 'base64',
|
||||||
|
});
|
||||||
|
successCount += 1;
|
||||||
|
} catch {
|
||||||
|
failedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadProgress({ done: index + 1, total: items.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadProgress(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['files', orgId, serverId, currentPath] });
|
||||||
|
|
||||||
|
if (failedCount === 0) {
|
||||||
|
toast.success(`${successCount} item uploaded`);
|
||||||
|
} else {
|
||||||
|
toast.error(`${failedCount} item failed to upload`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilesPicked = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = Array.from(event.target.files ?? []);
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
const uploadList: UploadItem[] = selected.map((file) => ({
|
||||||
|
file,
|
||||||
|
targetPath: joinRemotePath(currentPath, file.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await uploadItems(uploadList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFolderPicked = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selected = Array.from(event.target.files ?? []);
|
||||||
|
if (selected.length === 0) return;
|
||||||
|
|
||||||
|
const uploadList: UploadItem[] = selected.map((file) => {
|
||||||
|
const pathFromFolder =
|
||||||
|
(file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
targetPath: joinRemotePath(currentPath, pathFromFolder),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await uploadItems(uploadList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFile = async (file: FileEntry) => {
|
||||||
|
if (!orgId || !serverId || file.isDirectory) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get<FileReadResponse>(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/files/read`,
|
||||||
|
{ path: file.path, encoding: 'base64' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = new Blob([base64ToArrayBuffer(response.data)], {
|
||||||
|
type: response.mimeType || 'application/octet-stream',
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = file.name;
|
||||||
|
document.body.append(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(extractApiMessage(error, 'Download failed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingFile) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{editingFile ? (
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
<CardTitle className="text-sm font-mono">{editingFile.path}</CardTitle>
|
<CardTitle className="text-sm font-mono">{editingFile.path}</CardTitle>
|
||||||
<div className="flex gap-2">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tab/Shift+Tab indent, Enter auto-indent, Ctrl/Cmd+S save
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<span className="text-xs text-amber-600">Unsaved changes</span>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={saveCurrentFile}
|
||||||
saveMutation.mutate({ path: editingFile.path, data: editingFile.content })
|
disabled={saveMutation.isPending || !hasUnsavedChanges}
|
||||||
}
|
|
||||||
disabled={saveMutation.isPending}
|
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
Save
|
{saveMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditingFile(null)}>
|
<Button size="sm" variant="outline" onClick={closeEditor}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -129,53 +547,128 @@ export function FilesPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={editorRef}
|
||||||
value={editingFile.content}
|
value={editingFile.content}
|
||||||
onChange={(e) => setEditingFile({ ...editingFile, content: e.target.value })}
|
onChange={(event) =>
|
||||||
className="min-h-[500px] w-full rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
setEditingFile((prev) =>
|
||||||
|
prev ? { ...prev, content: event.target.value } : prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onKeyDown={handleEditorKeyDown}
|
||||||
|
className="min-h-[560px] w-full resize-y rounded-md border bg-background p-3 font-mono text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
style={{ tabSize: 2 }}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
</div>
|
||||||
<>
|
);
|
||||||
<div className="flex items-center justify-between">
|
}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
void onFilesPicked(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={folderInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
void onFolderPicked(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<Button variant="ghost" size="icon" onClick={goUp} disabled={currentPath === '/'}>
|
<Button variant="ghost" size="icon" onClick={goUp} disabled={currentPath === '/'}>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
{breadcrumbs.map((crumb, i) => (
|
|
||||||
<span key={i} className="flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground transition hover:text-foreground"
|
||||||
onClick={() =>
|
onClick={() => setCurrentPath('/')}
|
||||||
setCurrentPath('/' + breadcrumbs.slice(0, i + 1).join('/'))
|
>
|
||||||
}
|
/
|
||||||
|
</button>
|
||||||
|
{breadcrumbs.map((crumb, index) => (
|
||||||
|
<span key={index} className="flex items-center gap-1">
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground transition hover:text-foreground"
|
||||||
|
onClick={() => setCurrentPath('/' + breadcrumbs.slice(0, index + 1).join('/'))}
|
||||||
>
|
>
|
||||||
{crumb}
|
{crumb}
|
||||||
</button>
|
</button>
|
||||||
{i < breadcrumbs.length - 1 && (
|
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="outline" onClick={() => setShowNewFile(true)}>
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={triggerUploadFiles}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={triggerUploadFolder}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Upload Folder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowNewFolder(true)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<FolderPlus className="h-4 w-4" />
|
||||||
|
New Folder
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowNewFile(true)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New File
|
New File
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUploading && uploadProgress && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Uploading... {uploadProgress.done}/{uploadProgress.total}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{showNewFile && (
|
{showNewFile && (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="filename.txt"
|
placeholder="filename.txt"
|
||||||
value={newFileName}
|
value={newFileName}
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
onChange={(event) => setNewFileName(event.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(event) => {
|
||||||
if (e.key === 'Enter' && newFileName) {
|
if (event.key === 'Enter' && newFileName.trim()) {
|
||||||
const path =
|
const path = joinRemotePath(currentPath, newFileName.trim());
|
||||||
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
|
|
||||||
createFileMutation.mutate({ path, data: '' });
|
createFileMutation.mutate({ path, data: '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -183,11 +676,11 @@ export function FilesPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!newFileName) return;
|
if (!newFileName.trim()) return;
|
||||||
const path =
|
const path = joinRemotePath(currentPath, newFileName.trim());
|
||||||
currentPath === '/' ? `/${newFileName}` : `${currentPath}/${newFileName}`;
|
|
||||||
createFileMutation.mutate({ path, data: '' });
|
createFileMutation.mutate({ path, data: '' });
|
||||||
}}
|
}}
|
||||||
|
disabled={createFileMutation.isPending}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -204,42 +697,119 @@ export function FilesPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showNewFolder && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="folder-name"
|
||||||
|
value={newFolderName}
|
||||||
|
onChange={(event) => setNewFolderName(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && newFolderName.trim()) {
|
||||||
|
const folderPath = joinRemotePath(currentPath, newFolderName.trim());
|
||||||
|
createFolderMutation.mutate(folderPath);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (!newFolderName.trim()) return;
|
||||||
|
const folderPath = joinRemotePath(currentPath, newFolderName.trim());
|
||||||
|
createFolderMutation.mutate(folderPath);
|
||||||
|
}}
|
||||||
|
disabled={createFolderMutation.isPending}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewFolder(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
{files.length === 0 && (
|
{filesQuery.isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
Loading files...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filesQuery.isError && (
|
||||||
|
<div className="space-y-2 py-8 text-center">
|
||||||
|
<p className="text-sm text-destructive">Failed to load directory</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => filesQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!filesQuery.isLoading && !filesQuery.isError && files.length === 0 && (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
This directory is empty
|
This directory is empty
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{files.map((file) => (
|
|
||||||
|
{!filesQuery.isLoading &&
|
||||||
|
!filesQuery.isError &&
|
||||||
|
files.map((file) => (
|
||||||
<div
|
<div
|
||||||
key={file.path}
|
key={file.path}
|
||||||
className="flex cursor-pointer items-center justify-between px-4 py-2.5 hover:bg-muted/50"
|
className="flex cursor-pointer items-center justify-between px-4 py-2.5 hover:bg-muted/50"
|
||||||
onClick={() => openFile(file)}
|
onClick={() => {
|
||||||
|
void openFile(file);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
{file.isDirectory ? (
|
{file.isDirectory ? (
|
||||||
<Folder className="h-4 w-4 text-blue-400" />
|
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
|
||||||
) : (
|
) : (
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-sm">{file.name}</span>
|
<span className="truncate text-sm">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
|
<div className="ml-3 flex items-center gap-2">
|
||||||
{!file.isDirectory && (
|
{!file.isDirectory && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{formatBytes(file.size)}
|
{formatBytes(file.size)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{!file.isDirectory && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={(e) => {
|
onClick={(event) => {
|
||||||
e.stopPropagation();
|
event.stopPropagation();
|
||||||
setDeleteTarget(file.path);
|
void downloadFile(file);
|
||||||
}}
|
}}
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setDeleteTarget(file);
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -253,10 +823,11 @@ export function FilesPage() {
|
||||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete File</DialogTitle>
|
<DialogTitle>{deleteTarget?.isDirectory ? 'Delete Folder' : 'Delete File'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Are you sure you want to delete <code className="font-mono">{deleteTarget}</code>?
|
Are you sure you want to delete{' '}
|
||||||
|
<code className="font-mono">{deleteTarget?.path}</code>?
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<DialogClose asChild>
|
<DialogClose asChild>
|
||||||
|
|
@ -264,7 +835,10 @@ export function FilesPage() {
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => deleteTarget && deleteMutation.mutate([deleteTarget])}
|
onClick={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteMutation.mutate([deleteTarget.path]);
|
||||||
|
}}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
|
@ -272,8 +846,6 @@ export function FilesPage() {
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,27 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useOutletContext, useParams } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
|
Download,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
Search,
|
Search,
|
||||||
Download,
|
Star,
|
||||||
Trash2,
|
|
||||||
ToggleLeft,
|
ToggleLeft,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
Star,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
|
Store,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/lib/api';
|
import { toast } from 'sonner';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -50,9 +54,46 @@ interface SpigetResult {
|
||||||
external: boolean;
|
external: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarketplacePlugin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
source: 'spiget' | 'manual';
|
||||||
|
externalId: string | null;
|
||||||
|
downloadUrl: string | null;
|
||||||
|
version: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
isInstalled: boolean;
|
||||||
|
installId: string | null;
|
||||||
|
installedVersion: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
installedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketplaceResponse {
|
||||||
|
game: {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
plugins: MarketplacePlugin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractApiMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||||
|
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||||
|
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||||
|
return maybeMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function PluginsPage() {
|
export function PluginsPage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
const queryClient = useQueryClient();
|
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
|
||||||
|
const isMinecraft = server?.gameSlug === 'minecraft-java';
|
||||||
|
|
||||||
const { data: pluginsData } = useQuery({
|
const { data: pluginsData } = useQuery({
|
||||||
queryKey: ['plugins', orgId, serverId],
|
queryKey: ['plugins', orgId, serverId],
|
||||||
|
|
@ -65,29 +106,41 @@ export function PluginsPage() {
|
||||||
const installed = pluginsData?.plugins ?? [];
|
const installed = pluginsData?.plugins ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="installed" className="space-y-4">
|
<Tabs defaultValue="marketplace" className="space-y-4">
|
||||||
<TabsList>
|
<TabsList className="flex h-auto w-full flex-wrap">
|
||||||
|
<TabsTrigger value="marketplace">
|
||||||
|
<Store className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Marketplace
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="installed">
|
<TabsTrigger value="installed">
|
||||||
<Puzzle className="mr-1.5 h-3.5 w-3.5" />
|
<Puzzle className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Installed ({installed.length})
|
Installed ({installed.length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
{isMinecraft && (
|
||||||
<TabsTrigger value="search">
|
<TabsTrigger value="search">
|
||||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Search Plugins
|
Spiget Search
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
<TabsTrigger value="manual">
|
<TabsTrigger value="manual">
|
||||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||||
Manual Install
|
Manual Install
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="marketplace">
|
||||||
|
<MarketplacePlugins orgId={orgId!} serverId={serverId!} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="installed">
|
<TabsContent value="installed">
|
||||||
<InstalledPlugins installed={installed} orgId={orgId!} serverId={serverId!} />
|
<InstalledPlugins installed={installed} orgId={orgId!} serverId={serverId!} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{isMinecraft && (
|
||||||
<TabsContent value="search">
|
<TabsContent value="search">
|
||||||
<SpigetSearch orgId={orgId!} serverId={serverId!} />
|
<SpigetSearch orgId={orgId!} serverId={serverId!} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="manual">
|
<TabsContent value="manual">
|
||||||
<ManualInstall orgId={orgId!} serverId={serverId!} />
|
<ManualInstall orgId={orgId!} serverId={serverId!} />
|
||||||
|
|
@ -96,6 +149,369 @@ export function PluginsPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: string }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editingPluginId, setEditingPluginId] = useState<string | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [slug, setSlug] = useState('');
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState('');
|
||||||
|
const [version, setVersion] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<MarketplaceResponse>(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`,
|
||||||
|
searchTerm ? { q: searchTerm } : undefined,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const installMutation = useMutation({
|
||||||
|
mutationFn: (pluginId: string) =>
|
||||||
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin installed');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin install failed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uninstallMutation = useMutation({
|
||||||
|
mutationFn: (installId: string) =>
|
||||||
|
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin uninstalled');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin uninstall failed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
version?: string;
|
||||||
|
}) =>
|
||||||
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace`, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Marketplace plugin added');
|
||||||
|
setCreateOpen(false);
|
||||||
|
setName('');
|
||||||
|
setSlug('');
|
||||||
|
setDownloadUrl('');
|
||||||
|
setVersion('');
|
||||||
|
setDescription('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to add marketplace plugin'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (pluginId: string) =>
|
||||||
|
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${pluginId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Marketplace plugin removed');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to remove marketplace plugin'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
pluginId: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
version?: string;
|
||||||
|
}) =>
|
||||||
|
api.patch(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/plugins/marketplace/${body.pluginId}`,
|
||||||
|
{
|
||||||
|
name: body.name,
|
||||||
|
slug: body.slug,
|
||||||
|
description: body.description,
|
||||||
|
downloadUrl: body.downloadUrl,
|
||||||
|
version: body.version,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Marketplace plugin updated');
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditingPluginId(null);
|
||||||
|
setName('');
|
||||||
|
setSlug('');
|
||||||
|
setDownloadUrl('');
|
||||||
|
setVersion('');
|
||||||
|
setDescription('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to update marketplace plugin'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openEditDialog = (plugin: MarketplacePlugin) => {
|
||||||
|
setEditingPluginId(plugin.id);
|
||||||
|
setName(plugin.name);
|
||||||
|
setSlug(plugin.slug);
|
||||||
|
setDownloadUrl(plugin.downloadUrl ?? '');
|
||||||
|
setVersion(plugin.version ?? '');
|
||||||
|
setDescription(plugin.description ?? '');
|
||||||
|
setEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugins = data?.plugins ?? [];
|
||||||
|
const gameName = data?.game.name ?? 'Game';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{gameName} Marketplace</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Oyununuza uygun eklentileri tek tıkla kur/kaldırın.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Plugin Ekle
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Marketplace Plugin Ekle</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
createMutation.mutate({
|
||||||
|
name,
|
||||||
|
slug: slug || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
downloadUrl,
|
||||||
|
version: version || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plugin Name</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Slug (optional)</Label>
|
||||||
|
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Download URL</Label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={downloadUrl}
|
||||||
|
onChange={(e) => setDownloadUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/plugin.jar"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Version (optional)</Label>
|
||||||
|
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description (optional)</Label>
|
||||||
|
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Ekleniyor...' : 'Ekle'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setEditOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setEditingPluginId(null);
|
||||||
|
setName('');
|
||||||
|
setSlug('');
|
||||||
|
setDownloadUrl('');
|
||||||
|
setVersion('');
|
||||||
|
setDescription('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Marketplace Plugin Düzenle</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingPluginId) return;
|
||||||
|
updateMutation.mutate({
|
||||||
|
pluginId: editingPluginId,
|
||||||
|
name,
|
||||||
|
slug: slug || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
downloadUrl: downloadUrl || undefined,
|
||||||
|
version: version || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plugin Name</Label>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Slug (optional)</Label>
|
||||||
|
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Download URL (optional)</Label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={downloadUrl}
|
||||||
|
onChange={(e) => setDownloadUrl(e.target.value)}
|
||||||
|
placeholder="https://example.com/plugin.jar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Version (optional)</Label>
|
||||||
|
<Input value={version} onChange={(e) => setVersion(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description (optional)</Label>
|
||||||
|
<Input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Plugin ara..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') setSearchTerm(search.trim());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setSearchTerm(search.trim())}>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Ara
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p className="text-sm text-muted-foreground">Marketplace yükleniyor...</p>}
|
||||||
|
|
||||||
|
{!isLoading && plugins.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
||||||
|
<Store className="mb-3 h-10 w-10 text-muted-foreground/60" />
|
||||||
|
<p className="font-medium">Bu oyun için plugin bulunamadı</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Yetkili kullanıcılar yukarıdan marketplace plugin ekleyebilir.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<Card key={plugin.id}>
|
||||||
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="font-medium">{plugin.name}</p>
|
||||||
|
<Badge variant="outline">{plugin.source}</Badge>
|
||||||
|
{plugin.version && <Badge variant="secondary">v{plugin.version}</Badge>}
|
||||||
|
{plugin.isInstalled && <Badge>Installed</Badge>}
|
||||||
|
</div>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
{plugin.downloadUrl && (
|
||||||
|
<p className="line-clamp-1 text-xs text-muted-foreground">{plugin.downloadUrl}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{plugin.isInstalled && plugin.installId ? (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => uninstallMutation.mutate(plugin.installId!)}
|
||||||
|
disabled={uninstallMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Kaldır
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => installMutation.mutate(plugin.id)}
|
||||||
|
disabled={installMutation.isPending}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Kur
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openEditDialog(plugin)}
|
||||||
|
title="Marketplace kaydını düzenle"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => deleteMutation.mutate(plugin.id)}
|
||||||
|
disabled={deleteMutation.isPending || plugin.isInstalled}
|
||||||
|
title={plugin.isInstalled ? 'Önce sunucudan kaldırın' : 'Marketplace kaydını sil'}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InstalledPlugins({
|
function InstalledPlugins({
|
||||||
installed,
|
installed,
|
||||||
orgId,
|
orgId,
|
||||||
|
|
@ -111,12 +527,22 @@ function InstalledPlugins({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
api.patch(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/toggle`),
|
api.patch(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/toggle`),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin durumu güncellenemedi'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const uninstallMutation = useMutation({
|
const uninstallMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${id}`),
|
api.delete(`/organizations/${orgId}/servers/${serverId}/plugins/${id}`),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin kaldırıldı');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin kaldırılamadı'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (installed.length === 0) {
|
if (installed.length === 0) {
|
||||||
|
|
@ -126,7 +552,7 @@ function InstalledPlugins({
|
||||||
<Puzzle className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
<Puzzle className="mb-4 h-12 w-12 text-muted-foreground/50" />
|
||||||
<p className="text-muted-foreground">No plugins installed</p>
|
<p className="text-muted-foreground">No plugins installed</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Search for plugins or install manually
|
Marketplace sekmesinden tek tıkla kurulum yapabilirsiniz.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -199,7 +625,14 @@ function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string })
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, {
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/spiget`, {
|
||||||
resourceId,
|
resourceId,
|
||||||
}),
|
}),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] }),
|
onSuccess: () => {
|
||||||
|
toast.success('Spiget plugin installed');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Spiget install failed'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
|
|
@ -270,11 +703,16 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||||
mutationFn: (body: { name: string; fileName: string; version?: string }) =>
|
mutationFn: (body: { name: string; fileName: string; version?: string }) =>
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/manual`, body),
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/manual`, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin registered');
|
||||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
setName('');
|
setName('');
|
||||||
setFileName('');
|
setFileName('');
|
||||||
setVersion('');
|
setVersion('');
|
||||||
},
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin registration failed'));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -307,7 +745,7 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Upload the file to /plugins/ directory via the Files tab first
|
Upload the file to the correct plugin directory via Files tab first.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useOutletContext } from 'react-router';
|
import { useNavigate, useOutletContext, useParams } from 'react-router';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api';
|
import { toast } from 'sonner';
|
||||||
|
import { ApiError, api } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
|
|
||||||
interface ServerDetail {
|
interface ServerDetail {
|
||||||
|
|
@ -19,13 +21,61 @@ interface ServerDetail {
|
||||||
environment?: Record<string, string>;
|
environment?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AutomationEvent =
|
||||||
|
| 'server.created'
|
||||||
|
| 'server.install.completed'
|
||||||
|
| 'server.power.started'
|
||||||
|
| 'server.power.stopped';
|
||||||
|
|
||||||
|
interface AutomationRunResult {
|
||||||
|
workflowsMatched: number;
|
||||||
|
workflowsExecuted: number;
|
||||||
|
workflowsSkipped: number;
|
||||||
|
workflowsFailed: number;
|
||||||
|
actionFailures: number;
|
||||||
|
failures: Array<{
|
||||||
|
level: 'action' | 'workflow';
|
||||||
|
workflowId: string;
|
||||||
|
actionId?: string;
|
||||||
|
message: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutomationRunResponse {
|
||||||
|
success: boolean;
|
||||||
|
event: AutomationEvent;
|
||||||
|
force: boolean;
|
||||||
|
result: AutomationRunResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUTOMATION_EVENTS: AutomationEvent[] = [
|
||||||
|
'server.created',
|
||||||
|
'server.install.completed',
|
||||||
|
'server.power.started',
|
||||||
|
'server.power.stopped',
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractApiMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
|
||||||
|
const maybeMessage = (error.data as { message?: unknown }).message;
|
||||||
|
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
|
||||||
|
return maybeMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function ServerSettingsPage() {
|
export function ServerSettingsPage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { server } = useOutletContext<{ server?: ServerDetail }>();
|
const { server } = useOutletContext<{ server?: ServerDetail }>();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [name, setName] = useState(server?.name ?? '');
|
const [name, setName] = useState(server?.name ?? '');
|
||||||
const [description, setDescription] = useState(server?.description ?? '');
|
const [description, setDescription] = useState(server?.description ?? '');
|
||||||
|
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
||||||
|
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
||||||
|
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
|
|
@ -35,6 +85,38 @@ export function ServerSettingsPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/organizations/${orgId}/servers/${serverId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
||||||
|
navigate(`/org/${orgId}/servers`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const automationRunMutation = useMutation({
|
||||||
|
mutationFn: (body: { event: AutomationEvent; force: boolean }) =>
|
||||||
|
api.post<AutomationRunResponse>(`/organizations/${orgId}/servers/${serverId}/automation/run`, body),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setLastAutomationResult(response.result);
|
||||||
|
if (response.result.workflowsFailed > 0 || response.result.actionFailures > 0) {
|
||||||
|
const firstFailure = response.result.failures[0]?.message;
|
||||||
|
toast.error(
|
||||||
|
firstFailure
|
||||||
|
? `Automation failed: ${firstFailure}`
|
||||||
|
: `Automation completed with errors (${response.result.workflowsFailed} workflow failures)`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`Automation completed: ${response.result.workflowsExecuted} workflows executed`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to run automation'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -87,13 +169,125 @@ export function ServerSettingsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Automation</CardTitle>
|
||||||
|
<CardDescription>Manually trigger an automation event for this server</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Event</Label>
|
||||||
|
<Select value={automationEvent} onValueChange={(value) => setAutomationEvent(value as AutomationEvent)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTOMATION_EVENTS.map((eventName) => (
|
||||||
|
<SelectItem key={eventName} value={eventName}>
|
||||||
|
{eventName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={forceAutomationRun ? 'default' : 'outline'}
|
||||||
|
onClick={() => setForceAutomationRun((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{forceAutomationRun ? 'Force: ON' : 'Force: OFF'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => automationRunMutation.mutate({ event: automationEvent, force: forceAutomationRun })}
|
||||||
|
disabled={automationRunMutation.isPending}
|
||||||
|
>
|
||||||
|
{automationRunMutation.isPending ? 'Running...' : 'Run Automation Event'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Enabling force will rerun workflows that are marked runOncePerServer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{lastAutomationResult && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Matched</p>
|
||||||
|
<p className="text-lg font-semibold">{lastAutomationResult.workflowsMatched}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Executed</p>
|
||||||
|
<p className="text-lg font-semibold">{lastAutomationResult.workflowsExecuted}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Skipped</p>
|
||||||
|
<p className="text-lg font-semibold">{lastAutomationResult.workflowsSkipped}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Failed</p>
|
||||||
|
<p className="text-lg font-semibold">{lastAutomationResult.workflowsFailed}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Action Failures</p>
|
||||||
|
<p className="text-lg font-semibold">{lastAutomationResult.actionFailures}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lastAutomationResult.failures.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-md border border-destructive/40 bg-destructive/5 p-3">
|
||||||
|
<p className="text-sm font-medium text-destructive">Failure Details</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{lastAutomationResult.failures.slice(0, 5).map((failure, index) => (
|
||||||
|
<p key={`${failure.workflowId}-${failure.actionId ?? 'workflow'}-${index}`} className="text-xs text-destructive">
|
||||||
|
[{failure.workflowId}{failure.actionId ? ` > ${failure.actionId}` : ''}] {failure.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length === 0 && (
|
||||||
|
<p className="text-xs text-green-600">Automation run completed successfully.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{automationRunMutation.isSuccess && lastAutomationResult && lastAutomationResult.failures.length > 0 && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Automation run completed with {lastAutomationResult.failures.length} error(s).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{automationRunMutation.isError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Failed to run automation event.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="border-destructive">
|
<Card className="border-destructive">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||||
<CardDescription>Irreversible actions</CardDescription>
|
<CardDescription>Irreversible actions</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button variant="destructive">Delete Server</Button>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm('Delete this server permanently? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete Server'}
|
||||||
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ export function CreateServerPage() {
|
||||||
const [cpuLimit, setCpuLimit] = useState(100);
|
const [cpuLimit, setCpuLimit] = useState(100);
|
||||||
|
|
||||||
const { data: gamesData } = useQuery({
|
const { data: gamesData } = useQuery({
|
||||||
queryKey: ['admin-games'],
|
queryKey: ['games'],
|
||||||
queryFn: () => api.get<PaginatedResponse<Game>>('/admin/games'),
|
queryFn: () => api.get<PaginatedResponse<Game>>('/games'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: nodesData } = useQuery({
|
const { data: nodesData } = useQuery({
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,32 @@ interface Member {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
|
customPermissions: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MembershipPreset = 'admin' | 'moderator' | 'user';
|
||||||
|
|
||||||
|
const MODERATOR_PERMISSIONS: Record<string, boolean> = {
|
||||||
|
'plugin.manage': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMemberPreset(member: Member): MembershipPreset {
|
||||||
|
if (member.role === 'admin') return 'admin';
|
||||||
|
if (member.customPermissions?.['plugin.manage']) return 'moderator';
|
||||||
|
return 'user';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPresetPayload(preset: MembershipPreset): {
|
||||||
|
role: 'admin' | 'user';
|
||||||
|
customPermissions: Record<string, boolean>;
|
||||||
|
} {
|
||||||
|
if (preset === 'admin') {
|
||||||
|
return { role: 'admin', customPermissions: {} };
|
||||||
|
}
|
||||||
|
if (preset === 'moderator') {
|
||||||
|
return { role: 'user', customPermissions: MODERATOR_PERMISSIONS };
|
||||||
|
}
|
||||||
|
return { role: 'user', customPermissions: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MembersPage() {
|
export function MembersPage() {
|
||||||
|
|
@ -32,6 +58,7 @@ export function MembersPage() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [role, setRole] = useState<'admin' | 'user'>('user');
|
const [role, setRole] = useState<'admin' | 'user'>('user');
|
||||||
|
const [updatingMemberId, setUpdatingMemberId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: membersData } = useQuery({
|
const { data: membersData } = useQuery({
|
||||||
queryKey: ['members', orgId],
|
queryKey: ['members', orgId],
|
||||||
|
|
@ -58,6 +85,26 @@ export function MembersPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
memberId,
|
||||||
|
preset,
|
||||||
|
}: {
|
||||||
|
memberId: string;
|
||||||
|
preset: MembershipPreset;
|
||||||
|
}) =>
|
||||||
|
api.patch(`/organizations/${orgId}/members/${memberId}`, buildPresetPayload(preset)),
|
||||||
|
onMutate: ({ memberId }) => {
|
||||||
|
setUpdatingMemberId(memberId);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['members', orgId] });
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setUpdatingMemberId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -123,9 +170,28 @@ export function MembersPage() {
|
||||||
<p className="text-sm text-muted-foreground">{member.email}</p>
|
<p className="text-sm text-muted-foreground">{member.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={member.role === 'admin' ? 'default' : 'secondary'}>
|
<Badge variant={getMemberPreset(member) === 'admin' ? 'default' : 'secondary'}>
|
||||||
{member.role}
|
{getMemberPreset(member)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Select
|
||||||
|
value={getMemberPreset(member)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
memberId: member.id,
|
||||||
|
preset: value as MembershipPreset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={updateMutation.isPending && updatingMemberId === member.id}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[130px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="moderator">Moderator</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
ALTER TABLE "games"
|
||||||
|
ADD COLUMN IF NOT EXISTS "automation_rules" jsonb DEFAULT '[]'::jsonb NOT NULL;
|
||||||
|
|
||||||
|
UPDATE "games"
|
||||||
|
SET
|
||||||
|
"automation_rules" = '[
|
||||||
|
{
|
||||||
|
"id": "cs2-install-latest-counterstrikesharp-runtime",
|
||||||
|
"event": "server.install.completed",
|
||||||
|
"enabled": true,
|
||||||
|
"runOncePerServer": true,
|
||||||
|
"continueOnError": false,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "install-cs2-runtime",
|
||||||
|
"type": "github_release_extract",
|
||||||
|
"owner": "roflmuffin",
|
||||||
|
"repo": "CounterStrikeSharp",
|
||||||
|
"assetNamePatterns": [
|
||||||
|
"^counterstrikesharp-with-runtime-.*linux.*\\\\.zip$",
|
||||||
|
"^counterstrikesharp-with-runtime.*\\\\.zip$"
|
||||||
|
],
|
||||||
|
"destination": "/game/csgo",
|
||||||
|
"stripComponents": 0,
|
||||||
|
"maxBytes": 268435456
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]'::jsonb,
|
||||||
|
"updated_at" = now()
|
||||||
|
WHERE
|
||||||
|
"slug" = 'cs2'
|
||||||
|
AND (
|
||||||
|
"automation_rules" IS NULL
|
||||||
|
OR "automation_rules" = '[]'::jsonb
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
WITH metamod_rule AS (
|
||||||
|
SELECT '[
|
||||||
|
{
|
||||||
|
"id": "cs2-install-latest-metamod",
|
||||||
|
"event": "server.install.completed",
|
||||||
|
"enabled": true,
|
||||||
|
"runOncePerServer": true,
|
||||||
|
"continueOnError": false,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "install-cs2-metamod",
|
||||||
|
"type": "http_directory_extract",
|
||||||
|
"indexUrl": "https://mms.alliedmods.net/mmsdrop/2.0/",
|
||||||
|
"assetNamePattern": "^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$",
|
||||||
|
"destination": "/game/csgo",
|
||||||
|
"stripComponents": 0,
|
||||||
|
"maxBytes": 268435456
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]'::jsonb AS rule
|
||||||
|
)
|
||||||
|
UPDATE "games" g
|
||||||
|
SET
|
||||||
|
"automation_rules" = CASE
|
||||||
|
WHEN g."automation_rules" IS NULL OR jsonb_typeof(g."automation_rules") <> 'array'
|
||||||
|
THEN (SELECT rule FROM metamod_rule)
|
||||||
|
ELSE g."automation_rules" || (SELECT rule FROM metamod_rule)
|
||||||
|
END,
|
||||||
|
"updated_at" = now()
|
||||||
|
WHERE
|
||||||
|
g."slug" = 'cs2'
|
||||||
|
AND NOT (
|
||||||
|
COALESCE(g."automation_rules", '[]'::jsonb) @> '[{"id":"cs2-install-latest-metamod"}]'::jsonb
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771748754705,
|
||||||
|
"tag": "0000_red_sunset_bain",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772200000000,
|
||||||
|
"tag": "0001_game_automation_rules",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772300000000,
|
||||||
|
"tag": "0002_cs2_add_metamod_workflow",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ export const games = pgTable('games', {
|
||||||
dockerImage: text('docker_image').notNull(),
|
dockerImage: text('docker_image').notNull(),
|
||||||
defaultPort: integer('default_port').notNull(),
|
defaultPort: integer('default_port').notNull(),
|
||||||
configFiles: jsonb('config_files').default([]).notNull(),
|
configFiles: jsonb('config_files').default([]).notNull(),
|
||||||
|
automationRules: jsonb('automation_rules').default([]).notNull(),
|
||||||
startupCommand: text('startup_command').notNull(),
|
startupCommand: text('startup_command').notNull(),
|
||||||
stopCommand: text('stop_command'),
|
stopCommand: text('stop_command'),
|
||||||
environmentVars: jsonb('environment_vars').default([]).notNull(),
|
environmentVars: jsonb('environment_vars').default([]).notNull(),
|
||||||
|
|
|
||||||
|
|
@ -91,14 +91,13 @@ async function seed() {
|
||||||
{
|
{
|
||||||
slug: 'cs2',
|
slug: 'cs2',
|
||||||
name: 'Counter-Strike 2',
|
name: 'Counter-Strike 2',
|
||||||
dockerImage: 'cm2network/csgo:latest',
|
dockerImage: 'cm2network/cs2:latest',
|
||||||
defaultPort: 27015,
|
defaultPort: 27015,
|
||||||
startupCommand:
|
startupCommand: '',
|
||||||
'./srcds_run -game csgo -console -usercon +game_type 0 +game_mode 0 +mapgroup mg_active +map de_dust2',
|
|
||||||
stopCommand: 'quit',
|
stopCommand: 'quit',
|
||||||
configFiles: [
|
configFiles: [
|
||||||
{
|
{
|
||||||
path: 'csgo/cfg/server.cfg',
|
path: 'game/csgo/cfg/server.cfg',
|
||||||
parser: 'keyvalue',
|
parser: 'keyvalue',
|
||||||
editableKeys: [
|
editableKeys: [
|
||||||
'hostname',
|
'hostname',
|
||||||
|
|
@ -109,21 +108,66 @@ async function seed() {
|
||||||
'mp_limitteams',
|
'mp_limitteams',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: 'csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
{ path: 'game/csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
||||||
|
],
|
||||||
|
automationRules: [
|
||||||
|
{
|
||||||
|
id: 'cs2-install-latest-metamod',
|
||||||
|
event: 'server.install.completed',
|
||||||
|
enabled: true,
|
||||||
|
runOncePerServer: true,
|
||||||
|
continueOnError: false,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'install-cs2-metamod',
|
||||||
|
type: 'http_directory_extract',
|
||||||
|
indexUrl: 'https://mms.alliedmods.net/mmsdrop/2.0/',
|
||||||
|
assetNamePattern: '^mmsource-2\\.0\\.0-git\\d+-linux\\.tar\\.gz$',
|
||||||
|
destination: '/game/csgo',
|
||||||
|
stripComponents: 0,
|
||||||
|
maxBytes: 256 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cs2-install-latest-counterstrikesharp-runtime',
|
||||||
|
event: 'server.install.completed',
|
||||||
|
enabled: true,
|
||||||
|
runOncePerServer: true,
|
||||||
|
continueOnError: false,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'install-cs2-runtime',
|
||||||
|
type: 'github_release_extract',
|
||||||
|
owner: 'roflmuffin',
|
||||||
|
repo: 'CounterStrikeSharp',
|
||||||
|
assetNamePatterns: [
|
||||||
|
'^counterstrikesharp-with-runtime-.*linux.*\\.zip$',
|
||||||
|
'^counterstrikesharp-with-runtime.*\\.zip$',
|
||||||
|
],
|
||||||
|
destination: '/game/csgo',
|
||||||
|
stripComponents: 0,
|
||||||
|
maxBytes: 256 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
environmentVars: [
|
environmentVars: [
|
||||||
{
|
{
|
||||||
key: 'SRCDS_TOKEN',
|
key: 'SRCDS_TOKEN',
|
||||||
default: '',
|
default: '',
|
||||||
description: 'Steam Game Server Login Token',
|
description: 'Steam Game Server Login Token (optional for local testing)',
|
||||||
required: true,
|
required: false,
|
||||||
},
|
},
|
||||||
{ key: 'SRCDS_RCONPW', default: '', description: 'RCON password', required: false },
|
{ key: 'CS2_SERVERNAME', default: 'GamePanel CS2 Server', description: 'Server name', required: false },
|
||||||
{ key: 'SRCDS_PW', default: '', description: 'Server password', required: false },
|
{ key: 'CS2_PORT', default: '27015', description: 'Game port', required: false },
|
||||||
|
{ key: 'CS2_STARTMAP', default: 'de_dust2', description: 'Initial map', required: false },
|
||||||
|
{ key: 'CS2_MAXPLAYERS', default: '16', description: 'Max players', required: false },
|
||||||
|
{ key: 'CS2_RCONPW', default: '', description: 'RCON password', required: false },
|
||||||
{
|
{
|
||||||
key: 'SRCDS_MAXPLAYERS',
|
key: 'CS2_IP',
|
||||||
default: '16',
|
default: '0.0.0.0',
|
||||||
description: 'Max players',
|
description: 'Bind address',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
// Proto generated types will be exported here after running `pnpm generate`
|
// Proto generated types will be exported here after running `pnpm generate`
|
||||||
// For now, this is a placeholder
|
// For now, this is a placeholder
|
||||||
|
|
||||||
export const PROTO_PATH = new URL('../daemon.proto', import.meta.url).pathname;
|
const moduleUrl = (import.meta as ImportMeta & { url: string }).url;
|
||||||
|
|
||||||
|
export const PROTO_PATH = decodeURIComponent(
|
||||||
|
moduleUrl
|
||||||
|
.replace(/^file:\/\//, '')
|
||||||
|
.replace(/\/src\/index\.(ts|js)$/, '/daemon.proto'),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,68 @@ export type PluginSource = 'spiget' | 'manual';
|
||||||
|
|
||||||
export type ConfigParser = 'properties' | 'json' | 'yaml' | 'keyvalue';
|
export type ConfigParser = 'properties' | 'json' | 'yaml' | 'keyvalue';
|
||||||
|
|
||||||
|
export type ServerAutomationEvent =
|
||||||
|
| 'server.created'
|
||||||
|
| 'server.install.completed'
|
||||||
|
| 'server.power.started'
|
||||||
|
| 'server.power.stopped';
|
||||||
|
|
||||||
|
export type ServerAutomationActionType =
|
||||||
|
| 'github_release_extract'
|
||||||
|
| 'http_directory_extract'
|
||||||
|
| 'write_file'
|
||||||
|
| 'send_command';
|
||||||
|
|
||||||
|
export interface ServerAutomationGitHubReleaseExtractAction {
|
||||||
|
id: string;
|
||||||
|
type: 'github_release_extract';
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
assetNamePatterns: string[];
|
||||||
|
destination?: string;
|
||||||
|
stripComponents?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerAutomationWriteFileAction {
|
||||||
|
id: string;
|
||||||
|
type: 'write_file';
|
||||||
|
path: string;
|
||||||
|
data: string;
|
||||||
|
encoding?: 'utf8' | 'base64';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerAutomationHttpDirectoryExtractAction {
|
||||||
|
id: string;
|
||||||
|
type: 'http_directory_extract';
|
||||||
|
indexUrl: string;
|
||||||
|
assetNamePattern: string;
|
||||||
|
destination?: string;
|
||||||
|
stripComponents?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerAutomationSendCommandAction {
|
||||||
|
id: string;
|
||||||
|
type: 'send_command';
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerAutomationAction =
|
||||||
|
| ServerAutomationGitHubReleaseExtractAction
|
||||||
|
| ServerAutomationHttpDirectoryExtractAction
|
||||||
|
| ServerAutomationWriteFileAction
|
||||||
|
| ServerAutomationSendCommandAction;
|
||||||
|
|
||||||
|
export interface GameAutomationWorkflow {
|
||||||
|
id: string;
|
||||||
|
event: ServerAutomationEvent;
|
||||||
|
enabled?: boolean;
|
||||||
|
runOncePerServer?: boolean;
|
||||||
|
continueOnError?: boolean;
|
||||||
|
actions: ServerAutomationAction[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GameConfigFile {
|
export interface GameConfigFile {
|
||||||
path: string;
|
path: string;
|
||||||
parser: ConfigParser;
|
parser: ConfigParser;
|
||||||
|
|
@ -23,6 +85,8 @@ export interface GameEnvVar {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GameAutomationRule = GameAutomationWorkflow;
|
||||||
|
|
||||||
export interface ConfigEntry {
|
export interface ConfigEntry {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
|
||||||
191
pnpm-lock.yaml
191
pnpm-lock.yaml
|
|
@ -86,7 +86,19 @@ importers:
|
||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.0
|
specifier: ^4.8.0
|
||||||
version: 4.8.3
|
version: 4.8.3
|
||||||
|
tar-stream:
|
||||||
|
specifier: ^3.1.7
|
||||||
|
version: 3.1.7
|
||||||
|
unzipper:
|
||||||
|
specifier: ^0.12.3
|
||||||
|
version: 0.12.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/tar-stream':
|
||||||
|
specifier: ^3.1.4
|
||||||
|
version: 3.1.4
|
||||||
|
'@types/unzipper':
|
||||||
|
specifier: ^0.10.11
|
||||||
|
version: 0.10.11
|
||||||
dotenv-cli:
|
dotenv-cli:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0
|
version: 8.0.0
|
||||||
|
|
@ -1766,6 +1778,12 @@ packages:
|
||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
|
'@types/tar-stream@3.1.4':
|
||||||
|
resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==}
|
||||||
|
|
||||||
|
'@types/unzipper@0.10.11':
|
||||||
|
resolution: {integrity: sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==}
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.56.0':
|
'@typescript-eslint/eslint-plugin@8.56.0':
|
||||||
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
|
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
@ -1917,9 +1935,25 @@ packages:
|
||||||
avvio@9.2.0:
|
avvio@9.2.0:
|
||||||
resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==}
|
resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==}
|
||||||
|
|
||||||
|
b4a@1.8.0:
|
||||||
|
resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==}
|
||||||
|
peerDependencies:
|
||||||
|
react-native-b4a: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-native-b4a:
|
||||||
|
optional: true
|
||||||
|
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
bare-events@2.8.2:
|
||||||
|
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
|
||||||
|
peerDependencies:
|
||||||
|
bare-abort-controller: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bare-abort-controller:
|
||||||
|
optional: true
|
||||||
|
|
||||||
base64id@2.0.0:
|
base64id@2.0.0:
|
||||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
engines: {node: ^4.5.0 || >= 5.9}
|
engines: {node: ^4.5.0 || >= 5.9}
|
||||||
|
|
@ -1933,6 +1967,9 @@ packages:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
bluebird@3.7.2:
|
||||||
|
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
|
||||||
|
|
||||||
bn.js@4.12.3:
|
bn.js@4.12.3:
|
||||||
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||||
|
|
||||||
|
|
@ -2012,6 +2049,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
cors@2.8.6:
|
cors@2.8.6:
|
||||||
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
@ -2164,6 +2204,9 @@ packages:
|
||||||
sqlite3:
|
sqlite3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
duplexer2@0.1.4:
|
||||||
|
resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==}
|
||||||
|
|
||||||
duplexify@4.1.3:
|
duplexify@4.1.3:
|
||||||
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
|
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
|
||||||
|
|
||||||
|
|
@ -2279,6 +2322,9 @@ packages:
|
||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
events-universal@1.0.1:
|
||||||
|
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||||
|
|
||||||
fast-copy@4.0.2:
|
fast-copy@4.0.2:
|
||||||
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
|
resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==}
|
||||||
|
|
||||||
|
|
@ -2288,6 +2334,9 @@ packages:
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
|
fast-fifo@1.3.2:
|
||||||
|
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||||
|
|
||||||
fast-glob@3.3.3:
|
fast-glob@3.3.3:
|
||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
|
|
@ -2368,6 +2417,10 @@ packages:
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
fs-extra@11.3.3:
|
||||||
|
resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
|
|
@ -2408,6 +2461,9 @@ packages:
|
||||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11:
|
||||||
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
has-flag@4.0.0:
|
has-flag@4.0.0:
|
||||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
@ -2470,6 +2526,9 @@ packages:
|
||||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
engines: {node: '>=0.12.0'}
|
engines: {node: '>=0.12.0'}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
|
@ -2517,6 +2576,9 @@ packages:
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonfile@6.2.0:
|
||||||
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
|
|
@ -2616,6 +2678,9 @@ packages:
|
||||||
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
node-int64@0.4.0:
|
||||||
|
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
|
||||||
|
|
||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
|
@ -2761,6 +2826,9 @@ packages:
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
process-warning@4.0.1:
|
process-warning@4.0.1:
|
||||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||||
|
|
||||||
|
|
@ -2840,6 +2908,9 @@ packages:
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
@ -2891,6 +2962,9 @@ packages:
|
||||||
run-parallel@1.2.0:
|
run-parallel@1.2.0:
|
||||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
safe-buffer@5.2.1:
|
safe-buffer@5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
|
|
||||||
|
|
@ -2979,10 +3053,16 @@ packages:
|
||||||
stream-shift@1.0.3:
|
stream-shift@1.0.3:
|
||||||
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||||
|
|
||||||
|
streamx@2.23.0:
|
||||||
|
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
|
|
@ -3019,6 +3099,12 @@ packages:
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tar-stream@3.1.7:
|
||||||
|
resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==}
|
||||||
|
|
||||||
|
text-decoder@1.2.7:
|
||||||
|
resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==}
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
@ -3115,6 +3201,13 @@ packages:
|
||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
|
universalify@2.0.1:
|
||||||
|
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
unzipper@0.12.3:
|
||||||
|
resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
@ -4516,6 +4609,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
'@types/tar-stream@3.1.4':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.11
|
||||||
|
|
||||||
|
'@types/unzipper@0.10.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.11
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)':
|
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.3(jiti@1.21.7))(typescript@5.9.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
|
|
@ -4706,14 +4807,20 @@ snapshots:
|
||||||
'@fastify/error': 4.2.0
|
'@fastify/error': 4.2.0
|
||||||
fastq: 1.20.1
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
b4a@1.8.0: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
bare-events@2.8.2: {}
|
||||||
|
|
||||||
base64id@2.0.0: {}
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.0: {}
|
baseline-browser-mapping@2.10.0: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
|
bluebird@3.7.2: {}
|
||||||
|
|
||||||
bn.js@4.12.3: {}
|
bn.js@4.12.3: {}
|
||||||
|
|
||||||
brace-expansion@1.1.12:
|
brace-expansion@1.1.12:
|
||||||
|
|
@ -4792,6 +4899,8 @@ snapshots:
|
||||||
|
|
||||||
cookie@1.1.1: {}
|
cookie@1.1.1: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cors@2.8.6:
|
cors@2.8.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
object-assign: 4.1.1
|
object-assign: 4.1.1
|
||||||
|
|
@ -4850,6 +4959,10 @@ snapshots:
|
||||||
postgres: 3.4.8
|
postgres: 3.4.8
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
|
|
||||||
|
duplexer2@0.1.4:
|
||||||
|
dependencies:
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
|
||||||
duplexify@4.1.3:
|
duplexify@4.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
end-of-stream: 1.4.5
|
end-of-stream: 1.4.5
|
||||||
|
|
@ -5095,12 +5208,20 @@ snapshots:
|
||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
|
events-universal@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
bare-events: 2.8.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bare-abort-controller
|
||||||
|
|
||||||
fast-copy@4.0.2: {}
|
fast-copy@4.0.2: {}
|
||||||
|
|
||||||
fast-decode-uri-component@1.0.1: {}
|
fast-decode-uri-component@1.0.1: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
|
fast-fifo@1.3.2: {}
|
||||||
|
|
||||||
fast-glob@3.3.3:
|
fast-glob@3.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.stat': 2.0.5
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
|
@ -5207,6 +5328,12 @@ snapshots:
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
fs-extra@11.3.3:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
jsonfile: 6.2.0
|
||||||
|
universalify: 2.0.1
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -5243,6 +5370,8 @@ snapshots:
|
||||||
|
|
||||||
globals@14.0.0: {}
|
globals@14.0.0: {}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
|
|
||||||
hasown@2.0.2:
|
hasown@2.0.2:
|
||||||
|
|
@ -5286,6 +5415,8 @@ snapshots:
|
||||||
|
|
||||||
is-number@7.0.0: {}
|
is-number@7.0.0: {}
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
isexe@3.1.5: {}
|
isexe@3.1.5: {}
|
||||||
|
|
@ -5316,6 +5447,12 @@ snapshots:
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonfile@6.2.0:
|
||||||
|
dependencies:
|
||||||
|
universalify: 2.0.1
|
||||||
|
optionalDependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
@ -5404,6 +5541,8 @@ snapshots:
|
||||||
|
|
||||||
node-gyp-build@4.8.4: {}
|
node-gyp-build@4.8.4: {}
|
||||||
|
|
||||||
|
node-int64@0.4.0: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
@ -5537,6 +5676,8 @@ snapshots:
|
||||||
|
|
||||||
prettier@3.8.1: {}
|
prettier@3.8.1: {}
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
process-warning@4.0.1: {}
|
process-warning@4.0.1: {}
|
||||||
|
|
||||||
process-warning@5.0.0: {}
|
process-warning@5.0.0: {}
|
||||||
|
|
@ -5615,6 +5756,16 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
pify: 2.3.0
|
pify: 2.3.0
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
|
|
@ -5682,6 +5833,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
queue-microtask: 1.2.3
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
safe-buffer@5.2.1: {}
|
safe-buffer@5.2.1: {}
|
||||||
|
|
||||||
safe-regex2@5.0.0:
|
safe-regex2@5.0.0:
|
||||||
|
|
@ -5781,12 +5934,25 @@ snapshots:
|
||||||
|
|
||||||
stream-shift@1.0.3: {}
|
stream-shift@1.0.3: {}
|
||||||
|
|
||||||
|
streamx@2.23.0:
|
||||||
|
dependencies:
|
||||||
|
events-universal: 1.0.1
|
||||||
|
fast-fifo: 1.3.2
|
||||||
|
text-decoder: 1.2.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bare-abort-controller
|
||||||
|
- react-native-b4a
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
is-fullwidth-code-point: 3.0.0
|
is-fullwidth-code-point: 3.0.0
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
string_decoder@1.3.0:
|
string_decoder@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
@ -5845,6 +6011,21 @@ snapshots:
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
tar-stream@3.1.7:
|
||||||
|
dependencies:
|
||||||
|
b4a: 1.8.0
|
||||||
|
fast-fifo: 1.3.2
|
||||||
|
streamx: 2.23.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bare-abort-controller
|
||||||
|
- react-native-b4a
|
||||||
|
|
||||||
|
text-decoder@1.2.7:
|
||||||
|
dependencies:
|
||||||
|
b4a: 1.8.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- react-native-b4a
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
thenify: 3.3.1
|
thenify: 3.3.1
|
||||||
|
|
@ -5932,6 +6113,16 @@ snapshots:
|
||||||
undici-types@7.18.2:
|
undici-types@7.18.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
universalify@2.0.1: {}
|
||||||
|
|
||||||
|
unzipper@0.12.3:
|
||||||
|
dependencies:
|
||||||
|
bluebird: 3.7.2
|
||||||
|
duplexer2: 0.1.4
|
||||||
|
fs-extra: 11.3.3
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
node-int64: 0.4.0
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue