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

@@ -0,0 +1,3 @@
node_modules
dist
.git

View File

@@ -278,6 +278,7 @@ export default function App() {
const [positions, setPositions] = useState([]);
const [botStatus, setBotStatus] = useState({});
const [trades, setTrades] = useState([]);
const [tradesTotal, setTradesTotal] = useState(0);
const [daily, setDaily] = useState([]);
const [candles, setCandles] = useState([]);
@@ -308,7 +309,10 @@ export default function App() {
setPositions(pRes.positions || []);
if (pRes.bot) setBotStatus(pRes.bot);
}
if (tRes?.trades) setTrades(tRes.trades);
if (tRes?.trades) {
setTrades(tRes.trades);
setTradesTotal(tRes.total || tRes.trades.length);
}
if (dRes?.daily) setDaily(dRes.daily);
if (cRes?.candles) setCandles(cRes.candles);
}, [selectedSymbol]);
@@ -415,7 +419,7 @@ export default function App() {
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
: null);
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null;
const pnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
return (
<div key={pos.id} style={{
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
@@ -436,7 +440,7 @@ export default function App() {
{fmt(pos.entry_price)}
</span>
{pnlPct !== null && (
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: pnlColor }}>
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: posPnlColor }}>
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
</span>
@@ -594,7 +598,7 @@ export default function App() {
marginTop: 6,
}}
>
전체 {trades.length} 보기
전체 {tradesTotal} 보기
</div>
)}
</div>
@@ -607,7 +611,7 @@ export default function App() {
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
}}>
전체 거래 내역 ({trades.length})
전체 거래 내역 ({tradesTotal})
</div>
{trades.map((t) => (
<TradeRow
@@ -648,17 +652,22 @@ export default function App() {
display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginTop: 12,
}}>
<ChartBox title="RSI">
<ChartBox title="OI 변화율">
<ResponsiveContainer width="100%" height={150}>
<LineChart data={candles.map((c) => ({ ts: fmtTime(c.ts), rsi: c.rsi }))}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), oi_change: c.oi_change }))}>
<defs>
<linearGradient id="gOI" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.amber} stopOpacity={0.15} />
<stop offset="100%" stopColor={S.amber} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} {...axisStyle} />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey={() => 70} stroke="rgba(248,113,113,0.2)" strokeDasharray="4 4" dot={false} name="과매수" />
<Line type="monotone" dataKey={() => 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" />
<Line type="monotone" dataKey="rsi" name="RSI" stroke={S.amber} strokeWidth={1.5} dot={false} />
</LineChart>
<Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
<Area type="monotone" dataKey="oi_change" name="OI변화율" stroke={S.amber} strokeWidth={1.5} fill="url(#gOI)" dot={false} />
</AreaChart>
</ResponsiveContainer>
</ChartBox>
@@ -697,10 +706,16 @@ export default function App() {
</span>
<button
onClick={async () => {
const key = prompt("Reset API Key를 입력하세요:");
if (!key) return;
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
try {
const r = await fetch("/api/reset", { method: "POST" });
const r = await fetch("/api/reset", {
method: "POST",
headers: { "X-API-Key": key },
});
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
else alert("초기화 실패: " + r.statusText);
} catch (e) { alert("초기화 실패: " + e.message); }
}}