feat: 콘솔 스크롤 개선 및 닉네임 실시간 동기화 구현
- 콘솔 탭 스크롤 동작 개선 (조건부 자동 스크롤, 맨 아래로 버튼) - 탭 전환 시 레이아웃 쉬프트 방지 (scrollbar-gutter: stable) - 맨 아래로 버튼에 그림자 효과 추가 - Sidebar에 소켓 기반 닉네임 실시간 동기화 로직 추가 - /link/status API에서 displayName 사용하도록 수정
This commit is contained in:
parent
c4d148810e
commit
6fe6d0dda0
7 changed files with 1127 additions and 174 deletions
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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');
|
||||
const res = await fetch('/link/status', {
|
||||
await fetch('/link/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.linked) {
|
||||
setMinecraftLink(data);
|
||||
await checkAuth();
|
||||
}
|
||||
// user 상태 갱신
|
||||
await checkAuthRef.current();
|
||||
} catch (error) {
|
||||
// 무시
|
||||
// 에러 무시
|
||||
} finally {
|
||||
isSyncing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => socket.disconnect();
|
||||
}, [isLoggedIn, minecraftLink?.minecraftUuid, user?.name, checkAuth]);
|
||||
}, []); // 의존성 없음 - ref로 최신 값 참조
|
||||
|
||||
// 토스트 자동 숨기기
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 기본 body 스타일 - 다크 배경 */
|
||||
/* 기본 html, body 스타일 - 다크 배경 & 스크롤바 레이아웃 고정 */
|
||||
html {
|
||||
scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */
|
||||
}
|
||||
|
||||
body {
|
||||
background: #141414;
|
||||
min-height: 100vh;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue