diff --git a/src/api/apis/log.js b/src/api/apis/log.js new file mode 100644 index 0000000..5eee9a2 --- /dev/null +++ b/src/api/apis/log.js @@ -0,0 +1,19 @@ +import api from "../utils/axios.jsx"; + +// log.js +// REST용 +const toRestPath = (t) => + ({ BACKEND: "backend", FRONTEND: "frontend", RDS: "rds" }[t] || + String(t || "").toLowerCase()); + +export const registerLog = (sourceType, teamCode) => + api.post(`/api/log/register/${toRestPath(sourceType)}/${teamCode}`); + +export const queryRange = (sourceType, teamCode, from, to) => + api.get('/api/log/query/range', { params: { sourceType, teamCode, from, to } }); + +export const queryContext = (sourceType, teamCode, level, context = 50) => + api.get('/api/log/query/context', { params: { sourceType, teamCode, level, context } }); + +export const queryStatus = (sourceType, teamCode) => + api.get('/api/log/query/status', { params: { sourceType, teamCode } }); diff --git a/src/api/hooks/log.js b/src/api/hooks/log.js new file mode 100644 index 0000000..63e098a --- /dev/null +++ b/src/api/hooks/log.js @@ -0,0 +1,149 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; + +const WS_URL = + import.meta.env.VITE_WS_URL || + ((import.meta.env.VITE_API_URL || '') + .replace(/^http(s?):\/\//, (_, s) => (s ? 'wss://' : 'ws://')) + '/ws/log'); + +const stateMap = { 0: 'connecting', 1: 'open', 2: 'closing', 3: 'closed' }; + +// ✅ WS 쿼리스트링은 서버 계약 그대로 "대문자" 유지 +const toWsParam = (t) => + ({ backend: "BACKEND", frontend: "FRONTEND", rds: "RDS" }[String(t || "").toLowerCase()] || + String(t || "").toUpperCase()); + +// ✅ 훅 함수명은 반드시 use로 시작 +export default function useLogSocket({ teamCode, sourceType }) { + const wsRef = useRef(null); + const [status, setStatus] = useState('closed'); + const [rows, setRows] = useState([]); + const [lastError, setLastError] = useState(null); + + const backoffRef = useRef(1000); + const manualCloseRef = useRef(false); + const reconnectTimerRef = useRef(null); + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const scheduleReconnect = useCallback(() => { + if (manualCloseRef.current) return; + if (reconnectTimerRef.current) return; + const wait = backoffRef.current; + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null; + connect(); + backoffRef.current = Math.min(backoffRef.current * 1.8, 30000); + }, wait); + }, []); + + const connect = useCallback(() => { + clearReconnectTimer(); + if (!teamCode || !sourceType) return; + + try { wsRef.current?.close(); } catch {} + wsRef.current = null; + + // ★ 계약: 쿼리스트링 방식으로만 연결 + console.log('[WS URL]', WS_URL); + const ws = new WebSocket(WS_URL); + + wsRef.current = ws; + setStatus(stateMap[ws.readyState] || 'connecting'); + + ws.onopen = () => { + console.log('[WS OPENED]'); + backoffRef.current = 1000; + setStatus('open'); + + const frame = { + type: 'register', + sourceType: toWsParam(sourceType), // ex: "BACKEND" + code: teamCode, // ex: "28a00517" + }; + + console.log('[WS SEND REGISTER]', frame); + ws.send(JSON.stringify(frame)); + }; + + + ws.onmessage = (e) => { + let msg; try { msg = JSON.parse(e.data); } catch { return; } + if (!msg || typeof msg !== 'object') return; + + // 1) 등록 ACK 등 제어 프레임은 무시 + if (msg.type === 'ok' || (msg.type === 'ack' && msg.action === 'register')) return; + if (msg.type === 'error') { setLastError({ code: msg.code, message: msg.message }); return; } + + // 2) 단일 DTO (ts/level/service/message) 수신 + if ('ts' in msg || 'timestamp' in msg || 'time' in msg) { + const ts = msg.ts ?? msg.timestamp ?? msg.time ?? ''; + const local = new Date((ts || '') + 'Z'); + const row = { + time: isNaN(local) ? ts : local.toLocaleString(), + level: msg.level || 'INFO', + service: msg.service || '-', + message: msg.message || '', + }; + setRows(prev => [row, ...prev]); + return; + } + + // 3) 배열 응답(과거 로그 묶음)도 지원 + if (Array.isArray(msg.data)) { + const list = msg.data.map(r => { + const ts = r.ts ?? r.timestamp ?? r.time ?? ''; + const local = new Date((ts || '') + 'Z'); + return { + time: isNaN(local) ? ts : local.toLocaleString(), + level: r.level || 'INFO', + service: r.service || '-', + message: r.message || '', + }; + }); + setRows(prev => [...list.reverse(), ...prev]); + } + };; + + ws.onclose = (e) => { + setStatus("closed"); + console.warn("[WS CLOSE]", { code: e.code, reason: e.reason, wasClean: e.wasClean }); + if (!manualCloseRef.current) scheduleReconnect(); + }; + + ws.onerror = (err) => { + setLastError({ code: "WS", message: String(err?.message || "WebSocket error") }); + console.error("[WS ERROR]", err); + }; + }, [teamCode, sourceType, scheduleReconnect]); + + const disconnect = useCallback(() => { + manualCloseRef.current = true; + clearReconnectTimer(); + try { wsRef.current?.close(1000, 'client close'); } catch {} + wsRef.current = null; + setStatus('closed'); + }, []); + + useEffect(() => { + manualCloseRef.current = false; + connect(); + return () => disconnect(); + }, [connect, disconnect]); + + const requestRange = useCallback((from, to) => { + if (!wsRef.current || wsRef.current.readyState !== 1) return; + wsRef.current.send(JSON.stringify({ type: 'filter', from, to })); + }, []); + + const requestLevelContext = useCallback((level, context = 50) => { + if (!wsRef.current || wsRef.current.readyState !== 1) return; + wsRef.current.send(JSON.stringify({ type: 'levelFilter', level, context })); + }, []); + + return { status, rows, lastError, requestRange, requestLevelContext, reconnect: connect, disconnect }; +} diff --git a/src/api/utils/axios.jsx b/src/api/utils/axios.jsx index 97d41e7..c5c38b9 100644 --- a/src/api/utils/axios.jsx +++ b/src/api/utils/axios.jsx @@ -88,4 +88,5 @@ clientAI.interceptors.response.use( } ); +export default client; export { client, clientAI }; diff --git a/src/components/DevTools/ERD/edge/ERDEdge.jsx b/src/components/DevTools/ERD/edge/ERDEdge.jsx index 1948745..aeaf29a 100644 --- a/src/components/DevTools/ERD/edge/ERDEdge.jsx +++ b/src/components/DevTools/ERD/edge/ERDEdge.jsx @@ -1,6 +1,6 @@ // src/components/DevTools/ERD/edge/ERDEdge.jsx import React from 'react'; -import { BaseEdge, EdgeLabelRenderer, getBezierPath, useStore } from 'reactflow'; +import { useStore } from 'reactflow'; import { O_Crow, O_Bar, @@ -24,8 +24,8 @@ const pickIcon = (card) => { }; const ICON_HALF = 12; -const PAD = -14; // 0~1px 미세조정 (경계 겹침/틈 방지) -const STUB_INSET = 0; // 왼쪽 짧은 막대가 아이콘 왼쪽 가장자리에서 안쪽으로 들어온 거리(px) +const PAD = -15; // 0~1px 미세조정 (경계 겹침/틈 방지) +const STUB_INSET = 2; // 왼쪽 짧은 막대가 아이콘 왼쪽 가장자리에서 안쪽으로 들어온 거리(px) // 각도(deg)만큼 회전한 아이콘 로컬 좌표를 월드 좌표로 변환 function rotateLocalToWorld(cx, cy, deg, lx, ly) { @@ -108,22 +108,6 @@ export default function ErdEdge(props) { const sPt = getIntersectionOnRect(sRect, sc, tc); const tPt = getIntersectionOnRect(tRect, tc, sc); - const dx = tPt.x - sPt.x; - const dy = tPt.y - sPt.y; - const horizontalDominant = Math.abs(dx) >= Math.abs(dy); - - let sourceIconDeg, targetIconDeg; - if (horizontalDominant) { - // 좌↔우 배치: 서로 마주보게 (← →) - sourceIconDeg = dx >= 0 ? 180 : 0; - targetIconDeg = dx >= 0 ? 0 : 180; - } else { - // 위↕아래 배치: 서로 마주보게 (↑ ↓) - // dy>0: 타깃이 아래 => 소스는 ↓(90°), 타깃은 ↑(-90°) - sourceIconDeg = dy >= 0 ? -90 : 90; - targetIconDeg = dy >= 0 ? 90 : -90; - } - function whichSide(rect, p) { const left = Math.abs(p.x - rect.x); const right = Math.abs(rect.x + rect.w - p.x); @@ -136,10 +120,10 @@ export default function ErdEdge(props) { return 'B'; } const sideInfo = { - L: { nx: -1, ny: 0, deg: 180 }, - R: { nx: 1, ny: 0, deg: 0 }, - T: { nx: 0, ny: -1, deg: -90 }, - B: { nx: 0, ny: 1, deg: 90 }, + L: { nx: -1, ny: 0, deg: 0 }, + R: { nx: 1, ny: 0, deg: 180 }, + T: { nx: 0, ny: -1, deg: 90 }, + B: { nx: 0, ny: 1, deg: -90 }, }; // 접점이 위치한 변 구하기 @@ -148,6 +132,10 @@ const sideInfo = { const sN = sideInfo[sSide]; const tN = sideInfo[tSide]; + // ✅ 아이콘 회전을 '붙은 변의 각도'로 고정 + const sourceIconDeg = sN.deg; + const targetIconDeg = tN.deg; + // 아이콘 중심 = 접점에서 바깥쪽(법선)으로 ICON_HALF + PAD 만큼 const sourceIconX = sPt.x + sN.nx * (ICON_HALF + PAD); const sourceIconY = sPt.y + sN.ny * (ICON_HALF + PAD); diff --git a/src/components/Github/GithubManagerModal.jsx b/src/components/Github/GithubManagerModal.jsx index 2c8a144..60239cc 100644 --- a/src/components/Github/GithubManagerModal.jsx +++ b/src/components/Github/GithubManagerModal.jsx @@ -114,7 +114,7 @@ export default function GithubManagerModal({ teamCode, onClose }) { )} {tab === 'delete' && (
-

현재 등록된 GitHub 설정을 삭제합니다. 이 작업은 되돌릴 수 없습니다.

+

현재 등록된 GitHub 설정을 삭제합니다. 이 작업은 되돌릴 수 없습니다.

- +
+ {/* 저장소 URL */} + - + {/* 기본 브랜치 */} + - + {/* Private 체크박스 */} + + {/* 토큰 */} + - + {/* 액션 버튼 */} +
+ + +
-
- - -
+ {/* 에러 표시 */} + {data && !data.success && ( +

+ {data.error?.code} · {data.error?.message} +

+ )} + {error &&

네트워크 오류

} +
- {data && !data.success &&

{data.error?.code} · {data.error?.message}

} - {error &&

네트워크 오류

} - ); } diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 63e6a66..5730886 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -117,7 +117,7 @@ const Home = () => { {activeTab === "성능 테스트" && ( )}{" "} - {activeTab === "로그 모니터링" && } + {activeTab === "로그 모니터링" && } {/* ✅ 모달은 여기에서 관리 */} diff --git a/src/pages/LogMonitoring.jsx b/src/pages/LogMonitoring.jsx index 426ee6d..267c3a2 100644 --- a/src/pages/LogMonitoring.jsx +++ b/src/pages/LogMonitoring.jsx @@ -1,112 +1,281 @@ -import React, { useState, useEffect, useRef } from "react"; + +import React, { useEffect, useMemo, useRef, useState } from "react"; import "../styles/css/LogMonitoring.css"; import { Pause, Play } from "lucide-react"; -const mockLogs = [ - "[INFO] 서버가 시작되었습니다.", - "[WARN] 메모리 사용량 증가 감지", - "[ERROR] 데이터베이스 연결 실패", - "[INFO] 요청이 정상 처리되었습니다.", -]; +// ✅ 네가 만든 훅/REST 래퍼 +import useLogSocket from "@/api/hooks/log"; +import { registerLog, queryStatus } from "@/api/apis/log"; + +// 레벨 옵션 +const LEVELS = ["ALL", "INFO", "WARN", "ERROR"]; + +// 화면에 표시할 한 줄을 문자열로 포맷 (기존 스타일 유지) +function formatLine(row) { + // row: { time, level, service, message } + // 기존 컴포넌트는 문자열 배열을 썼으므로 같은 스타일로 출력 + return `[${row.level}] ${row.service || "-"} — ${row.message}`; +} + +export default function LogMonitoring({ teamCode, defaultSource = "BACKEND" }) { + // 🔧 필요하면 상단 상태/입력으로 컨트롤 + const [sourceType, setSourceType] = useState(defaultSource); // FRONTEND | RDS + + const [rangeStart, setRangeStart] = useState(""); + const [rangeEnd, setRangeEnd] = useState(""); + + const canConnect = !!teamCode; -function LogMonitoring() { - const [logs, setLogs] = useState([]); - const [isStreaming, setIsStreaming] = useState(true); const [filterLevel, setFilterLevel] = useState("ALL"); - const logEndRef = useRef(null); + const [isStreaming, setIsStreaming] = useState(true); // 일시정지/재시작 + + // ✅ WS 자동연결 방지: 최초엔 undefined로 두었다가, 수집 시작 후에만 세팅 + const [wsTeamCode, setWsTeamCode] = useState(undefined); - // 실제 API 요청 - // const fetchLogs = async () => { - // try { - // const res = await fetch("/logging/live"); - // if (!res.ok) throw new Error("로그 로딩 실패"); - // const data = await res.json(); - // setLogs(data); - // } catch (err) { - // console.error("로그 가져오기 실패:", err); + // // 컴포넌트 내부 + // const [statusActive, setStatusActive] = useState(null); // true | false | null + // const [statusLoading, setStatusLoading] = useState(false); + // const [statusError, setStatusError] = useState(null); + + // useEffect(() => { + // if (!teamCode || !sourceType) { + // setStatusActive(null); + // setStatusError(null); + // setStatusLoading(false); + // return; // } - // }; + // let stop = false; + // const tick = async () => { + // // 첫 로딩에만 로딩 표시 + // setStatusLoading((prev) => (statusActive === null ? true : prev)); + // try { + // const { data } = await queryStatus(sourceType, teamCode); + // if (stop) return; + // setStatusActive(!!data?.active); // true면 "수집 중", false면 "대기" + // setStatusError(null); + // } catch (err) { + // if (stop) return; + // const code = err?.response?.data?.code; + // if (code === "40710") { + // // 서버 약속: 수집 미시작 + // setStatusActive(false); + // setStatusError(null); + // } else { + // setStatusError("error"); + // } + // } finally { + // if (!stop) setStatusLoading(false); + // } + // }; + + // tick(); // 즉시 한 번 + // const id = setInterval(tick, 15000); // 15초마다 + // return () => { + // stop = true; + // clearInterval(id); + // }; + // }, [teamCode, sourceType]); + + const StatusBadge = ({ active, loading, error }) => { + let label = "대기"; + let cls = "bg-zinc-600"; + if (loading) { + label = "확인중"; + cls = "bg-gray-500"; + } else if (error) { + label = "오류"; + cls = "bg-red-600"; + } else if (active === true) { + label = "수집 중"; + cls = "bg-green-600"; + } + + return ( + + + {label} + + ); + }; + + // ✅ 여기서 teamCode 대신 wsTeamCode를 넘긴다 (undefined면 훅이 connect() 안 함) + const { + status, + rows, + lastError, + requestRange, + requestLevelContext, + reconnect, + disconnect, + } = useLogSocket({ teamCode: wsTeamCode, sourceType }); + + const isWsOpen = status === 'open'; + + // ⏸ 일시정지를 구현하기 위해 "스냅샷"을 따로 유지 + const [snapshotRows, setSnapshotRows] = useState([]); useEffect(() => { - const interval = setInterval(() => { - if (!isStreaming) return; + if (isStreaming) { + setSnapshotRows(rows); + } + }, [rows, isStreaming]); - // 모의 로그 추가 - setLogs((prev) => { - const nextLog = mockLogs[Math.floor(Math.random() * mockLogs.length)]; - return [...prev, nextLog]; - }); + // 현재 렌더 대상: 스트리밍 중이면 rows, 일시정지면 snapshotRows + const activeRows = isStreaming ? rows : snapshotRows; - // 실제 API 요청 - // fetchLogs(); - }, 2000); + // 레벨 필터 + const filteredRows = useMemo(() => { + if (filterLevel === "ALL") return activeRows; + return activeRows.filter((r) => r.level === filterLevel); + }, [activeRows, filterLevel]); - return () => clearInterval(interval); - }, [isStreaming]); + // 문자열로 변환 (기존 렌더 구조 유지) + const filteredLogs = useMemo( + () => filteredRows.map(formatLine), + [filteredRows] + ); + // 자동 스크롤 + const logEndRef = useRef(null); useEffect(() => { - if (logEndRef.current) { + if (logEndRef.current && isStreaming) { logEndRef.current.scrollIntoView({ behavior: "smooth" }); } - }, [logs]); + }, [filteredLogs, isStreaming]); + + // REST: 수집 시작 → 성공 시에만 WS 연결 허용 + const onStartCollect = async () => { + if (!teamCode) return alert("팀이 선택되지 않았습니다."); + try { + await registerLog(sourceType, teamCode); + // 1) WS를 열 수 있도록 teamCode 주입 + setWsTeamCode(teamCode); + // 2) 연결 시도 + reconnect(); + } catch (e) { + alert("수집 시작 실패: " + (e?.response?.data?.message || e.message)); + } + }; - const filteredLogs = logs.filter((log) => { - if (filterLevel === "ALL") return true; - return log.includes(filterLevel); - }); + // WS 수동 연결/해제 + const onManualReconnect = () => { + if (!wsTeamCode) setWsTeamCode(teamCode); // 혹시 비어있으면 채움 + reconnect(); + }; + const onManualDisconnect = () => { + disconnect(); + setWsTeamCode(undefined); // 🔒 자동 재연결 차단 + }; return (

CLI 로그 모니터링

-
+
+ + + 팀: {teamCode || "-"} + + + {/* */} + + {/* */} + +
+
+ WS 상태: {status} + {lastError ? ` | 에러: [${lastError.code}] ${lastError.message}` : ""} + + + {status === 'open' ? '✅ 연결됨' : + status === 'connecting' ? '⏳ 연결 중...' : + status === 'closed' ? '❌ 끊김' : + status === 'closing' ? '🔒 닫는 중...' : + ''} + +
+
- {filteredLogs.map((log, i) => ( + {filteredRows.map((row, i) => (
- {log} + {formatLine(row)}
))} - {/* 자동 스크롤 대상 */}
+ +
+ + + + + +
); } - -export default LogMonitoring; diff --git a/src/styles/css/AddServerModal.css b/src/styles/css/AddServerModal.css index 97bc191..6074fb5 100644 --- a/src/styles/css/AddServerModal.css +++ b/src/styles/css/AddServerModal.css @@ -106,4 +106,44 @@ .modal-actions button:last-of-type:disabled { background: #cbd5e1; cursor: not-allowed; +} + +.modal-github-button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + cursor: pointer; + background-color: white; + color: black; + gap: 10px; + border: 1px solid #D9D9D9; +} + +.github-modal-body .modal-delete-button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + cursor: pointer; + background-color: red; + color: white; + gap: 10px; + border: 1px solid #D9D9D9; + width: -moz-fit-content; + width: fit-content; + display: inline-flex; +} + +.modal-refresh-button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + cursor: pointer; + background-color: #3b82f6; + color: white; + gap: 10px; + border: 1px solid #D9D9D9; + width: -moz-fit-content; + width: fit-content; + display: inline-flex; + } \ No newline at end of file diff --git a/src/styles/css/LogMonitoring.css b/src/styles/css/LogMonitoring.css index ce8d68a..700b65b 100644 --- a/src/styles/css/LogMonitoring.css +++ b/src/styles/css/LogMonitoring.css @@ -1,3 +1,4 @@ +@charset "UTF-8"; .log-monitoring-container { height: 100%; display: flex; @@ -70,4 +71,23 @@ .log-entry.error { color: #f87171; +} + +input[type=datetime-local] { + background-color: #ffffff; /* 흰색 배경 */ + color: #000000; /* 검정 텍스트 */ + border: 1px solid #ccc; + border-radius: 6px; + padding: 4px 6px; + /* 다크 모드에서도 달력 아이콘 안 사라지게 */ +} +input[type=datetime-local]::-webkit-calendar-picker-indicator { + filter: invert(0); +} + +input[type=datetime-local]:focus { + background-color: #ffffff; + color: #000000; + border-color: #3b82f6; /* 파란색 테두리 */ + outline: none; } \ No newline at end of file diff --git a/src/styles/css/login.css b/src/styles/css/login.css index 8ea0c50..d455703 100644 --- a/src/styles/css/login.css +++ b/src/styles/css/login.css @@ -112,7 +112,7 @@ justify-content: center; font-size: 0.85rem; color: #666; - gap: 60px; + gap: 30px; } .login-page .login-card .login-links a { font-size: 14px; diff --git a/src/styles/css/sideBar.css b/src/styles/css/sideBar.css index de2736c..6bf802e 100644 --- a/src/styles/css/sideBar.css +++ b/src/styles/css/sideBar.css @@ -58,6 +58,7 @@ display: flex; flex-direction: column; } + .side-bar .github-container { margin: 20px 0 0 8px; } diff --git a/src/styles/scss/auth/login.scss b/src/styles/scss/auth/login.scss index 8b50464..d024d17 100644 --- a/src/styles/scss/auth/login.scss +++ b/src/styles/scss/auth/login.scss @@ -116,7 +116,7 @@ justify-content: center; font-size: 0.85rem; color: #666; - gap: 60px; + gap: 30px; a { @include font-style("body2"); diff --git a/src/styles/scss/components/ServerManagement/AddServerModal.scss b/src/styles/scss/components/ServerManagement/AddServerModal.scss index 7b0b35c..fbcab9c 100644 --- a/src/styles/scss/components/ServerManagement/AddServerModal.scss +++ b/src/styles/scss/components/ServerManagement/AddServerModal.scss @@ -103,4 +103,41 @@ } } } +} + +.modal-github-button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + cursor: pointer; + background-color: white; + color: black; + gap: 10px; + border: 1px solid #D9D9D9; +} + +.github-modal-body .modal-delete-button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + cursor: pointer; + background-color: red; + color: white; + gap: 10px; + border: 1px solid #D9D9D9; + width: fit-content; + display: inline-flex; +} + +.modal-refresh-button { + padding: 0.5rem 1rem; + border-radius: 6px; + border: none; + cursor: pointer; + background-color: #3b82f6; + color: white; + gap: 10px; + border: 1px solid #D9D9D9; + width: fit-content; + display: inline-flex; } \ No newline at end of file diff --git a/src/styles/scss/components/sideBar.scss b/src/styles/scss/components/sideBar.scss index 5f3a43c..2e22278 100644 --- a/src/styles/scss/components/sideBar.scss +++ b/src/styles/scss/components/sideBar.scss @@ -60,6 +60,7 @@ display: flex; flex-direction: column; } + .github-container { margin: 20px 0 0 8px; diff --git a/src/styles/scss/pages/LogMonitoring.scss b/src/styles/scss/pages/LogMonitoring.scss index 8355cd9..ada0af0 100644 --- a/src/styles/scss/pages/LogMonitoring.scss +++ b/src/styles/scss/pages/LogMonitoring.scss @@ -71,4 +71,24 @@ .log-entry.error { color: #f87171; - } \ No newline at end of file + } + + input[type='datetime-local'] { + background-color: #ffffff; /* 흰색 배경 */ + color: #000000; /* 검정 텍스트 */ + border: 1px solid #ccc; + border-radius: 6px; + padding: 4px 6px; + + /* 다크 모드에서도 달력 아이콘 안 사라지게 */ + &::-webkit-calendar-picker-indicator { + filter: invert(0); + } +} + +input[type='datetime-local']:focus { + background-color: #ffffff; + color: #000000; + border-color: #3b82f6; /* 파란색 테두리 */ + outline: none; +} \ No newline at end of file