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
+639
View File
@@ -0,0 +1,639 @@
<!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;
}
/* CRT Scanline Effect for mock camera */
.cam-feed::after {
content: " ";
display: block;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06));
z-index: 10;
background-size: 100% 2px, 3px 100%;
pointer-events: 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;
}
</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 id="main-camera-view" class="w-full mt-6">
<!-- Single Camera view will be injected here by JS -->
</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>
<!-- 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>
// Mock Data for 1 Camera monitoring 3 AC Units in the same room
const roomCamera = {
id: 'main-cam',
name: "IMOU-WIDE-01",
location: "Main Auditorium",
status: "ONLINE",
acs: [
{id: 1, name: "AC-1 (Left)", isRunning: true, posX: "20%"},
{id: 2, name: "AC-2 (Center)", isRunning: true, posX: "50%"},
{id: 3, name: "AC-3 (Right)", isRunning: true, posX: "80%"}
]
};
let logTable;
// Notification Settings State
let notifyConfig = {
line: {enabled: false, token: ''},
telegram: {enabled: false, token: '', chatId: ''}
};
// Initialize Application
$(document).ready(function () {
loadSettings();
initClock();
renderCamera();
// Initialize DataTable
logTable = $('#logTable').DataTable({
"order": [[0, "desc"]],
"pageLength": 5,
"lengthMenu": [5, 10, 25],
"language": {
"emptyTable": "No events recorded yet."
}
});
// Initial logs
addLog(roomCamera.name, roomCamera.location, "System Start", "Camera online. Monitoring 3 AC units.");
sendNotification(`🚀 System Started\nCamera: ${roomCamera.name}\nMonitoring 3 AC units.`);
// Start Simulation Loop
setInterval(simulateACStatus, 3000); // Check every 3 seconds
});
// --- 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}`;
// Send via Telegram (Native CORS supported, ทำงานได้ 100% บนเบราว์เซอร์)
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);
}
}
// Send via LINE Notify
if (notifyConfig.line.enabled && notifyConfig.line.token) {
// ใช้ Proxy ชั่วคราวเพื่อเลี่ยง CORS ของ Browser (หากจะใช้จริงจังควรสร้าง Backend ส่ง API)
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);
}
}
}
// -------------------------------------
// Clock Function
function initClock() {
setInterval(() => {
const now = new Date();
$('#current-time').text(now.toLocaleTimeString('th-TH', {hour12: false}));
}, 1000);
}
function renderCamera() {
const container = $('#main-camera-view');
container.empty();
let acHtmlStr = '';
// Generate HTML for each AC inside the single camera feed
roomCamera.acs.forEach(ac => {
const statusText = ac.isRunning ? 'AIRFLOW DETECTED' : 'NO AIRFLOW';
const textColor = ac.isRunning ? 'text-emerald-400' : 'text-red-400';
const ribbonAnim = ac.isRunning ? 'animate-ribbon-flutter' : 'animate-ribbon-still';
const ribbonTransform = ac.isRunning ? '' : 'transform: rotate(0deg);';
acHtmlStr += `
<!-- AC Unit ${ac.id} -->
<div class="absolute top-[20%] -translate-x-1/2 -translate-y-1/2 w-[18%] md:w-[12%] h-1/4 bg-gray-300 rounded shadow-2xl flex justify-center items-end pb-1 border-b-4 border-gray-400 z-10 transition-all duration-300" style="left: ${ac.posX};">
<div class="absolute -top-6 text-center w-full text-xs font-bold text-gray-200 drop-shadow-md">
${ac.name}
</div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-4/5 h-1/3 bg-gray-800 rounded-sm"></div> <!-- AC Vent Dark Area -->
<!-- The Ribbon -->
<div class="ribbon ${ribbonAnim} z-20 h-[40px] md:h-[60px] w-[6px] md:w-[8px]" style="${ribbonTransform}" id="ribbon-${ac.id}"></div>
<!-- AI Tracking Box -->
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-[20%] w-[120%] h-[150%] border-2 border-dashed ${ac.isRunning ? 'border-emerald-500/50' : 'border-red-500/80'} rounded z-20 transition-colors duration-300" id="ai-box-${ac.id}">
<div class="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[8px] md:text-[10px] ${textColor} bg-black/70 px-1 font-mono rounded whitespace-nowrap" id="ai-text-${ac.id}">
${statusText}
</div>
</div>
</div>
`;
});
// Calculate overall system status
const allRunning = roomCamera.acs.every(ac => ac.isRunning);
const anyStopped = roomCamera.acs.some(ac => !ac.isRunning);
const borderGlow = anyStopped ? 'shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40' : 'shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50';
const cardHtml = `
<div class="glass-card rounded-xl p-4 md:p-6 flex flex-col gap-4 transition-all duration-500 border ${borderGlow}" 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">
${roomCamera.name}
<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-map-pin mr-1"></i>${roomCamera.location} (Wide View)</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 Units Running</p>
<p class="text-sm font-bold text-gray-200"><span id="active-count" class="text-blue-400">3</span> / 3</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-emerald-500 animate-pulse" id="sys-status-dot"></span>
<span class="text-xs font-mono text-emerald-400" id="sys-status-text">NORMAL</span>
</div>
</div>
</div>
<!-- Mock Camera Feed -->
<div class="cam-feed rounded-lg aspect-[21/9] md: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> 4K UHD | H.265
</div>
<div class="absolute bottom-3 right-3 text-[10px] md:text-xs font-mono text-red-400 bg-black/60 px-2 py-1 rounded z-30 animate-pulse backdrop-blur-sm border border-red-500/20 flex items-center gap-1">
<div class="w-2 h-2 rounded-full bg-red-500"></div> REC
</div>
<!-- Room Background Elements (Mock) -->
<div class="absolute inset-0 opacity-20 pointer-events-none">
<div class="absolute bottom-0 w-full h-1/2 bg-gradient-to-t from-blue-900/30 to-transparent"></div>
<div class="absolute w-full h-px bg-gray-600 top-1/4"></div> <!-- Wall Line -->
</div>
${acHtmlStr}
</div>
</div>
`;
container.append(cardHtml);
updateSystemBadge();
}
function simulateACStatus() {
let uiNeedsUpdate = false;
roomCamera.acs.forEach(ac => {
const rand = Math.random();
if (ac.isRunning && rand < 0.03) {
// AC Stops
ac.isRunning = false;
uiNeedsUpdate = true;
showToast(`Alert: No airflow detected at ${ac.name}`, 'error');
addLog(roomCamera.name, roomCamera.location, `<span class="text-red-400 font-medium">STOPPED</span>`, `Ribbon motion ceased on <b>${ac.name}</b>.`);
sendNotification(`🚨 ALERT: AC Stopped!\nLocation: ${roomCamera.location}\nUnit: ${ac.name}\nStatus: No Airflow Detected.`);
} else if (!ac.isRunning && rand < 0.15) {
// AC Starts again
ac.isRunning = true;
uiNeedsUpdate = true;
showToast(`Recovery: Airflow restored at ${ac.name}`, 'success');
addLog(roomCamera.name, roomCamera.location, `<span class="text-emerald-400 font-medium">RUNNING</span>`, `Ribbon motion detected on <b>${ac.name}</b>.`);
sendNotification(`✅ RECOVERY: AC Started\nLocation: ${roomCamera.location}\nUnit: ${ac.name}\nStatus: Airflow Restored.`);
}
});
if (uiNeedsUpdate) {
updateUIWithoutFullRender();
}
}
function updateSystemBadge() {
const activeCount = roomCamera.acs.filter(ac => ac.isRunning).length;
$('#active-count').text(activeCount);
const sysDot = $('#sys-status-dot');
const sysText = $('#sys-status-text');
const mainCard = $('#main-cam-card');
if (activeCount === 3) {
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('ALL 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');
} else if (activeCount === 0) {
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');
} else {
sysDot.removeClass('bg-emerald-500 bg-red-500 animate-pulse').addClass('bg-amber-500 animate-pulse');
sysText.removeClass('text-emerald-400 text-red-400').addClass('text-amber-400').text('PARTIAL FAILURE');
mainCard.removeClass('shadow-[0_0_15px_rgba(59,130,246,0.15)] border-blue-900/50 shadow-[0_0_20px_rgba(239,68,68,0.2)] border-red-900/40').addClass('shadow-[0_0_20px_rgba(245,158,11,0.2)] border-amber-900/40');
}
}
function updateUIWithoutFullRender() {
roomCamera.acs.forEach(ac => {
const ribbon = $(`#ribbon-${ac.id}`);
const aiBox = $(`#ai-box-${ac.id}`);
const aiText = $(`#ai-text-${ac.id}`);
if (ac.isRunning) {
ribbon.removeClass('animate-ribbon-still').addClass('animate-ribbon-flutter').css('transform', '');
aiBox.removeClass('border-red-500/80').addClass('border-emerald-500/50');
aiText.removeClass('text-red-400').addClass('text-emerald-400').text('AIRFLOW DETECTED');
} else {
ribbon.removeClass('animate-ribbon-flutter').addClass('animate-ribbon-still').css('transform', 'rotate(0deg)');
aiBox.removeClass('border-emerald-500/50').addClass('border-red-500/80');
aiText.removeClass('text-emerald-400').addClass('text-red-400').text('NO AIRFLOW');
}
});
updateSystemBadge();
}
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>