633 lines
26 KiB
HTML
633 lines
26 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-TW">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>KL630 Event Monitor</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #10121a; color: #dde1f0; min-height: 100vh; }
|
||
|
||
/* ── Header ── */
|
||
header {
|
||
display: flex; align-items: center; gap: 12px;
|
||
padding: 12px 20px;
|
||
background: #181b27; border-bottom: 1px solid #252836;
|
||
}
|
||
.dot-live { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; flex-shrink: 0; }
|
||
header h1 { font-size: 1rem; font-weight: 600; color: #fff; }
|
||
#utc-clock { margin-left: auto; font-size: 0.78rem; color: #555d7a; font-family: monospace; }
|
||
#can-status-badge {
|
||
font-size: 0.68rem; font-weight: 700; padding: 2px 9px;
|
||
border-radius: 10px; letter-spacing: 0.04em; cursor: pointer;
|
||
border: none; background: #1e2130; color: #555d7a;
|
||
transition: background 0.2s;
|
||
}
|
||
#can-status-badge.ok { background: #14312a; color: #4ade80; }
|
||
#can-status-badge.err { background: #2a1a1a; color: #f87171; }
|
||
|
||
/* ── Layout ── */
|
||
.layout { display: grid; grid-template-columns: 1fr 1fr; height: calc(100vh - 49px); }
|
||
|
||
/* ── Panel ── */
|
||
.panel { display: flex; flex-direction: column; border-right: 1px solid #1e2130; overflow: hidden; }
|
||
.panel:last-child { border-right: none; }
|
||
.panel-head {
|
||
padding: 10px 16px; background: #181b27;
|
||
border-bottom: 1px solid #1e2130;
|
||
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
|
||
}
|
||
.ch-badge {
|
||
font-size: 0.65rem; font-weight: 800; padding: 2px 7px;
|
||
border-radius: 10px; letter-spacing: 0.06em;
|
||
}
|
||
.ch-a { background: #1a3254; color: #60a5fa; }
|
||
.ch-b { background: #14312a; color: #4ade80; }
|
||
.panel-head h2 { font-size: 0.78rem; font-weight: 600; color: #8891b0; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
|
||
/* ── Current event card ── */
|
||
#evt-card {
|
||
margin: 12px; padding: 16px;
|
||
background: #181b27; border: 1px solid #252836; border-radius: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
#evt-type { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: 10px; }
|
||
#evt-type.none { color: #3a3f55; }
|
||
|
||
/* Level timeline */
|
||
.level-track { display: flex; align-items: center; gap: 0; margin-bottom: 12px; }
|
||
.lv-step {
|
||
display: flex; flex-direction: column; align-items: center; flex: 1;
|
||
position: relative;
|
||
}
|
||
.lv-step + .lv-step::before {
|
||
content: ''; position: absolute; top: 13px; left: -50%; width: 100%;
|
||
height: 2px; background: #252836;
|
||
}
|
||
.lv-step + .lv-step.done::before { background: #4ade80; }
|
||
.lv-step + .lv-step.active::before { background: #60a5fa; }
|
||
.lv-circle {
|
||
width: 26px; height: 26px; border-radius: 50%;
|
||
border: 2px solid #252836; background: #10121a;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.7rem; font-weight: 700; color: #3a3f55;
|
||
position: relative; z-index: 1;
|
||
transition: all 0.3s;
|
||
}
|
||
.lv-label { font-size: 0.62rem; color: #3a3f55; margin-top: 4px; }
|
||
|
||
.lv-step.active .lv-circle { border-color: #60a5fa; background: #1a3254; color: #60a5fa; }
|
||
.lv-step.done .lv-circle { border-color: #4ade80; background: #14312a; color: #4ade80; }
|
||
.lv-step.warn1 .lv-circle { border-color: #facc15; background: #2a2400; color: #facc15; }
|
||
.lv-step.warn2 .lv-circle { border-color: #fb923c; background: #2a1200; color: #fb923c; }
|
||
.lv-step.warn3 .lv-circle { border-color: #f87171; background: #2a0a0a; color: #f87171; }
|
||
.lv-step.single .lv-circle { border-color: #a78bfa; background: #1e1040; color: #a78bfa; }
|
||
|
||
.lv-step.active .lv-label,
|
||
.lv-step.done .lv-label,
|
||
.lv-step.warn1 .lv-label,
|
||
.lv-step.warn2 .lv-label,
|
||
.lv-step.warn3 .lv-label,
|
||
.lv-step.single .lv-label { color: #8891b0; }
|
||
|
||
#evt-meta { font-size: 0.73rem; color: #555d7a; font-family: monospace; }
|
||
#evt-meta span { color: #4a7ab0; }
|
||
|
||
/* ── Event log ── */
|
||
.log-scroll { flex: 1; overflow-y: auto; padding: 0 12px 12px; }
|
||
.log-scroll h3 {
|
||
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
|
||
color: #3a3f55; padding: 10px 0 8px; position: sticky; top: 0;
|
||
background: #10121a;
|
||
}
|
||
.log-row {
|
||
display: flex; align-items: baseline; gap: 10px;
|
||
padding: 7px 10px; margin-bottom: 3px;
|
||
background: #181b27; border-radius: 6px;
|
||
border-left: 3px solid #252836;
|
||
font-size: 0.78rem;
|
||
}
|
||
.log-row.lv0 { border-color: #4ade80; }
|
||
.log-row.lv1 { border-color: #facc15; }
|
||
.log-row.lv2 { border-color: #fb923c; }
|
||
.log-row.lv3 { border-color: #f87171; }
|
||
.log-row.single { border-color: #a78bfa; }
|
||
.log-row.throttle { border-color: #22d3ee; }
|
||
.log-time { color: #555d7a; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
|
||
.log-type { font-weight: 600; color: #dde1f0; }
|
||
.log-sub { color: #555d7a; }
|
||
|
||
/* ── Test bar ── */
|
||
.test-bar {
|
||
border-top: 1px solid #1e2130; padding: 8px 12px;
|
||
background: #0e1018; flex-shrink: 0;
|
||
max-height: 45vh; overflow-y: auto;
|
||
}
|
||
details summary {
|
||
font-size: 0.68rem; color: #3a3f55; cursor: pointer; user-select: none;
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
list-style: none; display: flex; align-items: center; gap: 6px;
|
||
}
|
||
details summary::before { content: '▶'; font-size: 0.5rem; transition: transform 0.2s; }
|
||
details[open] summary::before { transform: rotate(90deg); }
|
||
.btn-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||
.btn {
|
||
padding: 4px 10px; border: none; border-radius: 5px;
|
||
font-size: 0.72rem; font-weight: 600; cursor: pointer; opacity: 0.85;
|
||
}
|
||
.btn:hover { opacity: 1; }
|
||
.b-l1 { background:#facc15;color:#000; }
|
||
.b-l2 { background:#fb923c;color:#000; }
|
||
.b-l3 { background:#f87171;color:#fff; }
|
||
.b-l0 { background:#4ade80;color:#000; }
|
||
.b-hz { background:#60a5fa;color:#000; }
|
||
.b-pe { background:#a78bfa;color:#fff; }
|
||
.b-up { background:#1e2130;color:#60a5fa;border:1px solid #2a3050; }
|
||
.b-can { background:#1a2b2f;color:#22d3ee;border:1px solid #164e63; }
|
||
|
||
/* ── CAN config row ── */
|
||
.can-cfg {
|
||
display: flex; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap;
|
||
}
|
||
.can-cfg label { font-size: 0.68rem; color: #555d7a; white-space: nowrap; }
|
||
.can-cfg input {
|
||
background: #1a1d29; border: 1px solid #252836; border-radius: 4px;
|
||
color: #dde1f0; font-size: 0.72rem; padding: 3px 6px; width: 80px;
|
||
}
|
||
.can-toggle {
|
||
display: flex; align-items: center; gap: 6px; margin-top: 8px;
|
||
}
|
||
.can-toggle input[type=checkbox] { accent-color: #22d3ee; width: 14px; height: 14px; cursor: pointer; }
|
||
.can-toggle label { font-size: 0.72rem; color: #8891b0; cursor: pointer; }
|
||
.can-diag {
|
||
margin-top: 8px; padding: 8px;
|
||
border: 1px solid #1e2130; border-radius: 6px;
|
||
background: #10131d; font-size: 0.68rem; color: #8ea0bf;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
line-height: 1.45;
|
||
}
|
||
.can-diag .k { color: #5f7093; }
|
||
.can-diag .v { color: #c7d5ef; }
|
||
|
||
/* ── File list (Channel B) ── */
|
||
.file-scroll { flex: 1; overflow-y: auto; padding: 12px; }
|
||
.file-count { font-size: 0.68rem; color: #3a3f55; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
|
||
.file-card {
|
||
background: #181b27; border: 1px solid #252836; border-radius: 8px;
|
||
padding: 11px 13px; margin-bottom: 7px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.file-icon { font-size: 1.3rem; flex-shrink: 0; }
|
||
.file-info { flex: 1; min-width: 0; }
|
||
.file-name {
|
||
font-size: 0.78rem; font-weight: 600; color: #b0c4e0;
|
||
font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||
}
|
||
.file-meta { font-size: 0.68rem; color: #3a3f55; margin-top: 3px; }
|
||
.file-dl {
|
||
padding: 4px 11px; background: #14312a; color: #4ade80;
|
||
border-radius: 5px; font-size: 0.72rem; font-weight: 600;
|
||
text-decoration: none; flex-shrink: 0;
|
||
}
|
||
.file-dl:hover { background: #1a4035; }
|
||
.empty { color: #2a2f45; font-size: 0.82rem; text-align: center; padding: 40px 0; }
|
||
|
||
/* ── Archive viewer ── */
|
||
.archive-contents {
|
||
display: none; margin-top: 8px; padding: 10px;
|
||
background: #10121a; border-radius: 6px; border: 1px solid #1e2130;
|
||
}
|
||
.archive-contents.open { display: block; }
|
||
.arc-json { font-size: 0.72rem; color: #8891b0; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-bottom: 8px; }
|
||
.img-grid { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
.img-thumb {
|
||
width: 110px; height: 72px; object-fit: cover;
|
||
border-radius: 4px; border: 1px solid #252836; cursor: pointer;
|
||
background: #1e2130;
|
||
}
|
||
.img-label { font-size: 0.62rem; color: #555d7a; text-align: center; margin-top: 2px; font-family: monospace; }
|
||
|
||
/* ── Lightbox ── */
|
||
#lightbox {
|
||
display: none; position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.88); z-index: 999;
|
||
align-items: center; justify-content: center; flex-direction: column; gap: 12px;
|
||
}
|
||
#lightbox.open { display: flex; }
|
||
#lb-img { max-width: 92vw; max-height: 85vh; border-radius: 6px; }
|
||
#lb-name { font-size: 0.78rem; color: #8891b0; font-family: monospace; }
|
||
#lb-close {
|
||
position: absolute; top: 16px; right: 20px;
|
||
background: none; border: none; color: #8891b0; font-size: 1.4rem;
|
||
cursor: pointer; line-height: 1;
|
||
}
|
||
#lb-close:hover { color: #fff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div class="dot-live"></div>
|
||
<h1>KL630 Golf Event Monitor</h1>
|
||
<button id="can-status-badge" onclick="toggleCanConfig()" title="CAN bus 狀態 / 設定">CAN ···</button>
|
||
<div id="utc-clock">—</div>
|
||
</header>
|
||
|
||
<div class="layout">
|
||
|
||
<!-- ── Channel A ── -->
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="ch-badge ch-a">CH A</span>
|
||
<h2>即時事件 → iPad / BLE</h2>
|
||
</div>
|
||
|
||
<div id="evt-card">
|
||
<div id="evt-type" class="none">— 無事件 —</div>
|
||
|
||
<div class="level-track">
|
||
<div class="lv-step" id="s1">
|
||
<div class="lv-circle">1</div>
|
||
<div class="lv-label">T+0s</div>
|
||
</div>
|
||
<div class="lv-step" id="s2">
|
||
<div class="lv-circle">2</div>
|
||
<div class="lv-label">T+6s</div>
|
||
</div>
|
||
<div class="lv-step" id="s3">
|
||
<div class="lv-circle">3</div>
|
||
<div class="lv-label">T+10s</div>
|
||
</div>
|
||
<div class="lv-step" id="s0">
|
||
<div class="lv-circle">✓</div>
|
||
<div class="lv-label">解除</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="evt-meta">id: <span id="m-id">—</span> | <span id="m-ts">—</span></div>
|
||
</div>
|
||
|
||
<div class="log-scroll">
|
||
<h3>事件紀錄</h3>
|
||
<div id="evt-log"></div>
|
||
</div>
|
||
|
||
<div class="test-bar">
|
||
<details open>
|
||
<summary>指令</summary>
|
||
<div class="btn-row">
|
||
<button class="btn b-l1" onclick="sendCan('road',1)">道路 Lv1</button>
|
||
<button class="btn b-l2" onclick="sendCan('road',2)">道路 Lv2</button>
|
||
<button class="btn b-l3" onclick="sendCan('road',3)">道路 Lv3</button>
|
||
<button class="btn b-l0" onclick="sendCan('road',0)">道路 解除</button>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn b-l1" onclick="sendCan('grass',1)">草地 Lv1</button>
|
||
<button class="btn b-l2" onclick="sendCan('grass',2)">草地 Lv2</button>
|
||
<button class="btn b-l3" onclick="sendCan('grass',3)">草地 Lv3</button>
|
||
<button class="btn b-l0" onclick="sendCan('grass',0)">草地 解除</button>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn b-hz" onclick="sendCan('hazard',1)">危險 Lv1</button>
|
||
<button class="btn b-hz" onclick="sendCan('hazard',2)">危險 Lv2</button>
|
||
<button class="btn b-hz" onclick="sendCan('hazard',3)">危險 Lv3</button>
|
||
<button class="btn b-l0" onclick="sendCan('hazard',0)">危險 解除</button>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn b-pe" onclick="sendCan('person',1)">行人偵測</button>
|
||
<button class="btn b-l1" onclick="sendCan('bunker',1)">沙坑</button>
|
||
<button class="btn b-l1" onclick="sendCan('pond',1)">水池</button>
|
||
<button class="btn b-l1" onclick="sendCan('tree',1)">樹木</button>
|
||
<button class="btn b-l1" onclick="sendCan('car',1)">車輛</button>
|
||
</div>
|
||
<div class="btn-row">
|
||
<button class="btn b-can" onclick="sendCanCmd(0x00)">油門 關閉</button>
|
||
<button class="btn b-can" onclick="sendCanCmd(0x20)">油門 32</button>
|
||
<button class="btn b-can" onclick="sendCanCmd(0x40)">油門 64</button>
|
||
<button class="btn b-can" onclick="sendCanCmd(0x80)">油門 128</button>
|
||
<button class="btn b-can" onclick="sendCanCmd(0xFF)">油門 全開</button>
|
||
</div>
|
||
</details>
|
||
|
||
<details open>
|
||
<summary>CAN 設定</summary>
|
||
|
||
<!-- CAN config (always shown) -->
|
||
<div id="can-cfg-row" class="can-cfg" style="display:block">
|
||
<label>Channel</label>
|
||
<input id="can-channel" value="can0" title="SocketCAN 介面名稱">
|
||
<label>Bitrate</label>
|
||
<input id="can-bitrate" value="250000" title="CAN 速率 bps">
|
||
<label>CAN ID (hex)</label>
|
||
<input id="can-id-input" value="100" title="11-bit CAN frame ID">
|
||
<button class="btn b-can" onclick="applyCanCfg()">Apply</button>
|
||
<button class="btn b-can" onclick="bringUpCan()" title="sudo ip link set canX up type can bitrate XXXXX">Bring Up</button>
|
||
<button class="btn b-can" onclick="checkCanNow()">CAN 檢查</button>
|
||
<button class="btn b-can" onclick="sendCanProbe()">測試送包</button>
|
||
</div>
|
||
|
||
<div id="can-diag" class="can-diag" style="display:block">
|
||
<div><span class="k">status</span>: <span class="v" id="diag-status">-</span></div>
|
||
<div><span class="k">rx_count</span>: <span class="v" id="diag-rx">0</span> <span class="k">tx_count</span>: <span class="v" id="diag-tx">0</span> <span class="k">tx_fail</span>: <span class="v" id="diag-txf">0</span></div>
|
||
<div><span class="k">last_rx</span>: <span class="v" id="diag-last-rx">-</span></div>
|
||
<div><span class="k">last_tx_err</span>: <span class="v" id="diag-last-tx-err">-</span></div>
|
||
</div>
|
||
|
||
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Channel B ── -->
|
||
<div class="panel">
|
||
<div class="panel-head">
|
||
<span class="ch-badge ch-b">CH B</span>
|
||
<h2>壓縮檔上傳 → 奧創雲</h2>
|
||
</div>
|
||
|
||
<div class="file-scroll">
|
||
<div class="file-count" id="file-count">尚未收到上傳</div>
|
||
<div id="file-list"></div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Lightbox -->
|
||
<div id="lightbox" onclick="closeLB()">
|
||
<button id="lb-close" onclick="closeLB()">✕</button>
|
||
<img id="lb-img" src="" alt="">
|
||
<div id="lb-name"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
const TYPE_LABEL = { grass:'草地違規', bunker:'沙坑', pond:'水池', tree:'樹木', person:'行人偵測', can_raw:'CAN 原始資料', can_tx_cmd:'CAN 控制輸出', throttle:'油門控制' };
|
||
const SINGLE = ['bunker','pond','tree','person'];
|
||
let knownCount = 0, knownFiles = [], eidCounter = Date.now();
|
||
|
||
// ── Polling ──────────────────────────────────────────────────────────────
|
||
async function poll() {
|
||
try {
|
||
const evts = await fetch('/api/events').then(r=>r.json());
|
||
if (evts.length !== knownCount) { knownCount = evts.length; render(evts); }
|
||
} catch(e){}
|
||
try {
|
||
const files = await fetch('/api/files').then(r=>r.json());
|
||
const names = files.map(f=>f.name).join();
|
||
if (names !== knownFiles.map(f=>f.name).join()) { knownFiles=files; await renderFiles(files); }
|
||
} catch(e){}
|
||
}
|
||
|
||
async function pollTime() {
|
||
try {
|
||
const t = await fetch('/api/time').then(r=>r.json());
|
||
document.getElementById('utc-clock').textContent = '台灣時間 ' + t.iso.replace('T',' ').replace('+08:00','');
|
||
} catch(e){}
|
||
}
|
||
|
||
// ── Render Channel A ────────────────────────────────────────────────────
|
||
function render(evts) {
|
||
// update status card from latest event
|
||
if (evts.length > 0) updateCard(evts[evts.length-1]);
|
||
|
||
const log = document.getElementById('evt-log');
|
||
log.innerHTML = [...evts].reverse().map(ev => {
|
||
const c = ev.content || {};
|
||
const ts = (ev.server_time||'').slice(11,19);
|
||
const lbl = TYPE_LABEL[c.type] || c.type || '?';
|
||
const isSingle = SINGLE.includes(c.type);
|
||
const isThrottle = c.type === 'throttle';
|
||
const cls = isThrottle ? 'throttle' : (isSingle ? 'single' : `lv${c.level ?? ''}`);
|
||
let sub = (c.type === 'can_raw' || c.type === 'can_tx_cmd')
|
||
? (`RAW ${c.hex || ''}`)
|
||
: isThrottle
|
||
? (c.status || `throttle=${c.level}`)
|
||
: (isSingle ? '單次紀錄' : `Level ${c.level}`);
|
||
const src = (ev.source || '').toLowerCase();
|
||
const isCan = src === 'can' || ev.channel === 'CAN';
|
||
const sourceBadge = isCan
|
||
? `<span style="font-size:0.65rem;background:#164e63;color:#22d3ee;border-radius:4px;padding:1px 5px;margin-left:4px">CAN</span>`
|
||
: `<span style="font-size:0.65rem;background:#1a3254;color:#60a5fa;border-radius:4px;padding:1px 5px;margin-left:4px">HTTP</span>`;
|
||
return `<div class="log-row ${cls}">
|
||
<span class="log-time">${ts}</span>
|
||
<span class="log-type">${lbl}</span>${sourceBadge}
|
||
<span class="log-sub">— ${sub}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function updateCard(ev) {
|
||
const c = ev.content || {};
|
||
const lbl = TYPE_LABEL[c.type] || c.type || '—';
|
||
const isSingle = SINGLE.includes(c.type);
|
||
const isThrottle = c.type === 'throttle';
|
||
const el = document.getElementById('evt-type');
|
||
el.textContent = lbl;
|
||
el.className = '';
|
||
|
||
// reset all steps
|
||
['s1','s2','s3','s0'].forEach(id => document.getElementById(id).className = 'lv-step');
|
||
|
||
if (isThrottle) {
|
||
// Throttle just shows the label, no level steps
|
||
// Could optionally show a simple indicator
|
||
} else if (isSingle) {
|
||
document.getElementById('s1').classList.add('single');
|
||
} else {
|
||
const lv = c.level ?? -1;
|
||
if (lv >= 1) document.getElementById('s1').classList.add('warn1');
|
||
if (lv >= 2) document.getElementById('s2').classList.add('warn2');
|
||
if (lv >= 3) document.getElementById('s3').classList.add('warn3');
|
||
if (lv === 0) {
|
||
document.getElementById('s0').classList.add('done');
|
||
}
|
||
}
|
||
|
||
document.getElementById('m-id').textContent = c.id || '—';
|
||
document.getElementById('m-ts').textContent = (c.date||'').replace('T',' ').replace('Z','');
|
||
}
|
||
|
||
// ── Render Channel B ────────────────────────────────────────────────────
|
||
let _prevNewest = '';
|
||
|
||
async function renderFiles(files) {
|
||
const cnt = document.getElementById('file-count');
|
||
cnt.textContent = files.length ? `${files.length} 個封存檔案` : '尚未收到上傳';
|
||
|
||
document.getElementById('file-list').innerHTML = files.map(f => `
|
||
<div class="file-card" style="flex-direction:column;align-items:stretch">
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<div class="file-icon"><3E><><EFBFBD><EFBFBD><EFBFBD><EFBFBD></div>
|
||
<div class="file-info">
|
||
<div class="file-name">${f.name}</div>
|
||
<div class="file-meta">${f.mtime} · ${(f.size/1024).toFixed(1)} KB</div>
|
||
</div>
|
||
<button class="file-dl" style="background:#1a3254;color:#60a5fa;border:none;cursor:pointer"
|
||
id="btn-${f.name.replace(/[^a-z0-9]/gi,'_')}"
|
||
onclick="toggleContents('${f.name}',this)">檢視</button>
|
||
<a class="file-dl" href="${f.url}" download>下載</a>
|
||
</div>
|
||
<div class="archive-contents" id="arc-${f.name.replace(/[^a-z0-9]/gi,'_')}"></div>
|
||
</div>`).join('');
|
||
|
||
/* Auto-expand newest file when a new upload arrives */
|
||
if (files.length > 0 && files[0].name !== _prevNewest) {
|
||
_prevNewest = files[0].name;
|
||
const id = files[0].name.replace(/[^a-z0-9]/gi,'_');
|
||
const btn = document.getElementById('btn-' + id);
|
||
if (btn) await toggleContents(files[0].name, btn);
|
||
}
|
||
}
|
||
|
||
async function toggleContents(name, btn) {
|
||
const id = name.replace(/[^a-z0-9]/gi,'_');
|
||
const el = document.getElementById('arc-' + id);
|
||
if (el.classList.contains('open')) {
|
||
el.classList.remove('open'); btn.textContent = '檢視'; return;
|
||
}
|
||
btn.textContent = '載入中…';
|
||
try {
|
||
const r = await fetch(`/api/contents/${encodeURIComponent(name)}`);
|
||
const d = await r.json();
|
||
if (!d.ok) { el.innerHTML = `<div style="color:#f87171;font-size:0.75rem">${d.error}</div>`; }
|
||
else {
|
||
const jsonFile = d.files.find(f => f.name === 'event.json');
|
||
const imgs = d.files.filter(f => f.is_image);
|
||
let html = '';
|
||
if (jsonFile) {
|
||
const jt = await fetch(`/uploads/${encodeURIComponent(name)}/event.json`).then(r=>r.text());
|
||
html += `<div class="arc-json">${jt}</div>`;
|
||
}
|
||
if (imgs.length) {
|
||
html += `<div class="img-grid">` + imgs.map(im => `
|
||
<div>
|
||
<img class="img-thumb" src="/uploads/${encodeURIComponent(name)}/${im.name}"
|
||
onclick="openLB(this.src,'${im.name}')" alt="${im.name}">
|
||
<div class="img-label">${im.name}</div>
|
||
</div>`).join('') + `</div>`;
|
||
}
|
||
el.innerHTML = html || '<div style="color:#555d7a;font-size:0.75rem">封存檔內無內容</div>';
|
||
}
|
||
} catch(e) {
|
||
el.innerHTML = `<div style="color:#f87171;font-size:0.75rem">${e}</div>`;
|
||
}
|
||
el.classList.add('open'); btn.textContent = '收起';
|
||
}
|
||
|
||
function openLB(src, name) {
|
||
document.getElementById('lb-img').src = src;
|
||
document.getElementById('lb-name').textContent = name;
|
||
document.getElementById('lightbox').classList.add('open');
|
||
}
|
||
function closeLB() { document.getElementById('lightbox').classList.remove('open'); }
|
||
|
||
// ── CAN bus helpers ───────────────────────────────────────────────────
|
||
function canId() { return parseInt(document.getElementById('can-id-input').value, 16) || 0x100; }
|
||
|
||
async function pollCanStatus() {
|
||
try {
|
||
const s = await fetch('/api/can/status').then(r=>r.json());
|
||
const badge = document.getElementById('can-status-badge');
|
||
if (s.available) {
|
||
badge.textContent = `CAN ${s.channel} ${s.bitrate/1000}k`;
|
||
badge.className = 'ok';
|
||
} else {
|
||
badge.textContent = 'CAN offline';
|
||
badge.className = 'err';
|
||
}
|
||
renderCanDiag(s);
|
||
return s;
|
||
} catch(e) {
|
||
document.getElementById('can-status-badge').className = 'err';
|
||
document.getElementById('can-status-badge').textContent = 'CAN ?';
|
||
}
|
||
}
|
||
|
||
function renderCanDiag(s) {
|
||
const diag = document.getElementById('can-diag');
|
||
if (!diag) return;
|
||
const status = s.available ? `online ${s.channel} ${s.bitrate/1000}k` : `offline (${s.last_error || 'unknown'})`;
|
||
document.getElementById('diag-status').textContent = status;
|
||
document.getElementById('diag-rx').textContent = String(s.rx_count ?? 0);
|
||
document.getElementById('diag-tx').textContent = String(s.tx_count ?? 0);
|
||
document.getElementById('diag-txf').textContent = String(s.tx_fail ?? 0);
|
||
const age = (s.last_rx_age_sec == null) ? '-' : `${s.last_rx_age_sec}s ago`;
|
||
const rxLine = s.last_rx_id ? `${s.last_rx_id} ${age} ${s.last_rx_text || ''}` : age;
|
||
document.getElementById('diag-last-rx').textContent = rxLine;
|
||
document.getElementById('diag-last-tx-err').textContent = s.last_tx_error || '-';
|
||
}
|
||
|
||
|
||
async function applyCanCfg() {
|
||
const ch = document.getElementById('can-channel').value.trim();
|
||
const br = parseInt(document.getElementById('can-bitrate').value) || 250000;
|
||
const r = await fetch('/api/can/config', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({channel: ch, bitrate: br})
|
||
}).then(r=>r.json());
|
||
await pollCanStatus();
|
||
if (!r.available) alert('CAN config: ' + (r.last_error || 'failed'));
|
||
}
|
||
|
||
async function bringUpCan() {
|
||
const ch = document.getElementById('can-channel').value.trim() || 'can0';
|
||
const br = parseInt(document.getElementById('can-bitrate').value) || 250000;
|
||
// Ask server to run the ip link command
|
||
const r = await fetch('/api/can/bringup', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({channel: ch, bitrate: br})
|
||
}).then(r=>r.json()).catch(e=>({ok:false,error:String(e)}));
|
||
if (r.ok) { await new Promise(res=>setTimeout(res, 800)); await applyCanCfg(); }
|
||
else alert('bring-up failed: ' + (r.error||'?') + '\nRun manually:\nsudo ip link set ' + ch + ' up type can bitrate ' + br);
|
||
}
|
||
|
||
async function sendCan(type, level) {
|
||
const r = await fetch('/api/can/send', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ type, level, can_id: canId() })
|
||
}).then(r=>r.json()).catch(e=>({ok:false,error:String(e)}));
|
||
const inline = document.getElementById('can-inline-status');
|
||
if (r.ok) {
|
||
if (inline) inline.textContent = `✓ ${type}:${level}`;
|
||
} else {
|
||
if (inline) inline.textContent = '✗ ' + (r.error||'error');
|
||
await pollCanStatus(); // refresh badge immediately on error
|
||
}
|
||
}
|
||
|
||
async function sendCanCmd(cmd) {
|
||
const r = await fetch('/api/can/send_cmd', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ cmd, can_id: 0x75 })
|
||
}).then(r=>r.json()).catch(e=>({ok:false,error:String(e)}));
|
||
await pollCanStatus();
|
||
}
|
||
|
||
async function sendCanProbe() {
|
||
const can_id = canId();
|
||
const r = await fetch('/api/can/send', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ type:'diag', level:9, can_id })
|
||
}).then(r=>r.json()).catch(e=>({ok:false,error:String(e)}));
|
||
await pollCanStatus();
|
||
}
|
||
|
||
async function checkCanNow() {
|
||
const can_id = canId();
|
||
const r = await fetch(`/api/can/check?can_id=0x${can_id.toString(16)}`).then(r=>r.json()).catch(e=>({available:false,last_error:String(e)}));
|
||
renderCanDiag(r);
|
||
}
|
||
|
||
// ── Test helpers ──────────────────────────────────────────────────────
|
||
function nowISO() { return new Date().toISOString().replace(/\.\d+Z$/,'Z'); }
|
||
|
||
// ── Boot ────────────────────────────────────────────────────────────
|
||
setInterval(poll, 1000);
|
||
setInterval(pollTime, 5000);
|
||
setInterval(pollCanStatus, 3000);
|
||
poll(); pollTime(); pollCanStatus();
|
||
</script>
|
||
</body>
|
||
</html>
|