first commit
This commit is contained in:
6
frontend/Dockerfile
Normal file
6
frontend/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY index.html /usr/share/nginx/html/
|
||||
COPY css/ /usr/share/nginx/html/css/
|
||||
COPY js/ /usr/share/nginx/html/js/
|
||||
EXPOSE 80
|
||||
387
frontend/css/style.css
Normal file
387
frontend/css/style.css
Normal file
@@ -0,0 +1,387 @@
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
ESP32 Web Flasher – style.css
|
||||
────────────────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--surface: #161b22;
|
||||
--surface2: #21262d;
|
||||
--border: #30363d;
|
||||
--accent: #00c896;
|
||||
--accent-dim: #00a07a;
|
||||
--danger: #f85149;
|
||||
--warn: #e3b341;
|
||||
--text: #e6edf3;
|
||||
--text-muted: #8b949e;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────── */
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 24px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo svg { width: 28px; height: 28px; }
|
||||
|
||||
.header-badge {
|
||||
font-size: 11px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────── */
|
||||
main {
|
||||
flex: 1;
|
||||
max-width: 960px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 32px 16px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 14px 20px;
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-badge.done { background: #238636; color: #fff; }
|
||||
.step-badge.locked { background: var(--border); color: var(--text-muted); }
|
||||
|
||||
.card-body { padding: 20px; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: opacity .15s, filter .15s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.btn:not(:disabled):hover { filter: brightness(1.1); }
|
||||
|
||||
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.btn-secondary { background: transparent; color: var(--text); border-color: var(--border); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-sm { padding: 5px 12px; font-size: 12px; }
|
||||
|
||||
/* ── Status indicator ────────────────────────────────────────── */
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-idle { background: var(--border); }
|
||||
.dot-ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||||
.dot-error { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
|
||||
.dot-loading { background: var(--warn); animation: pulse 1s infinite; }
|
||||
|
||||
@keyframes pulse { 0%,100%{ opacity:1 } 50%{ opacity:.3 } }
|
||||
|
||||
/* ── Forms ───────────────────────────────────────────────────── */
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||
.form-group label { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
input[type="text"],
|
||||
input[type="file"],
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── File drop zone ──────────────────────────────────────────── */
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .2s, background .2s;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.drop-zone:hover, .drop-zone.dragover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0,200,150,.05);
|
||||
}
|
||||
|
||||
.drop-zone input { display: none; }
|
||||
|
||||
.drop-zone .icon { font-size: 32px; margin-bottom: 8px; }
|
||||
|
||||
.drop-zone .file-selected {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────── */
|
||||
.progress-wrap {
|
||||
background: var(--surface2);
|
||||
border-radius: 20px;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dim));
|
||||
border-radius: 20px;
|
||||
width: 0%;
|
||||
transition: width .3s;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* ── Serial monitor ──────────────────────────────────────────── */
|
||||
#monitor {
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 12px;
|
||||
background: #0d1117;
|
||||
color: #58a6ff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
#monitor .line-ok { color: var(--accent); }
|
||||
#monitor .line-err { color: var(--danger); }
|
||||
#monitor .line-warn { color: var(--warn); }
|
||||
#monitor .line-info { color: var(--text-muted); }
|
||||
|
||||
/* ── Firmware list ───────────────────────────────────────────── */
|
||||
.fw-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.fw-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
.fw-item:hover { border-color: var(--accent); }
|
||||
.fw-item.selected { border-color: var(--accent); background: rgba(0,200,150,.08); }
|
||||
|
||||
.fw-item .fw-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fw-item .fw-info { flex: 1; overflow: hidden; }
|
||||
|
||||
.fw-item .fw-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fw-item .fw-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.fw-item .fw-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.chip-badge {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* ── Alert banner ────────────────────────────────────────────── */
|
||||
.alert {
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-info { background: rgba(56,139,253,.1); border: 1px solid rgba(56,139,253,.3); }
|
||||
.alert-warn { background: rgba(227,179,65,.1); border: 1px solid rgba(227,179,65,.3); }
|
||||
|
||||
/* ── esp-web-install-button override ────────────────────────── */
|
||||
esp-web-install-button {
|
||||
display: block;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
esp-web-install-button[active] > button {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 10px 22px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────────────────────────── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0 20px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color .15s;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.tab-panel { display: none; padding: 20px; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
355
frontend/index.html
Normal file
355
frontend/index.html
Normal file
@@ -0,0 +1,355 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ESP32 Web Flasher</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
|
||||
<!--
|
||||
esp-web-tools (Espressif 공식)
|
||||
Web Serial API를 통해 ESP32 시리얼 플래시 수행
|
||||
Chrome 89+ / Edge 89+ 필요
|
||||
-->
|
||||
<script
|
||||
type="module"
|
||||
src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js">
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
HEADER
|
||||
══════════════════════════════════════════════════════════════════ -->
|
||||
<header>
|
||||
<a class="logo" href="#">
|
||||
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="6" fill="#00c896" fill-opacity=".15"/>
|
||||
<rect x="6" y="6" width="20" height="20" rx="3" stroke="#00c896" stroke-width="2"/>
|
||||
<rect x="10" y="10" width="5" height="5" fill="#00c896"/>
|
||||
<rect x="17" y="10" width="5" height="5" fill="#00c896" fill-opacity=".5"/>
|
||||
<rect x="10" y="17" width="5" height="5" fill="#00c896" fill-opacity=".5"/>
|
||||
<rect x="17" y="17" width="5" height="5" fill="#00c896"/>
|
||||
<line x1="2" y1="12" x2="6" y2="12" stroke="#00c896" stroke-width="1.5"/>
|
||||
<line x1="2" y1="20" x2="6" y2="20" stroke="#00c896" stroke-width="1.5"/>
|
||||
<line x1="26" y1="12" x2="30" y2="12" stroke="#00c896" stroke-width="1.5"/>
|
||||
<line x1="26" y1="20" x2="30" y2="20" stroke="#00c896" stroke-width="1.5"/>
|
||||
</svg>
|
||||
ESP32 Web Flasher
|
||||
</a>
|
||||
<span class="header-badge">ESP32-S3</span>
|
||||
</header>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
MAIN
|
||||
══════════════════════════════════════════════════════════════════ -->
|
||||
<main>
|
||||
|
||||
<!-- 브라우저 경고 배너 -->
|
||||
<div id="browser-warning" class="alert alert-warn" style="display:none;">
|
||||
<span style="font-size:18px;">⚠</span>
|
||||
<div>
|
||||
<strong>지원되지 않는 브라우저</strong><br>
|
||||
Web Serial API는 <strong>Chrome 89+</strong> 또는 <strong>Edge 89+</strong>에서만 동작합니다.
|
||||
현재 브라우저에서는 시리얼 연결 및 플래시 기능을 사용할 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────────
|
||||
STEP 1: 시리얼 연결 확인
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="step-badge" id="step1-badge">1</div>
|
||||
<h2>시리얼 연결 확인</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info" style="margin-bottom:16px;">
|
||||
<span>ℹ</span>
|
||||
<div>
|
||||
ESP32S3를 USB 케이블로 PC에 연결한 후 아래 버튼을 눌러 연결 상태를 확인하세요.
|
||||
<br><small style="color:var(--text-muted);">내장 USB 포트 또는 USB-UART 변환기 모두 지원</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot dot-idle" id="conn-dot"></div>
|
||||
<span id="conn-text">연결 안 됨</span>
|
||||
<span id="device-info" style="margin-left:auto;font-size:12px;color:var(--text-muted);"></span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:10px;margin-top:14px;">
|
||||
<button id="btn-connect" class="btn btn-primary needs-serial">
|
||||
🔌 포트 연결
|
||||
</button>
|
||||
<button id="btn-disconnect" class="btn btn-secondary" style="display:none;">
|
||||
⏏ 연결 해제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────────
|
||||
STEP 2 & 3: 펌웨어 업로드 / 플래시 (탭)
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="step-badge" id="step2-badge">2</div>
|
||||
<h2>펌웨어 관리 및 플래시</h2>
|
||||
</div>
|
||||
|
||||
<!-- 탭 바 -->
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="tab-upload">📤 펌웨어 업로드</button>
|
||||
<button class="tab-btn" data-tab="tab-flash">⚡ 플래시</button>
|
||||
</div>
|
||||
|
||||
<!-- ── 탭: 펌웨어 업로드 ──────────────────────────────── -->
|
||||
<div class="tab-panel active" id="tab-upload">
|
||||
|
||||
<div class="alert alert-info" style="margin-bottom:16px;">
|
||||
<span>ℹ</span>
|
||||
<div>
|
||||
Arduino IDE <strong>Sketch → Export Compiled Binary</strong> 로 생성한 .bin 파일을 업로드하세요.
|
||||
<br>병합 바이너리(merged.bin)는 '펌웨어' 하나만 업로드해도 됩니다.
|
||||
<br><small style="color:var(--text-muted);">
|
||||
분리 파일 사용 시: bootloader(0x0000) + partition-table(0x8000) + app(0x10000)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="upload-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label>펌웨어 이름</label>
|
||||
<input id="fw-name" type="text" placeholder="예: MyProduct v1.0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>버전</label>
|
||||
<input id="fw-version" type="text" placeholder="예: 1.0.0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>설명 (선택)</label>
|
||||
<input id="fw-desc" type="text" placeholder="간단한 설명" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>칩 패밀리</label>
|
||||
<select id="fw-chip">
|
||||
<option value="ESP32-S3" selected>ESP32-S3</option>
|
||||
<option value="ESP32-S2">ESP32-S2</option>
|
||||
<option value="ESP32-C3">ESP32-C3</option>
|
||||
<option value="ESP32">ESP32</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 드롭존: 펌웨어 (필수) -->
|
||||
<div class="form-group">
|
||||
<label>펌웨어 바이너리 <span style="color:var(--danger);">*</span></label>
|
||||
<div class="drop-zone" id="drop-firmware">
|
||||
<input type="file" id="file-firmware" accept=".bin" />
|
||||
<div class="icon">📦</div>
|
||||
<div class="drop-label">
|
||||
펌웨어 .bin 파일을 드래그하거나 클릭하세요
|
||||
<div style="font-size:11px;margin-top:4px;color:var(--text-muted);">(최대 8 MB)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분리 파일 섹션 (선택) -->
|
||||
<details style="margin-bottom:14px;">
|
||||
<summary style="cursor:pointer;font-size:13px;color:var(--text-muted);margin-bottom:8px;">
|
||||
▸ 분리 바이너리 파일 추가 (선택)
|
||||
</summary>
|
||||
<div style="padding-top:10px;display:flex;flex-direction:column;gap:12px;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label>부트로더 (bootloader.bin) — offset 0x0000</label>
|
||||
<div class="drop-zone" id="drop-bootloader" style="padding:16px;">
|
||||
<input type="file" id="file-bootloader" accept=".bin" />
|
||||
<div class="drop-label">부트로더 .bin (선택사항)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label>파티션 테이블 (partition-table.bin) — offset 0x8000</label>
|
||||
<div class="drop-zone" id="drop-partitions" style="padding:16px;">
|
||||
<input type="file" id="file-partitions" accept=".bin" />
|
||||
<div class="drop-label">파티션 테이블 .bin (선택사항)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div id="upload-progress" style="display:none;margin-bottom:14px;">
|
||||
<div class="progress-wrap"><div class="progress-bar" id="upload-bar"></div></div>
|
||||
<div class="progress-label">
|
||||
<span id="upload-pct">0%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
📤 서버에 업로드
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ── 탭: 플래시 ─────────────────────────────────────── -->
|
||||
<div class="tab-panel" id="tab-flash">
|
||||
|
||||
<div class="alert alert-info" style="margin-bottom:16px;">
|
||||
<span>⚡</span>
|
||||
<div>
|
||||
업로드된 펌웨어를 선택 후 <strong>플래시 실행</strong> 버튼을 누르세요.
|
||||
<br><small style="color:var(--text-muted);">
|
||||
esp-web-tools가 시리얼 포트 선택 대화상자를 표시하고 자동으로 플래싱합니다.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 업로드된 펌웨어 목록 -->
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
|
||||
<span style="font-size:14px;font-weight:600;">업로드된 펌웨어</span>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadFirmwareList()">🔄 새로고침</button>
|
||||
</div>
|
||||
<div id="fw-list" class="fw-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 선택 상태 & 플래시 버튼 -->
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;margin-top:4px;">
|
||||
<div class="status-row" style="margin-bottom:12px;">
|
||||
<span>💾</span>
|
||||
<span id="selected-fw-info" style="font-size:14px;">선택된 펌웨어 없음 — 위 목록에서 선택하세요</span>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
esp-web-install-button 웹 컴포넌트
|
||||
manifest 속성에 백엔드 manifest URL을 동적으로 설정
|
||||
펌웨어 선택 시 selectFirmware() 함수가 manifest를 업데이트
|
||||
-->
|
||||
<esp-web-install-button id="esp-install-btn">
|
||||
<button
|
||||
slot="activate"
|
||||
class="btn btn-primary needs-serial"
|
||||
style="width:100%;justify-content:center;padding:12px;"
|
||||
>
|
||||
⚡ ESP32S3 플래시 실행
|
||||
</button>
|
||||
<span slot="unsupported" style="color:var(--danger);font-size:13px;">
|
||||
⚠ 이 브라우저는 Web Serial을 지원하지 않습니다 (Chrome/Edge 필요)
|
||||
</span>
|
||||
</esp-web-install-button>
|
||||
|
||||
<div class="alert alert-warn" style="margin-top:14px;">
|
||||
<span>⚠</span>
|
||||
<div style="font-size:12px;">
|
||||
<strong>플래시 전 확인 사항:</strong>
|
||||
<ul style="margin:4px 0 0 16px;line-height:1.8;">
|
||||
<li>ESP32S3가 USB로 연결되어 있어야 합니다</li>
|
||||
<li>다른 프로그램(Arduino IDE, esptool)이 해당 포트를 사용 중이면 안 됩니다</li>
|
||||
<li>플래시 중 USB 케이블을 분리하지 마세요</li>
|
||||
<li>일부 보드는 BOOT 버튼을 누른 상태로 연결해야 합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────────
|
||||
시리얼 모니터 / 로그
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header" style="justify-content:space-between;">
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<span>🖥</span>
|
||||
<h2>로그 / 상태</h2>
|
||||
</div>
|
||||
<button id="btn-clear-log" class="btn btn-secondary btn-sm">지우기</button>
|
||||
</div>
|
||||
<div class="card-body" style="padding:12px;">
|
||||
<pre id="monitor"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─────────────────────────────────────────────────────────────
|
||||
도움말
|
||||
──────────────────────────────────────────────────────────────── -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span>📖</span>
|
||||
<h2>사용 방법 & 플래시 파일 준비</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">Arduino IDE에서 .bin 내보내기</h3>
|
||||
<ol style="font-size:13px;line-height:2;color:var(--text-muted);padding-left:18px;">
|
||||
<li>Arduino IDE 2.x 실행</li>
|
||||
<li>ESP32S3 보드 선택 후 코드 작성</li>
|
||||
<li><strong>Sketch → Export Compiled Binary</strong></li>
|
||||
<li>스케치 폴더에 <code>.bin</code> 파일 생성됨</li>
|
||||
<li>위 업로드 탭에서 해당 파일 업로드</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">병합 바이너리(merged.bin) 생성</h3>
|
||||
<pre style="font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow:auto;color:var(--text-muted);">esptool.py --chip esp32s3 \
|
||||
merge_bin \
|
||||
-o merged.bin \
|
||||
0x0 bootloader.bin \
|
||||
0x8000 partitions.bin \
|
||||
0x10000 app.bin</pre>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-top:8px;">
|
||||
병합 후 merged.bin 하나만 업로드하면 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">Flash 암호화 (보안 강화)</h3>
|
||||
<pre style="font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow:auto;color:var(--text-muted);"># Flash Encryption 활성화
|
||||
espefuse.py --chip esp32s3 \
|
||||
burn_efuse DIS_DOWNLOAD_PLAIN_DECRYPT</pre>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-top:8px;">
|
||||
활성화 후 덤프 시 암호화된 데이터만 읽힙니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">지원 환경</h3>
|
||||
<table style="font-size:12px;width:100%;border-collapse:collapse;color:var(--text-muted);">
|
||||
<tr>
|
||||
<td style="padding:4px 0;">Chrome 89+</td>
|
||||
<td style="color:var(--accent);">✓ 지원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;">Edge 89+</td>
|
||||
<td style="color:var(--accent);">✓ 지원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;">Firefox</td>
|
||||
<td style="color:var(--danger);">✗ 미지원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;">Safari</td>
|
||||
<td style="color:var(--danger);">✗ 미지원</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:4px 0;">HTTP (non-localhost)</td>
|
||||
<td style="color:var(--danger);">✗ HTTPS 필요</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
395
frontend/js/app.js
Normal file
395
frontend/js/app.js
Normal file
@@ -0,0 +1,395 @@
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
ESP32 Web Flasher – app.js
|
||||
Web Serial API + esp-web-tools 연동
|
||||
────────────────────────────────────────────────────────────── */
|
||||
|
||||
// 백엔드 API 베이스 URL (nginx 리버스 프록시 경유)
|
||||
const API = '';
|
||||
|
||||
// ── 상태 ──────────────────────────────────────────────────────
|
||||
const state = {
|
||||
port: null, // 현재 열린 시리얼 포트
|
||||
selectedFwId: null, // 선택된 펌웨어 ID
|
||||
firmwareList: [],
|
||||
};
|
||||
|
||||
// ── DOM 헬퍼 ──────────────────────────────────────────────────
|
||||
const $ = (sel, ctx = document) => ctx.querySelector(sel);
|
||||
const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)];
|
||||
|
||||
// ── 시리얼 모니터 ─────────────────────────────────────────────
|
||||
const monitor = $('#monitor');
|
||||
|
||||
function log(text, type = '') {
|
||||
const line = document.createElement('span');
|
||||
if (type) line.className = `line-${type}`;
|
||||
line.textContent = text + '\n';
|
||||
monitor.appendChild(line);
|
||||
monitor.scrollTop = monitor.scrollHeight;
|
||||
}
|
||||
|
||||
function logClear() {
|
||||
monitor.innerHTML = '';
|
||||
}
|
||||
|
||||
// ── 브라우저 호환성 체크 ──────────────────────────────────────
|
||||
function checkBrowserCompat() {
|
||||
const supported = 'serial' in navigator;
|
||||
const banner = $('#browser-warning');
|
||||
|
||||
if (!supported) {
|
||||
banner.style.display = 'flex';
|
||||
$$('.needs-serial').forEach(el => el.disabled = true);
|
||||
log('⚠ Web Serial API 미지원 브라우저입니다. Chrome 또는 Edge를 사용하세요.', 'err');
|
||||
} else {
|
||||
log('✓ 브라우저 Web Serial API 지원 확인', 'ok');
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
|
||||
return supported;
|
||||
}
|
||||
|
||||
// ── STEP 1: 시리얼 연결 확인 ──────────────────────────────────
|
||||
const btnConnect = $('#btn-connect');
|
||||
const btnDisconnect = $('#btn-disconnect');
|
||||
const connDot = $('#conn-dot');
|
||||
const connText = $('#conn-text');
|
||||
const deviceInfo = $('#device-info');
|
||||
|
||||
function setConnStatus(status) {
|
||||
connDot.className = 'dot';
|
||||
if (status === 'ok') {
|
||||
connDot.classList.add('dot-ok');
|
||||
connText.textContent = '연결됨';
|
||||
btnConnect.style.display = 'none';
|
||||
btnDisconnect.style.display = 'inline-flex';
|
||||
} else if (status === 'loading') {
|
||||
connDot.classList.add('dot-loading');
|
||||
connText.textContent = '연결 중…';
|
||||
} else if (status === 'error') {
|
||||
connDot.classList.add('dot-error');
|
||||
connText.textContent = '연결 실패';
|
||||
btnConnect.style.display = 'inline-flex';
|
||||
btnDisconnect.style.display = 'none';
|
||||
} else {
|
||||
connDot.classList.add('dot-idle');
|
||||
connText.textContent = '연결 안 됨';
|
||||
btnConnect.style.display = 'inline-flex';
|
||||
btnDisconnect.style.display = 'none';
|
||||
deviceInfo.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
btnConnect.addEventListener('click', async () => {
|
||||
if (!('serial' in navigator)) return;
|
||||
|
||||
setConnStatus('loading');
|
||||
logClear();
|
||||
log('시리얼 포트 선택 대화상자 열기…', 'info');
|
||||
|
||||
try {
|
||||
// ESP32S3 USB VID: 0x303A (Espressif)
|
||||
// 필터 없이 모든 포트 허용 (테스트용)
|
||||
state.port = await navigator.serial.requestPort();
|
||||
await state.port.open({ baudRate: 115200 });
|
||||
|
||||
const info = state.port.getInfo();
|
||||
const vid = info.usbVendorId != null ? `0x${info.usbVendorId.toString(16).toUpperCase().padStart(4,'0')}` : 'N/A';
|
||||
const pid = info.usbProductId != null ? `0x${info.usbProductId.toString(16).toUpperCase().padStart(4,'0')}` : 'N/A';
|
||||
|
||||
const isEspressif = info.usbVendorId === 0x303A;
|
||||
|
||||
setConnStatus('ok');
|
||||
deviceInfo.textContent = `VID: ${vid} PID: ${pid} ${isEspressif ? '(Espressif ✓)' : ''}`;
|
||||
|
||||
log(`✓ 포트 연결 성공`, 'ok');
|
||||
log(` VID: ${vid} PID: ${pid}`, 'info');
|
||||
if (isEspressif) {
|
||||
log(' Espressif 장치 감지됨 (ESP32S3 가능성 높음)', 'ok');
|
||||
} else {
|
||||
log('⚠ Espressif VID가 아닙니다. USB-UART 변환기일 수 있습니다.', 'warn');
|
||||
}
|
||||
|
||||
// 연결 확인 후 포트 닫기 (esp-web-tools가 재사용할 수 있도록)
|
||||
await state.port.close();
|
||||
log(' 포트를 닫았습니다 (플래시 시 자동 재연결)', 'info');
|
||||
|
||||
updateStep1Badge('done');
|
||||
} catch (err) {
|
||||
if (err.name === 'NotFoundError') {
|
||||
log('ℹ 포트 선택이 취소되었습니다.', 'info');
|
||||
setConnStatus('idle');
|
||||
} else {
|
||||
log(`✗ 연결 실패: ${err.message}`, 'err');
|
||||
setConnStatus('error');
|
||||
}
|
||||
state.port = null;
|
||||
}
|
||||
});
|
||||
|
||||
btnDisconnect.addEventListener('click', async () => {
|
||||
try {
|
||||
if (state.port) {
|
||||
if (state.port.readable || state.port.writable) {
|
||||
await state.port.close();
|
||||
}
|
||||
state.port = null;
|
||||
}
|
||||
} catch {}
|
||||
setConnStatus('idle');
|
||||
updateStep1Badge('pending');
|
||||
log('포트 연결 해제됨', 'info');
|
||||
});
|
||||
|
||||
function updateStep1Badge(status) {
|
||||
const badge = $('#step1-badge');
|
||||
badge.className = 'step-badge' + (status === 'done' ? ' done' : '');
|
||||
badge.textContent = status === 'done' ? '✓' : '1';
|
||||
}
|
||||
|
||||
// ── STEP 2: 펌웨어 업로드 ─────────────────────────────────────
|
||||
const uploadForm = $('#upload-form');
|
||||
const progressWrap = $('#upload-progress');
|
||||
const progressBar = $('#upload-bar');
|
||||
const progressPct = $('#upload-pct');
|
||||
|
||||
// Drag & Drop 처리
|
||||
function setupDropZone(zoneId, inputId) {
|
||||
const zone = $(`#${zoneId}`);
|
||||
const input = $(`#${inputId}`);
|
||||
const label = zone.querySelector('.drop-label');
|
||||
|
||||
zone.addEventListener('click', () => input.click());
|
||||
|
||||
zone.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.add('dragover');
|
||||
});
|
||||
|
||||
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
||||
|
||||
zone.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove('dragover');
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.name.endsWith('.bin')) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
label.innerHTML = `<span class="file-selected">📎 ${file.name} (${formatBytes(file.size)})</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files[0]) {
|
||||
label.innerHTML = `<span class="file-selected">📎 ${input.files[0].name} (${formatBytes(input.files[0].size)})</span>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupDropZone('drop-firmware', 'file-firmware');
|
||||
setupDropZone('drop-bootloader', 'file-bootloader');
|
||||
setupDropZone('drop-partitions', 'file-partitions');
|
||||
|
||||
uploadForm.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const fwFile = $('#file-firmware').files[0];
|
||||
if (!fwFile) {
|
||||
alert('펌웨어(.bin) 파일을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('name', $('#fw-name').value || fwFile.name.replace('.bin',''));
|
||||
fd.append('version', $('#fw-version').value || '1.0.0');
|
||||
fd.append('description', $('#fw-desc').value);
|
||||
fd.append('chipFamily', $('#fw-chip').value);
|
||||
fd.append('flashAddress', '0x10000');
|
||||
fd.append('firmware', fwFile);
|
||||
|
||||
const blFile = $('#file-bootloader').files[0];
|
||||
if (blFile) fd.append('bootloader', blFile);
|
||||
|
||||
const ptFile = $('#file-partitions').files[0];
|
||||
if (ptFile) fd.append('partitions', ptFile);
|
||||
|
||||
progressWrap.style.display = 'block';
|
||||
progressBar.style.width = '0%';
|
||||
progressPct.textContent = '업로드 중…';
|
||||
|
||||
try {
|
||||
const resp = await fetchWithProgress(`${API}/api/firmware/upload`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
}, pct => {
|
||||
progressBar.style.width = `${pct}%`;
|
||||
progressPct.textContent = `${pct}%`;
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.error || '업로드 실패');
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
progressPct.textContent = '✓ 업로드 완료';
|
||||
progressBar.style.width = '100%';
|
||||
log(`✓ 펌웨어 업로드 완료: ${data.firmware.name} (ID: ${data.id})`, 'ok');
|
||||
|
||||
uploadForm.reset();
|
||||
$$('.drop-label').forEach(l => {
|
||||
if (!l.classList.contains('file-selected')) return;
|
||||
l.innerHTML = l.closest('[id^=drop-firmware]')
|
||||
? '📁 펌웨어 .bin 파일을 드래그하거나 클릭하세요'
|
||||
: '📁 선택 (선택사항)';
|
||||
});
|
||||
|
||||
await loadFirmwareList();
|
||||
switchTab('tab-flash');
|
||||
} catch (err) {
|
||||
progressPct.textContent = `✗ ${err.message}`;
|
||||
progressBar.style.background = 'var(--danger)';
|
||||
log(`✗ 업로드 오류: ${err.message}`, 'err');
|
||||
}
|
||||
});
|
||||
|
||||
function fetchWithProgress(url, options, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(options.method || 'GET', url);
|
||||
|
||||
xhr.upload.onprogress = e => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
const r = new Response(xhr.responseText, { status: xhr.status });
|
||||
resolve(r);
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
||||
xhr.send(options.body);
|
||||
});
|
||||
}
|
||||
|
||||
// ── STEP 3: 펌웨어 목록 & 플래시 ────────────────────────────────
|
||||
const fwListEl = $('#fw-list');
|
||||
const installBtn = $('#esp-install-btn');
|
||||
|
||||
async function loadFirmwareList() {
|
||||
try {
|
||||
const resp = await fetch(`${API}/api/firmware`);
|
||||
state.firmwareList = await resp.json();
|
||||
renderFwList();
|
||||
} catch (err) {
|
||||
log(`✗ 펌웨어 목록 로드 실패: ${err.message}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
function renderFwList() {
|
||||
if (!state.firmwareList.length) {
|
||||
fwListEl.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="icon">📦</div>
|
||||
<div>업로드된 펌웨어가 없습니다.</div>
|
||||
<div style="margin-top:6px;font-size:12px;">먼저 <strong>펌웨어 업로드</strong> 탭에서 .bin 파일을 업로드하세요.</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
fwListEl.innerHTML = '';
|
||||
state.firmwareList.forEach(fw => {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'fw-item' + (fw.id === state.selectedFwId ? ' selected' : '');
|
||||
el.dataset.id = fw.id;
|
||||
el.innerHTML = `
|
||||
<div class="fw-icon">💾</div>
|
||||
<div class="fw-info">
|
||||
<div class="fw-name">${escHtml(fw.name)}</div>
|
||||
<div class="fw-meta">
|
||||
v${escHtml(fw.version)} · ${escHtml(fw.chipFamily)} ·
|
||||
<span class="chip-badge">${fw.parts.length}개 파트</span> ·
|
||||
${new Date(fw.createdAt).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="fw-actions">
|
||||
<button class="btn btn-danger btn-sm btn-delete" data-id="${fw.id}">삭제</button>
|
||||
</div>`;
|
||||
|
||||
el.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('btn-delete')) return;
|
||||
selectFirmware(fw.id);
|
||||
});
|
||||
|
||||
el.querySelector('.btn-delete').addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
deleteFirmware(fw.id);
|
||||
});
|
||||
|
||||
fwListEl.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
function selectFirmware(id) {
|
||||
state.selectedFwId = id;
|
||||
$$('.fw-item').forEach(el => el.classList.toggle('selected', el.dataset.id === id));
|
||||
|
||||
const manifestUrl = `${location.origin}/api/firmware/${id}/manifest`;
|
||||
installBtn.setAttribute('manifest', manifestUrl);
|
||||
$('#selected-fw-info').textContent = `선택됨: ${state.firmwareList.find(f=>f.id===id)?.name}`;
|
||||
|
||||
log(`✓ 플래시 대상 선택: ${state.firmwareList.find(f=>f.id===id)?.name}`, 'ok');
|
||||
log(` Manifest: ${manifestUrl}`, 'info');
|
||||
}
|
||||
|
||||
async function deleteFirmware(id) {
|
||||
if (!confirm('이 펌웨어를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await fetch(`${API}/api/firmware/${id}`, { method: 'DELETE' });
|
||||
if (state.selectedFwId === id) {
|
||||
state.selectedFwId = null;
|
||||
installBtn.removeAttribute('manifest');
|
||||
$('#selected-fw-info').textContent = '선택된 펌웨어 없음';
|
||||
}
|
||||
await loadFirmwareList();
|
||||
log(`✓ 펌웨어 삭제 완료`, 'ok');
|
||||
} catch (err) {
|
||||
log(`✗ 삭제 실패: ${err.message}`, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 탭 전환 ───────────────────────────────────────────────────
|
||||
function switchTab(tabId) {
|
||||
$$('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId));
|
||||
$$('.tab-panel').forEach(p => p.classList.toggle('active', p.id === tabId));
|
||||
}
|
||||
|
||||
$$('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
// ── 로그 지우기 ───────────────────────────────────────────────
|
||||
$('#btn-clear-log').addEventListener('click', logClear);
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ── 초기화 ────────────────────────────────────────────────────
|
||||
(async () => {
|
||||
checkBrowserCompat();
|
||||
setConnStatus('idle');
|
||||
btnDisconnect.style.display = 'none';
|
||||
await loadFirmwareList();
|
||||
log('ESP32 Web Flasher 준비 완료', 'ok');
|
||||
})();
|
||||
27
frontend/nginx.conf
Normal file
27
frontend/nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 정적 파일 서빙
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 백엔드 API 프록시
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 펌웨어 파일 프록시
|
||||
location /firmware/ {
|
||||
proxy_pass http://backend:3000/firmware/;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user