276 lines
15 KiB
C++
276 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;display:flex;overflow-x:auto;scrollbar-width:none;-webkit-overflow-scrolling:touch}
|
|
nav::-webkit-scrollbar{display:none}
|
|
nav .nav-title{color:#43cea2;font-weight:700;font-size:1.0em;padding:10px 14px;white-space:nowrap;border-bottom:2px solid transparent}
|
|
nav a{display:inline-flex;align-items:center;padding:10px 13px;text-decoration:none;color:#8b949e;font-size:.78em;font-weight:500;border-bottom:2px solid transparent;white-space:nowrap;transition:color .2s,border-color .2s}
|
|
nav a:hover{color:#e6edf3}
|
|
nav a.active{color:#43cea2;border-bottom-color:#43cea2}
|
|
nav .nav-right{margin-left:auto;display:flex;align-items:center;padding:0 14px}
|
|
.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="nav-title">⚡ CANFD Logger</span>
|
|
<a href="/" class="active">대시보드</a>
|
|
<a href="/transmit">송신</a>
|
|
<a href="/graph">그래프</a>
|
|
<a href="/graph-view">그래프 뷰어</a>
|
|
<a href="/settings">설정</a>
|
|
<span class="nav-right">
|
|
<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 |