Add panel feature updates across API, daemon, and web
This commit is contained in:
parent
6b463c2b1a
commit
afc64b83c1
|
|
@ -38,3 +38,10 @@ WEB_PORT=80
|
||||||
# --- Daemon ---
|
# --- Daemon ---
|
||||||
DAEMON_CONFIG=/etc/gamepanel/config.yml
|
DAEMON_CONFIG=/etc/gamepanel/config.yml
|
||||||
DAEMON_GRPC_PORT=50051
|
DAEMON_GRPC_PORT=50051
|
||||||
|
|
||||||
|
# --- CDN (Plugin Artifacts) ---
|
||||||
|
CDN_BASE_URL=https://cdn.hibna.com.tr
|
||||||
|
CDN_API_KEY=
|
||||||
|
CDN_PLUGIN_BUCKET=gamepanel-plugin-artifacts
|
||||||
|
CDN_PLUGIN_ARTIFACT_TTL_SECONDS=900
|
||||||
|
CDN_WEBHOOK_SECRET=
|
||||||
|
|
|
||||||
|
|
@ -10,15 +10,17 @@
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "^1.14.0",
|
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
|
||||||
"@fastify/cookie": "^11.0.0",
|
"@fastify/cookie": "^11.0.0",
|
||||||
"@fastify/cors": "^10.0.0",
|
"@fastify/cors": "^10.0.0",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/jwt": "^9.0.0",
|
"@fastify/jwt": "^9.0.0",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^10.3.0",
|
"@fastify/rate-limit": "^10.3.0",
|
||||||
"@fastify/websocket": "^11.0.0",
|
"@fastify/websocket": "^11.0.0",
|
||||||
|
"@grpc/grpc-js": "^1.14.0",
|
||||||
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
"@sinclair/typebox": "^0.34.0",
|
"@sinclair/typebox": "^0.34.0",
|
||||||
|
"@source/cdn": "1.4.0",
|
||||||
"@source/database": "workspace:*",
|
"@source/database": "workspace:*",
|
||||||
"@source/proto": "workspace:*",
|
"@source/proto": "workspace:*",
|
||||||
"@source/shared": "workspace:*",
|
"@source/shared": "workspace:*",
|
||||||
|
|
@ -26,14 +28,16 @@
|
||||||
"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",
|
||||||
|
"pino-pretty": "^13.0.0",
|
||||||
|
"socket.io": "^4.8.0",
|
||||||
"tar-stream": "^3.1.7",
|
"tar-stream": "^3.1.7",
|
||||||
"unzipper": "^0.12.3",
|
"unzipper": "^0.12.3",
|
||||||
"pino-pretty": "^13.0.0",
|
"yazl": "^3.3.1"
|
||||||
"socket.io": "^4.8.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/tar-stream": "^3.1.4",
|
"@types/tar-stream": "^3.1.4",
|
||||||
"@types/unzipper": "^0.10.11",
|
"@types/unzipper": "^0.10.11",
|
||||||
|
"@types/yazl": "^3.3.0",
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"tsx": "^4.19.0"
|
"tsx": "^4.19.0"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { CdnClient, CdnError, type FileInfo } from '@source/cdn';
|
||||||
|
import { AppError } from './errors.js';
|
||||||
|
|
||||||
|
const DEFAULT_PLUGIN_BUCKET = 'gamepanel-plugin-artifacts';
|
||||||
|
const DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS = 900;
|
||||||
|
const ARTIFACT_POINTER_PREFIX = 'cdn://file/';
|
||||||
|
|
||||||
|
let cachedClient: CdnClient | null = null;
|
||||||
|
let cachedFingerprint: string | null = null;
|
||||||
|
|
||||||
|
function envValue(name: string): string | null {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCdnConfig(): { baseUrl: string; apiKey: string } | null {
|
||||||
|
const baseUrl = envValue('CDN_BASE_URL');
|
||||||
|
const apiKey = envValue('CDN_API_KEY');
|
||||||
|
if (!baseUrl || !apiKey) return null;
|
||||||
|
return { baseUrl, apiKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArtifactAccessTtlSeconds(): number {
|
||||||
|
const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS);
|
||||||
|
if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS;
|
||||||
|
return Math.floor(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateClient(): CdnClient | null {
|
||||||
|
const config = getCdnConfig();
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const fingerprint = `${config.baseUrl}::${config.apiKey}`;
|
||||||
|
if (cachedClient && cachedFingerprint === fingerprint) return cachedClient;
|
||||||
|
|
||||||
|
cachedClient = new CdnClient({
|
||||||
|
baseUrl: config.baseUrl,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
retry: {
|
||||||
|
retries: 2,
|
||||||
|
retryDelayMs: 250,
|
||||||
|
maxRetryDelayMs: 2_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
cachedFingerprint = fingerprint;
|
||||||
|
return cachedClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireClient(): CdnClient {
|
||||||
|
const client = getOrCreateClient();
|
||||||
|
if (!client) {
|
||||||
|
throw new AppError(
|
||||||
|
500,
|
||||||
|
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
||||||
|
'CDN_NOT_CONFIGURED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCdnAppError(error: unknown, fallbackMessage: string, fallbackCode: string): AppError {
|
||||||
|
if (error instanceof AppError) return error;
|
||||||
|
if (error instanceof CdnError) {
|
||||||
|
return new AppError(502, `CDN error: ${error.message}`, fallbackCode);
|
||||||
|
}
|
||||||
|
return new AppError(502, fallbackMessage, fallbackCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginBucketName(): string {
|
||||||
|
return envValue('CDN_PLUGIN_BUCKET') ?? DEFAULT_PLUGIN_BUCKET;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePrivatePluginBucket(): Promise<string> {
|
||||||
|
const client = requireClient();
|
||||||
|
const bucketName = getPluginBucketName();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bucket = await client.getBucket(bucketName);
|
||||||
|
if (bucket.isPublic) {
|
||||||
|
await client.updateBucket(bucketName, { isPublic: false });
|
||||||
|
}
|
||||||
|
return bucketName;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CdnError && error.statusCode === 404) {
|
||||||
|
try {
|
||||||
|
await client.createBucket(bucketName, {
|
||||||
|
description: 'GamePanel plugin artifacts',
|
||||||
|
isPublic: false,
|
||||||
|
});
|
||||||
|
return bucketName;
|
||||||
|
} catch (createError) {
|
||||||
|
throw toCdnAppError(
|
||||||
|
createError,
|
||||||
|
'Failed to create CDN plugin bucket',
|
||||||
|
'CDN_BUCKET_CREATE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw toCdnAppError(
|
||||||
|
error,
|
||||||
|
'Failed to fetch CDN plugin bucket',
|
||||||
|
'CDN_BUCKET_READ_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCdnArtifactPointer(fileId: string): string {
|
||||||
|
return `${ARTIFACT_POINTER_PREFIX}${fileId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCdnArtifactPointer(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (trimmed.startsWith(ARTIFACT_POINTER_PREFIX)) {
|
||||||
|
const id = trimmed.slice(ARTIFACT_POINTER_PREFIX.length).trim();
|
||||||
|
return id.length > 0 ? id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol === 'cdn:' && parsed.hostname === 'file') {
|
||||||
|
const candidate = parsed.pathname.replace(/^\/+/, '').trim();
|
||||||
|
return candidate.length > 0 ? candidate : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPluginArtifact(
|
||||||
|
content: Uint8Array,
|
||||||
|
filename: string,
|
||||||
|
metadata: Record<string, unknown> = {},
|
||||||
|
): Promise<{ bucket: string; file: FileInfo; artifactPointer: string }> {
|
||||||
|
const client = requireClient();
|
||||||
|
const bucket = await ensurePrivatePluginBucket();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const file = await client.upload(content, {
|
||||||
|
bucket,
|
||||||
|
filename,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bucket,
|
||||||
|
file,
|
||||||
|
artifactPointer: buildCdnArtifactPointer(file.id),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw toCdnAppError(error, 'Failed to upload artifact to CDN', 'CDN_UPLOAD_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise<string> {
|
||||||
|
const fileId = parseCdnArtifactPointer(artifactUrl);
|
||||||
|
if (!fileId) return artifactUrl;
|
||||||
|
|
||||||
|
const client = requireClient();
|
||||||
|
const config = getCdnConfig();
|
||||||
|
const ttl = getArtifactAccessTtlSeconds();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const access = await client.getFileAccessUrl(fileId, ttl);
|
||||||
|
if (!access.url || typeof access.url !== 'string') {
|
||||||
|
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedUrl = access.url.trim();
|
||||||
|
if (!resolvedUrl) {
|
||||||
|
throw new AppError(502, 'CDN access URL is empty', 'CDN_ACCESS_URL_EMPTY');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(resolvedUrl)) {
|
||||||
|
return resolvedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
throw new AppError(
|
||||||
|
500,
|
||||||
|
'CDN configuration is missing. Set CDN_BASE_URL and CDN_API_KEY.',
|
||||||
|
'CDN_NOT_CONFIGURED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(resolvedUrl, config.baseUrl).toString();
|
||||||
|
} catch (error) {
|
||||||
|
throw toCdnAppError(
|
||||||
|
error,
|
||||||
|
'Failed to get temporary CDN access URL',
|
||||||
|
'CDN_ACCESS_URL_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import {
|
||||||
|
daemonReadFile,
|
||||||
|
daemonWriteFile,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from './daemon.js';
|
||||||
|
|
||||||
|
export const CS2_SERVER_CFG_PATH = 'game/csgo/cfg/server.cfg';
|
||||||
|
export const CS2_PERSISTED_SERVER_CFG_PATH = 'game/csgo/cfg/.sourcegamepanel-server.cfg';
|
||||||
|
export const CS2_PERSISTED_SERVER_CFG_FILE = '.sourcegamepanel-server.cfg';
|
||||||
|
const LEGACY_IMAGE_CS2_SERVER_CFG = `// Server Defaults
|
||||||
|
|
||||||
|
hostname "GamePanel CS2 Server" // Set server hostname
|
||||||
|
sv_cheats 0 // Enable or disable cheats
|
||||||
|
sv_hibernate_when_empty 0 // Disable server hibernation
|
||||||
|
|
||||||
|
// Passwords
|
||||||
|
|
||||||
|
rcon_password "" // Set rcon password
|
||||||
|
sv_password "" // Set server password
|
||||||
|
|
||||||
|
// CSTV
|
||||||
|
|
||||||
|
sv_hibernate_postgame_delay 30 // Delay server hibernation after all clients disconnect
|
||||||
|
|
||||||
|
tv_allow_camera_man 1 // Auto director allows spectators to become camera man
|
||||||
|
tv_allow_static_shots 1 // Auto director uses fixed level cameras for shots
|
||||||
|
tv_autorecord 0 // Automatically records all games as CSTV demos: 0=off, 1=on.
|
||||||
|
tv_chatgroupsize 0 // Set the default chat group size
|
||||||
|
tv_chattimelimit 8 // Limits spectators to chat only every n seconds
|
||||||
|
tv_debug 0 // CSTV debug info.
|
||||||
|
tv_delay 0 // CSTV broadcast delay in seconds
|
||||||
|
tv_delaymapchange 1 // Delays map change until broadcast is complete
|
||||||
|
tv_deltacache 2 // Enable delta entity bit stream cache
|
||||||
|
tv_dispatchmode 1 // Dispatch clients to relay proxies: 0=never, 1=if appropriate, 2=always
|
||||||
|
tv_enable 0 // Activates CSTV on server: 0=off, 1=on.
|
||||||
|
tv_maxclients 10 // Maximum client number on CSTV server.
|
||||||
|
tv_maxrate 0 // Max CSTV spectator bandwidth rate allowed, 0 == unlimited
|
||||||
|
tv_name "GamePanel CS2 Server CSTV" // CSTV host name
|
||||||
|
tv_overridemaster 0 // Overrides the CSTV master root address.
|
||||||
|
tv_port 27020 // Host SourceTV port
|
||||||
|
tv_password "changeme" // CSTV password for clients
|
||||||
|
tv_relaypassword "changeme" // CSTV password for relay proxies
|
||||||
|
tv_relayvoice 1 // Relay voice data: 0=off, 1=on
|
||||||
|
tv_timeout 60 // CSTV connection timeout in seconds.
|
||||||
|
tv_title "GamePanel CS2 Server CSTV" // Set title for CSTV spectator UI
|
||||||
|
tv_transmitall 1 // Transmit all entities (not only director view)
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
|
||||||
|
log on // Turns logging 'on' or 'off', defaults to 'on'
|
||||||
|
mp_logmoney 0 // Turns money logging on/off: 0=off, 1=on
|
||||||
|
mp_logdetail 0 // Combat damage logging: 0=disabled, 1=enemy, 2=friendly, 3=all
|
||||||
|
mp_logdetail_items 0 // Turns item logging on/off: 0=off, 1=on
|
||||||
|
`;
|
||||||
|
export const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
||||||
|
// CS2 Server Config
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ---- Sunucu Bilgileri ----
|
||||||
|
hostname "SourceGamePanel CS2 Server"
|
||||||
|
sv_password ""
|
||||||
|
rcon_password "changeme"
|
||||||
|
sv_cheats 0
|
||||||
|
|
||||||
|
// ---- Topluluk Sunucu Gorunurlugu ----
|
||||||
|
sv_region 3
|
||||||
|
sv_tags "competitive,community"
|
||||||
|
sv_lan 0
|
||||||
|
sv_steamgroup ""
|
||||||
|
sv_steamgroup_exclusive 0
|
||||||
|
|
||||||
|
// ---- Performans ----
|
||||||
|
sv_maxrate 0
|
||||||
|
sv_minrate 64000
|
||||||
|
sv_max_queries_sec 5
|
||||||
|
sv_max_queries_window 30
|
||||||
|
sv_parallel_sendsnapshot 1
|
||||||
|
net_maxroutable 1200
|
||||||
|
|
||||||
|
// ---- Baglanti ----
|
||||||
|
sv_maxclients 16
|
||||||
|
sv_timeout 60
|
||||||
|
|
||||||
|
// ---- GOTV (Tamamen Kapali) ----
|
||||||
|
tv_enable 0
|
||||||
|
tv_autorecord 0
|
||||||
|
tv_delay 0
|
||||||
|
tv_maxclients 0
|
||||||
|
tv_port 0
|
||||||
|
|
||||||
|
// ---- Loglama ----
|
||||||
|
log on
|
||||||
|
mp_logmoney 0
|
||||||
|
mp_logdetail 0
|
||||||
|
mp_logdetail_items 0
|
||||||
|
sv_logfile 1
|
||||||
|
|
||||||
|
// ---- Genel Oyun Ayarlari ----
|
||||||
|
mp_autokick 0
|
||||||
|
sv_allow_votes 0
|
||||||
|
sv_alltalk 0
|
||||||
|
sv_deadtalk 1
|
||||||
|
sv_voiceenable 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
const normalized = path
|
||||||
|
.trim()
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/\/{2,}/g, '/');
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingFileError(error: unknown): boolean {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return (
|
||||||
|
message.includes('No such file or directory') ||
|
||||||
|
message.includes('Server responded with NOT_FOUND') ||
|
||||||
|
message.includes('status code 404')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeComparableContent(content: string): string {
|
||||||
|
return content.replace(/\r\n/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isManagedCs2ServerConfigPath(gameSlug: string, path: string): boolean {
|
||||||
|
return (
|
||||||
|
gameSlug.trim().toLowerCase() === 'cs2' &&
|
||||||
|
normalizePath(path) === CS2_SERVER_CFG_PATH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readManagedCs2ServerConfig(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
serverUuid: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
const persisted = await daemonReadFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH);
|
||||||
|
return persisted.data.toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingFileError(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const current = await daemonReadFile(node, serverUuid, CS2_SERVER_CFG_PATH);
|
||||||
|
const content = current.data.toString('utf8');
|
||||||
|
const nextContent =
|
||||||
|
normalizeComparableContent(content) === normalizeComparableContent(LEGACY_IMAGE_CS2_SERVER_CFG)
|
||||||
|
? DEFAULT_CS2_SERVER_CFG
|
||||||
|
: content;
|
||||||
|
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, nextContent);
|
||||||
|
return nextContent;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingFileError(error)) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, DEFAULT_CS2_SERVER_CFG);
|
||||||
|
return DEFAULT_CS2_SERVER_CFG;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeManagedCs2ServerConfig(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
serverUuid: string,
|
||||||
|
content: string | Buffer,
|
||||||
|
): Promise<void> {
|
||||||
|
await daemonWriteFile(node, serverUuid, CS2_PERSISTED_SERVER_CFG_PATH, content);
|
||||||
|
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reapplyManagedCs2ServerConfig(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
serverUuid: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const content = await readManagedCs2ServerConfig(node, serverUuid);
|
||||||
|
await daemonWriteFile(node, serverUuid, CS2_SERVER_CFG_PATH, content);
|
||||||
|
}
|
||||||
|
|
@ -27,11 +27,31 @@ export interface DaemonCreateServerRequest {
|
||||||
install_plugin_urls: string[];
|
install_plugin_urls: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DaemonUpdateServerRequest {
|
||||||
|
uuid: string;
|
||||||
|
docker_image: string;
|
||||||
|
memory_limit: number;
|
||||||
|
disk_limit: number;
|
||||||
|
cpu_limit: number;
|
||||||
|
startup_command: string;
|
||||||
|
environment: Record<string, string>;
|
||||||
|
ports: DaemonPortMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
interface DaemonServerResponse {
|
interface DaemonServerResponse {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DaemonManagedDatabaseCredentialsRaw {
|
||||||
|
database_name: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
phpmyadmin_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DaemonNodeStatusRaw {
|
interface DaemonNodeStatusRaw {
|
||||||
version: string;
|
version: string;
|
||||||
is_healthy: boolean;
|
is_healthy: boolean;
|
||||||
|
|
@ -124,6 +144,15 @@ export interface DaemonBackupResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DaemonManagedDatabaseCredentials {
|
||||||
|
databaseName: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
phpMyAdminUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DaemonNodeStatus {
|
export interface DaemonNodeStatus {
|
||||||
version: string;
|
version: string;
|
||||||
isHealthy: boolean;
|
isHealthy: boolean;
|
||||||
|
|
@ -156,11 +185,31 @@ interface DaemonServiceClient extends grpc.Client {
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
callback: UnaryCallback<DaemonServerResponse>,
|
callback: UnaryCallback<DaemonServerResponse>,
|
||||||
): void;
|
): void;
|
||||||
|
updateServer(
|
||||||
|
request: DaemonUpdateServerRequest,
|
||||||
|
metadata: grpc.Metadata,
|
||||||
|
callback: UnaryCallback<DaemonServerResponse>,
|
||||||
|
): void;
|
||||||
deleteServer(
|
deleteServer(
|
||||||
request: { uuid: string },
|
request: { uuid: string },
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
callback: UnaryCallback<EmptyResponse>,
|
callback: UnaryCallback<EmptyResponse>,
|
||||||
): void;
|
): void;
|
||||||
|
createDatabase(
|
||||||
|
request: { server_uuid: string; name: string; password?: string },
|
||||||
|
metadata: grpc.Metadata,
|
||||||
|
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
|
||||||
|
): void;
|
||||||
|
updateDatabasePassword(
|
||||||
|
request: { username: string; password: string },
|
||||||
|
metadata: grpc.Metadata,
|
||||||
|
callback: UnaryCallback<EmptyResponse>,
|
||||||
|
): void;
|
||||||
|
deleteDatabase(
|
||||||
|
request: { database_name: string; username: string },
|
||||||
|
metadata: grpc.Metadata,
|
||||||
|
callback: UnaryCallback<EmptyResponse>,
|
||||||
|
): void;
|
||||||
setPowerState(
|
setPowerState(
|
||||||
request: { uuid: string; action: number },
|
request: { uuid: string; action: number },
|
||||||
metadata: grpc.Metadata,
|
metadata: grpc.Metadata,
|
||||||
|
|
@ -388,6 +437,12 @@ 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;
|
||||||
|
const POWER_RPC_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
|
interface DaemonRequestTimeoutOptions {
|
||||||
|
connectTimeoutMs?: number;
|
||||||
|
rpcTimeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function daemonGetNodeStatus(
|
export async function daemonGetNodeStatus(
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
|
|
@ -464,6 +519,104 @@ export async function daemonDeleteServer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function daemonUpdateServer(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
request: DaemonUpdateServerRequest,
|
||||||
|
): Promise<DaemonServerResponse> {
|
||||||
|
const client = createClient(node);
|
||||||
|
try {
|
||||||
|
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
|
return await callUnary<DaemonServerResponse>(
|
||||||
|
(callback) => client.updateServer(request, getMetadata(node.daemonToken), callback),
|
||||||
|
DEFAULT_RPC_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function daemonCreateDatabase(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
request: { serverUuid: string; name: string; password?: string },
|
||||||
|
): Promise<DaemonManagedDatabaseCredentials> {
|
||||||
|
const client = createClient(node);
|
||||||
|
try {
|
||||||
|
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
|
const response = await callUnary<DaemonManagedDatabaseCredentialsRaw>(
|
||||||
|
(callback) =>
|
||||||
|
client.createDatabase(
|
||||||
|
{
|
||||||
|
server_uuid: request.serverUuid,
|
||||||
|
name: request.name,
|
||||||
|
password: request.password ?? '',
|
||||||
|
},
|
||||||
|
getMetadata(node.daemonToken),
|
||||||
|
callback,
|
||||||
|
),
|
||||||
|
DEFAULT_RPC_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
databaseName: response.database_name,
|
||||||
|
username: response.username,
|
||||||
|
password: response.password,
|
||||||
|
host: response.host,
|
||||||
|
port: Number(response.port),
|
||||||
|
phpMyAdminUrl: response.phpmyadmin_url.trim() ? response.phpmyadmin_url : null,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function daemonUpdateDatabasePassword(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
request: { username: string; password: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const client = createClient(node);
|
||||||
|
try {
|
||||||
|
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
|
await callUnary<EmptyResponse>(
|
||||||
|
(callback) =>
|
||||||
|
client.updateDatabasePassword(
|
||||||
|
{
|
||||||
|
username: request.username,
|
||||||
|
password: request.password,
|
||||||
|
},
|
||||||
|
getMetadata(node.daemonToken),
|
||||||
|
callback,
|
||||||
|
),
|
||||||
|
DEFAULT_RPC_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function daemonDeleteDatabase(
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
request: { databaseName: string; username: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const client = createClient(node);
|
||||||
|
try {
|
||||||
|
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
|
await callUnary<EmptyResponse>(
|
||||||
|
(callback) =>
|
||||||
|
client.deleteDatabase(
|
||||||
|
{
|
||||||
|
database_name: request.databaseName,
|
||||||
|
username: request.username,
|
||||||
|
},
|
||||||
|
getMetadata(node.daemonToken),
|
||||||
|
callback,
|
||||||
|
),
|
||||||
|
DEFAULT_RPC_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function daemonSetPowerState(
|
export async function daemonSetPowerState(
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
serverUuid: string,
|
serverUuid: string,
|
||||||
|
|
@ -474,7 +627,7 @@ export async function daemonSetPowerState(
|
||||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
await callUnary<EmptyResponse>(
|
await callUnary<EmptyResponse>(
|
||||||
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
|
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
|
||||||
DEFAULT_RPC_TIMEOUT_MS,
|
POWER_RPC_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
|
|
@ -484,13 +637,14 @@ export async function daemonSetPowerState(
|
||||||
export async function daemonGetServerStatus(
|
export async function daemonGetServerStatus(
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
serverUuid: string,
|
serverUuid: string,
|
||||||
|
timeouts: DaemonRequestTimeoutOptions = {},
|
||||||
): Promise<DaemonStatusResponse> {
|
): Promise<DaemonStatusResponse> {
|
||||||
const client = createClient(node);
|
const client = createClient(node);
|
||||||
try {
|
try {
|
||||||
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
|
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
||||||
return await callUnary<DaemonStatusResponse>(
|
return await callUnary<DaemonStatusResponse>(
|
||||||
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
|
||||||
DEFAULT_RPC_TIMEOUT_MS,
|
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
ServerAutomationGitHubReleaseExtractAction,
|
ServerAutomationGitHubReleaseExtractAction,
|
||||||
ServerAutomationHttpDirectoryExtractAction,
|
ServerAutomationHttpDirectoryExtractAction,
|
||||||
ServerAutomationInsertBeforeLineAction,
|
ServerAutomationInsertBeforeLineAction,
|
||||||
|
ServerAutomationWriteFileAction,
|
||||||
} from '@source/shared';
|
} from '@source/shared';
|
||||||
import {
|
import {
|
||||||
daemonReadFile,
|
daemonReadFile,
|
||||||
|
|
@ -17,6 +18,11 @@ import {
|
||||||
daemonWriteFile,
|
daemonWriteFile,
|
||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
} from './daemon.js';
|
} from './daemon.js';
|
||||||
|
import {
|
||||||
|
CS2_PERSISTED_SERVER_CFG_PATH,
|
||||||
|
CS2_SERVER_CFG_PATH,
|
||||||
|
DEFAULT_CS2_SERVER_CFG,
|
||||||
|
} from './cs2-server-config.js';
|
||||||
|
|
||||||
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
|
||||||
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
@ -26,7 +32,6 @@ const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod';
|
||||||
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
|
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
|
||||||
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
|
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
|
||||||
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
|
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
|
||||||
|
|
||||||
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
|
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
|
||||||
id: CS2_GAMEINFO_INSERT_ACTION_ID,
|
id: CS2_GAMEINFO_INSERT_ACTION_ID,
|
||||||
type: 'insert_before_line',
|
type: 'insert_before_line',
|
||||||
|
|
@ -37,8 +42,33 @@ const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction
|
||||||
skipIfExists: true,
|
skipIfExists: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CS2_SERVER_CONFIG_ACTION: ServerAutomationWriteFileAction = {
|
||||||
|
id: 'write-cs2-default-server-config',
|
||||||
|
type: 'write_file',
|
||||||
|
path: `/${CS2_SERVER_CFG_PATH}`,
|
||||||
|
data: DEFAULT_CS2_SERVER_CFG,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION: ServerAutomationWriteFileAction = {
|
||||||
|
id: 'write-cs2-persisted-server-config',
|
||||||
|
type: 'write_file',
|
||||||
|
path: `/${CS2_PERSISTED_SERVER_CFG_PATH}`,
|
||||||
|
data: DEFAULT_CS2_SERVER_CFG,
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
|
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
|
||||||
cs2: [
|
cs2: [
|
||||||
|
{
|
||||||
|
id: 'cs2-write-default-server-config',
|
||||||
|
event: 'server.install.completed',
|
||||||
|
enabled: true,
|
||||||
|
runOncePerServer: true,
|
||||||
|
continueOnError: false,
|
||||||
|
actions: [
|
||||||
|
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
|
||||||
|
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'cs2-install-latest-metamod',
|
id: 'cs2-install-latest-metamod',
|
||||||
event: 'server.install.completed',
|
event: 'server.install.completed',
|
||||||
|
|
@ -147,6 +177,16 @@ function normalizeWorkflow(
|
||||||
): GameAutomationRule {
|
): GameAutomationRule {
|
||||||
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
|
||||||
|
|
||||||
|
if (workflow.id === 'cs2-write-default-server-config') {
|
||||||
|
return {
|
||||||
|
...workflow,
|
||||||
|
actions: [
|
||||||
|
{ ...DEFAULT_CS2_SERVER_CONFIG_ACTION },
|
||||||
|
{ ...DEFAULT_CS2_SERVER_CONFIG_SHADOW_ACTION },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
|
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
|
||||||
const normalizedActions = workflow.actions.map((action) => {
|
const normalizedActions = workflow.actions.map((action) => {
|
||||||
if (action.type !== 'github_release_extract') return action;
|
if (action.type !== 'github_release_extract') return action;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { createDb, type Database } from '@source/database';
|
import { createDb, type Database } from '@source/database';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
|
|
@ -17,5 +18,26 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
const db = createDb(databaseUrl);
|
const db = createDb(databaseUrl);
|
||||||
app.decorate('db', db);
|
app.decorate('db', db);
|
||||||
|
|
||||||
|
await db.execute(sql.raw(`
|
||||||
|
CREATE TABLE IF NOT EXISTS server_databases (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
database_name varchar(255) NOT NULL UNIQUE,
|
||||||
|
username varchar(64) NOT NULL UNIQUE,
|
||||||
|
password text NOT NULL,
|
||||||
|
host varchar(255) NOT NULL,
|
||||||
|
port integer NOT NULL,
|
||||||
|
phpmyadmin_url text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`));
|
||||||
|
await db.execute(
|
||||||
|
sql.raw(
|
||||||
|
'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
app.log.info('Database connected');
|
app.log.info('Database connected');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,20 @@ declare module 'fastify' {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConsolePermission = 'console.read' | 'console.write';
|
type ConsolePermission = 'console.read' | 'console.write';
|
||||||
|
type ConsoleCommandAck = {
|
||||||
|
requestId: string | null;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SharedConsoleStream {
|
||||||
|
handle: DaemonConsoleStreamHandle;
|
||||||
|
subscribers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomForServer(serverId: string): string {
|
||||||
|
return `server:console:${serverId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default fp(async (app: FastifyInstance) => {
|
export default fp(async (app: FastifyInstance) => {
|
||||||
const io = new SocketIOServer(app.server, {
|
const io = new SocketIOServer(app.server, {
|
||||||
|
|
@ -32,7 +46,16 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
|
|
||||||
app.decorate('io', io);
|
app.decorate('io', io);
|
||||||
|
|
||||||
const activeStreams = new Map<string, DaemonConsoleStreamHandle>();
|
const serverStreams = new Map<string, SharedConsoleStream>();
|
||||||
|
const socketSubscriptions = new Map<string, string>();
|
||||||
|
|
||||||
|
const clearServerSubscriptions = (serverId: string) => {
|
||||||
|
for (const [socketId, subscribedServerId] of socketSubscriptions.entries()) {
|
||||||
|
if (subscribedServerId === serverId) {
|
||||||
|
socketSubscriptions.delete(socketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
io.use((socket, next) => {
|
io.use((socket, next) => {
|
||||||
const token = typeof socket.handshake.auth?.token === 'string'
|
const token = typeof socket.handshake.auth?.token === 'string'
|
||||||
|
|
@ -61,10 +84,20 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const cleanupSocketStream = () => {
|
const cleanupSocketStream = () => {
|
||||||
const current = activeStreams.get(socket.id);
|
const subscribedServerId = socketSubscriptions.get(socket.id);
|
||||||
if (!current) return;
|
if (!subscribedServerId) return;
|
||||||
current.close();
|
|
||||||
activeStreams.delete(socket.id);
|
socketSubscriptions.delete(socket.id);
|
||||||
|
socket.leave(roomForServer(subscribedServerId));
|
||||||
|
|
||||||
|
const shared = serverStreams.get(subscribedServerId);
|
||||||
|
if (!shared) return;
|
||||||
|
|
||||||
|
shared.subscribers = Math.max(0, shared.subscribers - 1);
|
||||||
|
if (shared.subscribers === 0) {
|
||||||
|
shared.handle.close();
|
||||||
|
serverStreams.delete(subscribedServerId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.on('server:console:join', async (payload: unknown) => {
|
socket.on('server:console:join', async (payload: unknown) => {
|
||||||
|
|
@ -94,34 +127,63 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousSubscription = socketSubscriptions.get(socket.id);
|
||||||
|
if (previousSubscription === serverId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cleanupSocketStream();
|
cleanupSocketStream();
|
||||||
|
socket.join(roomForServer(serverId));
|
||||||
|
|
||||||
|
let shared = serverStreams.get(serverId);
|
||||||
|
if (!shared) {
|
||||||
try {
|
try {
|
||||||
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
|
const streamHandle = await daemonOpenConsoleStream(server.node, server.serverUuid);
|
||||||
|
const room = roomForServer(serverId);
|
||||||
|
|
||||||
streamHandle.stream.on('data', (output) => {
|
streamHandle.stream.on('data', (output) => {
|
||||||
socket.emit('server:console:output', { line: output.line });
|
io.to(room).emit('server:console:output', { line: output.line });
|
||||||
});
|
|
||||||
streamHandle.stream.on('end', () => {
|
|
||||||
activeStreams.delete(socket.id);
|
|
||||||
socket.emit('server:console:output', { line: '[console] Stream ended' });
|
|
||||||
});
|
|
||||||
streamHandle.stream.on('error', (error) => {
|
|
||||||
activeStreams.delete(socket.id);
|
|
||||||
app.log.warn(
|
|
||||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
|
||||||
'Console stream failed',
|
|
||||||
);
|
|
||||||
socket.emit('server:console:output', { line: '[error] Console stream failed' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
activeStreams.set(socket.id, streamHandle);
|
streamHandle.stream.on('end', () => {
|
||||||
|
const current = serverStreams.get(serverId);
|
||||||
|
if (current?.handle !== streamHandle) return;
|
||||||
|
serverStreams.delete(serverId);
|
||||||
|
clearServerSubscriptions(serverId);
|
||||||
|
io.to(room).emit('server:console:output', { line: '[console] Stream ended' });
|
||||||
|
io.in(room).socketsLeave(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
streamHandle.stream.on('error', (error) => {
|
||||||
|
const current = serverStreams.get(serverId);
|
||||||
|
if (current?.handle !== streamHandle) return;
|
||||||
|
serverStreams.delete(serverId);
|
||||||
|
clearServerSubscriptions(serverId);
|
||||||
|
app.log.warn(
|
||||||
|
{ error, serverId, serverUuid: server.serverUuid },
|
||||||
|
'Console stream failed',
|
||||||
|
);
|
||||||
|
io.to(room).emit('server:console:output', { line: '[error] Console stream failed' });
|
||||||
|
io.in(room).socketsLeave(room);
|
||||||
|
});
|
||||||
|
|
||||||
|
shared = {
|
||||||
|
handle: streamHandle,
|
||||||
|
subscribers: 0,
|
||||||
|
};
|
||||||
|
serverStreams.set(serverId, shared);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.warn(
|
app.log.warn(
|
||||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||||
'Failed to open console stream',
|
'Failed to open console stream',
|
||||||
);
|
);
|
||||||
|
socket.leave(roomForServer(serverId));
|
||||||
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
|
socket.emit('server:console:output', { line: '[error] Failed to open console stream' });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shared.subscribers += 1;
|
||||||
|
socketSubscriptions.set(socket.id, serverId);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('server:console:leave', () => {
|
socket.on('server:console:leave', () => {
|
||||||
|
|
@ -133,43 +195,67 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
serverId?: unknown;
|
serverId?: unknown;
|
||||||
orgId?: unknown;
|
orgId?: unknown;
|
||||||
command?: unknown;
|
command?: unknown;
|
||||||
|
requestId?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
const serverId = typeof body.serverId === 'string' ? body.serverId : '';
|
||||||
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
const orgId = typeof body.orgId === 'string' ? body.orgId : '';
|
||||||
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
const command = typeof body.command === 'string' ? body.command.trim() : '';
|
||||||
|
const requestId = typeof body.requestId === 'string' && body.requestId.trim()
|
||||||
|
? body.requestId.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!serverId || !orgId || !command) {
|
if (!serverId || !orgId || !command) {
|
||||||
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
socket.emit('server:console:output', { line: '[error] Invalid command payload' });
|
||||||
|
const ack: ConsoleCommandAck = {
|
||||||
|
requestId,
|
||||||
|
ok: false,
|
||||||
|
error: 'Invalid command payload',
|
||||||
|
};
|
||||||
|
socket.emit('server:console:command:ack', ack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = (socket.data as { user?: AccessTokenPayload }).user;
|
const user = (socket.data as { user?: AccessTokenPayload }).user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
socket.emit('server:console:output', { line: '[error] Unauthorized' });
|
socket.emit('server:console:output', { line: '[error] Unauthorized' });
|
||||||
|
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Unauthorized' };
|
||||||
|
socket.emit('server:console:command:ack', ack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await getServerContext(app, serverId, orgId);
|
const server = await getServerContext(app, serverId, orgId);
|
||||||
if (!server) {
|
if (!server) {
|
||||||
socket.emit('server:console:output', { line: '[error] Server not found' });
|
socket.emit('server:console:output', { line: '[error] Server not found' });
|
||||||
|
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Server not found' };
|
||||||
|
socket.emit('server:console:command:ack', ack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
|
const allowed = await hasConsolePermission(app, user, orgId, 'console.write');
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
|
socket.emit('server:console:output', { line: '[error] Missing permission: console.write' });
|
||||||
|
const ack: ConsoleCommandAck = {
|
||||||
|
requestId,
|
||||||
|
ok: false,
|
||||||
|
error: 'Missing permission: console.write',
|
||||||
|
};
|
||||||
|
socket.emit('server:console:command:ack', ack);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await daemonSendCommand(server.node, server.serverUuid, command);
|
await daemonSendCommand(server.node, server.serverUuid, command);
|
||||||
|
const ack: ConsoleCommandAck = { requestId, ok: true };
|
||||||
|
socket.emit('server:console:command:ack', ack);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.warn(
|
app.log.warn(
|
||||||
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
{ error, serverId, serverUuid: server.serverUuid, socketId: socket.id },
|
||||||
'Failed to send console command',
|
'Failed to send console command',
|
||||||
);
|
);
|
||||||
socket.emit('server:console:output', { line: '[error] Failed to send command' });
|
socket.emit('server:console:output', { line: '[error] Failed to send command' });
|
||||||
|
const ack: ConsoleCommandAck = { requestId, ok: false, error: 'Failed to send command' };
|
||||||
|
socket.emit('server:console:command:ack', ack);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -179,10 +265,11 @@ export default fp(async (app: FastifyInstance) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
for (const handle of activeStreams.values()) {
|
for (const stream of serverStreams.values()) {
|
||||||
handle.close();
|
stream.handle.close();
|
||||||
}
|
}
|
||||||
activeStreams.clear();
|
serverStreams.clear();
|
||||||
|
socketSubscriptions.clear();
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
io.close(() => resolve());
|
io.close(() => resolve());
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,197 @@
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, desc, count } from 'drizzle-orm';
|
import multipart from '@fastify/multipart';
|
||||||
import { users, games, nodes, auditLogs } from '@source/database';
|
import { eq, desc, count, and } from 'drizzle-orm';
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import { users, games, nodes, auditLogs, plugins, pluginReleases } from '@source/database';
|
||||||
import { AppError } from '../../lib/errors.js';
|
import { AppError } from '../../lib/errors.js';
|
||||||
import { requireSuperAdmin } from '../../lib/permissions.js';
|
import { requireSuperAdmin } from '../../lib/permissions.js';
|
||||||
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js';
|
||||||
import { CreateGameSchema, UpdateGameSchema, GameIdParamSchema } from './schemas.js';
|
import { uploadPluginArtifact } from '../../lib/cdn.js';
|
||||||
|
import * as yazl from 'yazl';
|
||||||
|
import {
|
||||||
|
CreateGameSchema,
|
||||||
|
UpdateGameSchema,
|
||||||
|
GameIdParamSchema,
|
||||||
|
PluginIdParamSchema,
|
||||||
|
PluginReleaseIdParamSchema,
|
||||||
|
CreateGlobalPluginSchema,
|
||||||
|
UpdateGlobalPluginSchema,
|
||||||
|
ImportPluginsSchema,
|
||||||
|
CreatePluginReleaseSchema,
|
||||||
|
UpdatePluginReleaseSchema,
|
||||||
|
} from './schemas.js';
|
||||||
|
|
||||||
|
type ReleaseChannel = 'stable' | 'beta' | 'alpha';
|
||||||
|
|
||||||
|
interface UploadArtifactFile {
|
||||||
|
relativePath: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadJsonFile {
|
||||||
|
filename: string;
|
||||||
|
data: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeRelativeSegments(path: string): string[] {
|
||||||
|
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
|
||||||
|
const normalized: string[] = [];
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (segment === '.' || segment === '') continue;
|
||||||
|
if (segment === '..') {
|
||||||
|
throw AppError.badRequest('Invalid artifact path segment');
|
||||||
|
}
|
||||||
|
normalized.push(segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(path: string, fallbackName: string): string {
|
||||||
|
const segments = sanitizeRelativeSegments(path);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return sanitizeRelativeSegments(fallbackName).join('/');
|
||||||
|
}
|
||||||
|
return segments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] {
|
||||||
|
if (rawValue === undefined || rawValue === null || rawValue === '') return [];
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
throw AppError.badRequest(`${fieldName} must be a JSON string`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawValue);
|
||||||
|
} catch {
|
||||||
|
throw AppError.badRequest(`${fieldName} is not valid JSON`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw AppError.badRequest(`${fieldName} must be a JSON array`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArrayUploadFile(
|
||||||
|
file: UploadJsonFile | null,
|
||||||
|
fieldName: string,
|
||||||
|
): unknown[] {
|
||||||
|
if (!file) return [];
|
||||||
|
|
||||||
|
let rawValue = file.data.toString('utf8');
|
||||||
|
if (rawValue.charCodeAt(0) === 0xfeff) {
|
||||||
|
rawValue = rawValue.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseJsonArrayField(rawValue, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArrayInput(
|
||||||
|
rawValue: unknown,
|
||||||
|
file: UploadJsonFile | null,
|
||||||
|
fieldName: string,
|
||||||
|
): unknown[] {
|
||||||
|
if (file) return parseJsonArrayUploadFile(file, fieldName);
|
||||||
|
return parseJsonArrayField(rawValue, fieldName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalBoolean(rawValue: unknown): boolean | undefined {
|
||||||
|
if (rawValue === undefined || rawValue === null || rawValue === '') return undefined;
|
||||||
|
if (typeof rawValue === 'boolean') return rawValue;
|
||||||
|
if (typeof rawValue !== 'string') return undefined;
|
||||||
|
|
||||||
|
const normalized = rawValue.trim().toLowerCase();
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReleaseChannel(rawValue: unknown): ReleaseChannel {
|
||||||
|
if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue;
|
||||||
|
if (typeof rawValue === 'string') {
|
||||||
|
const normalized = rawValue.trim().toLowerCase();
|
||||||
|
if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized;
|
||||||
|
}
|
||||||
|
return 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zipArtifacts(files: UploadArtifactFile[]): Promise<Buffer> {
|
||||||
|
return await new Promise<Buffer>((resolve, reject) => {
|
||||||
|
const archive = new yazl.ZipFile();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
archive.outputStream.on('data', (chunk: Buffer) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
archive.outputStream.on('error', reject);
|
||||||
|
archive.outputStream.on('end', () => {
|
||||||
|
resolve(Buffer.concat(chunks));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
archive.addBuffer(file.data, file.relativePath.replace(/^\/+/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
archive.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveImportGame(
|
||||||
|
app: FastifyInstance,
|
||||||
|
{
|
||||||
|
gameId,
|
||||||
|
gameSlug,
|
||||||
|
}: {
|
||||||
|
gameId?: string;
|
||||||
|
gameSlug?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (gameId) {
|
||||||
|
const game = await app.db.query.games.findFirst({
|
||||||
|
where: eq(games.id, gameId),
|
||||||
|
});
|
||||||
|
if (!game) {
|
||||||
|
throw AppError.notFound(`Game not found: ${gameId}`);
|
||||||
|
}
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSlug = gameSlug?.trim().toLowerCase();
|
||||||
|
if (normalizedSlug) {
|
||||||
|
const game = await app.db.query.games.findFirst({
|
||||||
|
where: eq(games.slug, normalizedSlug),
|
||||||
|
});
|
||||||
|
if (!game) {
|
||||||
|
throw AppError.notFound(`Game not found: ${normalizedSlug}`);
|
||||||
|
}
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw AppError.badRequest('gameId or gameSlug is required for each import item');
|
||||||
|
}
|
||||||
|
|
||||||
export default async function adminRoutes(app: FastifyInstance) {
|
export default async function adminRoutes(app: FastifyInstance) {
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
files: 200,
|
||||||
|
parts: 600,
|
||||||
|
fileSize: 512 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// All admin routes require auth + super admin
|
// All admin routes require auth + super admin
|
||||||
app.addHook('onRequest', app.authenticate);
|
app.addHook('onRequest', app.authenticate);
|
||||||
app.addHook('onRequest', async (request) => {
|
app.addHook('onRequest', async (request) => {
|
||||||
|
|
@ -100,6 +285,617 @@ export default async function adminRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
// === Nodes (global view) ===
|
// === Nodes (global view) ===
|
||||||
|
|
||||||
|
// === Global Plugins ===
|
||||||
|
|
||||||
|
app.get(
|
||||||
|
'/plugins',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
querystring: Type.Object({
|
||||||
|
gameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request) => {
|
||||||
|
const { gameId } = request.query as { gameId?: string };
|
||||||
|
|
||||||
|
const rows = await app.db
|
||||||
|
.select({
|
||||||
|
id: plugins.id,
|
||||||
|
gameId: plugins.gameId,
|
||||||
|
name: plugins.name,
|
||||||
|
slug: plugins.slug,
|
||||||
|
description: plugins.description,
|
||||||
|
source: plugins.source,
|
||||||
|
isGlobal: plugins.isGlobal,
|
||||||
|
updatedAt: plugins.updatedAt,
|
||||||
|
gameName: games.name,
|
||||||
|
gameSlug: games.slug,
|
||||||
|
})
|
||||||
|
.from(plugins)
|
||||||
|
.innerJoin(games, eq(plugins.gameId, games.id))
|
||||||
|
.where(gameId ? eq(plugins.gameId, gameId) : undefined)
|
||||||
|
.orderBy(plugins.name);
|
||||||
|
|
||||||
|
return { data: rows };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/plugins', { schema: CreateGlobalPluginSchema }, async (request, reply) => {
|
||||||
|
const body = request.body as {
|
||||||
|
gameId: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
source?: 'manual' | 'spiget';
|
||||||
|
};
|
||||||
|
|
||||||
|
const game = await app.db.query.games.findFirst({
|
||||||
|
where: eq(games.id, body.gameId),
|
||||||
|
});
|
||||||
|
if (!game) throw AppError.notFound('Game not found');
|
||||||
|
|
||||||
|
const slug = toSlug(body.slug ?? body.name);
|
||||||
|
if (!slug) throw AppError.badRequest('Plugin slug is invalid');
|
||||||
|
|
||||||
|
const existing = await app.db.query.plugins.findFirst({
|
||||||
|
where: and(eq(plugins.gameId, body.gameId), eq(plugins.slug, slug)),
|
||||||
|
});
|
||||||
|
if (existing) throw AppError.conflict('Plugin slug already exists for this game');
|
||||||
|
|
||||||
|
const [created] = await app.db
|
||||||
|
.insert(plugins)
|
||||||
|
.values({
|
||||||
|
gameId: body.gameId,
|
||||||
|
name: body.name,
|
||||||
|
slug,
|
||||||
|
description: body.description ?? null,
|
||||||
|
source: body.source ?? 'manual',
|
||||||
|
isGlobal: true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return reply.code(201).send(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/plugins/import', { schema: ImportPluginsSchema }, async (request) => {
|
||||||
|
const body = request.body as {
|
||||||
|
defaultGameId?: string;
|
||||||
|
defaultGameSlug?: string;
|
||||||
|
stopOnError?: boolean;
|
||||||
|
items: Array<{
|
||||||
|
gameId?: string;
|
||||||
|
gameSlug?: string;
|
||||||
|
plugin: {
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
source?: 'manual' | 'spiget';
|
||||||
|
isGlobal?: boolean;
|
||||||
|
};
|
||||||
|
release?: {
|
||||||
|
version: string;
|
||||||
|
channel?: 'stable' | 'beta' | 'alpha';
|
||||||
|
artifactType?: 'file' | 'zip';
|
||||||
|
artifactUrl: string;
|
||||||
|
destination?: string;
|
||||||
|
fileName?: string;
|
||||||
|
changelog?: string;
|
||||||
|
installSchema?: unknown[];
|
||||||
|
configTemplates?: unknown[];
|
||||||
|
isPublished?: boolean;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const results: Array<{
|
||||||
|
index: number;
|
||||||
|
success: boolean;
|
||||||
|
gameId?: string;
|
||||||
|
gameSlug?: string;
|
||||||
|
pluginId?: string;
|
||||||
|
pluginSlug?: string;
|
||||||
|
pluginAction?: 'created' | 'updated';
|
||||||
|
releaseId?: string;
|
||||||
|
releaseVersion?: string;
|
||||||
|
releaseAction?: 'created' | 'updated' | 'skipped';
|
||||||
|
error?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const [index, item] of body.items.entries()) {
|
||||||
|
try {
|
||||||
|
const game = await resolveImportGame(app, {
|
||||||
|
gameId: item.gameId ?? body.defaultGameId,
|
||||||
|
gameSlug: item.gameSlug ?? body.defaultGameSlug,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pluginPayload = item.plugin;
|
||||||
|
const pluginSlug = toSlug(pluginPayload.slug ?? pluginPayload.name);
|
||||||
|
if (!pluginSlug) {
|
||||||
|
throw AppError.badRequest('Plugin slug is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPlugin = await app.db.query.plugins.findFirst({
|
||||||
|
where: and(eq(plugins.gameId, game.id), eq(plugins.slug, pluginSlug)),
|
||||||
|
});
|
||||||
|
|
||||||
|
let pluginRecord: typeof plugins.$inferSelect;
|
||||||
|
let pluginAction: 'created' | 'updated';
|
||||||
|
|
||||||
|
if (existingPlugin) {
|
||||||
|
const [updatedPlugin] = await app.db
|
||||||
|
.update(plugins)
|
||||||
|
.set({
|
||||||
|
name: pluginPayload.name,
|
||||||
|
slug: pluginSlug,
|
||||||
|
description:
|
||||||
|
pluginPayload.description !== undefined
|
||||||
|
? pluginPayload.description
|
||||||
|
: existingPlugin.description,
|
||||||
|
source: pluginPayload.source ?? existingPlugin.source,
|
||||||
|
isGlobal: pluginPayload.isGlobal ?? existingPlugin.isGlobal,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(plugins.id, existingPlugin.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedPlugin) {
|
||||||
|
throw AppError.notFound('Plugin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginRecord = updatedPlugin;
|
||||||
|
pluginAction = 'updated';
|
||||||
|
} else {
|
||||||
|
const [createdPlugin] = await app.db
|
||||||
|
.insert(plugins)
|
||||||
|
.values({
|
||||||
|
gameId: game.id,
|
||||||
|
name: pluginPayload.name,
|
||||||
|
slug: pluginSlug,
|
||||||
|
description: pluginPayload.description ?? null,
|
||||||
|
source: pluginPayload.source ?? 'manual',
|
||||||
|
isGlobal: pluginPayload.isGlobal ?? true,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!createdPlugin) {
|
||||||
|
throw new AppError(500, 'Failed to create plugin');
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginRecord = createdPlugin;
|
||||||
|
pluginAction = 'created';
|
||||||
|
}
|
||||||
|
|
||||||
|
let releaseAction: 'created' | 'updated' | 'skipped' = 'skipped';
|
||||||
|
let releaseRecord: typeof pluginReleases.$inferSelect | null = null;
|
||||||
|
|
||||||
|
if (item.release) {
|
||||||
|
const releasePayload = item.release;
|
||||||
|
const existingRelease = await app.db.query.pluginReleases.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(pluginReleases.pluginId, pluginRecord.id),
|
||||||
|
eq(pluginReleases.version, releasePayload.version),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRelease) {
|
||||||
|
const [updatedRelease] = await app.db
|
||||||
|
.update(pluginReleases)
|
||||||
|
.set({
|
||||||
|
channel: releasePayload.channel ?? existingRelease.channel,
|
||||||
|
artifactType: releasePayload.artifactType ?? existingRelease.artifactType,
|
||||||
|
artifactUrl: releasePayload.artifactUrl,
|
||||||
|
destination:
|
||||||
|
releasePayload.destination !== undefined
|
||||||
|
? releasePayload.destination
|
||||||
|
: existingRelease.destination,
|
||||||
|
fileName:
|
||||||
|
releasePayload.fileName !== undefined
|
||||||
|
? releasePayload.fileName
|
||||||
|
: existingRelease.fileName,
|
||||||
|
changelog:
|
||||||
|
releasePayload.changelog !== undefined
|
||||||
|
? releasePayload.changelog
|
||||||
|
: existingRelease.changelog,
|
||||||
|
installSchema: releasePayload.installSchema ?? existingRelease.installSchema,
|
||||||
|
configTemplates: releasePayload.configTemplates ?? existingRelease.configTemplates,
|
||||||
|
isPublished: releasePayload.isPublished ?? existingRelease.isPublished,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(pluginReleases.id, existingRelease.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updatedRelease) {
|
||||||
|
throw AppError.notFound('Plugin release not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseRecord = updatedRelease;
|
||||||
|
releaseAction = 'updated';
|
||||||
|
} else {
|
||||||
|
const [createdRelease] = await app.db
|
||||||
|
.insert(pluginReleases)
|
||||||
|
.values({
|
||||||
|
pluginId: pluginRecord.id,
|
||||||
|
version: releasePayload.version,
|
||||||
|
channel: releasePayload.channel ?? 'stable',
|
||||||
|
artifactType: releasePayload.artifactType ?? 'file',
|
||||||
|
artifactUrl: releasePayload.artifactUrl,
|
||||||
|
destination: releasePayload.destination ?? null,
|
||||||
|
fileName: releasePayload.fileName ?? null,
|
||||||
|
changelog: releasePayload.changelog ?? null,
|
||||||
|
installSchema: releasePayload.installSchema ?? [],
|
||||||
|
configTemplates: releasePayload.configTemplates ?? [],
|
||||||
|
isPublished: releasePayload.isPublished ?? true,
|
||||||
|
createdByUserId: request.user.sub,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!createdRelease) {
|
||||||
|
throw new AppError(500, 'Failed to create plugin release');
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseRecord = createdRelease;
|
||||||
|
releaseAction = 'created';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
index,
|
||||||
|
success: true,
|
||||||
|
gameId: game.id,
|
||||||
|
gameSlug: game.slug,
|
||||||
|
pluginId: pluginRecord.id,
|
||||||
|
pluginSlug: pluginRecord.slug,
|
||||||
|
pluginAction,
|
||||||
|
releaseId: releaseRecord?.id,
|
||||||
|
releaseVersion: releaseRecord?.version,
|
||||||
|
releaseAction,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (body.stopOnError) {
|
||||||
|
throw AppError.badRequest(`Import failed at item ${index}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
index,
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const succeeded = results.filter((result) => result.success).length;
|
||||||
|
const failed = results.length - succeeded;
|
||||||
|
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
summary: {
|
||||||
|
total: results.length,
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => {
|
||||||
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
source?: 'manual' | 'spiget';
|
||||||
|
isGlobal?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = await app.db.query.plugins.findFirst({
|
||||||
|
where: eq(plugins.id, pluginId),
|
||||||
|
});
|
||||||
|
if (!existing) throw AppError.notFound('Plugin not found');
|
||||||
|
|
||||||
|
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, existing.gameId), eq(plugins.slug, nextSlug)),
|
||||||
|
});
|
||||||
|
if (duplicate && duplicate.id !== existing.id) {
|
||||||
|
throw AppError.conflict('Plugin slug already exists for this game');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await app.db
|
||||||
|
.update(plugins)
|
||||||
|
.set({
|
||||||
|
name: body.name ?? existing.name,
|
||||||
|
slug: nextSlug,
|
||||||
|
description: body.description ?? existing.description,
|
||||||
|
source: body.source ?? existing.source,
|
||||||
|
isGlobal: body.isGlobal ?? existing.isGlobal,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(plugins.id, existing.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) throw AppError.notFound('Plugin not found');
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => {
|
||||||
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
|
|
||||||
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
|
where: eq(plugins.id, pluginId),
|
||||||
|
});
|
||||||
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||||
|
|
||||||
|
const releases = await app.db
|
||||||
|
.select()
|
||||||
|
.from(pluginReleases)
|
||||||
|
.where(eq(pluginReleases.pluginId, pluginId))
|
||||||
|
.orderBy(desc(pluginReleases.createdAt));
|
||||||
|
|
||||||
|
return { plugin, releases };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => {
|
||||||
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
|
|
||||||
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
|
where: eq(plugins.id, pluginId),
|
||||||
|
});
|
||||||
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||||
|
|
||||||
|
if (!request.isMultipart()) {
|
||||||
|
throw AppError.badRequest('Content-Type must be multipart/form-data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: Record<string, unknown> = {};
|
||||||
|
const files: UploadArtifactFile[] = [];
|
||||||
|
let installSchemaFile: UploadJsonFile | null = null;
|
||||||
|
let configTemplatesFile: UploadJsonFile | null = null;
|
||||||
|
const relativePathQueue: string[] = [];
|
||||||
|
|
||||||
|
for await (const part of request.parts()) {
|
||||||
|
if (part.type === 'file') {
|
||||||
|
if (part.fieldname === 'installSchemaFile') {
|
||||||
|
const data = await part.toBuffer();
|
||||||
|
if (data.length > 0) {
|
||||||
|
installSchemaFile = {
|
||||||
|
filename: part.filename || 'install-schema.json',
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.fieldname === 'configTemplatesFile') {
|
||||||
|
const data = await part.toBuffer();
|
||||||
|
if (data.length > 0) {
|
||||||
|
configTemplatesFile = {
|
||||||
|
filename: part.filename || 'config-templates.json',
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackName = `artifact-${files.length + 1}.bin`;
|
||||||
|
const queuedPath = relativePathQueue.shift();
|
||||||
|
const relativePath = normalizeRelativePath(
|
||||||
|
queuedPath ?? part.filename ?? '',
|
||||||
|
fallbackName,
|
||||||
|
);
|
||||||
|
const data = await part.toBuffer();
|
||||||
|
if (data.length === 0) continue;
|
||||||
|
files.push({ relativePath, data });
|
||||||
|
} else {
|
||||||
|
if (part.fieldname === 'relativePath') {
|
||||||
|
const raw = typeof part.value === 'string' ? part.value : '';
|
||||||
|
relativePathQueue.push(raw);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields[part.fieldname] = part.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw AppError.badRequest('At least one file is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = typeof fields.version === 'string' ? fields.version.trim() : '';
|
||||||
|
if (!version) {
|
||||||
|
throw AppError.badRequest('version is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = parseReleaseChannel(fields.channel);
|
||||||
|
const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0
|
||||||
|
? fields.destination.trim()
|
||||||
|
: null;
|
||||||
|
const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0
|
||||||
|
? fields.changelog
|
||||||
|
: null;
|
||||||
|
const isPublished = parseOptionalBoolean(fields.isPublished) ?? true;
|
||||||
|
const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema');
|
||||||
|
const configTemplates = parseJsonArrayInput(
|
||||||
|
fields.configTemplates,
|
||||||
|
configTemplatesFile,
|
||||||
|
'configTemplates',
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : '';
|
||||||
|
const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/'));
|
||||||
|
const shouldZip = files.length > 1 || hasNestedPaths;
|
||||||
|
|
||||||
|
let artifactType: 'file' | 'zip';
|
||||||
|
let artifactContent: Buffer;
|
||||||
|
let uploadFileName: string;
|
||||||
|
let releaseFileName: string | null;
|
||||||
|
|
||||||
|
if (shouldZip) {
|
||||||
|
artifactType = 'zip';
|
||||||
|
artifactContent = await zipArtifacts(files);
|
||||||
|
|
||||||
|
const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`;
|
||||||
|
uploadFileName = suggestedName.toLowerCase().endsWith('.zip')
|
||||||
|
? suggestedName
|
||||||
|
: `${suggestedName}.zip`;
|
||||||
|
releaseFileName = null;
|
||||||
|
} else {
|
||||||
|
artifactType = 'file';
|
||||||
|
const [singleFile] = files;
|
||||||
|
if (!singleFile) {
|
||||||
|
throw AppError.badRequest('No artifact file received');
|
||||||
|
}
|
||||||
|
|
||||||
|
artifactContent = singleFile.data;
|
||||||
|
const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin';
|
||||||
|
uploadFileName = rawFileName || originalName;
|
||||||
|
releaseFileName = uploadFileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, {
|
||||||
|
pluginId: plugin.id,
|
||||||
|
pluginSlug: plugin.slug,
|
||||||
|
releaseVersion: version,
|
||||||
|
uploadedBy: request.user.sub,
|
||||||
|
uploadMode: shouldZip ? 'archive' : 'single',
|
||||||
|
sourceFileCount: files.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [created] = await app.db
|
||||||
|
.insert(pluginReleases)
|
||||||
|
.values({
|
||||||
|
pluginId: plugin.id,
|
||||||
|
version,
|
||||||
|
channel,
|
||||||
|
artifactType,
|
||||||
|
artifactUrl: uploaded.artifactPointer,
|
||||||
|
destination,
|
||||||
|
fileName: releaseFileName,
|
||||||
|
changelog,
|
||||||
|
installSchema,
|
||||||
|
configTemplates,
|
||||||
|
isPublished,
|
||||||
|
createdByUserId: request.user.sub,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return reply.code(201).send({
|
||||||
|
release: created,
|
||||||
|
artifact: {
|
||||||
|
bucket: uploaded.bucket,
|
||||||
|
fileId: uploaded.file.id,
|
||||||
|
storedName: uploaded.file.storedName,
|
||||||
|
originalName: uploaded.file.originalName,
|
||||||
|
pointer: uploaded.artifactPointer,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => {
|
||||||
|
const { pluginId } = request.params as { pluginId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
version: string;
|
||||||
|
channel?: 'stable' | 'beta' | 'alpha';
|
||||||
|
artifactType?: 'file' | 'zip';
|
||||||
|
artifactUrl: string;
|
||||||
|
destination?: string;
|
||||||
|
fileName?: string;
|
||||||
|
changelog?: string;
|
||||||
|
installSchema?: unknown[];
|
||||||
|
configTemplates?: unknown[];
|
||||||
|
isPublished?: boolean;
|
||||||
|
cloneFromReleaseId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const plugin = await app.db.query.plugins.findFirst({
|
||||||
|
where: eq(plugins.id, pluginId),
|
||||||
|
});
|
||||||
|
if (!plugin) throw AppError.notFound('Plugin not found');
|
||||||
|
|
||||||
|
let baseRelease: typeof pluginReleases.$inferSelect | null = null;
|
||||||
|
if (body.cloneFromReleaseId) {
|
||||||
|
baseRelease = await app.db.query.pluginReleases.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(pluginReleases.id, body.cloneFromReleaseId),
|
||||||
|
eq(pluginReleases.pluginId, pluginId),
|
||||||
|
),
|
||||||
|
}) ?? null;
|
||||||
|
if (!baseRelease) {
|
||||||
|
throw AppError.notFound('Clone source release not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [created] = await app.db
|
||||||
|
.insert(pluginReleases)
|
||||||
|
.values({
|
||||||
|
pluginId,
|
||||||
|
version: body.version,
|
||||||
|
channel: body.channel ?? baseRelease?.channel ?? 'stable',
|
||||||
|
artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file',
|
||||||
|
artifactUrl: body.artifactUrl,
|
||||||
|
destination: body.destination ?? baseRelease?.destination ?? null,
|
||||||
|
fileName: body.fileName ?? baseRelease?.fileName ?? null,
|
||||||
|
changelog: body.changelog ?? baseRelease?.changelog ?? null,
|
||||||
|
installSchema: body.installSchema ?? baseRelease?.installSchema ?? [],
|
||||||
|
configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [],
|
||||||
|
isPublished: body.isPublished ?? baseRelease?.isPublished ?? true,
|
||||||
|
createdByUserId: request.user.sub,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return reply.code(201).send(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch(
|
||||||
|
'/plugins/:pluginId/releases/:releaseId',
|
||||||
|
{ schema: { ...PluginReleaseIdParamSchema, ...UpdatePluginReleaseSchema } },
|
||||||
|
async (request) => {
|
||||||
|
const { pluginId, releaseId } = request.params as { pluginId: string; releaseId: string };
|
||||||
|
const body = request.body as {
|
||||||
|
version?: string;
|
||||||
|
channel?: 'stable' | 'beta' | 'alpha';
|
||||||
|
artifactType?: 'file' | 'zip';
|
||||||
|
artifactUrl?: string;
|
||||||
|
destination?: string;
|
||||||
|
fileName?: string;
|
||||||
|
changelog?: string;
|
||||||
|
installSchema?: unknown[];
|
||||||
|
configTemplates?: unknown[];
|
||||||
|
isPublished?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = await app.db.query.pluginReleases.findFirst({
|
||||||
|
where: and(eq(pluginReleases.id, releaseId), eq(pluginReleases.pluginId, pluginId)),
|
||||||
|
});
|
||||||
|
if (!release) throw AppError.notFound('Plugin release not found');
|
||||||
|
|
||||||
|
const [updated] = await app.db
|
||||||
|
.update(pluginReleases)
|
||||||
|
.set({
|
||||||
|
version: body.version ?? release.version,
|
||||||
|
channel: body.channel ?? release.channel,
|
||||||
|
artifactType: body.artifactType ?? release.artifactType,
|
||||||
|
artifactUrl: body.artifactUrl ?? release.artifactUrl,
|
||||||
|
destination: body.destination ?? release.destination,
|
||||||
|
fileName: body.fileName ?? release.fileName,
|
||||||
|
changelog: body.changelog ?? release.changelog,
|
||||||
|
installSchema: body.installSchema ?? release.installSchema,
|
||||||
|
configTemplates: body.configTemplates ?? release.configTemplates,
|
||||||
|
isPublished: body.isPublished ?? release.isPublished,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(pluginReleases.id, release.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) throw AppError.notFound('Plugin release not found');
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// GET /api/admin/nodes
|
// GET /api/admin/nodes
|
||||||
app.get('/nodes', async () => {
|
app.get('/nodes', async () => {
|
||||||
const nodeList = await app.db
|
const nodeList = await app.db
|
||||||
|
|
|
||||||
|
|
@ -32,3 +32,132 @@ export const GameIdParamSchema = {
|
||||||
gameId: Type.String({ format: 'uuid' }),
|
gameId: Type.String({ format: 'uuid' }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const PluginIdParamSchema = {
|
||||||
|
params: Type.Object({
|
||||||
|
pluginId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PluginReleaseIdParamSchema = {
|
||||||
|
params: Type.Object({
|
||||||
|
pluginId: Type.String({ format: 'uuid' }),
|
||||||
|
releaseId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateGlobalPluginSchema = {
|
||||||
|
body: Type.Object({
|
||||||
|
gameId: Type.String({ format: 'uuid' }),
|
||||||
|
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
description: Type.Optional(Type.String()),
|
||||||
|
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpdateGlobalPluginSchema = {
|
||||||
|
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()),
|
||||||
|
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||||
|
isGlobal: Type.Optional(Type.Boolean()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportPluginPayloadSchema = Type.Object({
|
||||||
|
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
slug: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
description: Type.Optional(Type.String()),
|
||||||
|
source: Type.Optional(Type.Union([Type.Literal('manual'), Type.Literal('spiget')])),
|
||||||
|
isGlobal: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReleaseInstallFieldSchema = Type.Object({
|
||||||
|
key: Type.String({ minLength: 1, maxLength: 120 }),
|
||||||
|
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
type: Type.Union([
|
||||||
|
Type.Literal('text'),
|
||||||
|
Type.Literal('number'),
|
||||||
|
Type.Literal('boolean'),
|
||||||
|
Type.Literal('select'),
|
||||||
|
]),
|
||||||
|
description: Type.Optional(Type.String({ maxLength: 1000 })),
|
||||||
|
required: Type.Optional(Type.Boolean()),
|
||||||
|
defaultValue: Type.Optional(Type.Any()),
|
||||||
|
options: Type.Optional(Type.Array(Type.Object({
|
||||||
|
label: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
value: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
}))),
|
||||||
|
min: Type.Optional(Type.Number()),
|
||||||
|
max: Type.Optional(Type.Number()),
|
||||||
|
pattern: Type.Optional(Type.String({ maxLength: 500 })),
|
||||||
|
secret: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReleaseTemplateSchema = Type.Object({
|
||||||
|
path: Type.String({ minLength: 1 }),
|
||||||
|
content: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ImportPluginReleasePayloadSchema = Type.Object({
|
||||||
|
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||||
|
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||||
|
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||||
|
artifactUrl: Type.String({ format: 'uri' }),
|
||||||
|
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
changelog: Type.Optional(Type.String()),
|
||||||
|
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||||
|
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||||
|
isPublished: Type.Optional(Type.Boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ImportPluginsSchema = {
|
||||||
|
body: Type.Object({
|
||||||
|
defaultGameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||||
|
defaultGameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||||
|
stopOnError: Type.Optional(Type.Boolean()),
|
||||||
|
items: Type.Array(
|
||||||
|
Type.Object({
|
||||||
|
gameId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||||
|
gameSlug: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||||
|
plugin: ImportPluginPayloadSchema,
|
||||||
|
release: Type.Optional(ImportPluginReleasePayloadSchema),
|
||||||
|
}),
|
||||||
|
{ minItems: 1, maxItems: 500 },
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreatePluginReleaseSchema = {
|
||||||
|
body: Type.Object({
|
||||||
|
version: Type.String({ minLength: 1, maxLength: 100 }),
|
||||||
|
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||||
|
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||||
|
artifactUrl: Type.String({ format: 'uri' }),
|
||||||
|
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
changelog: Type.Optional(Type.String()),
|
||||||
|
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||||
|
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||||
|
isPublished: Type.Optional(Type.Boolean()),
|
||||||
|
cloneFromReleaseId: Type.Optional(Type.String({ format: 'uuid' })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpdatePluginReleaseSchema = {
|
||||||
|
body: Type.Object({
|
||||||
|
version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })),
|
||||||
|
channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])),
|
||||||
|
artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])),
|
||||||
|
artifactUrl: Type.Optional(Type.String({ format: 'uri' })),
|
||||||
|
destination: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
|
fileName: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
changelog: Type.Optional(Type.String()),
|
||||||
|
installSchema: Type.Optional(Type.Array(ReleaseInstallFieldSchema)),
|
||||||
|
configTemplates: Type.Optional(Type.Array(ReleaseTemplateSchema)),
|
||||||
|
isPublished: Type.Optional(Type.Boolean()),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,19 @@ function extractBearerToken(authHeader?: string): string | null {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractCdnWebhookSecret(request: FastifyRequest): string | null {
|
||||||
|
const byHeader = request.headers['x-cdn-webhook-secret'] ?? request.headers['x-webhook-secret'];
|
||||||
|
if (typeof byHeader === 'string' && byHeader.trim().length > 0) {
|
||||||
|
return byHeader.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = typeof request.headers.authorization === 'string'
|
||||||
|
? request.headers.authorization
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return extractBearerToken(authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
async function requireDaemonToken(
|
async function requireDaemonToken(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
|
|
@ -39,6 +52,36 @@ async function requireDaemonToken(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function internalRoutes(app: FastifyInstance) {
|
export default async function internalRoutes(app: FastifyInstance) {
|
||||||
|
app.post(
|
||||||
|
'/cdn/webhook/plugins',
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
body: Type.Optional(Type.Unknown()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const configuredSecret = process.env.CDN_WEBHOOK_SECRET?.trim();
|
||||||
|
if (configuredSecret) {
|
||||||
|
const providedSecret = extractCdnWebhookSecret(request);
|
||||||
|
if (!providedSecret || providedSecret !== configuredSecret) {
|
||||||
|
throw AppError.unauthorized('Invalid CDN webhook secret', 'CDN_WEBHOOK_AUTH_INVALID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = request.body as Record<string, unknown> | undefined;
|
||||||
|
const eventType = typeof body?.eventType === 'string'
|
||||||
|
? body.eventType
|
||||||
|
: (typeof body?.type === 'string' ? body.type : 'unknown');
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
{ eventType, payload: body },
|
||||||
|
'Received CDN plugin webhook event',
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.code(202).send({ accepted: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/schedules/due', async (request) => {
|
app.get('/schedules/due', async (request) => {
|
||||||
const node = await requireDaemonToken(app, request);
|
const node = await requireDaemonToken(app, request);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ import { AppError } from '../../lib/errors.js';
|
||||||
import { requirePermission } from '../../lib/permissions.js';
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
import { parseConfig, serializeConfig } from '../../lib/config-parsers.js';
|
||||||
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from '../../lib/daemon.js';
|
||||||
|
import {
|
||||||
|
isManagedCs2ServerConfigPath,
|
||||||
|
readManagedCs2ServerConfig,
|
||||||
|
writeManagedCs2ServerConfig,
|
||||||
|
} from '../../lib/cs2-server-config.js';
|
||||||
|
|
||||||
const ParamSchema = {
|
const ParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
|
|
@ -61,12 +66,16 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||||
};
|
};
|
||||||
await requirePermission(request, orgId, 'config.read');
|
await requirePermission(request, orgId, 'config.read');
|
||||||
|
|
||||||
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||||
|
|
||||||
let raw = '';
|
let raw = '';
|
||||||
try {
|
try {
|
||||||
|
if (isManagedCs2ServerConfigPath(game.slug, configFile.path)) {
|
||||||
|
raw = await readManagedCs2ServerConfig(node, server.uuid);
|
||||||
|
} else {
|
||||||
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
const file = await daemonReadFile(node, server.uuid, configFile.path);
|
||||||
raw = file.data.toString('utf8');
|
raw = file.data.toString('utf8');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingConfigFileError(error)) {
|
if (!isMissingConfigFileError(error)) {
|
||||||
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
|
app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon');
|
||||||
|
|
@ -109,13 +118,19 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||||
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
const { entries } = request.body as { entries: { key: string; value: string }[] };
|
||||||
await requirePermission(request, orgId, 'config.write');
|
await requirePermission(request, orgId, 'config.write');
|
||||||
|
|
||||||
const { server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex);
|
||||||
|
|
||||||
|
const isManagedCs2Config = isManagedCs2ServerConfigPath(game.slug, configFile.path);
|
||||||
|
|
||||||
let originalContent: string | undefined;
|
let originalContent: string | undefined;
|
||||||
let originalEntries: { key: string; value: string }[] = [];
|
let originalEntries: { key: string; value: string }[] = [];
|
||||||
try {
|
try {
|
||||||
|
if (isManagedCs2Config) {
|
||||||
|
originalContent = await readManagedCs2ServerConfig(node, server.uuid);
|
||||||
|
} else {
|
||||||
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);
|
originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isMissingConfigFileError(error)) {
|
if (!isMissingConfigFileError(error)) {
|
||||||
|
|
@ -146,7 +161,11 @@ export default async function configRoutes(app: FastifyInstance) {
|
||||||
originalContent,
|
originalContent,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isManagedCs2Config) {
|
||||||
|
await writeManagedCs2ServerConfig(node, server.uuid, content);
|
||||||
|
} else {
|
||||||
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
await daemonWriteFile(node, server.uuid, configFile.path, content);
|
||||||
|
}
|
||||||
return { success: true, path: configFile.path, content };
|
return { success: true, path: configFile.path, content };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,343 @@
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { Type } from '@sinclair/typebox';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { nodes, serverDatabases, servers } from '@source/database';
|
||||||
|
import { AppError } from '../../lib/errors.js';
|
||||||
|
import { requirePermission } from '../../lib/permissions.js';
|
||||||
|
import { createAuditLog } from '../../lib/audit.js';
|
||||||
|
import {
|
||||||
|
daemonCreateDatabase,
|
||||||
|
daemonDeleteDatabase,
|
||||||
|
daemonUpdateDatabasePassword,
|
||||||
|
type DaemonNodeConnection,
|
||||||
|
} from '../../lib/daemon.js';
|
||||||
|
|
||||||
|
const ServerDatabaseParamSchema = {
|
||||||
|
params: Type.Object({
|
||||||
|
orgId: Type.String({ format: 'uuid' }),
|
||||||
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
|
databaseId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerScopeSchema = {
|
||||||
|
params: Type.Object({
|
||||||
|
orgId: Type.String({ format: 'uuid' }),
|
||||||
|
serverId: Type.String({ format: 'uuid' }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const CreateServerDatabaseSchema = {
|
||||||
|
body: Type.Object({
|
||||||
|
name: Type.String({ minLength: 1, maxLength: 255 }),
|
||||||
|
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const UpdateServerDatabaseSchema = {
|
||||||
|
body: Type.Object({
|
||||||
|
name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })),
|
||||||
|
password: Type.Optional(Type.String({ minLength: 8, maxLength: 255 })),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string) {
|
||||||
|
const [server] = await app.db
|
||||||
|
.select({
|
||||||
|
id: servers.id,
|
||||||
|
name: servers.name,
|
||||||
|
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 server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeConnection(server: {
|
||||||
|
nodeDaemonToken: string;
|
||||||
|
nodeFqdn: string;
|
||||||
|
nodeGrpcPort: number;
|
||||||
|
}): DaemonNodeConnection {
|
||||||
|
return {
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daemonErrorMessage(error: unknown, fallback: string): string {
|
||||||
|
if (error instanceof Error && error.message.trim()) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function databaseRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook('onRequest', app.authenticate);
|
||||||
|
|
||||||
|
app.get('/', { schema: ServerScopeSchema }, async (request) => {
|
||||||
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
|
await requirePermission(request, orgId, 'server.read');
|
||||||
|
await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
|
const databases = await app.db
|
||||||
|
.select()
|
||||||
|
.from(serverDatabases)
|
||||||
|
.where(eq(serverDatabases.serverId, serverId))
|
||||||
|
.orderBy(serverDatabases.createdAt);
|
||||||
|
|
||||||
|
return { data: databases };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => {
|
||||||
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
|
await requirePermission(request, orgId, 'server.update');
|
||||||
|
|
||||||
|
const body = request.body as { name: string; password?: string };
|
||||||
|
const name = body.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
throw AppError.badRequest('Database name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
|
let managedDatabase;
|
||||||
|
try {
|
||||||
|
managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), {
|
||||||
|
name,
|
||||||
|
password: body.password,
|
||||||
|
serverUuid: server.uuid,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{ error, orgId, serverId, serverUuid: server.uuid },
|
||||||
|
'Failed to provision node-local MySQL database',
|
||||||
|
);
|
||||||
|
throw new AppError(
|
||||||
|
502,
|
||||||
|
daemonErrorMessage(error, 'Failed to provision node-local MySQL database'),
|
||||||
|
'MANAGED_MYSQL_CREATE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [created] = await app.db
|
||||||
|
.insert(serverDatabases)
|
||||||
|
.values({
|
||||||
|
serverId,
|
||||||
|
name,
|
||||||
|
databaseName: managedDatabase.databaseName,
|
||||||
|
username: managedDatabase.username,
|
||||||
|
password: managedDatabase.password,
|
||||||
|
host: managedDatabase.host,
|
||||||
|
port: managedDatabase.port,
|
||||||
|
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'server.database.create',
|
||||||
|
metadata: {
|
||||||
|
name: created!.name,
|
||||||
|
databaseName: created!.databaseName,
|
||||||
|
username: created!.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(201).send(created);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
await daemonDeleteDatabase(buildNodeConnection(server), {
|
||||||
|
databaseName: managedDatabase.databaseName,
|
||||||
|
username: managedDatabase.username,
|
||||||
|
});
|
||||||
|
} catch (cleanupError) {
|
||||||
|
request.log.error(
|
||||||
|
{ cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||||
|
'Failed to roll back node-local MySQL database after panel insert failure',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error(
|
||||||
|
{ error, orgId, serverId, databaseName: managedDatabase.databaseName },
|
||||||
|
'Failed to persist managed MySQL database metadata',
|
||||||
|
);
|
||||||
|
throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => {
|
||||||
|
const { orgId, serverId, databaseId } = request.params as {
|
||||||
|
databaseId: string;
|
||||||
|
orgId: string;
|
||||||
|
serverId: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'server.update');
|
||||||
|
|
||||||
|
const body = request.body as { name?: string; password?: string };
|
||||||
|
|
||||||
|
const [current] = await app.db
|
||||||
|
.select({
|
||||||
|
id: serverDatabases.id,
|
||||||
|
name: serverDatabases.name,
|
||||||
|
databaseName: serverDatabases.databaseName,
|
||||||
|
username: serverDatabases.username,
|
||||||
|
password: serverDatabases.password,
|
||||||
|
host: serverDatabases.host,
|
||||||
|
port: serverDatabases.port,
|
||||||
|
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
|
||||||
|
createdAt: serverDatabases.createdAt,
|
||||||
|
updatedAt: serverDatabases.updatedAt,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
})
|
||||||
|
.from(serverDatabases)
|
||||||
|
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(serverDatabases.id, databaseId),
|
||||||
|
eq(serverDatabases.serverId, serverId),
|
||||||
|
eq(servers.organizationId, orgId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw AppError.notFound('Database not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextName = body.name === undefined ? undefined : body.name.trim();
|
||||||
|
if (body.name !== undefined && !nextName) {
|
||||||
|
throw AppError.badRequest('Database name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPassword = body.password?.trim();
|
||||||
|
if (!nextName && !nextPassword) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextPassword) {
|
||||||
|
try {
|
||||||
|
await daemonUpdateDatabasePassword(buildNodeConnection(current), {
|
||||||
|
password: nextPassword,
|
||||||
|
username: current.username,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{ error, orgId, serverId, databaseId, username: current.username },
|
||||||
|
'Failed to rotate node-local MySQL password',
|
||||||
|
);
|
||||||
|
throw new AppError(
|
||||||
|
502,
|
||||||
|
daemonErrorMessage(error, 'Failed to rotate database password'),
|
||||||
|
'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (nextName) patch.name = nextName;
|
||||||
|
if (nextPassword) patch.password = nextPassword;
|
||||||
|
|
||||||
|
const [updated] = await app.db
|
||||||
|
.update(serverDatabases)
|
||||||
|
.set(patch)
|
||||||
|
.where(eq(serverDatabases.id, databaseId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'server.database.update',
|
||||||
|
metadata: {
|
||||||
|
databaseId,
|
||||||
|
updatedName: nextName ?? undefined,
|
||||||
|
passwordRotated: Boolean(nextPassword),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => {
|
||||||
|
const { orgId, serverId, databaseId } = request.params as {
|
||||||
|
databaseId: string;
|
||||||
|
orgId: string;
|
||||||
|
serverId: string;
|
||||||
|
};
|
||||||
|
await requirePermission(request, orgId, 'server.update');
|
||||||
|
|
||||||
|
const [current] = await app.db
|
||||||
|
.select({
|
||||||
|
id: serverDatabases.id,
|
||||||
|
name: serverDatabases.name,
|
||||||
|
databaseName: serverDatabases.databaseName,
|
||||||
|
username: serverDatabases.username,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
})
|
||||||
|
.from(serverDatabases)
|
||||||
|
.innerJoin(servers, eq(serverDatabases.serverId, servers.id))
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(serverDatabases.id, databaseId),
|
||||||
|
eq(serverDatabases.serverId, serverId),
|
||||||
|
eq(servers.organizationId, orgId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!current) {
|
||||||
|
throw AppError.notFound('Database not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await daemonDeleteDatabase(buildNodeConnection(current), {
|
||||||
|
databaseName: current.databaseName,
|
||||||
|
username: current.username,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error(
|
||||||
|
{ error, orgId, serverId, databaseId, databaseName: current.databaseName },
|
||||||
|
'Failed to delete node-local MySQL database',
|
||||||
|
);
|
||||||
|
throw new AppError(
|
||||||
|
502,
|
||||||
|
daemonErrorMessage(error, 'Failed to delete node-local MySQL database'),
|
||||||
|
'MANAGED_MYSQL_DELETE_FAILED',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, databaseId));
|
||||||
|
|
||||||
|
await createAuditLog(app.db, request, {
|
||||||
|
organizationId: orgId,
|
||||||
|
serverId,
|
||||||
|
action: 'server.database.delete',
|
||||||
|
metadata: {
|
||||||
|
databaseId,
|
||||||
|
name: current.name,
|
||||||
|
databaseName: current.databaseName,
|
||||||
|
username: current.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,13 @@ import {
|
||||||
daemonWriteFile,
|
daemonWriteFile,
|
||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
} from '../../lib/daemon.js';
|
} from '../../lib/daemon.js';
|
||||||
|
import {
|
||||||
|
CS2_PERSISTED_SERVER_CFG_PATH,
|
||||||
|
CS2_PERSISTED_SERVER_CFG_FILE,
|
||||||
|
isManagedCs2ServerConfigPath,
|
||||||
|
readManagedCs2ServerConfig,
|
||||||
|
writeManagedCs2ServerConfig,
|
||||||
|
} from '../../lib/cs2-server-config.js';
|
||||||
|
|
||||||
const FileParamSchema = {
|
const FileParamSchema = {
|
||||||
params: Type.Object({
|
params: Type.Object({
|
||||||
|
|
@ -21,6 +28,7 @@ const FileParamSchema = {
|
||||||
|
|
||||||
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
|
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
|
||||||
if (gameSlug !== 'cs2') return false;
|
if (gameSlug !== 'cs2') return false;
|
||||||
|
if (fileName.trim() === CS2_PERSISTED_SERVER_CFG_FILE) return true;
|
||||||
if (isDirectory) return false;
|
if (isDirectory) return false;
|
||||||
|
|
||||||
const normalizedName = fileName.trim().toLowerCase();
|
const normalizedName = fileName.trim().toLowerCase();
|
||||||
|
|
@ -96,16 +104,28 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||||
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 requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8';
|
const requestedEncoding = encoding === 'base64' ? 'base64' : 'utf8';
|
||||||
|
let payload: Buffer;
|
||||||
|
let mimeType = 'text/plain';
|
||||||
|
|
||||||
|
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
||||||
|
payload = Buffer.from(
|
||||||
|
await readManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const content = await daemonReadFile(serverContext.node, serverContext.serverUuid, path);
|
||||||
|
payload = content.data;
|
||||||
|
mimeType = content.mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data:
|
data:
|
||||||
requestedEncoding === 'base64'
|
requestedEncoding === 'base64'
|
||||||
? content.data.toString('base64')
|
? payload.toString('base64')
|
||||||
: content.data.toString('utf8'),
|
: payload.toString('utf8'),
|
||||||
encoding: requestedEncoding,
|
encoding: requestedEncoding,
|
||||||
mimeType: content.mimeType,
|
mimeType,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -136,7 +156,11 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
const payload = encoding === 'base64' ? decodeBase64Payload(data) : data;
|
||||||
|
|
||||||
|
if (isManagedCs2ServerConfigPath(serverContext.gameSlug, path)) {
|
||||||
|
await writeManagedCs2ServerConfig(serverContext.node, serverContext.serverUuid, payload);
|
||||||
|
} else {
|
||||||
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
await daemonWriteFile(serverContext.node, serverContext.serverUuid, path, payload);
|
||||||
|
}
|
||||||
return { success: true, path };
|
return { success: true, path };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -158,7 +182,18 @@ export default async function fileRoutes(app: FastifyInstance) {
|
||||||
await requirePermission(request, orgId, 'files.delete');
|
await requirePermission(request, orgId, 'files.delete');
|
||||||
const serverContext = await getServerContext(app, orgId, serverId);
|
const serverContext = await getServerContext(app, orgId, serverId);
|
||||||
|
|
||||||
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, paths);
|
const resolvedPaths = paths.flatMap((path) =>
|
||||||
|
isManagedCs2ServerConfigPath(serverContext.gameSlug, path)
|
||||||
|
? [
|
||||||
|
path,
|
||||||
|
path.trim().startsWith('/')
|
||||||
|
? `/${CS2_PERSISTED_SERVER_CFG_PATH}`
|
||||||
|
: CS2_PERSISTED_SERVER_CFG_PATH,
|
||||||
|
]
|
||||||
|
: [path],
|
||||||
|
);
|
||||||
|
|
||||||
|
await daemonDeleteFiles(serverContext.node, serverContext.serverUuid, resolvedPaths);
|
||||||
return { success: true, paths };
|
return { success: true, paths };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ 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, serverDatabases } from '@source/database';
|
||||||
import type { GameAutomationRule, PowerAction, ServerAutomationEvent } 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';
|
||||||
|
|
@ -15,9 +15,11 @@ import {
|
||||||
daemonDeleteServer,
|
daemonDeleteServer,
|
||||||
daemonGetServerStatus,
|
daemonGetServerStatus,
|
||||||
daemonSetPowerState,
|
daemonSetPowerState,
|
||||||
|
daemonUpdateServer,
|
||||||
type DaemonNodeConnection,
|
type DaemonNodeConnection,
|
||||||
type DaemonPortMapping,
|
type DaemonPortMapping,
|
||||||
} from '../../lib/daemon.js';
|
} from '../../lib/daemon.js';
|
||||||
|
import { reapplyManagedCs2ServerConfig } from '../../lib/cs2-server-config.js';
|
||||||
import {
|
import {
|
||||||
ServerParamSchema,
|
ServerParamSchema,
|
||||||
CreateServerSchema,
|
CreateServerSchema,
|
||||||
|
|
@ -30,8 +32,10 @@ import pluginRoutes from './plugins.js';
|
||||||
import playerRoutes from './players.js';
|
import playerRoutes from './players.js';
|
||||||
import scheduleRoutes from './schedules.js';
|
import scheduleRoutes from './schedules.js';
|
||||||
import backupRoutes from './backups.js';
|
import backupRoutes from './backups.js';
|
||||||
|
import databaseRoutes from './databases.js';
|
||||||
|
|
||||||
type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error';
|
type MutableServerStatus = 'installing' | 'running' | 'stopped' | 'error';
|
||||||
|
type RuntimeServerStatus = MutableServerStatus | 'starting' | 'stopping' | 'suspended';
|
||||||
|
|
||||||
function mapDaemonStatus(rawStatus: string): MutableServerStatus | null {
|
function mapDaemonStatus(rawStatus: string): MutableServerStatus | null {
|
||||||
switch (rawStatus.toLowerCase()) {
|
switch (rawStatus.toLowerCase()) {
|
||||||
|
|
@ -52,6 +56,27 @@ function mapDaemonStatus(rawStatus: string): MutableServerStatus | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRuntimeServerStatus(rawStatus: string): RuntimeServerStatus | null {
|
||||||
|
switch (rawStatus.toLowerCase()) {
|
||||||
|
case 'installing':
|
||||||
|
return 'installing';
|
||||||
|
case 'running':
|
||||||
|
return 'running';
|
||||||
|
case 'stopped':
|
||||||
|
return 'stopped';
|
||||||
|
case 'starting':
|
||||||
|
return 'starting';
|
||||||
|
case 'stopping':
|
||||||
|
return 'stopping';
|
||||||
|
case 'error':
|
||||||
|
return 'error';
|
||||||
|
case 'suspended':
|
||||||
|
return 'suspended';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildDaemonEnvironment(
|
function buildDaemonEnvironment(
|
||||||
gameEnvVarsRaw: unknown,
|
gameEnvVarsRaw: unknown,
|
||||||
overrides: Record<string, string> | undefined,
|
overrides: Record<string, string> | undefined,
|
||||||
|
|
@ -63,6 +88,7 @@ function buildDaemonEnvironment(
|
||||||
for (const item of gameEnvVarsRaw) {
|
for (const item of gameEnvVarsRaw) {
|
||||||
if (!item || typeof item !== 'object') continue;
|
if (!item || typeof item !== 'object') continue;
|
||||||
const record = item as Record<string, unknown>;
|
const record = item as Record<string, unknown>;
|
||||||
|
if (typeof record.composeInto === 'string' && record.composeInto.trim()) continue;
|
||||||
const key = typeof record.key === 'string' ? record.key.trim() : '';
|
const key = typeof record.key === 'string' ? record.key.trim() : '';
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
|
|
||||||
|
|
@ -99,6 +125,34 @@ function buildDaemonPorts(gameSlug: string, allocationPort: number, containerPor
|
||||||
return [{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }];
|
return [{ host_port: allocationPort, container_port: containerPort, protocol: 'tcp' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeEnvironmentOverrides(raw: unknown): Record<string, string> {
|
||||||
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||||
|
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||||
|
const normalizedKey = key.trim();
|
||||||
|
if (!normalizedKey) continue;
|
||||||
|
normalized[normalizedKey] = String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEnvironmentOverrides(
|
||||||
|
left: Record<string, string>,
|
||||||
|
right: Record<string, string>,
|
||||||
|
): boolean {
|
||||||
|
const leftEntries = Object.entries(left);
|
||||||
|
const rightEntries = Object.entries(right);
|
||||||
|
if (leftEntries.length !== rightEntries.length) return false;
|
||||||
|
|
||||||
|
for (const [key, value] of leftEntries) {
|
||||||
|
if (right[key] !== value) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function syncServerInstallStatus(
|
async function syncServerInstallStatus(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
node: DaemonNodeConnection,
|
node: DaemonNodeConnection,
|
||||||
|
|
@ -162,6 +216,32 @@ async function syncServerInstallStatus(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sustainCs2ServerConfigAfterPowerStart(
|
||||||
|
app: FastifyInstance,
|
||||||
|
node: DaemonNodeConnection,
|
||||||
|
serverId: string,
|
||||||
|
serverUuid: string,
|
||||||
|
gameSlug: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (gameSlug.trim().toLowerCase() !== 'cs2') return;
|
||||||
|
|
||||||
|
const attempts = 6;
|
||||||
|
const intervalMs = 10_000;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
await sleep(intervalMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reapplyManagedCs2ServerConfig(node, serverUuid);
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn(
|
||||||
|
{ error, serverId, serverUuid, attempt },
|
||||||
|
'Failed to reapply managed CS2 server.cfg after power start',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function serverRoutes(app: FastifyInstance) {
|
export default async function serverRoutes(app: FastifyInstance) {
|
||||||
app.addHook('onRequest', app.authenticate);
|
app.addHook('onRequest', app.authenticate);
|
||||||
|
|
||||||
|
|
@ -172,6 +252,7 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
await app.register(playerRoutes, { prefix: '/:serverId/players' });
|
await app.register(playerRoutes, { prefix: '/:serverId/players' });
|
||||||
await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' });
|
await app.register(scheduleRoutes, { prefix: '/:serverId/schedules' });
|
||||||
await app.register(backupRoutes, { prefix: '/:serverId/backups' });
|
await app.register(backupRoutes, { prefix: '/:serverId/backups' });
|
||||||
|
await app.register(databaseRoutes, { prefix: '/:serverId/databases' });
|
||||||
|
|
||||||
// GET /api/organizations/:orgId/servers
|
// GET /api/organizations/:orgId/servers
|
||||||
app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => {
|
||||||
|
|
@ -463,6 +544,8 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
nodeId: nodes.id,
|
nodeId: nodes.id,
|
||||||
nodeName: nodes.name,
|
nodeName: nodes.name,
|
||||||
nodeFqdn: nodes.fqdn,
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
gameId: games.id,
|
gameId: games.id,
|
||||||
gameName: games.name,
|
gameName: games.name,
|
||||||
gameSlug: games.slug,
|
gameSlug: games.slug,
|
||||||
|
|
@ -474,7 +557,55 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
|
|
||||||
if (!server) throw AppError.notFound('Server not found');
|
if (!server) throw AppError.notFound('Server not found');
|
||||||
|
|
||||||
return server;
|
let liveStatus: RuntimeServerStatus = server.status;
|
||||||
|
|
||||||
|
if (server.status !== 'suspended') {
|
||||||
|
try {
|
||||||
|
const daemonStatus = await daemonGetServerStatus(
|
||||||
|
{
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
server.uuid,
|
||||||
|
{
|
||||||
|
connectTimeoutMs: 1_500,
|
||||||
|
rpcTimeoutMs: 2_500,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const normalized = normalizeRuntimeServerStatus(daemonStatus.state);
|
||||||
|
if (normalized) {
|
||||||
|
liveStatus = normalized;
|
||||||
|
|
||||||
|
if (normalized !== 'starting' && normalized !== 'stopping') {
|
||||||
|
const persistedStatus = mapDaemonStatus(normalized);
|
||||||
|
if (persistedStatus && persistedStatus !== server.status) {
|
||||||
|
await app.db
|
||||||
|
.update(servers)
|
||||||
|
.set({
|
||||||
|
status: persistedStatus,
|
||||||
|
installedAt: persistedStatus === 'running' || persistedStatus === 'stopped'
|
||||||
|
? (server.installedAt ?? new Date())
|
||||||
|
: server.installedAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(servers.id, server.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn(
|
||||||
|
{ error, serverId: server.id, serverUuid: server.uuid },
|
||||||
|
'Failed to fetch live daemon status for server detail',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodeGrpcPort: _nodeGrpcPort, nodeDaemonToken: _nodeDaemonToken, ...response } = server;
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
status: liveStatus,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/organizations/:orgId/servers/:serverId
|
// PATCH /api/organizations/:orgId/servers/:serverId
|
||||||
|
|
@ -482,21 +613,121 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
await requirePermission(request, orgId, 'server.update');
|
await requirePermission(request, orgId, 'server.update');
|
||||||
|
|
||||||
const body = request.body as Record<string, unknown>;
|
const body = request.body as {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
memoryLimit?: number;
|
||||||
|
diskLimit?: number;
|
||||||
|
cpuLimit?: number;
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
startupOverride?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [current] = await app.db
|
||||||
|
.select({
|
||||||
|
id: servers.id,
|
||||||
|
uuid: servers.uuid,
|
||||||
|
organizationId: servers.organizationId,
|
||||||
|
name: servers.name,
|
||||||
|
description: servers.description,
|
||||||
|
status: servers.status,
|
||||||
|
memoryLimit: servers.memoryLimit,
|
||||||
|
diskLimit: servers.diskLimit,
|
||||||
|
cpuLimit: servers.cpuLimit,
|
||||||
|
port: servers.port,
|
||||||
|
environment: servers.environment,
|
||||||
|
startupOverride: servers.startupOverride,
|
||||||
|
nodeFqdn: nodes.fqdn,
|
||||||
|
nodeGrpcPort: nodes.grpcPort,
|
||||||
|
nodeDaemonToken: nodes.daemonToken,
|
||||||
|
gameDockerImage: games.dockerImage,
|
||||||
|
gameDefaultPort: games.defaultPort,
|
||||||
|
gameSlug: games.slug,
|
||||||
|
gameStartupCommand: games.startupCommand,
|
||||||
|
gameEnvironmentVars: games.environmentVars,
|
||||||
|
})
|
||||||
|
.from(servers)
|
||||||
|
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
|
||||||
|
.innerJoin(games, eq(servers.gameId, games.id))
|
||||||
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
|
||||||
|
|
||||||
|
if (!current) throw AppError.notFound('Server not found');
|
||||||
|
|
||||||
|
const nextMemoryLimit = body.memoryLimit ?? current.memoryLimit;
|
||||||
|
const nextDiskLimit = body.diskLimit ?? current.diskLimit;
|
||||||
|
const nextCpuLimit = body.cpuLimit ?? current.cpuLimit;
|
||||||
|
const currentEnvironment = normalizeEnvironmentOverrides(current.environment);
|
||||||
|
const nextEnvironment = body.environment !== undefined
|
||||||
|
? normalizeEnvironmentOverrides(body.environment)
|
||||||
|
: currentEnvironment;
|
||||||
|
const nextStartupOverride = body.startupOverride !== undefined
|
||||||
|
? (body.startupOverride.trim() || null)
|
||||||
|
: (current.startupOverride ?? null);
|
||||||
|
|
||||||
|
const needsRuntimeRecreate =
|
||||||
|
nextMemoryLimit !== current.memoryLimit ||
|
||||||
|
nextDiskLimit !== current.diskLimit ||
|
||||||
|
nextCpuLimit !== current.cpuLimit ||
|
||||||
|
!sameEnvironmentOverrides(nextEnvironment, currentEnvironment) ||
|
||||||
|
nextStartupOverride !== (current.startupOverride ?? null);
|
||||||
|
|
||||||
|
let nextStatus: MutableServerStatus | null = null;
|
||||||
|
if (needsRuntimeRecreate) {
|
||||||
|
try {
|
||||||
|
const response = await daemonUpdateServer(
|
||||||
|
{
|
||||||
|
fqdn: current.nodeFqdn,
|
||||||
|
grpcPort: current.nodeGrpcPort,
|
||||||
|
daemonToken: current.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
uuid: current.uuid,
|
||||||
|
docker_image: current.gameDockerImage,
|
||||||
|
memory_limit: nextMemoryLimit,
|
||||||
|
disk_limit: nextDiskLimit,
|
||||||
|
cpu_limit: nextCpuLimit,
|
||||||
|
startup_command: nextStartupOverride ?? current.gameStartupCommand,
|
||||||
|
environment: buildDaemonEnvironment(
|
||||||
|
current.gameEnvironmentVars,
|
||||||
|
nextEnvironment,
|
||||||
|
nextMemoryLimit,
|
||||||
|
),
|
||||||
|
ports: buildDaemonPorts(current.gameSlug, current.port, current.gameDefaultPort),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
nextStatus = mapDaemonStatus(response.status);
|
||||||
|
} catch (error) {
|
||||||
|
app.log.error(
|
||||||
|
{ error, serverId: current.id, serverUuid: current.uuid },
|
||||||
|
'Failed to update server on daemon',
|
||||||
|
);
|
||||||
|
throw new AppError(502, 'Failed to apply runtime changes on daemon', 'DAEMON_UPDATE_FAILED');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
if (body.name !== undefined) patch.name = body.name;
|
||||||
|
if (body.description !== undefined) patch.description = body.description;
|
||||||
|
if (body.memoryLimit !== undefined) patch.memoryLimit = nextMemoryLimit;
|
||||||
|
if (body.diskLimit !== undefined) patch.diskLimit = nextDiskLimit;
|
||||||
|
if (body.cpuLimit !== undefined) patch.cpuLimit = nextCpuLimit;
|
||||||
|
if (body.environment !== undefined) patch.environment = nextEnvironment;
|
||||||
|
if (body.startupOverride !== undefined) patch.startupOverride = nextStartupOverride;
|
||||||
|
if (nextStatus) patch.status = nextStatus;
|
||||||
|
|
||||||
const [updated] = await app.db
|
const [updated] = await app.db
|
||||||
.update(servers)
|
.update(servers)
|
||||||
.set({ ...body, updatedAt: new Date() })
|
.set(patch)
|
||||||
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)))
|
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updated) throw AppError.notFound('Server not found');
|
|
||||||
|
|
||||||
await createAuditLog(app.db, request, {
|
await createAuditLog(app.db, request, {
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
serverId,
|
serverId,
|
||||||
action: 'server.update',
|
action: 'server.update',
|
||||||
metadata: body,
|
metadata: patch,
|
||||||
});
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
|
|
@ -507,6 +738,15 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
const { orgId, serverId } = request.params as { orgId: string; serverId: string };
|
||||||
await requirePermission(request, orgId, 'server.delete');
|
await requirePermission(request, orgId, 'server.delete');
|
||||||
|
|
||||||
|
const [databaseUsage] = await app.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(serverDatabases)
|
||||||
|
.where(eq(serverDatabases.serverId, serverId));
|
||||||
|
|
||||||
|
if ((databaseUsage?.count ?? 0) > 0) {
|
||||||
|
throw AppError.conflict('Delete server databases before deleting the server');
|
||||||
|
}
|
||||||
|
|
||||||
const [server] = await app.db
|
const [server] = await app.db
|
||||||
.select({
|
.select({
|
||||||
id: servers.id,
|
id: servers.id,
|
||||||
|
|
@ -633,6 +873,18 @@ export default async function serverRoutes(app: FastifyInstance) {
|
||||||
.where(eq(servers.id, serverId));
|
.where(eq(servers.id, serverId));
|
||||||
|
|
||||||
if (serverWithGame) {
|
if (serverWithGame) {
|
||||||
|
void sustainCs2ServerConfigAfterPowerStart(
|
||||||
|
app,
|
||||||
|
{
|
||||||
|
fqdn: server.nodeFqdn,
|
||||||
|
grpcPort: server.nodeGrpcPort,
|
||||||
|
daemonToken: server.nodeDaemonToken,
|
||||||
|
},
|
||||||
|
serverId,
|
||||||
|
server.uuid,
|
||||||
|
serverWithGame.gameSlug,
|
||||||
|
);
|
||||||
|
|
||||||
void runServerAutomationEvent(app, {
|
void runServerAutomationEvent(app, {
|
||||||
serverId,
|
serverId,
|
||||||
serverUuid: server.uuid,
|
serverUuid: server.uuid,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -14,6 +14,7 @@ FROM debian:bookworm-slim AS production
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
libssl3 \
|
libssl3 \
|
||||||
|
mariadb-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use tokio::sync::{mpsc, oneshot, RwLock};
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::server::ServerManager;
|
||||||
|
|
||||||
|
const DEFAULT_QUEUE_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CommandJob {
|
||||||
|
command: String,
|
||||||
|
response_tx: oneshot::Sender<Result<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct WorkerHandle {
|
||||||
|
id: u64,
|
||||||
|
sender: mpsc::Sender<CommandJob>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommandDispatcher {
|
||||||
|
server_manager: Arc<ServerManager>,
|
||||||
|
workers: Arc<RwLock<HashMap<String, WorkerHandle>>>,
|
||||||
|
next_worker_id: Arc<AtomicU64>,
|
||||||
|
queue_capacity: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandDispatcher {
|
||||||
|
pub fn new(server_manager: Arc<ServerManager>) -> Self {
|
||||||
|
Self {
|
||||||
|
server_manager,
|
||||||
|
workers: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
next_worker_id: Arc::new(AtomicU64::new(1)),
|
||||||
|
queue_capacity: DEFAULT_QUEUE_CAPACITY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||||
|
let cmd = command.trim();
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return Err(anyhow!("Command cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry once if the current worker channel is unexpectedly closed.
|
||||||
|
for _ in 0..2 {
|
||||||
|
let worker = self.get_or_create_worker(server_uuid).await;
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
let job = CommandJob {
|
||||||
|
command: cmd.to_string(),
|
||||||
|
response_tx,
|
||||||
|
};
|
||||||
|
|
||||||
|
match worker.sender.send(job).await {
|
||||||
|
Ok(_) => {
|
||||||
|
return response_rx
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| Err(anyhow!("Command worker dropped response channel")));
|
||||||
|
}
|
||||||
|
Err(send_err) => {
|
||||||
|
warn!(
|
||||||
|
server_uuid = %server_uuid,
|
||||||
|
worker_id = worker.id,
|
||||||
|
error = %send_err,
|
||||||
|
"Command worker queue send failed, rotating worker",
|
||||||
|
);
|
||||||
|
self.remove_worker_if_matches(server_uuid, worker.id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!("Failed to dispatch command after retry"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_or_create_worker(&self, server_uuid: &str) -> WorkerHandle {
|
||||||
|
if let Some(existing) = self.workers.read().await.get(server_uuid).cloned() {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker_id = self.next_worker_id.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let (sender, receiver) = mpsc::channel::<CommandJob>(self.queue_capacity);
|
||||||
|
let handle = WorkerHandle {
|
||||||
|
id: worker_id,
|
||||||
|
sender: sender.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut workers = self.workers.write().await;
|
||||||
|
if let Some(existing) = workers.get(server_uuid).cloned() {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
workers.insert(server_uuid.to_string(), handle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.spawn_worker(server_uuid.to_string(), worker_id, receiver);
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_worker(
|
||||||
|
&self,
|
||||||
|
server_uuid: String,
|
||||||
|
worker_id: u64,
|
||||||
|
mut receiver: mpsc::Receiver<CommandJob>,
|
||||||
|
) {
|
||||||
|
let server_manager = self.server_manager.clone();
|
||||||
|
let workers = self.workers.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
debug!(server_uuid = %server_uuid, worker_id, "Command worker started");
|
||||||
|
|
||||||
|
while let Some(job) = receiver.recv().await {
|
||||||
|
let result = execute_command(server_manager.clone(), &server_uuid, &job.command).await;
|
||||||
|
let _ = job.response_tx.send(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut map = workers.write().await;
|
||||||
|
if let Some(current) = map.get(&server_uuid) {
|
||||||
|
if current.id == worker_id {
|
||||||
|
map.remove(&server_uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(server_uuid = %server_uuid, worker_id, "Command worker stopped");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_worker_if_matches(&self, server_uuid: &str, worker_id: u64) {
|
||||||
|
let mut workers = self.workers.write().await;
|
||||||
|
if let Some(current) = workers.get(server_uuid) {
|
||||||
|
if current.id == worker_id {
|
||||||
|
workers.remove(server_uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn execute_command(
|
||||||
|
server_manager: Arc<ServerManager>,
|
||||||
|
server_uuid: &str,
|
||||||
|
command: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
server_manager
|
||||||
|
.docker()
|
||||||
|
.send_command(server_uuid, command)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,8 @@ pub struct DaemonConfig {
|
||||||
pub data_path: PathBuf,
|
pub data_path: PathBuf,
|
||||||
#[serde(default = "default_backup_path")]
|
#[serde(default = "default_backup_path")]
|
||||||
pub backup_path: PathBuf,
|
pub backup_path: PathBuf,
|
||||||
|
#[serde(default)]
|
||||||
|
pub managed_mysql: Option<ManagedMysqlConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -36,6 +38,19 @@ impl Default for DockerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
pub struct ManagedMysqlConfig {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub connection_host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub connection_port: Option<u16>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phpmyadmin_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bin: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn default_grpc_port() -> u16 {
|
fn default_grpc_port() -> u16 {
|
||||||
50051
|
50051
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bollard::container::{
|
use bollard::container::{
|
||||||
Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
|
||||||
StopContainerOptions, StatsOptions, Stats,
|
StopContainerOptions, StatsOptions, Stats,
|
||||||
};
|
};
|
||||||
use bollard::image::CreateImageOptions;
|
use bollard::image::CreateImageOptions;
|
||||||
use bollard::models::{HostConfig, PortBinding};
|
use bollard::models::{HostConfig, PortBinding};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use tokio::time::{sleep, Duration};
|
use tokio::time::{sleep, Duration};
|
||||||
use tracing::info;
|
use tracing::{debug, info};
|
||||||
|
|
||||||
use crate::docker::DockerManager;
|
use crate::docker::DockerManager;
|
||||||
use crate::server::ServerSpec;
|
use crate::server::ServerSpec;
|
||||||
|
|
@ -33,6 +33,65 @@ fn container_data_path_for_image(image: &str) -> &'static str {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DockerManager {
|
impl DockerManager {
|
||||||
|
async fn attach_command_stream(
|
||||||
|
&self,
|
||||||
|
container_name: &str,
|
||||||
|
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||||
|
let bollard::container::AttachContainerResults { mut output, input } = self
|
||||||
|
.client()
|
||||||
|
.attach_container(
|
||||||
|
container_name,
|
||||||
|
Some(AttachContainerOptions::<String> {
|
||||||
|
stdin: Some(true),
|
||||||
|
stream: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let name = container_name.to_string();
|
||||||
|
let drain_task = tokio::spawn(async move {
|
||||||
|
while let Some(chunk) = output.next().await {
|
||||||
|
if let Err(error) = chunk {
|
||||||
|
debug!(container = %name, error = %error, "Container stdin attach stream closed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
debug!(container = %name, "Container stdin attach stream ended");
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Arc::new(crate::docker::manager::CommandStreamHandle::new(input, drain_task)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_or_attach_command_stream(
|
||||||
|
&self,
|
||||||
|
server_uuid: &str,
|
||||||
|
) -> Result<Arc<crate::docker::manager::CommandStreamHandle>> {
|
||||||
|
let name = container_name(server_uuid);
|
||||||
|
|
||||||
|
if let Some(existing) = self.command_streams().read().await.get(&name).cloned() {
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = self.attach_command_stream(&name).await?;
|
||||||
|
|
||||||
|
let mut streams = self.command_streams().write().await;
|
||||||
|
if let Some(existing) = streams.get(&name).cloned() {
|
||||||
|
created.abort();
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.insert(name, created.clone());
|
||||||
|
Ok(created)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_command_stream(&self, server_uuid: &str) {
|
||||||
|
let name = container_name(server_uuid);
|
||||||
|
if let Some(stream) = self.command_streams().write().await.remove(&name) {
|
||||||
|
stream.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
.client()
|
.client()
|
||||||
|
|
@ -206,6 +265,7 @@ impl DockerManager {
|
||||||
/// Stop a container gracefully.
|
/// Stop a container gracefully.
|
||||||
pub async fn stop_container(&self, server_uuid: &str, timeout_secs: i64) -> Result<()> {
|
pub async fn stop_container(&self, server_uuid: &str, timeout_secs: i64) -> Result<()> {
|
||||||
let name = container_name(server_uuid);
|
let name = container_name(server_uuid);
|
||||||
|
self.clear_command_stream(server_uuid).await;
|
||||||
self.client()
|
self.client()
|
||||||
.stop_container(
|
.stop_container(
|
||||||
&name,
|
&name,
|
||||||
|
|
@ -221,6 +281,7 @@ impl DockerManager {
|
||||||
/// Kill a container immediately.
|
/// Kill a container immediately.
|
||||||
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
|
pub async fn kill_container(&self, server_uuid: &str) -> Result<()> {
|
||||||
let name = container_name(server_uuid);
|
let name = container_name(server_uuid);
|
||||||
|
self.clear_command_stream(server_uuid).await;
|
||||||
self.client()
|
self.client()
|
||||||
.kill_container::<String>(&name, None)
|
.kill_container::<String>(&name, None)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -231,6 +292,7 @@ impl DockerManager {
|
||||||
/// Remove a container and its volumes.
|
/// Remove a container and its volumes.
|
||||||
pub async fn remove_container(&self, server_uuid: &str) -> Result<()> {
|
pub async fn remove_container(&self, server_uuid: &str) -> Result<()> {
|
||||||
let name = container_name(server_uuid);
|
let name = container_name(server_uuid);
|
||||||
|
self.clear_command_stream(server_uuid).await;
|
||||||
self.client()
|
self.client()
|
||||||
.remove_container(
|
.remove_container(
|
||||||
&name,
|
&name,
|
||||||
|
|
@ -338,24 +400,22 @@ impl DockerManager {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a command to a container via exec (attach to stdin).
|
/// Send a command to a container via a persistent Docker attach stdin stream.
|
||||||
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
pub async fn send_command(&self, server_uuid: &str, command: &str) -> Result<()> {
|
||||||
let name = container_name(server_uuid);
|
let trimmed = command.trim_end_matches(|ch| ch == '\r' || ch == '\n');
|
||||||
|
let payload = format!("{trimmed}\n");
|
||||||
|
|
||||||
// Preferred path for Minecraft-like images where rcon-cli is available.
|
for _ in 0..2 {
|
||||||
if self
|
let stream = self.get_or_attach_command_stream(server_uuid).await?;
|
||||||
.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
|
match stream.write_all(payload.as_bytes()).await {
|
||||||
.await
|
Ok(_) => return Ok(()),
|
||||||
.is_ok()
|
Err(error) => {
|
||||||
{
|
debug!(server_uuid = %server_uuid, error = %error, "Failed to write to container stdin, resetting attach stream");
|
||||||
return Ok(());
|
self.clear_command_stream(server_uuid).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic fallback: write directly to PID 1 stdin.
|
Err(anyhow::anyhow!("failed to write command to container stdin"))
|
||||||
let escaped = command.replace('\'', "'\"'\"'");
|
|
||||||
let shell_cmd = format!("printf '%s\\n' '{}' > /proc/1/fd/0", escaped);
|
|
||||||
self.run_exec(&name, vec!["sh".to_string(), "-c".to_string(), shell_cmd])
|
|
||||||
.await
|
|
||||||
.map(|_| ())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,50 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use bollard::Docker;
|
use bollard::Docker;
|
||||||
use bollard::network::CreateNetworkOptions;
|
use bollard::network::CreateNetworkOptions;
|
||||||
|
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||||
|
use tokio::sync::{Mutex, RwLock};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::config::DockerConfig;
|
use crate::config::DockerConfig;
|
||||||
|
|
||||||
|
type AttachedInput = Pin<Box<dyn AsyncWrite + Send>>;
|
||||||
|
|
||||||
|
pub(crate) struct CommandStreamHandle {
|
||||||
|
input: Mutex<AttachedInput>,
|
||||||
|
drain_task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandStreamHandle {
|
||||||
|
pub(crate) fn new(input: AttachedInput, drain_task: JoinHandle<()>) -> Self {
|
||||||
|
Self {
|
||||||
|
input: Mutex::new(input),
|
||||||
|
drain_task,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn write_all(&self, bytes: &[u8]) -> Result<()> {
|
||||||
|
let mut input = self.input.lock().await;
|
||||||
|
input.write_all(bytes).await?;
|
||||||
|
input.flush().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn abort(&self) {
|
||||||
|
self.drain_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the Docker client and network setup.
|
/// Manages the Docker client and network setup.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DockerManager {
|
pub struct DockerManager {
|
||||||
client: Docker,
|
client: Docker,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
|
command_streams: Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DockerManager {
|
impl DockerManager {
|
||||||
|
|
@ -30,6 +65,7 @@ impl DockerManager {
|
||||||
let manager = Self {
|
let manager = Self {
|
||||||
client,
|
client,
|
||||||
network_name: config.network.clone(),
|
network_name: config.network.clone(),
|
||||||
|
command_streams: Arc::new(RwLock::new(HashMap::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
manager.ensure_network(&config.network_subnet).await?;
|
manager.ensure_network(&config.network_subnet).await?;
|
||||||
|
|
@ -45,6 +81,10 @@ impl DockerManager {
|
||||||
&self.network_name
|
&self.network_name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn command_streams(&self) -> &Arc<RwLock<HashMap<String, Arc<CommandStreamHandle>>>> {
|
||||||
|
&self.command_streams
|
||||||
|
}
|
||||||
|
|
||||||
async fn ensure_network(&self, subnet: &str) -> Result<()> {
|
async fn ensure_network(&self, subnet: &str) -> Result<()> {
|
||||||
let networks = self.client.list_networks::<String>(None).await?;
|
let networks = self.client.list_networks::<String>(None).await?;
|
||||||
let exists = networks
|
let exists = networks
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,11 @@ use tokio_stream::wrappers::ReceiverStream;
|
||||||
use tonic::{Request, Response, Status};
|
use tonic::{Request, Response, Status};
|
||||||
use tracing::{info, error, warn};
|
use tracing::{info, error, warn};
|
||||||
|
|
||||||
|
use crate::command::CommandDispatcher;
|
||||||
use crate::server::{ServerManager, PortMap};
|
use crate::server::{ServerManager, PortMap};
|
||||||
use crate::filesystem::FileSystem;
|
use crate::filesystem::FileSystem;
|
||||||
use crate::backup::BackupManager;
|
use crate::backup::BackupManager;
|
||||||
|
use crate::managed_mysql::ManagedMysqlManager;
|
||||||
|
|
||||||
// Import generated protobuf types
|
// Import generated protobuf types
|
||||||
pub mod pb {
|
pub mod pb {
|
||||||
|
|
@ -27,7 +29,9 @@ use pb::*;
|
||||||
|
|
||||||
pub struct DaemonServiceImpl {
|
pub struct DaemonServiceImpl {
|
||||||
server_manager: Arc<ServerManager>,
|
server_manager: Arc<ServerManager>,
|
||||||
|
command_dispatcher: Arc<CommandDispatcher>,
|
||||||
backup_manager: BackupManager,
|
backup_manager: BackupManager,
|
||||||
|
managed_mysql: Arc<ManagedMysqlManager>,
|
||||||
daemon_token: String,
|
daemon_token: String,
|
||||||
start_time: Instant,
|
start_time: Instant,
|
||||||
}
|
}
|
||||||
|
|
@ -35,9 +39,11 @@ pub struct DaemonServiceImpl {
|
||||||
impl DaemonServiceImpl {
|
impl DaemonServiceImpl {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
server_manager: Arc<ServerManager>,
|
server_manager: Arc<ServerManager>,
|
||||||
|
command_dispatcher: Arc<CommandDispatcher>,
|
||||||
daemon_token: String,
|
daemon_token: String,
|
||||||
backup_root: PathBuf,
|
backup_root: PathBuf,
|
||||||
api_url: String,
|
api_url: String,
|
||||||
|
managed_mysql: Arc<ManagedMysqlManager>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let backup_manager = BackupManager::new(
|
let backup_manager = BackupManager::new(
|
||||||
server_manager.clone(),
|
server_manager.clone(),
|
||||||
|
|
@ -48,7 +54,9 @@ impl DaemonServiceImpl {
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
server_manager,
|
server_manager,
|
||||||
|
command_dispatcher,
|
||||||
backup_manager,
|
backup_manager,
|
||||||
|
managed_mysql,
|
||||||
daemon_token,
|
daemon_token,
|
||||||
start_time: Instant::now(),
|
start_time: Instant::now(),
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +114,21 @@ impl DaemonServiceImpl {
|
||||||
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
|
Self::env_value(env, &["CS2_RCONPW", "CS2_RCON_PASSWORD", "SRCDS_RCONPW", "RCON_PASSWORD"])
|
||||||
.unwrap_or_else(|| "changeme".to_string())
|
.unwrap_or_else(|| "changeme".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_ports(ports: &[PortMapping]) -> Vec<PortMap> {
|
||||||
|
ports
|
||||||
|
.iter()
|
||||||
|
.map(|p| PortMap {
|
||||||
|
host_port: p.host_port as u16,
|
||||||
|
container_port: p.container_port as u16,
|
||||||
|
protocol: if p.protocol.is_empty() {
|
||||||
|
"tcp".to_string()
|
||||||
|
} else {
|
||||||
|
p.protocol.clone()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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>>;
|
||||||
|
|
@ -168,20 +191,6 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
self.check_auth(&request)?;
|
self.check_auth(&request)?;
|
||||||
let req = request.into_inner();
|
let req = request.into_inner();
|
||||||
|
|
||||||
let ports: Vec<PortMap> = req
|
|
||||||
.ports
|
|
||||||
.iter()
|
|
||||||
.map(|p| PortMap {
|
|
||||||
host_port: p.host_port as u16,
|
|
||||||
container_port: p.container_port as u16,
|
|
||||||
protocol: if p.protocol.is_empty() {
|
|
||||||
"tcp".to_string()
|
|
||||||
} else {
|
|
||||||
p.protocol.clone()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
self.server_manager
|
self.server_manager
|
||||||
.create_server(
|
.create_server(
|
||||||
req.uuid.clone(),
|
req.uuid.clone(),
|
||||||
|
|
@ -191,7 +200,7 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
req.cpu_limit,
|
req.cpu_limit,
|
||||||
req.startup_command,
|
req.startup_command,
|
||||||
req.environment,
|
req.environment,
|
||||||
ports,
|
Self::map_ports(&req.ports),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Status::from(e))?;
|
.map_err(|e| Status::from(e))?;
|
||||||
|
|
@ -202,6 +211,33 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_server(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateServerRequest>,
|
||||||
|
) -> Result<Response<ServerResponse>, Status> {
|
||||||
|
self.check_auth(&request)?;
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
let state = self.server_manager
|
||||||
|
.update_server(
|
||||||
|
req.uuid.clone(),
|
||||||
|
req.docker_image,
|
||||||
|
req.memory_limit,
|
||||||
|
req.disk_limit,
|
||||||
|
req.cpu_limit,
|
||||||
|
req.startup_command,
|
||||||
|
req.environment,
|
||||||
|
Self::map_ports(&req.ports),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Status::from)?;
|
||||||
|
|
||||||
|
Ok(Response::new(ServerResponse {
|
||||||
|
uuid: req.uuid,
|
||||||
|
status: state.to_string(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_server(
|
async fn delete_server(
|
||||||
&self,
|
&self,
|
||||||
request: Request<ServerIdentifier>,
|
request: Request<ServerIdentifier>,
|
||||||
|
|
@ -232,6 +268,85 @@ impl DaemonService for DaemonServiceImpl {
|
||||||
Ok(Response::new(Empty {}))
|
Ok(Response::new(Empty {}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn create_database(
|
||||||
|
&self,
|
||||||
|
request: Request<CreateDatabaseRequest>,
|
||||||
|
) -> Result<Response<ManagedDatabaseCredentials>, Status> {
|
||||||
|
self.check_auth(&request)?;
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if req.server_uuid.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Server UUID is required"));
|
||||||
|
}
|
||||||
|
if req.name.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Database name is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let password = req.password.trim();
|
||||||
|
let database = self
|
||||||
|
.managed_mysql
|
||||||
|
.create_database(
|
||||||
|
req.server_uuid.trim(),
|
||||||
|
req.name.trim(),
|
||||||
|
if password.is_empty() { None } else { Some(password) },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(Status::from)?;
|
||||||
|
|
||||||
|
Ok(Response::new(ManagedDatabaseCredentials {
|
||||||
|
database_name: database.database_name,
|
||||||
|
username: database.username,
|
||||||
|
password: database.password,
|
||||||
|
host: database.host,
|
||||||
|
port: i32::from(database.port),
|
||||||
|
phpmyadmin_url: database.phpmyadmin_url.unwrap_or_default(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_database_password(
|
||||||
|
&self,
|
||||||
|
request: Request<UpdateDatabasePasswordRequest>,
|
||||||
|
) -> Result<Response<Empty>, Status> {
|
||||||
|
self.check_auth(&request)?;
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if req.username.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Database username is required"));
|
||||||
|
}
|
||||||
|
if req.password.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Database password is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.managed_mysql
|
||||||
|
.update_password(req.username.trim(), req.password.trim())
|
||||||
|
.await
|
||||||
|
.map_err(Status::from)?;
|
||||||
|
|
||||||
|
Ok(Response::new(Empty {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_database(
|
||||||
|
&self,
|
||||||
|
request: Request<DeleteDatabaseRequest>,
|
||||||
|
) -> Result<Response<Empty>, Status> {
|
||||||
|
self.check_auth(&request)?;
|
||||||
|
let req = request.into_inner();
|
||||||
|
|
||||||
|
if req.database_name.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Database name is required"));
|
||||||
|
}
|
||||||
|
if req.username.trim().is_empty() {
|
||||||
|
return Err(Status::invalid_argument("Database username is required"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.managed_mysql
|
||||||
|
.delete_database(req.database_name.trim(), req.username.trim())
|
||||||
|
.await
|
||||||
|
.map_err(Status::from)?;
|
||||||
|
|
||||||
|
Ok(Response::new(Empty {}))
|
||||||
|
}
|
||||||
|
|
||||||
// === Power ===
|
// === Power ===
|
||||||
|
|
||||||
async fn set_power_state(
|
async fn set_power_state(
|
||||||
|
|
@ -331,31 +446,7 @@ 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 {
|
self.command_dispatcher
|
||||||
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
|
|
||||||
.docker()
|
|
||||||
.send_command(&req.uuid, &req.command)
|
.send_command(&req.uuid, &req.command)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Status::internal(e.to_string()))?;
|
.map_err(|e| Status::internal(e.to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -6,19 +6,23 @@ use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod backup;
|
mod backup;
|
||||||
|
mod command;
|
||||||
mod config;
|
mod config;
|
||||||
mod docker;
|
mod docker;
|
||||||
mod error;
|
mod error;
|
||||||
mod filesystem;
|
mod filesystem;
|
||||||
mod game;
|
mod game;
|
||||||
mod grpc;
|
mod grpc;
|
||||||
|
mod managed_mysql;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
use crate::docker::DockerManager;
|
use crate::docker::DockerManager;
|
||||||
use crate::grpc::DaemonServiceImpl;
|
use crate::grpc::DaemonServiceImpl;
|
||||||
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
|
use crate::grpc::service::pb::daemon_service_server::DaemonServiceServer;
|
||||||
|
use crate::managed_mysql::ManagedMysqlManager;
|
||||||
use crate::server::ServerManager;
|
use crate::server::ServerManager;
|
||||||
|
use crate::command::CommandDispatcher;
|
||||||
|
|
||||||
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
const MAX_GRPC_MESSAGE_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
|
@ -45,12 +49,21 @@ async fn main() -> Result<()> {
|
||||||
let server_manager = Arc::new(ServerManager::new(docker, &config));
|
let server_manager = Arc::new(ServerManager::new(docker, &config));
|
||||||
info!("Server manager initialized");
|
info!("Server manager initialized");
|
||||||
|
|
||||||
|
// Initialize shared command dispatcher (single command pipeline for all games/sources)
|
||||||
|
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
|
||||||
|
info!("Command dispatcher initialized");
|
||||||
|
|
||||||
|
let managed_mysql = Arc::new(ManagedMysqlManager::new(config.managed_mysql.clone())?);
|
||||||
|
info!(enabled = managed_mysql.is_enabled(), "Managed MySQL initialized");
|
||||||
|
|
||||||
// Create gRPC service
|
// Create gRPC service
|
||||||
let daemon_service = DaemonServiceImpl::new(
|
let daemon_service = DaemonServiceImpl::new(
|
||||||
server_manager.clone(),
|
server_manager.clone(),
|
||||||
|
command_dispatcher.clone(),
|
||||||
config.node_token.clone(),
|
config.node_token.clone(),
|
||||||
config.backup_path.clone(),
|
config.backup_path.clone(),
|
||||||
config.api_url.clone(),
|
config.api_url.clone(),
|
||||||
|
managed_mysql.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start gRPC server
|
// Start gRPC server
|
||||||
|
|
@ -68,6 +81,7 @@ async fn main() -> Result<()> {
|
||||||
// Scheduler task
|
// Scheduler task
|
||||||
let sched = Arc::new(scheduler::Scheduler::new(
|
let sched = Arc::new(scheduler::Scheduler::new(
|
||||||
server_manager.clone(),
|
server_manager.clone(),
|
||||||
|
command_dispatcher.clone(),
|
||||||
config.api_url.clone(),
|
config.api_url.clone(),
|
||||||
config.node_token.clone(),
|
config.node_token.clone(),
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
|
||||||
|
use reqwest::Url;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tonic::Status;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config::ManagedMysqlConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ManagedMysqlRuntimeConfig {
|
||||||
|
admin_database: String,
|
||||||
|
admin_host: String,
|
||||||
|
admin_password: String,
|
||||||
|
admin_port: u16,
|
||||||
|
admin_username: String,
|
||||||
|
client_bin: Option<String>,
|
||||||
|
connection_host: String,
|
||||||
|
connection_port: u16,
|
||||||
|
phpmyadmin_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ManagedMysqlDatabase {
|
||||||
|
pub database_name: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub phpmyadmin_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ManagedMysqlError {
|
||||||
|
#[error("Managed MySQL is not configured on this node")]
|
||||||
|
NotConfigured,
|
||||||
|
|
||||||
|
#[error("Managed MySQL configuration is invalid: {0}")]
|
||||||
|
InvalidConfig(String),
|
||||||
|
|
||||||
|
#[error("Managed MySQL client binary is not installed on this node")]
|
||||||
|
ClientMissing,
|
||||||
|
|
||||||
|
#[error("Managed MySQL command failed: {0}")]
|
||||||
|
CommandFailed(String),
|
||||||
|
|
||||||
|
#[error("Managed MySQL I/O error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ManagedMysqlError> for Status {
|
||||||
|
fn from(error: ManagedMysqlError) -> Self {
|
||||||
|
match error {
|
||||||
|
ManagedMysqlError::NotConfigured | ManagedMysqlError::ClientMissing => {
|
||||||
|
Status::failed_precondition(error.to_string())
|
||||||
|
}
|
||||||
|
ManagedMysqlError::InvalidConfig(_) => Status::internal(error.to_string()),
|
||||||
|
ManagedMysqlError::CommandFailed(_) => Status::internal(error.to_string()),
|
||||||
|
ManagedMysqlError::Io(_) => Status::internal(error.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ManagedMysqlManager {
|
||||||
|
config: Option<ManagedMysqlRuntimeConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManagedMysqlManager {
|
||||||
|
pub fn new(config: Option<ManagedMysqlConfig>) -> Result<Self, ManagedMysqlError> {
|
||||||
|
let runtime = match config {
|
||||||
|
Some(config) => Some(resolve_runtime_config(config)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self { config: runtime })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_enabled(&self) -> bool {
|
||||||
|
self.config.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_database(
|
||||||
|
&self,
|
||||||
|
server_uuid: &str,
|
||||||
|
label: &str,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> Result<ManagedMysqlDatabase, ManagedMysqlError> {
|
||||||
|
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||||
|
let label = label.trim();
|
||||||
|
if label.is_empty() {
|
||||||
|
return Err(ManagedMysqlError::CommandFailed(
|
||||||
|
"Database name is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let database_name = build_database_name(server_uuid, label);
|
||||||
|
let username = build_username(server_uuid);
|
||||||
|
let password = build_password(password);
|
||||||
|
|
||||||
|
self.run_sql(
|
||||||
|
config,
|
||||||
|
&format!(
|
||||||
|
"CREATE DATABASE {} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci",
|
||||||
|
escape_identifier(&database_name)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Err(error) = self
|
||||||
|
.run_sql(
|
||||||
|
config,
|
||||||
|
&format!(
|
||||||
|
"CREATE USER {}@'%' IDENTIFIED BY {};GRANT ALL PRIVILEGES ON {}.* TO {}@'%'",
|
||||||
|
escape_string(&username),
|
||||||
|
escape_string(&password),
|
||||||
|
escape_identifier(&database_name),
|
||||||
|
escape_string(&username),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let _ = self
|
||||||
|
.run_sql(
|
||||||
|
config,
|
||||||
|
&format!("DROP DATABASE IF EXISTS {}", escape_identifier(&database_name)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ManagedMysqlDatabase {
|
||||||
|
database_name: database_name.clone(),
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
host: config.connection_host.clone(),
|
||||||
|
port: config.connection_port,
|
||||||
|
phpmyadmin_url: build_phpmyadmin_url(config.phpmyadmin_url.as_deref(), &database_name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_password(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> Result<(), ManagedMysqlError> {
|
||||||
|
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||||
|
let password = password.trim();
|
||||||
|
if password.is_empty() {
|
||||||
|
return Err(ManagedMysqlError::CommandFailed(
|
||||||
|
"Database password is required".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.run_sql(
|
||||||
|
config,
|
||||||
|
&format!(
|
||||||
|
"ALTER USER {}@'%' IDENTIFIED BY {}",
|
||||||
|
escape_string(username),
|
||||||
|
escape_string(password),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_database(
|
||||||
|
&self,
|
||||||
|
database_name: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<(), ManagedMysqlError> {
|
||||||
|
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
|
||||||
|
|
||||||
|
self.run_sql(
|
||||||
|
config,
|
||||||
|
&format!(
|
||||||
|
"DROP DATABASE IF EXISTS {};DROP USER IF EXISTS {}@'%'",
|
||||||
|
escape_identifier(database_name),
|
||||||
|
escape_string(username),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_sql(
|
||||||
|
&self,
|
||||||
|
config: &ManagedMysqlRuntimeConfig,
|
||||||
|
sql: &str,
|
||||||
|
) -> Result<(), ManagedMysqlError> {
|
||||||
|
let binaries = match config.client_bin.as_deref() {
|
||||||
|
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
|
||||||
|
_ => vec!["mariadb".to_string(), "mysql".to_string()],
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut missing_binary = false;
|
||||||
|
|
||||||
|
for binary in binaries {
|
||||||
|
let output = Command::new(&binary)
|
||||||
|
.args([
|
||||||
|
"--protocol=TCP",
|
||||||
|
"--batch",
|
||||||
|
"--skip-column-names",
|
||||||
|
"-h",
|
||||||
|
&config.admin_host,
|
||||||
|
"-P",
|
||||||
|
&config.admin_port.to_string(),
|
||||||
|
"-u",
|
||||||
|
&config.admin_username,
|
||||||
|
&config.admin_database,
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
])
|
||||||
|
.env("MYSQL_PWD", &config.admin_password)
|
||||||
|
.output()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match output {
|
||||||
|
Ok(output) if output.status.success() => return Ok(()),
|
||||||
|
Ok(output) => {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let message = if !stderr.is_empty() {
|
||||||
|
stderr
|
||||||
|
} else if !stdout.is_empty() {
|
||||||
|
stdout
|
||||||
|
} else {
|
||||||
|
format!("{} exited with status {}", binary, output.status)
|
||||||
|
};
|
||||||
|
return Err(ManagedMysqlError::CommandFailed(message));
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
|
missing_binary = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(ManagedMysqlError::Io(error)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing_binary {
|
||||||
|
return Err(ManagedMysqlError::ClientMissing);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ManagedMysqlError::ClientMissing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_runtime_config(
|
||||||
|
config: ManagedMysqlConfig,
|
||||||
|
) -> Result<ManagedMysqlRuntimeConfig, ManagedMysqlError> {
|
||||||
|
let parsed = Url::parse(&config.url)
|
||||||
|
.map_err(|error| ManagedMysqlError::InvalidConfig(error.to_string()))?;
|
||||||
|
|
||||||
|
if parsed.scheme() != "mysql" && parsed.scheme() != "mariadb" {
|
||||||
|
return Err(ManagedMysqlError::InvalidConfig(
|
||||||
|
"url must use mysql:// or mariadb://".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let admin_host = parsed.host_str().unwrap_or_default().trim().to_string();
|
||||||
|
let admin_username = parsed.username().trim().to_string();
|
||||||
|
|
||||||
|
if admin_host.is_empty() || admin_username.is_empty() {
|
||||||
|
return Err(ManagedMysqlError::InvalidConfig(
|
||||||
|
"url must include host and username".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let admin_database = {
|
||||||
|
let trimmed = parsed.path().trim_start_matches('/').trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"mysql".to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ManagedMysqlRuntimeConfig {
|
||||||
|
admin_database,
|
||||||
|
admin_host: admin_host.clone(),
|
||||||
|
admin_password: parsed.password().unwrap_or_default().to_string(),
|
||||||
|
admin_port: parsed.port().unwrap_or(3306),
|
||||||
|
admin_username,
|
||||||
|
client_bin: config.bin,
|
||||||
|
connection_host: config.connection_host.unwrap_or(admin_host),
|
||||||
|
connection_port: config.connection_port.unwrap_or(parsed.port().unwrap_or(3306)),
|
||||||
|
phpmyadmin_url: config.phpmyadmin_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_token(value: &str, fallback: &str, max_len: usize) -> String {
|
||||||
|
let mut normalized = String::with_capacity(value.len());
|
||||||
|
|
||||||
|
for ch in value.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() {
|
||||||
|
normalized.push(ch.to_ascii_lowercase());
|
||||||
|
} else if !normalized.ends_with('_') {
|
||||||
|
normalized.push('_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = normalized.trim_matches('_');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return fallback.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed
|
||||||
|
.chars()
|
||||||
|
.take(max_len)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end_matches('_')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_database_name(server_uuid: &str, label: &str) -> String {
|
||||||
|
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 12);
|
||||||
|
let label_token = normalize_token(label, "db", 16);
|
||||||
|
let suffix = Uuid::new_v4().simple().to_string();
|
||||||
|
format!("srv_{}_{}_{}", server_token, label_token, &suffix[..8])
|
||||||
|
.chars()
|
||||||
|
.take(64)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end_matches('_')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_username(server_uuid: &str) -> String {
|
||||||
|
let server_token = normalize_token(&server_uuid.replace('-', ""), "server", 8);
|
||||||
|
let suffix = Uuid::new_v4().simple().to_string();
|
||||||
|
format!("u_{}_{}", server_token, &suffix[..8])
|
||||||
|
.chars()
|
||||||
|
.take(32)
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_end_matches('_')
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_password(password: Option<&str>) -> String {
|
||||||
|
match password {
|
||||||
|
Some(password) if !password.trim().is_empty() => password.trim().to_string(),
|
||||||
|
_ => {
|
||||||
|
let first = Uuid::new_v4().simple().to_string();
|
||||||
|
let second = Uuid::new_v4().simple().to_string();
|
||||||
|
format!("{}{}", first, second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_identifier(value: &str) -> String {
|
||||||
|
format!("`{}`", value.replace('`', "``"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_string(value: &str) -> String {
|
||||||
|
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_phpmyadmin_url(base_url: Option<&str>, database_name: &str) -> Option<String> {
|
||||||
|
let base_url = base_url?.trim();
|
||||||
|
if base_url.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
match Url::parse(base_url) {
|
||||||
|
Ok(mut url) => {
|
||||||
|
url.query_pairs_mut().append_pair("db", database_name);
|
||||||
|
Some(url.to_string())
|
||||||
|
}
|
||||||
|
Err(_) => Some(base_url.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ use tokio::time::{interval, Duration};
|
||||||
use tracing::{info, error, warn};
|
use tracing::{info, error, warn};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::command::CommandDispatcher;
|
||||||
use crate::server::ServerManager;
|
use crate::server::ServerManager;
|
||||||
|
|
||||||
/// A scheduled task received from the panel API.
|
/// A scheduled task received from the panel API.
|
||||||
|
|
@ -21,6 +22,7 @@ pub struct ScheduledTask {
|
||||||
/// Scheduler that polls the panel API for due tasks and executes them.
|
/// Scheduler that polls the panel API for due tasks and executes them.
|
||||||
pub struct Scheduler {
|
pub struct Scheduler {
|
||||||
server_manager: Arc<ServerManager>,
|
server_manager: Arc<ServerManager>,
|
||||||
|
command_dispatcher: Arc<CommandDispatcher>,
|
||||||
api_url: String,
|
api_url: String,
|
||||||
node_token: String,
|
node_token: String,
|
||||||
poll_interval_secs: u64,
|
poll_interval_secs: u64,
|
||||||
|
|
@ -29,11 +31,13 @@ pub struct Scheduler {
|
||||||
impl Scheduler {
|
impl Scheduler {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
server_manager: Arc<ServerManager>,
|
server_manager: Arc<ServerManager>,
|
||||||
|
command_dispatcher: Arc<CommandDispatcher>,
|
||||||
api_url: String,
|
api_url: String,
|
||||||
node_token: String,
|
node_token: String,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
server_manager,
|
server_manager,
|
||||||
|
command_dispatcher,
|
||||||
api_url,
|
api_url,
|
||||||
node_token,
|
node_token,
|
||||||
poll_interval_secs: 15,
|
poll_interval_secs: 15,
|
||||||
|
|
@ -117,9 +121,7 @@ impl Scheduler {
|
||||||
|
|
||||||
match task.action.as_str() {
|
match task.action.as_str() {
|
||||||
"command" => {
|
"command" => {
|
||||||
// Send command to server's stdin via Docker exec
|
self.command_dispatcher
|
||||||
let docker = self.server_manager.docker();
|
|
||||||
docker
|
|
||||||
.send_command(&task.server_uuid, &task.payload)
|
.send_command(&task.server_uuid, &task.payload)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,27 @@ pub struct ServerManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerManager {
|
impl ServerManager {
|
||||||
|
async fn ensure_server_data_dir(&self, data_path: &PathBuf) -> Result<(), DaemonError> {
|
||||||
|
tokio::fs::create_dir_all(data_path)
|
||||||
|
.await
|
||||||
|
.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)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running_state(state: &str) -> bool {
|
||||||
|
matches!(state, "running" | "restarting")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn new(docker: Arc<DockerManager>, config: &DaemonConfig) -> Self {
|
pub fn new(docker: Arc<DockerManager>, config: &DaemonConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
servers: Arc::new(RwLock::new(HashMap::new())),
|
servers: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
|
@ -61,20 +82,7 @@ impl ServerManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
let data_path = self.data_root.join(&uuid);
|
let data_path = self.data_root.join(&uuid);
|
||||||
|
self.ensure_server_data_dir(&data_path).await?;
|
||||||
// Create data directory
|
|
||||||
tokio::fs::create_dir_all(&data_path)
|
|
||||||
.await
|
|
||||||
.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(),
|
||||||
|
|
@ -109,6 +117,117 @@ impl ServerManager {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recreate a server container with updated runtime configuration while preserving data files.
|
||||||
|
pub async fn update_server(
|
||||||
|
&self,
|
||||||
|
uuid: String,
|
||||||
|
docker_image: String,
|
||||||
|
memory_limit: i64,
|
||||||
|
disk_limit: i64,
|
||||||
|
cpu_limit: i32,
|
||||||
|
startup_command: String,
|
||||||
|
environment: HashMap<String, String>,
|
||||||
|
ports: Vec<PortMap>,
|
||||||
|
) -> Result<ServerState, DaemonError> {
|
||||||
|
let existing = {
|
||||||
|
let servers = self.servers.read().await;
|
||||||
|
servers.get(&uuid).cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches!(existing.as_ref().map(|spec| &spec.state), Some(ServerState::Installing)) {
|
||||||
|
return Err(DaemonError::InvalidStateTransition {
|
||||||
|
current: "installing".to_string(),
|
||||||
|
requested: "update".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtime_state = self
|
||||||
|
.docker
|
||||||
|
.container_state(&uuid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DaemonError::Internal(format!("Failed to inspect container: {}", e)))?;
|
||||||
|
|
||||||
|
if existing.is_none() && runtime_state.is_none() {
|
||||||
|
return Err(DaemonError::ServerNotFound(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
let should_restart = runtime_state
|
||||||
|
.as_deref()
|
||||||
|
.map(Self::is_running_state)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
existing
|
||||||
|
.as_ref()
|
||||||
|
.map(|spec| matches!(spec.state, ServerState::Running | ServerState::Starting))
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let data_path = existing
|
||||||
|
.as_ref()
|
||||||
|
.map(|spec| spec.data_path.clone())
|
||||||
|
.unwrap_or_else(|| self.data_root.join(&uuid));
|
||||||
|
self.ensure_server_data_dir(&data_path).await?;
|
||||||
|
|
||||||
|
let mut desired_spec = ServerSpec {
|
||||||
|
uuid: uuid.clone(),
|
||||||
|
docker_image,
|
||||||
|
memory_limit,
|
||||||
|
disk_limit,
|
||||||
|
cpu_limit,
|
||||||
|
startup_command,
|
||||||
|
environment,
|
||||||
|
ports,
|
||||||
|
data_path,
|
||||||
|
state: ServerState::Stopped,
|
||||||
|
container_id: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if runtime_state
|
||||||
|
.as_deref()
|
||||||
|
.map(Self::is_running_state)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
if let Err(stop_error) = self.docker.stop_container(&uuid, 30).await {
|
||||||
|
warn!(uuid = %uuid, error = %stop_error, "Graceful stop failed during server update, forcing kill");
|
||||||
|
self.docker.kill_container(&uuid).await.map_err(|e| {
|
||||||
|
DaemonError::Internal(format!("Failed to stop running container during update: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime_state.is_some() {
|
||||||
|
self.docker.remove_container(&uuid).await.map_err(|e| {
|
||||||
|
DaemonError::Internal(format!("Failed to remove existing container during update: {}", e))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.docker.create_container(&desired_spec).await {
|
||||||
|
Ok(container_id) => {
|
||||||
|
desired_spec.container_id = Some(container_id);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
desired_spec.state = ServerState::Error;
|
||||||
|
let mut servers = self.servers.write().await;
|
||||||
|
servers.insert(uuid.clone(), desired_spec);
|
||||||
|
return Err(DaemonError::Internal(format!(
|
||||||
|
"Failed to recreate container during update: {}",
|
||||||
|
error
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut servers = self.servers.write().await;
|
||||||
|
servers.insert(uuid.clone(), desired_spec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_restart {
|
||||||
|
self.start_server(&uuid).await?;
|
||||||
|
return Ok(ServerState::Running);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ServerState::Stopped)
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a server: pull image, create container.
|
/// Install a server: pull image, create container.
|
||||||
async fn install_server(
|
async fn install_server(
|
||||||
docker: Arc<DockerManager>,
|
docker: Arc<DockerManager>,
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,13 @@ import { SchedulesPage } from '@/pages/server/schedules';
|
||||||
import { ConfigPage } from '@/pages/server/config';
|
import { ConfigPage } from '@/pages/server/config';
|
||||||
import { PluginsPage } from '@/pages/server/plugins';
|
import { PluginsPage } from '@/pages/server/plugins';
|
||||||
import { PlayersPage } from '@/pages/server/players';
|
import { PlayersPage } from '@/pages/server/players';
|
||||||
|
import { DatabasesPage } from '@/pages/server/databases';
|
||||||
import { ServerSettingsPage } from '@/pages/server/settings';
|
import { ServerSettingsPage } from '@/pages/server/settings';
|
||||||
|
|
||||||
// Admin pages
|
// Admin pages
|
||||||
import { AdminUsersPage } from '@/pages/admin/users';
|
import { AdminUsersPage } from '@/pages/admin/users';
|
||||||
import { AdminGamesPage } from '@/pages/admin/games';
|
import { AdminGamesPage } from '@/pages/admin/games';
|
||||||
|
import { AdminPluginsPage } from '@/pages/admin/plugins';
|
||||||
import { AdminNodesPage } from '@/pages/admin/nodes';
|
import { AdminNodesPage } from '@/pages/admin/nodes';
|
||||||
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
import { AdminAuditLogsPage } from '@/pages/admin/audit-logs';
|
||||||
import { AccountSecurityPage } from '@/pages/account/security';
|
import { AccountSecurityPage } from '@/pages/account/security';
|
||||||
|
|
@ -106,6 +108,7 @@ export function App() {
|
||||||
<Route path="console" element={<ConsolePage />} />
|
<Route path="console" element={<ConsolePage />} />
|
||||||
<Route path="files" element={<FilesPage />} />
|
<Route path="files" element={<FilesPage />} />
|
||||||
<Route path="config" element={<ConfigPage />} />
|
<Route path="config" element={<ConfigPage />} />
|
||||||
|
<Route path="databases" element={<DatabasesPage />} />
|
||||||
<Route path="plugins" element={<PluginsPage />} />
|
<Route path="plugins" element={<PluginsPage />} />
|
||||||
<Route path="backups" element={<BackupsPage />} />
|
<Route path="backups" element={<BackupsPage />} />
|
||||||
<Route path="schedules" element={<SchedulesPage />} />
|
<Route path="schedules" element={<SchedulesPage />} />
|
||||||
|
|
@ -116,6 +119,7 @@ export function App() {
|
||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
<Route path="/admin/games" element={<AdminGamesPage />} />
|
<Route path="/admin/games" element={<AdminGamesPage />} />
|
||||||
|
<Route path="/admin/plugins" element={<AdminPluginsPage />} />
|
||||||
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
<Route path="/admin/nodes" element={<AdminNodesPage />} />
|
||||||
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
<Route path="/admin/audit-logs" element={<AdminAuditLogsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
import { Outlet, useParams, Link, useLocation } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2 } from 'lucide-react';
|
import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react';
|
||||||
import { cn } from '@source/ui';
|
import { cn } from '@source/ui';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
@ -26,6 +26,7 @@ const tabs = [
|
||||||
{ label: 'Console', path: 'console', icon: Terminal },
|
{ label: 'Console', path: 'console', icon: Terminal },
|
||||||
{ label: 'Files', path: 'files', icon: FolderOpen },
|
{ label: 'Files', path: 'files', icon: FolderOpen },
|
||||||
{ label: 'Config', path: 'config', icon: Settings2 },
|
{ label: 'Config', path: 'config', icon: Settings2 },
|
||||||
|
{ label: 'Databases', path: 'databases', icon: DatabaseIcon },
|
||||||
{ label: 'Plugins', path: 'plugins', icon: Puzzle },
|
{ label: 'Plugins', path: 'plugins', icon: Puzzle },
|
||||||
{ label: 'Backups', path: 'backups', icon: HardDrive },
|
{ label: 'Backups', path: 'backups', icon: HardDrive },
|
||||||
{ label: 'Schedules', path: 'schedules', icon: Calendar },
|
{ label: 'Schedules', path: 'schedules', icon: Calendar },
|
||||||
|
|
@ -40,6 +41,7 @@ export function ServerLayout() {
|
||||||
const { data: server } = useQuery({
|
const { data: server } = useQuery({
|
||||||
queryKey: ['server', orgId, serverId],
|
queryKey: ['server', orgId, serverId],
|
||||||
queryFn: () => api.get<ServerDetail>(`/organizations/${orgId}/servers/${serverId}`),
|
queryFn: () => api.get<ServerDetail>(`/organizations/${orgId}/servers/${serverId}`),
|
||||||
|
refetchInterval: 3_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentTab = location.pathname.split('/').pop();
|
const currentTab = location.pathname.split('/').pop();
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
|
Puzzle,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
@ -40,6 +41,7 @@ export function Sidebar() {
|
||||||
? [
|
? [
|
||||||
{ label: 'Users', href: '/admin/users', icon: Users },
|
{ label: 'Users', href: '/admin/users', icon: Users },
|
||||||
{ label: 'Games', href: '/admin/games', icon: Gamepad2 },
|
{ label: 'Games', href: '/admin/games', icon: Gamepad2 },
|
||||||
|
{ label: 'Plugins', href: '/admin/plugins', icon: Puzzle },
|
||||||
{ label: 'Nodes', href: '/admin/nodes', icon: Network },
|
{ label: 'Nodes', href: '/admin/nodes', icon: Network },
|
||||||
{ label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText },
|
{ label: 'Audit Logs', href: '/admin/audit-logs', icon: ScrollText },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -19,14 +19,38 @@ interface PowerControlsProps {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
|
||||||
|
|
||||||
|
interface CachedServerDetail {
|
||||||
|
status: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
|
export function PowerControls({ serverId, orgId, status }: PowerControlsProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const serverQueryKey = ['server', orgId, serverId] as const;
|
||||||
|
|
||||||
const powerMutation = useMutation({
|
const powerMutation = useMutation({
|
||||||
mutationFn: (action: string) =>
|
mutationFn: (action: PowerAction) =>
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
|
api.post(`/organizations/${orgId}/servers/${serverId}/power`, { action }),
|
||||||
onSuccess: () => {
|
onMutate: (action) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
|
const nextStatusByAction: Record<PowerAction, string> = {
|
||||||
|
start: 'starting',
|
||||||
|
stop: 'stopping',
|
||||||
|
restart: 'stopping',
|
||||||
|
kill: 'stopped',
|
||||||
|
};
|
||||||
|
|
||||||
|
queryClient.setQueryData<CachedServerDetail | undefined>(serverQueryKey, (current) => {
|
||||||
|
if (!current) return current;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
status: nextStatusByAction[action],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: serverQueryKey });
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
queryClient.invalidateQueries({ queryKey: ['servers', orgId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,23 @@
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 0% 0%, hsl(var(--primary) / 0.18), transparent 34%),
|
||||||
|
radial-gradient(circle at 88% 10%, hsl(var(--ring) / 0.12), transparent 28%),
|
||||||
|
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--muted) / 0.72) 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,21 @@ interface RequestOptions extends RequestInit {
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRequestBody(body: unknown): BodyInit | undefined {
|
||||||
|
if (body === undefined || body === null) return undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
body instanceof FormData ||
|
||||||
|
body instanceof Blob ||
|
||||||
|
body instanceof URLSearchParams ||
|
||||||
|
body instanceof ArrayBuffer
|
||||||
|
) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
public status: number,
|
public status: number,
|
||||||
|
|
@ -102,19 +117,19 @@ export const api = {
|
||||||
post: <T>(path: string, body?: unknown) =>
|
post: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, {
|
request<T>(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: toRequestBody(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
put: <T>(path: string, body?: unknown) =>
|
put: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, {
|
request<T>(path, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: toRequestBody(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
patch: <T>(path: string, body?: unknown) =>
|
patch: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, {
|
request<T>(path, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: toRequestBody(body),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: <T>(path: string) =>
|
delete: <T>(path: string) =>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,820 @@
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, UploadCloud, Puzzle, Rocket, Copy } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { api, ApiError } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface Game {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GamesResponse {
|
||||||
|
data: Game[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalPlugin {
|
||||||
|
id: string;
|
||||||
|
gameId: string;
|
||||||
|
gameName: string;
|
||||||
|
gameSlug: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
source: 'manual' | 'spiget';
|
||||||
|
isGlobal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalPluginsResponse {
|
||||||
|
data: GlobalPlugin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginRelease {
|
||||||
|
id: string;
|
||||||
|
pluginId: string;
|
||||||
|
version: string;
|
||||||
|
channel: 'stable' | 'beta' | 'alpha';
|
||||||
|
artifactType: 'file' | 'zip';
|
||||||
|
artifactUrl: string;
|
||||||
|
destination: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
changelog: string | null;
|
||||||
|
installSchema: unknown[];
|
||||||
|
configTemplates: unknown[];
|
||||||
|
isPublished: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginReleaseResponse {
|
||||||
|
plugin: GlobalPlugin;
|
||||||
|
releases: PluginRelease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReleaseInputMode = 'url' | 'upload';
|
||||||
|
|
||||||
|
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 prettyJson(input: unknown): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(input, null, 2);
|
||||||
|
} catch {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonArray(raw: string): unknown[] {
|
||||||
|
if (raw.trim() === '') return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error('JSON value must be an array');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJsonArrayFile(file: File, label: string): Promise<unknown[]> {
|
||||||
|
let raw = await file.text();
|
||||||
|
if (raw.charCodeAt(0) === 0xfeff) {
|
||||||
|
raw = raw.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error('JSON value must be an array');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||||
|
throw new Error(`${label}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPluginsPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [selectedGameId, setSelectedGameId] = useState<string>('');
|
||||||
|
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createPluginOpen, setCreatePluginOpen] = useState(false);
|
||||||
|
const [createPluginName, setCreatePluginName] = useState('');
|
||||||
|
const [createPluginSlug, setCreatePluginSlug] = useState('');
|
||||||
|
const [createPluginDescription, setCreatePluginDescription] = useState('');
|
||||||
|
|
||||||
|
const [createReleaseOpen, setCreateReleaseOpen] = useState(false);
|
||||||
|
const [releaseInputMode, setReleaseInputMode] = useState<ReleaseInputMode>('upload');
|
||||||
|
const [releaseVersion, setReleaseVersion] = useState('');
|
||||||
|
const [releaseChannel, setReleaseChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||||
|
const [releaseArtifactType, setReleaseArtifactType] = useState<'file' | 'zip'>('file');
|
||||||
|
const [releaseArtifactUrl, setReleaseArtifactUrl] = useState('');
|
||||||
|
const [releaseDestination, setReleaseDestination] = useState('');
|
||||||
|
const [releaseFileName, setReleaseFileName] = useState('');
|
||||||
|
const [releaseChangelog, setReleaseChangelog] = useState('');
|
||||||
|
const [releaseInstallSchemaJson, setReleaseInstallSchemaJson] = useState('[]');
|
||||||
|
const [releaseTemplatesJson, setReleaseTemplatesJson] = useState('[]');
|
||||||
|
const [releaseInstallSchemaFile, setReleaseInstallSchemaFile] = useState<File | null>(null);
|
||||||
|
const [releaseTemplatesFile, setReleaseTemplatesFile] = useState<File | null>(null);
|
||||||
|
const [releaseInstallSchemaFileInputKey, setReleaseInstallSchemaFileInputKey] = useState(0);
|
||||||
|
const [releaseTemplatesFileInputKey, setReleaseTemplatesFileInputKey] = useState(0);
|
||||||
|
const [releaseArtifactFiles, setReleaseArtifactFiles] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const { data: gamesData } = useQuery({
|
||||||
|
queryKey: ['admin-games'],
|
||||||
|
queryFn: () => api.get<GamesResponse>('/admin/games'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = gamesData?.data ?? [];
|
||||||
|
|
||||||
|
const { data: pluginsData } = useQuery({
|
||||||
|
queryKey: ['admin-plugins', selectedGameId],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<GlobalPluginsResponse>(
|
||||||
|
'/admin/plugins',
|
||||||
|
selectedGameId ? { gameId: selectedGameId } : undefined,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugins = pluginsData?.data ?? [];
|
||||||
|
|
||||||
|
const selectedPlugin = useMemo(
|
||||||
|
() => plugins.find((plugin) => plugin.id === selectedPluginId) ?? null,
|
||||||
|
[plugins, selectedPluginId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: releaseData } = useQuery({
|
||||||
|
queryKey: ['admin-plugin-releases', selectedPluginId],
|
||||||
|
enabled: Boolean(selectedPluginId),
|
||||||
|
queryFn: () => api.get<PluginReleaseResponse>(`/admin/plugins/${selectedPluginId}/releases`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const releases = releaseData?.releases ?? [];
|
||||||
|
|
||||||
|
const resetReleaseForm = () => {
|
||||||
|
setCreateReleaseOpen(false);
|
||||||
|
setReleaseInputMode('upload');
|
||||||
|
setReleaseVersion('');
|
||||||
|
setReleaseChannel('stable');
|
||||||
|
setReleaseArtifactType('file');
|
||||||
|
setReleaseArtifactUrl('');
|
||||||
|
setReleaseDestination('');
|
||||||
|
setReleaseFileName('');
|
||||||
|
setReleaseChangelog('');
|
||||||
|
setReleaseInstallSchemaJson('[]');
|
||||||
|
setReleaseTemplatesJson('[]');
|
||||||
|
setReleaseInstallSchemaFile(null);
|
||||||
|
setReleaseTemplatesFile(null);
|
||||||
|
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||||
|
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||||
|
setReleaseArtifactFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendReleaseFiles = (incoming: FileList | null) => {
|
||||||
|
if (!incoming || incoming.length === 0) return;
|
||||||
|
|
||||||
|
setReleaseArtifactFiles((prev) => {
|
||||||
|
const map = new Map<string, File>();
|
||||||
|
|
||||||
|
for (const item of prev) {
|
||||||
|
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||||
|
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of Array.from(incoming)) {
|
||||||
|
const relative = (item as File & { webkitRelativePath?: string }).webkitRelativePath || item.name;
|
||||||
|
map.set(`${relative}::${item.size}::${item.lastModified}`, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPluginMutation = useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
gameId: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
description?: string;
|
||||||
|
}) => api.post('/admin/plugins', body),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Global plugin created');
|
||||||
|
setCreatePluginOpen(false);
|
||||||
|
setCreatePluginName('');
|
||||||
|
setCreatePluginSlug('');
|
||||||
|
setCreatePluginDescription('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to create plugin'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createReleaseMutation = useMutation({
|
||||||
|
mutationFn: (body: {
|
||||||
|
version: string;
|
||||||
|
channel: 'stable' | 'beta' | 'alpha';
|
||||||
|
artifactType: 'file' | 'zip';
|
||||||
|
artifactUrl: string;
|
||||||
|
destination?: string;
|
||||||
|
fileName?: string;
|
||||||
|
changelog?: string;
|
||||||
|
installSchema?: unknown[];
|
||||||
|
configTemplates?: unknown[];
|
||||||
|
}) => {
|
||||||
|
if (!selectedPluginId) {
|
||||||
|
throw new Error('No plugin selected');
|
||||||
|
}
|
||||||
|
return api.post(`/admin/plugins/${selectedPluginId}/releases`, body);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Release published');
|
||||||
|
resetReleaseForm();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to publish release'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUploadReleaseMutation = useMutation({
|
||||||
|
mutationFn: (formData: FormData) => {
|
||||||
|
if (!selectedPluginId) {
|
||||||
|
throw new Error('No plugin selected');
|
||||||
|
}
|
||||||
|
return api.post(`/admin/plugins/${selectedPluginId}/releases/upload`, formData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Release uploaded and published');
|
||||||
|
resetReleaseForm();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-plugins'] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to upload release'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePublishedMutation = useMutation({
|
||||||
|
mutationFn: ({ releaseId, isPublished }: { releaseId: string; isPublished: boolean }) => {
|
||||||
|
if (!selectedPluginId) {
|
||||||
|
throw new Error('No plugin selected');
|
||||||
|
}
|
||||||
|
return api.patch(`/admin/plugins/${selectedPluginId}/releases/${releaseId}`, { isPublished });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-plugin-releases', selectedPluginId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to update release'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openReleaseDialogFrom = (release?: PluginRelease) => {
|
||||||
|
setReleaseVersion('');
|
||||||
|
setReleaseChannel('stable');
|
||||||
|
setReleaseArtifactType('file');
|
||||||
|
setReleaseArtifactUrl('');
|
||||||
|
setReleaseDestination('');
|
||||||
|
setReleaseFileName('');
|
||||||
|
setReleaseChangelog('');
|
||||||
|
setReleaseInstallSchemaJson('[]');
|
||||||
|
setReleaseTemplatesJson('[]');
|
||||||
|
setReleaseInstallSchemaFile(null);
|
||||||
|
setReleaseTemplatesFile(null);
|
||||||
|
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||||
|
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||||
|
setReleaseArtifactFiles([]);
|
||||||
|
setReleaseInputMode(release ? 'url' : 'upload');
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
setReleaseChannel(release.channel);
|
||||||
|
setReleaseArtifactType(release.artifactType);
|
||||||
|
setReleaseArtifactUrl(release.artifactUrl);
|
||||||
|
setReleaseDestination(release.destination ?? '');
|
||||||
|
setReleaseFileName(release.fileName ?? '');
|
||||||
|
setReleaseChangelog(release.changelog ?? '');
|
||||||
|
setReleaseInstallSchemaJson(prettyJson(release.installSchema));
|
||||||
|
setReleaseTemplatesJson(prettyJson(release.configTemplates));
|
||||||
|
}
|
||||||
|
setCreateReleaseOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Global Plugins</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Oyun bazında global plugin tanımla, release yayınla, install ayar şemasını yönet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog open={createPluginOpen} onOpenChange={setCreatePluginOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4" /> Add Global Plugin
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create Global Plugin</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!selectedGameId) {
|
||||||
|
toast.error('Select a game first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createPluginMutation.mutate({
|
||||||
|
gameId: selectedGameId,
|
||||||
|
name: createPluginName,
|
||||||
|
slug: createPluginSlug || undefined,
|
||||||
|
description: createPluginDescription || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Game</Label>
|
||||||
|
<Input
|
||||||
|
value={games.find((game) => game.id === selectedGameId)?.name ?? ''}
|
||||||
|
readOnly
|
||||||
|
placeholder="Select game from filter above"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Name</Label>
|
||||||
|
<Input value={createPluginName} onChange={(e) => setCreatePluginName(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Slug (optional)</Label>
|
||||||
|
<Input value={createPluginSlug} onChange={(e) => setCreatePluginSlug(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description (optional)</Label>
|
||||||
|
<Input value={createPluginDescription} onChange={(e) => setCreatePluginDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={createPluginMutation.isPending}>
|
||||||
|
{createPluginMutation.isPending ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Label className="min-w-20">Game Filter</Label>
|
||||||
|
<select
|
||||||
|
className="h-10 rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={selectedGameId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedGameId(e.target.value);
|
||||||
|
setSelectedPluginId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Games</option>
|
||||||
|
{games.map((game) => (
|
||||||
|
<option key={game.id} value={game.id}>
|
||||||
|
{game.name} ({game.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Plugins</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{plugins.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No plugins found for this filter.</p>
|
||||||
|
)}
|
||||||
|
{plugins.map((plugin) => (
|
||||||
|
<button
|
||||||
|
key={plugin.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedPluginId(plugin.id)}
|
||||||
|
className={`w-full rounded-md border px-3 py-2 text-left transition ${
|
||||||
|
selectedPluginId === plugin.id ? 'border-primary bg-primary/5' : 'hover:bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Puzzle className="h-4 w-4 text-primary" />
|
||||||
|
<span className="font-medium">{plugin.name}</span>
|
||||||
|
<Badge variant="outline">{plugin.gameSlug}</Badge>
|
||||||
|
<Badge variant="secondary">{plugin.source}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{plugin.slug}</p>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Releases</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => openReleaseDialogFrom(releases[0])}
|
||||||
|
disabled={!selectedPlugin}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" /> Clone Latest
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openReleaseDialogFrom()}
|
||||||
|
disabled={!selectedPlugin}
|
||||||
|
>
|
||||||
|
<UploadCloud className="h-4 w-4" /> New Release
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{!selectedPlugin && (
|
||||||
|
<p className="text-sm text-muted-foreground">Select a plugin to manage releases.</p>
|
||||||
|
)}
|
||||||
|
{selectedPlugin && releases.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No releases published yet.</p>
|
||||||
|
)}
|
||||||
|
{releases.map((release) => (
|
||||||
|
<div key={release.id} className="rounded-md border px-3 py-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">v{release.version}</span>
|
||||||
|
<Badge variant="outline">{release.channel}</Badge>
|
||||||
|
<Badge variant="secondary">{release.artifactType}</Badge>
|
||||||
|
{!release.isPublished && <Badge variant="destructive">Unpublished</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{release.artifactUrl}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Schema: {Array.isArray(release.installSchema) ? release.installSchema.length : 0} fields • Templates:{' '}
|
||||||
|
{Array.isArray(release.configTemplates) ? release.configTemplates.length : 0}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
togglePublishedMutation.mutate({
|
||||||
|
releaseId: release.id,
|
||||||
|
isPublished: !release.isPublished,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={togglePublishedMutation.isPending}
|
||||||
|
>
|
||||||
|
<Rocket className="h-4 w-4" />
|
||||||
|
{release.isPublished ? 'Unpublish' : 'Publish'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={createReleaseOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setCreateReleaseOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetReleaseForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Publish Release{selectedPlugin ? ` - ${selectedPlugin.name}` : ''}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const installSchema = releaseInstallSchemaFile
|
||||||
|
? await parseJsonArrayFile(releaseInstallSchemaFile, 'Install schema file')
|
||||||
|
: parseJsonArray(releaseInstallSchemaJson);
|
||||||
|
const configTemplates = releaseTemplatesFile
|
||||||
|
? await parseJsonArrayFile(releaseTemplatesFile, 'Config templates file')
|
||||||
|
: parseJsonArray(releaseTemplatesJson);
|
||||||
|
|
||||||
|
if (releaseInputMode === 'upload') {
|
||||||
|
if (releaseArtifactFiles.length === 0) {
|
||||||
|
toast.error('Select at least one file or folder');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('version', releaseVersion);
|
||||||
|
formData.append('channel', releaseChannel);
|
||||||
|
if (releaseDestination.trim()) formData.append('destination', releaseDestination.trim());
|
||||||
|
if (releaseFileName.trim()) formData.append('fileName', releaseFileName.trim());
|
||||||
|
if (releaseChangelog.trim()) formData.append('changelog', releaseChangelog);
|
||||||
|
if (releaseInstallSchemaFile) {
|
||||||
|
formData.append(
|
||||||
|
'installSchemaFile',
|
||||||
|
releaseInstallSchemaFile,
|
||||||
|
releaseInstallSchemaFile.name,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
formData.append('installSchema', JSON.stringify(installSchema));
|
||||||
|
}
|
||||||
|
if (releaseTemplatesFile) {
|
||||||
|
formData.append(
|
||||||
|
'configTemplatesFile',
|
||||||
|
releaseTemplatesFile,
|
||||||
|
releaseTemplatesFile.name,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
formData.append('configTemplates', JSON.stringify(configTemplates));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of releaseArtifactFiles) {
|
||||||
|
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||||
|
formData.append('relativePath', relativePath && relativePath.length > 0 ? relativePath : file.name);
|
||||||
|
formData.append('files', file, file.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
createUploadReleaseMutation.mutate(formData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createReleaseMutation.mutate({
|
||||||
|
version: releaseVersion,
|
||||||
|
channel: releaseChannel,
|
||||||
|
artifactType: releaseArtifactType,
|
||||||
|
artifactUrl: releaseArtifactUrl,
|
||||||
|
destination: releaseDestination || undefined,
|
||||||
|
fileName: releaseFileName || undefined,
|
||||||
|
changelog: releaseChangelog || undefined,
|
||||||
|
installSchema,
|
||||||
|
configTemplates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Invalid JSON';
|
||||||
|
toast.error(`Release JSON error: ${message}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Version</Label>
|
||||||
|
<Input value={releaseVersion} onChange={(e) => setReleaseVersion(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Channel</Label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={releaseChannel}
|
||||||
|
onChange={(e) => setReleaseChannel(e.target.value as 'stable' | 'beta' | 'alpha')}
|
||||||
|
>
|
||||||
|
<option value="stable">stable</option>
|
||||||
|
<option value="beta">beta</option>
|
||||||
|
<option value="alpha">alpha</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Release Source</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={releaseInputMode === 'upload' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setReleaseInputMode('upload')}
|
||||||
|
>
|
||||||
|
CDN Upload
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={releaseInputMode === 'url' ? 'default' : 'outline'}
|
||||||
|
onClick={() => setReleaseInputMode('url')}
|
||||||
|
>
|
||||||
|
URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{releaseInputMode === 'url' && (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Artifact Type</Label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={releaseArtifactType}
|
||||||
|
onChange={(e) => setReleaseArtifactType(e.target.value as 'file' | 'zip')}
|
||||||
|
>
|
||||||
|
<option value="file">file</option>
|
||||||
|
<option value="zip">zip</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Artifact URL</Label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={releaseArtifactUrl}
|
||||||
|
onChange={(e) => setReleaseArtifactUrl(e.target.value)}
|
||||||
|
required={releaseInputMode === 'url'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{releaseInputMode === 'upload' && (
|
||||||
|
<div className="space-y-3 rounded-md border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tek dosya secersen tekil upload olur. Birden fazla dosya veya klasor secersen otomatik zip
|
||||||
|
yapilip CDN'e yuklenir.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Files</Label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="block w-full text-sm"
|
||||||
|
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Folder</Label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
{...({ webkitdirectory: '', directory: '' } as Record<string, string>)}
|
||||||
|
className="block w-full text-sm"
|
||||||
|
onChange={(e) => appendReleaseFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-muted-foreground">Selected: {releaseArtifactFiles.length} file(s)</p>
|
||||||
|
{releaseArtifactFiles.length > 0 && (
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setReleaseArtifactFiles([])}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{releaseArtifactFiles.length > 0 && (
|
||||||
|
<div className="max-h-28 space-y-1 overflow-auto rounded bg-muted/40 p-2 text-xs">
|
||||||
|
{releaseArtifactFiles.map((file, index) => {
|
||||||
|
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath;
|
||||||
|
return (
|
||||||
|
<p key={`${relativePath || file.name}-${index}`} className="truncate">
|
||||||
|
{relativePath || file.name}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Destination (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={releaseDestination}
|
||||||
|
onChange={(e) => setReleaseDestination(e.target.value)}
|
||||||
|
placeholder="/game/csgo/addons"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>File Name (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={releaseFileName}
|
||||||
|
onChange={(e) => setReleaseFileName(e.target.value)}
|
||||||
|
placeholder="plugin.dll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Changelog (optional)</Label>
|
||||||
|
<textarea
|
||||||
|
value={releaseChangelog}
|
||||||
|
onChange={(e) => setReleaseChangelog(e.target.value)}
|
||||||
|
className="min-h-[90px] w-full rounded-md border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Install Schema JSON (array)</Label>
|
||||||
|
<input
|
||||||
|
key={releaseInstallSchemaFileInputKey}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="block w-full text-xs"
|
||||||
|
onChange={(e) => setReleaseInstallSchemaFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{releaseInstallSchemaFile && (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<p>File secili: {releaseInstallSchemaFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReleaseInstallSchemaFile(null);
|
||||||
|
setReleaseInstallSchemaFileInputKey((prev) => prev + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={releaseInstallSchemaJson}
|
||||||
|
onChange={(e) => setReleaseInstallSchemaJson(e.target.value)}
|
||||||
|
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Config Templates JSON (array)</Label>
|
||||||
|
<input
|
||||||
|
key={releaseTemplatesFileInputKey}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
className="block w-full text-xs"
|
||||||
|
onChange={(e) => setReleaseTemplatesFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{releaseTemplatesFile && (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
<p>File secili: {releaseTemplatesFile.name}. Bu dosya, alttaki metni override eder.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReleaseTemplatesFile(null);
|
||||||
|
setReleaseTemplatesFileInputKey((prev) => prev + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
value={releaseTemplatesJson}
|
||||||
|
onChange={(e) => setReleaseTemplatesJson(e.target.value)}
|
||||||
|
className="min-h-[180px] w-full rounded-md border bg-background px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
createReleaseMutation.isPending ||
|
||||||
|
createUploadReleaseMutation.isPending ||
|
||||||
|
!selectedPlugin
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(createReleaseMutation.isPending || createUploadReleaseMutation.isPending)
|
||||||
|
? 'Publishing...'
|
||||||
|
: 'Publish Release'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ export function LoginPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ export function RegisterPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
<div className="flex min-h-screen items-center justify-center bg-transparent p-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg bg-primary">
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Settings2, FileText, Save } from 'lucide-react';
|
import { Settings2, FileText, Save } from 'lucide-react';
|
||||||
|
|
@ -30,6 +30,24 @@ interface ConfigDetail {
|
||||||
raw: string;
|
raw: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeConfigEntries(
|
||||||
|
entries: ConfigEntry[],
|
||||||
|
editableKeys: string[] | null,
|
||||||
|
): ConfigEntry[] {
|
||||||
|
if (!editableKeys || editableKeys.length === 0) return entries;
|
||||||
|
|
||||||
|
const existing = new Map(entries.map((entry) => [entry.key, entry]));
|
||||||
|
const merged = [...entries];
|
||||||
|
|
||||||
|
for (const key of editableKeys) {
|
||||||
|
if (!existing.has(key)) {
|
||||||
|
merged.push({ key, value: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -102,13 +120,11 @@ function ConfigEditor({
|
||||||
});
|
});
|
||||||
|
|
||||||
const [entries, setEntries] = useState<ConfigEntry[]>([]);
|
const [entries, setEntries] = useState<ConfigEntry[]>([]);
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
// Initialize entries from server data
|
useEffect(() => {
|
||||||
if (detail && !initialized) {
|
if (!detail) return;
|
||||||
setEntries(detail.entries);
|
setEntries(mergeConfigEntries(detail.entries, configFile.editableKeys));
|
||||||
setInitialized(true);
|
}, [detail, configFile.editableKeys]);
|
||||||
}
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
mutationFn: (data: { entries: ConfigEntry[] }) =>
|
||||||
|
|
@ -129,14 +145,6 @@ function ConfigEditor({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayEntries = configFile.editableKeys
|
|
||||||
? entries.filter((e) => configFile.editableKeys!.includes(e.key))
|
|
||||||
: 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">
|
||||||
|
|
@ -147,13 +155,13 @@ function ConfigEditor({
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{configFile.editableKeys
|
{configFile.editableKeys
|
||||||
? `${configFile.editableKeys.length} editable keys`
|
? `${configFile.editableKeys.length} allowed additions, plus existing keys`
|
||||||
: 'All keys editable'}
|
: 'All detected keys editable'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => saveMutation.mutate({ entries: entriesToSave })}
|
onClick={() => saveMutation.mutate({ entries })}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
>
|
>
|
||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
|
|
@ -161,13 +169,13 @@ function ConfigEditor({
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{displayEntries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
{detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'}
|
{detail ? 'No entries found. The server may need to be started first to generate config files.' : 'Loading...'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{displayEntries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<div key={entry.key} className="grid gap-1.5">
|
<div key={entry.key} className="grid gap-1.5">
|
||||||
<Label className="font-mono text-xs text-muted-foreground">
|
<Label className="font-mono text-xs text-muted-foreground">
|
||||||
{entry.key}
|
{entry.key}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useOutletContext, useParams } from 'react-router';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
import { FitAddon } from '@xterm/addon-fit';
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
|
|
@ -10,17 +10,50 @@ import { Button } from '@/components/ui/button';
|
||||||
import { Send } from 'lucide-react';
|
import { Send } from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
|
||||||
|
interface ConsoleOutletContext {
|
||||||
|
server?: {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ConsolePage() {
|
export function ConsolePage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
|
const { server } = useOutletContext<ConsoleOutletContext>();
|
||||||
const termRef = useRef<HTMLDivElement>(null);
|
const termRef = useRef<HTMLDivElement>(null);
|
||||||
const terminalRef = useRef<Terminal | null>(null);
|
const terminalRef = useRef<Terminal | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const serverStatusRef = useRef<string | null>(server?.status ?? null);
|
||||||
|
const rejoinTimeoutRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
||||||
const [command, setCommand] = useState('');
|
const [command, setCommand] = useState('');
|
||||||
const [history, setHistory] = useState<string[]>([]);
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!termRef.current) return;
|
serverStatusRef.current = server?.status ?? null;
|
||||||
|
}, [server?.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!termRef.current || !serverId) return;
|
||||||
|
|
||||||
|
const joinConsole = () => {
|
||||||
|
connectSocket();
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('server:console:join', { serverId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRejoin = (delayMs = 1_000) => {
|
||||||
|
const status = serverStatusRef.current;
|
||||||
|
if (status !== 'starting' && status !== 'running') return;
|
||||||
|
|
||||||
|
if (rejoinTimeoutRef.current) {
|
||||||
|
window.clearTimeout(rejoinTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejoinTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
rejoinTimeoutRef.current = null;
|
||||||
|
joinConsole();
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
cursorBlink: false,
|
cursorBlink: false,
|
||||||
|
|
@ -48,33 +81,72 @@ export function ConsolePage() {
|
||||||
|
|
||||||
terminal.writeln('\x1b[90m--- Console connected ---\x1b[0m');
|
terminal.writeln('\x1b[90m--- Console connected ---\x1b[0m');
|
||||||
|
|
||||||
// Socket.IO connection
|
|
||||||
connectSocket();
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
|
const handleConnect = () => {
|
||||||
socket.emit('server:console:join', { serverId });
|
joinConsole();
|
||||||
|
};
|
||||||
|
|
||||||
const handleOutput = (data: { line: string }) => {
|
const handleOutput = (data: { line: string }) => {
|
||||||
terminal.writeln(data.line);
|
terminal.writeln(data.line);
|
||||||
|
if (data.line === '[console] Stream ended') {
|
||||||
|
scheduleRejoin();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleCommandAck = (data: { ok: boolean; error?: string }) => {
|
||||||
|
if (!data.ok && data.error) {
|
||||||
|
terminal.writeln(`[error] ${data.error}`);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
socket.on('connect', handleConnect);
|
||||||
socket.on('server:console:output', handleOutput);
|
socket.on('server:console:output', handleOutput);
|
||||||
|
socket.on('server:console:command:ack', handleCommandAck);
|
||||||
|
|
||||||
const handleResize = () => fitAddon.fit();
|
const handleResize = () => fitAddon.fit();
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
joinConsole();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (rejoinTimeoutRef.current) {
|
||||||
|
window.clearTimeout(rejoinTimeoutRef.current);
|
||||||
|
rejoinTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
socket.off('connect', handleConnect);
|
||||||
socket.off('server:console:output', handleOutput);
|
socket.off('server:console:output', handleOutput);
|
||||||
|
socket.off('server:console:command:ack', handleCommandAck);
|
||||||
socket.emit('server:console:leave', { serverId });
|
socket.emit('server:console:leave', { serverId });
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
};
|
};
|
||||||
}, [serverId]);
|
}, [serverId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
const status = server?.status;
|
||||||
|
if (status !== 'starting' && status !== 'running') {
|
||||||
|
if (rejoinTimeoutRef.current) {
|
||||||
|
window.clearTimeout(rejoinTimeoutRef.current);
|
||||||
|
rejoinTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectSocket();
|
||||||
|
const socket = getSocket();
|
||||||
|
socket.emit('server:console:join', { serverId });
|
||||||
|
}, [server?.status, serverId]);
|
||||||
|
|
||||||
const sendCommand = () => {
|
const sendCommand = () => {
|
||||||
if (!command.trim()) return;
|
if (!command.trim()) return;
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
socket.emit('server:console:command', { serverId, orgId, command: command.trim() });
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
socket.emit('server:console:command', {
|
||||||
|
serverId,
|
||||||
|
orgId,
|
||||||
|
command: command.trim(),
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
setHistory((prev) => [...prev, command.trim()]);
|
setHistory((prev) => [...prev, command.trim()]);
|
||||||
setHistoryIndex(-1);
|
setHistoryIndex(-1);
|
||||||
setCommand('');
|
setCommand('');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,320 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Database, ExternalLink, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { ApiError, api } from '@/lib/api';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface ManagedDatabase {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
databaseName: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
phpMyAdminUrl: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||||
|
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatabasesPage() {
|
||||||
|
const { orgId, serverId } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [createName, setCreateName] = useState('');
|
||||||
|
const [createPassword, setCreatePassword] = useState('');
|
||||||
|
const [editingDatabase, setEditingDatabase] = useState<ManagedDatabase | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editPassword, setEditPassword] = useState('');
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['server-databases', orgId, serverId],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ data: ManagedDatabase[] }>(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/databases`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editingDatabase) return;
|
||||||
|
setEditName(editingDatabase.name);
|
||||||
|
setEditPassword('');
|
||||||
|
}, [editingDatabase]);
|
||||||
|
|
||||||
|
const databases = data?.data ?? [];
|
||||||
|
|
||||||
|
const resetCreateForm = () => {
|
||||||
|
setCreateName('');
|
||||||
|
setCreatePassword('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (body: { name: string; password?: string }) =>
|
||||||
|
api.post<ManagedDatabase>(`/organizations/${orgId}/servers/${serverId}/databases`, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
resetCreateForm();
|
||||||
|
toast.success('Database created');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to create database'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (body: { name?: string; password?: string }) =>
|
||||||
|
api.patch<ManagedDatabase>(
|
||||||
|
`/organizations/${orgId}/servers/${serverId}/databases/${editingDatabase!.id}`,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
|
||||||
|
setEditingDatabase(null);
|
||||||
|
setEditPassword('');
|
||||||
|
toast.success('Database updated');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to update database'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (databaseId: string) =>
|
||||||
|
api.delete(`/organizations/${orgId}/servers/${serverId}/databases/${databaseId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
|
||||||
|
toast.success('Database deleted');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to delete database'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Databases</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Unlimited MySQL databases for this server, with password rotation and phpMyAdmin links.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCreateOpen(open);
|
||||||
|
if (!open) resetCreateForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button>
|
||||||
|
<Plus className="h-4 w-4" /> Create Database
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create MySQL Database</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
createMutation.mutate({
|
||||||
|
name: createName,
|
||||||
|
password: createPassword.trim() || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Label</Label>
|
||||||
|
<Input
|
||||||
|
value={createName}
|
||||||
|
onChange={(event) => setCreateName(event.target.value)}
|
||||||
|
placeholder="LuckPerms"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Password (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={createPassword}
|
||||||
|
onChange={(event) => setCreatePassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
placeholder="Leave empty to auto-generate"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
If left empty, the panel generates a strong password automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(editingDatabase)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setEditingDatabase(null);
|
||||||
|
setEditPassword('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Database</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
updateMutation.mutate({
|
||||||
|
name: editName !== editingDatabase?.name ? editName : undefined,
|
||||||
|
password: editPassword.trim() || undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Label</Label>
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(event) => setEditName(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>New Password (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(event) => setEditPassword(event.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
placeholder="Leave empty to keep the current password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Entering a value rotates the MySQL user password immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={updateMutation.isPending}>
|
||||||
|
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : databases.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||||
|
No databases yet. Create one for plugins, web panels, or server-side data.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{databases.map((database) => (
|
||||||
|
<Card key={database.id}>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-5 w-5 text-primary" />
|
||||||
|
<CardTitle className="text-base">{database.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Created {new Date(database.createdAt).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{database.phpMyAdminUrl ? (
|
||||||
|
<Button asChild size="sm" variant="outline">
|
||||||
|
<a href={database.phpMyAdminUrl} rel="noreferrer" target="_blank">
|
||||||
|
<ExternalLink className="h-4 w-4" /> phpMyAdmin
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setEditingDatabase(database)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" /> Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Delete "${database.name}" and permanently drop ${database.databaseName}?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
deleteMutation.mutate(database.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<InfoRow label="Host" value={database.host} />
|
||||||
|
<InfoRow label="Port" value={String(database.port)} />
|
||||||
|
<InfoRow label="Database" value={database.databaseName} />
|
||||||
|
<InfoRow label="Username" value={database.username} />
|
||||||
|
</div>
|
||||||
|
<InfoRow label="Password" value={database.password} />
|
||||||
|
<InfoRow
|
||||||
|
label="Connection URI"
|
||||||
|
value={`mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}`}
|
||||||
|
/>
|
||||||
|
{!database.phpMyAdminUrl ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the daemon config for this node.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -31,9 +31,30 @@ import {
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface PluginInstallField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'number' | 'boolean' | 'select';
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
defaultValue?: unknown;
|
||||||
|
options?: Array<{ label: string; value: string }>;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
pattern?: string;
|
||||||
|
secret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstalledPluginRelease {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
installSchema: PluginInstallField[];
|
||||||
|
}
|
||||||
|
|
||||||
interface InstalledPlugin {
|
interface InstalledPlugin {
|
||||||
id: string;
|
id: string;
|
||||||
pluginId: string;
|
pluginId: string;
|
||||||
|
releaseId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
|
@ -41,7 +62,17 @@ interface InstalledPlugin {
|
||||||
externalId: string | null;
|
externalId: string | null;
|
||||||
installedVersion: string | null;
|
installedVersion: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
installOptions: Record<string, unknown>;
|
||||||
|
autoUpdateChannel: 'stable' | 'beta' | 'alpha';
|
||||||
|
isPinned: boolean;
|
||||||
|
status: 'installed' | 'updating' | 'failed';
|
||||||
|
lastError: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
latestReleaseId: string | null;
|
||||||
|
latestVersion: string | null;
|
||||||
|
latestChannel: 'stable' | 'beta' | 'alpha' | null;
|
||||||
installedAt: string;
|
installedAt: string;
|
||||||
|
currentRelease: InstalledPluginRelease | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpigetResult {
|
interface SpigetResult {
|
||||||
|
|
@ -68,7 +99,19 @@ interface MarketplacePlugin {
|
||||||
installId: string | null;
|
installId: string | null;
|
||||||
installedVersion: string | null;
|
installedVersion: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
autoUpdateChannel: 'stable' | 'beta' | 'alpha';
|
||||||
installedAt: string | null;
|
installedAt: string | null;
|
||||||
|
releaseId: string | null;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
latestRelease: {
|
||||||
|
id: string;
|
||||||
|
version: string;
|
||||||
|
channel: 'stable' | 'beta' | 'alpha';
|
||||||
|
artifactType: 'file' | 'zip';
|
||||||
|
artifactUrl: string;
|
||||||
|
installSchema: PluginInstallField[];
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MarketplaceResponse {
|
interface MarketplaceResponse {
|
||||||
|
|
@ -90,6 +133,91 @@ function extractApiMessage(error: unknown, fallback: string): string {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInstallOptionsState(
|
||||||
|
fields: PluginInstallField[],
|
||||||
|
current: Record<string, unknown> = {},
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.defaultValue !== undefined) {
|
||||||
|
next[field.key] = field.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(current)) {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginInstallSchemaFields({
|
||||||
|
fields,
|
||||||
|
values,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
fields: PluginInstallField[];
|
||||||
|
values: Record<string, unknown>;
|
||||||
|
onChange: (next: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<div className="space-y-2" key={field.key}>
|
||||||
|
<Label>{field.label}</Label>
|
||||||
|
{field.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={String(values[field.key] ?? '')}
|
||||||
|
onChange={(e) => onChange({ ...values, [field.key]: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{(field.options ?? []).map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.type === 'boolean' ? (
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(values[field.key])}
|
||||||
|
onChange={(e) => onChange({ ...values, [field.key]: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
type={field.type === 'number' ? 'number' : field.secret ? 'password' : 'text'}
|
||||||
|
value={String(values[field.key] ?? '')}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...values,
|
||||||
|
[field.key]:
|
||||||
|
field.type === 'number'
|
||||||
|
? e.target.value === ''
|
||||||
|
? ''
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={field.type === 'number' ? field.min : undefined}
|
||||||
|
max={field.type === 'number' ? field.max : undefined}
|
||||||
|
pattern={field.type === 'text' ? field.pattern : undefined}
|
||||||
|
required={Boolean(field.required)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.description && (
|
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function PluginsPage() {
|
export function PluginsPage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
|
const { server } = useOutletContext<{ server?: { gameSlug: string } }>();
|
||||||
|
|
@ -161,6 +289,11 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
const [downloadUrl, setDownloadUrl] = useState('');
|
const [downloadUrl, setDownloadUrl] = useState('');
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
|
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||||
|
const [installTarget, setInstallTarget] = useState<MarketplacePlugin | null>(null);
|
||||||
|
const [installOptions, setInstallOptions] = useState<Record<string, unknown>>({});
|
||||||
|
const [installPinVersion, setInstallPinVersion] = useState(false);
|
||||||
|
const [installAutoUpdateChannel, setInstallAutoUpdateChannel] = useState<'stable' | 'beta' | 'alpha'>('stable');
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
queryKey: ['plugin-marketplace', orgId, serverId, searchTerm],
|
||||||
|
|
@ -172,10 +305,24 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
});
|
});
|
||||||
|
|
||||||
const installMutation = useMutation({
|
const installMutation = useMutation({
|
||||||
mutationFn: (pluginId: string) =>
|
mutationFn: ({
|
||||||
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`),
|
pluginId,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
pluginId: string;
|
||||||
|
payload?: {
|
||||||
|
releaseId?: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
pinVersion?: boolean;
|
||||||
|
autoUpdateChannel?: 'stable' | 'beta' | 'alpha';
|
||||||
|
};
|
||||||
|
}) =>
|
||||||
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/install/${pluginId}`, payload ?? {}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Plugin installed');
|
toast.success('Plugin installed');
|
||||||
|
setInstallDialogOpen(false);
|
||||||
|
setInstallTarget(null);
|
||||||
|
setInstallOptions({});
|
||||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
},
|
},
|
||||||
|
|
@ -197,6 +344,19 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateInstallMutation = useMutation({
|
||||||
|
mutationFn: ({ installId }: { installId: string }) =>
|
||||||
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${installId}/update`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin updated');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin update failed'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (body: {
|
mutationFn: (body: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -278,6 +438,26 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openInstallDialog = (plugin: MarketplacePlugin) => {
|
||||||
|
const fields = plugin.latestRelease?.installSchema ?? [];
|
||||||
|
setInstallTarget(plugin);
|
||||||
|
setInstallOptions(buildInstallOptionsState(fields));
|
||||||
|
setInstallPinVersion(false);
|
||||||
|
setInstallAutoUpdateChannel('stable');
|
||||||
|
setInstallDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const installDirect = (plugin: MarketplacePlugin) => {
|
||||||
|
installMutation.mutate({
|
||||||
|
pluginId: plugin.id,
|
||||||
|
payload: {
|
||||||
|
releaseId: plugin.latestRelease?.id ?? undefined,
|
||||||
|
pinVersion: false,
|
||||||
|
autoUpdateChannel: 'stable',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const plugins = data?.plugins ?? [];
|
const plugins = data?.plugins ?? [];
|
||||||
const gameName = data?.game.name ?? 'Game';
|
const gameName = data?.game.name ?? 'Game';
|
||||||
|
|
||||||
|
|
@ -453,18 +633,35 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="font-medium">{plugin.name}</p>
|
<p className="font-medium">{plugin.name}</p>
|
||||||
<Badge variant="outline">{plugin.source}</Badge>
|
<Badge variant="outline">{plugin.source}</Badge>
|
||||||
{plugin.version && <Badge variant="secondary">v{plugin.version}</Badge>}
|
{plugin.latestRelease?.version && (
|
||||||
|
<Badge variant="secondary">v{plugin.latestRelease.version}</Badge>
|
||||||
|
)}
|
||||||
{plugin.isInstalled && <Badge>Installed</Badge>}
|
{plugin.isInstalled && <Badge>Installed</Badge>}
|
||||||
|
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
|
||||||
</div>
|
</div>
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
||||||
)}
|
)}
|
||||||
{plugin.downloadUrl && (
|
{plugin.latestRelease?.artifactUrl && (
|
||||||
<p className="line-clamp-1 text-xs text-muted-foreground">{plugin.downloadUrl}</p>
|
<p className="line-clamp-1 text-xs text-muted-foreground">
|
||||||
|
{plugin.latestRelease.artifactUrl}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{plugin.isInstalled && plugin.installId ? (
|
{plugin.isInstalled && plugin.installId ? (
|
||||||
|
<>
|
||||||
|
{plugin.updateAvailable && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => updateInstallMutation.mutate({ installId: plugin.installId! })}
|
||||||
|
disabled={updateInstallMutation.isPending}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Güncelle
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -474,16 +671,30 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
Kaldır
|
Kaldır
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(plugin.latestRelease?.installSchema?.length ?? 0) > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openInstallDialog(plugin)}
|
||||||
|
disabled={installMutation.isPending}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Ayarla ve Kur
|
||||||
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => installMutation.mutate(plugin.id)}
|
onClick={() => installDirect(plugin)}
|
||||||
disabled={installMutation.isPending}
|
disabled={installMutation.isPending}
|
||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
Kur
|
Kur
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
@ -508,6 +719,70 @@ function MarketplacePlugins({ orgId, serverId }: { orgId: string; serverId: stri
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Install Plugin
|
||||||
|
{installTarget ? ` - ${installTarget.name}` : ''}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!installTarget) return;
|
||||||
|
|
||||||
|
installMutation.mutate({
|
||||||
|
pluginId: installTarget.id,
|
||||||
|
payload: {
|
||||||
|
releaseId: installTarget.latestRelease?.id ?? undefined,
|
||||||
|
options: installOptions,
|
||||||
|
pinVersion: installPinVersion,
|
||||||
|
autoUpdateChannel: installAutoUpdateChannel,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PluginInstallSchemaFields
|
||||||
|
fields={installTarget?.latestRelease?.installSchema ?? []}
|
||||||
|
values={installOptions}
|
||||||
|
onChange={setInstallOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Auto Update Channel</Label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border bg-background px-3 text-sm"
|
||||||
|
value={installAutoUpdateChannel}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInstallAutoUpdateChannel(e.target.value as 'stable' | 'beta' | 'alpha')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="stable">stable</option>
|
||||||
|
<option value="beta">beta</option>
|
||||||
|
<option value="alpha">alpha</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={installPinVersion}
|
||||||
|
onChange={(e) => setInstallPinVersion(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Pin this release version
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={installMutation.isPending || !installTarget}>
|
||||||
|
{installMutation.isPending ? 'Installing...' : 'Install'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -522,6 +797,9 @@ function InstalledPlugins({
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
|
||||||
|
const [configureTarget, setConfigureTarget] = useState<InstalledPlugin | null>(null);
|
||||||
|
const [configureOptions, setConfigureOptions] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
|
|
@ -545,6 +823,50 @@ function InstalledPlugins({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/update`),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin güncellendi');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin güncellenemedi'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const configureMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
payload: {
|
||||||
|
releaseId: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}) => api.post(`/organizations/${orgId}/servers/${serverId}/plugins/${id}/update`, payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Plugin ayarları güncellendi');
|
||||||
|
setConfigureDialogOpen(false);
|
||||||
|
setConfigureTarget(null);
|
||||||
|
setConfigureOptions({});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Plugin ayarları güncellenemedi'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openConfigureDialog = (plugin: InstalledPlugin) => {
|
||||||
|
const fields = plugin.currentRelease?.installSchema ?? [];
|
||||||
|
setConfigureTarget(plugin);
|
||||||
|
setConfigureOptions(buildInstallOptionsState(fields, plugin.installOptions));
|
||||||
|
setConfigureDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (installed.length === 0) {
|
if (installed.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -560,24 +882,63 @@ function InstalledPlugins({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{installed.map((plugin) => (
|
{installed.map((plugin) => (
|
||||||
<Card key={plugin.id}>
|
<Card key={plugin.id}>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Puzzle className="h-5 w-5 text-primary" />
|
<Puzzle className="h-5 w-5 text-primary" />
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="font-medium">{plugin.name}</p>
|
<p className="font-medium">{plugin.name}</p>
|
||||||
<Badge variant="outline">{plugin.source}</Badge>
|
<Badge variant="outline">{plugin.source}</Badge>
|
||||||
|
{plugin.installedVersion && <Badge variant="secondary">v{plugin.installedVersion}</Badge>}
|
||||||
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
|
{!plugin.isActive && <Badge variant="secondary">Disabled</Badge>}
|
||||||
|
{plugin.status !== 'installed' && (
|
||||||
|
<Badge variant={plugin.status === 'failed' ? 'destructive' : 'outline'}>
|
||||||
|
{plugin.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{plugin.updateAvailable && <Badge variant="destructive">Update Available</Badge>}
|
||||||
</div>
|
</div>
|
||||||
{plugin.description && (
|
{plugin.description && (
|
||||||
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
<p className="text-sm text-muted-foreground">{plugin.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{plugin.latestVersion && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Latest: v{plugin.latestVersion}
|
||||||
|
{plugin.latestChannel ? ` (${plugin.latestChannel})` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{plugin.lastError && (
|
||||||
|
<p className="text-xs text-destructive">{plugin.lastError}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{plugin.currentRelease && plugin.currentRelease.installSchema.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => openConfigureDialog(plugin)}
|
||||||
|
title="Ayarları düzenle"
|
||||||
|
disabled={configureMutation.isPending}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{plugin.updateAvailable && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => updateMutation.mutate(plugin.id)}
|
||||||
|
title="Update"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -602,6 +963,57 @@ function InstalledPlugins({
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={configureDialogOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setConfigureDialogOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setConfigureTarget(null);
|
||||||
|
setConfigureOptions({});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Plugin Ayarları
|
||||||
|
{configureTarget ? ` - ${configureTarget.name}` : ''}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!configureTarget?.currentRelease) return;
|
||||||
|
|
||||||
|
configureMutation.mutate({
|
||||||
|
id: configureTarget.id,
|
||||||
|
payload: {
|
||||||
|
releaseId: configureTarget.currentRelease.id,
|
||||||
|
options: configureOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PluginInstallSchemaFields
|
||||||
|
fields={configureTarget?.currentRelease?.installSchema ?? []}
|
||||||
|
values={configureOptions}
|
||||||
|
onChange={setConfigureOptions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={configureMutation.isPending || !configureTarget?.currentRelease}
|
||||||
|
>
|
||||||
|
{configureMutation.isPending ? 'Kaydediliyor...' : 'Kaydet'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -696,18 +1108,18 @@ function SpigetSearch({ orgId, serverId }: { orgId: string; serverId: string })
|
||||||
function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string }) {
|
function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [fileName, setFileName] = useState('');
|
const [filePath, setFilePath] = useState('');
|
||||||
const [version, setVersion] = useState('');
|
const [version, setVersion] = useState('');
|
||||||
|
|
||||||
const installMutation = useMutation({
|
const installMutation = useMutation({
|
||||||
mutationFn: (body: { name: string; fileName: string; version?: string }) =>
|
mutationFn: (body: { name: string; filePath: 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');
|
toast.success('Plugin registered');
|
||||||
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
queryClient.invalidateQueries({ queryKey: ['plugins', orgId, serverId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
queryClient.invalidateQueries({ queryKey: ['plugin-marketplace', orgId, serverId] });
|
||||||
setName('');
|
setName('');
|
||||||
setFileName('');
|
setFilePath('');
|
||||||
setVersion('');
|
setVersion('');
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
|
|
@ -727,7 +1139,7 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
installMutation.mutate({
|
installMutation.mutate({
|
||||||
name,
|
name,
|
||||||
fileName,
|
filePath,
|
||||||
version: version || undefined,
|
version: version || undefined,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
@ -737,15 +1149,15 @@ function ManualInstall({ orgId, serverId }: { orgId: string; serverId: string })
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>File Name</Label>
|
<Label>File Path</Label>
|
||||||
<Input
|
<Input
|
||||||
value={fileName}
|
value={filePath}
|
||||||
onChange={(e) => setFileName(e.target.value)}
|
onChange={(e) => setFilePath(e.target.value)}
|
||||||
placeholder="plugin.jar"
|
placeholder="plugins/plugin.jar"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Upload the file to the correct plugin directory via Files tab first.
|
Files sekmesinden dosyayı önce sunucuya yükleyin. Relative path girerseniz oyunun varsayılan plugin dizinine göre çözülür.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useOutletContext, useParams } from 'react-router';
|
import { useNavigate, useOutletContext, useParams } from 'react-router';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ApiError, api } from '@/lib/api';
|
import { ApiError, api } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -12,15 +13,50 @@ import { formatBytes } from '@/lib/utils';
|
||||||
|
|
||||||
interface ServerDetail {
|
interface ServerDetail {
|
||||||
id: string;
|
id: string;
|
||||||
|
gameId: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
diskLimit: number;
|
diskLimit: number;
|
||||||
cpuLimit: number;
|
cpuLimit: number;
|
||||||
startupOverride?: string;
|
startupOverride?: string | null;
|
||||||
environment?: Record<string, string>;
|
environment?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GameEnvironmentVar {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
default?: string;
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
inputType?: 'text' | 'boolean';
|
||||||
|
composeInto?: string;
|
||||||
|
flagValue?: string;
|
||||||
|
enabledLabel?: string;
|
||||||
|
disabledLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GameDefinition {
|
||||||
|
id: string;
|
||||||
|
startupCommand: string;
|
||||||
|
environmentVars?: GameEnvironmentVar[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvironmentField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
defaultValue: string;
|
||||||
|
description: string;
|
||||||
|
required: boolean;
|
||||||
|
inputType: 'text' | 'boolean';
|
||||||
|
composeInto?: string;
|
||||||
|
flagValue?: string;
|
||||||
|
enabledLabel?: string;
|
||||||
|
disabledLabel?: string;
|
||||||
|
isCustom: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
type AutomationEvent =
|
type AutomationEvent =
|
||||||
| 'server.created'
|
| 'server.created'
|
||||||
| 'server.install.completed'
|
| 'server.install.completed'
|
||||||
|
|
@ -65,23 +101,182 @@ function extractApiMessage(error: unknown, fallback: string): string {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeStringRecord(value: unknown): Record<string, string> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
||||||
|
|
||||||
|
const normalized: Record<string, string> = {};
|
||||||
|
for (const [key, entryValue] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
const normalizedKey = key.trim();
|
||||||
|
if (!normalizedKey) continue;
|
||||||
|
normalized[normalizedKey] = String(entryValue ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvironmentFields(
|
||||||
|
game: GameDefinition | undefined,
|
||||||
|
serverEnvironment: unknown,
|
||||||
|
): EnvironmentField[] {
|
||||||
|
const overrides = normalizeStringRecord(serverEnvironment);
|
||||||
|
const fields: EnvironmentField[] = [];
|
||||||
|
const knownKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const variable of game?.environmentVars ?? []) {
|
||||||
|
const key = variable.key?.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
|
||||||
|
const composeInto = variable.composeInto?.trim();
|
||||||
|
const flagValue = variable.flagValue?.trim();
|
||||||
|
if (!composeInto) {
|
||||||
|
knownKeys.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composeInto && flagValue) {
|
||||||
|
const baseValue = overrides[composeInto] ?? '';
|
||||||
|
const tokens = baseValue.trim() ? baseValue.trim().split(/\s+/) : [];
|
||||||
|
fields.push({
|
||||||
|
key,
|
||||||
|
label: variable.label?.trim() || key,
|
||||||
|
value: tokens.includes(flagValue) ? 'true' : 'false',
|
||||||
|
defaultValue: 'false',
|
||||||
|
description: variable.description ?? '',
|
||||||
|
required: Boolean(variable.required),
|
||||||
|
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
|
||||||
|
composeInto,
|
||||||
|
flagValue,
|
||||||
|
enabledLabel: variable.enabledLabel,
|
||||||
|
disabledLabel: variable.disabledLabel,
|
||||||
|
isCustom: false,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
key,
|
||||||
|
label: variable.label?.trim() || key,
|
||||||
|
value: overrides[key] ?? String(variable.default ?? ''),
|
||||||
|
defaultValue: String(variable.default ?? ''),
|
||||||
|
description: variable.description ?? '',
|
||||||
|
required: Boolean(variable.required),
|
||||||
|
inputType: variable.inputType === 'boolean' ? 'boolean' : 'text',
|
||||||
|
composeInto,
|
||||||
|
flagValue,
|
||||||
|
enabledLabel: variable.enabledLabel,
|
||||||
|
disabledLabel: variable.disabledLabel,
|
||||||
|
isCustom: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(overrides)) {
|
||||||
|
if (knownKeys.has(key)) continue;
|
||||||
|
fields.push({
|
||||||
|
key,
|
||||||
|
label: key,
|
||||||
|
value,
|
||||||
|
defaultValue: '',
|
||||||
|
description: '',
|
||||||
|
required: false,
|
||||||
|
inputType: 'text',
|
||||||
|
isCustom: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnvironmentPayload(fields: EnvironmentField[]): Record<string, string> {
|
||||||
|
const payload: Record<string, string> = {};
|
||||||
|
const defaults = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const key = field.key.trim();
|
||||||
|
if (!key) continue;
|
||||||
|
if (!field.isCustom) {
|
||||||
|
defaults.set(key, field.defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.isCustom) {
|
||||||
|
payload[key] = field.value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.composeInto) continue;
|
||||||
|
|
||||||
|
if (field.value !== field.defaultValue) {
|
||||||
|
payload[key] = field.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.isCustom || !field.composeInto || !field.flagValue) continue;
|
||||||
|
|
||||||
|
const targetKey = field.composeInto.trim();
|
||||||
|
if (!targetKey) continue;
|
||||||
|
|
||||||
|
const defaultValue = defaults.get(targetKey) ?? '';
|
||||||
|
const currentValue = payload[targetKey] ?? defaultValue;
|
||||||
|
const tokens = currentValue.trim() ? currentValue.trim().split(/\s+/) : [];
|
||||||
|
const nextTokens = tokens.filter((token) => token !== field.flagValue);
|
||||||
|
if (field.value === 'true') {
|
||||||
|
nextTokens.push(field.flagValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = nextTokens.join(' ').trim();
|
||||||
|
if (!nextValue || nextValue === defaultValue) {
|
||||||
|
delete payload[targetKey];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload[targetKey] = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
export function ServerSettingsPage() {
|
export function ServerSettingsPage() {
|
||||||
const { orgId, serverId } = useParams();
|
const { orgId, serverId } = useParams();
|
||||||
const navigate = useNavigate();
|
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('');
|
||||||
const [description, setDescription] = useState(server?.description ?? '');
|
const [description, setDescription] = useState('');
|
||||||
|
const [startupOverride, setStartupOverride] = useState('');
|
||||||
|
const [environmentFields, setEnvironmentFields] = useState<EnvironmentField[]>([]);
|
||||||
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
const [automationEvent, setAutomationEvent] = useState<AutomationEvent>('server.install.completed');
|
||||||
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
const [forceAutomationRun, setForceAutomationRun] = useState(false);
|
||||||
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
const [lastAutomationResult, setLastAutomationResult] = useState<AutomationRunResult | null>(null);
|
||||||
|
|
||||||
|
const { data: gamesData } = useQuery({
|
||||||
|
queryKey: ['games'],
|
||||||
|
queryFn: () => api.get<{ data: GameDefinition[] }>('/games'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeGame = (gamesData?.data ?? []).find((game) => game.id === server?.gameId);
|
||||||
|
const serverEnvironmentJson = JSON.stringify(server?.environment ?? {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server) return;
|
||||||
|
setName(server.name);
|
||||||
|
setDescription(server.description ?? '');
|
||||||
|
}, [server?.id, server?.name, server?.description]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!server) return;
|
||||||
|
setStartupOverride(server.startupOverride ?? '');
|
||||||
|
setEnvironmentFields(buildEnvironmentFields(activeGame, server.environment));
|
||||||
|
}, [server?.id, server?.startupOverride, serverEnvironmentJson, activeGame]);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
mutationFn: (body: Record<string, unknown>) =>
|
||||||
api.patch(`/organizations/${orgId}/servers/${serverId}`, body),
|
api.patch(`/organizations/${orgId}/servers/${serverId}`, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
|
queryClient.invalidateQueries({ queryKey: ['server', orgId, serverId] });
|
||||||
|
toast.success('Server settings saved');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(extractApiMessage(error, 'Failed to save server settings'));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -117,6 +312,42 @@ export function ServerSettingsPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateEnvironmentField = (
|
||||||
|
index: number,
|
||||||
|
patch: Partial<Pick<EnvironmentField, 'key' | 'value'>>,
|
||||||
|
) => {
|
||||||
|
setEnvironmentFields((prev) =>
|
||||||
|
prev.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomEnvironmentField = () => {
|
||||||
|
setEnvironmentFields((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: '',
|
||||||
|
label: '',
|
||||||
|
value: '',
|
||||||
|
defaultValue: '',
|
||||||
|
description: '',
|
||||||
|
required: false,
|
||||||
|
inputType: 'text',
|
||||||
|
isCustom: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeEnvironmentField = (index: number) => {
|
||||||
|
setEnvironmentFields((prev) => prev.filter((_, fieldIndex) => fieldIndex !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveStartupSettings = () => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
startupOverride: startupOverride.trim(),
|
||||||
|
environment: buildEnvironmentPayload(environmentFields),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -169,6 +400,134 @@ export function ServerSettingsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Startup</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Saving these values recreates the container with the same files and restarts it if it
|
||||||
|
was running.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Startup Override</Label>
|
||||||
|
<Input
|
||||||
|
value={startupOverride}
|
||||||
|
onChange={(e) => setStartupOverride(e.target.value)}
|
||||||
|
placeholder={activeGame?.startupCommand || 'Use image default command'}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty to use the game default startup command or the image entrypoint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>Environment Variables</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add custom keys for image-specific startup switches such as extra launch args.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addCustomEnvironmentField}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Variable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{environmentFields.length === 0 ? (
|
||||||
|
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
This game does not define any startup variables yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{environmentFields.map((field, index) => (
|
||||||
|
field.isCustom ? (
|
||||||
|
<div
|
||||||
|
key={`custom-${index}`}
|
||||||
|
className="grid gap-2 rounded-md border p-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={field.key}
|
||||||
|
onChange={(e) => updateEnvironmentField(index, { key: e.target.value })}
|
||||||
|
placeholder="ENV_KEY"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
|
||||||
|
placeholder="value"
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeEnvironmentField(index)}
|
||||||
|
aria-label="Remove environment variable"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div key={field.key} className="grid gap-1.5 rounded-md border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<Label className="font-mono text-xs text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
Default: <span className="font-mono">{field.defaultValue || 'empty'}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{field.inputType === 'boolean' ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={field.value === 'true' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateEnvironmentField(index, { value: 'true' })}
|
||||||
|
>
|
||||||
|
{field.enabledLabel ?? 'Enabled'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={field.value === 'false' ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateEnvironmentField(index, { value: 'false' })}
|
||||||
|
>
|
||||||
|
{field.disabledLabel ?? 'Disabled'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={field.value}
|
||||||
|
onChange={(e) => updateEnvironmentField(index, { value: e.target.value })}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(field.description || field.required) && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{field.description || 'Required startup variable'}
|
||||||
|
{field.required ? ' Required.' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={saveStartupSettings}
|
||||||
|
disabled={updateMutation.isPending || !server}
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? 'Applying...' : 'Save Startup Settings'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Automation</CardTitle>
|
<CardTitle>Automation</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,13 @@ docker:
|
||||||
socket: "/var/run/docker.sock"
|
socket: "/var/run/docker.sock"
|
||||||
network: "gamepanel_nw"
|
network: "gamepanel_nw"
|
||||||
network_subnet: "172.18.0.0/16"
|
network_subnet: "172.18.0.0/16"
|
||||||
|
|
||||||
|
# Optional node-local MySQL/MariaDB management for server databases.
|
||||||
|
# `connection_host` should be reachable by the game containers on this node.
|
||||||
|
managed_mysql:
|
||||||
|
url: "mysql://root:change-me@127.0.0.1:3306/mysql"
|
||||||
|
connection_host: "CHANGE_ME_REACHABLE_FROM_GAME_CONTAINERS"
|
||||||
|
connection_port: 3306
|
||||||
|
phpmyadmin_url: "http://127.0.0.1:8080/"
|
||||||
|
# Optional: overrides the client binary. Defaults to trying "mariadb" then "mysql".
|
||||||
|
# bin: "mariadb"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,34 @@
|
||||||
"when": 1772300000000,
|
"when": 1772300000000,
|
||||||
"tag": "0002_cs2_add_metamod_workflow",
|
"tag": "0002_cs2_add_metamod_workflow",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772400000000,
|
||||||
|
"tag": "0003_global_plugin_registry",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772600000000,
|
||||||
|
"tag": "0004_cs2_startup_parameters",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772800000000,
|
||||||
|
"tag": "0005_cs2_servername_default",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1772900000000,
|
||||||
|
"tag": "0006_cs2_servername_branding",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,3 +8,4 @@ export * from './backups';
|
||||||
export * from './plugins';
|
export * from './plugins';
|
||||||
export * from './schedules';
|
export * from './schedules';
|
||||||
export * from './audit-logs';
|
export * from './audit-logs';
|
||||||
|
export * from './server-databases';
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,17 @@ import {
|
||||||
boolean,
|
boolean,
|
||||||
timestamp,
|
timestamp,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
|
jsonb,
|
||||||
|
bigint,
|
||||||
} from 'drizzle-orm/pg-core';
|
} from 'drizzle-orm/pg-core';
|
||||||
import { games } from './games';
|
import { games } from './games';
|
||||||
import { servers } from './servers';
|
import { servers } from './servers';
|
||||||
|
import { users } from './users';
|
||||||
|
|
||||||
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
|
export const pluginSourceEnum = pgEnum('plugin_source', ['spiget', 'manual']);
|
||||||
|
export const pluginReleaseChannelEnum = pgEnum('plugin_release_channel', ['stable', 'beta', 'alpha']);
|
||||||
|
export const pluginReleaseArtifactTypeEnum = pgEnum('plugin_release_artifact_type', ['file', 'zip']);
|
||||||
|
export const pluginInstallStatusEnum = pgEnum('plugin_install_status', ['installed', 'updating', 'failed']);
|
||||||
|
|
||||||
export const plugins = pgTable('plugins', {
|
export const plugins = pgTable('plugins', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
|
@ -24,6 +30,29 @@ export const plugins = pgTable('plugins', {
|
||||||
externalId: varchar('external_id', { length: 255 }),
|
externalId: varchar('external_id', { length: 255 }),
|
||||||
downloadUrl: text('download_url'),
|
downloadUrl: text('download_url'),
|
||||||
version: varchar('version', { length: 100 }),
|
version: varchar('version', { length: 100 }),
|
||||||
|
isGlobal: boolean('is_global').default(true).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pluginReleases = pgTable('plugin_releases', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
pluginId: uuid('plugin_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => plugins.id, { onDelete: 'cascade' }),
|
||||||
|
version: varchar('version', { length: 100 }).notNull(),
|
||||||
|
channel: pluginReleaseChannelEnum('channel').default('stable').notNull(),
|
||||||
|
artifactType: pluginReleaseArtifactTypeEnum('artifact_type').default('file').notNull(),
|
||||||
|
artifactUrl: text('artifact_url').notNull(),
|
||||||
|
destination: text('destination'),
|
||||||
|
fileName: varchar('file_name', { length: 255 }),
|
||||||
|
checksumSha256: varchar('checksum_sha256', { length: 128 }),
|
||||||
|
sizeBytes: bigint('size_bytes', { mode: 'number' }),
|
||||||
|
changelog: text('changelog'),
|
||||||
|
installSchema: jsonb('install_schema').default([]).notNull(),
|
||||||
|
configTemplates: jsonb('config_templates').default([]).notNull(),
|
||||||
|
isPublished: boolean('is_published').default(true).notNull(),
|
||||||
|
createdByUserId: uuid('created_by_user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
@ -36,7 +65,24 @@ export const serverPlugins = pgTable('server_plugins', {
|
||||||
pluginId: uuid('plugin_id')
|
pluginId: uuid('plugin_id')
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => plugins.id, { onDelete: 'cascade' }),
|
.references(() => plugins.id, { onDelete: 'cascade' }),
|
||||||
|
releaseId: uuid('release_id').references(() => pluginReleases.id, { onDelete: 'set null' }),
|
||||||
installedVersion: varchar('installed_version', { length: 100 }),
|
installedVersion: varchar('installed_version', { length: 100 }),
|
||||||
isActive: boolean('is_active').default(true).notNull(),
|
isActive: boolean('is_active').default(true).notNull(),
|
||||||
|
installOptions: jsonb('install_options').default({}).notNull(),
|
||||||
|
autoUpdateChannel: pluginReleaseChannelEnum('auto_update_channel').default('stable').notNull(),
|
||||||
|
isPinned: boolean('is_pinned').default(false).notNull(),
|
||||||
|
status: pluginInstallStatusEnum('status').default('installed').notNull(),
|
||||||
|
lastError: text('last_error'),
|
||||||
installedAt: timestamp('installed_at', { withTimezone: true }).defaultNow().notNull(),
|
installedAt: timestamp('installed_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const serverPluginFiles = pgTable('server_plugin_files', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
serverPluginId: uuid('server_plugin_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => serverPlugins.id, { onDelete: 'cascade' }),
|
||||||
|
path: text('path').notNull(),
|
||||||
|
kind: varchar('kind', { length: 32 }).default('artifact').notNull(),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
timestamp,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { servers } from './servers';
|
||||||
|
|
||||||
|
export const serverDatabases = pgTable('server_databases', {
|
||||||
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
|
serverId: uuid('server_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => servers.id, { onDelete: 'cascade' }),
|
||||||
|
name: varchar('name', { length: 255 }).notNull(),
|
||||||
|
databaseName: varchar('database_name', { length: 255 }).notNull().unique(),
|
||||||
|
username: varchar('username', { length: 64 }).notNull().unique(),
|
||||||
|
password: text('password').notNull(),
|
||||||
|
host: varchar('host', { length: 255 }).notNull(),
|
||||||
|
port: integer('port').notNull(),
|
||||||
|
phpMyAdminUrl: text('phpmyadmin_url'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,57 @@ import { createDb } from './client';
|
||||||
import { games } from './schema/games';
|
import { games } from './schema/games';
|
||||||
import { users } from './schema/users';
|
import { users } from './schema/users';
|
||||||
|
|
||||||
|
const DEFAULT_CS2_SERVER_CFG = `// ============================================
|
||||||
|
// CS2 Server Config
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// ---- Sunucu Bilgileri ----
|
||||||
|
hostname "SourceGamePanel CS2 Server"
|
||||||
|
sv_password ""
|
||||||
|
rcon_password "changeme"
|
||||||
|
sv_cheats 0
|
||||||
|
|
||||||
|
// ---- Topluluk Sunucu Gorunurlugu ----
|
||||||
|
sv_region 3
|
||||||
|
sv_tags "competitive,community"
|
||||||
|
sv_lan 0
|
||||||
|
sv_steamgroup ""
|
||||||
|
sv_steamgroup_exclusive 0
|
||||||
|
|
||||||
|
// ---- Performans ----
|
||||||
|
sv_maxrate 0
|
||||||
|
sv_minrate 64000
|
||||||
|
sv_max_queries_sec 5
|
||||||
|
sv_max_queries_window 30
|
||||||
|
sv_parallel_sendsnapshot 1
|
||||||
|
net_maxroutable 1200
|
||||||
|
|
||||||
|
// ---- Baglanti ----
|
||||||
|
sv_maxclients 16
|
||||||
|
sv_timeout 60
|
||||||
|
|
||||||
|
// ---- GOTV (Tamamen Kapali) ----
|
||||||
|
tv_enable 0
|
||||||
|
tv_autorecord 0
|
||||||
|
tv_delay 0
|
||||||
|
tv_maxclients 0
|
||||||
|
tv_port 0
|
||||||
|
|
||||||
|
// ---- Loglama ----
|
||||||
|
log on
|
||||||
|
mp_logmoney 0
|
||||||
|
mp_logdetail 0
|
||||||
|
mp_logdetail_items 0
|
||||||
|
sv_logfile 1
|
||||||
|
|
||||||
|
// ---- Genel Oyun Ayarlari ----
|
||||||
|
mp_autokick 0
|
||||||
|
sv_allow_votes 0
|
||||||
|
sv_alltalk 0
|
||||||
|
sv_deadtalk 1
|
||||||
|
sv_voiceenable 1
|
||||||
|
`;
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
|
|
@ -101,9 +152,33 @@ async function seed() {
|
||||||
parser: 'keyvalue',
|
parser: 'keyvalue',
|
||||||
editableKeys: [
|
editableKeys: [
|
||||||
'hostname',
|
'hostname',
|
||||||
|
'sv_tags',
|
||||||
'sv_password',
|
'sv_password',
|
||||||
'rcon_password',
|
'rcon_password',
|
||||||
'sv_cheats',
|
'sv_cheats',
|
||||||
|
'sv_region',
|
||||||
|
'sv_lan',
|
||||||
|
'sv_steamgroup',
|
||||||
|
'sv_steamgroup_exclusive',
|
||||||
|
'sv_maxrate',
|
||||||
|
'sv_minrate',
|
||||||
|
'sv_max_queries_sec',
|
||||||
|
'sv_max_queries_window',
|
||||||
|
'sv_parallel_sendsnapshot',
|
||||||
|
'net_maxroutable',
|
||||||
|
'sv_maxclients',
|
||||||
|
'sv_timeout',
|
||||||
|
'tv_enable',
|
||||||
|
'tv_autorecord',
|
||||||
|
'tv_delay',
|
||||||
|
'tv_maxclients',
|
||||||
|
'tv_port',
|
||||||
|
'sv_logfile',
|
||||||
|
'mp_autokick',
|
||||||
|
'sv_allow_votes',
|
||||||
|
'sv_alltalk',
|
||||||
|
'sv_deadtalk',
|
||||||
|
'sv_voiceenable',
|
||||||
'mp_autoteambalance',
|
'mp_autoteambalance',
|
||||||
'mp_limitteams',
|
'mp_limitteams',
|
||||||
],
|
],
|
||||||
|
|
@ -111,6 +186,27 @@ async function seed() {
|
||||||
{ path: 'game/csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
{ path: 'game/csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
||||||
],
|
],
|
||||||
automationRules: [
|
automationRules: [
|
||||||
|
{
|
||||||
|
id: 'cs2-write-default-server-config',
|
||||||
|
event: 'server.install.completed',
|
||||||
|
enabled: true,
|
||||||
|
runOncePerServer: true,
|
||||||
|
continueOnError: false,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'write-cs2-default-server-config',
|
||||||
|
type: 'write_file',
|
||||||
|
path: '/game/csgo/cfg/server.cfg',
|
||||||
|
data: DEFAULT_CS2_SERVER_CFG,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'write-cs2-persisted-server-config',
|
||||||
|
type: 'write_file',
|
||||||
|
path: '/game/csgo/cfg/.sourcegamepanel-server.cfg',
|
||||||
|
data: DEFAULT_CS2_SERVER_CFG,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'cs2-install-latest-metamod',
|
id: 'cs2-install-latest-metamod',
|
||||||
event: 'server.install.completed',
|
event: 'server.install.completed',
|
||||||
|
|
@ -168,7 +264,12 @@ async function seed() {
|
||||||
description: 'Steam Game Server Login Token (optional for local testing)',
|
description: 'Steam Game Server Login Token (optional for local testing)',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
{ key: 'CS2_SERVERNAME', default: 'GamePanel CS2 Server', description: 'Server name', required: false },
|
{
|
||||||
|
key: 'CS2_SERVERNAME',
|
||||||
|
default: 'SourceGamePanel CS2 Server',
|
||||||
|
description: 'Server name',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
{ key: 'CS2_PORT', default: '27015', description: 'Game port', 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_STARTMAP', default: 'de_dust2', description: 'Initial map', required: false },
|
||||||
{ key: 'CS2_MAXPLAYERS', default: '16', description: 'Max players', required: false },
|
{ key: 'CS2_MAXPLAYERS', default: '16', description: 'Max players', required: false },
|
||||||
|
|
@ -179,6 +280,38 @@ async function seed() {
|
||||||
description: 'Bind address',
|
description: 'Bind address',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'CS2_HOST_WORKSHOP_COLLECTION',
|
||||||
|
default: '',
|
||||||
|
description: 'Steam Workshop collection id to load',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'CS2_HOST_WORKSHOP_MAP',
|
||||||
|
default: '',
|
||||||
|
description: 'Steam Workshop map id to launch',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{ key: 'CS2_GAMETYPE', default: '0', description: 'Game type numeric value', required: false },
|
||||||
|
{ key: 'CS2_GAMEMODE', default: '1', description: 'Game mode numeric value', required: false },
|
||||||
|
{
|
||||||
|
key: 'CS2_ADDITIONAL_ARGS',
|
||||||
|
default: '',
|
||||||
|
description: 'Extra startup arguments appended to the server launch command',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'CS2_INSECURE',
|
||||||
|
label: 'Insecure Mode',
|
||||||
|
default: '',
|
||||||
|
description: 'Toggles the -insecure launch flag inside CS2_ADDITIONAL_ARGS',
|
||||||
|
required: false,
|
||||||
|
inputType: 'boolean',
|
||||||
|
composeInto: 'CS2_ADDITIONAL_ARGS',
|
||||||
|
flagValue: '-insecure',
|
||||||
|
enabledLabel: 'Aktif',
|
||||||
|
disabledLabel: 'Pasif',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -46,11 +46,49 @@ message CreateServerRequest {
|
||||||
repeated string install_plugin_urls = 9;
|
repeated string install_plugin_urls = 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message UpdateServerRequest {
|
||||||
|
string uuid = 1;
|
||||||
|
string docker_image = 2;
|
||||||
|
int64 memory_limit = 3;
|
||||||
|
int64 disk_limit = 4;
|
||||||
|
int32 cpu_limit = 5;
|
||||||
|
string startup_command = 6;
|
||||||
|
map<string, string> environment = 7;
|
||||||
|
repeated PortMapping ports = 8;
|
||||||
|
}
|
||||||
|
|
||||||
message ServerResponse {
|
message ServerResponse {
|
||||||
string uuid = 1;
|
string uuid = 1;
|
||||||
string status = 2;
|
string status = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Managed Databases ===
|
||||||
|
|
||||||
|
message CreateDatabaseRequest {
|
||||||
|
string server_uuid = 1;
|
||||||
|
string name = 2;
|
||||||
|
string password = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateDatabasePasswordRequest {
|
||||||
|
string username = 1;
|
||||||
|
string password = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteDatabaseRequest {
|
||||||
|
string database_name = 1;
|
||||||
|
string username = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ManagedDatabaseCredentials {
|
||||||
|
string database_name = 1;
|
||||||
|
string username = 2;
|
||||||
|
string password = 3;
|
||||||
|
string host = 4;
|
||||||
|
int32 port = 5;
|
||||||
|
string phpmyadmin_url = 6;
|
||||||
|
}
|
||||||
|
|
||||||
// === Power ===
|
// === Power ===
|
||||||
|
|
||||||
enum PowerAction {
|
enum PowerAction {
|
||||||
|
|
@ -210,8 +248,12 @@ service DaemonService {
|
||||||
|
|
||||||
// Server lifecycle
|
// Server lifecycle
|
||||||
rpc CreateServer(CreateServerRequest) returns (ServerResponse);
|
rpc CreateServer(CreateServerRequest) returns (ServerResponse);
|
||||||
|
rpc UpdateServer(UpdateServerRequest) returns (ServerResponse);
|
||||||
rpc DeleteServer(ServerIdentifier) returns (Empty);
|
rpc DeleteServer(ServerIdentifier) returns (Empty);
|
||||||
rpc ReinstallServer(ServerIdentifier) returns (Empty);
|
rpc ReinstallServer(ServerIdentifier) returns (Empty);
|
||||||
|
rpc CreateDatabase(CreateDatabaseRequest) returns (ManagedDatabaseCredentials);
|
||||||
|
rpc UpdateDatabasePassword(UpdateDatabasePasswordRequest) returns (Empty);
|
||||||
|
rpc DeleteDatabase(DeleteDatabaseRequest) returns (Empty);
|
||||||
|
|
||||||
// Power
|
// Power
|
||||||
rpc SetPowerState(PowerRequest) returns (Empty);
|
rpc SetPowerState(PowerRequest) returns (Empty);
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,12 @@ export interface GameEnvVar {
|
||||||
default: string;
|
default: string;
|
||||||
description: string;
|
description: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
|
label?: string;
|
||||||
|
inputType?: 'text' | 'boolean';
|
||||||
|
composeInto?: string;
|
||||||
|
flagValue?: string;
|
||||||
|
enabledLabel?: string;
|
||||||
|
disabledLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameAutomationRule = GameAutomationWorkflow;
|
export type GameAutomationRule = GameAutomationWorkflow;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,9 @@ importers:
|
||||||
'@fastify/jwt':
|
'@fastify/jwt':
|
||||||
specifier: ^9.0.0
|
specifier: ^9.0.0
|
||||||
version: 9.1.0
|
version: 9.1.0
|
||||||
|
'@fastify/multipart':
|
||||||
|
specifier: ^9.4.0
|
||||||
|
version: 9.4.0
|
||||||
'@fastify/rate-limit':
|
'@fastify/rate-limit':
|
||||||
specifier: ^10.3.0
|
specifier: ^10.3.0
|
||||||
version: 10.3.0
|
version: 10.3.0
|
||||||
|
|
@ -59,6 +62,9 @@ importers:
|
||||||
'@sinclair/typebox':
|
'@sinclair/typebox':
|
||||||
specifier: ^0.34.0
|
specifier: ^0.34.0
|
||||||
version: 0.34.48
|
version: 0.34.48
|
||||||
|
'@source/cdn':
|
||||||
|
specifier: 1.4.0
|
||||||
|
version: 1.4.0
|
||||||
'@source/database':
|
'@source/database':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/database
|
version: link:../../packages/database
|
||||||
|
|
@ -92,6 +98,9 @@ importers:
|
||||||
unzipper:
|
unzipper:
|
||||||
specifier: ^0.12.3
|
specifier: ^0.12.3
|
||||||
version: 0.12.3
|
version: 0.12.3
|
||||||
|
yazl:
|
||||||
|
specifier: ^3.3.1
|
||||||
|
version: 3.3.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/tar-stream':
|
'@types/tar-stream':
|
||||||
specifier: ^3.1.4
|
specifier: ^3.1.4
|
||||||
|
|
@ -99,6 +108,9 @@ importers:
|
||||||
'@types/unzipper':
|
'@types/unzipper':
|
||||||
specifier: ^0.10.11
|
specifier: ^0.10.11
|
||||||
version: 0.10.11
|
version: 0.10.11
|
||||||
|
'@types/yazl':
|
||||||
|
specifier: ^3.3.0
|
||||||
|
version: 3.3.0
|
||||||
dotenv-cli:
|
dotenv-cli:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0
|
version: 8.0.0
|
||||||
|
|
@ -979,12 +991,18 @@ packages:
|
||||||
'@fastify/ajv-compiler@4.0.5':
|
'@fastify/ajv-compiler@4.0.5':
|
||||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.0':
|
||||||
|
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
|
||||||
|
|
||||||
'@fastify/cookie@11.0.2':
|
'@fastify/cookie@11.0.2':
|
||||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||||
|
|
||||||
'@fastify/cors@10.1.0':
|
'@fastify/cors@10.1.0':
|
||||||
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
|
resolution: {integrity: sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==}
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1':
|
||||||
|
resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==}
|
||||||
|
|
||||||
'@fastify/error@4.2.0':
|
'@fastify/error@4.2.0':
|
||||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||||
|
|
||||||
|
|
@ -1003,6 +1021,9 @@ packages:
|
||||||
'@fastify/merge-json-schemas@0.2.1':
|
'@fastify/merge-json-schemas@0.2.1':
|
||||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||||
|
|
||||||
|
'@fastify/multipart@9.4.0':
|
||||||
|
resolution: {integrity: sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==}
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||||
|
|
||||||
|
|
@ -1735,6 +1756,9 @@ packages:
|
||||||
'@socket.io/component-emitter@3.1.2':
|
'@socket.io/component-emitter@3.1.2':
|
||||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
|
'@source/cdn@1.4.0':
|
||||||
|
resolution: {integrity: sha512-D9WXphac1sHBhwAZhEc/iM1omXz8R5LRLi3mpYlCGUgPzFQR222j4DT5AjVJk/eobZgkF3Tw3mpSPyVUai/uOw==, tarball: https://gits.hibna.com.tr/api/packages/hibna/npm/%40source%2Fcdn/-/1.4.0/cdn-1.4.0.tgz}
|
||||||
|
|
||||||
'@tanstack/query-core@5.90.20':
|
'@tanstack/query-core@5.90.20':
|
||||||
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
|
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
|
||||||
|
|
||||||
|
|
@ -1784,6 +1808,9 @@ packages:
|
||||||
'@types/unzipper@0.10.11':
|
'@types/unzipper@0.10.11':
|
||||||
resolution: {integrity: sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==}
|
resolution: {integrity: sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==}
|
||||||
|
|
||||||
|
'@types/yazl@3.3.0':
|
||||||
|
resolution: {integrity: sha512-mFL6lGkk2N5u5nIxpNV/K5LW3qVSbxhJrMxYGOOxZndWxMgCamr/iCsq/1t9kd8pEwhuNP91LC5qZm/qS9pOEw==}
|
||||||
|
|
||||||
'@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}
|
||||||
|
|
@ -1988,6 +2015,10 @@ packages:
|
||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
buffer-crc32@1.0.0:
|
||||||
|
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
|
@ -3357,6 +3388,9 @@ packages:
|
||||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yazl@3.3.1:
|
||||||
|
resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==}
|
||||||
|
|
||||||
yocto-queue@0.1.0:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3850,6 +3884,8 @@ snapshots:
|
||||||
ajv-formats: 3.0.1(ajv@8.18.0)
|
ajv-formats: 3.0.1(ajv@8.18.0)
|
||||||
fast-uri: 3.1.0
|
fast-uri: 3.1.0
|
||||||
|
|
||||||
|
'@fastify/busboy@3.2.0': {}
|
||||||
|
|
||||||
'@fastify/cookie@11.0.2':
|
'@fastify/cookie@11.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
|
|
@ -3860,6 +3896,8 @@ snapshots:
|
||||||
fastify-plugin: 5.1.0
|
fastify-plugin: 5.1.0
|
||||||
mnemonist: 0.40.0
|
mnemonist: 0.40.0
|
||||||
|
|
||||||
|
'@fastify/deepmerge@3.2.1': {}
|
||||||
|
|
||||||
'@fastify/error@4.2.0': {}
|
'@fastify/error@4.2.0': {}
|
||||||
|
|
||||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||||
|
|
@ -3885,6 +3923,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
dequal: 2.0.3
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
'@fastify/multipart@9.4.0':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/busboy': 3.2.0
|
||||||
|
'@fastify/deepmerge': 3.2.1
|
||||||
|
'@fastify/error': 4.2.0
|
||||||
|
fastify-plugin: 5.1.0
|
||||||
|
secure-json-parse: 4.1.0
|
||||||
|
|
||||||
'@fastify/proxy-addr@5.1.0':
|
'@fastify/proxy-addr@5.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/forwarded': 3.0.1
|
'@fastify/forwarded': 3.0.1
|
||||||
|
|
@ -4556,6 +4602,8 @@ snapshots:
|
||||||
|
|
||||||
'@socket.io/component-emitter@3.1.2': {}
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
|
'@source/cdn@1.4.0': {}
|
||||||
|
|
||||||
'@tanstack/query-core@5.90.20': {}
|
'@tanstack/query-core@5.90.20': {}
|
||||||
|
|
||||||
'@tanstack/react-query@5.90.21(react@19.2.4)':
|
'@tanstack/react-query@5.90.21(react@19.2.4)':
|
||||||
|
|
@ -4617,6 +4665,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.19.11
|
'@types/node': 22.19.11
|
||||||
|
|
||||||
|
'@types/yazl@3.3.0':
|
||||||
|
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
|
||||||
|
|
@ -4844,6 +4896,8 @@ snapshots:
|
||||||
node-releases: 2.0.27
|
node-releases: 2.0.27
|
||||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||||
|
|
||||||
|
buffer-crc32@1.0.0: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
@ -6212,6 +6266,10 @@ snapshots:
|
||||||
y18n: 5.0.8
|
y18n: 5.0.8
|
||||||
yargs-parser: 21.1.1
|
yargs-parser: 21.1.1
|
||||||
|
|
||||||
|
yazl@3.3.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-crc32: 1.0.0
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
zustand@5.0.11(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue