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

@@ -83,6 +83,7 @@ model ProjectFile {
mimeType String mimeType String
originalName String? originalName String?
flashOffset String @default("0x0") flashOffset String @default("0x0")
firmwareVersion String? // e.g. "1.0.0" — firmware 파일에만 사용
displayOrder Int @default(0) displayOrder Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
@@ -121,38 +122,79 @@ model Order {
refundedAt DateTime? refundedAt DateTime?
refundReason String? refundReason String?
flashToken FlashToken? flashTokens FlashToken[] // 원본 + 업데이트 토큰 복수 허용
review Review? review Review?
} }
model FlashToken { model FlashToken {
id String @id @default(uuid()) id String @id @default(uuid())
token String @unique @default(uuid()) token String @unique @default(uuid())
orderId String @unique orderId String
order Order @relation(fields: [orderId], references: [id]) order Order @relation(fields: [orderId], references: [id])
isUsed Boolean @default(false) isUsed Boolean @default(false) // 성공적으로 플래시된 경우 true
usedAt DateTime? usedAt DateTime?
macAddress String? macAddress String?
chipFamily String? chipFamily String?
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
maxAttempts Int @default(3) // 최대 시도 횟수
attemptCount Int @default(0) // 현재 시도 횟수 (실패 포함)
lastAttemptAt DateTime?
isLocked Boolean @default(false) // maxAttempts 초과 시 잠금
lockedReason String?
isUpdateToken Boolean @default(false) // 펌웨어 업데이트용 재발급 토큰
flashLog FlashLog? flashLogs FlashLog[]
@@index([orderId])
} }
model FlashLog { model FlashLog {
id String @id @default(uuid()) id String @id @default(uuid())
flashTokenId String @unique flashTokenId String
flashToken FlashToken @relation(fields: [flashTokenId], references: [id]) flashToken FlashToken @relation(fields: [flashTokenId], references: [id])
macAddress String attemptNumber Int @default(1)
chipFamily String macAddress String? // 플래시 전 보드에서 읽은 MAC 주소
chipFamily String? // 칩 종류 (esptool-js 에서 읽음)
chipId String? // 칩 ID
flashSize String? // 플래시 메모리 크기
firmwareName String firmwareName String
firmwareId String firmwareId String
success Boolean success Boolean
errorMessage String? errorMessage String?
clientIp String clientIp String
userAgent String? userAgent String?
startedAt DateTime? // 플래시 시작 시각
completedAt DateTime? // 플래시 완료/실패 시각
durationMs Int? // 소요 시간 ms
flashedAt DateTime @default(now()) flashedAt DateTime @default(now())
@@index([flashTokenId])
@@index([clientIp])
@@index([macAddress])
@@index([flashedAt])
}
// 이상 감지 이벤트
model FlashAnomaly {
id String @id @default(uuid())
type String // RATE_LIMIT_IP | HIGH_VOLUME_IP | MAC_REUSE | TOKEN_LOCK | SUSPICIOUS_DURATION
severity String @default("medium") // low | medium | high
description String
clientIp String?
macAddress String?
flashTokenId String?
flashLogId String?
metadata Json?
resolved Boolean @default(false)
resolvedAt DateTime?
resolvedBy String?
createdAt DateTime @default(now())
@@index([type])
@@index([clientIp])
@@index([resolved])
@@index([createdAt])
} }
model Review { model Review {

View File

@@ -242,4 +242,135 @@ router.get('/stats', async (_req, res) => {
} }
}); });
// ── 플래시 지표 ─────────────────────────────────────────────────────────────
// GET /api/admin/flash/metrics — 플래시 통계 대시보드
router.get('/flash/metrics', async (_req, res) => {
try {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today - 24 * 3600 * 1000);
const [
todayTotal, todaySuccess, todayFailed,
allTotal, allSuccess,
unresolvedAnomalies,
recentLogs,
topIps,
] = await Promise.all([
prisma.flashLog.count({ where: { flashedAt: { gte: today } } }),
prisma.flashLog.count({ where: { flashedAt: { gte: today }, success: true } }),
prisma.flashLog.count({ where: { flashedAt: { gte: today }, success: false } }),
prisma.flashLog.count(),
prisma.flashLog.count({ where: { success: true } }),
prisma.flashAnomaly.count({ where: { resolved: false } }),
prisma.flashLog.findMany({
orderBy: { flashedAt: 'desc' }, take: 20,
include: {
flashToken: {
select: {
token: true, isLocked: true,
order: { select: { product: { select: { project: { select: { title: true } } } } } },
},
},
},
}),
// 상위 IP (24시간)
prisma.flashLog.groupBy({
by: ['clientIp'],
where: { flashedAt: { gte: yesterday } },
_count: { clientIp: true },
orderBy: { _count: { clientIp: 'desc' } },
take: 10,
}),
]);
res.json({
today: { total: todayTotal, success: todaySuccess, failed: todayFailed },
allTime: {
total: allTotal,
successRate: allTotal > 0 ? Math.round(allSuccess / allTotal * 100) : 0,
},
anomalies: { unresolved: unresolvedAnomalies },
recentLogs: recentLogs.map(l => ({
id: l.id,
productName: l.flashToken?.order?.product?.project?.title || '?',
macAddress: l.macAddress,
chipFamily: l.chipFamily,
clientIp: l.clientIp,
success: l.success,
durationMs: l.durationMs,
startedAt: l.startedAt,
completedAt: l.completedAt,
flashedAt: l.flashedAt,
errorMessage: l.errorMessage,
isLocked: l.flashToken?.isLocked,
token: l.flashToken?.token?.slice(0, 8) + '…',
})),
topIps: topIps.map(r => ({ ip: r.clientIp, count: r._count.clientIp })),
});
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// GET /api/admin/flash/anomalies
router.get('/flash/anomalies', async (req, res) => {
try {
const resolved = req.query.resolved === 'true';
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = 50;
const skip = (page - 1) * limit;
const [anomalies, total] = await Promise.all([
prisma.flashAnomaly.findMany({
where: { resolved },
orderBy: { createdAt: 'desc' },
skip, take: limit,
}),
prisma.flashAnomaly.count({ where: { resolved } }),
]);
res.json({ anomalies, total, page, pages: Math.ceil(total / limit) });
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// PUT /api/admin/flash/anomalies/:id/resolve — 이상 이벤트 해소 처리
router.put('/flash/anomalies/:id/resolve', async (req, res) => {
try {
const updated = await prisma.flashAnomaly.update({
where: { id: req.params.id },
data: { resolved: true, resolvedAt: new Date(), resolvedBy: req.user.email },
});
res.json(updated);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
// POST /api/admin/flash/tokens/:id/unlock — 잠긴 토큰 해제
router.post('/flash/tokens/:id/unlock', async (req, res) => {
try {
const token = await prisma.flashToken.update({
where: { id: req.params.id },
data: {
isLocked: false, lockedReason: null,
attemptCount: 0, // 시도 횟수 초기화
maxAttempts: req.body.maxAttempts || 3,
},
});
await writeAuditLog({
userId: req.user.id, action: 'ADMIN_TOKEN_UNLOCK',
targetType: 'FlashToken', targetId: token.id,
req, responseStatus: 200,
});
res.json(token);
} catch (err) {
res.status(500).json({ error: '서버 오류' });
}
});
module.exports = router; module.exports = router;

View File

@@ -2,7 +2,7 @@ const router = require('express').Router();
const prisma = require('../config/db'); const prisma = require('../config/db');
const { writeAuditLog } = require('../middleware/audit'); const { writeAuditLog } = require('../middleware/audit');
// FlashToken 포함 조회 헬퍼 // ── FlashToken + 펌웨어 파일 포함 조회 ──────────────────────────────────────
async function findToken(token) { async function findToken(token) {
return prisma.flashToken.findUnique({ return prisma.flashToken.findUnique({
where: { token }, where: { token },
@@ -28,7 +28,59 @@ async function findToken(token) {
}); });
} }
// GET /api/flash/:token — 토큰 유효성 확인 (Flash 페이지, webflash 둘 다 사용) // ── 이상 감지 ──────────────────────────────────────────────────────────────
async function detectAnomalies({ clientIp, macAddress, flashTokenId }) {
const now = new Date();
const t10m = new Date(now - 10 * 60 * 1000);
const t1h = new Date(now - 60 * 60 * 1000);
const alerts = [];
// 10분 내 동일 IP 5회 이상
const cnt10m = await prisma.flashLog.count({
where: { clientIp, startedAt: { gte: t10m } },
});
if (cnt10m >= 5) {
alerts.push({
type: 'RATE_LIMIT_IP', severity: 'high',
description: `IP ${clientIp} — 10분 내 ${cnt10m + 1}회 플래시 시도`,
clientIp, flashTokenId,
});
}
// 1시간 내 동일 IP 10회 이상
const cnt1h = await prisma.flashLog.count({
where: { clientIp, startedAt: { gte: t1h } },
});
if (cnt1h >= 10) {
alerts.push({
type: 'HIGH_VOLUME_IP', severity: 'medium',
description: `IP ${clientIp} — 1시간 내 ${cnt1h + 1}회 플래시`,
clientIp, flashTokenId,
});
}
// 동일 MAC → 1시간 내 3개 이상의 다른 토큰에서 사용
if (macAddress && macAddress !== 'unknown') {
const macGroups = await prisma.flashLog.groupBy({
by: ['flashTokenId'],
where: { macAddress, startedAt: { gte: t1h }, flashTokenId: { not: flashTokenId } },
});
if (macGroups.length >= 3) {
alerts.push({
type: 'MAC_REUSE', severity: 'medium',
description: `MAC ${macAddress} — 1시간 내 ${macGroups.length + 1}개 토큰에서 사용`,
clientIp, macAddress, flashTokenId,
});
}
}
if (alerts.length > 0) {
await prisma.flashAnomaly.createMany({ data: alerts });
}
return alerts;
}
// ── GET /api/flash/:token — 토큰 유효성 + 펌웨어 정보 ───────────────────────
router.get('/:token', async (req, res) => { router.get('/:token', async (req, res) => {
try { try {
const ft = await findToken(req.params.token); const ft = await findToken(req.params.token);
@@ -36,16 +88,31 @@ router.get('/:token', async (req, res) => {
const expired = new Date() > new Date(ft.expiresAt); const expired = new Date() > new Date(ft.expiresAt);
const project = ft.order.product.project; const project = ft.order.product.project;
const remaining = Math.max(0, ft.maxAttempts - ft.attemptCount);
res.json({ res.json({
valid: !expired && !ft.isUsed, valid: !expired && !ft.isUsed && !ft.isLocked,
isUsed: ft.isUsed, isUsed: ft.isUsed,
expired, expired,
isLocked: ft.isLocked,
lockedReason: ft.lockedReason,
expiresAt: ft.expiresAt, expiresAt: ft.expiresAt,
productName: project.title, productName: project.title,
chipFamily: project.chipFamily, chipFamily: project.chipFamily,
hasFirmware: project.files.length > 0, hasFirmware: project.files.length > 0,
usedAt: ft.usedAt, usedAt: ft.usedAt,
attemptsRemaining: remaining,
maxAttempts: ft.maxAttempts,
attemptCount: ft.attemptCount,
isUpdateToken: ft.isUpdateToken,
orderId: ft.orderId,
firmwareFiles: project.files.map(f => ({
url: f.url,
offset: f.flashOffset || '0x0',
size: f.fileSize,
name: f.originalName || 'firmware.bin',
version: f.firmwareVersion || null,
})),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -53,26 +120,21 @@ router.get('/:token', async (req, res) => {
} }
}); });
// GET /api/flash/:token/manifest — esp-web-tools 매니페스트 반환 // ── GET /api/flash/:token/manifest — esp-web-tools 호환 매니페스트 ──────────
// 브라우저에서 직접 fetch하므로 CORS 허용
router.get('/:token/manifest', async (req, res) => { router.get('/:token/manifest', async (req, res) => {
try { try {
const ft = await findToken(req.params.token); const ft = await findToken(req.params.token);
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' }); if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰' });
if (ft.isUsed) { if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰' });
return res.status(410).json({ error: '이미 플래시에 사용된 토큰입니다' }); if (ft.isLocked) return res.status(403).json({ error: '잠긴 토큰' });
}
if (new Date() > new Date(ft.expiresAt)) {
return res.status(410).json({ error: '만료된 토큰입니다' });
}
const project = ft.order.product.project; const project = ft.order.product.project;
if (!project.files.length) { if (!project.files.length) return res.status(404).json({ error: '펌웨어 파일 없음' });
return res.status(404).json({ error: '펌웨어 파일이 없습니다. 판매자에게 문의하세요.' });
}
const manifest = { res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.json({
name: project.title, name: project.title,
new_install_improv_wait_ms: 0, new_install_improv_wait_ms: 0,
builds: [{ builds: [{
@@ -82,57 +144,157 @@ router.get('/:token/manifest', async (req, res) => {
offset: parseInt(f.flashOffset || '0x0', 16), offset: parseInt(f.flashOffset || '0x0', 16),
})), })),
}], }],
}; });
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Content-Type', 'application/json');
res.json(manifest);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: '서버 오류' }); res.status(500).json({ error: '서버 오류' });
} }
}); });
// POST /api/flash/:token/consume — 플래시 완료 기록 // ── POST /api/flash/:token/start — 플래시 시작 기록 ─────────────────────────
// esp-web-tools 또는 플래시 페이지에서 호출 router.post('/:token/start', async (req, res) => {
router.post('/:token/consume', async (req, res) => {
try { try {
const ft = await findToken(req.params.token); const ft = await findToken(req.params.token);
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' }); if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰입니다' }); if (ft.isUsed) return res.status(410).json({ error: '이미 사용된 토큰입니다' });
if (ft.isLocked) return res.status(403).json({ error: `토큰이 잠겼습니다: ${ft.lockedReason}` });
if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' }); if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' });
const { mac = 'unknown', chipFamily, success = true, errorMessage } = req.body; // 시도 횟수 초과 → 잠금
const project = ft.order.product.project; if (ft.attemptCount >= ft.maxAttempts) {
await prisma.flashToken.update({
await prisma.$transaction([
prisma.flashToken.update({
where: { id: ft.id }, where: { id: ft.id },
data: { isUsed: true, usedAt: new Date(), macAddress: mac, chipFamily: chipFamily || project.chipFamily }, data: { isLocked: true, lockedReason: `최대 시도 횟수(${ft.maxAttempts}회) 초과` },
}), });
prisma.flashLog.create({ return res.status(403).json({ error: '최대 시도 횟수를 초과하여 토큰이 잠겼습니다', attemptsRemaining: 0 });
}
const { boardMac, chipType, chipId, flashSize } = req.body;
const clientIp = req.ip || 'unknown';
const project = ft.order.product.project;
const newCount = ft.attemptCount + 1;
const flashLog = await prisma.flashLog.create({
data: { data: {
flashTokenId: ft.id, flashTokenId: ft.id,
macAddress: mac, attemptNumber: newCount,
chipFamily: chipFamily || project.chipFamily, macAddress: boardMac || null,
chipFamily: chipType || project.chipFamily,
chipId: chipId || null,
flashSize: flashSize || null,
firmwareName: project.title, firmwareName: project.title,
firmwareId: project.id, firmwareId: project.id,
success: Boolean(success), success: false,
errorMessage: errorMessage || null, clientIp,
clientIp: req.ip || 'unknown',
userAgent: req.headers['user-agent'] || null, userAgent: req.headers['user-agent'] || null,
startedAt: new Date(),
}, },
}),
]);
await writeAuditLog({
userId: ft.order.buyerId,
action: success ? 'FLASH_SUCCESS' : 'FLASH_FAIL',
targetType: 'FlashToken', targetId: ft.id,
req, metadata: { mac, chipFamily, firmwareId: project.id }, responseStatus: 200,
}); });
res.json({ success: true, message: success ? '플래시 완료 기록됨' : '실패 기록됨' }); await prisma.flashToken.update({
where: { id: ft.id },
data: { attemptCount: newCount, lastAttemptAt: new Date() },
});
// 이상 감지 (비동기)
detectAnomalies({ clientIp, macAddress: boardMac, flashTokenId: ft.id }).catch(() => {});
res.json({
logId: flashLog.id,
attemptsRemaining: ft.maxAttempts - newCount,
maxAttempts: ft.maxAttempts,
attemptNumber: newCount,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// ── POST /api/flash/:token/consume — 플래시 완료/실패 기록 ───────────────────
router.post('/:token/consume', async (req, res) => {
try {
const ft = await findToken(req.params.token);
if (!ft) return res.status(404).json({ error: '유효하지 않은 토큰입니다' });
if (new Date() > new Date(ft.expiresAt)) return res.status(410).json({ error: '만료된 토큰입니다' });
const { logId, boardMac, chipType, success = true, errorMessage, durationMs } = req.body;
const completedAt = new Date();
const project = ft.order.product.project;
// 해당 logId 로그 업데이트
if (logId) {
await prisma.flashLog.update({
where: { id: logId },
data: {
macAddress: boardMac || undefined,
chipFamily: chipType || undefined,
success: Boolean(success),
errorMessage: errorMessage || null,
completedAt,
durationMs: durationMs ? Number(durationMs) : null,
},
}).catch(() => {}); // logId 없을 수도 있으므로 무시
}
// 비정상적으로 짧은 플래시 시간 감지 (5초 미만)
if (success && durationMs && Number(durationMs) < 5000) {
prisma.flashAnomaly.create({
data: {
type: 'SUSPICIOUS_DURATION', severity: 'low',
description: `플래시 소요시간 ${durationMs}ms — 비정상적으로 짧음`,
clientIp: req.ip || 'unknown', macAddress: boardMac,
flashTokenId: ft.id, flashLogId: logId || null,
metadata: { durationMs: Number(durationMs) },
},
}).catch(() => {});
}
if (success) {
await prisma.flashToken.update({
where: { id: ft.id },
data: {
isUsed: true, usedAt: completedAt,
macAddress: boardMac, chipFamily: chipType || project.chipFamily,
},
});
await writeAuditLog({
userId: ft.order.buyerId, action: 'FLASH_SUCCESS',
targetType: 'FlashToken', targetId: ft.id, req,
metadata: { boardMac, chipType, firmwareId: project.id, durationMs }, responseStatus: 200,
});
return res.json({ success: true, message: '플래시 완료 기록됨' });
}
// 실패 처리
const remaining = ft.maxAttempts - ft.attemptCount;
if (remaining <= 0) {
await prisma.flashToken.update({
where: { id: ft.id },
data: { isLocked: true, lockedReason: `최대 시도 횟수(${ft.maxAttempts}회) 초과` },
});
prisma.flashAnomaly.create({
data: {
type: 'TOKEN_LOCK', severity: 'medium',
description: `토큰 잠금 — ${ft.maxAttempts}회 실패 (MAC: ${boardMac || '?'} / IP: ${req.ip})`,
clientIp: req.ip || 'unknown', macAddress: boardMac, flashTokenId: ft.id,
},
}).catch(() => {});
}
await writeAuditLog({
userId: ft.order.buyerId, action: 'FLASH_FAIL',
targetType: 'FlashToken', targetId: ft.id, req,
metadata: { boardMac, chipType, errorMessage, durationMs, remaining }, responseStatus: 200,
});
res.json({
success: false, message: '실패 기록됨',
attemptsRemaining: Math.max(0, remaining),
isLocked: remaining <= 0,
});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: '서버 오류' }); res.status(500).json({ error: '서버 오류' });

View File

@@ -1,9 +1,14 @@
const router = require('express').Router(); const router = require('express').Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const prisma = require('../config/db'); const prisma = require('../config/db');
const { requireAuth, requireRole } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { writeAuditLog } = require('../middleware/audit'); const { writeAuditLog } = require('../middleware/audit');
// 유효한 FlashToken 조회 헬퍼 (만료 전, 미사용, 미잠금)
function activeToken(tokens = []) {
return tokens.find(t => !t.isUsed && !t.isLocked && new Date(t.expiresAt) > new Date()) || null;
}
// POST /api/orders — 주문 생성 (결제 전) // POST /api/orders — 주문 생성 (결제 전)
router.post('/', requireAuth, async (req, res) => { router.post('/', requireAuth, async (req, res) => {
const { productId } = req.body; const { productId } = req.body;
@@ -18,15 +23,15 @@ router.post('/', requireAuth, async (req, res) => {
if (!product.isOnSale) return res.status(400).json({ error: '판매 중지된 상품입니다' }); if (!product.isOnSale) return res.status(400).json({ error: '판매 중지된 상품입니다' });
if (product.project.userId === req.user.id) return res.status(400).json({ error: '자신의 상품은 구매할 수 없습니다' }); if (product.project.userId === req.user.id) return res.status(400).json({ error: '자신의 상품은 구매할 수 없습니다' });
// 이미 유효한 주문이 있는지 확인 (중복 구매 방지)
const existing = await prisma.order.findFirst({ const existing = await prisma.order.findFirst({
where: { buyerId: req.user.id, productId, status: { in: ['pending', 'paid'] } }, where: { buyerId: req.user.id, productId, status: { in: ['pending', 'paid'] } },
include: { flashToken: true }, include: { flashTokens: true },
}); });
if (existing?.status === 'paid') { if (existing?.status === 'paid') {
const token = activeToken(existing.flashTokens);
return res.status(409).json({ return res.status(409).json({
error: '이미 구매한 상품입니다', error: '이미 구매한 상품입니다',
flashToken: existing.flashToken?.token, flashToken: token?.token,
orderId: existing.id, orderId: existing.id,
}); });
} }
@@ -39,11 +44,8 @@ router.post('/', requireAuth, async (req, res) => {
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
buyerId: req.user.id, buyerId: req.user.id, productId,
productId, amount: product.price, commissionAmount, sellerAmount,
amount: product.price,
commissionAmount,
sellerAmount,
paymentGateway: 'toss', paymentGateway: 'toss',
tossOrderId: `mock_${uuidv4()}`, tossOrderId: `mock_${uuidv4()}`,
buyerInfo: { email: req.user.email, nickname: req.user.nickname }, buyerInfo: { email: req.user.email, nickname: req.user.nickname },
@@ -59,33 +61,32 @@ router.post('/', requireAuth, async (req, res) => {
} }
}); });
// POST /api/orders/:id/mock-pay — 모의 결제 (사업자 등록 전 테스트용) // POST /api/orders/:id/mock-pay — 모의 결제 (테스트 모드)
// 실제 결제 없이 즉시 paid 처리하고 FlashToken 발급
router.post('/:id/mock-pay', requireAuth, async (req, res) => { router.post('/:id/mock-pay', requireAuth, async (req, res) => {
try { try {
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: req.params.id }, where: { id: req.params.id },
include: { flashToken: true }, include: { flashTokens: true },
}); });
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' }); if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
if (order.status === 'paid') { if (order.status === 'paid') {
return res.json({ flashToken: order.flashToken?.token, orderId: order.id, alreadyPaid: true }); const token = activeToken(order.flashTokens);
return res.json({ flashToken: token?.token, orderId: order.id, alreadyPaid: true });
} }
if (order.status !== 'pending') { if (order.status !== 'pending') {
return res.status(400).json({ error: `${order.status} 상태의 주문은 결제할 수 없습니다` }); return res.status(400).json({ error: `${order.status} 상태의 주문은 결제할 수 없습니다` });
} }
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30일 const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const [updatedOrder, flashToken] = await prisma.$transaction([ const [, flashToken] = await prisma.$transaction([
prisma.order.update({ prisma.order.update({
where: { id: order.id }, where: { id: order.id },
data: { status: 'paid', paidAt: new Date(), paymentKey: `mock_${uuidv4()}` }, data: { status: 'paid', paidAt: new Date(), paymentKey: `mock_${uuidv4()}` },
}), }),
prisma.flashToken.create({ prisma.flashToken.create({ data: { orderId: order.id, expiresAt } }),
data: { orderId: order.id, expiresAt },
}),
prisma.product.update({ prisma.product.update({
where: { id: order.productId }, where: { id: order.productId },
data: { totalSales: { increment: 1 } }, data: { totalSales: { increment: 1 } },
@@ -105,6 +106,42 @@ router.post('/:id/mock-pay', requireAuth, async (req, res) => {
} }
}); });
// POST /api/orders/:id/request-reflash — 펌웨어 업데이트용 재플래시 토큰 발급
// 결제 완료된 주문에 한해 새 FlashToken 무료 발급
router.post('/:id/request-reflash', requireAuth, async (req, res) => {
try {
const order = await prisma.order.findUnique({
where: { id: req.params.id },
include: { flashTokens: true },
});
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
if (order.buyerId !== req.user.id) return res.status(403).json({ error: '권한이 없습니다' });
if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 주문만 재플래시가 가능합니다' });
// 아직 유효한 토큰이 있으면 그것을 반환
const existing = activeToken(order.flashTokens);
if (existing) {
return res.json({ token: existing.token, isNew: false, message: '기존 유효 토큰이 있습니다' });
}
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
const newToken = await prisma.flashToken.create({
data: { orderId: order.id, expiresAt, isUpdateToken: true },
});
await writeAuditLog({
userId: req.user.id, action: 'REFLASH_TOKEN_ISSUED',
targetType: 'Order', targetId: order.id,
req, metadata: { tokenId: newToken.id }, responseStatus: 201,
});
res.status(201).json({ token: newToken.token, isNew: true, expiresAt, message: '펌웨어 업데이트 토큰이 발급되었습니다' });
} catch (err) {
console.error(err);
res.status(500).json({ error: '서버 오류' });
}
});
// GET /api/orders/me — 내 주문 목록 // GET /api/orders/me — 내 주문 목록
router.get('/me', requireAuth, async (req, res) => { router.get('/me', requireAuth, async (req, res) => {
try { try {
@@ -118,7 +155,7 @@ router.get('/me', requireAuth, async (req, res) => {
project: { select: { title: true, chipFamily: true, files: { where: { fileType: 'image' }, take: 1 } } }, project: { select: { title: true, chipFamily: true, files: { where: { fileType: 'image' }, take: 1 } } },
}, },
}, },
flashToken: { select: { token: true, isUsed: true, expiresAt: true } }, flashTokens: { select: { token: true, isUsed: true, isLocked: true, expiresAt: true, attemptCount: true, maxAttempts: true, isUpdateToken: true } },
review: { select: { id: true } }, review: { select: { id: true } },
}, },
}); });
@@ -135,7 +172,7 @@ router.get('/:id', requireAuth, async (req, res) => {
where: { id: req.params.id }, where: { id: req.params.id },
include: { include: {
product: { include: { project: true } }, product: { include: { project: true } },
flashToken: true, flashTokens: true,
}, },
}); });
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
@@ -148,19 +185,21 @@ router.get('/:id', requireAuth, async (req, res) => {
} }
}); });
// POST /api/orders/:id/refund — 환불 (모의 / 플래시 미완료 시만) // POST /api/orders/:id/refund — 환불 (플래시 미완료 시만)
router.post('/:id/refund', requireAuth, async (req, res) => { router.post('/:id/refund', requireAuth, async (req, res) => {
try { try {
const order = await prisma.order.findUnique({ const order = await prisma.order.findUnique({
where: { id: req.params.id }, where: { id: req.params.id },
include: { flashToken: true }, include: { flashTokens: true },
}); });
if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' }); if (!order) return res.status(404).json({ error: '주문을 찾을 수 없습니다' });
if (order.buyerId !== req.user.id && req.user.role !== 'admin') { if (order.buyerId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: '권한이 없습니다' }); return res.status(403).json({ error: '권한이 없습니다' });
} }
if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 상태가 아닙니다' }); if (order.status !== 'paid') return res.status(400).json({ error: '결제 완료 상태가 아닙니다' });
if (order.flashToken?.isUsed && req.user.role !== 'admin') {
const hasFlashed = order.flashTokens.some(t => t.isUsed);
if (hasFlashed && req.user.role !== 'admin') {
return res.status(400).json({ error: '플래시가 완료된 주문은 환불할 수 없습니다' }); return res.status(400).json({ error: '플래시가 완료된 주문은 환불할 수 없습니다' });
} }
@@ -169,11 +208,11 @@ router.post('/:id/refund', requireAuth, async (req, res) => {
where: { id: order.id }, where: { id: order.id },
data: { status: 'refunded', refundedAt: new Date(), refundReason: req.body.reason || '사용자 요청' }, data: { status: 'refunded', refundedAt: new Date(), refundReason: req.body.reason || '사용자 요청' },
}), }),
// FlashToken 무효화 // 모든 토큰 만료 처리
...(order.flashToken ? [prisma.flashToken.update({ prisma.flashToken.updateMany({
where: { id: order.flashToken.id }, where: { orderId: order.id },
data: { expiresAt: new Date() }, data: { expiresAt: new Date() },
})] : []), }),
prisma.product.update({ prisma.product.update({
where: { id: order.productId }, where: { id: order.productId },
data: { totalSales: { decrement: 1 } }, data: { totalSales: { decrement: 1 } },

View File

@@ -9,6 +9,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.2", "axios": "^1.7.2",
"esptool-js": "^0.4.6",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.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 AdminProjects from './pages/Admin/Projects';
import AdminUsers from './pages/Admin/Users'; import AdminUsers from './pages/Admin/Users';
import AdminLogs from './pages/Admin/Logs'; import AdminLogs from './pages/Admin/Logs';
import FlashMetrics from './pages/Admin/FlashMetrics';
function RequireAuth({ children }) { function RequireAuth({ children }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -58,6 +59,7 @@ function AppRoutes() {
<Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} /> <Route path="/admin/projects" element={<RequireAdmin><AdminProjects /></RequireAdmin>} />
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></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 />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </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

@@ -40,6 +40,7 @@ export default function AdminIndex() {
{ to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' }, { to: '/admin/projects', icon: '📋', title: '프로젝트 승인', desc: '검토 대기 중인 프로젝트를 승인/반려' },
{ to: '/admin/users', 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 => ( ].map(m => (
<Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}> <Link key={m.to} to={m.to} style={{ textDecoration: 'none' }}>
<div className="card" style={{ cursor: 'pointer', transition: 'border-color .2s' }} <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 { useParams, Link } from 'react-router-dom';
import { ESPLoader, Transport } from 'esptool-js';
import api from '../api/client'; import api from '../api/client';
const CHIP_LABELS = { const BAUD = 115200;
'ESP32-S3': 'ESP32-S3', 'ESP32-S2': 'ESP32-S2', 'ESP32-C3': 'ESP32-C3',
'ESP32-C6': 'ESP32-C6', 'ESP32-H2': 'ESP32-H2', 'ESP32': 'ESP32', 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;
}
function fmtMs(ms) {
if (!ms) return '—';
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
}
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 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>
);
}
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() { export default function Flash() {
const { token } = useParams(); const { token } = useParams();
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true); const [infoLoading, setInfoLoading] = useState(true);
const [flashDone, setFlashDone] = useState(false); const [phase, setPhase] = useState('idle');
const installRef = useRef(null); 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 manifestUrl = `${window.location.origin}/api/flash/${token}/manifest`; 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(() => { useEffect(() => {
api.get(`/flash/${token}`) api.get(`/flash/${token}`)
.then(r => setInfo(r.data)) .then(r => { setInfo(r.data); setRemaining(r.data.attemptsRemaining); })
.catch(() => setInfo(null)) .catch(() => setInfo(null))
.finally(() => setLoading(false)); .finally(() => setInfoLoading(false));
}, [token]); }, [token]);
// esp-web-tools 이벤트 — 플래시 완료/실패 시 서버에 기록 // 1단계: 연결 + 보드 정보 읽기
useEffect(() => { async function connectAndRead() {
const btn = installRef.current; if (!navigator.serial) {
if (!btn) return; setError('Web Serial API를 사용할 수 없습니다.\nChrome/Edge + HTTPS(또는 localhost) 환경이 필요합니다.\n\n테스트용: Chrome에서 chrome://flags/#unsafely-treat-insecure-origin-as-secure 설정');
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) { setPhase('connecting');
api.post(`/flash/${token}/consume`, { setError(null);
mac: 'unknown', chipFamily: info?.chipFamily, setLogs([]);
success: false, errorMessage: e.detail?.message,
}).catch(() => {}); let port;
try {
port = await navigator.serial.requestPort();
} catch (e) {
if (e.name === 'NotFoundError') { setPhase('idle'); return; }
setError(`포트 선택 실패: ${e.message}`);
setPhase('idle');
return;
} }
btn.addEventListener('state-changed', (e) => { try {
if (e.detail?.state === 'finished') onSuccess(e); const transport = new Transport(port, true);
if (e.detail?.state === 'error') onFail(e); transportRef.current = transport;
const loader = new ESPLoader({
transport, baudrate: BAUD,
terminal: { clean: () => {}, writeLine: addLog, write: addLog },
}); });
}, [info, token]); loaderRef.current = loader;
if (loading) return <div className="spinner" />; 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) { if (!info) {
return ( return (
<div className="container page" style={{ maxWidth: 500 }}> <StatusCard icon="❌" title="유효하지 않은 토큰" desc="토큰을 찾을 수 없습니다.">
<div className="card text-center" style={{ padding: 48 }}> <Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
<div style={{ fontSize: 48, marginBottom: 16 }}></div> </StatusCard>
<h2 style={{ marginBottom: 8 }}>유효하지 않은 토큰</h2>
<p className="text-muted">토큰을 찾을 없습니다.</p>
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}>
구매 내역으로
</Link>
</div>
</div>
); );
} }
// 이미 사용된 토큰
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>
);
}
// 만료된 토큰
if (info.expired) { if (info.expired) {
return ( return (
<div className="container page" style={{ maxWidth: 500 }}> <StatusCard icon="⏰" title="만료된 토큰"
<div className="card text-center" style={{ padding: 48 }}> desc={`유효기간: ${new Date(info.expiresAt).toLocaleDateString('ko-KR')}`}>
<div style={{ fontSize: 48, marginBottom: 16 }}></div> {info.orderId && <button className="btn btn-primary" onClick={requestReflash}> 토큰 발급 (무료)</button>}
<h2 style={{ marginBottom: 8 }}>만료된 토큰</h2> <Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
<p className="text-muted">토큰 유효기간이 지났습니다.</p> </StatusCard>
<p className="text-muted" style={{ fontSize: 13 }}>고객센터에 문의해주세요.</p> );
<Link to="/dashboard/orders" className="btn btn-outline" style={{ marginTop: 20 }}> }
구매 내역으로 if (info.isUsed && phase !== 'success') {
</Link> return (
</div> <StatusCard icon="✅" title="이미 플래시됨"
</div> 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) { if (!info.hasFirmware) {
return ( return (
<div className="container page" style={{ maxWidth: 500 }}> <StatusCard icon="⚠️" title="펌웨어 없음" desc="판매자가 아직 펌웨어를 업로드하지 않았습니다." >
<div className="card text-center" style={{ padding: 48 }}> <Link to="/dashboard/orders" className="btn btn-outline">구매 내역으로</Link>
<div style={{ fontSize: 48, marginBottom: 16 }}></div> </StatusCard>
<h2 style={{ marginBottom: 8 }}>펌웨어 파일 없음</h2>
<p className="text-muted">판매자가 아직 펌웨어를 업로드하지 않았습니다.</p>
<p className="text-muted" style={{ fontSize: 13 }}>판매자에게 문의하거나 환불을 요청하세요.</p>
</div>
</div>
); );
} }
return ( return (
<div className="container page" style={{ maxWidth: 600 }}> <div className="container page" style={{ maxWidth: 680 }}>
<div className="card"> <div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
{/* 헤더 */}
<div style={{ display: 'flex', gap: 14, alignItems: 'center', marginBottom: 24 }}>
<div style={{ fontSize: 36 }}></div> <div style={{ fontSize: 36 }}></div>
<div> <div>
<h2 style={{ marginBottom: 2 }}>{info.productName}</h2> <h2 style={{ marginBottom: 2 }}>{info.productName}</h2>
<span className="text-muted" style={{ fontSize: 13 }}> <span className="text-muted" style={{ fontSize: 13 }}>
{CHIP_LABELS[info.chipFamily] || info.chipFamily} · 만료: {new Date(info.expiresAt).toLocaleDateString('ko-KR')} {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> </span>
</div> </div>
</div> </div>
{flashDone ? ( <StepBar phase={phase} />
<div className="alert alert-success" style={{ textAlign: 'center', padding: 24 }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🎉</div> {/* 남은 시도 횟수 경고 */}
<strong>플래시 완료!</strong> {remaining !== null && remaining < info.maxAttempts && phase !== 'success' && phase !== 'locked' && (
<p style={{ marginTop: 8, fontSize: 13 }}>펌웨어가 성공적으로 ESP32에 기록되었습니다.</p> <div style={{
<div style={{ marginTop: 16, display: 'flex', gap: 8, justifyContent: 'center' }}> 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> <Link to="/dashboard/orders" className="btn btn-outline btn-sm">구매 내역</Link>
</div> </div>
</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 className="alert alert-info" style={{ marginBottom: 20 }}> <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> <strong>플래시 확인사항</strong>
<ul style={{ marginTop: 8, paddingLeft: 16, fontSize: 13, lineHeight: 2 }}> <ul style={{ marginTop: 8, paddingLeft: 16, lineHeight: 2 }}>
<li>Chrome 또는 Edge 브라우저 사용하고 있나?</li> <li>Chrome 또는 Edge 브라우저 사용 중인가?</li>
<li>ESP32 USB 케이블 PC에 연결나요?</li> <li>ESP32 USB로 PC에 연결되어 나요?</li>
<li> 토큰은 <strong>1회만</strong> 사용 가능합니다</li> <li>HTTPS 또는 localhost 접속인가요? <span style={{ color: 'var(--warn)' }}>(Web Serial 필수)</span></li>
</ul> </ul>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: '24px 0' }}> <button className="btn btn-primary" style={{ fontSize: 15, padding: '12px 32px' }} onClick={connectAndRead}>
<esp-web-install-button 🔌 ESP32 연결 보드 정보 읽기
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> </button>
<span slot="unsupported" style={{ color: 'var(--danger)', fontSize: 14 }}> <p className="text-muted" style={{ fontSize: 12 }}>버튼 클릭 팝업에서 포트 선택</p>
Chrome 또는 Edge 브라우저가 필요합니다
</span>
</esp-web-install-button>
<p className="text-muted" style={{ fontSize: 12, textAlign: 'center' }}>
버튼 클릭 팝업에서 ESP32 포트를 선택하세요
</p>
</div> </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>
</> </>
)} )}
{/* 연결/읽기 중 */}
{(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>
</div> </div>
); );