Files
bytetrack-counter-dashboard/templates/recounting_lamborghini.html
T

739 lines
25 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} Recounting</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #000000;
--surface: #181818;
--surface2: #202020;
--gold: #FFC000;
--gold-text: #FFCE3E;
--white: #FFFFFF;
--ash: #7D7D7D;
--graphite: #494949;
--divider: #333333;
--success: #00ff88;
}
body {
font-family: 'Helvetica Neue', 'Arial', sans-serif;
background: var(--bg);
color: var(--white);
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 1600px; margin: 0 auto; padding: 32px 40px;
}
/* Header */
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 0; margin-bottom: 40px;
border-bottom: 1px solid var(--divider);
}
.header-left { display: flex; align-items: center; gap: 16px; }
.logo-icon {
width: 40px; height: 40px;
background: var(--gold); color: #000;
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 700;
}
.header h1 {
font-size: 16px; font-weight: 400; letter-spacing: 4px;
text-transform: uppercase; color: var(--white);
}
.header-sub { font-size: 9px; color: var(--ash); letter-spacing: 3px; text-transform: uppercase; }
.header-right { display: flex; align-items: center; gap: 24px; }
.live-chip {
display: flex; align-items: center; gap: 8px;
font-size: 9px; letter-spacing: 3px; padding: 6px 14px;
text-transform: uppercase; color: var(--white);
border: 1px solid rgba(255,255,255,0.2);
}
.live-chip.live-chip-green { border-color: var(--success); color: var(--success); }
.live-chip .dot {
width: 6px; height: 6px;
}
.live-chip .dot { background: var(--white); }
.live-chip.live-chip-green .dot { background: var(--success); }
.section-title {
font-size: 11px; font-weight: 400; letter-spacing: 4px;
text-transform: uppercase; color: var(--ash);
margin-bottom: 20px; display: inline-block;
}
/* Counter panels */
.counters-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.panel {
background: var(--surface);
padding: 32px;
}
.counter-card {
text-align: center;
padding: 20px 0;
}
.counter-card .counter-val {
font-size: 56px; font-weight: 300;
color: var(--white);
line-height: 1;
margin-bottom: 8px;
}
.counter-card.recount .counter-val { color: var(--white); }
.counter-card .counter-label {
font-size: 10px; color: var(--ash); text-transform: uppercase; letter-spacing: 3px;
}
.counter-card .counter-sub {
font-size: 9px; color: var(--graphite); margin-top: 8px;
}
.counter-card .counter-sub .batch-num {
font-size: 18px; font-weight: 600; color: var(--gold-text);
}
.counter-status {
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
margin-top: 12px; padding: 4px 12px; display: inline-block;
}
.counter-status.connected { color: var(--success); }
.counter-status.disconnected { color: var(--ash); }
/* Bottom row: video + file browser */
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
margin-bottom: 40px;
background: var(--divider);
}
.video-panel {
background: var(--surface);
padding: 24px;
display: flex; flex-direction: column;
min-height: 380px;
}
.video-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px;
}
.video-container {
flex: 1; display: flex; align-items: center; justify-content: center;
background: #000;
overflow: hidden; position: relative;
aspect-ratio: 4 / 3; max-height: 480px;
}
.video-container img {
width: 100%; height: 100%; object-fit: contain; display: block;
}
.video-placeholder {
text-align: center; color: var(--graphite); padding: 40px;
}
.video-placeholder .vp-icon { font-size: 40px; opacity: 0.2; margin-bottom: 12px; }
.video-placeholder .vp-text { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; }
.video-placeholder .vp-hint { font-size: 8px; margin-top: 8px; color: var(--graphite); }
.video-status {
font-size: 8px; letter-spacing: 2px; text-transform: uppercase;
display: flex; align-items: center; gap: 8px;
}
.video-status.connected { color: var(--success); }
.video-status.stopped { color: var(--ash); }
.video-toggle {
background: none; border: 1px solid rgba(255,255,255,0.3);
padding: 5px 14px; cursor: pointer;
font-size: 9px; letter-spacing: 2px; text-transform: uppercase;
color: var(--ash); transition: border-color 0.3s, color 0.3s;
}
.video-toggle:hover { border-color: var(--white); color: var(--white); }
.video-toggle.active { border-color: var(--success); color: var(--success); }
/* File browser panel */
.file-panel {
background: var(--surface);
padding: 24px;
display: flex; flex-direction: column;
}
.file-panel .fp-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px;
}
.file-panel .fp-dir {
font-size: 8px; color: var(--ash); letter-spacing: 1px;
word-break: break-all; margin-bottom: 16px;
padding: 8px 12px; background: var(--surface2);
border: 1px solid var(--divider);
}
.file-list {
flex: 1; overflow-y: auto; max-height: 400px;
}
.file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; margin-bottom: 2px;
background: var(--surface2);
border: 1px solid var(--divider);
cursor: pointer; transition: border-color 0.2s, background 0.2s;
}
.file-item:hover { border-color: var(--white); }
.file-item.selected {
border-color: var(--gold);
background: rgba(255,192,0,0.06);
}
.file-item.streaming {
border-color: var(--success);
background: rgba(0,255,136,0.06);
}
.file-item .fi-name {
font-size: 10px; color: var(--white);
word-break: break-all; flex: 1;
}
.file-item .fi-size {
font-size: 8px; color: var(--graphite);
margin-left: 8px; white-space: nowrap;
}
.file-item .fi-icon {
font-size: 12px; margin-right: 8px;
color: var(--ash);
}
.file-item.streaming .fi-icon { color: var(--success); }
.folder-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; margin-bottom: 2px;
background: var(--surface2);
border: 1px solid var(--divider);
cursor: pointer; transition: border-color 0.2s;
font-size: 10px; letter-spacing: 2px;
color: var(--gold-text); text-transform: uppercase;
}
.folder-header:hover { border-color: var(--white); }
.folder-header .folder-arrow {
font-size: 10px; transition: transform 0.2s; width: 14px; text-align: center;
}
.folder-header.open .folder-arrow { transform: rotate(90deg); }
.folder-body { display: none; padding-left: 8px; }
.folder-body.open { display: block; }
/* Buttons */
.btn {
font-size: 9px; letter-spacing: 3px; text-transform: uppercase;
padding: 10px 20px; cursor: pointer; font-weight: 600; border: none;
transition: all 0.3s;
}
.btn-primary {
background: var(--gold); color: #000;
}
.btn-primary:hover { opacity: 0.85; }
.btn-danger {
background: var(--white); color: #000;
}
.btn-danger:hover { opacity: 0.85; }
.btn-ghost {
background: none; color: var(--ash);
border: 1px solid rgba(255,255,255,0.2);
}
.btn-ghost:hover { border-color: var(--white); color: var(--white); }
.btn:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 16px; }
/* Footer */
.footer {
margin-top: 40px; text-align: center;
font-size: 8px; letter-spacing: 4px; color: var(--graphite); text-transform: uppercase;
}
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--graphite); }
@media (max-width: 900px) {
.counters-row, .bottom-row { grid-template-columns: 1fr; }
.container { padding: 20px 12px; }
.header { flex-direction: column; gap: 12px; }
.counter-card .counter-val { font-size: 40px; }
}
</style>
</head>
<body>
<div class="container">
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="logo-icon">&#10006;</div>
<div>
<h1>{{ site_name }} Recounting</h1>
<div class="header-sub">Edge counter batch replay</div>
</div>
</div>
<div class="header-right">
<div class="live-chip live-chip-green">
<span class="dot"></span>
<span>LIVE</span>
</div>
<div class="live-chip">
<span class="dot"></span>
<span>RECOUNT</span>
</div>
</div>
</div>
<!-- Counter panels -->
<div class="counters-row">
<div class="panel">
<span class="section-title" id="live-title">Live Counter</span>
<div class="counter-card">
<div class="counter-val" id="live-count">--</div>
<div class="counter-label" id="live-label">Objects Counted</div>
<div class="counter-sub">Batch <span class="batch-num" id="live-batch">--</span> &middot; <span id="live-time">--</span></div>
<div class="counter-status disconnected" id="live-status">
no file selected
</div>
</div>
</div>
<div class="panel">
<span class="section-title">Recount Progress</span>
<div class="counter-card recount">
<div class="counter-val" id="recount-count">--</div>
<div class="counter-label">Objects Recounted</div>
<div class="counter-sub"><span id="recount-time">--</span></div>
<div class="counter-status disconnected" id="recount-status">
waiting...
</div>
</div>
</div>
</div>
<!-- Bottom: video + file list -->
<div class="bottom-row">
<div class="video-panel">
<div class="video-header">
<span class="section-title" style="margin-bottom:0;">Preview</span>
<div style="display:flex;align-items:center;gap:10px;">
<span class="video-status stopped" id="video-status">
<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span>
stopped
</span>
<button class="video-toggle" id="videoToggle" onclick="togglePreview()">STREAM OFF</button>
</div>
</div>
<div class="video-container" id="video-container">
<div class="video-placeholder" id="video-placeholder">
<div class="vp-icon">&#9654;</div>
<div class="vp-text">Select an MP4 file to stream</div>
<div class="vp-hint">Requires go2rtc on this host</div>
</div>
<img id="stream-img" alt="Stream" style="display:none;" />
</div>
</div>
<div class="file-panel">
<div class="fp-header">
<span class="section-title" style="margin-bottom:0;font-size:11px;">Recordings</span>
<button class="btn btn-ghost" onclick="loadFiles()" style="font-size:8px;padding:4px 8px;">
<i class="fas fa-sync-alt"></i>
</button>
</div>
<div class="fp-dir" id="output-dir">{{ output_dir }}</div>
<div class="file-list" id="file-list">
<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;">
Loading files...
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="btn-start" onclick="startRecount()" disabled>
&#9654; START RECOUNT
</button>
<button class="btn btn-danger" id="btn-stop" onclick="stopRecount()" disabled>
&#9632; STOP
</button>
<button class="btn btn-ghost" id="btn-download" onclick="downloadFile()" disabled>
<i class="fas fa-download"></i>
</button>
</div>
</div>
</div>
<div class="footer">{{ site_name }} Recounting Dashboard</div>
</div>
<script>
let selectedFile = null;
let streaming = false;
let streamUrl = '';
document.addEventListener('DOMContentLoaded', () => {
loadRecountProgress();
loadFiles();
syncState();
setInterval(loadRecountProgress, 500);
setInterval(loadFiles, 30000);
setInterval(syncState, 1000);
});
async function syncState() {
try {
const res = await fetch('/api/state');
const data = await res.json();
applyState(data);
} catch (err) {}
}
function applyState(data) {
if (!data) return;
if (data.streaming && data.file) {
streaming = true;
streamUrl = data.stream_url;
selectedFile = data.file;
document.getElementById('btn-stop').disabled = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = false;
document.getElementById('btn-start').textContent = 'STREAMING';
document.getElementById('btn-start').classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + (data.file_name || '');
document.getElementById('video-status').className = 'video-status connected';
enablePreview();
loadBatchResult(data.file);
} else if (data.file && selectedFile === data.file) {
loadBatchResult(data.file);
}
}
// --- File list ---
async function loadFiles() {
const container = document.getElementById('file-list');
try {
const res = await fetch('/api/mp4-files');
const folders = await res.json();
if (!folders || folders.length === 0) {
container.innerHTML = '<div style="font-size:10px;color:var(--graphite);text-align:center;padding:20px;text-transform:uppercase;letter-spacing:1px;">No MP4 files found</div>';
return;
}
container.innerHTML = folders.map((g, gi) => {
const label = g.folder || 'root';
const fid = 'folder-' + gi;
const wasOpen = document.getElementById(fid) && document.getElementById(fid).classList.contains('open');
return `
<div class="folder-header" id="${fid}-hdr" onclick="toggleFolder('${fid}')">
<span class="folder-arrow">&#9654;</span>
<i class="fas fa-folder" style="color:var(--gold-text);"></i>
<span>${label}</span>
<span style="font-size:8px;color:var(--graphite);">(${g.files.length})</span>
</div>
<div class="folder-body${wasOpen ? ' open' : ''}" id="${fid}">
${g.files.map(f => {
const sel = selectedFile === f.path;
const str = streaming && selectedFile === f.path;
let cls = 'file-item';
if (str) cls += ' streaming';
else if (sel) cls += ' selected';
return `
<div class="${cls}" onclick="event.stopPropagation(); selectFile('${f.path.replace(/'/g, "\\'")}', '${f.name.replace(/'/g, "\\'")}')" data-path="${f.path.replace(/"/g, '&quot;')}">
<span class="fi-icon">${str ? '&#9673;' : '&#9654;'}</span>
<span class="fi-name">${f.name}</span>
<span class="fi-size">${formatSize(f.size)}</span>
</div>`;
}).join('')}
</div>`;
}).join('');
} catch (err) {
console.error('Failed to load files:', err);
}
}
function toggleFolder(fid) {
const hdr = document.getElementById(fid + '-hdr');
const body = document.getElementById(fid);
if (hdr) hdr.classList.toggle('open');
if (body) body.classList.toggle('open');
}
function selectFile(path, name) {
if (selectedFile === path) {
selectedFile = null;
batchResultLoaded = false;
document.getElementById('btn-start').disabled = true;
document.getElementById('btn-download').disabled = true;
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
updateFileSelection();
return;
}
selectedFile = path;
document.getElementById('btn-download').disabled = false;
if (!streaming) document.getElementById('btn-start').disabled = false;
updateFileSelection();
loadBatchResult(path);
}
function updateFileSelection() {
document.querySelectorAll('.file-item').forEach(el => {
const path = el.getAttribute('data-path');
if (path === selectedFile) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
let batchResultLoaded = false;
async function loadBatchResult(filePath) {
if (!filePath) {
batchResultLoaded = false;
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
return;
}
try {
const res = await fetch('/api/batch-result?file=' + encodeURIComponent(filePath));
const data = await res.json();
if (!data.success) {
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = data.error || 'not found';
document.getElementById('live-status').textContent = 'not found';
document.getElementById('live-status').className = 'counter-status disconnected';
batchResultLoaded = false;
return;
}
batchResultLoaded = true;
document.getElementById('live-title').innerHTML = 'Batch Result';
document.getElementById('live-count').textContent = data.count.toLocaleString();
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = data.batch_number;
document.getElementById('live-time').textContent = data.date;
var startStr = data.start_time ? new Date(data.start_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
var endStr = data.end_time ? new Date(data.end_time).toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit'}) : '--';
document.getElementById('live-status').textContent = 'recorded \u00b7 ' + startStr + ' \u2013 ' + endStr;
document.getElementById('live-status').className = 'counter-status connected';
} catch (err) {
console.error('Failed to load batch result:', err);
batchResultLoaded = false;
}
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
function downloadFile() {
if (!selectedFile) return;
var a = document.createElement('a');
a.href = '/api/download-mp4?file=' + encodeURIComponent(selectedFile);
a.download = selectedFile.split('/').pop();
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// --- Streaming ---
async function startRecount() {
if (!selectedFile) return;
const btnStart = document.getElementById('btn-start');
btnStart.disabled = true;
btnStart.textContent = 'CONNECTING...';
lastRecountCount = null;
lastRecountTime = null;
document.getElementById('recount-count').textContent = '0';
document.getElementById('recount-time').textContent = 'resetting...';
document.getElementById('recount-status').textContent = 'resetting';
document.getElementById('recount-status').className = 'counter-status disconnected';
try {
const res = await fetch('/api/start-recount', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: selectedFile })
});
const data = await res.json();
if (!data.success) {
alert('Failed: ' + (data.error || 'unknown'));
btnStart.disabled = false;
btnStart.textContent = '\u25b6 START RECOUNT';
return;
}
streamUrl = data.stream_url;
streaming = true;
selectedFile = data.path;
document.getElementById('btn-stop').disabled = false;
btnStart.textContent = 'STREAMING';
btnStart.classList.add('btn-ghost');
document.getElementById('video-status').innerHTML =
'<span style="width:5px;height:5px;background:var(--success);display:inline-block;"></span> streaming ' + data.file;
document.getElementById('video-status').className = 'video-status connected';
enablePreview();
} catch (err) {
alert('Error: ' + err.message);
btnStart.disabled = false;
btnStart.textContent = '\u25b6 START RECOUNT';
}
}
async function stopRecount() {
try {
await fetch('/api/stop-recount', { method: 'POST' });
} catch (err) {}
streaming = false;
streamUrl = '';
selectedFile = null;
batchResultLoaded = false;
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
const btnStart = document.getElementById('btn-start');
const btnStop = document.getElementById('btn-stop');
btnStart.disabled = true;
btnStart.textContent = '\u25b6 START RECOUNT';
btnStart.classList.remove('btn-ghost');
btnStop.disabled = true;
document.getElementById('video-status').innerHTML =
'<span style="width:5px;height:5px;background:var(--ash);display:inline-block;"></span> stopped';
document.getElementById('video-status').className = 'video-status stopped';
document.getElementById('live-title').innerHTML = 'Live Counter';
document.getElementById('live-count').textContent = '--';
document.getElementById('live-label').textContent = 'Objects Counted';
document.getElementById('live-batch').textContent = '--';
document.getElementById('live-time').textContent = '--';
document.getElementById('live-status').textContent = 'no file selected';
document.getElementById('live-status').className = 'counter-status disconnected';
resetVideoToggle();
loadFiles();
}
function togglePreview() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
if (img.style.display === 'block') {
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
} else {
enablePreview();
}
}
function resetVideoToggle() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
btn.textContent = 'STREAM OFF';
btn.classList.remove('active');
}
function enablePreview() {
const btn = document.getElementById('videoToggle');
const img = document.getElementById('stream-img');
const placeholder = document.getElementById('video-placeholder');
if (!streamUrl) return;
img.onerror = function() {
if (streaming && streamUrl) {
img.src = streamUrl + '?' + Date.now();
}
};
img.src = streamUrl + '?' + Date.now();
img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none';
btn.textContent = 'STREAM ON';
btn.classList.add('active');
}
// --- Recount counter ---
let lastRecountCount = null;
let lastRecountTime = null;
async function loadRecountProgress() {
const countEl = document.getElementById('recount-count');
const timeEl = document.getElementById('recount-time');
const statusEl = document.getElementById('recount-status');
try {
const res = await fetch('/api/recount-progress');
const data = await res.json();
if (!data.success) {
if (lastRecountCount === null) {
countEl.textContent = '--';
timeEl.textContent = data.error || 'unreachable';
}
statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'completed';
return;
}
lastRecountCount = data.count.toLocaleString();
countEl.textContent = lastRecountCount;
if (data.last_detection_time) {
const d = new Date(data.last_detection_time);
lastRecountTime = d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
timeEl.textContent = lastRecountTime;
} else if (lastRecountTime) {
timeEl.textContent = lastRecountTime;
} else {
timeEl.textContent = 'waiting...';
}
statusEl.className = 'counter-status connected';
statusEl.textContent = 'connected';
} catch (err) {
if (lastRecountCount === null) {
countEl.textContent = '--';
timeEl.textContent = 'unreachable';
}
statusEl.className = 'counter-status disconnected';
statusEl.textContent = 'completed';
}
}
</script>
</body>
</html>