639 lines
30 KiB
HTML
639 lines
30 KiB
HTML
<!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> |