diff --git a/src/index.ts b/src/index.ts index c2d8a54..a858e5e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { deployService } from './utils/k8s'; import { webServer } from '@/web/index'; import { Manager } from './manager'; +import { ResourceMonitor } from './manager/resource-monitor'; import { SystemRequiredDeployments } from './deployment/system'; import { defaultFilter, FileControllerManager } from './manager/file-manager'; import { DomainManager } from './manager/domain-manager'; @@ -28,6 +29,9 @@ try { // Initialize the Server Manager Manager.getInstance(); + // Start collecting pod resource metrics in the background. + ResourceMonitor.getInstance(); + // Initialize the File Controller Manager await FileControllerManager.initialize('', { filter: defaultFilter, diff --git a/src/manager/resource-monitor.ts b/src/manager/resource-monitor.ts new file mode 100644 index 0000000..da93014 --- /dev/null +++ b/src/manager/resource-monitor.ts @@ -0,0 +1,89 @@ +import { Namespace } from '@/utils/config'; +import { type PodData, coreV1Api, k8sMetrics } from '@/utils/k8s'; + +export class ResourceMonitor { + private static instance: ResourceMonitor | null = null; + private podData: Map = new Map(); + + private cronJob: NodeJS.Timeout | null = null; + + constructor(interval: number = 1_000 * 2) { + this.cronJob = setInterval(async () => { + try { + const allServerPod = await coreV1Api.listNamespacedPod({ + namespace: Namespace, + labelSelector: 'app=minecraft-server', + }); + const nextPodData: Map = new Map(); + const podNameToServerName: Map = new Map(); + + allServerPod.items.forEach((pod) => { + const podName = pod.metadata?.name; + const serverName = pod.metadata?.labels?.name || podName; + if (!podName || !serverName) { + return; + } + const allocatedCpu = + pod.spec?.containers?.[0]?.resources?.requests?.cpu || '0'; + const allocatedMemory = + pod.spec?.containers?.[0]?.resources?.requests?.memory || '0'; + podNameToServerName.set(podName, serverName); + nextPodData.set(serverName, { + name: serverName, + allocatedCpu, + allocatedMemory, + cpu: this.podData.get(serverName)?.cpu || '0', + memory: this.podData.get(serverName)?.memory || '0', + }); + }); + + const currentPodsMetrics = await k8sMetrics.getPodMetrics(Namespace); + currentPodsMetrics.items.forEach((podMetric) => { + const podName = podMetric.metadata?.name; + if (!podName) { + return; + } + const serverName = + podNameToServerName.get(podName) || + podMetric.metadata?.labels?.name || + podName; + const existingData = nextPodData.get(serverName); + if (!existingData) { + return; + } + const cpu = podMetric.containers?.[0]?.usage?.cpu || '0'; + const memory = podMetric.containers?.[0]?.usage?.memory || '0'; + nextPodData.set(serverName, { + ...existingData, + cpu, + memory, + }); + }); + this.podData = nextPodData; + } catch (error) { + console.error('Failed to update resource monitor data:', error); + } + }, interval); + } + + public static getInstance(interval?: number) { + if (!this.instance) { + this.instance = new ResourceMonitor(interval); + } + return this.instance; + } + + public getPodData() { + return Array.from(this.podData.values()); + } + + public getPodDataByName(name: string) { + return this.podData.get(name); + } + + public clean() { + this.podData.clear(); + clearInterval(this.cronJob!); + ResourceMonitor.instance = null; + } +} diff --git a/src/manager/server-controller.ts b/src/manager/server-controller.ts index de2d376..d3eb67f 100644 --- a/src/manager/server-controller.ts +++ b/src/manager/server-controller.ts @@ -22,6 +22,12 @@ export class ServerController { private rconClient: Rcon; private host: string; private port: number; + private isEnded: boolean = false; + private isConnected: boolean = false; + private retryCount: number = 0; + private maxRetries: number = 5; + private shouldReconnect: boolean = true; + private isConnectionListenerRegistered: boolean = false; constructor(host: string, port: number, log = false) { this.host = host; @@ -31,15 +37,59 @@ export class ServerController { port: this.port, password: RCONPassword, }); + this.registerConnectionListeners(); + } + + private registerConnectionListeners() { + if (this.isConnectionListenerRegistered) { + return; + } + + this.isConnectionListenerRegistered = true; + this.rconClient.on('end', () => { + this.isEnded = true; + this.isConnected = false; + if (!this.shouldReconnect) { + return; + } + if (this.retryCount >= this.maxRetries) { + console.error( + `RCON reconnection aborted after ${this.maxRetries} retries.`, + ); + this.shouldReconnect = false; + return; + } + this.retryCount++; + setTimeout( + () => { + this.connect().catch((e) => + console.error('Failed to reconnect RCON:', (e as Error).message), + ); + }, + 1000 * Math.min(2 ** this.retryCount, 30), + ); // Exponential backoff with max delay of 30 seconds + }); + this.rconClient.on('connect', () => { + this.isConnected = true; + this.isEnded = false; + this.retryCount = 0; + }); } public connect() { + this.shouldReconnect = true; return this.rconClient.connect(); } + public disconnect() { + this.isEnded = true; + this.shouldReconnect = false; return this.rconClient.end(); } public async sendCommand(command: string) { + if (this.isEnded) { + throw new Error('RCON connection has ended'); + } return await this.rconClient.send(command); } } diff --git a/src/manager/server-mamager.ts b/src/manager/server-mamager.ts index f243bfd..3e01809 100644 --- a/src/manager/server-mamager.ts +++ b/src/manager/server-mamager.ts @@ -7,11 +7,13 @@ import gateClient from '@/utils/gate'; import { PhaseEnum, Watcher, + coreV1Api, deleteService, deployService, getConfigMapData, getDeploymentData, k8sApiEndpoint, + k8sLogger, patchConfigMap, patchDeployment, stopDeploymentRollout, @@ -27,6 +29,7 @@ import { minecraftServerDeployment } from '@/deployment/minecraft-server'; import { FileController, FileControllerManager } from './file-manager'; import { DomainManager, getTopLevelDomain } from './domain-manager'; import { TaskQueue } from '@/utils/taskQueue'; +import { PassThrough } from 'node:stream'; export enum ServerStatusEnum { RUNNING = 'running', @@ -89,7 +92,7 @@ export class Manager { const existingServer = this.servers.get(serverName); if (existingServer) { if ( - existingServer.servicesUpdateResourceVersion <= + existingServer.servicesUpdateResourceVersion >= parseInt(currentResourceVersion) ) { return; @@ -510,6 +513,24 @@ export class Manager { return server.nameTemplate.replace('@PlaceHolder@', target); } + private static async getCurrentServerPodName( + serverName: string, + ): Promise { + const pods = await coreV1Api.listNamespacedPod({ + namespace: Namespace, + labelSelector: `app=minecraft-server,name=${serverName}`, + }); + const podName = + pods.items.find((pod) => pod.status?.phase === 'Running')?.metadata + ?.name || pods.items[0]?.metadata?.name; + + if (!podName) { + throw new Error(`No pod found for server ${serverName}.`); + } + + return podName; + } + public static async stopServer(serverName: string): Promise { if (!this.servers.has(serverName)) { throw new Error(`Server ${serverName} not found.`); @@ -560,6 +581,74 @@ export class Manager { return Array.from(Manager.servers.values()); } + public static async executeServerPod( + serverName: string, + command: string, + ): Promise { + const server = Manager.getServerInfoByName(serverName); + if (!server) { + throw new Error(`Server ${serverName} not found.`); + } + const podName = await this.getCurrentServerPodName(serverName); + const executeResponse = + await coreV1Api.connectPostNamespacedPodExecWithHttpInfo({ + namespace: Namespace, + name: podName, + command, + stderr: true, + stdin: true, + stdout: true, + }); + if (executeResponse.httpStatusCode !== 101) { + throw new Error( + `Failed to execute command on server ${serverName}. HTTP status code: ${executeResponse.httpStatusCode}`, + ); + } + + const executeResultText = await executeResponse.body.text(); + return executeResultText; + } + + public static async readServerLogs( + serverName: string, + lines: number = 100, + ): Promise { + const server = Manager.getServerInfoByName(serverName); + if (!server) { + throw new Error(`Server ${serverName} not found.`); + } + const podName = await this.getCurrentServerPodName(serverName); + const logsResponse = await coreV1Api.readNamespacedPodLogWithHttpInfo({ + namespace: Namespace, + name: podName, + tailLines: lines, + }); + const logs = await logsResponse.body.text(); + return logs; + } + + public static async getFollowedServerLogs( + serverName: string, + ): Promise { + const server = Manager.getServerInfoByName(serverName); + if (!server) { + throw new Error(`Server ${serverName} not found.`); + } + const podName = await this.getCurrentServerPodName(serverName); + const logStream = new PassThrough(); + k8sLogger.log( + Namespace, + podName, + 'minecraft-server', + logStream, + { + follow: true, + pretty: true, + }, + ); + return logStream; + } + public static getServerStatus( serverName: string, ): ServerStatusEnum | undefined { diff --git a/src/manager/stream-manager.ts b/src/manager/stream-manager.ts new file mode 100644 index 0000000..0bd7fc0 --- /dev/null +++ b/src/manager/stream-manager.ts @@ -0,0 +1,71 @@ +import { PassThrough } from 'node:stream'; +import { Manager } from './server-mamager'; + +export class LogStreamManager { + private static instance: LogStreamManager | null = null; + private logStreams: Map = new Map(); + private sendFn: ((subscriptionId: string, data: string) => void) | null = + null; + + private constructor(sendFn: (subscriptionId: string, data: string) => void) { + this.sendFn = sendFn; + } + + public static getInstance( + sendFn?: (subscriptionId: string, data: string) => void, + ) { + if (!this.instance) { + if (!sendFn) { + throw new Error( + 'sendFn is required for the first initialization of LogStreamManager', + ); + } + this.instance = new LogStreamManager(sendFn); + } + return this.instance; + } + + private registerLogStream(serverName: string, logStream: PassThrough) { + this.logStreams.set(serverName, logStream); + } + + private unregisterLogStream(serverName: string) { + this.logStreams.delete(serverName); + } + + public async createLogStream(serverName: string, subscriptionId: string) { + const logStream = await Manager.getFollowedServerLogs(serverName); + this.registerLogStream(subscriptionId, logStream); + + logStream.on('data', (chunk) => { + const data = chunk.toString(); + if (this.sendFn) { + this.sendFn(subscriptionId, data); + } + }); + + logStream.on('error', (error) => { + console.error(`Error in log stream for server ${serverName}:`, error); + if (this.sendFn) { + this.sendFn(subscriptionId, `Error: ${error.message}`); + } + this.unregisterLogStream(subscriptionId); + }); + + logStream.on('end', () => { + console.log(`Log stream for server ${serverName} ended.`); + if (this.sendFn) { + this.sendFn(subscriptionId, 'Log stream ended.'); + } + this.unregisterLogStream(subscriptionId); + }); + } + + public async closeLogStream(subscriptionId: string) { + const logStream = this.logStreams.get(subscriptionId); + if (logStream) { + logStream.end(); + this.unregisterLogStream(subscriptionId); + } + } +} diff --git a/src/preview.ts b/src/preview.ts index 327237e..6ef8ffb 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -23,6 +23,22 @@ const server = serve({ success: true, data: { instances: ['preview-server-1', 'preview-server-2'] }, }), + '/api/server-resource': async () => + Response.json({ + status: 'ok', + data: { + name: 'preview-server-1', + cpu: '120m', + memory: '512Mi', + allocatedCpu: '1', + allocatedMemory: '2Gi', + }, + }), + '/api/server-logs': async () => + Response.json({ + status: 'ok', + data: '[12:00:00] [Server thread/INFO]: Mock preview log line\n[12:00:01] [Server thread/INFO]: Server is running', + }), '/api/file-system': async (req: Request) => { const url = new URL(req.url); const type = url.searchParams.get('type'); diff --git a/src/utils/k8s.ts b/src/utils/k8s.ts index 70ccd8f..9c974ac 100644 --- a/src/utils/k8s.ts +++ b/src/utils/k8s.ts @@ -2,6 +2,8 @@ import { AppsV1Api, CoreV1Api, KubeConfig, + Log, + Metrics, V1ConfigMap, V1Deployment, V1Pod, @@ -12,7 +14,6 @@ import type { ServicesDeployments } from './type'; import { Namespace } from './config'; import { spawn } from 'child_process'; import * as yaml from 'js-yaml'; -import { fetch } from 'bun'; const kubeConfig = new KubeConfig(); @@ -99,6 +100,8 @@ if (process.env.KUBERNETES_SERVICE_HOST) { export const coreV1Api = kubeConfig.makeApiClient(CoreV1Api); export const appsV1Api = kubeConfig.makeApiClient(AppsV1Api); +export const k8sLogger = new Log(kubeConfig); +export const k8sMetrics = new Metrics(kubeConfig); export default kubeConfig; export enum k8sApiEndpoint { @@ -1040,3 +1043,11 @@ export async function patchService( throw err; } } + +export type PodData = { + cpu: string; + memory: string; + allocatedCpu: string; + allocatedMemory: string; + name: string; +}; diff --git a/src/web/api/serverInstance.ts b/src/web/api/serverInstance.ts index 4c34790..e856749 100644 --- a/src/web/api/serverInstance.ts +++ b/src/web/api/serverInstance.ts @@ -206,11 +206,15 @@ export async function PATCH(request: Request): Promise { delete (updatedVariables as Record)[key]; } - await patchENVConfigMap( - Namespace, - `minecraft-server-env-configmap-${serverName}`, - updatedVariables, - ); + try { + await patchENVConfigMap( + Namespace, + `minecraft-server-env-configmap-${serverName}`, + updatedVariables, + ); + } catch (e) { + throw new Error(`Failed to patch ENV ConfigMap: ${(e as Error).message}`); + } // Always trigger rollout when config changes by adding annotation const patchOperations: Array<{ @@ -260,11 +264,15 @@ export async function PATCH(request: Request): Promise { console.log('Patch operations for deployment:', patchOperations); if (patchOperations.length > 1) { - await patchDeployment( - Namespace, - `minecraft-server-deployment-${serverName}`, - patchOperations, - ); + try { + await patchDeployment( + Namespace, + `minecraft-server-deployment-${serverName}`, + patchOperations, + ); + } catch (e) { + throw new Error(`Failed to patch Deployment: ${(e as Error).message}`); + } } const hasDomainChange = 'domain' in variables; @@ -284,11 +292,19 @@ export async function PATCH(request: Request): Promise { } : {}), }; - await patchService(Namespace, `minecraft-server-service-${serverName}`, { - metadata: { - labels: labelsToPatch, - }, - }); + try { + await patchService( + Namespace, + `minecraft-server-service-${serverName}`, + { + metadata: { + labels: labelsToPatch, + }, + }, + ); + } catch (e) { + throw new Error(`Failed to patch Service: ${(e as Error).message}`); + } } // Persist updated variables to source folder if client provided serverSettingId try { diff --git a/src/web/api/serverLogs.ts b/src/web/api/serverLogs.ts new file mode 100644 index 0000000..4cc0722 --- /dev/null +++ b/src/web/api/serverLogs.ts @@ -0,0 +1,32 @@ +import { Manager } from '@/manager'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const serverName = url.searchParams.get('serverName') || ''; + const requestedLines = Number(url.searchParams.get('lines') || '120'); + const lines = Number.isFinite(requestedLines) + ? Math.min(1000, Math.max(1, Math.floor(requestedLines))) + : 120; + + if (!serverName) { + return Response.json( + { status: 'error', message: 'Missing serverName' }, + { status: 400 }, + ); + } + + try { + const data = await Manager.readServerLogs(serverName, lines); + return Response.json({ status: 'ok', data }, { status: 200 }); + } catch (error) { + console.error('Failed to read server logs:', error); + return Response.json( + { status: 'error', message: 'Failed to read server logs' }, + { status: 500 }, + ); + } +} + +export default { + GET, +}; diff --git a/src/web/api/serverResource.ts b/src/web/api/serverResource.ts new file mode 100644 index 0000000..3e06f6a --- /dev/null +++ b/src/web/api/serverResource.ts @@ -0,0 +1,32 @@ +import { ResourceMonitor } from '@/manager/resource-monitor'; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const serverName = url.searchParams.get('serverName') || ''; + + if (!serverName) { + return Response.json( + { status: 'error', message: 'Missing serverName' }, + { status: 400 }, + ); + } + + try { + const resourceData = + ResourceMonitor.getInstance().getPodDataByName(serverName); + return Response.json( + { status: 'ok', data: resourceData ?? null }, + { status: 200 }, + ); + } catch (error) { + console.error('Failed to read server resource data:', error); + return Response.json( + { status: 'error', message: 'Failed to read server resource data' }, + { status: 500 }, + ); + } +} + +export default { + GET, +}; diff --git a/src/web/component/directoryDisplay.tsx b/src/web/component/directoryDisplay.tsx index 7e90486..5f25c66 100644 --- a/src/web/component/directoryDisplay.tsx +++ b/src/web/component/directoryDisplay.tsx @@ -1,4 +1,8 @@ -import { DirectoryType, type DirectoryStructure } from '@/utils/type'; +import { + DirectoryType, + type DirectoryStructure, + type DirectoryType as DirectoryTypeValue, +} from '../utils/directoryType'; import { Folder, File, @@ -43,7 +47,7 @@ export default function DirectoryDisplay({ handleCreate: (path: string, type: DirectoryType) => void; handleDelete: ( path: string, - type: DirectoryType, + type: DirectoryTypeValue, recursive: boolean, ) => Promise; handleRename: (oldPath: string, newPath: string) => Promise; @@ -62,9 +66,8 @@ export default function DirectoryDisplay({ const [creatingState, setCreatingState] = useState( FileCreatingState.None, ); - const [currentCreatingType, setCurrentCreatingType] = useState( - DirectoryType.File, - ); + const [currentCreatingType, setCurrentCreatingType] = + useState(DirectoryType.File); const [isRenaming, setIsRenaming] = useState(null); const openFilePath = useRef(''); @@ -147,7 +150,7 @@ export default function DirectoryDisplay({ }; return ( -
+
{isOpenFile && (
@@ -372,7 +375,7 @@ export default function DirectoryDisplay({
-
    +
    • - {deviceType !== DeviceType.Mobile && ( -
      - - Minecraft Server Manager -
      - )} +
      +
      + + Minecraft Server Manager +
      + {PageSectionList.map((section) => (
      ([]); - const [isOpen, setIsOpen] = useState(false); +export default function Rcon({ + serverName, + alwaysOpen = false, +}: { + serverName: string; + alwaysOpen?: boolean; +}) { + const [output, setOutput] = useState(''); + const [isOpen, setIsOpen] = useState(alwaysOpen); const [inputCommand, setInputCommand] = useState(''); const { sendMessage, message } = useWebSocket(); const messageIdRef = useRef(''); + const terminalRef = useRef(null); + + const appendOutput = (value: string) => { + setOutput((prev) => { + const merged = prev ? `${prev}\n${value}` : value; + return merged.split('\n').slice(-500).join('\n'); + }); + }; const handleSendCommand = () => { if (inputCommand.trim() === '') return; @@ -19,7 +33,7 @@ export default function Rcon({ serverName }: { serverName: string }) { serverName, }, }); - setLines((prevLines) => [...prevLines, `> ${inputCommand}`]); + appendOutput(`> ${inputCommand}`); setInputCommand(''); }; @@ -39,19 +53,20 @@ export default function Rcon({ serverName }: { serverName: string }) { rconPayload.response !== undefined && rconPayload.serverName === serverName ) { - setLines((prevLines) => [...prevLines, `< ${rconPayload.response}`]); + appendOutput(`< ${rconPayload.response}`); } messageIdRef.current = message?.id || ''; } + }, [message, serverName]); - const container = document.getElementById('terminal'); - if (container) { - container.scrollTop = container.scrollHeight; + useEffect(() => { + if (terminalRef.current) { + terminalRef.current.scrollTop = terminalRef.current.scrollHeight; } - }, [lines, message]); + }, [output]); return ( -
      +
      {!isOpen ? ( ) : ( <> - -
      -
      setIsOpen(false)} + className='p-4 w-full flex gap-2' > - {lines.map((line, index) => ( -
      - {line} -
      - ))} -
      -
      + RCON Terminal + + )} +
      +
      +              {output || 'No RCON output yet.'}
      +            
      +
      +
      +
      +
      + ); +} diff --git a/src/web/component/server-manage/management-sidebar.tsx b/src/web/component/server-manage/management-sidebar.tsx new file mode 100644 index 0000000..2f50b57 --- /dev/null +++ b/src/web/component/server-manage/management-sidebar.tsx @@ -0,0 +1,93 @@ +import { FolderOpen, LayoutDashboard, SlidersHorizontal } from 'lucide-react'; +import { type PodData } from '@/utils/k8s'; +import { ManagementSection } from './types'; + +type ServerSummary = { + name: string; + address: string; + status: string; + playersOnline: number; +}; + +type ServerOption = { + id: string; + name: string; +}; + +export default function ManagementSidebar({ + server, + serverOptions, + selectedServerId, + onSelectServer, + activeSection, + setActiveSection, + resourceData, + isRefreshingDiagnostics, +}: { + server: ServerSummary; + serverOptions: ServerOption[]; + selectedServerId: string; + onSelectServer: (serverId: string) => void; + activeSection: ManagementSection; + setActiveSection: (section: ManagementSection) => void; + resourceData: PodData | null; + isRefreshingDiagnostics: boolean; +}) { + const sectionButtonClass = (section: ManagementSection) => + `flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors ${ + activeSection === section + ? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-950/40 dark:text-blue-200' + : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700' + }`; + + return ( + + ); +} diff --git a/src/web/component/server-manage/types.ts b/src/web/component/server-manage/types.ts new file mode 100644 index 0000000..7dcf961 --- /dev/null +++ b/src/web/component/server-manage/types.ts @@ -0,0 +1,5 @@ +export enum ManagementSection { + Overview = 'overview', + Files = 'files', + Settings = 'settings', +} diff --git a/src/web/component/serverSetting.tsx b/src/web/component/serverSetting.tsx index 1e8a4b7..01c2395 100644 --- a/src/web/component/serverSetting.tsx +++ b/src/web/component/serverSetting.tsx @@ -82,7 +82,7 @@ export default function ServerSetting({ }, [fieldValues, setSetting]); const MainComponent = ( -
      +
      {FIELDS_BY_CATEGORY.map(([category, fields]) => (
      +
      {!isToggleAble ? ( MainComponent ) : !isOpen ? ( diff --git a/src/web/contexts/serverManagementFiles.tsx b/src/web/contexts/serverManagementFiles.tsx new file mode 100644 index 0000000..05c5986 --- /dev/null +++ b/src/web/contexts/serverManagementFiles.tsx @@ -0,0 +1,414 @@ +import { + createContext, + useContext, + useEffect, + useOptimistic, + useState, + useTransition, +} from 'react'; +import { + DirectoryType, + type DirectoryStructure, + type DirectoryType as DirectoryTypeValue, +} from '../utils/directoryType'; +import { NotificationType } from '../utils/enums'; + +type ServerManagementFilesContextType = { + fileStructure: DirectoryStructure; + selectedFiles: string[]; + setSelectedFiles: React.Dispatch>; + fetchFileStructure: () => Promise; + handleFileChange: (path: string, content: string) => void; + handleFileRead: (path: string) => Promise; + handleCreate: (path: string, type: DirectoryTypeValue) => void; + handleDelete: ( + path: string, + type: DirectoryTypeValue, + recursive: boolean, + ) => Promise; + handleRename: (oldPath: string, newPath: string) => Promise; + handleUpload: (path: string, file: File) => Promise; + handleDownload: (path: string, fileName: string) => Promise; + onCompress: (path: string, files: string[]) => Promise; + onUncompress: (path: string, zipFile: string) => Promise; + onFileSelect: (fileName: string) => void; + showConfirmDialog?: (options: any) => Promise; +}; + +const ServerManagementFilesContext = createContext< + ServerManagementFilesContextType | undefined +>(undefined); + +const emptyStructure: DirectoryStructure = { + name: '/', + type: DirectoryType.Directory, + children: [], +}; + +export function ServerManagementFilesProvider({ + serverId, + showConfirmDialog, + addNotification, + children, +}: { + serverId: string; + showConfirmDialog?: (options: any) => Promise; + addNotification: (message: string, type: NotificationType) => void; + children: React.ReactNode; +}) { + const [, startTransition] = useTransition(); + const [currentFileStructure, setCurrentFileStructure] = + useState(emptyStructure); + const [selectedFiles, setSelectedFiles] = useState([]); + const encodedServerId = encodeURIComponent(serverId); + + const [optimisticFileStructure, addOptimisticFileStructure] = useOptimistic( + currentFileStructure, + (state, action: { type: string; payload: any }) => { + const updateNode = ( + node: DirectoryStructure, + pathParts: string[], + updateFn: (n: DirectoryStructure) => DirectoryStructure, + ): DirectoryStructure => { + if (pathParts.length === 0) return updateFn(node); + const [head, ...tail] = pathParts; + return { + ...node, + children: node.children?.map((child) => + child.name === head ? updateNode(child, tail, updateFn) : child, + ), + }; + }; + + switch (action.type) { + case 'create': { + const { path, type } = action.payload; + const parts = path.split('/').filter(Boolean); + const name = parts.pop(); + return updateNode(state, parts, (node) => ({ + ...node, + children: [ + ...(node.children || []), + { + name: name!, + type, + children: type === DirectoryType.Directory ? [] : undefined, + }, + ], + })); + } + case 'delete': { + const { path } = action.payload; + const parts = path.split('/').filter(Boolean); + const name = parts.pop(); + return updateNode(state, parts, (node) => ({ + ...node, + children: node.children?.filter((child) => child.name !== name), + })); + } + case 'rename': { + const { oldPath, newPath } = action.payload; + const oldParts = oldPath.split('/').filter(Boolean); + const oldName = oldParts.pop(); + const newName = newPath.split('/').filter(Boolean).pop(); + return updateNode(state, oldParts, (node) => ({ + ...node, + children: node.children?.map((child) => + child.name === oldName ? { ...child, name: newName! } : child, + ), + })); + } + default: + return state; + } + }, + ); + + const fetchFileStructure = async () => { + if (!serverId) { + setCurrentFileStructure(emptyStructure); + return; + } + + const response = await fetch( + `/api/file-system?name=${encodedServerId}&type=structure`, + ); + if (response.ok) { + const data = await response.json(); + if (data.success) { + setCurrentFileStructure(data.data); + return; + } + } + + setCurrentFileStructure(emptyStructure); + }; + + useEffect(() => { + setSelectedFiles([]); + void fetchFileStructure(); + }, [serverId]); + + const handleFileChange = (path: string, content: string) => { + if (!path || !serverId) return; + + const submitFileChange = async () => { + await fetch( + `/api/file-system?name=${encodedServerId}&type=file&path=${encodeURIComponent(path)}`, + { method: 'PUT', body: content }, + ); + void fetchFileStructure(); + }; + + void submitFileChange(); + }; + + const handleFileRead = async (path: string): Promise => { + if (!path || !serverId) return ''; + + const fileResponse = await fetch( + `/api/file-system?name=${encodedServerId}&type=file&path=${encodeURIComponent(path)}`, + ); + if (fileResponse.ok) { + const data = await fileResponse.json(); + if (data.success) { + return data.data as string; + } + return ''; + } + + return `Error: ${fileResponse.status} ${fileResponse.statusText}`; + }; + + const handleCreate = (path: string, type: DirectoryTypeValue) => { + if (!serverId) return; + + startTransition(async () => { + addOptimisticFileStructure({ type: 'create', payload: { path, type } }); + await fetch('/api/file-system', { + method: 'POST', + body: JSON.stringify({ + name: serverId, + type, + path, + ...(type === DirectoryType.File ? { content: '' } : {}), + }), + }); + void fetchFileStructure(); + }); + }; + + const handleDelete = async ( + path: string, + type: DirectoryTypeValue, + recursive: boolean, + ): Promise => { + if (!serverId) return false; + if (!path) { + console.error('Cannot delete root directory'); + return false; + } + + if (recursive) { + if (type === DirectoryType.File) { + throw new Error('Invalid state: recursive delete on file'); + } + const confirmed = await showConfirmDialog?.({ + title: 'Delete Folder Recursively', + message: `Are you sure you want to recursively delete the folder at ${path} and all its contents?\n\nThis action cannot be undone.`, + checkboxLabel: 'I understand this will delete all contents permanently', + requireCheckbox: true, + confirmText: 'Delete', + cancelText: 'Cancel', + }); + if (!confirmed) return false; + } + + if (type === DirectoryType.Directory && !recursive) { + const confirmed = await showConfirmDialog?.({ + title: 'Delete Empty Folder', + message: `Are you sure you want to delete the folder at ${path} without deleting its contents?\n\nThis may fail if the folder is not empty.`, + confirmText: 'Delete', + cancelText: 'Cancel', + }); + if (!confirmed) return false; + } + + startTransition(async () => { + addOptimisticFileStructure({ type: 'delete', payload: { path } }); + try { + const response = await fetch(`/api/file-system?name=${encodedServerId}`, { + method: 'DELETE', + body: JSON.stringify({ + name: serverId, + path, + type, + recursive, + }), + }); + + if (response.ok) { + setSelectedFiles([]); + void fetchFileStructure(); + return; + } + + const data = await response.json(); + addNotification( + data.message || 'Unknown error occurred during deletion.', + NotificationType.Error, + ); + void fetchFileStructure(); + } catch (error) { + console.error('Error during delete request:', error); + void fetchFileStructure(); + } + }); + + return true; + }; + + const handleRename = async (oldPath: string, newPath: string) => { + if (!serverId) return; + + startTransition(async () => { + addOptimisticFileStructure({ + type: 'rename', + payload: { oldPath, newPath }, + }); + await fetch('/api/file-system', { + method: 'PATCH', + body: JSON.stringify({ + name: serverId, + oldPath, + newPath, + }), + }); + setSelectedFiles([]); + void fetchFileStructure(); + }); + }; + + const handleUpload = async (path: string, file: File) => { + if (!serverId) return; + + startTransition(async () => { + addOptimisticFileStructure({ + type: 'create', + payload: { path, type: DirectoryType.File }, + }); + await fetch( + `/api/file-system?name=${encodedServerId}&type=file&path=${encodeURIComponent(path)}`, + { method: 'PUT', body: file }, + ); + void fetchFileStructure(); + }); + }; + + const handleDownload = async (path: string, fileName: string) => { + if (!path || !serverId) return; + + const fileResponse = await fetch( + `/api/file-system?name=${encodedServerId}&type=file&path=${encodeURIComponent(path)}`, + ); + if (fileResponse.ok) { + const data = await fileResponse.json(); + if (data.success) { + const blob = new Blob([data.data], { + type: 'application/octet-stream', + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } + } + }; + + const onCompress = async (path: string, files: string[]) => { + if (!serverId) return; + + const outputPath = path ? `${path}/compressed.zip` : 'compressed.zip'; + await fetch('/api/file-system', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'compress', + name: serverId, + files: files.map((fileName) => + path ? `${path}/${fileName}` : fileName, + ), + outputPath, + }), + }); + setSelectedFiles([]); + void fetchFileStructure(); + }; + + const onUncompress = async (path: string, zipFile: string) => { + if (!serverId) return; + + const outputDir = path + ? `${path}/${zipFile}-uncompressed` + : `${zipFile}-uncompressed`; + await fetch('/api/file-system', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'uncompress', + name: serverId, + zipPath: path ? `${path}/${zipFile}` : zipFile, + outputDir, + }), + }); + setSelectedFiles([]); + void fetchFileStructure(); + }; + + const onFileSelect = (fileName: string) => { + setSelectedFiles((prev) => + prev.includes(fileName) + ? prev.filter((current) => current !== fileName) + : [...prev, fileName], + ); + }; + + const value: ServerManagementFilesContextType = { + fileStructure: optimisticFileStructure, + selectedFiles, + setSelectedFiles, + fetchFileStructure, + handleFileChange, + handleFileRead, + handleCreate, + handleDelete, + handleRename, + handleUpload, + handleDownload, + onCompress, + onUncompress, + onFileSelect, + showConfirmDialog, + }; + + return ( + + {children} + + ); +} + +export function useServerManagementFiles() { + const context = useContext(ServerManagementFilesContext); + if (!context) { + throw new Error( + 'useServerManagementFiles must be used within ServerManagementFilesProvider', + ); + } + + return context; +} diff --git a/src/web/contexts/servers.tsx b/src/web/contexts/servers.tsx index 63d083d..f0aa3d7 100644 --- a/src/web/contexts/servers.tsx +++ b/src/web/contexts/servers.tsx @@ -3,7 +3,6 @@ import { useContext, useState, useEffect, - useRef, type SetStateAction, type Dispatch, } from 'react'; @@ -21,54 +20,77 @@ export type ServerInfo = { const ServerContext = createContext<{ serverInfo: ServerInfo[]; setServerInfo: Dispatch>; - currentSelectedServerId: string | undefined; + currentSelectedServerId: string; setCurrentSelectedServerId: (id: string) => void; }>({ serverInfo: [], setServerInfo: () => {}, - currentSelectedServerId: undefined, + currentSelectedServerId: '', setCurrentSelectedServerId: () => {}, }); +const SELECTED_SERVER_STORAGE_KEY = 'minecraft:selectedServerId'; + export const ServerProvider = ({ children }: { children: React.ReactNode }) => { const [serverInfo, setServerInfo] = useState([]); const [currentSelectedServerId, setCurrentSelectedServerId] = useState(''); const { message, sendMessage } = useWebSocket(); - const timeoutRef = useRef(null); - // useEffect(() => { - // const fetchServerInfo = async () => { - // // try { - // // const response = await fetch('/api/server-info'); - // // const data = await response.json(); - // // setServerInfo(data.data); - // // } catch (error) { - // // console.error('Failed to fetch server info:', error); - // // } - // }; - // const timeout = setInterval(fetchServerInfo, 10 * 1_000); - // fetchServerInfo(); - // return () => clearInterval(timeout); - // }, []); + useEffect(() => { + try { + const saved = window.localStorage.getItem(SELECTED_SERVER_STORAGE_KEY); + if (saved) { + setCurrentSelectedServerId(saved); + } + } catch (error) { + console.error('Failed to read selected server from storage:', error); + } + }, []); + + useEffect(() => { + try { + if (currentSelectedServerId) { + window.localStorage.setItem( + SELECTED_SERVER_STORAGE_KEY, + currentSelectedServerId, + ); + } else { + window.localStorage.removeItem(SELECTED_SERVER_STORAGE_KEY); + } + } catch (error) { + console.error('Failed to persist selected server:', error); + } + }, [currentSelectedServerId]); useEffect(() => { if (message && message.type === MessageType.SERVERINFO) { const updatedServers = (message as ReceiveMessage) .payload.servers; setServerInfo(updatedServers); + + if ( + currentSelectedServerId && + !updatedServers.some((server) => server.id === currentSelectedServerId) + ) { + setCurrentSelectedServerId(''); + } } - timeoutRef.current = setTimeout(() => { - sendMessage({ type: MessageType.SERVERINFO, payload: {} }); - }, 5 * 1_000); - }, [message]); + }, [message, currentSelectedServerId]); useEffect(() => { - setTimeout(() => { + const requestServerInfo = () => { sendMessage({ type: MessageType.SERVERINFO, payload: {} }); - console.log('Requested server info via WebSocket'); - }, 500); - }, []); + }; + + const startupTimeout = setTimeout(requestServerInfo, 500); + const interval = setInterval(requestServerInfo, 5 * 1_000); + + return () => { + clearTimeout(startupTimeout); + clearInterval(interval); + }; + }, [sendMessage]); return ( = ({ }; }, []); - const sendMessage = (message: SendMessage) => { + const sendMessage = useCallback((message: SendMessage) => { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify(message)); } else { console.error('WebSocket is not open. Unable to send message.'); } - }; + }, []); return ( diff --git a/src/web/index.tsx b/src/web/index.tsx index 91a1c0e..6b891bf 100644 --- a/src/web/index.tsx +++ b/src/web/index.tsx @@ -10,6 +10,12 @@ import fileSystem from './api/fileSystem'; import gateManage from './api/gateManage'; import settings from './api/settings'; import { serverInfoHandler } from './websocket/serverinfo'; +import serverLogs from './api/serverLogs'; +import serverResource from './api/serverResource'; +import { + closeTrackedLogSubscriptions, + serverLogHandler, +} from './websocket/serverlogs'; export type WebServerArguments = Partial< Omit< @@ -18,6 +24,11 @@ export type WebServerArguments = Partial< > >; +type WebSocketConnectionData = { + connectionId: string; + logSubscriptions: Set; +}; + export const webServer = async (args?: WebServerArguments) => { const server = serve({ idleTimeout: 120, @@ -28,6 +39,8 @@ export const webServer = async (args?: WebServerArguments) => { '/api/server-manage': serverManage, '/api/server-instance': serverInstance, '/api/file-system': fileSystem, + '/api/server-logs': serverLogs, + '/api/server-resource': serverResource, '/api/gate-manage': gateManage, '/api/settings': settings, '/api/instance-scanner': instanceScanner, @@ -39,7 +52,10 @@ export const webServer = async (args?: WebServerArguments) => { if (pathname === '/api/websocket') { const isUpgraded = server.upgrade(request, { - data: {}, + data: { + connectionId: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + logSubscriptions: new Set(), + }, }); if (isUpgraded) { return; @@ -92,6 +108,20 @@ export const webServer = async (args?: WebServerArguments) => { }, ); break; + case MessageType.SERVERLOG: { + const connectionData = ws.data as WebSocketConnectionData; + void serverLogHandler( + parsedMessage as SendMessage, + (responseMessage) => { + ws.send(JSON.stringify(responseMessage)); + }, + connectionData.connectionId, + (subscriptionId) => { + connectionData.logSubscriptions.add(subscriptionId); + }, + ); + break; + } default: console.error( 'Unknown WebSocket message type:', @@ -102,7 +132,13 @@ export const webServer = async (args?: WebServerArguments) => { console.error('Error parsing WebSocket message:', error); } }, - close(ws, code, reason) { + async close(ws, code, reason) { + const connectionData = ws.data as WebSocketConnectionData; + if (connectionData.logSubscriptions?.size) { + await closeTrackedLogSubscriptions( + connectionData.logSubscriptions.values(), + ); + } //console.log(`WebSocket connection closed: ${code} - ${reason}`); }, }, @@ -117,3 +153,17 @@ export const webServer = async (args?: WebServerArguments) => { } as Parameters[0]); return server; }; + +if (import.meta.main) { + (async () => { + try { + const server = await webServer({ + port: 3000, + hostname: 'localhost', + }); + console.log(`Server started at http://${server.hostname}:${server.port}`); + } catch (error) { + console.error('Failed to start server:', error); + } + })(); +} diff --git a/src/web/page/server.tsx b/src/web/page/server.tsx index 235c0ce..0f9c876 100644 --- a/src/web/page/server.tsx +++ b/src/web/page/server.tsx @@ -1,6 +1,5 @@ import { useServers } from '../contexts/servers'; import { CirclePlus, SquarePen, RotateCcw, Power, Trash } from 'lucide-react'; -import Rcon from './../component/rcon'; import { useOpenServerPanel } from '../contexts/addServerPanel'; import { PageSectionEnum, usePage } from '../contexts/page'; import { useNotification } from '../contexts/notification'; @@ -50,17 +49,16 @@ export default function Server() {
      -
      -
      - Status:{' '} - {server.status} -
      -
      - Players Online:{' '} - - {server.playersOnline} - -
      +
      + + Status: {server.status} + + + Players: {server.playersOnline} + + + ID: {server.id} +
      @@ -72,7 +70,7 @@ export default function Server() { setCurrentSection(PageSectionEnum.ServerManagement); }} > - Edit + Manage
      - -
      - -
      ))}
      diff --git a/src/web/page/serverManagement.tsx b/src/web/page/serverManagement.tsx index 4120ce4..536add2 100644 --- a/src/web/page/serverManagement.tsx +++ b/src/web/page/serverManagement.tsx @@ -1,25 +1,33 @@ -import { useState, useEffect, useOptimistic, useTransition } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useServers } from '../contexts/servers'; -import ServerSetting from './../component/serverSetting'; -import { Send } from 'lucide-react'; import { - DirectoryType, MinecraftServerType, - type DirectoryStructure, type MinecraftServerDeploymentsGeneratorArguments, type Variables, } from '@/utils/type'; -import DirectoryDisplay from '../component/directoryDisplay'; import { useNotification } from '../contexts/notification'; import { useConfirmDialog } from '../contexts/confirmDialog'; import { NotificationType } from '../utils/enums'; +import ManagementSidebar from '../component/server-manage/management-sidebar'; +import ManagementOverview from '../component/server-manage/management-overview'; +import ManagementFilesPanel from '../component/server-manage/management-files-panel'; +import ManagementSettings from '../component/server-manage/management-settings'; +import { ManagementSection } from '../component/server-manage/types'; +import { type PodData } from '@/utils/k8s'; +import { useWebSocket } from '../contexts/websocket'; +import { MessageType } from '../websocket/type'; +import { ServerManagementFilesProvider } from '../contexts/serverManagementFiles'; export default function ServerManagement() { const { serverInfo, currentSelectedServerId, setCurrentSelectedServerId } = useServers(); - const [isPending, startTransition] = useTransition(); const { addNotification } = useNotification(); const { showConfirmDialog } = useConfirmDialog(); + const { sendMessage, message } = useWebSocket(); + const latestHandledLogMessageId = useRef(''); + const [activeSection, setActiveSection] = useState( + ManagementSection.Overview, + ); const [currentServerSetting, setCurrentServerSetting] = useState< Omit & Variables & { serverSettingId?: string } @@ -27,107 +35,16 @@ export default function ServerManagement() { const [newServerSetting, setNewServerSetting] = useState< Omit & Variables >({}); + const [resourceData, setResourceData] = useState(null); + const [serverLogs, setServerLogs] = useState(''); + const [diagnosticError, setDiagnosticError] = useState(null); + const [isRefreshingDiagnostics, setIsRefreshingDiagnostics] = useState(false); - const [currentFileStructure, setCurrentFileStructure] = - useState({ - name: '/', - type: DirectoryType.Directory, - children: [], - }); - - const [optimisticFileStructure, addOptimisticFileStructure] = useOptimistic( - currentFileStructure, - (state, action: { type: string; payload: any }) => { - const updateNode = ( - node: DirectoryStructure, - pathParts: string[], - updateFn: (n: DirectoryStructure) => DirectoryStructure, - ): DirectoryStructure => { - if (pathParts.length === 0) return updateFn(node); - const [head, ...tail] = pathParts; - return { - ...node, - children: node.children?.map((child) => - child.name === head ? updateNode(child, tail, updateFn) : child, - ), - }; - }; - - switch (action.type) { - case 'create': { - const { path, type } = action.payload; - const parts = path.split('/').filter(Boolean); - const name = parts.pop(); - return updateNode(state, parts, (node) => ({ - ...node, - children: [ - ...(node.children || []), - { - name: name!, - type, - children: type === DirectoryType.Directory ? [] : undefined, - }, - ], - })); - } - case 'delete': { - const { path } = action.payload; - const parts = path.split('/').filter(Boolean); - const name = parts.pop(); - return updateNode(state, parts, (node) => ({ - ...node, - children: node.children?.filter((child) => child.name !== name), - })); - } - case 'rename': { - const { oldPath, newPath } = action.payload; - const oldParts = oldPath.split('/').filter(Boolean); - const oldName = oldParts.pop(); - const newName = newPath.split('/').filter(Boolean).pop(); - return updateNode(state, oldParts, (node) => ({ - ...node, - children: node.children?.map((child) => - child.name === oldName ? { ...child, name: newName! } : child, - ), - })); - } - default: - return state; - } - }, + const currentServer = serverInfo.find( + (server) => server.id === currentSelectedServerId, ); - const [selectedFiles, setSelectedFiles] = useState([]); - - const fetchFileStructure = async () => { - if (currentSelectedServerId === '') return; - const response = await fetch( - `/api/file-system?name=${currentSelectedServerId}&type=structure`, - ); - if (response.ok) { - const data = await response.json(); - if (data.success) { - setCurrentFileStructure(data.data); - } else { - setCurrentFileStructure({ - name: '/', - type: DirectoryType.Directory, - children: [], - }); - } - } else { - setCurrentFileStructure({ - name: '/', - type: DirectoryType.Directory, - children: [], - }); - } - }; - useEffect(() => { - if (serverInfo.length > 0 && currentSelectedServerId === '') { - setCurrentSelectedServerId(serverInfo[0]!.id); - } async function fetchServerSettings() { if (currentSelectedServerId === '') { setCurrentServerSetting({}); @@ -157,356 +74,295 @@ export default function ServerManagement() { } } - fetchServerSettings(); - fetchFileStructure(); + void fetchServerSettings(); + }, [currentSelectedServerId]); + + useEffect(() => { + setActiveSection(ManagementSection.Overview); + }, [currentSelectedServerId]); + + useEffect(() => { + if (currentSelectedServerId === '') { + setResourceData(null); + setServerLogs(''); + setDiagnosticError(null); + return; + } + + let cancelled = false; + + const refreshResource = async () => { + setIsRefreshingDiagnostics(true); + try { + const resourceResponse = await fetch( + `/api/server-resource?serverName=${encodeURIComponent(currentSelectedServerId || '')}`, + ); + + if (cancelled) { + return; + } + + if (resourceResponse.ok) { + const resourcePayload = await resourceResponse.json(); + setResourceData( + resourcePayload.status === 'ok' ? resourcePayload.data : null, + ); + } else { + setResourceData(null); + } + + setDiagnosticError(null); + } catch (error) { + if (!cancelled) { + console.error('Failed to refresh diagnostics:', error); + setDiagnosticError('Failed to refresh diagnostics.'); + } + } finally { + if (!cancelled) { + setIsRefreshingDiagnostics(false); + } + } + }; + + const fetchInitialLogs = async () => { + try { + const logsResponse = await fetch( + `/api/server-logs?serverName=${encodeURIComponent(currentSelectedServerId || '')}&lines=120`, + ); + if (cancelled) { + return; + } + + if (logsResponse.ok) { + const logsPayload = await logsResponse.json(); + setServerLogs(logsPayload.status === 'ok' ? logsPayload.data : ''); + } else { + setServerLogs(''); + } + } catch (error) { + if (!cancelled) { + console.error('Failed to fetch initial logs:', error); + } + } + }; + + void Promise.all([fetchInitialLogs(), refreshResource()]); + const interval = setInterval(() => { + void refreshResource(); + }, 10_000); + + return () => { + cancelled = true; + clearInterval(interval); + }; }, [currentSelectedServerId]); + useEffect(() => { + if (!currentSelectedServerId) { + return; + } + + sendMessage({ + type: MessageType.SERVERLOG, + payload: { + serverName: currentSelectedServerId, + action: 'subscribe', + }, + }); + + return () => { + sendMessage({ + type: MessageType.SERVERLOG, + payload: { + serverName: currentSelectedServerId, + action: 'unsubscribe', + }, + }); + }; + }, [currentSelectedServerId, sendMessage]); + + useEffect(() => { + if ( + !message || + message.id === latestHandledLogMessageId.current || + message.type !== MessageType.SERVERLOG + ) { + return; + } + + const payload = message.payload as { + status: 'ok' | 'error'; + serverName: string; + chunk?: string; + }; + + if (payload.serverName !== currentSelectedServerId) { + latestHandledLogMessageId.current = message.id; + return; + } + + if (payload.status === 'error') { + setDiagnosticError('Live log stream failed.'); + latestHandledLogMessageId.current = message.id; + return; + } + + if (payload.chunk) { + const incomingChunk = payload.chunk; + setServerLogs((previousLogs) => { + const merged = previousLogs + ? `${previousLogs}\n${incomingChunk}` + : incomingChunk; + const lines = merged.split('\n'); + return lines.slice(-500).join('\n'); + }); + } + + latestHandledLogMessageId.current = message.id; + }, [message, currentSelectedServerId]); + + const serverSettingDefaultValue = + Object.keys(currentServerSetting).length > 0 + ? { + ...currentServerSetting, + serverSettingId: currentServerSetting.serverSettingId || '', + } + : { + serverSettingId: '', + }; + return ( -
      - -
      -
      - {currentSelectedServerId === '' ? ( -
      - No server selected. -
      - ) : ( -
      -
      - Management options for server ID: {currentSelectedServerId} -
      -
      -
      -
      - 0 - ? { - ...currentServerSetting, - serverSettingId: - currentServerSetting.serverSettingId || '', - } - : { - serverSettingId: '', - } - } - setSetting={setNewServerSetting} - /> -
      -
      -
      - )} -
      +
      + )}
      ); } diff --git a/src/web/preview/websocket.ts b/src/web/preview/websocket.ts index 0eecb8b..bcc579d 100644 --- a/src/web/preview/websocket.ts +++ b/src/web/preview/websocket.ts @@ -57,6 +57,20 @@ export const wsHandlers = [ }), ); } + + if (message.type === MessageType.SERVERLOG) { + if (message.payload.action === 'subscribe') { + client.send( + JSON.stringify({ + type: MessageType.SERVERLOG, + payload: { + status: 'ok', + serverName: message.payload.serverName, + }, + }), + ); + } + } }); const interval = setInterval(() => { @@ -83,6 +97,17 @@ export const wsHandlers = [ }, }), ); + + client.send( + JSON.stringify({ + type: MessageType.SERVERLOG, + payload: { + status: 'ok', + serverName: 'my-server', + chunk: `[${new Date().toISOString()}] [Server thread/INFO]: Mock log line`, + }, + }), + ); }, 10000); client.addEventListener('close', () => clearInterval(interval)); diff --git a/src/web/utils/directoryType.ts b/src/web/utils/directoryType.ts new file mode 100644 index 0000000..6be2a1d --- /dev/null +++ b/src/web/utils/directoryType.ts @@ -0,0 +1,23 @@ +export const DirectoryType = { + File: 'file', + Directory: 'directory', +} as const; + +export type DirectoryType = (typeof DirectoryType)[keyof typeof DirectoryType]; + +export type DirectoryFileType = 'compressed' | 'textFile'; + +export type DirectoryFile = { + name: string; + format: typeof DirectoryType.File; + size: number; + content?: string; + fileType: DirectoryFileType; +}; + +export type DirectoryStructure = { + children?: DirectoryStructure[]; + name: string; + type: DirectoryType; + file?: DirectoryFile; +}; diff --git a/src/web/websocket/serverlogs.ts b/src/web/websocket/serverlogs.ts new file mode 100644 index 0000000..e764f52 --- /dev/null +++ b/src/web/websocket/serverlogs.ts @@ -0,0 +1,79 @@ +import { LogStreamManager } from '@/manager/stream-manager'; +import { MessageType, type ReceiveMessage, type SendMessage } from './type'; + +const subscriberSenders = new Map< + string, + (message: ReceiveMessage) => void +>(); + +const streamManager = LogStreamManager.getInstance((subscriptionId, data) => { + const sender = subscriberSenders.get(subscriptionId); + if (!sender) return; + + sender({ + type: MessageType.SERVERLOG, + payload: { + status: 'ok', + serverName: subscriptionId.split('::')[1] || '', + chunk: data, + }, + }); +}); + +export async function serverLogHandler( + message: SendMessage, + send: (message: ReceiveMessage) => void, + wsConnectionId: string, + trackSubscriptionId: (subscriptionId: string) => void, +) { + const payload = message.payload; + const subscriptionId = `${wsConnectionId}::${payload.serverName}`; + + if (payload.action === 'unsubscribe') { + subscriberSenders.delete(subscriptionId); + await streamManager.closeLogStream(subscriptionId); + send({ + type: MessageType.SERVERLOG, + payload: { + status: 'ok', + serverName: payload.serverName, + }, + }); + return; + } + + try { + subscriberSenders.set(subscriptionId, send); + trackSubscriptionId(subscriptionId); + await streamManager.createLogStream(payload.serverName, subscriptionId); + + send({ + type: MessageType.SERVERLOG, + payload: { + status: 'ok', + serverName: payload.serverName, + }, + }); + } catch (error) { + console.error( + `Failed to create log stream for server ${payload.serverName}:`, + error, + ); + send({ + type: MessageType.SERVERLOG, + payload: { + status: 'error', + serverName: payload.serverName, + }, + }); + } +} + +export async function closeTrackedLogSubscriptions( + subscriptionIds: Iterable, +) { + for (const subscriptionId of subscriptionIds) { + subscriberSenders.delete(subscriptionId); + await streamManager.closeLogStream(subscriptionId); + } +} diff --git a/src/web/websocket/type.ts b/src/web/websocket/type.ts index 29abe40..2b05284 100644 --- a/src/web/websocket/type.ts +++ b/src/web/websocket/type.ts @@ -2,6 +2,7 @@ export enum MessageType { RCON = 'rcon', SYSTEM = 'system', SERVERINFO = 'serverinfo', + SERVERLOG = 'serverlog', HEARTBEAT = 'heartbeat', } @@ -14,6 +15,10 @@ interface SendMessagePayload { [MessageType.RCON]: { command: string; serverName: string }; [MessageType.SYSTEM]: { command: string }; [MessageType.SERVERINFO]: {}; + [MessageType.SERVERLOG]: { + serverName: string; + action: 'subscribe' | 'unsubscribe'; + }; [MessageType.HEARTBEAT]: { timestamp: number }; } @@ -35,6 +40,11 @@ interface ReceiveMessagePayload { [MessageType.SERVERINFO]: { servers: ServerInfo[]; }; + [MessageType.SERVERLOG]: { + status: 'ok' | 'error'; + serverName: string; + chunk?: string; + }; [MessageType.HEARTBEAT]: { timestamp: number; }; diff --git a/tsconfig.json b/tsconfig.json index 3af5d52..0ba0087 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,11 +20,9 @@ "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - - "baseUrl": ".", "paths": { "@/*": ["./src/*"], - "#/*": ["src/web/*"], + "#/*": ["./src/web/*"], "@component/*": ["./src/web/component/*"] },