fix(dashboard): address code review — auth, DB stability, idempotency, UI fixes

C1: /api/reset에 API key 인증 추가 (DASHBOARD_RESET_KEY 환경변수)
C2: /proc 스캐닝 제거, PID file + SIGHUP 기반 파서 재파싱으로 교체
C3: daily_pnl 업데이트를 trades 테이블에서 재계산하여 idempotent하게 변경
I1: CORS origins를 CORS_ORIGINS 환경변수로 설정 가능하게 변경
I2: offset 파라미터에 ge=0 검증 추가
I3: 매 줄 commit → 파일 단위 배치 commit으로 성능 개선
I4: _pending_candles 크기 제한으로 메모리 누적 방지
I5: bot.log glob 중복 파싱 제거 (sorted(set(...)))
I6: /api/health 에러 메시지에서 내부 경로 미노출
I7: RSI 차트(데이터 없음)를 OI 변화율 차트로 교체
M1: pnlColor 변수 shadowing 수정 (posPnlColor)
M2: 거래 목록에 API total 필드 사용
M3: dashboard/ui/.dockerignore 추가
M4: API Dockerfile Python 3.11→3.12
M5: 테스트 fixture에서 temp DB cleanup 추가
M6: 누락 테스트 9건 추가 (health, daily, reset 인증, offset, pagination)
M7: 파서 SIGTERM graceful shutdown + entrypoint.sh signal forwarding
DB: 양쪽 busy_timeout=5000 + WAL pragma 설정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-20 00:00:16 +09:00
parent f14c521302
commit 9f0057e29d
7 changed files with 253 additions and 66 deletions

View File

@@ -5,27 +5,31 @@ dashboard_api.py — 멀티심볼 대시보드 API
import sqlite3
import os
import signal
from fastapi import FastAPI, Query
from fastapi import FastAPI, Query, Header, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
from contextlib import contextmanager
from typing import Optional
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
PARSER_PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
DASHBOARD_RESET_KEY = os.environ.get("DASHBOARD_RESET_KEY", "")
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else ["*"]
app = FastAPI(title="Trading Dashboard API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=CORS_ORIGINS,
allow_methods=["*"],
allow_headers=["*"],
)
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
try:
yield conn
finally:
@@ -64,7 +68,7 @@ def get_position(symbol: Optional[str] = None):
def get_trades(
symbol: Optional[str] = None,
limit: int = Query(50, ge=1, le=500),
offset: int = 0,
offset: int = Query(0, ge=0),
):
with get_db() as db:
if symbol:
@@ -166,28 +170,28 @@ def health():
with get_db() as db:
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
return {"status": "ok", "candles_count": cnt}
except Exception as e:
return {"status": "error", "detail": str(e)}
except Exception:
return {"status": "error", "detail": "database unavailable"}
@app.post("/api/reset")
def reset_db():
def reset_db(x_api_key: Optional[str] = Header(None)):
"""DB 초기화 + 파서에 SIGHUP으로 재파싱 요청."""
# C1: API key 인증 (DASHBOARD_RESET_KEY가 설정된 경우)
if DASHBOARD_RESET_KEY and x_api_key != DASHBOARD_RESET_KEY:
raise HTTPException(status_code=403, detail="invalid api key")
with get_db() as db:
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
db.execute(f"DELETE FROM {table}")
db.commit()
import subprocess, signal
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
if not pid_str.isdigit():
continue
try:
with open(f"/proc/{pid_str}/cmdline", "r") as f:
cmdline = f.read()
if "log_parser.py" in cmdline and str(os.getpid()) != pid_str:
os.kill(int(pid_str), signal.SIGTERM)
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
pass
subprocess.Popen(["python", "log_parser.py"])
# C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
try:
with open(PARSER_PID_FILE) as f:
pid = int(f.read().strip())
os.kill(pid, signal.SIGHUP)
except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
pass
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작"}
return {"status": "ok", "message": "DB 초기화 완료, 파서 재파싱 시작"}