feat: 콘솔 스크롤 개선 및 닉네임 실시간 동기화 구현

- 콘솔 탭 스크롤 동작 개선 (조건부 자동 스크롤, 맨 아래로 버튼)
- 탭 전환 시 레이아웃 쉬프트 방지 (scrollbar-gutter: stable)
- 맨 아래로 버튼에 그림자 효과 추가
- Sidebar에 소켓 기반 닉네임 실시간 동기화 로직 추가
- /link/status API에서 displayName 사용하도록 수정
This commit is contained in:
caadiq 2025-12-23 10:07:34 +09:00
parent c4d148810e
commit 6fe6d0dda0
7 changed files with 1127 additions and 174 deletions

View file

@ -75,13 +75,18 @@ function signV4(method, path, headers, payload, accessKey, secretKey) {
/** /**
* S3(RustFS) 파일 업로드 * S3(RustFS) 파일 업로드
*/ */
function uploadToS3(bucket, key, data) { function uploadToS3(
bucket,
key,
data,
contentType = "application/octet-stream"
) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const url = new URL(s3Config.endpoint); const url = new URL(s3Config.endpoint);
const path = `/${bucket}/${key}`; const path = `/${bucket}/${key}`;
const headers = { const headers = {
Host: url.host, Host: url.host,
"Content-Type": "image/png", "Content-Type": contentType,
"Content-Length": data.length.toString(), "Content-Length": data.length.toString(),
}; };
signV4("PUT", path, headers, data, s3Config.accessKey, s3Config.secretKey); 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 };

View file

@ -11,9 +11,7 @@ import { pool } from "../lib/db.js";
const router = express.Router(); const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret"; const JWT_SECRET = process.env.JWT_SECRET || "minecraft-jwt-secret";
const MOD_API_URL = `http://${ const MOD_API_URL = process.env.MOD_API_URL || "http://minecraft-server:8080";
process.env.MINECRAFT_HOST || "host.docker.internal"
}:${process.env.MINECRAFT_MOD_PORT || 25580}`;
/** /**
* JWT 토큰에서 사용자 정보 추출 * JWT 토큰에서 사용자 정보 추출
@ -43,13 +41,12 @@ async function requireAdmin(req, res, next) {
} }
try { try {
// DB에서 관리자 권한 확인 // DB에서 관리자 권한 확인 (MySQL 문법)
const result = await pool.query( const [rows] = await pool.query("SELECT is_admin FROM users WHERE id = ?", [
"SELECT is_admin FROM users WHERE id = $1 AND is_active = true", user.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: "관리자 권한이 필요합니다" }); 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); 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; export default router;

View file

@ -219,17 +219,19 @@ router.get("/status", async (req, res) => {
const modRes = await fetch(`${MOD_API_URL}/player/${uuid}`); const modRes = await fetch(`${MOD_API_URL}/player/${uuid}`);
if (modRes.ok) { if (modRes.ok) {
const playerData = await modRes.json(); 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 테이블 모두 업데이트 // 닉네임이 변경되었으면 minecraft_links 및 users 테이블 모두 업데이트
await pool.query( await pool.query(
"UPDATE minecraft_links SET minecraft_name = ? WHERE user_id = ?", "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 = ?", [ await pool.query("UPDATE users SET name = ? WHERE id = ?", [
playerData.name, newName,
user.id, user.id,
]); ]);
currentName = playerData.name; currentName = newName;
console.log( console.log(
`[Link] 닉네임 동기화: ${links[0].minecraft_name}${currentName}` `[Link] 닉네임 동기화: ${links[0].minecraft_name}${currentName}`
); );

View file

@ -68,7 +68,7 @@ app.use("/auth", authRoutes);
app.use("/link", linkRoutes); app.use("/link", linkRoutes);
// 관리자 라우트 // 관리자 라우트
app.use("/admin", adminRoutes); app.use("/api/admin", adminRoutes);
// Socket.IO 연결 처리 // Socket.IO 연결 처리
io.on("connection", (socket) => { io.on("connection", (socket) => {
@ -125,9 +125,28 @@ async function refreshAndBroadcast() {
io.emit("players", cachedPlayers); 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초마다 데이터 갱신 // 1초마다 데이터 갱신
setInterval(refreshAndBroadcast, 1000); setInterval(refreshAndBroadcast, 1000);
setInterval(refreshLogs, 1000);
refreshAndBroadcast(); refreshAndBroadcast();
refreshLogs();
// SPA 라우팅 - 모든 경로에 대해 index.html 제공 // SPA 라우팅 - 모든 경로에 대해 index.html 제공
app.get("*", (req, res) => { app.get("*", (req, res) => {

View file

@ -67,46 +67,70 @@ const Sidebar = ({ isMobile = false }) => {
}; };
fetchLinkStatus(); 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(() => { useEffect(() => {
const socket = io(window.location.origin, { path: '/socket.io' }); const socket = io(window.location.origin, { path: '/socket.io' });
let isSyncing = false; let isSyncing = false;
socket.on('status', async (status) => { //
socket.on('status', (status) => {
setServerOnline(status?.online || false); setServerOnline(status?.online || false);
});
//
// players (displayName )
socket.on('players', async (playersList) => {
const currentUuid = minecraftUuidRef.current;
if (!currentUuid || !playersList) return;
if (isSyncing) return; if (isSyncing) return;
// , const playerInGame = playersList.find(p => p.uuid === currentUuid);
if (status?.online && minecraftLink?.minecraftUuid && status?.players?.list) { // displayName displayName , name
const playerInGame = status.players.list.find(p => p.uuid === minecraftLink.minecraftUuid); const serverName = playerInGame?.displayName || playerInGame?.name;
//
if (playerInGame && playerInGame.name !== user?.name) { // ,
isSyncing = true; if (playerInGame && serverName &&
try { serverName !== lastSyncedServerNameRef.current &&
const token = localStorage.getItem('token'); serverName !== userNameRef.current) {
const res = await fetch('/link/status', { isSyncing = true;
headers: { 'Authorization': `Bearer ${token}` } lastSyncedServerNameRef.current = serverName; //
}); try {
const data = await res.json(); // /link/status DB
if (data.linked) { const token = localStorage.getItem('token');
setMinecraftLink(data); await fetch('/link/status', {
await checkAuth(); headers: { 'Authorization': `Bearer ${token}` }
} });
} catch (error) { // user
// await checkAuthRef.current();
} finally { } catch (error) {
isSyncing = false; //
} } finally {
isSyncing = false;
} }
} }
}); });
return () => socket.disconnect(); return () => socket.disconnect();
}, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name, checkAuth]); }, []); // - ref
// //
useEffect(() => { useEffect(() => {

View file

@ -2,7 +2,11 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 기본 body 스타일 - 다크 배경 */ /* 기본 html, body 스타일 - 다크 배경 & 스크롤바 레이아웃 고정 */
html {
scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */
}
body { body {
background: #141414; background: #141414;
min-height: 100vh; min-height: 100vh;

File diff suppressed because it is too large Load diff