1196 lines
44 KiB
HTML
1196 lines
44 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ZenAI KPC Dashboard</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Orbitron:wght@400;500;700;900&display=swap');
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
:root {
|
|
--bg-deep: #06080e;
|
|
--bg-surface: rgba(12, 16, 28, 0.9);
|
|
--border-glow: rgba(0, 240, 255, 0.12);
|
|
--accent: #00f0ff;
|
|
--accent2: #7b2fff;
|
|
--accent3: #ff2d78;
|
|
--accent4: #00ff88;
|
|
--text-primary: #dce2f0;
|
|
--text-secondary: #6b7394;
|
|
--glass: rgba(12, 16, 32, 0.7);
|
|
--cell-bg: rgba(255, 255, 255, 0.02);
|
|
--cell-border: rgba(255, 255, 255, 0.05);
|
|
--card-bg: rgba(255, 255, 255, 0.025);
|
|
--ring-bg: rgba(255, 255, 255, 0.04);
|
|
--divider: rgba(255, 255, 255, 0.05);
|
|
--input-bg: rgba(0, 0, 0, 0.4);
|
|
--radius: 18px;
|
|
}
|
|
|
|
[data-theme="light"] {
|
|
--bg-deep: #eef1f5;
|
|
--bg-surface: rgba(255, 255, 255, 0.9);
|
|
--border-glow: rgba(0, 160, 200, 0.15);
|
|
--accent: #0090c0;
|
|
--accent2: #6a20e0;
|
|
--accent3: #d02060;
|
|
--accent4: #00a060;
|
|
--text-primary: #1a1d30;
|
|
--text-secondary: #5a6088;
|
|
--glass: rgba(255, 255, 255, 0.8);
|
|
--cell-bg: rgba(0, 0, 0, 0.03);
|
|
--cell-border: rgba(0, 0, 0, 0.06);
|
|
--card-bg: rgba(0, 0, 0, 0.03);
|
|
--ring-bg: rgba(0, 0, 0, 0.06);
|
|
--divider: rgba(0, 0, 0, 0.06);
|
|
--input-bg: rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
body {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
background: var(--bg-deep);
|
|
color: var(--text-primary);
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
/* Animated grid background */
|
|
.bg-grid {
|
|
position: fixed; inset: 0; z-index: 0;
|
|
background-image:
|
|
linear-gradient(rgba(0, 240, 255, 0.025) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(0, 240, 255, 0.025) 1px, transparent 1px);
|
|
background-size: 64px 64px;
|
|
animation: gridScroll 20s linear infinite;
|
|
pointer-events: none;
|
|
}
|
|
@keyframes gridScroll {
|
|
0% { background-position: 0 0; }
|
|
100% { background-position: 64px 64px; }
|
|
}
|
|
|
|
/* Floating orbs */
|
|
.orb {
|
|
position: fixed; border-radius: 50%; filter: blur(140px); pointer-events: none;
|
|
animation: orbFloat 18s ease-in-out infinite;
|
|
}
|
|
.orb-1 { width: 600px; height: 600px; background: rgba(0, 240, 255, 0.04); top: -250px; left: -150px; }
|
|
.orb-2 { width: 500px; height: 500px; background: rgba(123, 47, 255, 0.04); bottom: -200px; right: -100px; animation-delay: -6s; }
|
|
.orb-3 { width: 400px; height: 400px; background: rgba(255, 45, 120, 0.03); top: 40%; left: 40%; animation-delay: -12s; }
|
|
@keyframes orbFloat {
|
|
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
33% { transform: translate(50px, -40px) scale(1.06); }
|
|
66% { transform: translate(-30px, 30px) scale(0.94); }
|
|
}
|
|
|
|
.container {
|
|
position: relative; z-index: 1;
|
|
max-width: 1600px; margin: 0 auto; padding: 24px 20px;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 18px 28px; margin-bottom: 24px;
|
|
background: var(--glass);
|
|
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
|
}
|
|
.header-left { display: flex; align-items: center; gap: 14px; }
|
|
.logo-icon {
|
|
width: 44px; height: 44px;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
border-radius: 11px; font-size: 22px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
box-shadow: 0 0 28px rgba(0, 240, 255, 0.35);
|
|
animation: logoPulse 3s ease-in-out infinite;
|
|
}
|
|
@keyframes logoPulse {
|
|
0%, 100% { box-shadow: 0 0 22px rgba(0, 240, 255, 0.3); }
|
|
50% { box-shadow: 0 0 44px rgba(0, 240, 255, 0.6); }
|
|
}
|
|
.header h1 {
|
|
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700; letter-spacing: 3px;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
|
}
|
|
.header-sub { font-size: 10px; color: var(--text-secondary); letter-spacing: 2px; text-transform: uppercase; }
|
|
.header-right { display: flex; align-items: center; gap: 18px; }
|
|
.header-date {
|
|
font-size: 10px; color: var(--text-secondary); letter-spacing: 1px;
|
|
text-align: right;
|
|
}
|
|
.header-date .date-val {
|
|
font-family: 'Orbitron', sans-serif; font-size: 14px; color: var(--accent); display: block;
|
|
}
|
|
.live-chip {
|
|
display: flex; align-items: center; gap: 8px;
|
|
font-size: 9px; letter-spacing: 1.5px; padding: 5px 12px; border-radius: 12px;
|
|
font-weight: 600; text-transform: uppercase;
|
|
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
|
|
border: 1px solid rgba(0, 255, 136, 0.2);
|
|
}
|
|
.live-chip .dot {
|
|
width: 7px; height: 7px; border-radius: 50%;
|
|
background: var(--accent4); box-shadow: 0 0 8px var(--accent4);
|
|
animation: blink 1.5s ease-in-out infinite;
|
|
}
|
|
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
|
|
|
|
.theme-toggle {
|
|
background: none; border: 1px solid var(--cell-border);
|
|
border-radius: 8px; padding: 6px 10px; cursor: pointer;
|
|
font-size: 16px; line-height: 1;
|
|
transition: border-color 0.3s, background 0.3s;
|
|
}
|
|
.theme-toggle:hover { border-color: var(--accent); background: var(--cell-bg); }
|
|
|
|
.nav-link {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: none; border: 1px solid var(--cell-border);
|
|
border-radius: 8px; padding: 7px 12px; cursor: pointer;
|
|
font-family: 'JetBrains Mono', monospace; font-size: 10px; letter-spacing: 1px;
|
|
color: var(--text-secondary); text-decoration: none;
|
|
transition: border-color 0.3s, color 0.3s, background 0.3s;
|
|
}
|
|
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
|
|
.nav-link.active { border-color: var(--accent); color: var(--accent); }
|
|
|
|
/* Section title */
|
|
.section-title {
|
|
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 2px;
|
|
margin-bottom: 16px;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
|
display: inline-block;
|
|
}
|
|
|
|
/* Summary Cards */
|
|
.summary-cards {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.s-card {
|
|
background: var(--glass);
|
|
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
|
padding: 20px 22px;
|
|
transition: border-color 0.3s, transform 0.2s;
|
|
position: relative; overflow: hidden;
|
|
}
|
|
.s-card:hover { border-color: rgba(0, 240, 255, 0.3); transform: translateY(-2px); }
|
|
.s-card .s-icon {
|
|
width: 42px; height: 42px; border-radius: 12px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 18px; margin-bottom: 14px;
|
|
}
|
|
.s-card .s-icon.live { background: linear-gradient(135deg, var(--accent4), #059669); color: #fff; }
|
|
.s-card .s-icon.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; }
|
|
.s-card .s-icon.warm { background: linear-gradient(135deg, var(--accent2), var(--accent3)); color: #fff; }
|
|
.s-card .s-icon.success { background: linear-gradient(135deg, var(--accent), var(--accent4)); color: #000; }
|
|
.s-card .s-chip {
|
|
font-size: 8px; text-transform: uppercase; letter-spacing: 2px; padding: 3px 10px;
|
|
border-radius: 10px; font-weight: 600;
|
|
position: absolute; top: 16px; right: 16px;
|
|
}
|
|
.s-card .s-chip.live-chip-tag {
|
|
background: rgba(0, 255, 136, 0.1); color: var(--accent4);
|
|
border: 1px solid rgba(0, 255, 136, 0.2);
|
|
}
|
|
.s-card .s-chip.live-chip-tag .ldot {
|
|
width: 6px; height: 6px; border-radius: 50%; display: inline-block;
|
|
background: var(--accent4); margin-right: 4px;
|
|
animation: blink 1.5s ease-in-out infinite;
|
|
}
|
|
.s-card .s-chip.info {
|
|
background: rgba(0, 240, 255, 0.08); color: var(--accent);
|
|
border: 1px solid rgba(0, 240, 255, 0.15);
|
|
}
|
|
.s-card .s-val {
|
|
font-family: 'Orbitron', sans-serif; font-size: 32px; font-weight: 700;
|
|
background: linear-gradient(180deg, #fff 0%, var(--accent) 100%);
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
|
margin-bottom: 4px;
|
|
}
|
|
.s-card .s-label { font-size: 11px; color: var(--text-secondary); }
|
|
.s-card .s-sub { font-size: 9px; color: var(--text-secondary); margin-top: 4px; }
|
|
|
|
/* In/Out sub cards inside a summary card */
|
|
.sub-cards {
|
|
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
|
|
margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--divider);
|
|
}
|
|
.sub-card {
|
|
background: var(--cell-bg);
|
|
border: 1px solid var(--cell-border); border-radius: 12px;
|
|
padding: 12px 14px;
|
|
}
|
|
.sub-card .sc-head {
|
|
display: flex; align-items: center; gap: 6px;
|
|
font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
|
|
color: var(--text-secondary); margin-bottom: 6px;
|
|
}
|
|
.sub-card .sc-head i { font-size: 11px; }
|
|
.sub-card.in .sc-head i { color: var(--accent4); }
|
|
.sub-card.out .sc-head i { color: var(--accent3); }
|
|
.sub-card .sc-val {
|
|
font-family: 'Orbitron', sans-serif; font-size: 20px; font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.hide-me { display: none; }
|
|
|
|
/* Video Feed Panel */
|
|
.video-panel {
|
|
background: var(--glass);
|
|
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
|
padding: 12px; margin-bottom: 24px;
|
|
position: relative; overflow: hidden;
|
|
min-height: 300px;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
.video-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 10px; padding: 0 12px;
|
|
}
|
|
.video-toggle {
|
|
background: none; border: 1px solid var(--cell-border);
|
|
border-radius: 8px; padding: 6px 14px; cursor: pointer;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 10px; letter-spacing: 1px;
|
|
color: var(--text-secondary);
|
|
transition: border-color 0.3s, color 0.3s;
|
|
}
|
|
.video-toggle:hover { border-color: var(--accent); color: var(--text-primary); }
|
|
.video-toggle.active { border-color: var(--accent4); color: var(--accent4); }
|
|
.video-container {
|
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
|
background: #000; border-radius: 10px;
|
|
overflow: hidden; position: relative;
|
|
min-height: 280px;
|
|
}
|
|
.video-container img {
|
|
max-width: 100%; max-height: 100%;
|
|
display: block;
|
|
}
|
|
.video-placeholder {
|
|
text-align: center; color: var(--text-secondary);
|
|
padding: 40px;
|
|
}
|
|
.video-placeholder .vp-icon {
|
|
font-size: 48px; opacity: 0.15; margin-bottom: 12px;
|
|
}
|
|
.video-placeholder .vp-text {
|
|
font-size: 11px; letter-spacing: 1px;
|
|
}
|
|
.video-placeholder .vp-hint {
|
|
font-size: 9px; margin-top: 8px; opacity: 0.5;
|
|
}
|
|
.video-error-overlay {
|
|
position: absolute; inset: 0;
|
|
display: flex; align-items: center; justify-content: center;
|
|
background: rgba(0,0,0,0.7);
|
|
color: var(--accent3); font-size: 11px;
|
|
letter-spacing: 1px;
|
|
pointer-events: none; opacity: 0; transition: opacity 0.5s;
|
|
}
|
|
.video-error-overlay.visible { opacity: 1; }
|
|
.video-status {
|
|
font-size: 9px; letter-spacing: 1px;
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 4px 10px; border-radius: 6px;
|
|
}
|
|
.video-status.connected {
|
|
color: var(--accent4); background: rgba(0, 255, 136, 0.06);
|
|
}
|
|
.video-status.waiting {
|
|
color: var(--text-secondary); background: var(--cell-bg);
|
|
}
|
|
|
|
/* Charts + Stats layout */
|
|
.charts-row {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr;
|
|
gap: 20px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.panel {
|
|
background: var(--glass);
|
|
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
|
padding: 24px 26px;
|
|
position: relative; overflow: hidden;
|
|
}
|
|
|
|
.chart-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
|
|
}
|
|
.chart-filters { display: flex; gap: 6px; }
|
|
.chart-filters button {
|
|
font-family: 'JetBrains Mono', monospace; font-size: 10px;
|
|
letter-spacing: 1px; padding: 6px 14px; border-radius: 7px;
|
|
border: 1px solid var(--cell-border);
|
|
background: var(--cell-bg); color: var(--text-secondary);
|
|
cursor: pointer; transition: all 0.3s;
|
|
}
|
|
.chart-filters button:hover { border-color: var(--accent); color: var(--text-primary); }
|
|
.chart-filters button.active {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
color: #000; border-color: transparent; font-weight: 600;
|
|
}
|
|
|
|
.chart-container { position: relative; height: 320px; }
|
|
|
|
/* Quick Stats */
|
|
.quick-stat {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 14px 16px; margin-bottom: 10px;
|
|
background: var(--cell-bg);
|
|
border: 1px solid var(--cell-border); border-radius: 10px;
|
|
transition: border-color 0.3s;
|
|
}
|
|
.quick-stat:hover { border-color: var(--border-glow); }
|
|
.quick-stat .qs-left { display: flex; align-items: center; gap: 12px; }
|
|
.quick-stat .qs-icon {
|
|
width: 38px; height: 38px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 15px;
|
|
}
|
|
.quick-stat .qs-icon.primary { background: linear-gradient(135deg, var(--accent), var(--accent2)); color: #000; }
|
|
.quick-stat .qs-icon.warm { background: linear-gradient(135deg, var(--accent2), var(--accent3)); color: #fff; }
|
|
.quick-stat .qs-icon.success { background: linear-gradient(135deg, var(--accent), var(--accent4)); color: #000; }
|
|
.quick-stat .qs-label { font-size: 10px; color: var(--text-secondary); }
|
|
.quick-stat .qs-sub { font-size: 8px; color: var(--text-secondary); }
|
|
.quick-stat .qs-val {
|
|
font-family: 'Orbitron', sans-serif; font-size: 18px; font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.recent-section { margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--divider); }
|
|
.recent-section .rs-title {
|
|
font-size: 10px; text-transform: uppercase; letter-spacing: 3px;
|
|
color: var(--text-secondary); margin-bottom: 14px;
|
|
}
|
|
.recent-item {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 10px 14px; margin-bottom: 6px;
|
|
background: var(--cell-bg);
|
|
border: 1px solid var(--cell-border); border-radius: 9px;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.recent-item:hover { border-color: var(--border-glow); }
|
|
.recent-item .ri-left { display: flex; align-items: center; gap: 10px; }
|
|
.recent-item .ri-badge {
|
|
width: 32px; height: 32px; border-radius: 8px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-family: 'Orbitron', sans-serif; font-size: 10px; font-weight: 700;
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
color: #000;
|
|
}
|
|
.recent-item .ri-date { font-size: 10px; color: var(--text-primary); }
|
|
.recent-item .ri-time { font-size: 8px; color: var(--text-secondary); }
|
|
.recent-item .ri-count {
|
|
font-family: 'Orbitron', sans-serif; font-size: 13px; font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
.recent-item .ri-dur { font-size: 8px; color: var(--text-secondary); text-align: right; }
|
|
|
|
/* Table Panel */
|
|
.table-panel {
|
|
background: var(--glass);
|
|
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
|
padding: 24px 26px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.table-toolbar {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 20px; flex-wrap: wrap; gap: 12px;
|
|
}
|
|
.table-toolbar .toolbar-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
|
|
.table-toolbar input[type="date"] {
|
|
font-family: 'JetBrains Mono', monospace; font-size: 11px;
|
|
background: var(--input-bg); color: var(--text-primary);
|
|
border: 1px solid var(--cell-border); border-radius: 7px;
|
|
padding: 7px 12px; outline: none;
|
|
transition: border-color 0.3s;
|
|
}
|
|
.table-toolbar input[type="date"]:focus { border-color: var(--accent); }
|
|
|
|
.btn {
|
|
font-family: 'Orbitron', sans-serif; font-size: 10px; letter-spacing: 2px;
|
|
padding: 8px 16px; border-radius: 7px; cursor: pointer; font-weight: 600;
|
|
border: none; transition: opacity 0.3s, box-shadow 0.3s;
|
|
}
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
color: #000;
|
|
}
|
|
.btn-primary:hover { opacity: 0.85; box-shadow: 0 0 20px rgba(0, 240, 255, 0.3); }
|
|
.btn-ghost {
|
|
background: var(--cell-bg); color: var(--text-secondary);
|
|
border: 1px solid var(--cell-border);
|
|
}
|
|
.btn-ghost:hover { border-color: var(--accent); color: var(--text-primary); }
|
|
.btn-export-btn {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent4));
|
|
color: #000;
|
|
}
|
|
.btn-export-btn:hover { opacity: 0.85; box-shadow: 0 0 16px rgba(0, 255, 136, 0.3); }
|
|
|
|
table {
|
|
width: 100%; border-collapse: collapse;
|
|
}
|
|
table thead th {
|
|
font-size: 8px; text-transform: uppercase; letter-spacing: 2px;
|
|
color: var(--text-secondary); text-align: left;
|
|
padding-bottom: 12px; border-bottom: 1px solid var(--divider);
|
|
}
|
|
table tbody td {
|
|
padding: 12px 0; font-size: 11px;
|
|
border-bottom: 1px solid var(--divider);
|
|
color: var(--text-primary);
|
|
}
|
|
.table-row tr { transition: background 0.2s; cursor: pointer; }
|
|
.table-row tr:hover { background: var(--cell-bg); }
|
|
|
|
.row-date { display: flex; align-items: center; gap: 10px; }
|
|
.row-date .rd-icon {
|
|
width: 36px; height: 36px; border-radius: 10px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 13px;
|
|
background: var(--cell-bg); color: var(--text-secondary);
|
|
border: 1px solid var(--cell-border);
|
|
}
|
|
.row-date .rd-icon.today {
|
|
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
|
color: #000; border: none;
|
|
}
|
|
.badge {
|
|
display: inline-flex; align-items: center; padding: 3px 10px;
|
|
border-radius: 10px; font-size: 9px; font-weight: 600; letter-spacing: 1px;
|
|
}
|
|
.badge-batches { background: rgba(0, 240, 255, 0.08); color: var(--accent); }
|
|
.badge-green { background: rgba(0, 255, 136, 0.1); color: var(--accent4); }
|
|
.badge-gray { background: var(--cell-bg); color: var(--text-secondary); }
|
|
.link-text { font-size: 10px; letter-spacing: 1px; cursor: pointer; transition: color 0.2s; }
|
|
.link-text:hover { color: var(--accent2); }
|
|
|
|
/* Modal */
|
|
.modal-overlay {
|
|
position: fixed; inset: 0; z-index: 100;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
display: flex; align-items: center; justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
.modal-overlay.hidden { display: none; }
|
|
.modal-content {
|
|
background: var(--bg-surface);
|
|
backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px);
|
|
border: 1px solid var(--border-glow); border-radius: var(--radius);
|
|
padding: 28px 30px;
|
|
width: 100%; max-width: 900px; max-height: 85vh; overflow-y: auto;
|
|
transform: scale(0.95); opacity: 0;
|
|
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
|
|
}
|
|
.modal-content.open { transform: scale(1); opacity: 1; }
|
|
.modal-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: 20px; padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--divider);
|
|
}
|
|
.modal-header h2 {
|
|
font-family: 'Orbitron', sans-serif; font-size: 16px; letter-spacing: 2px;
|
|
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
|
}
|
|
.modal-header .close-btn {
|
|
background: none; border: 1px solid var(--cell-border); color: var(--text-secondary);
|
|
width: 32px; height: 32px; border-radius: 8px; cursor: pointer;
|
|
font-size: 16px; display: flex; align-items: center; justify-content: center;
|
|
transition: border-color 0.3s, color 0.3s;
|
|
}
|
|
.modal-header .close-btn:hover { border-color: var(--accent3); color: var(--accent3); }
|
|
.modal-subtitle { font-size: 10px; color: var(--text-secondary); letter-spacing: 1px; margin-top: 4px; }
|
|
|
|
.modal-stats {
|
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.modal-stat {
|
|
background: var(--cell-bg);
|
|
border: 1px solid var(--cell-border); border-radius: 10px;
|
|
padding: 16px; text-align: center;
|
|
}
|
|
.modal-stat .ms-val {
|
|
font-family: 'Orbitron', sans-serif; font-size: 24px; font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
.modal-stat .ms-label { font-size: 9px; color: var(--text-secondary); letter-spacing: 1px; margin-top: 4px; }
|
|
.modal-table-wrap { max-height: 380px; overflow-y: auto; }
|
|
|
|
/* Footer */
|
|
.footer {
|
|
margin-top: 24px; text-align: center;
|
|
font-size: 9px; letter-spacing: 2px; color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* Scrollbar */
|
|
::-webkit-scrollbar { width: 6px; }
|
|
::-webkit-scrollbar-track { background: transparent; }
|
|
::-webkit-scrollbar-thumb { background: var(--text-secondary); border-radius: 3px; }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 1200px) {
|
|
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
|
.charts-row { grid-template-columns: 1fr; }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.container { padding: 16px 12px; }
|
|
.summary-cards { grid-template-columns: 1fr; }
|
|
.header { flex-direction: column; gap: 10px; padding: 14px 20px; }
|
|
.header-left { flex-direction: column; text-align: center; }
|
|
.header-right { flex-wrap: wrap; justify-content: center; }
|
|
.s-card .s-val { font-size: 24px; }
|
|
.panel { padding: 18px 16px; }
|
|
.modal-content { padding: 18px 16px; max-width: 95vw; }
|
|
.modal-stats { grid-template-columns: 1fr; }
|
|
.table-toolbar { flex-direction: column; align-items: flex-start; }
|
|
}
|
|
@media (max-width: 480px) {
|
|
.container { padding: 10px 8px; }
|
|
.header { padding: 12px 14px; border-radius: 12px; }
|
|
.header h1 { font-size: 16px; letter-spacing: 2px; }
|
|
.logo-icon { width: 36px; height: 36px; font-size: 18px; border-radius: 9px; }
|
|
.s-card { padding: 16px; border-radius: 14px; }
|
|
.s-card .s-val { font-size: 22px; }
|
|
.panel { padding: 14px 12px; border-radius: 14px; }
|
|
.chart-container { height: 240px; }
|
|
.footer { font-size: 8px; letter-spacing: 1px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="bg-grid"></div>
|
|
<div class="orb orb-1"></div>
|
|
<div class="orb orb-2"></div>
|
|
<div class="orb orb-3"></div>
|
|
|
|
<div class="container">
|
|
<!-- Header -->
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<div class="logo-icon">◆</div>
|
|
<div>
|
|
<h1>ZenAI KPC Module</h1>
|
|
<div class="header-sub">Edge Nano counter — YOLO RockChip</div>
|
|
</div>
|
|
</div>
|
|
<div class="header-right">
|
|
<div class="header-date">
|
|
<span class="date-val" id="current-date">--</span>
|
|
Counting Day
|
|
</div>
|
|
<div class="live-chip">
|
|
<span class="dot"></span>
|
|
<span>{{ site_name }}</span>
|
|
</div>
|
|
<a class="nav-link" href="/snapshots" title="Snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
|
|
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Summary Cards -->
|
|
<div class="summary-cards">
|
|
<div class="s-card" id="card-today">
|
|
<span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span>
|
|
<div class="s-icon primary"><i class="fas fa-calendar-day"></i></div>
|
|
<div class="s-val" id="today-count">--</div>
|
|
<div class="s-label">Diff Today (In − Out)</div>
|
|
<div class="sub-cards">
|
|
<div class="sub-card in">
|
|
<div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div>
|
|
<div class="sc-val" id="today-in">--</div>
|
|
</div>
|
|
<div class="sub-card out">
|
|
<div class="sc-head"><i class="fas fa-arrow-right-from-bracket"></i> Out</div>
|
|
<div class="sc-val" id="today-out">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="s-card">
|
|
<span class="s-chip info">Yesterday</span>
|
|
<div class="s-icon warm"><i class="fas fa-history"></i></div>
|
|
<div class="s-val" id="yesterday-count">--</div>
|
|
<div class="s-label">Diff Yesterday (In − Out)</div>
|
|
<div class="sub-cards">
|
|
<div class="sub-card in">
|
|
<div class="sc-head"><i class="fas fa-arrow-right-to-bracket"></i> In</div>
|
|
<div class="sc-val" id="yesterday-in">--</div>
|
|
</div>
|
|
<div class="sub-card out">
|
|
<div class="sc-head"><i class="fas fa-arrow-right-from-bracket"></i> Out</div>
|
|
<div class="sc-val" id="yesterday-out">--</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden (kept for JS refs) -->
|
|
<div class="hide-me">
|
|
<span id="live-count">--</span>
|
|
<span id="live-in">--</span>
|
|
<span id="live-out">--</span>
|
|
<span id="live-last-detection-in">--</span>
|
|
<span id="live-last-detection-out">--</span>
|
|
<span id="today-inout">--</span>
|
|
<span id="yesterday-inout">--</span>
|
|
<span id="avg-count">--</span>
|
|
<span id="total-days">--</span>
|
|
</div>
|
|
|
|
<!-- Best Day (hidden) -->
|
|
<div class="hide-me" id="best-day-data">
|
|
<span id="best-count">--</span>
|
|
<span id="best-date">--</span>
|
|
</div>
|
|
|
|
<!-- Live Video Feed -->
|
|
<div class="video-panel" id="video-panel">
|
|
<div class="video-header">
|
|
<span class="section-title" style="margin-bottom:0;">◉ Live Camera Feed</span>
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<span class="video-status waiting" id="video-status">
|
|
<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span>
|
|
waiting...
|
|
</span>
|
|
<button class="video-toggle" id="videoToggle" onclick="toggleVideo()">STREAM OFF</button>
|
|
</div>
|
|
</div>
|
|
<div class="video-container" id="video-container">
|
|
<div class="video-placeholder" id="video-placeholder">
|
|
<div class="vp-icon">▦</div>
|
|
<div class="vp-text">Live Feed Unavailable</div>
|
|
<div class="vp-hint">Enable <code>LIVE_STREAM_ENABLED=true</code> in counter .env</div>
|
|
</div>
|
|
<img id="live-video-img" alt="Live Feed" style="display:none;" onerror="onVideoError()" onload="onVideoLoad()" />
|
|
<div class="video-error-overlay" id="video-error-overlay">Connection lost — retrying...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts + Quick Stats -->
|
|
<div class="charts-row">
|
|
<div class="panel">
|
|
<div class="chart-header">
|
|
<span class="section-title">◉ Daily Trends</span>
|
|
<div class="chart-filters">
|
|
<button class="active" onclick="loadChartData(7)" data-days="7">7D</button>
|
|
<button onclick="loadChartData(14)" data-days="14">14D</button>
|
|
<button onclick="loadChartData(30)" data-days="30">30D</button>
|
|
</div>
|
|
</div>
|
|
<div class="chart-container">
|
|
<canvas id="mainChart"></canvas>
|
|
</div>
|
|
</div>
|
|
<div class="panel">
|
|
<span class="section-title">◉ Quick Stats</span>
|
|
<div style="margin-top:16px;">
|
|
<div class="quick-stat">
|
|
<div class="qs-left">
|
|
<div class="qs-icon primary"><i class="fas fa-layer-group"></i></div>
|
|
<div>
|
|
<div class="qs-label">Grand Diff</div>
|
|
<div class="qs-sub">All time (In − Out)</div>
|
|
</div>
|
|
</div>
|
|
<span class="qs-val" id="grand-total">--</span>
|
|
</div>
|
|
<div class="quick-stat">
|
|
<div class="qs-left">
|
|
<div class="qs-icon success"><i class="fas fa-arrow-right-to-bracket"></i></div>
|
|
<div>
|
|
<div class="qs-label">Total In</div>
|
|
<div class="qs-sub">All time</div>
|
|
</div>
|
|
</div>
|
|
<span class="qs-val" id="grand-in">--</span>
|
|
</div>
|
|
<div class="quick-stat">
|
|
<div class="qs-left">
|
|
<div class="qs-icon warm"><i class="fas fa-arrow-right-from-bracket"></i></div>
|
|
<div>
|
|
<div class="qs-label">Total Out</div>
|
|
<div class="qs-sub">All time</div>
|
|
</div>
|
|
</div>
|
|
<span class="qs-val" id="grand-out">--</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="recent-section">
|
|
<div class="rs-title">◉ Recent Days</div>
|
|
<div id="recent-days">
|
|
<!-- Populated by JS -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Daily Records Table -->
|
|
<div class="table-panel">
|
|
<div class="table-toolbar">
|
|
<div>
|
|
<span class="section-title" style="margin-bottom:0;">◉ Daily Records</span>
|
|
<div style="font-size:10px;color:var(--text-secondary);letter-spacing:1px;">Daily counter totals at cut-off</div>
|
|
</div>
|
|
<div class="toolbar-group">
|
|
<input type="date" id="date-filter">
|
|
<button class="btn btn-primary" onclick="filterByDate()">FILTER</button>
|
|
<button class="btn btn-ghost" onclick="resetFilter()">RESET</button>
|
|
<button class="btn btn-export-btn" onclick="exportDayDetail()">EXPORT EXCEL</button>
|
|
</div>
|
|
</div>
|
|
<div style="overflow-x:auto;">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style="padding-left:4px;">Date</th>
|
|
<th>Diff (In − Out)</th>
|
|
<th>In</th>
|
|
<th>Out</th>
|
|
<th style="padding-right:4px;">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="daily-table-body">
|
|
<!-- Populated by JS -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">Edge Nano Counter by ZenAi</div>
|
|
</div>
|
|
|
|
<script>
|
|
// --- Theme ---
|
|
(function() {
|
|
const saved = localStorage.getItem('theme');
|
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
updateToggleIcon();
|
|
})();
|
|
|
|
function toggleTheme() {
|
|
const el = document.documentElement;
|
|
const current = el.getAttribute('data-theme');
|
|
const next = current === 'light' ? '' : 'light';
|
|
if (next) el.setAttribute('data-theme', next);
|
|
else el.removeAttribute('data-theme');
|
|
localStorage.setItem('theme', next || 'dark');
|
|
updateToggleIcon();
|
|
}
|
|
|
|
function updateToggleIcon() {
|
|
const btn = document.getElementById('themeToggle');
|
|
if (!btn) return;
|
|
btn.textContent = document.documentElement.getAttribute('data-theme') === 'light' ? '🌙' : '☀️';
|
|
}
|
|
|
|
let mainChart = null;
|
|
let videoActive = false;
|
|
let videoErrorTimer = null;
|
|
let videoWatchdogTimer = null;
|
|
let lastVideoFrameAt = 0;
|
|
const VIDEO_STALL_MS = 8000;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadSummary();
|
|
loadCurrentCounter();
|
|
loadChartData(7);
|
|
loadRecentDays();
|
|
resetFilter();
|
|
|
|
setInterval(() => {
|
|
loadSummary();
|
|
loadRecentDays();
|
|
}, 30000);
|
|
|
|
setInterval(() => {
|
|
loadCurrentCounter();
|
|
}, 2000);
|
|
});
|
|
|
|
async function loadCurrentCounter() {
|
|
try {
|
|
const res = await fetch('/api/current-counter');
|
|
if (!res.ok) throw new Error('No active counter');
|
|
const data = await res.json();
|
|
|
|
const tin = data.count_in || 0;
|
|
const tout = data.count_out || 0;
|
|
document.getElementById('today-count').textContent = (tin - tout).toLocaleString();
|
|
document.getElementById('today-in').textContent = tin.toLocaleString();
|
|
document.getElementById('today-out').textContent = tout.toLocaleString();
|
|
} catch (err) {
|
|
document.getElementById('today-count').textContent = '0';
|
|
document.getElementById('today-in').textContent = '0';
|
|
document.getElementById('today-out').textContent = '0';
|
|
}
|
|
}
|
|
|
|
async function loadSummary() {
|
|
try {
|
|
const res = await fetch('/api/summary');
|
|
const data = await res.json();
|
|
|
|
document.getElementById('current-date').textContent = data.today.date;
|
|
|
|
document.getElementById('yesterday-count').textContent = (data.yesterday.total_in - data.yesterday.total_out).toLocaleString();
|
|
document.getElementById('yesterday-in').textContent = data.yesterday.total_in.toLocaleString();
|
|
document.getElementById('yesterday-out').textContent = data.yesterday.total_out.toLocaleString();
|
|
|
|
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
|
|
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
|
|
|
|
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
|
|
document.getElementById('best-date').textContent = data.best_day.date || '--';
|
|
|
|
document.getElementById('grand-total').textContent = (data.all_time.grand_in - data.all_time.grand_out).toLocaleString();
|
|
document.getElementById('grand-in').textContent = data.all_time.grand_in.toLocaleString();
|
|
document.getElementById('grand-out').textContent = data.all_time.grand_out.toLocaleString();
|
|
|
|
} catch (err) {
|
|
console.error('Failed to load summary:', err);
|
|
}
|
|
}
|
|
|
|
async function loadChartData(days) {
|
|
document.querySelectorAll('.chart-filters button').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
if (parseInt(btn.dataset.days) === days) btn.classList.add('active');
|
|
});
|
|
|
|
try {
|
|
const res = await fetch(`/api/daily-data?days=${days}`);
|
|
const data = await res.json();
|
|
|
|
const labels = data.map(d => {
|
|
const date = new Date(d.date);
|
|
return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
|
|
});
|
|
const counts = data.map(d => d.total_in - d.total_out);
|
|
const ins = data.map(d => d.total_in);
|
|
const outs = data.map(d => d.total_out);
|
|
|
|
if (mainChart) mainChart.destroy();
|
|
|
|
const ctx = document.getElementById('mainChart').getContext('2d');
|
|
const isDark = !document.documentElement.getAttribute('data-theme') ||
|
|
document.documentElement.getAttribute('data-theme') !== 'light';
|
|
const gridColor = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
|
|
const textColor = isDark ? '#6b7394' : '#5a6088';
|
|
|
|
mainChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [
|
|
{
|
|
label: 'In',
|
|
data: ins,
|
|
backgroundColor: 'rgba(0, 255, 136, 0.7)',
|
|
borderColor: 'rgba(0, 255, 136, 1)',
|
|
borderWidth: 2,
|
|
borderRadius: 6,
|
|
yAxisID: 'y'
|
|
},
|
|
{
|
|
label: 'Out',
|
|
data: outs,
|
|
backgroundColor: 'rgba(255, 45, 120, 0.7)',
|
|
borderColor: 'rgba(255, 45, 120, 1)',
|
|
borderWidth: 2,
|
|
borderRadius: 6,
|
|
yAxisID: 'y'
|
|
},
|
|
{
|
|
label: 'Diff',
|
|
data: counts,
|
|
type: 'line',
|
|
borderColor: 'rgba(0, 240, 255, 1)',
|
|
backgroundColor: 'rgba(0, 240, 255, 0.05)',
|
|
borderWidth: 3,
|
|
pointRadius: 4,
|
|
pointBackgroundColor: 'rgba(0, 240, 255, 1)',
|
|
tension: 0.4,
|
|
yAxisID: 'y'
|
|
}
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: {
|
|
legend: {
|
|
position: 'top',
|
|
labels: { usePointStyle: true, padding: 20, font: { size: 11 }, color: textColor }
|
|
},
|
|
tooltip: {
|
|
backgroundColor: 'rgba(12, 16, 32, 0.95)',
|
|
padding: 12,
|
|
cornerRadius: 8,
|
|
titleFont: { size: 12 },
|
|
bodyFont: { size: 11 }
|
|
}
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: { display: false },
|
|
ticks: { font: { size: 10 }, color: textColor }
|
|
},
|
|
y: {
|
|
type: 'linear',
|
|
display: true,
|
|
position: 'left',
|
|
grid: { color: gridColor },
|
|
ticks: { color: textColor, font: { size: 10 } },
|
|
title: { display: true, text: 'Count', color: textColor }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to load chart:', err);
|
|
}
|
|
}
|
|
|
|
async function loadRecentDays() {
|
|
try {
|
|
const res = await fetch('/api/available-dates');
|
|
const days = await res.json();
|
|
|
|
const container = document.getElementById('recent-days');
|
|
if (!days || days.length === 0) {
|
|
container.innerHTML = '<div class="recent-item" style="justify-content:center;color:var(--text-secondary);font-size:10px;">No records yet</div>';
|
|
return;
|
|
}
|
|
container.innerHTML = days.slice(0, 5).map(d => `
|
|
<div class="recent-item">
|
|
<div class="ri-left">
|
|
<div class="ri-badge"><i class="fas fa-calendar"></i></div>
|
|
<div>
|
|
<div class="ri-date">${d.date}</div>
|
|
<div class="ri-time">IN ${d.total_in.toLocaleString()} · OUT ${d.total_out.toLocaleString()}</div>
|
|
</div>
|
|
</div>
|
|
<div style="text-align:right;">
|
|
<div class="ri-count">${(d.total_in - d.total_out).toLocaleString()}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (err) {
|
|
console.error('Failed to load recent days:', err);
|
|
}
|
|
}
|
|
|
|
async function loadAllDates() {
|
|
try {
|
|
const res = await fetch('/api/available-dates');
|
|
const dates = await res.json();
|
|
renderDailyTable(dates);
|
|
} catch (err) {
|
|
console.error('Failed to load dates:', err);
|
|
}
|
|
}
|
|
|
|
function resetFilter() {
|
|
document.getElementById('date-filter').value = '';
|
|
loadAllDates();
|
|
}
|
|
|
|
function renderDailyTable(dates) {
|
|
const tbody = document.getElementById('daily-table-body');
|
|
const currentDate = document.getElementById('current-date').textContent;
|
|
|
|
if (dates.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="5" style="text-align:center;padding:32px;color:var(--text-secondary);">
|
|
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">□</div>
|
|
<div style="font-size:10px;letter-spacing:1px;">NO DATA AVAILABLE</div>
|
|
</td>
|
|
</tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = dates.map(d => {
|
|
const isToday = d.date === currentDate;
|
|
|
|
return `
|
|
<tr>
|
|
<td style="padding-left:4px;">
|
|
<div class="row-date">
|
|
<div class="rd-icon${isToday ? ' today' : ''}"><i class="fas fa-calendar"></i></div>
|
|
<div>
|
|
<div style="font-size:11px;color:var(--text-primary);">${d.date}</div>
|
|
${isToday ? '<div style="font-size:8px;color:var(--accent);letter-spacing:1px;">Today</div>' : ''}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${(d.total_in - d.total_out).toLocaleString()}</span></td>
|
|
<td><span class="badge badge-green">${d.total_in.toLocaleString()}</span></td>
|
|
<td><span class="badge badge-gray">${d.total_out.toLocaleString()}</span></td>
|
|
<td style="padding-right:4px;">
|
|
<span class="badge ${isToday ? 'badge-green' : 'badge-gray'}">
|
|
${isToday ? '<span style="width:5px;height:5px;border-radius:50%;background:var(--accent4);display:inline-block;margin-right:5px;animation:blink 1.5s ease-in-out infinite;"></span>Active' : 'Completed'}
|
|
</span>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
function filterByDate() {
|
|
const date = document.getElementById('date-filter').value;
|
|
if (!date) return;
|
|
|
|
fetch('/api/available-dates')
|
|
.then(r => r.json())
|
|
.then(dates => {
|
|
const filtered = dates.filter(d => d.date === date);
|
|
renderDailyTable(filtered);
|
|
});
|
|
}
|
|
|
|
function formatTime(isoString) {
|
|
if (!isoString) return '--';
|
|
const date = new Date(isoString);
|
|
return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') { hideVideoError(); }
|
|
});
|
|
|
|
// --- Live Video Feed ---
|
|
|
|
function reconnectVideo() {
|
|
if (!videoActive) return;
|
|
const img = document.getElementById('live-video-img');
|
|
if (!img) return;
|
|
lastVideoFrameAt = Date.now();
|
|
img.src = '/api/live-video?' + Date.now();
|
|
}
|
|
|
|
function startVideoWatchdog() {
|
|
stopVideoWatchdog();
|
|
lastVideoFrameAt = Date.now();
|
|
videoWatchdogTimer = setInterval(() => {
|
|
if (!videoActive) return;
|
|
if (Date.now() - lastVideoFrameAt > VIDEO_STALL_MS) {
|
|
reconnectVideo();
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function stopVideoWatchdog() {
|
|
if (videoWatchdogTimer) {
|
|
clearInterval(videoWatchdogTimer);
|
|
videoWatchdogTimer = null;
|
|
}
|
|
}
|
|
|
|
function toggleVideo() {
|
|
const btn = document.getElementById('videoToggle');
|
|
const img = document.getElementById('live-video-img');
|
|
const placeholder = document.getElementById('video-placeholder');
|
|
const status = document.getElementById('video-status');
|
|
|
|
videoActive = !videoActive;
|
|
|
|
if (videoActive) {
|
|
resetFilter();
|
|
img.style.display = 'block';
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
btn.textContent = 'STREAM ON';
|
|
btn.classList.add('active');
|
|
status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> connecting...';
|
|
status.className = 'video-status waiting';
|
|
reconnectVideo();
|
|
startVideoWatchdog();
|
|
} else {
|
|
stopVideoWatchdog();
|
|
img.src = '';
|
|
img.style.display = 'none';
|
|
if (placeholder) placeholder.style.display = '';
|
|
btn.textContent = 'STREAM OFF';
|
|
btn.classList.remove('active');
|
|
status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--text-secondary);"></span> stopped';
|
|
status.className = 'video-status waiting';
|
|
hideVideoError();
|
|
}
|
|
}
|
|
|
|
function onVideoLoad() {
|
|
lastVideoFrameAt = Date.now();
|
|
const status = document.getElementById('video-status');
|
|
status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> live';
|
|
status.className = 'video-status connected';
|
|
hideVideoError();
|
|
}
|
|
|
|
function onVideoError() {
|
|
const overlay = document.getElementById('video-error-overlay');
|
|
if (overlay) overlay.classList.add('visible');
|
|
if (videoErrorTimer) clearTimeout(videoErrorTimer);
|
|
videoErrorTimer = setTimeout(() => {
|
|
reconnectVideo();
|
|
}, 2000);
|
|
}
|
|
|
|
function hideVideoError() {
|
|
const overlay = document.getElementById('video-error-overlay');
|
|
if (overlay) overlay.classList.remove('visible');
|
|
if (videoErrorTimer) { clearTimeout(videoErrorTimer); videoErrorTimer = null; }
|
|
}
|
|
</script>
|
|
|
|
<script>
|
|
async function exportDayDetail() {
|
|
try {
|
|
const url = '/api/export-daily-csv?days=365';
|
|
const res = await fetch(url);
|
|
if (!res.ok) throw new Error('Export failed');
|
|
const blob = await res.blob();
|
|
const disposition = res.headers.get('Content-Disposition');
|
|
const filename = disposition
|
|
? decodeURIComponent(disposition.replace(/.*filename="?([^";]+)"?.*/, '$1'))
|
|
: `export_all.xlsx`;
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(a.href);
|
|
} catch (err) {
|
|
alert('Export error: ' + err.message);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|