feat(logs): date filter + clear log endpoints

- API GET /api/logs now accepts from_ts / to_ts (unix epoch, half-open
  [from, to)) so callers can scope by arbitrary time range.
- API DELETE /api/logs added. Same from_ts / to_ts semantics. No params
  = wipe everything and reset the AUTOINCREMENT counter.
- Dashboard Logs page: date picker that scopes both the view and the
  delete button to the selected day in the user's local timezone. The
  clear button is red and confirms before deleting; label switches
  between "전체 로그 초기화" and "<날짜> 하루치 삭제".
This commit is contained in:
2026-05-23 17:55:58 +09:00
parent 8312cfe861
commit 7362b45846
4 changed files with 116 additions and 5 deletions

View File

@@ -88,7 +88,8 @@ mc-filter-proxy 컨테이너 (25565)
| POST | `/api/domains` | 도메인 추가 | | POST | `/api/domains` | 도메인 추가 |
| PATCH | `/api/domains/{domain}` | 활성/메모 변경 | | PATCH | `/api/domains/{domain}` | 활성/메모 변경 |
| DELETE | `/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` | 프록시 상태 + 통계 | | GET | `/api/status` | 프록시 상태 + 통계 |
| POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) | | POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) |

View File

@@ -1,4 +1,4 @@
"""접속 로그 조회.""" """접속 로그 조회 및 초기화."""
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
@@ -15,16 +15,26 @@ def list_logs(
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
action: str | None = Query(None), 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: ) -> dict:
if not LOG_DB.exists(): if not LOG_DB.exists():
return {"total": 0, "items": []} return {"total": 0, "items": []}
con = sqlite3.connect(LOG_DB) con = sqlite3.connect(LOG_DB)
try: try:
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
where, params = "", [] conds: list[str] = []
params: list = []
if action: if action:
where = "WHERE action = ?" conds.append("action = ?")
params.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( total = con.execute(
f"SELECT COUNT(*) FROM connections {where}", params f"SELECT COUNT(*) FROM connections {where}", params
).fetchone()[0] ).fetchone()[0]
@@ -36,3 +46,40 @@ def list_logs(
finally: finally:
con.close() con.close()
return {"total": total, "items": [dict(r) for r in rows]} 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}

View File

@@ -32,5 +32,11 @@ export const api = {
).toString() ).toString()
return req('/logs' + (q ? `?${q}` : '')) 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' }), restart: () => req('/proxy/restart', { method: 'POST' }),
} }

View File

@@ -5,16 +5,33 @@ function fmtTime(ts) {
return new Date(ts * 1000).toLocaleString('ko-KR') 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() { export default function Logs() {
const [data, setData] = useState({ total: 0, items: [] }) const [data, setData] = useState({ total: 0, items: [] })
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [date, setDate] = useState('')
const [auto, setAuto] = useState(true) const [auto, setAuto] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [msg, setMsg] = useState(null)
async function load() { async function load() {
try { try {
const params = { limit: 100 } const params = { limit: 100 }
if (filter) params.action = filter if (filter) params.action = filter
if (date) {
params.from_ts = dayStart(date)
params.to_ts = dayEnd(date)
}
setData(await api.logs(params)) setData(await api.logs(params))
setError(null) setError(null)
} catch (e) { } catch (e) {
@@ -27,7 +44,30 @@ export default function Logs() {
if (!auto) return if (!auto) return
const id = setInterval(load, 3000) const id = setInterval(load, 3000)
return () => clearInterval(id) 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 ( return (
<div> <div>
@@ -40,6 +80,19 @@ export default function Logs() {
<option value="blocked">차단</option> <option value="blocked">차단</option>
<option value="error">에러</option> <option value="error">에러</option>
</select> </select>
<label className="inline">
날짜
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</label>
{date && (
<button className="ghost" onClick={() => setDate('')}>
오늘 해제
</button>
)}
<label className="inline"> <label className="inline">
<input <input
type="checkbox" type="checkbox"
@@ -49,6 +102,10 @@ export default function Logs() {
자동 갱신 (3) 자동 갱신 (3)
</label> </label>
<span className="muted"> {data.total.toLocaleString()}</span> <span className="muted"> {data.total.toLocaleString()}</span>
<button className="danger" onClick={clearLogs}>
{clearLabel}
</button>
{msg && <span className="ok">{msg}</span>}
</div> </div>
<table className="table"> <table className="table">
<thead> <thead>