Files
karung-counting-feedmill-se…/templates/monitoring.html
T
2026-09-24 15:45:12 +07:00

770 lines
28 KiB
HTML

{% extends "base.html" %}
{% block title %}Live CCTV Monitoring - Karung.AI{% endblock %}
{% block extra_head %}
<style>
.monitoring-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 1024px) {
.monitoring-grid {
grid-template-columns: 1fr;
}
}
/* Video Feed Panel */
.video-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.video-container {
position: relative;
width: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
background-color: #000000;
border: 1px solid var(--border-color);
aspect-ratio: 16/9;
box-shadow: var(--shadow-sm);
}
.video-feed {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.video-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #FFFFFF;
background-color: #111111;
gap: 12px;
}
.video-placeholder i {
font-size: 40px;
color: var(--accent-blue);
}
.video-placeholder p {
font-size: 14px;
font-weight: 500;
color: #8E8E93;
}
.video-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.video-title {
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
/* Active Batch Details (Right Column) */
.control-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
.active-batch-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow-sm);
}
.card-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.count-display {
font-size: 64px;
font-weight: 700;
letter-spacing: -2px;
line-height: 1;
color: var(--accent-blue);
margin: 12px 0;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 20px;
border-top: 1px solid var(--border-color);
padding-top: 16px;
}
.meta-item {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.meta-key {
color: var(--text-secondary);
}
.meta-value {
font-weight: 500;
color: var(--text-primary);
}
/* Live Event Logs */
.log-card {
display: flex;
flex-direction: column;
height: 380px;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.log-title {
font-size: 15px;
font-weight: 600;
}
.log-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
padding-right: 4px;
}
.log-item {
display: flex;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-md);
background-color: var(--bg-base);
border: 1px solid var(--border-color);
font-size: 13px;
transition: var(--transition);
}
.log-item:hover {
background-color: rgba(0,0,0,0.02);
}
[data-theme="dark"] .log-item:hover {
background-color: rgba(255,255,255,0.02);
}
.log-time {
font-weight: 600;
color: var(--text-secondary);
font-family: monospace;
min-width: 60px;
}
.log-content {
flex: 1;
color: var(--text-primary);
}
.log-icon {
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: #FFFFFF;
flex-shrink: 0;
margin-top: 1px;
}
.log-icon.masuk {
background-color: var(--accent-green);
}
.log-icon.keluar {
background-color: var(--accent-red);
}
.log-icon.info {
background-color: var(--accent-blue);
}
/* Apple Style Switch */
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: var(--border-color);
transition: .3s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .3s;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
input:checked + .slider {
background-color: var(--accent-green);
}
input:checked + .slider:before {
transform: translateX(20px);
}
</style>
{% endblock %}
{% block content %}
<div class="monitoring-grid">
<!-- Left Column: Video Feed -->
<div class="video-panel">
<div class="video-header">
<h2 class="video-title">
<i class="fa-solid fa-camera"></i> CCTV Feed - CC1
</h2>
<div style="display: flex; align-items: center; gap: 16px;">
<!-- Live CCTV Toggle Switch -->
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 13px; font-weight: 500; color: var(--text-secondary);">Live View:</span>
<label class="switch">
<input type="checkbox" id="liveFeedToggle" onchange="toggleLiveFeed()">
<span class="slider"></span>
</label>
</div>
<div class="btn btn-secondary btn-sm" onclick="reloadStream()" title="Reload Stream Feed">
<i class="fa-solid fa-refresh"></i> Refresh Feed
</div>
</div>
</div>
<div class="video-container">
<!-- Fetch direct live frame stream from local dashboard Flask -->
<img id="liveVideoFeed" class="video-feed" src="" alt="CCTV Stream Feed" style="display: none;" onerror="handleStreamError()">
<div id="videoPlaceholder" class="video-placeholder" style="display: flex;">
<i class="fa-solid fa-play" style="font-size: 40px; cursor: pointer; color: var(--accent-blue);" onclick="startLiveFeedManual()"></i>
<p>Live Feed dinonaktifkan (Hemat CPU & Bandwidth)</p>
<div class="btn btn-secondary" onclick="startLiveFeedManual()">Aktifkan Live View CCTV</div>
</div>
</div>
</div>
<!-- Right Column: Control & Active Batch Stats -->
<div class="control-panel">
<!-- Card 1: Current Batch Stats -->
<div class="active-batch-card">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<h3 class="card-label" style="margin-bottom: 0;">Active Batch</h3>
<!-- Mode Switcher: auto | do_manual | manual -->
<div style="display: inline-flex; background: var(--bg-base); border: 1px solid var(--border-color); border-radius: 20px; padding: 2px;">
<button id="btnModeAuto" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('auto')">
<i class="fa-solid fa-robot"></i> Otomatis
</button>
<button id="btnModeDoManual" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('do_manual')">
<i class="fa-solid fa-file-lines"></i> DO Manual
</button>
<button id="btnModeManual" class="btn btn-sm" style="padding: 4px 10px; font-size: 11px; border-radius: 18px; border: none; background: transparent; color: var(--text-secondary);" onclick="switchBatchMode('manual')">
<i class="fa-solid fa-hand"></i> Manual
</button>
</div>
</div>
<div class="count-display" id="liveCount">0</div>
<div class="card-label" style="font-size: 11px; margin-top: -4px;">Sacks Counted</div>
<div class="count-display" id="liveBoxCount">0</div>
<div class="card-label" style="font-size: 11px; margin-top: -4px;">Boxes Counted</div>
<div class="meta-list">
<div class="meta-item">
<span class="meta-key">Mode Batch</span>
<span class="meta-value" id="liveBatchModeText" style="font-weight: 600; color: var(--accent-blue);">Manual (Tombol)</span>
</div>
<div class="meta-item">
<span class="meta-key">Model Mode</span>
<span class="meta-value" id="liveModelMode" style="font-weight: 600;">--</span>
</div>
<div class="meta-item">
<span class="meta-key">Batch Number</span>
<span class="meta-value" id="liveBatchNumber">--</span>
</div>
<div class="meta-item">
<span class="meta-key">Last Detection</span>
<span class="meta-value" id="liveLastDetection">Waiting...</span>
</div>
<div class="meta-item">
<span class="meta-key">Status</span>
<span class="meta-value" id="liveStatus" style="color: var(--text-secondary);">Standby (Idle)</span>
</div>
<div class="meta-item">
<span class="meta-key">Pipeline Speed</span>
<span class="meta-value"><span id="liveFps">--</span> FPS</span>
</div>
<div class="meta-item">
<span class="meta-key">OCR Engine</span>
<span class="meta-value">
<select id="monOcrEngine" style="padding:4px 8px;border-radius:8px;border:1px solid var(--border-color);background:var(--bg-base);color:var(--text-primary);font-size:12px;" onchange="setOcrEngine(this.value)">
<option value="tesseract">tesseract</option>
<option value="paddle">paddle</option>
<option value="none">none</option>
</select>
</span>
</div>
<div class="meta-item">
<span class="meta-key">Require plat</span>
<span class="meta-value">
<label style="font-weight:400;">
<input type="checkbox" id="monRequirePlate" onchange="setRequirePlate(this.checked)"> wajib
</label>
</span>
</div>
</div>
<!-- Manual Batch Control Buttons for Office Dashboard (Hidden when in Auto Mode) -->
<div id="manualControlSection" style="margin-top: 20px; display: flex; gap: 10px;">
<button id="monBtnStart" class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="confirmStartBatch()">
<i class="fa-solid fa-play"></i> Mulai Batch
</button>
<button id="monBtnStop" class="btn btn-primary" style="flex: 1; background-color: var(--accent-red); display: none;" onclick="confirmStopBatch()">
<i class="fa-solid fa-square"></i> Akhiri Batch
</button>
</div>
<div id="autoModeHint" style="margin-top: 14px; font-size: 12px; color: var(--text-secondary); text-align: center; display: none;">
<i class="fa-solid fa-info-circle"></i> Batch dimulai & diakhiri otomatis oleh AI deteksi truk
</div>
</div>
<!-- Card 2: Live Activity Logs -->
<div class="card log-card">
<div class="log-header">
<h3 class="log-title">Live Activity Log</h3>
<span class="live-badge" style="font-size: 9px;"><span class="dot"></span> Real-time</span>
</div>
<div class="log-container" id="logContainer">
<div class="log-item" id="emptyLogItem">
<div class="log-time">--:--:--</div>
<div class="log-content">Sistem aktif. Menunggu batch dimulai...</div>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Konfirmasi Start -->
<div id="modalStart" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 999; padding: 20px;">
<div style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; max-width: 400px; width: 100%; text-align: center;">
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 12px;"><i class="fa-solid fa-play" style="color: var(--accent-green);"></i> Mulai Batch Baru?</h3>
<p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 24px;">Counter akan diaktifkan dan batch baru akan dibuat.</p>
<div style="display: flex; gap: 12px;">
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStart').style.display='none'">Batal</button>
<button class="btn btn-primary" style="flex: 1; background-color: var(--accent-green);" onclick="executeStartBatch()">Ya, Mulai</button>
</div>
</div>
</div>
<!-- Modal Konfirmasi Stop -->
<div id="modalStop" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 999; padding: 20px;">
<div style="background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; max-width: 400px; width: 100%; text-align: center;">
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 12px;"><i class="fa-solid fa-square" style="color: var(--accent-red);"></i> Selesai Batch?</h3>
<p style="font-size: 14px; color: var(--text-secondary); margin-bottom: 24px;">Pemuatan batch akan difinalisasi dan disimpan ke database.</p>
<div style="display: flex; gap: 12px;">
<button class="btn btn-secondary" style="flex: 1;" onclick="document.getElementById('modalStop').style.display='none'">Batal</button>
<button class="btn btn-primary" style="flex: 1; background-color: var(--accent-red);" onclick="executeStopBatch()">Ya, Selesai</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let streamRetryCount = 0;
function startLiveFeedManual() {
const toggle = document.getElementById('liveFeedToggle');
toggle.checked = true;
toggleLiveFeed();
}
function handleStreamError() {
const feed = document.getElementById('liveVideoFeed');
const placeholder = document.getElementById('videoPlaceholder');
feed.style.display = 'none';
placeholder.style.display = 'flex';
if (streamRetryCount < 10) {
streamRetryCount++;
setTimeout(reloadStream, 3000);
}
}
function reloadStream() {
const toggle = document.getElementById('liveFeedToggle');
if (!toggle.checked) return; // Jangan reload jika dimatikan
const feed = document.getElementById('liveVideoFeed');
const placeholder = document.getElementById('videoPlaceholder');
feed.style.display = 'block';
placeholder.style.display = 'none';
feed.src = '/api/live-video?t=' + new Date().getTime();
}
function toggleLiveFeed() {
const toggle = document.getElementById('liveFeedToggle');
const feed = document.getElementById('liveVideoFeed');
const placeholder = document.getElementById('videoPlaceholder');
if (toggle.checked) {
// Aktifkan live feed
feed.style.display = 'block';
placeholder.style.display = 'none';
feed.src = '/api/live-video';
} else {
// Matikan live feed (putus koneksi stream)
feed.style.display = 'none';
placeholder.style.display = 'flex';
placeholder.querySelector('p').textContent = 'Live Feed dinonaktifkan (Hemat CPU & Bandwidth)';
feed.src = ''; // Putus koneksi stream dengan backend!
}
}
function confirmStartBatch() {
document.getElementById('modalStart').style.display = 'flex';
}
function confirmStopBatch() {
document.getElementById('modalStop').style.display = 'flex';
}
async function executeStartBatch() {
document.getElementById('modalStart').style.display = 'none';
try {
const res = await fetch('/api/batch/start', { method: 'POST' });
const data = await res.json();
if (data.success) {
await pollCurrentBatch();
} else {
alert('Gagal memulai batch: ' + (data.error || 'Terjadi kesalahan'));
}
} catch (e) {
alert('Gagal terhubung ke server');
}
}
async function executeStopBatch() {
document.getElementById('modalStop').style.display = 'none';
try {
const res = await fetch('/api/batch/stop', { method: 'POST' });
const data = await res.json();
if (data.success) {
await pollCurrentBatch();
} else {
alert('Gagal mengakhiri batch: ' + (data.error || 'Terjadi kesalahan'));
}
} catch (e) {
alert('Gagal terhubung ke server');
}
}
// Polling current batch stats every 2 seconds
async function pollCurrentBatch() {
try {
const res = await fetch('/api/current-batch');
const data = await res.json();
const countEl = document.getElementById('liveCount');
const boxCountEl = document.getElementById('liveBoxCount');
const batchNumEl = document.getElementById('liveBatchNumber');
const lastDetEl = document.getElementById('liveLastDetection');
const statusEl = document.getElementById('liveStatus');
const fpsEl = document.getElementById('liveFps');
const modelModeEl = document.getElementById('liveModelMode');
const btnStart = document.getElementById('monBtnStart');
const btnStop = document.getElementById('monBtnStop');
if (data.fps !== undefined && data.fps !== null) {
fpsEl.textContent = data.fps;
} else {
fpsEl.textContent = '--';
}
if (data.model_mode && modelModeEl) modelModeEl.textContent = data.model_mode;
if (data.success && data.batch_number) {
// Batch sedang berjalan
const currentCount = parseInt(data.count) || 0;
countEl.textContent = currentCount.toLocaleString();
if (boxCountEl) boxCountEl.textContent = (parseInt(data.box_count) || 0).toLocaleString();
batchNumEl.textContent = '#' + (data.batch_number || '--');
statusEl.textContent = 'Counting Sacks (Active)';
statusEl.style.color = 'var(--accent-blue)';
if (data.last_detection_time) {
const lastDet = new Date(data.last_detection_time);
lastDetEl.textContent = lastDet.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
} else {
lastDetEl.textContent = 'waiting...';
}
if (btnStart && btnStop) {
btnStart.style.display = 'none';
btnStop.style.display = 'flex';
}
// Tambahkan event ke log secara dinamis jika jumlah hitungan bertambah
addLogItem(new Date().toLocaleTimeString('en-US', { hour12: false }), `Karung ke-${currentCount} terhitung di area counting.`, 'masuk');
} else {
// Tidak ada batch aktif (standby)
countEl.textContent = '0';
if (boxCountEl) boxCountEl.textContent = (parseInt(data.box_count) || 0).toLocaleString();
batchNumEl.textContent = '--';
lastDetEl.textContent = '--';
statusEl.textContent = 'Standby (Menunggu Mulai)';
statusEl.style.color = 'var(--text-secondary)';
if (btnStart && btnStop) {
btnStart.style.display = 'flex';
btnStop.style.display = 'none';
}
}
} catch (err) {
console.error('Failed to poll current batch:', err);
}
}
// Polling previous batch details periodically to see if a batch just finalized
let lastFinalizedBatchNum = null;
async function pollFinalizedBatch() {
try {
const res = await fetch('/api/previous-batch');
const data = await res.json();
if (data.success) {
if (lastFinalizedBatchNum !== null && lastFinalizedBatchNum !== data.batch_number) {
// Ada batch baru yang baru saja diselesaikan!
addLogItem(
new Date(data.end_time).toLocaleTimeString('en-US', { hour12: false }),
`Batch #${data.batch_number} selesai. Total: ${data.count} karung, Durasi: ${data.duration_minutes} m.`,
'info'
);
}
lastFinalizedBatchNum = data.batch_number;
}
} catch (err) {
console.error('Failed to poll previous batch:', err);
}
}
const addedLogs = new Set();
function addLogItem(time, message, type = 'info') {
const key = `${time}_${message}`;
if (addedLogs.has(key)) return;
addedLogs.add(key);
const container = document.getElementById('logContainer');
const emptyItem = document.getElementById('emptyLogItem');
if (emptyItem) emptyItem.remove();
const item = document.createElement('div');
item.className = 'log-item';
let iconClass = 'fa-info';
if (type === 'masuk') iconClass = 'fa-plus';
if (type === 'keluar') iconClass = 'fa-minus';
item.innerHTML = `
<div class="log-icon ${type}"><i class="fa-solid ${iconClass}"></i></div>
<div class="log-time">${time}</div>
<div class="log-content">${message}</div>
`;
container.insertBefore(item, container.firstChild);
// Batasi log maksimal 40 baris agar tidak membebani memori browser
while (container.children.length > 40) {
container.removeChild(container.lastChild);
}
}
let currentBatchMode = 'auto';
async function switchBatchMode(mode) {
try {
const res = await fetch('/api/batch/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode })
});
const data = await res.json();
if (data.success) {
updateModeUI(data.mode);
} else if (res.status === 409) {
alert(data.error || 'Batch sedang berjalan — hentikan dulu.');
} else if (res.status === 403) {
alert(data.error || 'Hanya dari port kantor.');
} else {
alert(data.error || 'Gagal ganti mode');
}
} catch (e) {
console.error('Failed to switch batch mode:', e);
}
}
async function checkBatchMode() {
try {
const res = await fetch('/api/batch/mode');
const data = await res.json();
if (data.success) {
updateModeUI(data.mode);
}
} catch (e) {
console.error('Failed to check batch mode:', e);
}
}
function updateModeUI(mode) {
currentBatchMode = mode;
const btnManual = document.getElementById('btnModeManual');
const btnAuto = document.getElementById('btnModeAuto');
const btnDo = document.getElementById('btnModeDoManual');
const modeText = document.getElementById('liveBatchModeText');
const manualSection = document.getElementById('manualControlSection');
const autoHint = document.getElementById('autoModeHint');
const setActive = (el, on) => {
if (!el) return;
if (on) {
el.style.background = 'var(--accent-blue)';
el.style.color = '#fff';
el.style.fontWeight = '600';
} else {
el.style.background = 'transparent';
el.style.color = 'var(--text-secondary)';
el.style.fontWeight = 'normal';
}
};
setActive(btnAuto, mode === 'auto');
setActive(btnDo, mode === 'do_manual');
setActive(btnManual, mode === 'manual');
if (modeText) {
if (mode === 'auto') {
modeText.textContent = 'Otomatis (AI Truk)';
modeText.style.color = 'var(--accent-orange)';
} else if (mode === 'do_manual') {
modeText.textContent = 'DO Manual (Scan Surat)';
modeText.style.color = 'var(--accent-blue)';
} else {
modeText.textContent = 'Manual (Tombol)';
modeText.style.color = 'var(--accent-blue)';
}
}
if (mode === 'auto') {
if (manualSection) manualSection.style.display = 'none';
if (autoHint) autoHint.style.display = 'block';
} else {
if (manualSection) manualSection.style.display = 'flex';
if (autoHint) autoHint.style.display = 'none';
}
}
async function loadDoSettings() {
try {
const res = await fetch('/api/do/settings');
const data = await res.json();
if (!data.success) return;
const sel = document.getElementById('monOcrEngine');
if (sel && data.ocr_engine) sel.value = data.ocr_engine;
const chk = document.getElementById('monRequirePlate');
if (chk) chk.checked = !!data.require_plate;
} catch (e) { console.error(e); }
}
async function setOcrEngine(eng) {
try {
const res = await fetch('/api/do/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocr_engine: eng })
});
const data = await res.json();
if (!data.success) alert(data.error || 'Gagal set OCR');
loadDoSettings();
} catch (e) { alert('Gagal terhubung'); loadDoSettings(); }
}
async function setRequirePlate(val) {
try {
const res = await fetch('/api/do/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ require_plate: val })
});
const data = await res.json();
if (!data.success) alert(data.error || 'Gagal set require_plate');
loadDoSettings();
} catch (e) { alert('Gagal terhubung'); loadDoSettings(); }
}
// Start interval pollings
setInterval(pollCurrentBatch, 2000);
setInterval(pollFinalizedBatch, 3000);
setInterval(checkBatchMode, 5000);
setInterval(loadDoSettings, 8000);
// Initial first load
checkBatchMode();
loadDoSettings();
pollCurrentBatch();
pollFinalizedBatch();
</script>
{% endblock %}