From 7ef99dabdc36e5dce0fda4f4bc23e6bf7417eb6f Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 29 Dec 2025 13:43:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?/=EC=A2=85=EB=A3=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Docker 소켓 마운트로 컨테이너 제어 - 백엔드 API: 서버 목록 조회, 시작, 종료 - 프론트엔드: API 연동, 시작/종료 다이얼로그 - 실행 중일 때 다른 서버 선택 비활성화 - 로딩 상태 표시 및 에러 처리 --- backend/routes/admin.js | 235 +++++++++++++++++++++++++++++ docker-compose.yml | 3 + frontend/src/pages/Admin.jsx | 277 ++++++++++++++++++++++++++++------- 3 files changed, 460 insertions(+), 55 deletions(-) diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 26bd3cf..fd5c6bc 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1105,4 +1105,239 @@ router.delete("/icons/:modId", requireAdmin, async (req, res) => { } }); +/** + * 서버 관리 API + */ + +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs"; +import path from "path"; + +const execAsync = promisify(exec); +const SERVER_BASE_PATH = "/minecraft/server"; + +// 서버 목록 조회 헬퍼 +async function getServerList() { + const servers = []; + + // 로더별 폴더 탐색 (neoforge, fabric 등) + const loaders = fs + .readdirSync(SERVER_BASE_PATH) + .filter((f) => fs.statSync(path.join(SERVER_BASE_PATH, f)).isDirectory()); + + for (const loader of loaders) { + const loaderPath = path.join(SERVER_BASE_PATH, loader); + + // 버전별, 서버별 폴더 재귀 탐색 + const findServers = (dir, relativePath = "") => { + const items = fs.readdirSync(dir); + + for (const item of items) { + const itemPath = path.join(dir, item); + const itemRelative = relativePath ? `${relativePath}/${item}` : item; + + if (fs.statSync(itemPath).isDirectory()) { + // docker-compose.yml이 있으면 서버로 인식 + const composePath = path.join(itemPath, "docker-compose.yml"); + if (fs.existsSync(composePath)) { + servers.push({ + id: item, + loader: loader.charAt(0).toUpperCase() + loader.slice(1), // neoforge -> Neoforge + path: `${loader}/${itemRelative}`, + fullPath: itemPath, + }); + } else { + // 하위 폴더 탐색 + findServers(itemPath, itemRelative); + } + } + } + }; + + findServers(loaderPath); + } + + return servers; +} + +// 실행 중인 서버 확인 +async function getRunningServer() { + try { + const { stdout } = await execAsync( + 'docker ps --format "{{.Names}}" | grep -E "^minecraft-server"' + ); + const containers = stdout.trim().split("\n").filter(Boolean); + return containers.length > 0 ? containers[0] : null; + } catch { + return null; + } +} + +// 컨테이너 상태 확인 +async function getContainerStatus(serverPath) { + try { + const fullPath = path.join(SERVER_BASE_PATH, serverPath); + const { stdout } = await execAsync( + `docker-compose ps --format json 2>/dev/null`, + { cwd: fullPath } + ); + if (stdout.trim()) { + const containers = stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const running = containers.some((c) => c.State === "running"); + return running ? "running" : "stopped"; + } + return "not_created"; + } catch { + return "not_created"; + } +} + +/** + * GET /api/admin/servers - 서버 목록 조회 + */ +router.get("/servers", async (req, res) => { + try { + const servers = await getServerList(); + const runningContainer = await getRunningServer(); + + // 각 서버의 상태 확인 + const serversWithStatus = await Promise.all( + servers.map(async (server) => { + const status = await getContainerStatus(server.path); + return { + ...server, + running: status === "running", + status, + }; + }) + ); + + res.json({ + success: true, + servers: serversWithStatus, + runningContainer, + }); + } catch (error) { + console.error("[Admin] 서버 목록 조회 오류:", error); + res.status(500).json({ error: "서버 목록 조회 실패" }); + } +}); + +/** + * POST /api/admin/servers/start - 서버 시작 + */ +router.post("/servers/start", async (req, res) => { + const { serverPath } = req.body; + + if (!serverPath) { + return res.status(400).json({ error: "서버 경로가 필요합니다" }); + } + + try { + // 이미 실행 중인 서버가 있는지 확인 + const runningContainer = await getRunningServer(); + if (runningContainer) { + return res + .status(400) + .json({ + error: "이미 실행 중인 서버가 있습니다", + running: runningContainer, + }); + } + + const fullPath = path.join(SERVER_BASE_PATH, serverPath); + + // docker-compose.yml 존재 확인 + if (!fs.existsSync(path.join(fullPath, "docker-compose.yml"))) { + return res + .status(400) + .json({ error: "docker-compose.yml을 찾을 수 없습니다" }); + } + + console.log(`[Admin] 서버 시작 중: ${serverPath}`); + + // docker-compose up -d 실행 + const { stdout, stderr } = await execAsync("docker-compose up -d", { + cwd: fullPath, + timeout: 60000, // 60초 타임아웃 + }); + + console.log(`[Admin] 서버 시작 완료: ${serverPath}`); + res.json({ + success: true, + message: "서버 시작됨", + stdout, + stderr, + }); + } catch (error) { + console.error("[Admin] 서버 시작 오류:", error); + res.status(500).json({ error: "서버 시작 실패", details: error.message }); + } +}); + +/** + * POST /api/admin/servers/stop - 서버 종료 + */ +router.post("/servers/stop", async (req, res) => { + const { serverPath } = req.body; + + if (!serverPath) { + return res.status(400).json({ error: "서버 경로가 필요합니다" }); + } + + try { + const fullPath = path.join(SERVER_BASE_PATH, serverPath); + + // docker-compose.yml 존재 확인 + if (!fs.existsSync(path.join(fullPath, "docker-compose.yml"))) { + return res + .status(400) + .json({ error: "docker-compose.yml을 찾을 수 없습니다" }); + } + + console.log(`[Admin] 서버 종료 중: ${serverPath}`); + + // docker-compose down 실행 + const { stdout, stderr } = await execAsync("docker-compose down", { + cwd: fullPath, + timeout: 60000, // 60초 타임아웃 + }); + + console.log(`[Admin] 서버 종료 완료: ${serverPath}`); + res.json({ + success: true, + message: "서버 종료됨", + stdout, + stderr, + }); + } catch (error) { + console.error("[Admin] 서버 종료 오류:", error); + res.status(500).json({ error: "서버 종료 실패", details: error.message }); + } +}); + +/** + * GET /api/admin/servers/status/:path - 특정 서버 상태 조회 + */ +router.get("/servers/status/:serverPath(*)", async (req, res) => { + const { serverPath } = req.params; + + try { + const status = await getContainerStatus(serverPath); + res.json({ + success: true, + serverPath, + status, + running: status === "running", + }); + } catch (error) { + console.error("[Admin] 서버 상태 조회 오류:", error); + res.status(500).json({ error: "상태 조회 실패" }); + } +}); + export default router; diff --git a/docker-compose.yml b/docker-compose.yml index 275afb7..e701255 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: dns: - 8.8.8.8 - 8.8.4.4 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /docker/minecraft/server:/minecraft/server:ro networks: - minecraft - db diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index c9e87c4..9bc9135 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -111,6 +111,9 @@ export default function Admin({ isMobile = false }) { // 서버 관리 상태 const [isServerListExpanded, setIsServerListExpanded] = useState(false); // 서버 목록 펼침 상태 const [selectedServer, setSelectedServer] = useState(null); // 선택된 서버 경로 + const [servers, setServers] = useState([]); // 서버 목록 (API에서 가져옴) + const [serverLoading, setServerLoading] = useState(false); // 서버 시작/종료 로딩 + const [serverDialog, setServerDialog] = useState({ show: false, action: null, server: null }); // 시작/종료 확인 다이얼로그 // 콘솔 관련 상태 const [logs, setLogs] = useState([]); @@ -857,6 +860,91 @@ export default function Admin({ isMobile = false }) { } }, []); // 의존성 없음 - 마운트 시에만 실행 + // 서버 목록 fetch + const fetchServers = async () => { + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/admin/servers', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await res.json(); + if (data.success) { + setServers(data.servers); + } + } catch (error) { + console.error('서버 목록 조회 실패:', error); + } + }; + + // 서버 시작 + const startServer = async (serverPath) => { + setServerLoading(true); + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/admin/servers/start', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ serverPath }) + }); + const data = await res.json(); + + if (data.success) { + setToast('서버가 시작되었습니다'); + fetchServers(); // 목록 새로고침 + setIsServerListExpanded(false); + setSelectedServer(null); + } else { + setToast(data.error || '서버 시작 실패', true); + } + } catch (error) { + console.error('서버 시작 오류:', error); + setToast('서버 시작 중 오류 발생', true); + } finally { + setServerLoading(false); + setServerDialog({ show: false, action: null, server: null }); + } + }; + + // 서버 종료 + const stopServer = async (serverPath) => { + setServerLoading(true); + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/admin/servers/stop', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ serverPath }) + }); + const data = await res.json(); + + if (data.success) { + setToast('서버가 종료되었습니다'); + fetchServers(); // 목록 새로고침 + } else { + setToast(data.error || '서버 종료 실패', true); + } + } catch (error) { + console.error('서버 종료 오류:', error); + setToast('서버 종료 중 오류 발생', true); + } finally { + setServerLoading(false); + setServerDialog({ show: false, action: null, server: null }); + } + }; + + // 콘솔 탭 활성화 시 서버 목록 로드 + useEffect(() => { + if (activeTab === 'console') { + fetchServers(); + } + }, [activeTab]); + // 로그 파일 목록 fetch 함수 const fetchLogFiles = async () => { try { @@ -1492,14 +1580,8 @@ export default function Admin({ isMobile = false }) { > {/* 서버 관리 */} {(() => { - // 서버 목록 (하드코딩 - 나중에 API로 대체) - const servers = [ - { id: '1.21.1', loader: 'NeoForge', version: '1.21.1', path: 'neoforge/1.21.1/1.21.1', running: true }, - { id: '1.21.1 create private', loader: 'NeoForge', version: '1.21.1', path: 'neoforge/1.21.1/1.21.1 create private', running: false }, - { id: 'test', loader: 'NeoForge', version: '1.21.1', path: 'neoforge/1.21.1/test', running: false }, - { id: '1.21.1 medieval', loader: 'Fabric', version: '1.21.1', path: 'fabric/1.21.1 medieval', running: false }, - ]; const runningServer = servers.find(s => s.running); + const otherServers = servers.filter(s => !s.running); return (
@@ -1507,6 +1589,9 @@ export default function Admin({ isMobile = false }) {

🖥️ 서버 관리 + {servers.length === 0 && ( + + )}

{runningServer && (
@@ -1527,17 +1612,18 @@ export default function Admin({ isMobile = false }) {

{runningServer.id}

- + {runningServer.loader} - {runningServer.version} + {runningServer.path.split('/')[1]}

@@ -1545,23 +1631,24 @@ export default function Admin({ isMobile = false }) {
)} - {/* 서버 선택 (접힘/펼침) */} -
+ {/* 서버 선택 (접힘/펼침) - 실행 중이면 비활성화 */} +
- {isServerListExpanded && ( + {isServerListExpanded && !runningServer && (
- {servers.filter(s => !s.running).map((server) => ( - - ))} + + )) + )}
{/* 시작 버튼 */} - {selectedServer && !runningServer && ( + {selectedServer && (
)} @@ -3319,6 +3419,73 @@ export default function Admin({ isMobile = false }) {
)} + {/* 서버 시작/종료 확인 다이얼로그 */} + {serverDialog.show && ( + !serverLoading && setServerDialog({ show: false, action: null, server: null })} + > + e.stopPropagation()} + > +

+ {serverDialog.action === 'start' ? '서버 시작' : '서버 종료'} +

+

+ + {serverDialog.server?.id} + + {serverDialog.action === 'start' + ? ' 서버를 시작하시겠습니까?' + : ' 서버를 종료하시겠습니까?'} +

+

+ {serverDialog.server?.loader} • {serverDialog.server?.path?.split('/')[1]} +

+
+ + +
+
+
+ )} + {/* 로그 삭제 확인 다이얼로그 */} {deleteLogDialog.show && (