Files
esp32DIY_web/frontend/src/pages/Admin/FlashMetrics.jsx
root 6d11a9c1cc feat: board info logging, anomaly detection, re-flash & firmware update tokens
- Flash.jsx: replace esp-web-tools with direct esptool-js integration
  → reads MAC address + chip type before flash via Web Serial API
  → step-by-step UI (connect → board info → download → flash → done)
  → retry button on failure with remaining-attempt counter
  → firmware update token request after successful flash

- Schema: FlashToken (maxAttempts/attemptCount/isLocked/isUpdateToken),
  FlashLog (startedAt/completedAt/durationMs/chipId/flashSize),
  FlashAnomaly model (RATE_LIMIT_IP/HIGH_VOLUME_IP/MAC_REUSE/TOKEN_LOCK/SUSPICIOUS_DURATION),
  ProjectFile.firmwareVersion

- flash.js: new POST /start (board info + IP log + anomaly detection),
  updated POST /consume (timing, lock on exhaustion), GET returns firmwareFiles

- orders.js: POST /request-reflash (free firmware update token for paid orders),
  updated to flashTokens[] relation

- admin.js: GET /flash/metrics, GET/PUT /flash/anomalies, POST /flash/tokens/:id/unlock

- Admin/FlashMetrics.jsx: dashboard with today stats, recent logs table,
  top-IP chart, anomaly management with resolve button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 05:48:29 +09:00

250 lines
12 KiB
JavaScript

import { useEffect, useState } from 'react';
import api from '../../api/client';
const SEVERITY_COLOR = { high: '#ef4444', medium: '#f59e0b', low: '#6b7280' };
const ANOMALY_LABELS = {
RATE_LIMIT_IP: 'IP 속도 제한',
HIGH_VOLUME_IP: 'IP 대량 플래시',
MAC_REUSE: 'MAC 재사용',
TOKEN_LOCK: '토큰 잠금',
SUSPICIOUS_DURATION: '비정상 시간',
};
function fmtMs(ms) {
if (!ms) return '—';
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
}
export default function FlashMetrics() {
const [metrics, setMetrics] = useState(null);
const [anomalies, setAnomalies] = useState([]);
const [tab, setTab] = useState('overview'); // overview | logs | anomalies
const [showResolved, setShowResolved] = useState(false);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const [mRes, aRes] = await Promise.all([
api.get('/admin/flash/metrics'),
api.get(`/admin/flash/anomalies?resolved=${showResolved}`),
]);
setMetrics(mRes.data);
setAnomalies(aRes.data.anomalies || []);
} catch {}
setLoading(false);
}
useEffect(() => { load(); }, [showResolved]);
async function resolveAnomaly(id) {
await api.put(`/admin/flash/anomalies/${id}/resolve`).catch(() => {});
load();
}
if (loading) return <div className="spinner" />;
const m = metrics;
return (
<div className="container page">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h2>플래시 지표</h2>
<button className="btn btn-outline btn-sm" onClick={load}>새로고침</button>
</div>
{/* 요약 카드 */}
{m && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 14, marginBottom: 28 }}>
{[
{ label: '오늘 전체', value: m.today.total, icon: '⚡' },
{ label: '오늘 성공', value: m.today.success, icon: '✅' },
{ label: '오늘 실패', value: m.today.failed, icon: '❌', warn: m.today.failed > 0 },
{ label: '전체 성공률', value: `${m.allTime.successRate}%`, icon: '📊' },
{ label: '미해소 이상', value: m.anomalies.unresolved, icon: '⚠️', warn: m.anomalies.unresolved > 0 },
].map(c => (
<div key={c.label} className="card" style={{ borderColor: c.warn ? 'var(--warn)' : undefined }}>
<div style={{ fontSize: 24, marginBottom: 6 }}>{c.icon}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: c.warn ? 'var(--warn)' : undefined }}>{c.value}</div>
<div className="text-muted" style={{ fontSize: 12 }}>{c.label}</div>
</div>
))}
</div>
)}
{/* 탭 */}
<div style={{ display: 'flex', gap: 4, marginBottom: 20, borderBottom: '1px solid var(--border)', paddingBottom: 0 }}>
{[
{ id: 'overview', label: '최근 플래시' },
{ id: 'topips', label: 'Top IP' },
{ id: 'anomalies', label: `이상 감지 ${m?.anomalies.unresolved > 0 ? `(${m.anomalies.unresolved})` : ''}` },
].map(t => (
<button key={t.id}
onClick={() => setTab(t.id)}
style={{
padding: '8px 16px', background: 'none', border: 'none', cursor: 'pointer',
color: tab === t.id ? 'var(--accent)' : 'var(--text2)',
borderBottom: tab === t.id ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1, fontWeight: tab === t.id ? 600 : 400, fontSize: 14,
}}>
{t.label}
</button>
))}
</div>
{/* 최근 플래시 로그 */}
{tab === 'overview' && m && (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
{['상품', 'MAC', '칩', 'IP', '소요', '결과', '시각'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{m.recentLogs.map((l, i) => (
<tr key={l.id} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'var(--bg2)' : undefined }}>
<td style={{ padding: '9px 14px' }}>{l.productName}</td>
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{l.macAddress || '—'}</td>
<td style={{ padding: '9px 14px', fontSize: 12 }}>{l.chipFamily || '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{l.clientIp}</td>
<td style={{ padding: '9px 14px' }}>{fmtMs(l.durationMs)}</td>
<td style={{ padding: '9px 14px' }}>
<span style={{
color: l.success ? 'var(--success)' : 'var(--danger)',
fontWeight: 600, fontSize: 12,
}}>
{l.success ? '성공' : '실패'}
</span>
{l.isLocked && <span style={{ marginLeft: 6, fontSize: 11, color: 'var(--warn)' }}>🔒잠김</span>}
{l.errorMessage && (
<div style={{ color: 'var(--danger)', fontSize: 11, marginTop: 2 }}>
{l.errorMessage.slice(0, 40)}
</div>
)}
</td>
<td style={{ padding: '9px 14px', fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
{l.flashedAt ? new Date(l.flashedAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—'}
</td>
</tr>
))}
{m.recentLogs.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text2)' }}>플래시 기록 없음</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* Top IP */}
{tab === 'topips' && m && (
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>IP 주소</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>24시간 플래시 횟수</th>
<th style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>상태</th>
</tr>
</thead>
<tbody>
{m.topIps.map((r, i) => (
<tr key={r.ip} style={{ borderBottom: '1px solid var(--border)', background: i % 2 === 1 ? 'var(--bg2)' : undefined }}>
<td style={{ padding: '9px 14px', fontFamily: 'monospace' }}>{r.ip}</td>
<td style={{ padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontWeight: 600 }}>{r.count}</span>
<div style={{ flex: 1, background: 'var(--bg3)', borderRadius: 3, height: 6, maxWidth: 120 }}>
<div style={{
width: `${Math.min(100, (r.count / (m.topIps[0]?.count || 1)) * 100)}%`,
height: '100%', background: r.count >= 10 ? 'var(--danger)' : r.count >= 5 ? 'var(--warn)' : 'var(--accent)',
borderRadius: 3,
}} />
</div>
</div>
</td>
<td style={{ padding: '9px 14px', fontSize: 12 }}>
{r.count >= 10 ? <span style={{ color: 'var(--danger)' }}>고위험</span>
: r.count >= 5 ? <span style={{ color: 'var(--warn)' }}>주의</span>
: <span style={{ color: 'var(--success)' }}>정상</span>}
</td>
</tr>
))}
{m.topIps.length === 0 && (
<tr><td colSpan={3} style={{ textAlign: 'center', padding: 40, color: 'var(--text2)' }}>데이터 없음</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* 이상 감지 */}
{tab === 'anomalies' && (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={showResolved} onChange={e => setShowResolved(e.target.checked)} />
해소된 항목 포함
</label>
</div>
<div className="card" style={{ padding: 0, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: 'var(--bg2)', borderBottom: '1px solid var(--border)' }}>
{['유형', '심각도', '설명', 'IP', 'MAC', '발생', '조치'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--text2)', fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{anomalies.map((a, i) => (
<tr key={a.id} style={{
borderBottom: '1px solid var(--border)',
background: a.resolved ? 'var(--bg2)' : i % 2 === 1 ? 'var(--bg2)' : undefined,
opacity: a.resolved ? 0.6 : 1,
}}>
<td style={{ padding: '9px 14px', fontWeight: 500 }}>
{ANOMALY_LABELS[a.type] || a.type}
</td>
<td style={{ padding: '9px 14px' }}>
<span style={{
color: SEVERITY_COLOR[a.severity] || 'var(--text2)',
fontWeight: 600, fontSize: 12,
}}>
{a.severity === 'high' ? '높음' : a.severity === 'medium' ? '중간' : '낮음'}
</span>
</td>
<td style={{ padding: '9px 14px', maxWidth: 240 }}>{a.description}</td>
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{a.clientIp || '—'}</td>
<td style={{ padding: '9px 14px', fontFamily: 'monospace', fontSize: 12 }}>{a.macAddress || '—'}</td>
<td style={{ padding: '9px 14px', fontSize: 12, color: 'var(--text2)', whiteSpace: 'nowrap' }}>
{new Date(a.createdAt).toLocaleString('ko-KR', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</td>
<td style={{ padding: '9px 14px' }}>
{a.resolved ? (
<span style={{ fontSize: 12, color: 'var(--success)' }}>해소됨</span>
) : (
<button className="btn btn-outline btn-sm" style={{ fontSize: 11, padding: '3px 10px' }}
onClick={() => resolveAnomaly(a.id)}>
해소
</button>
)}
</td>
</tr>
))}
{anomalies.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', padding: 40, color: 'var(--text2)' }}>
{showResolved ? '이상 이벤트 없음' : '미해소 이상 없음'}
</td></tr>
)}
</tbody>
</table>
</div>
</>
)}
</div>
);
}