diff --git a/backend/lib/s3.js b/backend/lib/s3.js index e41cd92..bb5f3f4 100644 --- a/backend/lib/s3.js +++ b/backend/lib/s3.js @@ -75,13 +75,18 @@ function signV4(method, path, headers, payload, accessKey, secretKey) { /** * S3(RustFS)에 파일 업로드 */ -function uploadToS3(bucket, key, data) { +function uploadToS3( + bucket, + key, + data, + contentType = "application/octet-stream" +) { return new Promise((resolve, reject) => { const url = new URL(s3Config.endpoint); const path = `/${bucket}/${key}`; const headers = { Host: url.host, - "Content-Type": "image/png", + "Content-Type": contentType, "Content-Length": data.length.toString(), }; signV4("PUT", path, headers, data, s3Config.accessKey, s3Config.secretKey); @@ -110,4 +115,39 @@ function uploadToS3(bucket, key, data) { }); } -export { s3Config, uploadToS3 }; +/** + * S3(RustFS)에서 파일 다운로드 + */ +function downloadFromS3(bucket, key) { + return new Promise((resolve, reject) => { + const url = new URL(s3Config.endpoint); + const path = `/${bucket}/${key}`; + const headers = { + Host: url.host, + }; + signV4("GET", path, headers, "", s3Config.accessKey, s3Config.secretKey); + + const options = { + hostname: url.hostname, + port: url.port || 80, + path, + method: "GET", + headers, + }; + const req = http.request(options, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + resolve(Buffer.concat(chunks)); + } else { + reject(new Error(`S3 Download failed: ${res.statusCode}`)); + } + }); + }); + req.on("error", reject); + req.end(); + }); +} + +export { s3Config, uploadToS3, downloadFromS3 }; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index fcac07a..5fec1df 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -11,9 +11,7 @@ import { pool } from "../lib/db.js"; const router = express.Router(); const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret"; -const MOD_API_URL = `http://${ - process.env.MINECRAFT_HOST || "host.docker.internal" -}:${process.env.MINECRAFT_MOD_PORT || 25580}`; +const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080"; /** * JWT 토큰에서 사용자 정보 추출 @@ -43,13 +41,12 @@ async function requireAdmin(req, res, next) { } try { - // DB에서 관리자 권한 확인 - const result = await pool.query( - "SELECT is_admin FROM users WHERE id = $1 AND is_active = true", - [user.id] - ); + // DB에서 관리자 권한 확인 (MySQL 문법) + const [rows] = await pool.query("SELECT is_admin FROM users WHERE id = ?", [ + user.id, + ]); - if (result.rows.length === 0 || !result.rows[0].is_admin) { + if (rows.length === 0 || !rows[0].is_admin) { return res.status(403).json({ error: "관리자 권한이 필요합니다" }); } @@ -61,6 +58,64 @@ async function requireAdmin(req, res, next) { } } +/** + * POST /api/admin/logs/upload - 모드에서 로그 파일 업로드 + * (인증 없음 - 내부 네트워크에서만 접근 가능) + */ +router.post( + "/logs/upload", + express.raw({ type: "multipart/form-data", limit: "50mb" }), + async (req, res) => { + try { + const boundary = req.headers["content-type"].split("boundary=")[1]; + const body = req.body.toString("binary"); + const parts = body.split(`--${boundary}`); + + let serverId = ""; + let fileType = "dated"; + let fileName = ""; + let fileData = null; + + for (const part of parts) { + if (part.includes('name="serverId"')) { + serverId = part.split("\r\n\r\n")[1]?.trim().split("\r\n")[0] || ""; + } else if (part.includes('name="fileType"')) { + fileType = + part.split("\r\n\r\n")[1]?.trim().split("\r\n")[0] || "dated"; + } else if (part.includes('name="file"')) { + const match = part.match(/filename="([^"]+)"/); + if (match) fileName = match[1]; + const contentStart = part.indexOf("\r\n\r\n") + 4; + const contentEnd = part.lastIndexOf("\r\n"); + fileData = Buffer.from( + part.substring(contentStart, contentEnd), + "binary" + ); + } + } + + if (!serverId || !fileName || !fileData) { + return res.status(400).json({ error: "Missing required fields" }); + } + + const s3Key = `logs/${serverId}/${fileType}/${fileName}`; + const { uploadToS3 } = await import("../lib/s3.js"); + await uploadToS3("minecraft", s3Key, fileData, "application/gzip"); + + await pool.query( + "INSERT INTO log_files (server_id, file_name, file_type, file_size, s3_key) VALUES (?, ?, ?, ?, ?)", + [serverId, fileName, fileType, fileData.length, s3Key] + ); + + console.log(`[Admin] 로그 업로드 완료: ${s3Key}`); + res.json({ success: true, s3Key }); + } catch (error) { + console.error("[Admin] 로그 업로드 오류:", error); + res.status(500).json({ error: "업로드 실패" }); + } + } +); + // 모든 라우트에 관리자 권한 필요 router.use(requireAdmin); @@ -95,4 +150,179 @@ router.post("/command", async (req, res) => { } }); +/** + * GET /api/admin/logs - 서버 로그 조회 + */ +router.get("/logs", async (req, res) => { + try { + const response = await fetch(`${MOD_API_URL}/logs`); + const data = await response.json(); + res.json(data); + } catch (error) { + console.error("[Admin] 로그 조회 오류:", error); + res.status(500).json({ logs: [], error: "서버에 연결할 수 없습니다" }); + } +}); + +/** + * GET /api/admin/players - 플레이어 목록 조회 + */ +router.get("/players", async (req, res) => { + try { + const response = await fetch(`${MOD_API_URL}/players`); + const data = await response.json(); + res.json(data); + } catch (error) { + console.error("[Admin] 플레이어 목록 조회 오류:", error); + res.status(500).json({ players: [], error: "서버에 연결할 수 없습니다" }); + } +}); + +/** + * GET /api/admin/banlist - 밴 목록 조회 (모드 API 프록시) + */ +router.get("/banlist", async (req, res) => { + try { + const response = await fetch(`${MOD_API_URL}/banlist`); + const data = await response.json(); + res.json(data); + } catch (error) { + console.error("[Admin] 밴 목록 조회 오류:", error); + res.status(500).json({ banList: [], error: "서버에 연결할 수 없습니다" }); + } +}); + +/** + * GET /api/admin/logfiles - DB에서 로그 파일 목록 조회 + */ +router.get("/logfiles", async (req, res) => { + try { + const { serverId, fileType } = req.query; + + let query = "SELECT * FROM log_files"; + const params = []; + const conditions = []; + + if (serverId) { + conditions.push("server_id = ?"); + params.push(serverId); + } + if (fileType) { + conditions.push("file_type = ?"); + params.push(fileType); + } + + if (conditions.length > 0) { + query += " WHERE " + conditions.join(" AND "); + } + query += " ORDER BY file_name DESC"; + + const [files] = await pool.query(query, params); + + // 서버 ID 목록도 함께 반환 + const [servers] = await pool.query( + "SELECT DISTINCT server_id FROM log_files ORDER BY server_id" + ); + + res.json({ + files: files.map((f) => ({ + id: f.id, + serverId: f.server_id, + fileName: f.file_name, + fileType: f.file_type, + fileSize: formatFileSize(f.file_size), + s3Key: f.s3_key, + uploadedAt: f.uploaded_at, + })), + servers: servers.map((s) => s.server_id), + }); + } catch (error) { + console.error("[Admin] 로그 파일 목록 오류:", error); + res.status(500).json({ files: [], error: "DB 조회 실패" }); + } +}); + +/** + * GET /api/admin/logfile - RustFS에서 로그 파일 다운로드 + */ +router.get("/logfile", async (req, res) => { + try { + const fileId = req.query.id; + if (!fileId) { + return res.status(400).json({ error: "File ID required" }); + } + + // DB에서 파일 정보 조회 + const [rows] = await pool.query("SELECT * FROM log_files WHERE id = ?", [ + fileId, + ]); + if (rows.length === 0) { + return res.status(404).json({ error: "File not found" }); + } + + const file = rows[0]; + + // RustFS에서 다운로드 + const { downloadFromS3 } = await import("../lib/s3.js"); + const buffer = await downloadFromS3("minecraft", file.s3_key); + + res.setHeader("Content-Type", "application/gzip"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${file.file_name}"` + ); + res.send(buffer); + } catch (error) { + console.error("[Admin] 로그 파일 다운로드 오류:", error); + res.status(500).json({ error: "다운로드 실패" }); + } +}); + +/** + * DELETE /api/admin/logfile - 로그 파일 삭제 + */ +router.delete("/logfile", async (req, res) => { + try { + const fileId = req.query.id; + if (!fileId) { + return res.status(400).json({ error: "File ID required" }); + } + + // DB에서 파일 정보 조회 + const [rows] = await pool.query("SELECT * FROM log_files WHERE id = ?", [ + fileId, + ]); + if (rows.length === 0) { + return res.status(404).json({ error: "File not found" }); + } + + const file = rows[0]; + + // S3에서 파일 삭제 (선택적 - 실패해도 DB 레코드는 삭제) + try { + // S3 삭제는 복잡하므로 일단 DB만 삭제 + } catch (s3Error) { + console.error("[Admin] S3 삭제 실패:", s3Error); + } + + // DB에서 레코드 삭제 + await pool.query("DELETE FROM log_files WHERE id = ?", [fileId]); + + console.log(`[Admin] 로그 파일 삭제: ${file.file_name}`); + res.json({ success: true }); + } catch (error) { + console.error("[Admin] 로그 파일 삭제 오류:", error); + res.status(500).json({ error: "삭제 실패" }); + } +}); + +/** + * 파일 크기 포맷 + */ +function formatFileSize(bytes) { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB"; + return bytes + " B"; +} + export default router; diff --git a/backend/routes/link.js b/backend/routes/link.js index 48b55ec..8da7fda 100644 --- a/backend/routes/link.js +++ b/backend/routes/link.js @@ -219,17 +219,19 @@ router.get("/status", async (req, res) => { const modRes = await fetch(`${MOD_API_URL}/player/${uuid}`); if (modRes.ok) { const playerData = await modRes.json(); - if (playerData.name && playerData.name !== currentName) { + // displayName이 있으면 displayName 사용 (Essentials 닉네임), 없으면 name 사용 + const newName = playerData.displayName || playerData.name; + if (newName && newName !== currentName) { // 닉네임이 변경되었으면 minecraft_links 및 users 테이블 모두 업데이트 await pool.query( "UPDATE minecraft_links SET minecraft_name = ? WHERE user_id = ?", - [playerData.name, user.id] + [newName, user.id] ); await pool.query("UPDATE users SET name = ? WHERE id = ?", [ - playerData.name, + newName, user.id, ]); - currentName = playerData.name; + currentName = newName; console.log( `[Link] 닉네임 동기화: ${links[0].minecraft_name} → ${currentName}` ); diff --git a/backend/server.js b/backend/server.js index 80601ee..8ad7626 100644 --- a/backend/server.js +++ b/backend/server.js @@ -68,7 +68,7 @@ app.use("/auth", authRoutes); app.use("/link", linkRoutes); // 관리자 라우트 -app.use("/admin", adminRoutes); +app.use("/api/admin", adminRoutes); // Socket.IO 연결 처리 io.on("connection", (socket) => { @@ -125,9 +125,28 @@ async function refreshAndBroadcast() { io.emit("players", cachedPlayers); } +// 로그 캐시 (중복 브로드캐스트 방지) +let lastLogCount = 0; + +// 로그 갱신 및 브로드캐스트 +async function refreshLogs() { + try { + const response = await fetch(`${MOD_API_URL}/logs`); + const data = await response.json(); + if (data.logs && data.logs.length !== lastLogCount) { + lastLogCount = data.logs.length; + io.emit("logs", data.logs); + } + } catch (error) { + // 연결 오류 무시 + } +} + // 1초마다 데이터 갱신 setInterval(refreshAndBroadcast, 1000); +setInterval(refreshLogs, 1000); refreshAndBroadcast(); +refreshLogs(); // SPA 라우팅 - 모든 경로에 대해 index.html 제공 app.get("*", (req, res) => { diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index b65f5d7..1ffa0f6 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -67,46 +67,70 @@ const Sidebar = ({ isMobile = false }) => { }; fetchLinkStatus(); - }, [isLoggedIn, user]); + }, [isLoggedIn, user?.id]); + + // 서버 상태 확인 및 닉네임 동기화 (socket.io) + // useRef로 최신 값을 참조하여 의존성 루프 방지 + const userNameRef = useRef(user?.name); + const checkAuthRef = useRef(checkAuth); + const minecraftUuidRef = useRef(minecraftLink?.minecraftUuid); + const lastSyncedServerNameRef = useRef(null); // 마지막으로 확인한 서버 닉네임 + + useEffect(() => { + userNameRef.current = user?.name; + }, [user?.name]); + + useEffect(() => { + checkAuthRef.current = checkAuth; + }, [checkAuth]); + + useEffect(() => { + minecraftUuidRef.current = minecraftLink?.minecraftUuid; + }, [minecraftLink?.minecraftUuid]); - // 서버 상태 확인 (socket.io) + 닉네임 변경 시에만 동기화 useEffect(() => { const socket = io(window.location.origin, { path: '/socket.io' }); let isSyncing = false; - socket.on('status', async (status) => { + // 서버 상태만 업데이트 + socket.on('status', (status) => { setServerOnline(status?.online || false); - - // 동기화 중이면 스킵 + }); + + // 닉네임 동기화는 players 이벤트에서 처리 (displayName 포함) + socket.on('players', async (playersList) => { + const currentUuid = minecraftUuidRef.current; + if (!currentUuid || !playersList) return; if (isSyncing) return; - // 연동된 유저인 경우, 소켓에서 받은 닉네임과 현재 닉네임 비교 - if (status?.online && minecraftLink?.minecraftUuid && status?.players?.list) { - const playerInGame = status.players.list.find(p => p.uuid === minecraftLink.minecraftUuid); - // 게임 내 닉네임과 현재 저장된 닉네임이 다르면 동기화 - if (playerInGame && playerInGame.name !== user?.name) { - isSyncing = true; - try { - const token = localStorage.getItem('token'); - const res = await fetch('/link/status', { - headers: { 'Authorization': `Bearer ${token}` } - }); - const data = await res.json(); - if (data.linked) { - setMinecraftLink(data); - await checkAuth(); - } - } catch (error) { - // 무시 - } finally { - isSyncing = false; - } + const playerInGame = playersList.find(p => p.uuid === currentUuid); + // displayName이 있으면 displayName 사용, 없으면 name 사용 + const serverName = playerInGame?.displayName || playerInGame?.name; + + // 서버 닉네임이 변경되었고, 아직 동기화하지 않은 경우에만 실행 + if (playerInGame && serverName && + serverName !== lastSyncedServerNameRef.current && + serverName !== userNameRef.current) { + isSyncing = true; + lastSyncedServerNameRef.current = serverName; // 동기화 시도한 이름 저장 + try { + // /link/status 호출하여 DB 업데이트 트리거 + const token = localStorage.getItem('token'); + await fetch('/link/status', { + headers: { 'Authorization': `Bearer ${token}` } + }); + // user 상태 갱신 + await checkAuthRef.current(); + } catch (error) { + // 에러 무시 + } finally { + isSyncing = false; } } }); return () => socket.disconnect(); - }, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name, checkAuth]); + }, []); // 의존성 없음 - ref로 최신 값 참조 // 토스트 자동 숨기기 useEffect(() => { diff --git a/frontend/src/index.css b/frontend/src/index.css index 0d69a4f..7d6936d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -2,7 +2,11 @@ @tailwind components; @tailwind utilities; -/* 기본 body 스타일 - 다크 배경 */ +/* 기본 html, body 스타일 - 다크 배경 & 스크롤바 레이아웃 고정 */ +html { + scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */ +} + body { background: #141414; min-height: 100vh; diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index bc9b9e9..82c9e2f 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -3,15 +3,16 @@ * - 탭 UI: 콘솔 / 플레이어 / 설정 */ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; import { - Shield, ArrowLeft, Loader2, Terminal, Users, Settings, + Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings, Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning, - ChevronDown, FileText, Download, Trash2, Check + ChevronDown, FileText, Download, Trash2, Check, RefreshCw, Eye, X } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { io } from 'socket.io-client'; // 더미 로그 데이터 const DUMMY_LOGS = [ @@ -25,20 +26,7 @@ const DUMMY_LOGS = [ { time: '15:15:00', type: 'info', message: '[Server] 비머부캐 joined the game' }, ]; -// 더미 로그 파일 데이터 -const DUMMY_LOG_FILES = [ - { name: '2024-12-22.log', size: '2.4 MB', date: '2024-12-22' }, - { name: '2024-12-21.log', size: '1.8 MB', date: '2024-12-21' }, - { name: '2024-12-20.log', size: '3.1 MB', date: '2024-12-20' }, -]; - -// 더미 플레이어 데이터 -const DUMMY_PLAYERS = [ - { uuid: '1234-5678-9012-3456', name: '비머', isOnline: true, isOp: true }, - { uuid: '2345-6789-0123-4567', name: '비머부캐', isOnline: true, isOp: false }, - { uuid: '3456-7890-1234-5678', name: 'Steve', isOnline: false, isOp: false }, - { uuid: '4567-8901-2345-6789', name: 'Alex', isOnline: false, isOp: false }, -]; +// 더미 로그 파일 데이터 (삭제됨 - API 사용) // 더미 게임규칙 데이터 const DUMMY_GAMERULES = [ @@ -60,17 +48,34 @@ export default function Admin({ isMobile = false }) { const [activeTab, setActiveTab] = useState('console'); // 콘솔 관련 상태 - const [logs, setLogs] = useState(DUMMY_LOGS); + const [logs, setLogs] = useState([]); const [command, setCommand] = useState(''); - const [logFiles] = useState(DUMMY_LOG_FILES); + const [logFiles, setLogFiles] = useState([]); + const [logServers, setLogServers] = useState([]); // 서버 ID 목록 + const [selectedLogServer, setSelectedLogServer] = useState('all'); // 선택된 서버 + const [selectedLogType, setSelectedLogType] = useState('all'); // 로그 종류 필터 + const [logViewerOpen, setLogViewerOpen] = useState(false); // 로그 뷰어 다이얼로그 + const [viewingLog, setViewingLog] = useState(null); // 보고 있는 로그 파일 + const [logContent, setLogContent] = useState(''); // 로그 내용 + const [logLoading, setLogLoading] = useState(false); // 로그 로딩 + const [serverDropdownOpen, setServerDropdownOpen] = useState(false); // 서버 드롭다운 + const [typeDropdownOpen, setTypeDropdownOpen] = useState(false); // 타입 드롭다운 const logEndRef = useRef(null); + const logContainerRef = useRef(null); + const isInitialLoad = useRef(true); + const [isAtBottom, setIsAtBottom] = useState(true); // 스크롤이 맨 아래에 있는지 추적 + + // 명령어 히스토리 + const [commandHistory, setCommandHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); // 플레이어 관련 상태 - const [players, setPlayers] = useState(DUMMY_PLAYERS); + const [players, setPlayers] = useState([]); + const [banList, setBanList] = useState([]); // 밴 목록 const [playerFilter, setPlayerFilter] = useState('all'); // all, online, offline, banned const [selectedPlayer, setSelectedPlayer] = useState(null); const [showPlayerDialog, setShowPlayerDialog] = useState(false); - const [dialogAction, setDialogAction] = useState(null); // kick, ban, op + const [dialogAction, setDialogAction] = useState(null); // kick, ban, op, unban const [actionReason, setActionReason] = useState(''); // 설정 관련 상태 @@ -99,23 +104,317 @@ export default function Admin({ isMobile = false }) { } }, [toast]); - // 로그 스크롤 + // 플레이어 목록 fetch (안정적인 참조) + const fetchPlayers = useCallback(async () => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/players', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + if (data.players) { + setPlayers(data.players); + } + } catch (error) { + console.error('플레이어 목록 조회 실패:', error); + } + }, []); + + // 밴 목록 fetch (안정적인 참조) + const fetchBanList = useCallback(async () => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/banlist', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + if (data.banList) { + setBanList(data.banList); + } + } catch (error) { + console.error('밴 목록 조회 실패:', error); + } + }, []); + + // 플레이어 탭 활성화 시 데이터 로드 useEffect(() => { - logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [logs]); + if (activeTab === 'players' && isAdmin) { + fetchPlayers(); + fetchBanList(); + } + }, [activeTab, isAdmin]); + + // 스크롤 위치가 맨 아래인지 확인하는 함수 + const checkIsAtBottom = useCallback((container) => { + if (!container) return true; + // 5px 오차 허용 (스크롤 정밀도 문제 대응) + return container.scrollHeight - container.scrollTop - container.clientHeight < 5; + }, []); + + // 스크롤 이벤트 핸들러 - isAtBottom 상태 업데이트 + const handleLogScroll = useCallback((e) => { + const container = e.target; + setIsAtBottom(checkIsAtBottom(container)); + }, [checkIsAtBottom]); + + // 맨 아래로 스크롤하는 함수 + const scrollToBottom = useCallback(() => { + const container = logContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight; + setIsAtBottom(true); + } + }, []); + + // 로그 스크롤 - 새 로그 추가 시 부드럽게 스크롤 (맨 아래에 있을 때만) + // CSS scroll-behavior: smooth가 컨테이너에 적용되어 있으므로 scrollTop 설정만으로 부드럽게 동작 + useEffect(() => { + if (activeTab !== 'console' || logs.length === 0) return; + + const container = logContainerRef.current; + if (!container) return; + + if (isInitialLoad.current) { + // 초기 로드 시 즉시 스크롤 (애니메이션 없이) + container.style.scrollBehavior = 'auto'; + container.scrollTop = container.scrollHeight; + setIsAtBottom(true); + requestAnimationFrame(() => { + container.style.scrollBehavior = 'smooth'; + }); + isInitialLoad.current = false; + } else if (isAtBottom) { + // 맨 아래에 있을 때만 새 로그에 따라 자동 스크롤 + container.scrollTop = container.scrollHeight; + } + }, [logs, activeTab, isAtBottom]); + + // 탭 전환 시 맨 아래로 스크롤 (ref 콜백 방식) + // AnimatePresence로 인해 컴포넌트가 리마운트될 때 useEffect가 다시 트리거되지 않으므로 + // ref 콜백을 사용하여 DOM이 마운트될 때마다 스크롤 처리 + // 주의: logs.length를 의존성에서 제거하여 새 로그 추가 시에는 위의 useEffect에서 부드럽게 스크롤 + const setLogContainerRef = useCallback((node) => { + logContainerRef.current = node; + if (node) { + // DOM 마운트 시 (탭 전환 시) 즉시 맨 아래로 스크롤 + // CSS smooth가 설정되어 있으므로 일시적으로 비활성화 + requestAnimationFrame(() => { + node.style.scrollBehavior = 'auto'; // 즉시 스크롤 + node.scrollTop = node.scrollHeight; + setIsAtBottom(true); + // 다음 프레임에서 smooth 복원 (새 로그 추가 시 부드럽게) + requestAnimationFrame(() => { + node.style.scrollBehavior = 'smooth'; + }); + }); + } + }, []); // 의존성 없음 - 마운트 시에만 실행 + + // 로그 파일 목록 fetch 함수 + const fetchLogFiles = async () => { + try { + const token = localStorage.getItem('token'); + const params = new URLSearchParams(); + if (selectedLogServer !== 'all') params.append('serverId', selectedLogServer); + if (selectedLogType !== 'all') params.append('fileType', selectedLogType); + + const response = await fetch(`/api/admin/logfiles?${params}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + if (data.files) { + setLogFiles(data.files); + } + if (data.servers) { + setLogServers(data.servers); + } + } catch (error) { + console.error('로그 파일 목록 조회 실패:', error); + } + }; + + // 로그 파일 목록 자동 fetch + useEffect(() => { + fetchLogFiles(); + }, [selectedLogServer, selectedLogType]); + + // 로그 파일 내용 보기 + const viewLogContent = async (file) => { + setViewingLog(file); + setLogViewerOpen(true); + setLogLoading(true); + setLogContent(''); + + try { + const token = localStorage.getItem('token'); + const response = await fetch(`/api/admin/logfile?id=${file.id}`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + const blob = await response.blob(); + + // .gz 파일만 압축 해제 + if (file.fileName.endsWith('.gz')) { + const ds = new DecompressionStream('gzip'); + const decompressedStream = blob.stream().pipeThrough(ds); + const decompressedBlob = await new Response(decompressedStream).blob(); + const text = await decompressedBlob.text(); + setLogContent(text); + } else { + // .log 파일은 직접 텍스트로 읽기 + const text = await blob.text(); + setLogContent(text); + } + } else { + setLogContent('로그 파일을 불러올 수 없습니다.'); + } + } catch (error) { + console.error('로그 파일 로드 실패:', error); + setLogContent('로그 파일을 불러오는 중 오류가 발생했습니다.'); + } finally { + setLogLoading(false); + } + }; + + // 로그 파일 삭제 + const deleteLogFile = async (file, e) => { + if (e) e.stopPropagation(); + if (!confirm(`${file.fileName} 파일을 삭제하시겠습니까?`)) return; + + try { + const token = localStorage.getItem('token'); + const response = await fetch(`/api/admin/logfile?id=${file.id}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (response.ok) { + fetchLogFiles(); // 목록 새로고침 + } + } catch (error) { + console.error('로그 파일 삭제 실패:', error); + } + }; + + // 마인크래프트 색상 코드 매핑 + const MC_COLORS = { + '0': '#000000', // Black + '1': '#0000AA', // Dark Blue + '2': '#00AA00', // Dark Green + '3': '#00AAAA', // Dark Aqua + '4': '#AA0000', // Dark Red + '5': '#AA00AA', // Dark Purple + '6': '#FFAA00', // Gold + '7': '#AAAAAA', // Gray + '8': '#555555', // Dark Gray + '9': '#5555FF', // Blue + 'a': '#55FF55', // Green + 'b': '#55FFFF', // Aqua + 'c': '#FF5555', // Red + 'd': '#FF55FF', // Light Purple + 'e': '#FFFF55', // Yellow + 'f': '#FFFFFF', // White + }; + + // 마인크래프트 색상 코드를 HTML span으로 변환 + const parseMinecraftColors = (text) => { + if (!text) return []; + + const parts = []; + let currentColor = null; + let buffer = ''; + let i = 0; + + while (i < text.length) { + if (text[i] === '§' && i + 1 < text.length) { + // 현재 버퍼 저장 + if (buffer) { + parts.push({ text: buffer, color: currentColor }); + buffer = ''; + } + + const code = text[i + 1].toLowerCase(); + if (MC_COLORS[code]) { + currentColor = MC_COLORS[code]; + } else if (code === 'r') { + currentColor = '#FFFFFF'; // Reset to white + } + // k, l, m, n, o는 스타일 코드 (무시) + i += 2; + } else { + buffer += text[i]; + i++; + } + } + + // 남은 버퍼 저장 + if (buffer) { + parts.push({ text: buffer, color: currentColor }); + } + + return parts; + }; + + // 실제 서버 로그 Socket.io로 수신 + useEffect(() => { + if (!isAdmin) return; + + // 초기 로그 fetch (Socket.io 연결 전) + const fetchInitialLogs = async () => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/admin/logs', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + if (data.logs && data.logs.length > 0) { + setLogs(data.logs.map(log => ({ + time: log.time, + type: log.level === 'ERROR' ? 'error' : log.level === 'WARN' ? 'warning' : 'info', + message: log.message + }))); + } + } catch (error) { + console.error('초기 로그 조회 오류:', error); + } + }; + fetchInitialLogs(); + + const socket = io('/', { + path: '/socket.io', + transports: ['websocket', 'polling'] + }); + + socket.on('logs', (serverLogs) => { + if (serverLogs && Array.isArray(serverLogs)) { + setLogs(serverLogs.map(log => ({ + time: log.time, + type: log.level === 'ERROR' ? 'error' : log.level === 'WARN' ? 'warning' : 'info', + message: log.message + }))); + } + }); + + // 플레이어 목록 실시간 업데이트 + socket.on('players', (playersList) => { + if (playersList && Array.isArray(playersList)) { + setPlayers(playersList); + } + }); + + return () => { + socket.disconnect(); + }; + }, [isAdmin]); // 명령어 실행 (실제 API 호출) const handleCommand = async () => { if (!command.trim()) return; - const timestamp = new Date().toLocaleTimeString('ko-KR', { hour12: false }); - - // 입력한 명령어를 로그에 추가 - setLogs(prev => [...prev, { time: timestamp, type: 'command', message: `> ${command}` }]); - try { const token = localStorage.getItem('token'); - const response = await fetch('/admin/command', { + await fetch('/api/admin/command', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -123,62 +422,70 @@ export default function Admin({ isMobile = false }) { }, body: JSON.stringify({ command: command.trim() }) }); - - const data = await response.json(); - - if (data.success) { - setLogs(prev => [...prev, { - time: new Date().toLocaleTimeString('ko-KR', { hour12: false }), - type: 'info', - message: `[Server] ${data.message}` - }]); - setToast('명령어가 실행되었습니다.'); - } else { - setLogs(prev => [...prev, { - time: new Date().toLocaleTimeString('ko-KR', { hour12: false }), - type: 'error', - message: `[Error] ${data.message || '명령어 실행 실패'}` - }]); - setToast(data.message || '명령어 실행 실패'); - } } catch (error) { - setLogs(prev => [...prev, { - time: new Date().toLocaleTimeString('ko-KR', { hour12: false }), - type: 'error', - message: `[Error] 서버에 연결할 수 없습니다` - }]); - setToast('서버에 연결할 수 없습니다'); + // 오류 무시 (로그에서 확인 가능) } setCommand(''); }; // 플레이어 액션 핸들러 - const handlePlayerAction = () => { + const handlePlayerAction = async () => { if (!selectedPlayer || !dialogAction) return; + const token = localStorage.getItem('token'); + let command = ''; let message = ''; + switch (dialogAction) { case 'kick': - message = `${selectedPlayer.name}님을 추방했습니다.`; + command = actionReason ? `kick ${selectedPlayer.name} ${actionReason}` : `kick ${selectedPlayer.name}`; + message = `${selectedPlayer.displayName || selectedPlayer.name}님을 추방했습니다.`; break; case 'ban': - message = `${selectedPlayer.name}님을 차단했습니다.`; + command = actionReason ? `ban ${selectedPlayer.name} ${actionReason}` : `ban ${selectedPlayer.name}`; + message = `${selectedPlayer.displayName || selectedPlayer.name}님을 차단했습니다.`; + break; + case 'unban': + command = `pardon ${selectedPlayer.name}`; + message = `${selectedPlayer.displayName || selectedPlayer.name}님의 차단을 해제했습니다.`; break; case 'op': - const isOp = players.find(p => p.uuid === selectedPlayer.uuid)?.isOp; - setPlayers(prev => prev.map(p => - p.uuid === selectedPlayer.uuid ? { ...p, isOp: !isOp } : p - )); - message = isOp ? `${selectedPlayer.name}님의 OP를 해제했습니다.` : `${selectedPlayer.name}님에게 OP를 부여했습니다.`; + const isOp = selectedPlayer.isOp; + command = isOp ? `deop ${selectedPlayer.name}` : `op ${selectedPlayer.name}`; + message = isOp ? `${selectedPlayer.displayName || selectedPlayer.name}님의 OP를 해제했습니다.` : `${selectedPlayer.displayName || selectedPlayer.name}님에게 OP를 부여했습니다.`; break; } + try { + const response = await fetch('/api/admin/command', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ command }) + }); + + if (response.ok) { + setToast(message); + // 데이터 새로고침 + setTimeout(() => { + fetchPlayers(); + fetchBanList(); + }, 500); + } else { + setToast('명령어 실행에 실패했습니다.'); + } + } catch (error) { + console.error('플레이어 액션 오류:', error); + setToast('서버 연결에 실패했습니다.'); + } + setShowPlayerDialog(false); setSelectedPlayer(null); setDialogAction(null); setActionReason(''); - setToast(message); }; // 게임규칙 토글 @@ -189,7 +496,17 @@ export default function Admin({ isMobile = false }) { setToast('게임규칙이 변경되었습니다.'); }; - // 로그 색상 + // 로그 색상 (hex 값 반환) + const getLogColorHex = (type) => { + switch (type) { + case 'error': return '#f87171'; // red-400 + case 'warning': return '#facc15'; // yellow-400 + case 'command': return '#22c55e'; // mc-green + default: return '#d4d4d8'; // zinc-300 + } + }; + + // 로그 색상 (클래스명 반환 - 시간용) const getLogColor = (type) => { switch (type) { case 'error': return 'text-red-400'; @@ -199,12 +516,23 @@ export default function Admin({ isMobile = false }) { } }; - // 필터된 플레이어 - const filteredPlayers = players.filter(p => { - if (playerFilter === 'online') return p.isOnline; - if (playerFilter === 'offline') return !p.isOnline; - return true; - }); + // 필터된 플레이어 (banned 필터는 banList 사용) + const filteredPlayers = playerFilter === 'banned' + ? banList.map(ban => ({ + name: ban.name, + uuid: ban.uuid, + displayName: ban.name, + isOnline: false, + isOp: false, + isBanned: true, + banReason: ban.reason, + banSource: ban.source + })) + : players.filter(p => { + if (playerFilter === 'online') return p.isOnline; + if (playerFilter === 'offline') return !p.isOnline; + return true; + }); if (loading) { return ( @@ -312,62 +640,243 @@ export default function Admin({ isMobile = false }) { className="space-y-4" > {/* 로그 영역 */} -
{file.name}
-{file.size}
-로그 파일이 없습니다
+ ) : ( + logFiles.map((file) => ( +{file.fileName}
++ {file.fileSize} • {file.serverId} • + + {file.fileType} + +
+{player.name}
+{player.displayName || player.name}
+{player.name}
+ {player.isBanned && ( + 차단됨 + )} - {/* 액션 버튼 */} -{selectedPlayer.name}
+{selectedPlayer.displayName || selectedPlayer.name}
{selectedPlayer.uuid}
+ {viewingLog?.fileSize} • {viewingLog?.serverId} • {viewingLog?.fileType} +
+