diff --git a/README.md b/README.md index c11ddbf..ca8693b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,8 @@ mc-filter-proxy 컨테이너 (25565) | POST | `/api/domains` | 도메인 추가 | | PATCH | `/api/domains/{domain}` | 활성/메모 변경 | | DELETE | `/api/domains/{domain}` | 도메인 삭제 | -| GET | `/api/logs?limit&offset&action` | 접속 로그 (페이지네이션) | +| GET | `/api/logs?limit&offset&action&from_ts&to_ts` | 접속 로그 (페이지네이션, 날짜 필터) | +| DELETE | `/api/logs?from_ts&to_ts` | 접속 로그 삭제 (범위 미지정 시 전체) | | GET | `/api/status` | 프록시 상태 + 통계 | | POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) | diff --git a/api/routes/logs.py b/api/routes/logs.py index a48825a..48a4247 100644 --- a/api/routes/logs.py +++ b/api/routes/logs.py @@ -1,4 +1,4 @@ -"""접속 로그 조회.""" +"""접속 로그 조회 및 초기화.""" from __future__ import annotations import sqlite3 @@ -15,16 +15,26 @@ def list_logs( limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), action: str | None = Query(None), + from_ts: float | None = Query(None, description="unix epoch seconds, inclusive"), + to_ts: float | None = Query(None, description="unix epoch seconds, exclusive"), ) -> dict: if not LOG_DB.exists(): return {"total": 0, "items": []} con = sqlite3.connect(LOG_DB) try: con.row_factory = sqlite3.Row - where, params = "", [] + conds: list[str] = [] + params: list = [] if action: - where = "WHERE action = ?" + conds.append("action = ?") params.append(action) + if from_ts is not None: + conds.append("ts >= ?") + params.append(from_ts) + if to_ts is not None: + conds.append("ts < ?") + params.append(to_ts) + where = ("WHERE " + " AND ".join(conds)) if conds else "" total = con.execute( f"SELECT COUNT(*) FROM connections {where}", params ).fetchone()[0] @@ -36,3 +46,40 @@ def list_logs( finally: con.close() return {"total": total, "items": [dict(r) for r in rows]} + + +@router.delete("/logs") +def clear_logs( + from_ts: float | None = Query(None, description="ts >= from_ts 만 삭제"), + to_ts: float | None = Query(None, description="ts < to_ts 만 삭제"), +) -> dict: + """접속 로그를 삭제한다. + + - from_ts/to_ts 둘 다 없으면 전체 삭제 (AUTOINCREMENT 카운터까지 리셋) + - 둘 중 하나만 있으면 그 조건만 적용 + - 둘 다 있으면 [from_ts, to_ts) 범위 삭제 — 날짜 선택 삭제 용도 + """ + if not LOG_DB.exists(): + return {"deleted": 0} + con = sqlite3.connect(LOG_DB, timeout=5) + try: + conds: list[str] = [] + params: list = [] + if from_ts is not None: + conds.append("ts >= ?") + params.append(from_ts) + if to_ts is not None: + conds.append("ts < ?") + params.append(to_ts) + if conds: + where = "WHERE " + " AND ".join(conds) + cur = con.execute(f"DELETE FROM connections {where}", params) + deleted = cur.rowcount + else: + cur = con.execute("DELETE FROM connections") + deleted = cur.rowcount + con.execute("DELETE FROM sqlite_sequence WHERE name='connections'") + con.commit() + finally: + con.close() + return {"deleted": deleted} diff --git a/frontend/src/api.js b/frontend/src/api.js index f368576..654e1a5 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -32,5 +32,11 @@ export const api = { ).toString() return req('/logs' + (q ? `?${q}` : '')) }, + clearLogs: (params = {}) => { + const q = new URLSearchParams( + Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v != null)) + ).toString() + return req('/logs' + (q ? `?${q}` : ''), { method: 'DELETE' }) + }, restart: () => req('/proxy/restart', { method: 'POST' }), } diff --git a/frontend/src/pages/Logs.jsx b/frontend/src/pages/Logs.jsx index eb7da76..211f877 100644 --- a/frontend/src/pages/Logs.jsx +++ b/frontend/src/pages/Logs.jsx @@ -5,16 +5,33 @@ function fmtTime(ts) { return new Date(ts * 1000).toLocaleString('ko-KR') } +// 'YYYY-MM-DD' 문자열을 로컬 자정 unix epoch (초) 로 바꾼다. +function dayStart(dateStr) { + if (!dateStr) return null + const [y, m, d] = dateStr.split('-').map((s) => parseInt(s, 10)) + return new Date(y, m - 1, d, 0, 0, 0, 0).getTime() / 1000 +} +function dayEnd(dateStr) { + const start = dayStart(dateStr) + return start == null ? null : start + 86400 +} + export default function Logs() { const [data, setData] = useState({ total: 0, items: [] }) const [filter, setFilter] = useState('') + const [date, setDate] = useState('') const [auto, setAuto] = useState(true) const [error, setError] = useState(null) + const [msg, setMsg] = useState(null) async function load() { try { const params = { limit: 100 } if (filter) params.action = filter + if (date) { + params.from_ts = dayStart(date) + params.to_ts = dayEnd(date) + } setData(await api.logs(params)) setError(null) } catch (e) { @@ -27,7 +44,30 @@ export default function Logs() { if (!auto) return const id = setInterval(load, 3000) return () => clearInterval(id) - }, [filter, auto]) + }, [filter, date, auto]) + + async function clearLogs() { + const scope = date ? `${date} 하루치` : '전체' + if (!window.confirm(`${scope} 접속 로그를 정말 삭제할까요? (되돌릴 수 없음)`)) { + return + } + try { + const params = {} + if (date) { + params.from_ts = dayStart(date) + params.to_ts = dayEnd(date) + } + const res = await api.clearLogs(params) + setMsg(`${res.deleted.toLocaleString()}건 삭제됨`) + setError(null) + setTimeout(() => setMsg(null), 2500) + load() + } catch (e) { + setError(e.message) + } + } + + const clearLabel = date ? `${date} 하루치 삭제` : '전체 로그 초기화' return (