Initial commit

This commit is contained in:
Porawit Dongwang
2026-09-16 23:20:08 +07:00
commit 0041668dbb
32577 changed files with 3687927 additions and 0 deletions
+677
View File
@@ -0,0 +1,677 @@
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IMOU Vision - AC Ribbon Monitor</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.7/css/jquery.dataTables.min.css">
<script src="https://cdn.datatables.net/1.13.7/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
magic: {
bg: '#030712',
card: '#111827',
border: '#1f2937'
}
},
animation: {
'ribbon-flutter': 'flutter 0.3s ease-in-out infinite alternate',
'ribbon-still': 'none',
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
flutter: {
'0%': { transform: 'rotate(5deg) skewX(2deg)' },
'100%': { transform: 'rotate(25deg) skewX(-5deg)' },
}
}
}
}
}
</script>
<style>
body {
background-color: #030712;
color: #f3f4f6;
background-image:
radial-gradient(at 0% 0%, hsla(253, 16%, 7%, 1) 0, transparent 50%),
radial-gradient(at 50% 0%, hsla(225, 39%, 30%, 0.2) 0, transparent 50%),
radial-gradient(at 100% 0%, hsla(339, 49%, 30%, 0.2) 0, transparent 50%);
background-attachment: fixed;
min-height: 100vh;
}
/* Magic UI Glassmorphism Card */
.glass-card {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
}
/* Camera Feed Container */
.cam-feed {
background: linear-gradient(to bottom, #1f2937, #111827);
position: relative;
overflow: hidden;
user-select: none;
}
/* Customizing DataTable for Dark Theme */
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_processing,
.dataTables_wrapper .dataTables_paginate {
color: #9ca3af !important;
margin-bottom: 1rem;
}
table.dataTable tbody tr {
background-color: transparent !important;
}
table.dataTable.row-border tbody th,
table.dataTable.row-border tbody td,
table.dataTable.display tbody th,
table.dataTable.display tbody td {
border-top: 1px solid #374151;
color: #d1d5db;
}
table.dataTable thead th,
table.dataTable thead td {
border-bottom: 1px solid #4b5563;
color: #e5e7eb;
}
.dataTables_wrapper .dataTables_paginate .paginate_button {
color: #9ca3af !important;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
background: #374151 !important;
color: white !important;
border: 1px solid #4b5563 !important;
}
.dataTables_wrapper .dataTables_filter input {
border: 1px solid #4b5563;
background: #1f2937;
color: white;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
margin-left: 0.5rem;
}
.dataTables_wrapper .dataTables_length select {
border: 1px solid #4b5563;
background: #1f2937;
color: white;
border-radius: 0.375rem;
padding: 0.25rem;
}
/* Ribbon Base Styling */
.ribbon {
width: 8px;
height: 60px;
background: linear-gradient(to bottom, #ef4444, #dc2626);
transform-origin: top center;
border-radius: 0 0 4px 4px;
box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5);
transition: all 0.5s ease;
}
/* Toggle Switch Styles */
.toggle-checkbox:checked {
right: 0;
border-color: #10b981;
}
.toggle-checkbox:checked+.toggle-label {
background-color: #10b981;
}
.toggle-checkbox {
right: 4px;
z-index: 1;
border-color: #4b5563;
transition: all 0.3s;
}
.toggle-label {
background-color: #374151;
transition: all 0.3s;
}
#mjpeg-stream {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
#overlay-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
z-index: 20;
}
</style>
</head>
<body class="p-4 md:p-8 font-sans antialiased">
<div class="max-w-7xl mx-auto space-y-6">
<!-- Header -->
<header class="flex flex-col md:flex-row justify-between items-start md:items-center glass-card p-6 rounded-2xl">
<div>
<h1 class="text-2xl md:text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-emerald-400 flex items-center gap-2">
<i class="ph ph-video-camera text-blue-400"></i>
IMOU Vision Smart Monitor
</h1>
<p class="text-gray-400 text-sm mt-1">AC Flow Detection System via IP Camera Ribbon Tracking</p>
</div>
<div class="mt-4 md:mt-0 flex items-center gap-4">
<button onclick="openSettings()" class="bg-gray-800/80 hover:bg-gray-700 text-gray-200 px-4 py-2 rounded-xl border border-gray-600 flex items-center gap-2 transition-colors shadow-lg hover:shadow-blue-500/20 cursor-pointer">
<i class="ph ph-gear text-lg text-blue-400"></i> ตั้งค่าแจ้งเตือน
</button>
<div class="flex items-center gap-3 bg-gray-800/50 px-4 py-2 rounded-full border border-gray-700">
<i class="ph ph-clock text-emerald-400"></i>
<span id="current-time" class="font-mono text-sm tracking-widest text-gray-300">00:00:00</span>
</div>
</div>
</header>
<div class="glass-card rounded-xl p-4 md:p-6 flex flex-col gap-4 transition-all duration-500 border shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50" id="main-cam-card">
<!-- Header -->
<div class="flex flex-wrap justify-between items-center gap-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-500/20 rounded-lg">
<i class="ph-fill ph-webcam text-blue-400 text-2xl"></i>
</div>
<div>
<h3 class="font-semibold text-gray-100 text-lg flex items-center gap-2">
IMOU-CAM-01
<span class="px-2 py-0.5 text-[10px] rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">ONLINE</span>
</h3>
<p class="text-sm text-gray-400"><i class="ph ph-info mr-1"></i>ลากกรอบสีเขียวคลุมริบบิ้นหน้าช่องแอร์ เพื่อเริ่มตรวจจับ</p>
</div>
</div>
<!-- System Overview Badge -->
<div class="flex items-center gap-3 bg-gray-900/80 px-4 py-2 rounded-lg border border-gray-700">
<div class="text-right">
<p class="text-xs text-gray-500">AC Unit Status</p>
<p class="text-sm font-bold text-gray-200" id="ac-status-label">WAITING</p>
</div>
<div class="w-px h-8 bg-gray-700"></div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-amber-500 animate-pulse" id="sys-status-dot"></span>
<span class="text-xs font-mono text-amber-400" id="sys-status-text">DRAW ROI</span>
</div>
</div>
</div>
<!-- MJPEG Camera Feed -->
<div class="cam-feed rounded-lg aspect-video w-full flex items-center justify-center border border-gray-800 shadow-inner relative">
<!-- Overlay Info -->
<div class="absolute top-3 left-3 text-[10px] md:text-xs font-mono text-white bg-black/60 px-2 py-1 rounded z-30 backdrop-blur-sm border border-white/10">
<i class="ph ph-video-camera mr-1"></i> LIVE STREAM
</div>
<!-- The MJPEG Stream Image -->
<img id="mjpeg-stream" src="http://localhost:3000/stream" alt="Waiting for stream..." crossorigin="anonymous" onerror="this.onerror=null; this.alt='Stream not available (Is Backend running?)';" />
<!-- Drawing Canvas -->
<canvas id="overlay-canvas"></canvas>
<!-- UI Indicators (Ribbon Animation inside the view) -->
<div id="ui-indicator" class="hidden absolute top-4 right-4 bg-gray-800/80 p-3 rounded-lg border border-gray-600 z-30 flex items-center gap-4">
<div class="text-xs text-gray-300">Motion State:</div>
<div class="w-10 h-16 bg-gray-900 rounded flex justify-center items-start pt-1">
<div id="virtual-ribbon" class="ribbon animate-ribbon-still" style="transform: rotate(0deg);"></div>
</div>
</div>
</div>
</div>
<div class="glass-card rounded-2xl p-6 mt-8">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2 text-gray-200">
<i class="ph ph-list-dashes text-blue-400"></i>
System Event Logs
</h2>
<div class="overflow-x-auto">
<table id="logTable" class="display w-full text-sm">
<thead>
<tr>
<th class="text-left">Time</th>
<th class="text-left">Camera / AC Unit</th>
<th class="text-left">Status</th>
<th class="text-left">Details</th>
</tr>
</thead>
<tbody>
<!-- Logs will be added here -->
</tbody>
</table>
</div>
</div>
</div>
<!-- Hidden canvas for image processing -->
<canvas id="process-canvas" style="display:none;"></canvas>
<!-- Settings Modal -->
<div id="settings-modal" class="fixed inset-0 z-50 hidden bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="glass-card w-full max-w-md rounded-2xl p-6 border border-gray-700 shadow-2xl transform transition-all">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold text-gray-100 flex items-center gap-2">
<i class="ph ph-bell-ringing text-amber-400"></i> Notification Settings
</h2>
<button onclick="closeSettings()" class="text-gray-400 hover:text-white transition-colors">
<i class="ph ph-x text-2xl"></i>
</button>
</div>
<div class="space-y-5">
<!-- LINE Notify -->
<div class="p-4 bg-gray-800/50 rounded-xl border border-gray-700">
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-2">
<i class="ph-fill ph-chat-circle text-green-500 text-xl"></i>
<span class="font-semibold text-gray-200">LINE Notify</span>
</div>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="toggleLine" id="toggleLine" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer" />
<label for="toggleLine" class="toggle-label block overflow-hidden h-5 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<input type="password" id="lineToken" placeholder="LINE Notify Token" class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-green-500">
<p class="text-[10px] text-gray-400 mt-1">* หมายเหตุ: LINE API อาจถูก Block (CORS) หากส่งจาก Browser หน้าเว็บโดยตรง (ระบบใช้ Proxy ชั่วคราวให้)</p>
</div>
<!-- Telegram -->
<div class="p-4 bg-gray-800/50 rounded-xl border border-gray-700">
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-2">
<i class="ph-fill ph-telegram-logo text-blue-500 text-xl"></i>
<span class="font-semibold text-gray-200">Telegram Bot</span>
</div>
<div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" name="toggleTelegram" id="toggleTelegram" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer" />
<label for="toggleTelegram" class="toggle-label block overflow-hidden h-5 rounded-full bg-gray-300 cursor-pointer"></label>
</div>
</div>
<div class="space-y-2">
<input type="password" id="tgBotToken" placeholder="Bot Token (e.g. 123456:ABC-DEF...)" class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500">
<input type="text" id="tgChatId" placeholder="Chat ID (e.g. 123456789)" class="w-full bg-gray-900 border border-gray-600 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-blue-500">
</div>
</div>
</div>
<button onclick="saveSettings()" class="w-full mt-6 bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white font-semibold py-2 px-4 rounded-xl transition-all shadow-lg shadow-blue-500/30">
Save Settings
</button>
</div>
</div>
<script>
let logTable;
let notifyConfig = {
line: { enabled: false, token: '' },
telegram: { enabled: false, token: '', chatId: '' }
};
// --- ROI Drawing Variables ---
const overlayCanvas = document.getElementById('overlay-canvas');
const overlayCtx = overlayCanvas.getContext('2d');
let isDrawing = false;
let startX = 0;
let startY = 0;
let roiRect = null; // {x, y, w, h}
// --- Motion Detection Variables ---
const processCanvas = document.getElementById('process-canvas');
const processCtx = processCanvas.getContext('2d', { willReadFrequently: true });
const mjpegImg = document.getElementById('mjpeg-stream');
let previousImageData = null;
let detectionInterval = null;
let isAcRunning = false;
let previousAcState = false; // To track changes and log
$(document).ready(function () {
loadSettings();
initClock();
logTable = $('#logTable').DataTable({
"order": [[0, "desc"]],
"pageLength": 5,
"lengthMenu": [5, 10, 25],
"language": {
"emptyTable": "No events recorded yet."
}
});
addLog("System", "Web UI", "System Start", "Frontend loaded. Waiting for ROI setup.");
// Resize canvas to match the image dimensions
function resizeCanvas() {
overlayCanvas.width = overlayCanvas.clientWidth;
overlayCanvas.height = overlayCanvas.clientHeight;
if (roiRect) drawROI();
}
window.addEventListener('resize', resizeCanvas);
mjpegImg.addEventListener('load', resizeCanvas);
setTimeout(resizeCanvas, 500);
// --- Mouse Events for Drawing ROI ---
overlayCanvas.addEventListener('mousedown', (e) => {
const rect = overlayCanvas.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
isDrawing = true;
roiRect = null; // Reset
});
overlayCanvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
const rect = overlayCanvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
const w = currentX - startX;
const h = currentY - startY;
overlayCtx.strokeStyle = '#10b981'; // Emerald 500
overlayCtx.lineWidth = 2;
overlayCtx.setLineDash([5, 5]);
overlayCtx.strokeRect(startX, startY, w, h);
overlayCtx.fillStyle = 'rgba(16, 185, 129, 0.2)';
overlayCtx.fillRect(startX, startY, w, h);
});
overlayCanvas.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
const rect = overlayCanvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
// Normalize rectangle (width/height can be negative if drawn backwards)
const x = Math.min(startX, currentX);
const y = Math.min(startY, currentY);
const w = Math.abs(currentX - startX);
const h = Math.abs(currentY - startY);
if (w > 10 && h > 10) {
roiRect = { x, y, w, h };
drawROI();
startMotionDetection();
} else {
roiRect = null;
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
stopMotionDetection();
}
});
});
function drawROI() {
if (!roiRect) return;
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
overlayCtx.strokeStyle = '#10b981';
overlayCtx.lineWidth = 2;
overlayCtx.setLineDash([]);
overlayCtx.strokeRect(roiRect.x, roiRect.y, roiRect.w, roiRect.h);
// Draw label
overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.7)';
overlayCtx.fillRect(roiRect.x, roiRect.y - 20, 100, 20);
overlayCtx.fillStyle = '#10b981';
overlayCtx.font = '12px monospace';
overlayCtx.fillText('ROI: AC Ribbon', roiRect.x + 5, roiRect.y - 5);
}
// --- Motion Detection Logic ---
function startMotionDetection() {
if (detectionInterval) clearInterval(detectionInterval);
previousImageData = null;
$('#ui-indicator').removeClass('hidden');
addLog("IMOU-CAM-01", "ROI Configured", "Info", "Started monitoring AC ribbon.");
// Run detection every 1000ms (1 second)
detectionInterval = setInterval(processFrame, 1000);
}
function stopMotionDetection() {
if (detectionInterval) clearInterval(detectionInterval);
$('#ui-indicator').addClass('hidden');
updateSystemBadge(false, true); // Reset to waiting state
}
function processFrame() {
if (!roiRect || !mjpegImg.complete || mjpegImg.naturalWidth === 0) return;
// Map canvas ROI coordinates to image natural coordinates
const scaleX = mjpegImg.naturalWidth / overlayCanvas.width;
const scaleY = mjpegImg.naturalHeight / overlayCanvas.height;
const sx = roiRect.x * scaleX;
const sy = roiRect.y * scaleY;
const sw = roiRect.w * scaleX;
const sh = roiRect.h * scaleY;
processCanvas.width = sw;
processCanvas.height = sh;
// Draw the ROI part of the image onto the hidden canvas
try {
processCtx.drawImage(mjpegImg, sx, sy, sw, sh, 0, 0, sw, sh);
} catch(e) {
// If stream is broken, ignore
return;
}
const currentImageData = processCtx.getImageData(0, 0, sw, sh);
if (previousImageData) {
// Compare pixels
let diffPixels = 0;
const threshold = 30; // Pixel color difference threshold
for (let i = 0; i < currentImageData.data.length; i += 4) {
const rDiff = Math.abs(currentImageData.data[i] - previousImageData.data[i]);
const gDiff = Math.abs(currentImageData.data[i+1] - previousImageData.data[i+1]);
const bDiff = Math.abs(currentImageData.data[i+2] - previousImageData.data[i+2]);
if (rDiff > threshold || gDiff > threshold || bDiff > threshold) {
diffPixels++;
}
}
const totalPixels = sw * sh;
const diffRatio = diffPixels / totalPixels;
// If more than 5% of pixels changed, we consider it "motion"
isAcRunning = diffRatio > 0.05;
updateUIState(isAcRunning);
}
// Save current frame as previous for next iteration
previousImageData = currentImageData;
}
function updateUIState(isRunning) {
const ribbon = $('#virtual-ribbon');
if (isRunning) {
ribbon.removeClass('animate-ribbon-still').addClass('animate-ribbon-flutter').css('transform', '');
updateSystemBadge(true, false);
} else {
ribbon.removeClass('animate-ribbon-flutter').addClass('animate-ribbon-still').css('transform', 'rotate(0deg)');
updateSystemBadge(false, false);
}
// Log state changes
if (isRunning !== previousAcState) {
if (isRunning) {
showToast(`Recovery: Airflow restored`, 'success');
addLog("IMOU-CAM-01", "AC-1", `<span class="text-emerald-400 font-medium">RUNNING</span>`, `Ribbon motion detected.`);
sendNotification(`✅ RECOVERY: AC Started\nStatus: Airflow Restored.`);
} else {
showToast(`Alert: No airflow detected`, 'error');
addLog("IMOU-CAM-01", "AC-1", `<span class="text-red-400 font-medium">STOPPED</span>`, `Ribbon motion ceased.`);
sendNotification(`🚨 ALERT: AC Stopped!\nStatus: No Airflow Detected.`);
}
previousAcState = isRunning;
}
}
function updateSystemBadge(isRunning, isWaiting) {
const sysDot = $('#sys-status-dot');
const sysText = $('#sys-status-text');
const mainCard = $('#main-cam-card');
const acStatusLabel = $('#ac-status-label');
if (isWaiting) {
sysDot.removeClass('bg-red-500 bg-emerald-500').addClass('bg-amber-500 animate-pulse');
sysText.removeClass('text-red-400 text-emerald-400').addClass('text-amber-400').text('DRAW ROI');
mainCard.removeClass('shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40').addClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50');
acStatusLabel.text('WAITING').removeClass('text-emerald-400 text-red-400').addClass('text-gray-200');
} else if (isRunning) {
sysDot.removeClass('bg-red-500 bg-amber-500').addClass('bg-emerald-500 animate-pulse');
sysText.removeClass('text-red-400 text-amber-400').addClass('text-emerald-400').text('NORMAL');
mainCard.removeClass('shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40 shadow-[0_0_20px_rgba(245,158,11,0.2)] border-amber-900/40').addClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50');
acStatusLabel.text('AIRFLOW DETECTED').removeClass('text-gray-200 text-red-400').addClass('text-emerald-400');
} else {
sysDot.removeClass('bg-emerald-500 bg-amber-500 animate-pulse').addClass('bg-red-500 animate-pulse');
sysText.removeClass('text-emerald-400 text-amber-400').addClass('text-red-400').text('CRITICAL');
mainCard.removeClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50 shadow-[0_0_20px_rgba(245,158,11,0.2)] border-amber-900/40').addClass('shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40');
acStatusLabel.text('NO AIRFLOW').removeClass('text-gray-200 text-emerald-400').addClass('text-red-400');
}
}
// --- Settings & Notification Logic ---
function loadSettings() {
const saved = localStorage.getItem('imouAcMonitorConfig');
if (saved) {
notifyConfig = JSON.parse(saved);
$('#toggleLine').prop('checked', notifyConfig.line.enabled);
$('#lineToken').val(notifyConfig.line.token);
$('#toggleTelegram').prop('checked', notifyConfig.telegram.enabled);
$('#tgBotToken').val(notifyConfig.telegram.token);
$('#tgChatId').val(notifyConfig.telegram.chatId);
}
}
function openSettings() { $('#settings-modal').removeClass('hidden'); }
function closeSettings() { $('#settings-modal').addClass('hidden'); }
function saveSettings() {
notifyConfig.line.enabled = $('#toggleLine').is(':checked');
notifyConfig.line.token = $('#lineToken').val();
notifyConfig.telegram.enabled = $('#toggleTelegram').is(':checked');
notifyConfig.telegram.token = $('#tgBotToken').val();
notifyConfig.telegram.chatId = $('#tgChatId').val();
localStorage.setItem('imouAcMonitorConfig', JSON.stringify(notifyConfig));
closeSettings();
showToast('Settings saved successfully', 'success');
}
async function sendNotification(message) {
const time = new Date().toLocaleTimeString('th-TH');
const fullMessage = `[${time}]\n${message}`;
if (notifyConfig.telegram.enabled && notifyConfig.telegram.token && notifyConfig.telegram.chatId) {
const url = `https://api.telegram.org/bot${notifyConfig.telegram.token}/sendMessage`;
try {
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: notifyConfig.telegram.chatId, text: fullMessage })
}).catch(e => console.error("Telegram error:", e));
} catch (error) { console.error("Telegram request failed", error); }
}
if (notifyConfig.line.enabled && notifyConfig.line.token) {
const proxyUrl = 'https://corsproxy.io/?';
const targetUrl = 'https://notify-api.line.me/api/notify';
try {
const params = new URLSearchParams();
params.append('message', fullMessage);
fetch(proxyUrl + encodeURIComponent(targetUrl), {
method: 'POST',
headers: { 'Authorization': `Bearer ${notifyConfig.line.token}`, 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
}).catch(e => console.error("Line API CORS Error", e));
} catch (error) { console.error("Line request failed", error); }
}
}
function initClock() {
setInterval(() => {
const now = new Date();
$('#current-time').text(now.toLocaleTimeString('th-TH', { hour12: false }));
}, 1000);
}
function showToast(message, iconType) {
const Toast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 4000,
timerProgressBar: true,
background: '#1f2937',
color: '#fff',
iconColor: iconType === 'error' ? '#ef4444' : '#10b981',
didOpen: (toast) => {
toast.addEventListener('mouseenter', Swal.stopTimer)
toast.addEventListener('mouseleave', Swal.resumeTimer)
}
});
Toast.fire({ icon: iconType, title: message });
}
function addLog(camName, location, statusHtml, details) {
const now = new Date();
const timeStr = now.toLocaleTimeString('th-TH', { hour12: false }) + '.' + now.getMilliseconds().toString().padStart(3, '0');
logTable.row.add([
`<span class="text-gray-400 font-mono text-xs">${timeStr}</span>`,
`<span class="font-medium text-gray-200">${camName}</span> <br><span class="text-xs text-gray-500">${location}</span>`,
statusHtml,
`<span class="text-gray-400 text-sm">${details}</span>`
]).draw(false);
}
</script>
</body>
</html>