feat: 서버 시작/종료 기능 구현
- Docker 소켓 마운트로 컨테이너 제어 - 백엔드 API: 서버 목록 조회, 시작, 종료 - 프론트엔드: API 연동, 시작/종료 다이얼로그 - 실행 중일 때 다른 서버 선택 비활성화 - 로딩 상태 표시 및 에러 처리
This commit is contained in:
parent
e6bd442aa4
commit
7ef99dabdc
3 changed files with 460 additions and 55 deletions
|
|
@ -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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ services:
|
||||||
dns:
|
dns:
|
||||||
- 8.8.8.8
|
- 8.8.8.8
|
||||||
- 8.8.4.4
|
- 8.8.4.4
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /docker/minecraft/server:/minecraft/server:ro
|
||||||
networks:
|
networks:
|
||||||
- minecraft
|
- minecraft
|
||||||
- db
|
- db
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,9 @@ export default function Admin({ isMobile = false }) {
|
||||||
// 서버 관리 상태
|
// 서버 관리 상태
|
||||||
const [isServerListExpanded, setIsServerListExpanded] = useState(false); // 서버 목록 펼침 상태
|
const [isServerListExpanded, setIsServerListExpanded] = useState(false); // 서버 목록 펼침 상태
|
||||||
const [selectedServer, setSelectedServer] = useState(null); // 선택된 서버 경로
|
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([]);
|
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 함수
|
// 로그 파일 목록 fetch 함수
|
||||||
const fetchLogFiles = async () => {
|
const fetchLogFiles = async () => {
|
||||||
try {
|
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 runningServer = servers.find(s => s.running);
|
||||||
|
const otherServers = servers.filter(s => !s.running);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
||||||
|
|
@ -1507,6 +1589,9 @@ export default function Admin({ isMobile = false }) {
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-white font-medium flex items-center gap-2">
|
<h3 className="text-white font-medium flex items-center gap-2">
|
||||||
🖥️ 서버 관리
|
🖥️ 서버 관리
|
||||||
|
{servers.length === 0 && (
|
||||||
|
<Loader2 size={14} className="animate-spin text-zinc-500" />
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
{runningServer && (
|
{runningServer && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1527,17 +1612,18 @@ export default function Admin({ isMobile = false }) {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white text-sm font-medium">{runningServer.id}</p>
|
<p className="text-white text-sm font-medium">{runningServer.id}</p>
|
||||||
<p className="text-zinc-400 text-xs">
|
<p className="text-zinc-400 text-xs">
|
||||||
<span className={runningServer.loader === 'NeoForge' ? 'text-orange-400' : 'text-blue-400'}>
|
<span className={runningServer.loader === 'NeoForge' || runningServer.loader === 'Neoforge' ? 'text-orange-400' : 'text-blue-400'}>
|
||||||
{runningServer.loader}
|
{runningServer.loader}
|
||||||
</span>
|
</span>
|
||||||
<span className="mx-1">•</span>
|
<span className="mx-1">•</span>
|
||||||
{runningServer.version}
|
{runningServer.path.split('/')[1]}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="px-4 py-2 text-xs font-medium rounded-lg bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors"
|
className="px-4 py-2 text-xs font-medium rounded-lg bg-red-600/20 text-red-400 hover:bg-red-600/30 transition-colors disabled:opacity-50"
|
||||||
onClick={() => setToast('서버 종료 기능 준비 중', true)}
|
disabled={serverLoading}
|
||||||
|
onClick={() => setServerDialog({ show: true, action: 'stop', server: runningServer })}
|
||||||
>
|
>
|
||||||
종료
|
종료
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1545,23 +1631,24 @@ export default function Admin({ isMobile = false }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 서버 선택 (접힘/펼침) */}
|
{/* 서버 선택 (접힘/펼침) - 실행 중이면 비활성화 */}
|
||||||
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
<div className={`border border-zinc-800 rounded-xl overflow-hidden ${runningServer ? 'opacity-50' : ''}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsServerListExpanded(!isServerListExpanded)}
|
onClick={() => !runningServer && setIsServerListExpanded(!isServerListExpanded)}
|
||||||
className="w-full flex items-center justify-between p-3 bg-zinc-800/50 hover:bg-zinc-800 transition-colors"
|
disabled={!!runningServer}
|
||||||
|
className="w-full flex items-center justify-between p-3 bg-zinc-800/50 hover:bg-zinc-800 transition-colors disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<span className="text-zinc-300 text-sm">
|
<span className="text-zinc-300 text-sm">
|
||||||
{runningServer ? '다른 서버 선택' : '서버 선택'}
|
{runningServer ? '서버 실행 중에는 선택 불가' : '서버 선택'}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={16}
|
size={16}
|
||||||
className={`text-zinc-400 transition-transform ${isServerListExpanded ? 'rotate-180' : ''}`}
|
className={`text-zinc-400 transition-transform ${isServerListExpanded && !runningServer ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isServerListExpanded && (
|
{isServerListExpanded && !runningServer && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ height: 0, opacity: 0 }}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
animate={{ height: 'auto', opacity: 1 }}
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
|
@ -1569,51 +1656,64 @@ export default function Admin({ isMobile = false }) {
|
||||||
className="overflow-hidden"
|
className="overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="p-2 space-y-1 max-h-[200px] overflow-y-auto custom-scrollbar">
|
<div className="p-2 space-y-1 max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||||
{servers.filter(s => !s.running).map((server) => (
|
{otherServers.length === 0 ? (
|
||||||
<button
|
<p className="text-zinc-500 text-sm text-center py-2">서버가 없습니다</p>
|
||||||
key={server.path}
|
) : (
|
||||||
onClick={() => {
|
otherServers.map((server) => (
|
||||||
setSelectedServer(server.path);
|
<button
|
||||||
}}
|
key={server.path}
|
||||||
className={`w-full flex items-center justify-between p-3 rounded-lg transition-colors ${
|
onClick={() => setSelectedServer(server.path)}
|
||||||
selectedServer === server.path
|
className={`w-full flex items-center justify-between p-3 rounded-lg transition-colors ${
|
||||||
? 'bg-zinc-700 border border-zinc-600'
|
|
||||||
: 'bg-zinc-800/30 hover:bg-zinc-800/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
|
||||||
selectedServer === server.path
|
selectedServer === server.path
|
||||||
? 'border-emerald-500'
|
? 'bg-zinc-700 border border-zinc-600'
|
||||||
: 'border-zinc-600'
|
: 'bg-zinc-800/30 hover:bg-zinc-800/50'
|
||||||
}`}>
|
}`}
|
||||||
{selectedServer === server.path && (
|
>
|
||||||
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
<div className="flex items-center gap-3">
|
||||||
)}
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
selectedServer === server.path
|
||||||
|
? 'border-emerald-500'
|
||||||
|
: 'border-zinc-600'
|
||||||
|
}`}>
|
||||||
|
{selectedServer === server.path && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-white text-sm">{server.id}</p>
|
||||||
|
<p className="text-zinc-500 text-xs">
|
||||||
|
<span className={server.loader === 'NeoForge' || server.loader === 'Neoforge' ? 'text-orange-400' : 'text-blue-400'}>
|
||||||
|
{server.loader}
|
||||||
|
</span>
|
||||||
|
<span className="mx-1">•</span>
|
||||||
|
{server.path.split('/')[1]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
</button>
|
||||||
<p className="text-white text-sm">{server.id}</p>
|
))
|
||||||
<p className="text-zinc-500 text-xs">
|
)}
|
||||||
<span className={server.loader === 'NeoForge' ? 'text-orange-400' : 'text-blue-400'}>
|
|
||||||
{server.loader}
|
|
||||||
</span>
|
|
||||||
<span className="mx-1">•</span>
|
|
||||||
{server.version}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 시작 버튼 */}
|
{/* 시작 버튼 */}
|
||||||
{selectedServer && !runningServer && (
|
{selectedServer && (
|
||||||
<div className="p-2 pt-0">
|
<div className="p-2 pt-0">
|
||||||
<button
|
<button
|
||||||
className="w-full py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg transition-colors"
|
className="w-full py-2.5 bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-700 text-white text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
onClick={() => setToast('서버 시작 기능 준비 중', true)}
|
disabled={serverLoading}
|
||||||
|
onClick={() => {
|
||||||
|
const server = servers.find(s => s.path === selectedServer);
|
||||||
|
if (server) {
|
||||||
|
setServerDialog({ show: true, action: 'start', server });
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
선택한 서버 시작
|
{serverLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
처리 중...
|
||||||
|
</>
|
||||||
|
) : '선택한 서버 시작'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -3319,6 +3419,73 @@ export default function Admin({ isMobile = false }) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 서버 시작/종료 확인 다이얼로그 */}
|
||||||
|
{serverDialog.show && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
onClick={() => !serverLoading && setServerDialog({ show: false, action: null, server: null })}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 w-full max-w-sm mx-4"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-white text-lg font-medium mb-2">
|
||||||
|
{serverDialog.action === 'start' ? '서버 시작' : '서버 종료'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-zinc-400 text-sm mb-4">
|
||||||
|
<span className={serverDialog.action === 'start' ? 'text-emerald-400' : 'text-red-400'}>
|
||||||
|
{serverDialog.server?.id}
|
||||||
|
</span>
|
||||||
|
{serverDialog.action === 'start'
|
||||||
|
? ' 서버를 시작하시겠습니까?'
|
||||||
|
: ' 서버를 종료하시겠습니까?'}
|
||||||
|
</p>
|
||||||
|
<p className="text-zinc-500 text-xs mb-4">
|
||||||
|
{serverDialog.server?.loader} • {serverDialog.server?.path?.split('/')[1]}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setServerDialog({ show: false, action: null, server: null })}
|
||||||
|
disabled={serverLoading}
|
||||||
|
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 text-white font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (serverDialog.action === 'start') {
|
||||||
|
startServer(serverDialog.server.path);
|
||||||
|
} else {
|
||||||
|
stopServer(serverDialog.server.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={serverLoading}
|
||||||
|
className={`flex-1 py-2.5 text-white font-medium rounded-xl transition-colors flex items-center justify-center gap-2 ${
|
||||||
|
serverDialog.action === 'start'
|
||||||
|
? 'bg-emerald-600 hover:bg-emerald-500 disabled:bg-emerald-800'
|
||||||
|
: 'bg-red-600 hover:bg-red-500 disabled:bg-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{serverLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
{serverDialog.action === 'start' ? '시작 중...' : '종료 중...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
serverDialog.action === 'start' ? '시작' : '종료'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 로그 삭제 확인 다이얼로그 */}
|
{/* 로그 삭제 확인 다이얼로그 */}
|
||||||
{deleteLogDialog.show && (
|
{deleteLogDialog.show && (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue