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>
This commit is contained in:
root
2026-05-22 05:48:29 +09:00
parent 182782f271
commit 6d11a9c1cc
9 changed files with 1206 additions and 293 deletions

View File

@@ -9,6 +9,7 @@
},
"dependencies": {
"axios": "^1.7.2",
"esptool-js": "^0.4.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.1"

View File

@@ -19,6 +19,7 @@ import AdminIndex from './pages/Admin/Index';
import AdminProjects from './pages/Admin/Projects';
import AdminUsers from './pages/Admin/Users';
import AdminLogs from './pages/Admin/Logs';
import FlashMetrics from './pages/Admin/FlashMetrics';
function RequireAuth({ children }) {
const { user, loading } = useAuth();
@@ -57,7 +58,8 @@ function AppRoutes() {
<Route path="/admin" element={<RequireAdmin><AdminIndex /></RequireAdmin>} />
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
<Route path="/admin/logs" element={<RequireAdmin><AdminLogs /></RequireAdmin>} />
<Route path="/admin/logs" element={<RequireAdmin><AdminLogs /></RequireAdmin>} />
<Route path="/admin/flash" element={<RequireAdmin><FlashMetrics /></RequireAdmin>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -0,0 +1,249 @@
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>
);
}

View File

@@ -39,7 +39,8 @@ export default function AdminIndex() {
{[
{ to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' },
{ to: '/admin/users', icon: '👥', title: '사용자 관리', desc: '사용자 목록, 역할 변경, 계정 비활성화' },
{ to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' },
{ to: '/admin/logs', icon: '📜', title: '감사 로그', desc: '모든 주요 행동 기록 조회' },
{ to: '/admin/flash', icon: '⚡', title: '플래시 지표', desc: 'IP·MAC·이상 감지·성공률 실시간 모니터링' },
].map(m => (
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }}

View File

@@ -1,183 +1,469 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { ESPLoader, Transport } from 'esptool-js';
import api from '../api/client';
const CHIP_LABELS = {
'ESP32-S3': 'ESP32-S3', 'ESP32-S2': 'ESP32-S2', 'ESP32-C3': 'ESP32-C3',
'ESP32-C6': 'ESP32-C6', 'ESP32-H2': 'ESP32-H2', 'ESP32': 'ESP32',
};
const BAUD = 115200;
export default function Flash() {
const { token } = useParams();
const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [flashDone, setFlashDone] = useState(false);
const installRef = useRef(null);
const manifestUrl = `${window.location.origin}/api/flash/${token}/manifest`;
useEffect(() => {
api.get(`/flash/${token}`)
.then(r => setInfo(r.data))
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [token]);
// esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록
useEffect(() => {
const btn = installRef.current;
if (!btn) return;
function onSuccess(e) {
const mac = e.detail?.device?.macAddress || 'unknown';
api.post(`/flash/${token}/consume`, {
mac, chipFamily: info?.chipFamily, success: true,
}).catch(() => {});
setFlashDone(true);
}
function onFail(e) {
api.post(`/flash/${token}/consume`, {
mac: 'unknown', chipFamily: info?.chipFamily,
success: false, errorMessage: e.detail?.message,
}).catch(() => {});
}
btn.addEventListener('state-changed', (e) => {
if (e.detail?.state === 'finished') onSuccess(e);
if (e.detail?.state === 'error') onFail(e);
});
}, [info, token]);
if (loading) return <div className="spinner" />;
// 유효하지 않은 토큰
if (!info) {
return (
<div className="container page" style={{ maxWidth: 500 }}>
<div className="card text-center" style={{ padding: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ marginBottom: 8 }}>유효하지 않은 토큰</h2>
<p className="text-muted">토큰을 찾을 없습니다.</p>
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
구매 내역으로
</Link>
</div>
</div>
);
function toBinaryString(buffer) {
const uint8 = new Uint8Array(buffer);
const CHUNK = 65536;
let out = '';
for (let i = 0; i < uint8.length; i += CHUNK) {
out += String.fromCharCode.apply(null, uint8.subarray(i, i + CHUNK));
}
return out;
}
// 이미 사용된 토큰
if (info.isUsed) {
return (
<div className="container page" style={{ maxWidth: 500 }}>
<div className="card text-center" style={{ padding: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ marginBottom: 8 }}>이미 플래시됨</h2>
<p className="text-muted" style={{ marginBottom: 8 }}>
토큰은 {info.usedAt ? new Date(info.usedAt).toLocaleString('ko-KR') : ''} 사용되었습니다.
</p>
<p className="text-muted" style={{ fontSize: 13 }}>1회용 토큰은 재사용할 없습니다.</p>
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
구매 내역으로
</Link>
</div>
</div>
);
}
function fmtMs(ms) {
if (!ms) return '—';
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
}
// 만료된 토큰
if (info.expired) {
return (
<div className="container page" style={{ maxWidth: 500 }}>
<div className="card text-center" style={{ padding: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ marginBottom: 8 }}>만료된 토큰</h2>
<p className="text-muted">토큰 유효기간이 지났습니다.</p>
<p className="text-muted" style={{ fontSize: 13 }}>고객센터에 문의해주세요.</p>
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
구매 내역으로
</Link>
</div>
</div>
);
}
// 펌웨어 없음
if (!info.hasFirmware) {
return (
<div className="container page" style={{ maxWidth: 500 }}>
<div className="card text-center" style={{ padding: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div>
<h2 style={{ marginBottom: 8 }}>펌웨어 파일 없음</h2>
<p className="text-muted">판매자가 아직 펌웨어를 업로드하지 않았습니다.</p>
<p className="text-muted" style={{ fontSize: 13 }}>판매자에게 문의하거나 환불을 요청하세요.</p>
</div>
</div>
);
}
const STEP_PHASES = ['connecting', 'board_ready', 'flashing', 'success'];
const STEP_LABELS = ['연결', '보드 확인', '플래시', '완료'];
function StepBar({ phase }) {
const phaseOrder = ['idle', 'connecting', 'reading', 'board_ready', 'downloading', 'flashing', 'success'];
const cur = phaseOrder.indexOf(phase);
return (
<div className="container page" style={{ maxWidth: 600 }}>
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div style={{ fontSize: 36 }}></div>
<div>
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
<span className="text-muted" style={{ fontSize: 13 }}>
{CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 24, position: 'relative' }}>
<div style={{ position: 'absolute', top: 12, left: '12%', right: '12%', height: 2, background: 'var(--border)', zIndex: 0 }} />
{STEP_PHASES.map((sp, i) => {
const si = phaseOrder.indexOf(sp);
const done = cur > si;
const active = cur === si || (sp === 'connecting' && (phase === 'connecting' || phase === 'reading'));
return (
<div key={sp} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', zIndex: 1, flex: 1 }}>
<div style={{
width: 28, height: 28, borderRadius: '50%', border: '2px solid',
borderColor: done ? 'var(--success)' : active ? 'var(--accent)' : 'var(--border)',
background: done ? 'var(--success)' : active ? 'var(--accent)' : 'var(--bg2)',
color: done || active ? '#fff' : 'var(--text2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, transition: 'all .3s',
}}>
{done ? '✓' : i + 1}
</div>
<span style={{ fontSize: 11, marginTop: 5, color: active ? 'var(--accent)' : 'var(--text2)' }}>
{STEP_LABELS[i]}
</span>
</div>
</div>
);
})}
</div>
);
}
{flashDone ? (
<div className="alert alert-success" style={{ textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🎉</div>
<strong>플래시 완료!</strong>
<p style={{ marginTop: 8, fontSize: 13 }}>펌웨어가 성공적으로 ESP32에 기록되었습니다.</p>
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}>
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
</div>
</div>
) : (
<>
<div className="alert alert-info" style={{ marginBottom: 20 }}>
<strong>플래시 확인사항</strong>
<ul style={{ marginTop: 8, paddingLeft: 16, fontSize: 13, lineHeight: 2 }}>
<li>Chrome 또는 Edge 브라우저를 사용하고 있나요?</li>
<li>ESP32를 USB 케이블로 PC에 연결했나요?</li>
<li> 토큰은 <strong>1회만</strong> 사용 가능합니다</li>
</ul>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '24px 0' }}>
<esp-web-install-button
ref={installRef}
manifest={manifestUrl}
style={{ '--esp-tools-button-color': '#6366f1', '--esp-tools-button-text-color': '#fff' }}
>
<button slot="activate" className="btn btn-primary"
style={{ fontSize: 16, padding: '12px 32px' }}>
ESP32 플래시 시작
</button>
<span slot="unsupported" style={{ color: 'var(--danger)', fontSize: 14 }}>
Chrome 또는 Edge 브라우저가 필요합니다
</span>
</esp-web-install-button>
<p className="text-muted" style={{ fontSize: 12, textAlign: 'center' }}>
버튼 클릭 팝업에서 ESP32 포트를 선택하세요
</p>
</div>
<div className="divider" />
<details style={{ fontSize: 13, color: 'var(--text2)' }}>
<summary style={{ cursor: 'pointer', marginBottom: 8 }}>토큰 정보 (고급)</summary>
<code style={{ wordBreak: 'break-all', display: 'block', background: 'var(--bg3)', padding: 8, borderRadius: 4 }}>
{token}
</code>
<p style={{ marginTop: 8 }}>매니페스트 URL: <code style={{ fontSize: 11 }}>{manifestUrl}</code></p>
</details>
</>
)}
function StatusCard({ icon, title, desc, children }) {
return (
<div className="container page" style={{ maxWidth: 480 }}>
<div className="card text-center" style={{ padding: 48 }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>{icon}</div>
<h2 style={{ marginBottom: 8 }}>{title}</h2>
<p className="text-muted" style={{ marginBottom: 20 }}>{desc}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>{children}</div>
</div>
</div>
);
}
function InfoRow({ label, value, mono }) {
return (
<div>
<div style={{ color: 'var(--text2)', fontSize: 11, marginBottom: 2 }}>{label}</div>
<div style={{ fontFamily: mono ? 'monospace' : undefined, fontSize: 13 }}>{value || '—'}</div>
</div>
);
}
export default function Flash() {
const { token } = useParams();
const [info, setInfo] = useState(null);
const [infoLoading, setInfoLoading] = useState(true);
const [phase, setPhase] = useState('idle');
const [boardInfo, setBoardInfo] = useState(null);
const [progress, setProgress] = useState(0);
const [curFile, setCurFile] = useState('');
const [error, setError] = useState(null);
const [remaining, setRemaining] = useState(null);
const [duration, setDuration] = useState(null);
const [logs, setLogs] = useState([]);
const loaderRef = useRef(null);
const transportRef = useRef(null);
const logIdRef = useRef(null);
const startRef = useRef(null);
const addLog = useCallback((msg) => setLogs(l => [...l.slice(-40), String(msg)]), []);
// 토큰 정보 로드
useEffect(() => {
api.get(`/flash/${token}`)
.then(r => { setInfo(r.data); setRemaining(r.data.attemptsRemaining); })
.catch(() => setInfo(null))
.finally(() => setInfoLoading(false));
}, [token]);
// 1단계: 연결 + 보드 정보 읽기
async function connectAndRead() {
if (!navigator.serial) {
setError('Web Serial API를 사용할 수 없습니다.\nChrome/Edge + HTTPS(또는 localhost) 환경이 필요합니다.\n\n테스트용: Chrome에서 chrome://flags/#unsafely-treat-insecure-origin-as-secure 설정');
return;
}
setPhase('connecting');
setError(null);
setLogs([]);
let port;
try {
port = await navigator.serial.requestPort();
} catch (e) {
if (e.name === 'NotFoundError') { setPhase('idle'); return; }
setError(`포트 선택 실패: ${e.message}`);
setPhase('idle');
return;
}
try {
const transport = new Transport(port, true);
transportRef.current = transport;
const loader = new ESPLoader({
transport, baudrate: BAUD,
terminal: { clean: () => {}, writeLine: addLog, write: addLog },
});
loaderRef.current = loader;
setPhase('reading');
await loader.main();
const macBytes = await loader.chip.get_mac(loader);
const mac = macBytes.map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(':');
const chipName = loader.chip.CHIP_NAME;
setBoardInfo({ mac, chipName });
addLog(`MAC: ${mac} 칩: ${chipName}`);
const res = await api.post(`/flash/${token}/start`, { boardMac: mac, chipType: chipName });
logIdRef.current = res.data.logId;
setRemaining(res.data.attemptsRemaining);
setPhase('board_ready');
} catch (err) {
setError(`연결/읽기 실패: ${err.message}`);
setPhase('idle');
try { await transportRef.current?.disconnect(); } catch {}
loaderRef.current = null;
transportRef.current = null;
}
}
// 2단계: 펌웨어 다운로드 + 플래시
async function startFlash() {
setPhase('downloading');
setProgress(0);
startRef.current = Date.now();
try {
const fileArray = [];
for (const f of info.firmwareFiles) {
setCurFile(f.name);
addLog(`다운로드: ${f.name} (${(f.size / 1024).toFixed(1)} KB)`);
const resp = await fetch(f.url);
if (!resp.ok) throw new Error(`다운로드 실패: ${f.name} (HTTP ${resp.status})`);
const buf = await resp.arrayBuffer();
fileArray.push({ data: toBinaryString(buf), address: parseInt(f.offset || '0x0', 16) });
}
setPhase('flashing');
addLog('플래시 시작...');
await loaderRef.current.write_flash({
fileArray,
flashSize: 'keep', flashMode: 'keep', flashFreq: 'keep',
eraseAll: false, compress: true,
reportProgress: (fi, written, total) => {
setProgress(Math.round(written / total * 100));
setCurFile(info.firmwareFiles[fi]?.name || '');
},
});
const ms = Date.now() - startRef.current;
setDuration(ms);
addLog(`완료! 소요시간: ${fmtMs(ms)}`);
try { await loaderRef.current.hard_reset(); } catch {}
try { await transportRef.current.disconnect(); } catch {}
await api.post(`/flash/${token}/consume`, {
logId: logIdRef.current, boardMac: boardInfo?.mac,
chipType: boardInfo?.chipName, success: true, durationMs: ms,
});
setPhase('success');
} catch (err) {
const ms = startRef.current ? Date.now() - startRef.current : 0;
setDuration(ms);
try { await transportRef.current?.disconnect(); } catch {}
const res = await api.post(`/flash/${token}/consume`, {
logId: logIdRef.current, boardMac: boardInfo?.mac,
chipType: boardInfo?.chipName, success: false,
errorMessage: err.message, durationMs: ms,
}).catch(() => null);
const left = res?.data?.attemptsRemaining ?? 0;
setRemaining(left);
setError(`플래시 실패: ${err.message}`);
setPhase(left <= 0 ? 'locked' : 'failed');
loaderRef.current = null;
transportRef.current = null;
logIdRef.current = null;
}
}
function retry() {
setBoardInfo(null); setProgress(0); setError(null);
setLogs([]); setCurFile(''); setPhase('idle');
loaderRef.current = null; transportRef.current = null;
logIdRef.current = null; startRef.current = null;
api.get(`/flash/${token}`)
.then(r => { setInfo(r.data); setRemaining(r.data.attemptsRemaining); })
.catch(() => {});
}
async function requestReflash() {
try {
const res = await api.post(`/orders/${info.orderId}/request-reflash`);
window.location.href = `/flash/${res.data.token}`;
} catch (err) {
setError(`재플래시 토큰 발급 실패: ${err.response?.data?.error || err.message}`);
}
}
// ── 렌더링 ──────────────────────────────────────────────────────────────────
if (infoLoading) return <div className="spinner" />;
if (!info) {
return (
<StatusCard icon="❌" title="유효하지 않은 토큰" desc="토큰을 찾을 수 없습니다.">
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
</StatusCard>
);
}
if (info.expired) {
return (
<StatusCard icon="⏰" title="만료된 토큰"
desc={`유효기간: ${new Date(info.expiresAt).toLocaleDateString('ko-KR')}`}>
{info.orderId && <button className="btn btn-primary" onClick={requestReflash}> 토큰 발급 (무료)</button>}
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
</StatusCard>
);
}
if (info.isUsed && phase !== 'success') {
return (
<StatusCard icon="✅" title="이미 플래시됨"
desc={`${new Date(info.usedAt).toLocaleString('ko-KR')} 완료`}>
{info.orderId && <button className="btn btn-primary" onClick={requestReflash}>업데이트 토큰 발급</button>}
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
</StatusCard>
);
}
if (info.isLocked && phase !== 'locked') {
return (
<StatusCard icon="🔒" title="토큰 잠김"
desc={info.lockedReason || '너무 많은 실패로 잠겼습니다. 관리자에게 문의하세요.'}>
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
</StatusCard>
);
}
if (!info.hasFirmware) {
return (
<StatusCard icon="⚠️" title="펌웨어 없음" desc="판매자가 아직 펌웨어를 업로드하지 않았습니다." >
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
</StatusCard>
);
}
return (
<div className="container page" style={{ maxWidth: 680 }}>
<div className="card">
{/* 헤더 */}
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 36 }}></div>
<div>
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
<span className="text-muted" style={{ fontSize: 13 }}>
{info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')}
{info.isUpdateToken && (
<span style={{ marginLeft: 8, background: 'var(--accent)', color: '#fff',
padding: '1px 7px', borderRadius: 10, fontSize: 11 }}>업데이트</span>
)}
</span>
</div>
</div>
<StepBar phase={phase} />
{/* 남은 시도 횟수 경고 */}
{remaining !== null && remaining < info.maxAttempts && phase !== 'success' && phase !== 'locked' && (
<div style={{
background: remaining <= 1 ? 'rgba(239,68,68,.12)' : 'rgba(251,191,36,.12)',
border: `1px solid ${remaining <= 1 ? 'var(--danger)' : 'var(--warn)'}`,
borderRadius: 6, padding: '8px 14px', marginBottom: 16, fontSize: 13,
}}>
남은 시도: <strong>{remaining}</strong>/{info.maxAttempts}
{remaining <= 1 && ' — 마지막 기회입니다!'}
</div>
)}
{/* 보드 정보 카드 */}
{boardInfo && phase !== 'success' && (
<div style={{ background: 'var(--bg2)', border: '1px solid var(--border)', borderRadius: 8,
padding: '14px 18px', marginBottom: 20 }}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10, color: 'var(--accent)' }}>
보드 정보 (esptool 읽기)
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<InfoRow label="MAC 주소" value={boardInfo.mac} mono />
<InfoRow label="칩 종류" value={boardInfo.chipName} />
</div>
</div>
)}
{/* 진행 바 */}
{(phase === 'downloading' || phase === 'flashing') && (
<div style={{ marginBottom: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, marginBottom: 6 }}>
<span className="text-muted">{phase === 'downloading' ? '다운로드' : '기록'}: {curFile}</span>
<span>{progress}%</span>
</div>
<div style={{ background: 'var(--bg3)', borderRadius: 4, height: 8, overflow: 'hidden' }}>
<div style={{ width: `${progress}%`, height: '100%', background: 'var(--accent)', transition: 'width .2s' }} />
</div>
</div>
)}
{/* 에러 */}
{error && (
<div style={{ background: 'rgba(239,68,68,.1)', border: '1px solid var(--danger)',
borderRadius: 6, padding: '10px 14px', marginBottom: 16, fontSize: 13, whiteSpace: 'pre-wrap' }}>
{error}
</div>
)}
{/* 성공 */}
{phase === 'success' && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: 40, marginBottom: 8 }}>🎉</div>
<div style={{ fontWeight: 700, fontSize: 18, marginBottom: 6 }}>플래시 완료!</div>
{boardInfo && <p style={{ fontSize: 13, color: 'var(--text2)' }}>MAC: {boardInfo.mac} · : {boardInfo.chipName}</p>}
{duration && <p style={{ fontSize: 13, color: 'var(--text2)' }}>소요시간: {fmtMs(duration)}</p>}
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
<button className="btn btn-outline btn-sm" onClick={requestReflash}>업데이트 토큰 발급</button>
<Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
</div>
</div>
)}
{/* 잠금 */}
{phase === 'locked' && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: 40, marginBottom: 8 }}>🔒</div>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>토큰이 잠겼습니다</div>
<p style={{ fontSize: 13, color: 'var(--text2)' }}>최대 시도 횟수를 초과했습니다.</p>
<p style={{ fontSize: 13, color: 'var(--text2)' }}>관리자에게 문의하시면 잠금 해제가 가능합니다.</p>
<Link to="/dashboard/orders" className="btn btn-outline btn-sm" style={{ marginTop: 12, display: 'inline-block' }}>구매 내역으로</Link>
</div>
)}
{/* 액션: 초기 상태 */}
{phase === 'idle' && (
<>
<div style={{ background: 'rgba(99,102,241,.08)', border: '1px solid rgba(99,102,241,.3)',
borderRadius: 6, padding: '12px 16px', marginBottom: 20, fontSize: 13 }}>
<strong>플래시 확인사항</strong>
<ul style={{ marginTop: 8, paddingLeft: 16, lineHeight: 2 }}>
<li>Chrome 또는 Edge 브라우저 사용 중인가요?</li>
<li>ESP32가 USB로 PC에 연결되어 있나요?</li>
<li>HTTPS 또는 localhost 접속인가요? <span style={{ color: 'var(--warn)' }}>(Web Serial 필수)</span></li>
</ul>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<button className="btn btn-primary" style={{ fontSize: 15, padding: '12px 32px' }} onClick={connectAndRead}>
🔌 ESP32 연결 보드 정보 읽기
</button>
<p className="text-muted" style={{ fontSize: 12 }}>버튼 클릭 팝업에서 포트 선택</p>
</div>
</>
)}
{/* 연결/읽기 중 */}
{(phase === 'connecting' || phase === 'reading') && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div className="spinner" style={{ margin: '0 auto 12px' }} />
<p className="text-muted" style={{ fontSize: 14 }}>
{phase === 'connecting' ? 'ESP32 연결 중…' : '보드 정보 읽는 중…'}
</p>
</div>
)}
{/* 보드 확인 완료 → 플래시 버튼 */}
{phase === 'board_ready' && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, paddingTop: 8 }}>
<button className="btn btn-primary" style={{ fontSize: 15, padding: '12px 32px' }} onClick={startFlash}>
펌웨어 플래시 시작
</button>
<button className="btn btn-outline btn-sm" onClick={retry}>다른 보드 연결</button>
</div>
)}
{/* 다운로드/플래시 중 */}
{(phase === 'downloading' || phase === 'flashing') && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<p className="text-muted" style={{ fontSize: 13 }}>
{phase === 'downloading' ? '펌웨어 다운로드 중…' : '플래시 기록 중… USB 케이블을 뽑지 마세요'}
</p>
</div>
)}
{/* 실패 → 재시도 */}
{phase === 'failed' && (
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', flexWrap: 'wrap', paddingTop: 8 }}>
{remaining > 0 && (
<button className="btn btn-primary" onClick={retry}>재시도 ({remaining} 남음)</button>
)}
<Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
</div>
)}
{/* ESP 디버그 로그 */}
{logs.length > 0 && (
<details style={{ marginTop: 20 }}>
<summary style={{ cursor: 'pointer', fontSize: 12, color: 'var(--text2)' }}>
ESP 로그 ({logs.length})
</summary>
<div style={{
marginTop: 8, background: 'var(--bg3)', padding: 10, borderRadius: 4,
maxHeight: 160, overflowY: 'auto', fontFamily: 'monospace',
fontSize: 11, whiteSpace: 'pre-wrap', color: 'var(--text2)',
}}>
{logs.join('\n')}
</div>
</details>
)}
<div style={{ borderTop: '1px solid var(--border)', marginTop: 20, paddingTop: 14 }}>
<details style={{ fontSize: 12, color: 'var(--text2)' }}>
<summary style={{ cursor: 'pointer' }}>토큰 정보</summary>
<code style={{ wordBreak: 'break-all', display: 'block', background: 'var(--bg3)',
padding: 8, borderRadius: 4, marginTop: 6 }}>{token}</code>
<p style={{ marginTop: 6 }}>펌웨어: {info.firmwareFiles?.length} 파일</p>
</details>
</div>
</div>
</div>
);