- 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>
250 lines
12 KiB
JavaScript
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>
|
|
);
|
|
}
|