Files
esp32s3_CANFD_logger/index.h
2026-04-30 17:40:07 +00:00

271 lines
15 KiB
C++

#ifndef INDEX_H
#define INDEX_H_H
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE html><html lang="ko"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>CANFD Logger</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',sans-serif;background:#0d1117;color:#c9d1d9;font-size:13px}
nav{background:#161b22;border-bottom:1px solid #30363d;padding:8px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
nav a{color:#58a6ff;text-decoration:none;padding:4px 10px;border-radius:6px;font-size:12px}
nav a:hover{background:#21262d} nav a.active{background:#1f6feb;color:#fff}
.title{color:#e6edf3;font-weight:700;font-size:15px;margin-right:8px}
.container{padding:12px;display:grid;gap:10px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.grid3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:12px}
.card h3{font-size:12px;color:#8b949e;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px}
.stat{display:flex;justify-content:space-between;align-items:center;padding:4px 0;border-bottom:1px solid #21262d}
.stat:last-child{border:none} .stat-label{color:#8b949e} .stat-val{color:#e6edf3;font-weight:600}
.stat-val.ok{color:#3fb950} .stat-val.warn{color:#d29922} .stat-val.err{color:#f85149}
.badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600}
.badge-green{background:#033a16;color:#3fb950;border:1px solid #238636}
.badge-red{background:#3d0000;color:#f85149;border:1px solid #da3633}
.badge-blue{background:#051d4d;color:#58a6ff;border:1px solid #1f6feb}
.badge-orange{background:#3d2200;color:#d29922;border:1px solid #9e6a03}
.badge-purple{background:#2d1b69;color:#a371f7;border:1px solid #6e40c9}
.btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;transition:.2s}
.btn-green{background:#238636;color:#fff} .btn-green:hover{background:#2ea043}
.btn-red{background:#da3633;color:#fff} .btn-red:hover{background:#f85149}
.btn-blue{background:#1f6feb;color:#fff} .btn-blue:hover{background:#388bfd}
.btn-gray{background:#21262d;color:#c9d1d9;border:1px solid #30363d} .btn-gray:hover{background:#30363d}
.btn:disabled{opacity:.4;cursor:not-allowed}
.log-ctrl{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
select{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 8px;border-radius:6px;font-size:12px}
table{width:100%;border-collapse:collapse;font-size:11px}
th{background:#21262d;color:#8b949e;padding:6px 8px;text-align:left;font-weight:600;position:sticky;top:0}
td{padding:5px 8px;border-bottom:1px solid #21262d;font-family:monospace}
tr:hover td{background:#1c2128}
.tbl-wrap{max-height:380px;overflow-y:auto;border-radius:6px;border:1px solid #30363d}
.fd-tag{display:inline-block;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:700;margin-left:3px}
.fd-tag.fd{background:#2d1b69;color:#a371f7} .fd-tag.brs{background:#033a16;color:#3fb950}
.fd-tag.ext{background:#051d4d;color:#58a6ff}
.progress-bar{background:#21262d;border-radius:4px;height:8px;overflow:hidden}
.progress-fill{height:100%;background:#1f6feb;transition:.5s;border-radius:4px}
.progress-fill.warn{background:#d29922} .progress-fill.err{background:#f85149}
.ws-status{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
.ws-ok{background:#3fb950} .ws-err{background:#f85149}
input[type=text]{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 8px;border-radius:6px;font-size:12px;width:100%}
@media(max-width:700px){.grid2,.grid3{grid-template-columns:1fr}}
</style>
</head><body>
<nav>
<span class="title">⚡ CANFD Logger</span>
<a href="/" class="active">대시보드</a>
<a href="/transmit"></a>
<a href="/graph"></a>
<a href="/settings"></a>
<span style="margin-left:auto;display:flex;align-items:center">
<span class="ws-status ws-err" id="wsLed"></span>
<span id="wsLabel" style="font-size:11px;color:#8b949e"> ...</span>
</span>
</nav>
<div class="container">
<!-- -->
<div class="grid3">
<div class="card">
<h3>CAN FD </h3>
<div class="stat"><span class="stat-label">속도 설정</span><span class="stat-val" id="speedName">-</span></div>
<div class="stat"><span class="stat-label">수신 모드</span><span id="listenBadge"><span class="badge badge-blue">-</span></span></div>
<div class="stat"><span class="stat-label">총 수신</span><span class="stat-val" id="totalMsg">0</span></div>
<div class="stat"><span class="stat-label">Msg/s</span><span class="stat-val ok" id="msgPerSec">0</span></div>
<div class="stat"><span class="stat-label">버스 부하</span><span class="stat-val" id="busLoad">0%</span></div>
<div class="stat"><span class="stat-label">총 송신</span><span class="stat-val" id="totalTx">0</span></div>
</div>
<div class="card">
<h3> / SD</h3>
<div class="stat"><span class="stat-label">SD 카드</span><span id="sdBadge"></span></div>
<div class="stat"><span class="stat-label">로깅 상태</span><span id="logBadge"></span></div>
<div class="stat"><span class="stat-label">파일 크기</span><span class="stat-val" id="fileSize">-</span></div>
<div class="stat"><span class="stat-label">현재 파일</span><span class="stat-val" id="curFile" style="font-size:10px;word-break:break-all">-</span></div>
<div style="margin-top:8px">
<div style="display:flex;justify-content:space-between;font-size:11px;margin-bottom:3px">
<span style="color:#8b949e"></span><span id="ringLabel" style="color:#8b949e">0/4096</span>
</div>
<div class="progress-bar"><div class="progress-fill" id="ringFill" style="width:0%"></div></div>
</div>
<div class="stat" style="margin-top:8px"><span class="stat-label">Drop</span><span class="stat-val err" id="dropped">0</span></div>
</div>
<div class="card">
<h3> / </h3>
<div class="stat"><span class="stat-label">시각 동기화</span><span id="timeBadge"></span></div>
<div class="stat"><span class="stat-label">RTC</span><span id="rtcBadge"></span></div>
<div class="stat"><span class="stat-label">NTP 동기 수</span><span class="stat-val" id="syncCount">0</span></div>
<div class="stat"><span class="stat-label">RTC 동기 수</span><span class="stat-val" id="rtcSyncCount">0</span></div>
<div class="stat"><span class="stat-label">현재 시각</span><span class="stat-val" id="curTime" style="font-size:11px">-</span></div>
<div class="stat"><span class="stat-label">AP IP (항상 접속가능)</span><span class="stat-val ok" id="apIP" style="font-size:11px">-</span></div>
<div class="stat"><span class="stat-label">STA IP</span><span class="stat-val" id="staIP" style="font-size:11px;color:#8b949e">-</span></div>
<div style="margin-top:8px;display:flex;gap:6px">
<button class="btn btn-gray" onclick="syncTime()">📱 시간 동기화</button>
<button class="btn btn-gray" onclick="clearStats()">🔄 통계 초기화</button>
</div>
</div>
</div>
<!-- -->
<div class="card">
<h3> </h3>
<div class="log-ctrl">
<select id="fmtSel">
<option value="bin">BIN (/ )</option>
<option value="csv">CSV ()</option>
<option value="pcap">PCAP (Wireshark)</option>
</select>
<button class="btn btn-green" id="startBtn" onclick="startLog()">▶ 시작</button>
<button class="btn btn-red" id="stopBtn" onclick="stopLog()" disabled>■ 중지</button>
<button class="btn btn-blue" onclick="getFiles()">📂 파일 목록</button>
<button class="btn btn-gray" onclick="hwReset()">🔌 재시작</button>
</div>
<div id="logInfo" style="font-size:11px;color:#8b949e;margin-top:4px"></div>
</div>
<!-- -->
<div class="card">
<h3>CAN FD ( 30 ID)</h3>
<div class="tbl-wrap">
<table id="msgTable">
<thead><tr>
<th>CAN ID</th><th></th><th>DLC</th><th></th><th> </th><th> (hex)</th>
</tr></thead>
<tbody id="msgBody"></tbody>
</table>
</div>
</div>
<!-- -->
<div class="card" id="fileCard" style="display:none">
<h3>SD </h3>
<div class="tbl-wrap">
<table id="fileTable">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody id="fileBody"></tbody>
</table>
</div>
</div>
</div>
<script>
let ws, reconnTimer;
function fmtBytes(b){if(b<1024)return b+'B';if(b<1048576)return(b/1024).toFixed(1)+'KB';return(b/1048576).toFixed(2)+'MB';}
function fmtNum(n){return n>=1000000?(n/1000000).toFixed(2)+'M':n>=1000?(n/1000).toFixed(1)+'K':n;}
function connect(){
ws=new WebSocket('ws://'+location.hostname+':81/');
ws.onopen=()=>{
document.getElementById('wsLed').className='ws-status ws-ok';
document.getElementById('wsLabel').textContent='';
clearTimeout(reconnTimer);
};
ws.onclose=()=>{
document.getElementById('wsLed').className='ws-status ws-err';
document.getElementById('wsLabel').textContent=' ';
reconnTimer=setTimeout(connect,3000);
};
ws.onmessage=e=>{
try{ handleMsg(JSON.parse(e.data)); }catch(ex){}
};
}
function handleMsg(d){
if(d.type==='update'){
// 속도/모드
document.getElementById('speedName').textContent=d.speedName||'-';
document.getElementById('listenBadge').innerHTML=d.listenOnly?
'<span class="badge badge-orange">Listen Only</span>':
'<span class="badge badge-green">Normal</span>';
// 통계
document.getElementById('totalMsg').textContent=fmtNum(d.totalMsg||0);
document.getElementById('msgPerSec').textContent=(d.msgPerSec||0).toLocaleString();
document.getElementById('busLoad').textContent=(d.busLoad||0)+'%';
document.getElementById('totalTx').textContent=fmtNum(d.totalTx||0);
// SD
document.getElementById('sdBadge').innerHTML=d.sdReady?
'<span class="badge badge-green">준비됨</span>':'<span class="badge badge-red">없음</span>';
document.getElementById('logBadge').innerHTML=d.logging?
'<span class="badge badge-green">● 로깅 중</span>':'<span class="badge badge-red">정지</span>';
document.getElementById('fileSize').textContent=fmtBytes(d.fileSize||0);
document.getElementById('curFile').textContent=d.currentFile||'-';
document.getElementById('logInfo').textContent=d.logging?
': '+d.logFormat+' | '+fmtBytes(d.fileSize||0):'';
// 링버퍼
let used=d.ringUsed||0, size=d.ringSize||4096;
let pct=Math.round(used/size*100);
document.getElementById('ringLabel').textContent=used+'/'+size;
let fill=document.getElementById('ringFill');
fill.style.width=pct+'%';
fill.className='progress-fill'+(pct>80?' err':pct>50?' warn':'');
document.getElementById('dropped').textContent=(d.dropped||0).toLocaleString();
// 시간
document.getElementById('timeBadge').innerHTML=d.timeSync?
'<span class="badge badge-green">동기됨</span>':'<span class="badge badge-red">미동기</span>';
document.getElementById('rtcBadge').innerHTML=d.rtcAvail?
'<span class="badge badge-blue">감지됨</span>':'<span class="badge badge-red">없음</span>';
document.getElementById('syncCount').textContent=d.syncCount||0;
document.getElementById('rtcSyncCount').textContent=d.rtcSyncCnt||0;
document.getElementById('curTime').textContent=d.timestamp?new Date(d.timestamp*1000).toLocaleString('ko-KR'):'';
if(d.apIP) document.getElementById('apIP').textContent=d.apIP;
if(d.staIP) document.getElementById('staIP').textContent=d.staConnected?d.staIP:'';
// 버튼
document.getElementById('startBtn').disabled=!!d.logging;
document.getElementById('stopBtn').disabled=!d.logging;
// 메시지 테이블
if(d.messages) updateTable(d.messages);
}
else if(d.type==='files') renderFiles(d.files||[]);
}
let msgMap={};
function updateTable(msgs){
msgs.forEach(m=>{msgMap[m.id]=m;});
let ids=Object.keys(msgMap).sort((a,b)=>parseInt(a)-parseInt(b));
let html='';
ids.forEach(idKey=>{
let m=msgMap[idKey];
let idStr=m.ext?'<span class="fd-tag ext">EXT</span>0x'+(m.id>>>0).toString(16).toUpperCase().padStart(8,'0'):
'0x'+(m.id>>>0).toString(16).toUpperCase().padStart(3,'0');
let type=(m.fd?'<span class="fd-tag fd">FD</span>':'CAN')+
(m.brs?'<span class="fd-tag brs">BRS</span>':'');
let dat=(m.data||[]).map(b=>b.toString(16).toUpperCase().padStart(2,'0')).join(' ');
if(m.len>16) dat+=' ...';
html+=`<tr><td>${idStr}</td><td>${type}</td><td>${m.dlc}</td><td>${m.len}B</td><td>${(m.count||0).toLocaleString()}</td><td style="font-family:monospace;font-size:11px">${dat}</td></tr>`;
});
document.getElementById('msgBody').innerHTML=html;
}
function renderFiles(files){
let card=document.getElementById('fileCard');
card.style.display='block';
let html='';
files.sort((a,b)=>a.name>b.name?-1:1);
files.forEach(f=>{
let ext=f.name.split('.').pop().toLowerCase();
html+=`<tr>
<td><a href="/download?file=${f.name}" style="color:#58a6ff">${f.name}</a></td>
<td>${fmtBytes(f.size)}</td>
<td><input type="text" value="${f.comment||''}" placeholder="코멘트..." onchange="saveComment('${f.name}',this.value)" style="width:120px"></td>
<td><button class="btn btn-red" onclick="delFile('${f.name}')" style="padding:2px 8px;font-size:11px">삭제</button></td>
</tr>`;
});
document.getElementById('fileBody').innerHTML=html;
}
function startLog(){ws.send(JSON.stringify({cmd:'startLogging',format:document.getElementById('fmtSel').value}));}
function stopLog(){ws.send(JSON.stringify({cmd:'stopLogging'}));}
function getFiles(){ws.send(JSON.stringify({cmd:'getFiles'}));}
function hwReset(){if(confirm(' ?'))ws.send(JSON.stringify({cmd:'hwReset'}));}
function clearStats(){ws.send(JSON.stringify({cmd:'clearStats'}));}
function delFile(n){if(confirm(n+' ?'))ws.send(JSON.stringify({cmd:'deleteFile',filename:n}));}
function saveComment(n,c){ws.send(JSON.stringify({cmd:'addComment',filename:n,comment:c}));}
function syncTime(){
let now=new Date();
ws.send(JSON.stringify({cmd:'syncTime',year:now.getFullYear(),month:now.getMonth()+1,
day:now.getDate(),hour:now.getHours(),minute:now.getMinutes(),second:now.getSeconds()}));
}
connect();
</script>
</body></html>
)rawliteral";
#endif