Paulownia commited on
Commit
27b973a
·
verified ·
1 Parent(s): 6545eb0

docker deployment

Browse files
All_weapon.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:47836d3c3a614948197aa4bc8cf95b1e572ba07f10486498d14c67bcab4bb7cf
3
+ size 155664349
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.9
2
+ FROM python:3.9
3
+
4
+ # Install system dependencies for OpenCV and GLib
5
+ USER root
6
+ RUN apt-get update && apt-get install -y \
7
+ libgl1 \
8
+ libglib2.0-0 \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Create a non-root user matching HF Spaces UID (1000)
12
+ RUN useradd -m -u 1000 user
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH \
15
+ YOLO_CONFIG_DIR=/tmp/Ultralytics \
16
+ MPLCONFIGDIR=/tmp/matplotlib
17
+
18
+ # Set working directory to user's home/app (Standard for spaces)
19
+ WORKDIR $HOME/app
20
+
21
+ # Copy only requirements first to cache dependencies
22
+ COPY --chown=user requirements.txt $HOME/app/requirements.txt
23
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
24
+
25
+ # Copy the rest of the application with correct ownership
26
+ COPY --chown=user . $HOME/app
27
+
28
+ # ensure uploads directory exists and is writable
29
+ RUN mkdir -p uploads && chmod 777 uploads
30
+
31
+ # Run as the user
32
+ USER user
33
+
34
+ # Command to run the application
35
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from sentinel_app_v13 import app
2
+
3
+ if __name__ == '__main__':
4
+ # Hugging Face Spaces Docker (and our requirements) expects the app to run on port 7860
5
+ # Port 8080 is reserved for local, so we won't conflict.
6
+ app.run(host='0.0.0.0', port=7860, debug=False)
face_mask.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9e7e8b18cfa8a154eb522f2b68b9ec7234ac48107f3089c71980f3172f0ada7
3
+ size 6240056
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ libgl1
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ opencv-python-headless
3
+ numpy
4
+ ultralytics
5
+ google-generativeai
6
+ filterpy
7
+ scikit-image
8
+ matplotlib
9
+ scipy
10
+ lap
11
+ requests
sentinel_app_v13.py ADDED
@@ -0,0 +1,1337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, math, time, datetime, threading, json, uuid
2
+ import requests as http_requests
3
+ from collections import deque
4
+ from flask import Flask, render_template, Response, jsonify, request
5
+ from werkzeug.utils import secure_filename
6
+ import cv2
7
+ import numpy as np
8
+ from ultralytics import YOLO
9
+ from sort import Sort
10
+ import google.generativeai as genai
11
+ from PIL import Image
12
+ import torch
13
+ import torchvision.transforms as T
14
+ from torchvision.models import resnet18, ResNet18_Weights
15
+
16
+ # Configure Gemini
17
+ GENAI_API_KEY = ''
18
+ genai.configure(api_key=GENAI_API_KEY)
19
+ model_gemini = genai.GenerativeModel('gemini-2.5-flash')
20
+
21
+ app = Flask(__name__)
22
+ app.config['UPLOAD_FOLDER'] = 'uploads'
23
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
24
+
25
+ # Load models
26
+ model_movement = YOLO('yolov8n.pt')
27
+ model_facemask = YOLO('face_mask.pt')
28
+ model_weapon = YOLO('All_weapon.pt')
29
+
30
+ # Re-ID Embedding Extractor (ResNet18)
31
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
32
+ reid_model = resnet18(weights=ResNet18_Weights.DEFAULT)
33
+ reid_model.fc = torch.nn.Identity() # Remove classification layer to get embeddings
34
+ reid_model.to(device)
35
+ reid_model.eval()
36
+
37
+ reid_transform = T.Compose([
38
+ T.ToPILImage(),
39
+ T.Resize((128, 64)),
40
+ T.ToTensor(),
41
+ T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
42
+ ])
43
+
44
+ print(f"All Models Loaded (V13 - Journey Tracker). Device: {device}")
45
+
46
+ # ════════════════════════════════════════════════════════════════
47
+ # GLOBAL STATE
48
+ # ════════════════════════════════════════════════════════════════
49
+ # Multi-module system: set of active detection modules
50
+ active_modules = {'movement'} # Default: movement active
51
+ red_alert = False
52
+
53
+ # Per-feed video source management
54
+ # feed_id -> filepath (None means no source / webcam for feed 0)
55
+ feed_sources = {} # e.g. {0: '/path/to/clip1.mp4', 1: '/path/to/clip2.mp4'}
56
+
57
+ # Multi-camera feed management
58
+ camera_feeds = {}
59
+ feed_versions = {}
60
+ MAX_FEEDS = 4
61
+
62
+ # Multi-camera tracking
63
+ feed_trackers = {i: Sort() for i in range(MAX_FEEDS)}
64
+ person_history = {}
65
+ object_history = {}
66
+ journey_log = deque(maxlen=500)
67
+
68
+ class SubjectIdentityManager:
69
+ def __init__(self, threshold=0.7):
70
+ self.threshold = threshold
71
+ self.global_subjects = {} # global_id -> {'embedding': tensor, 'first_seen': timestamp}
72
+ self.local_to_global = {} # (feed_id, local_id) -> {'global_id': int, 'last_seen': float}
73
+ self.next_global_id = 100
74
+ self.lock = threading.Lock()
75
+
76
+ def get_embedding(self, face_img):
77
+ img_t = reid_transform(face_img).unsqueeze(0).to(device)
78
+ with torch.no_grad():
79
+ emb = reid_model(img_t)
80
+ return emb / emb.norm() # Normalize
81
+
82
+ def match_or_register(self, feed_id, local_id, frame, bbox):
83
+ x1, y1, x2, y2 = map(int, bbox)
84
+ h, w = frame.shape[:2]
85
+ x1, y1 = max(0, x1), max(0, y1)
86
+ x2, y2 = min(w, x2), min(h, y2)
87
+
88
+ if x2 <= x1 or y2 <= y1:
89
+ return None
90
+
91
+ crop = frame[y1:y2, x1:x2]
92
+ if crop.size == 0:
93
+ return None
94
+
95
+ key = (feed_id, local_id)
96
+ now = time.time()
97
+ with self.lock:
98
+ # Use cached mapping if fresh (< 3 seconds), otherwise re-evaluate
99
+ if key in self.local_to_global:
100
+ entry = self.local_to_global[key]
101
+ if now - entry['last_seen'] < 3.0:
102
+ entry['last_seen'] = now
103
+ return entry['global_id']
104
+ # Stale — re-evaluate embedding
105
+
106
+ new_emb = self.get_embedding(crop)
107
+
108
+ best_id = None
109
+ best_sim = -1
110
+
111
+ for gid, data in self.global_subjects.items():
112
+ sim = torch.mm(new_emb, data['embedding'].t()).item()
113
+ if sim > best_sim:
114
+ best_sim = sim
115
+ best_id = gid
116
+
117
+ if best_sim > self.threshold:
118
+ match_id = best_id
119
+ # Update embedding (moving average)
120
+ self.global_subjects[match_id]['embedding'] = (
121
+ 0.9 * self.global_subjects[match_id]['embedding'] + 0.1 * new_emb
122
+ )
123
+ else:
124
+ match_id = self.next_global_id
125
+ self.next_global_id += 1
126
+ self.global_subjects[match_id] = {
127
+ 'embedding': new_emb,
128
+ 'first_seen': now
129
+ }
130
+
131
+ self.local_to_global[key] = {'global_id': match_id, 'last_seen': now}
132
+ return match_id
133
+
134
+ def cleanup_feed(self, feed_id, active_local_ids):
135
+ """Remove stale local-to-global mappings for tracks no longer active."""
136
+ with self.lock:
137
+ stale = [k for k in self.local_to_global if k[0] == feed_id and k[1] not in active_local_ids]
138
+ for k in stale:
139
+ del self.local_to_global[k]
140
+
141
+ def get_global_id_cached(self, feed_id, local_id):
142
+ """Get cached global_id without re-evaluating (for re-id check)."""
143
+ key = (feed_id, local_id)
144
+ entry = self.local_to_global.get(key)
145
+ return entry['global_id'] if entry else None
146
+
147
+ identity_manager = SubjectIdentityManager()
148
+ previous_score = 0.0
149
+ ALPHA = 0.2
150
+
151
+ # ════════════════════════════════════════════════════════════════
152
+ # OPERATOR AUDIT LOG
153
+ # ════════════════════════════════════════════════════════════════
154
+ audit_log = deque(maxlen=200)
155
+ audit_lock = threading.Lock()
156
+
157
+ def log_audit(action, details="", severity="INFO"):
158
+ """Append an event to the operator audit log."""
159
+ entry = {
160
+ 'timestamp': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
161
+ 'action': action,
162
+ 'details': details,
163
+ 'severity': severity # INFO, WARNING, CRITICAL
164
+ }
165
+ with audit_lock:
166
+ audit_log.appendleft(entry)
167
+ if severity == "CRITICAL":
168
+ print(f"[AUDIT-CRITICAL] {entry['timestamp']} | {action} | {details}")
169
+
170
+ log_audit("SYSTEM_BOOT", "Sentinel V14 initializing — Dispatch Integration active.", "INFO")
171
+
172
+ # ════════════════════════════════════════════════════════════════
173
+ # TELEGRAM DISPATCH ENGINE
174
+ # ════════════════════════════════════════════════════════════════
175
+
176
+ class TelegramDispatcher:
177
+ """Sends alert messages via Telegram Bot API using simple HTTP requests."""
178
+
179
+ def __init__(self, bot_token='', chat_id=''):
180
+ self.bot_token = bot_token
181
+ self.chat_id = chat_id
182
+ self.base_url = ''
183
+ if bot_token:
184
+ self.base_url = f'https://api.telegram.org/bot{bot_token}'
185
+
186
+ def configure(self, bot_token, chat_id=''):
187
+ self.bot_token = bot_token
188
+ self.chat_id = chat_id
189
+ self.base_url = f'https://api.telegram.org/bot{bot_token}'
190
+
191
+ def is_configured(self):
192
+ return bool(self.bot_token and self.chat_id)
193
+
194
+ def send_message(self, text, parse_mode='HTML'):
195
+ """Send a text message to the configured chat."""
196
+ if not self.is_configured():
197
+ return {'ok': False, 'error': 'Telegram not configured (missing token or chat_id)'}
198
+ try:
199
+ resp = http_requests.post(
200
+ f'{self.base_url}/sendMessage',
201
+ json={
202
+ 'chat_id': self.chat_id,
203
+ 'text': text,
204
+ 'parse_mode': parse_mode
205
+ },
206
+ timeout=10
207
+ )
208
+ result = resp.json()
209
+ if result.get('ok'):
210
+ log_audit("DISPATCH_SENT", f"Telegram message delivered to chat {self.chat_id}", "INFO")
211
+ else:
212
+ log_audit("DISPATCH_FAILED", f"Telegram API error: {result.get('description', 'Unknown')}", "WARNING")
213
+ return result
214
+ except Exception as e:
215
+ log_audit("DISPATCH_ERROR", f"Network error: {str(e)}", "WARNING")
216
+ return {'ok': False, 'error': str(e)}
217
+
218
+ def test_connection(self):
219
+ """Send a test message to verify the bot is working."""
220
+ test_msg = (
221
+ "🛡️ <b>SENTINEL DISPATCH — TEST</b>\n\n"
222
+ "✅ Connection verified.\n"
223
+ "This bot is now linked to Project SENTINEL.\n\n"
224
+ f"{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
225
+ "<i>Automated threat alerts will be delivered here.</i>"
226
+ )
227
+ return self.send_message(test_msg)
228
+
229
+ def auto_detect_chat_id(self):
230
+ """Try to detect chat_id from recent /start messages."""
231
+ if not self.bot_token:
232
+ return None
233
+ try:
234
+ resp = http_requests.get(f'{self.base_url}/getUpdates', timeout=10)
235
+ data = resp.json()
236
+ if data.get('ok') and data.get('result'):
237
+ for update in reversed(data['result']):
238
+ msg = update.get('message', {})
239
+ chat = msg.get('chat', {})
240
+ if chat.get('id'):
241
+ self.chat_id = str(chat['id'])
242
+ log_audit("DISPATCH_CONFIG", f"Auto-detected chat ID: {self.chat_id}", "INFO")
243
+ return self.chat_id
244
+ except:
245
+ pass
246
+ return None
247
+
248
+ def send_threat_alert(self, threat_score, details, active_modules):
249
+ """Format and send a threat alert message."""
250
+ modules_str = ', '.join([m.upper() for m in active_modules]) if active_modules else 'NONE'
251
+ timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
252
+
253
+ # Build detail lines
254
+ detail_lines = []
255
+ for module, data in details.items():
256
+ if isinstance(data, dict):
257
+ for k, v in data.items():
258
+ if v and v != 0 and v != False:
259
+ label = k.replace('_', ' ').title()
260
+ if isinstance(v, bool):
261
+ v = '⚠️ YES'
262
+ detail_lines.append(f" • {label}: {v}")
263
+
264
+ details_text = '\n'.join(detail_lines) if detail_lines else ' No specific details'
265
+
266
+ severity = '🔴 CRITICAL' if threat_score >= 80 else '🟠 ELEVATED' if threat_score >= 50 else '🟡 GUARDED'
267
+
268
+ msg = (
269
+ f"🚨 <b>SENTINEL THREAT ALERT</b>\n\n"
270
+ f"<b>Threat Level:</b> {severity} ({threat_score}/100)\n"
271
+ f"<b>Active Modules:</b> {modules_str}\n"
272
+ f"<b>Time:</b> {timestamp}\n\n"
273
+ f"<b>Detection Details:</b>\n{details_text}\n\n"
274
+ f"🏛️ <i>Project SENTINEL — Automated Dispatch</i>"
275
+ )
276
+ return self.send_message(msg)
277
+
278
+
279
+ # Initialize dispatcher with the bot token
280
+ telegram_dispatcher = TelegramDispatcher(
281
+ bot_token='8659917680:AAFHai-uliSdnX2zNhKL_-a5fZV_x0DcJ2E',
282
+ chat_id='8521681859' # Paulo's Telegram chat ID
283
+ )
284
+
285
+ # ════════════════════════════════════════════════════════════════
286
+ # DISPATCH STATE MANAGEMENT
287
+ # ════════════════════════════════════════════════════════════════
288
+
289
+ dispatch_log = deque(maxlen=100) # History of all dispatch events
290
+ pending_approvals = {} # id -> dispatch event awaiting approval
291
+ dispatch_lock = threading.Lock()
292
+
293
+ # Dispatch settings
294
+ dispatch_settings = {
295
+ 'auto_dispatch': False, # If True, send alerts immediately; if False, queue for approval
296
+ 'cooldown_seconds': 60, # Minimum seconds between auto-dispatches
297
+ 'enabled': True, # Master switch
298
+ 'last_dispatch_time': 0, # Timestamp of last sent alert
299
+ }
300
+
301
+ def create_dispatch_event(threat_score, details, active_modules):
302
+ """Create a dispatch event and either auto-send or queue for approval."""
303
+ event_id = str(uuid.uuid4())[:8]
304
+ now = time.time()
305
+ event = {
306
+ 'id': event_id,
307
+ 'timestamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
308
+ 'threat_score': threat_score,
309
+ 'details': dict(details) if details else {},
310
+ 'active_modules': list(active_modules),
311
+ 'status': 'pending', # pending, sent, rejected, failed
312
+ 'created_at': now,
313
+ }
314
+
315
+ if not dispatch_settings['enabled']:
316
+ return
317
+
318
+ if not telegram_dispatcher.is_configured():
319
+ event['status'] = 'failed'
320
+ event['error'] = 'Telegram not configured'
321
+ with dispatch_lock:
322
+ dispatch_log.appendleft(event)
323
+ log_audit("DISPATCH_SKIPPED", "Telegram not configured — alert not sent", "WARNING")
324
+ return
325
+
326
+ # Check cooldown
327
+ if now - dispatch_settings['last_dispatch_time'] < dispatch_settings['cooldown_seconds']:
328
+ remaining = int(dispatch_settings['cooldown_seconds'] - (now - dispatch_settings['last_dispatch_time']))
329
+ log_audit("DISPATCH_COOLDOWN", f"Alert suppressed — cooldown active ({remaining}s remaining)", "INFO")
330
+ return
331
+
332
+ if dispatch_settings['auto_dispatch']:
333
+ # Send immediately
334
+ result = telegram_dispatcher.send_threat_alert(threat_score, details, active_modules)
335
+ event['status'] = 'sent' if result.get('ok') else 'failed'
336
+ if not result.get('ok'):
337
+ event['error'] = result.get('error', result.get('description', 'Unknown'))
338
+ dispatch_settings['last_dispatch_time'] = now
339
+ with dispatch_lock:
340
+ dispatch_log.appendleft(event)
341
+ log_audit("AUTO_DISPATCH", f"Threat alert auto-dispatched (score: {threat_score})", "CRITICAL")
342
+ else:
343
+ # Queue for human approval
344
+ with dispatch_lock:
345
+ pending_approvals[event_id] = event
346
+ log_audit("DISPATCH_QUEUED", f"Alert queued for operator approval (score: {threat_score})", "WARNING")
347
+
348
+ def approve_dispatch_event(event_id):
349
+ """Operator approves a pending dispatch."""
350
+ with dispatch_lock:
351
+ event = pending_approvals.pop(event_id, None)
352
+ if not event:
353
+ return {'error': 'Event not found or already processed'}
354
+
355
+ result = telegram_dispatcher.send_threat_alert(
356
+ event['threat_score'], event['details'], event['active_modules']
357
+ )
358
+ event['status'] = 'sent' if result.get('ok') else 'failed'
359
+ if not result.get('ok'):
360
+ event['error'] = result.get('error', result.get('description', 'Unknown'))
361
+ event['approved_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
362
+ dispatch_settings['last_dispatch_time'] = time.time()
363
+
364
+ with dispatch_lock:
365
+ dispatch_log.appendleft(event)
366
+ log_audit("DISPATCH_APPROVED", f"Operator approved alert {event_id} (score: {event['threat_score']})", "CRITICAL")
367
+ return {'success': True, 'status': event['status']}
368
+
369
+ def reject_dispatch_event(event_id):
370
+ """Operator rejects a pending dispatch."""
371
+ with dispatch_lock:
372
+ event = pending_approvals.pop(event_id, None)
373
+ if not event:
374
+ return {'error': 'Event not found or already processed'}
375
+
376
+ event['status'] = 'rejected'
377
+ event['rejected_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
378
+ with dispatch_lock:
379
+ dispatch_log.appendleft(event)
380
+ log_audit("DISPATCH_REJECTED", f"Operator rejected alert {event_id}", "INFO")
381
+ return {'success': True}
382
+
383
+ # ════════════════════════════════════════════════════════════════
384
+ # GIF HANDLER
385
+ # ════════════════════════════════════════════════════════════════
386
+ class GIFReader:
387
+ """Handles GIF file reading and frame extraction."""
388
+ def __init__(self, filepath):
389
+ self.filepath = filepath
390
+ self.gif = Image.open(filepath)
391
+ self.frame_count = 0
392
+ try:
393
+ while True:
394
+ self.gif.seek(self.frame_count)
395
+ self.frame_count += 1
396
+ except EOFError:
397
+ pass
398
+
399
+ self.current_frame_idx = 0
400
+ # Get duration for each frame (in milliseconds)
401
+ try:
402
+ self.gif.seek(0)
403
+ self.frame_duration = self.gif.info.get('duration', 100) / 1000.0 # Convert to seconds
404
+ except:
405
+ self.frame_duration = 0.04 # Default ~25 FPS
406
+
407
+ def get_next_frame(self):
408
+ """Get the next frame from GIF."""
409
+ try:
410
+ self.gif.seek(self.current_frame_idx)
411
+ frame = self.gif.convert('RGB')
412
+ frame_np = np.array(frame)
413
+ # Convert RGB to BGR for OpenCV
414
+ frame_bgr = cv2.cvtColor(frame_np, cv2.COLOR_RGB2BGR)
415
+
416
+ self.current_frame_idx = (self.current_frame_idx + 1) % self.frame_count
417
+ return True, frame_bgr, self.frame_duration
418
+ except Exception as e:
419
+ print(f"[GIF] Error reading frame: {e}")
420
+ return False, None, 0
421
+
422
+ def close(self):
423
+ if self.gif:
424
+ self.gif.close()
425
+
426
+ # ════════════════════════════════════════════════════════════════
427
+ # THREADED VIDEO CAPTURE (OPTIMIZED FOR LARGE FILES)
428
+ # ════════════════════════════════════════════════════════════════
429
+ class VideoCamera:
430
+ def __init__(self, src=0, feed_id=0):
431
+ self.feed_id = feed_id
432
+ self.src = src
433
+ self.source_is_file = isinstance(src, str)
434
+ self.is_gif = False
435
+ self.gif_reader = None
436
+ self.stream = None
437
+ self.lock = threading.Lock()
438
+ self.frame_buffer = deque(maxlen=2) # Keep only last 2 frames to reduce memory
439
+
440
+ # Check if source is a GIF
441
+ if self.source_is_file and src.lower().endswith('.gif'):
442
+ self.is_gif = True
443
+ try:
444
+ self.gif_reader = GIFReader(src)
445
+ self.grabbed = True
446
+ success, frame, duration = self.gif_reader.get_next_frame()
447
+ self.frame = frame
448
+ self.target_delay = duration
449
+ self.stopped = False
450
+ print(f"[V8] GIF loaded: {src} ({self.gif_reader.frame_count} frames)")
451
+ except Exception as e:
452
+ print(f"[WARNING] Could not open GIF: {src} - {e}")
453
+ self.grabbed = False
454
+ self.frame = None
455
+ self.stopped = True
456
+ return
457
+ else:
458
+ # Regular video file or camera
459
+ self.stream = cv2.VideoCapture(src)
460
+
461
+ if not self.stream.isOpened():
462
+ print(f"[WARNING] Could not open video source: {src}")
463
+ self.grabbed = False
464
+ self.frame = None
465
+ self.stopped = True
466
+ return
467
+
468
+ (self.grabbed, self.frame) = self.stream.read()
469
+ self.stopped = False
470
+
471
+ # For file sources: read at the file's native FPS to simulate live playback
472
+ self.target_delay = 0
473
+ if self.source_is_file:
474
+ fps = self.stream.get(cv2.CAP_PROP_FPS)
475
+ if fps and fps > 0:
476
+ self.target_delay = 1.0 / fps
477
+ print(f"[V8] Video loaded: {src} (FPS: {fps:.2f})")
478
+ else:
479
+ self.target_delay = 1.0 / 25 # fallback 25fps
480
+
481
+ self.t = threading.Thread(target=self.update, args=())
482
+ self.t.daemon = True
483
+ self.t.start()
484
+
485
+ def update(self):
486
+ while True:
487
+ if self.stopped:
488
+ return
489
+
490
+ frame_start = time.time()
491
+
492
+ if self.is_gif:
493
+ # Handle GIF frames
494
+ grabbed, frame, delay = self.gif_reader.get_next_frame()
495
+ else:
496
+ # Handle video/camera frames
497
+ (grabbed, frame) = self.stream.read()
498
+
499
+ if not grabbed and self.source_is_file:
500
+ # End of video file — loop it seamlessly like CCTV
501
+ self.stream.set(cv2.CAP_PROP_POS_FRAMES, 0)
502
+ (grabbed, frame) = self.stream.read()
503
+
504
+ with self.lock:
505
+ self.grabbed = grabbed
506
+ if grabbed and frame is not None:
507
+ self.frame = frame
508
+ # Clear buffer and add only current frame to minimize memory
509
+ self.frame_buffer.clear()
510
+ self.frame_buffer.append(frame)
511
+
512
+ # Throttle to native FPS
513
+ if self.target_delay > 0:
514
+ elapsed = time.time() - frame_start
515
+ sleep_time = max(0, self.target_delay - elapsed)
516
+ if sleep_time > 0:
517
+ time.sleep(sleep_time)
518
+
519
+ def get_frame(self):
520
+ # Handle case where thread wasn't started
521
+ if self.stopped and self.frame is None:
522
+ return False, None
523
+
524
+ with self.lock:
525
+ return self.grabbed, self.frame.copy() if self.frame is not None else (False, None)
526
+
527
+ def stop(self):
528
+ self.stopped = True
529
+ if hasattr(self, 't') and self.t.is_alive():
530
+ self.t.join(timeout=2)
531
+
532
+ if self.is_gif and self.gif_reader:
533
+ self.gif_reader.close()
534
+ elif self.stream:
535
+ self.stream.release()
536
+
537
+ def set_source(self, src):
538
+ self.stop()
539
+ self.__init__(src, self.feed_id)
540
+ # Note: __init__ starts the new thread if successful
541
+
542
+ def is_opened(self):
543
+ if self.is_gif:
544
+ return self.gif_reader is not None
545
+ return self.stream is not None and self.stream.isOpened()
546
+
547
+ def get(self, prop):
548
+ if self.is_gif:
549
+ if prop == cv2.CAP_PROP_FPS:
550
+ return 1.0 / self.target_delay if self.target_delay > 0 else 25
551
+ return 0
552
+ return self.stream.get(prop) if self.stream else 0
553
+
554
+ def set(self, prop, val):
555
+ if not self.is_gif and self.stream:
556
+ self.stream.set(prop, val)
557
+
558
+
559
+ def get_or_create_feed(feed_id=0, src=None):
560
+ """Get existing feed or create a new one."""
561
+ global camera_feeds
562
+ if feed_id in camera_feeds and camera_feeds[feed_id].is_opened():
563
+ return camera_feeds[feed_id]
564
+
565
+ if src is None:
566
+ # Check per-feed source first
567
+ if feed_id in feed_sources and os.path.exists(feed_sources[feed_id]):
568
+ src = feed_sources[feed_id]
569
+ elif feed_id == 0:
570
+ src = 0 # Default webcam for feed 0 only
571
+ else:
572
+ return None # No source for this feed
573
+
574
+ cam = VideoCamera(src, feed_id)
575
+ camera_feeds[feed_id] = cam
576
+ log_audit("FEED_CREATED", f"Feed #{feed_id} initialized (source: {src})", "INFO")
577
+ return cam
578
+
579
+
580
+ def restart_feed(feed_id=0):
581
+ """Restart a specific feed and bump the version so generators re-acquire."""
582
+ global camera_feeds, feed_versions
583
+ if feed_id in camera_feeds:
584
+ camera_feeds[feed_id].stop()
585
+ del camera_feeds[feed_id]
586
+
587
+ src = feed_sources.get(feed_id)
588
+ if src and os.path.exists(src):
589
+ camera_feeds[feed_id] = VideoCamera(src, feed_id)
590
+ elif feed_id == 0:
591
+ camera_feeds[feed_id] = VideoCamera(0, feed_id)
592
+ # else: no source, feed stays empty
593
+
594
+ feed_versions[feed_id] = feed_versions.get(feed_id, 0) + 1
595
+ log_audit("FEED_RESTART", f"Feed #{feed_id} restarted (v{feed_versions[feed_id]}).", "INFO")
596
+
597
+
598
+ # ════════════════════════════════════════════════════════════════
599
+ # SCORING & DETECTION LOGIC
600
+ # ════════════════════════════════════════════════════════════════
601
+ def smooth_score(new_score):
602
+ global previous_score
603
+ smoothed = ALPHA * new_score + (1 - ALPHA) * previous_score
604
+ previous_score = smoothed
605
+ return int(smoothed)
606
+
607
+
608
+ def calculate_threat_score(mode, details):
609
+ score = 0
610
+ if mode == 'weapon':
611
+ score += details.get('guns', 0) * 100
612
+ score += details.get('knives', 0) * 80
613
+ elif mode == 'facemask':
614
+ score += details.get('with_mask', 0) * 60
615
+ score += details.get('obscured_faces', 0) * 90
616
+ elif mode == 'movement':
617
+ score += details.get('long_stays', 0) * 40
618
+ elif mode == 'public_safety':
619
+ score += details.get('abandoned_objects', 0) * 70
620
+ if details.get('crowd_panic', False):
621
+ score += 85
622
+ return min(score, 100)
623
+
624
+
625
+ # ════════════════════════════════════════════════════════════════
626
+ # FRAME GENERATOR (per-feed) - OPTIMIZED
627
+ # ════════════════════════════════════════════════════════════════
628
+ def generate_frames(feed_id=0):
629
+ global active_modules, person_history, object_history, previous_score, red_alert
630
+
631
+ camera = get_or_create_feed(feed_id)
632
+ my_version = feed_versions.get(feed_id, 0)
633
+
634
+ while True:
635
+ # Detect if the feed was restarted (e.g. user uploaded a video or switched source)
636
+ current_version = feed_versions.get(feed_id, 0)
637
+ if current_version != my_version:
638
+ # Feed was swapped — re-acquire the new camera
639
+ my_version = current_version
640
+ camera = camera_feeds.get(feed_id)
641
+ if camera is None:
642
+ time.sleep(0.1)
643
+ continue
644
+
645
+ if not camera.is_opened():
646
+ time.sleep(0.1)
647
+ camera = get_or_create_feed(feed_id)
648
+ my_version = feed_versions.get(feed_id, 0)
649
+ continue
650
+
651
+ success, img = camera.get_frame()
652
+
653
+ if not success or img is None:
654
+ # Generate a "NO SIGNAL" placeholder frame
655
+ blank_frame = np.zeros((480, 640, 3), dtype=np.uint8)
656
+ cv2.putText(blank_frame, "NO SIGNAL / CAMERA UNAVAILABLE", (100, 240),
657
+ cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
658
+ cv2.putText(blank_frame, "Please use 'Upload Video' on Server", (120, 280),
659
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 200, 200), 1)
660
+
661
+ # Encode and yield
662
+ ret, buffer = cv2.imencode('.jpg', blank_frame)
663
+ frame_bytes = buffer.tobytes()
664
+ yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n' b'X-Score: 0\r\n\r\n' + frame_bytes + b'\r\n')
665
+ time.sleep(1.0) # Slow update for static error frame
666
+ continue
667
+
668
+ # Resize for performance
669
+ h, w = img.shape[:2]
670
+ new_w = 640
671
+ new_h = int(h * (new_w / w))
672
+ frame = cv2.resize(img, (new_w, new_h))
673
+ frame = cv2.GaussianBlur(frame, (3, 3), 0)
674
+
675
+ current_time = time.time()
676
+ current_details = {}
677
+
678
+ if 'movement' in active_modules:
679
+ results = model_movement(frame, stream=True, verbose=False)
680
+ detections = np.empty((0, 5))
681
+ for r in results:
682
+ for box in r.boxes:
683
+ if int(box.cls[0]) == 0:
684
+ x1, y1, x2, y2 = map(int, box.xyxy[0])
685
+ conf = math.ceil(box.conf[0] * 100) / 100
686
+ detections = np.vstack((detections, [x1, y1, x2, y2, conf]))
687
+ # Use feed-specific tracker
688
+ tracks = feed_trackers[feed_id].update(detections)
689
+ current_used_ids = []
690
+ journey_active = 'suspect_journey' in active_modules
691
+
692
+ for tr in tracks:
693
+ x1, y1, x2, y2, local_id = tr
694
+
695
+ is_reidentified = False
696
+ display_id = f"L{int(local_id)}"
697
+
698
+ # Only run Re-ID when suspect_journey module is active
699
+ if journey_active:
700
+ global_id = identity_manager.match_or_register(feed_id, local_id, frame, (x1, y1, x2, y2))
701
+ if global_id is not None:
702
+ # Check if this subject exists on ANY other feed
703
+ for key, entry in identity_manager.local_to_global.items():
704
+ gid = entry['global_id'] if isinstance(entry, dict) else entry
705
+ if gid == global_id and key[0] != feed_id:
706
+ is_reidentified = True
707
+ break
708
+ display_id = global_id
709
+ else:
710
+ display_id = int(local_id)
711
+
712
+ midX = int((x1 + x2) / 2)
713
+ headX, headY = midX, int(y1)
714
+
715
+ # Track journey (only when journey module is active)
716
+ if journey_active:
717
+ journey_log.append({
718
+ 'global_id': display_id,
719
+ 'feed_id': feed_id,
720
+ 'timestamp': datetime.datetime.now().strftime("%H:%M:%S"),
721
+ 'local_id': int(local_id)
722
+ })
723
+
724
+ pid = display_id
725
+ if pid not in person_history:
726
+ person_history[pid] = []
727
+ person_history[pid].append((headX, headY, current_time))
728
+ person_history[pid] = [p for p in person_history[pid] if current_time - p[2] < 5.0]
729
+
730
+ if len(person_history[pid]) > 1:
731
+ pts = np.array([(p[0], p[1]) for p in person_history[pid]], np.int32).reshape((-1, 1, 2))
732
+ cv2.polylines(frame, [pts], False, (0, 255, 0), 2)
733
+
734
+ target_prev = current_time - 1.0
735
+ prev_pt = None
736
+ if len(person_history[pid]) > 2:
737
+ diffs = [abs(p[2] - target_prev) for p in person_history[pid]]
738
+ min_idx = int(np.argmin(diffs))
739
+ if diffs[min_idx] < 0.5:
740
+ prev_pt = person_history[pid][min_idx]
741
+
742
+ if prev_pt:
743
+ dt = current_time - prev_pt[2]
744
+ if dt > 0.1:
745
+ vx = (headX - prev_pt[0]) / dt
746
+ vy = (headY - prev_pt[1]) / dt
747
+ predX = int(headX + vx * 4)
748
+ predY = int(headY + vy * 4)
749
+ cv2.arrowedLine(frame, (headX, headY), (predX, predY), (0, 255, 255), 2, tipLength=0.3)
750
+
751
+ # ── Visual markers ──
752
+ if is_reidentified:
753
+ # TRACKED subject: cyan reticle + thick border
754
+ cx, cy = int((x1 + x2) / 2), int((y1 + y2) / 2)
755
+ r = min(int(x2 - x1), int(y2 - y1)) // 3
756
+ cv2.circle(frame, (cx, cy), r, (255, 255, 0), 2) # Cyan circle
757
+ cv2.line(frame, (cx - r - 5, cy), (cx + r + 5, cy), (255, 255, 0), 1) # H crosshair
758
+ cv2.line(frame, (cx, cy - r - 5), (cx, cy + r + 5), (255, 255, 0), 1) # V crosshair
759
+ cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (255, 255, 0), 3)
760
+ cv2.putText(frame, f"TRACKED #{display_id}", (int(x1), int(y1) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2)
761
+ else:
762
+ # Normal detection: magenta border
763
+ cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (255, 0, 255), 2)
764
+ cv2.putText(frame, f"ID:{display_id}", (int(x1), int(y1) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 255), 2)
765
+ current_used_ids.append(display_id)
766
+
767
+ # Cleanup stale Re-ID mappings for tracks no longer in this frame
768
+ if journey_active:
769
+ active_local_ids = set(int(tr[4]) for tr in tracks)
770
+ identity_manager.cleanup_feed(feed_id, active_local_ids)
771
+
772
+ long_stays = sum(1 for hist in person_history.values() if len(hist) > 30)
773
+ current_details['movement'] = {'total_people': len(person_history), 'current_people': len(current_used_ids), 'long_stays': long_stays}
774
+
775
+ if 'facemask' in active_modules:
776
+ results_mask = model_facemask(frame, stream=True, verbose=False)
777
+ with_mask = 0
778
+ without_mask = 0
779
+ mask_boxes = []
780
+
781
+ for r in results_mask:
782
+ for box in r.boxes:
783
+ cls = int(box.cls[0])
784
+ x1, y1, x2, y2 = map(int, box.xyxy[0])
785
+ mask_boxes.append((x1, y1, x2, y2))
786
+
787
+ if cls == 0:
788
+ with_mask += 1
789
+ color = (0, 0, 255)
790
+ label = "Mask (THREAT)"
791
+ else:
792
+ without_mask += 1
793
+ color = (0, 255, 0)
794
+ label = "No Mask"
795
+ cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
796
+ cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 2)
797
+
798
+ obscured_faces = 0
799
+ people_res = model_movement(frame, stream=True, verbose=False, classes=[0])
800
+ people_boxes = []
801
+ for r in people_res:
802
+ for box in r.boxes:
803
+ people_boxes.append(tuple(map(int, box.xyxy[0])))
804
+
805
+ def overlap(person, face):
806
+ px1, py1, px2, py2 = person
807
+ fx1, fy1, fx2, fy2 = face
808
+ upper = py1 + (py2 - py1) // 1.5
809
+ return fx1 >= px1 and fx2 <= px2 and fy1 >= py1 and fy2 <= upper
810
+
811
+ for pbox in people_boxes:
812
+ if not any(overlap(pbox, mbox) for mbox in mask_boxes):
813
+ obscured_faces += 1
814
+ cv2.rectangle(frame, (pbox[0], pbox[1]), (pbox[2], pbox[3]), (0, 0, 255), 3)
815
+ cv2.putText(frame, "Face Obscured (THREAT)", (pbox[0], pbox[1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
816
+
817
+ current_details['facemask'] = {'with_mask': with_mask, 'without_mask': without_mask, 'obscured_faces': obscured_faces}
818
+
819
+ if 'weapon' in active_modules:
820
+ h_w, w_w = frame.shape[:2]
821
+ weapon_w = 416
822
+ weapon_h = int(h_w * (weapon_w / w_w))
823
+ weapon_frame = cv2.resize(frame, (weapon_w, weapon_h))
824
+
825
+ results = model_weapon(weapon_frame, stream=True, verbose=False, conf=0.5)
826
+ guns = 0
827
+ melee = 0
828
+
829
+ scale_x = w_w / weapon_w
830
+ scale_y = h_w / weapon_h
831
+
832
+ firearm_keywords = ['gun', 'pistol', 'rifle', 'shotgun', 'sniper', 'machine', 'glock', 'ak47', 'm4', 'awp', 'famas', 'galil', 'mp5', 'p90']
833
+ melee_keywords = ['knife', 'sword', 'dagger', 'axe', 'bat', 'stick', 'machete', 'blade']
834
+
835
+ for r in results:
836
+ for box in r.boxes:
837
+ cls = int(box.cls[0])
838
+ conf = float(box.conf[0])
839
+ x1, y1, x2, y2 = box.xyxy[0]
840
+ x1 = int(x1 * scale_x)
841
+ y1 = int(y1 * scale_y)
842
+ x2 = int(x2 * scale_x)
843
+ y2 = int(y2 * scale_y)
844
+
845
+ class_name = model_weapon.names[cls].lower()
846
+ is_firearm = any(k in class_name for k in firearm_keywords)
847
+ is_melee = any(k in class_name for k in melee_keywords)
848
+
849
+ if is_firearm:
850
+ guns += 1
851
+ color = (0, 0, 255)
852
+ label = f"{class_name.upper()} {conf:.2f}"
853
+ elif is_melee:
854
+ melee += 1
855
+ color = (0, 165, 255)
856
+ label = f"{class_name.upper()} {conf:.2f}"
857
+ else:
858
+ guns += 1
859
+ color = (0, 0, 255)
860
+ label = f"{class_name.upper()} {conf:.2f}"
861
+
862
+ cv2.rectangle(frame, (x1, y1), (x2, y2), color, 3)
863
+ cv2.putText(frame, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
864
+ cv2.rectangle(frame, (0, 0), (new_w, new_h), (0, 255, 0), 5)
865
+
866
+ current_details['weapon'] = {'guns': guns, 'knives': melee}
867
+
868
+ if 'public_safety' in active_modules:
869
+ results = model_movement(frame, stream=True, verbose=False, classes=[0, 24, 26, 28])
870
+ detections = np.empty((0, 5))
871
+ object_detections = []
872
+
873
+ for r in results:
874
+ for box in r.boxes:
875
+ cls = int(box.cls[0])
876
+ x1, y1, x2, y2 = map(int, box.xyxy[0])
877
+ conf = float(box.conf[0])
878
+ if cls == 0:
879
+ detections = np.vstack((detections, [x1, y1, x2, y2, conf]))
880
+ else:
881
+ object_detections.append({'box': [x1, y1, x2, y2], 'cls': cls, 'conf': conf})
882
+ cv2.rectangle(frame, (x1, y1), (x2, y2), (255, 200, 0), 1)
883
+
884
+ tracks = feed_trackers[feed_id].update(detections)
885
+ velocities = []
886
+ for tr in tracks:
887
+ x1, y1, x2, y2, Id = tr
888
+ midX, midY = int((x1 + x2) / 2), int((y1 + y2) / 2)
889
+ if Id not in person_history:
890
+ person_history[Id] = []
891
+ person_history[Id].append((midX, midY, current_time))
892
+ person_history[Id] = [p for p in person_history[Id] if current_time - p[2] < 2.0]
893
+
894
+ if len(person_history[Id]) > 2:
895
+ p_start = person_history[Id][0]
896
+ p_end = person_history[Id][-1]
897
+ dt = p_end[2] - p_start[2]
898
+ if dt > 0.5:
899
+ dist = math.sqrt((p_end[0] - p_start[0])**2 + (p_end[1] - p_start[1])**2)
900
+ speed = dist / dt
901
+ velocities.append(speed)
902
+
903
+ cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 1)
904
+
905
+ crowd_panic = False
906
+ avg_speed = 0
907
+ if len(velocities) > 3:
908
+ avg_speed = sum(velocities) / len(velocities)
909
+ if avg_speed > 150:
910
+ crowd_panic = True
911
+ cv2.putText(frame, f"CROWD ANOMALY: PANIC ({int(avg_speed)} px/s)", (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 3)
912
+
913
+ active_objects = []
914
+ for obj in object_detections:
915
+ box = obj['box']
916
+ cx, cy = (box[0] + box[2]) // 2, (box[1] + box[3]) // 2
917
+ matched_id = None
918
+ for obj_id, history in object_history.items():
919
+ last_pos = history['last_pos']
920
+ dist = math.sqrt((cx - last_pos[0])**2 + (cy - last_pos[1])**2)
921
+ if dist < 50:
922
+ matched_id = obj_id
923
+ break
924
+ if matched_id is None:
925
+ new_id = str(time.time())
926
+ object_history[new_id] = {'start_time': current_time, 'last_seen': current_time, 'last_pos': (cx, cy), 'stationary': True}
927
+ matched_id = new_id
928
+ else:
929
+ object_history[matched_id]['last_seen'] = current_time
930
+ object_history[matched_id]['last_pos'] = (cx, cy)
931
+ active_objects.append(matched_id)
932
+
933
+ abandoned_count = 0
934
+ keys_to_remove = []
935
+ for obj_id, history in object_history.items():
936
+ if current_time - history['last_seen'] > 2.0:
937
+ keys_to_remove.append(obj_id)
938
+ continue
939
+ duration = current_time - history['start_time']
940
+ if duration > 10.0:
941
+ abandoned_count += 1
942
+ pos = history['last_pos']
943
+ cv2.circle(frame, pos, 30, (0, 0, 255), 2)
944
+ cv2.putText(frame, f"ABANDONED {int(duration)}s", (pos[0]-40, pos[1]-40), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
945
+
946
+ for k in keys_to_remove:
947
+ del object_history[k]
948
+
949
+ current_details['public_safety'] = {'people_count': len(tracks), 'avg_crowd_speed': int(avg_speed), 'crowd_panic': crowd_panic, 'abandoned_objects': abandoned_count}
950
+
951
+ # If no modules active, show idle state
952
+ if not active_modules:
953
+ cv2.putText(frame, "NO DETECTION ACTIVE", (new_w // 2 - 140, new_h // 2),
954
+ cv2.FONT_HERSHEY_SIMPLEX, 1.2, (100, 100, 100), 2)
955
+ current_details = {'status': 'idle', 'active_modules': 0}
956
+
957
+ # Store details globally
958
+ global latest_details
959
+ latest_details = current_details
960
+
961
+ # Aggregated threat scoring from ALL active modules
962
+ total_score = 0
963
+ for module, details in current_details.items():
964
+ if module == 'weapon':
965
+ total_score += details.get('guns', 0) * 100
966
+ total_score += details.get('knives', 0) * 80
967
+ elif module == 'facemask':
968
+ total_score += details.get('with_mask', 0) * 60
969
+ total_score += details.get('obscured_faces', 0) * 90
970
+ elif module == 'movement':
971
+ total_score += details.get('long_stays', 0) * 40
972
+ elif module == 'public_safety':
973
+ total_score += details.get('abandoned_objects', 0) * 70
974
+ if details.get('crowd_panic', False):
975
+ total_score += 85
976
+
977
+ raw_score = min(total_score, 100)
978
+ smooth = smooth_score(raw_score)
979
+
980
+ # Red Alert escalation
981
+ was_red = red_alert
982
+ red_alert = smooth >= 80
983
+ if red_alert and not was_red:
984
+ active_str = ', '.join([m.upper() for m in active_modules]) if active_modules else 'NONE'
985
+ log_audit("RED_ALERT_TRIGGERED", f"Threat score escalated to {smooth}. Active: {active_str}", "CRITICAL")
986
+ # ── Dispatch Integration: trigger alert on RED_ALERT ──
987
+ threading.Thread(
988
+ target=create_dispatch_event,
989
+ args=(smooth, dict(current_details), set(active_modules)),
990
+ daemon=True
991
+ ).start()
992
+ elif not red_alert and was_red:
993
+ log_audit("RED_ALERT_CLEARED", f"Threat score de-escalated to {smooth}.", "WARNING")
994
+
995
+ # Feed label overlay
996
+ cv2.putText(frame, f"FEED {feed_id} | LIVE", (10, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 255), 1)
997
+
998
+ ret, buffer = cv2.imencode('.jpg', frame)
999
+ frame_bytes = buffer.tobytes()
1000
+ yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n' b'X-Score: ' + str(smooth).encode() + b'\r\n\r\n' + frame_bytes + b'\r\n')
1001
+
1002
+
1003
+ # Global to store latest details for stats
1004
+ latest_details = {}
1005
+
1006
+
1007
+ # ════════════════════════════════════════════════════════════════
1008
+ # ROUTES
1009
+ # ════════════════════════════════════════════════════════════════
1010
+ @app.route('/')
1011
+ def index():
1012
+ return render_template('sentinel_dashboard_v13.html')
1013
+
1014
+
1015
+ @app.route('/journey_data')
1016
+ def get_journey_data():
1017
+ """Return recent subject journey logs for the dashboard."""
1018
+ # Group by global_id and get the last seen location for each
1019
+ unique_journeys = {}
1020
+ for entry in list(journey_log):
1021
+ gid = entry['global_id']
1022
+ if gid not in unique_journeys:
1023
+ unique_journeys[gid] = {
1024
+ 'id': gid,
1025
+ 'last_feed': entry['feed_id'],
1026
+ 'last_seen': entry['timestamp'],
1027
+ 'history': []
1028
+ }
1029
+ unique_journeys[gid]['history'].append({
1030
+ 'feed': entry['feed_id'],
1031
+ 'time': entry['timestamp']
1032
+ })
1033
+
1034
+ # Sort history and limit to last 5 entries per ID
1035
+ for gid in unique_journeys:
1036
+ unique_journeys[gid]['history'] = unique_journeys[gid]['history'][-5:]
1037
+ unique_journeys[gid]['last_feed'] = unique_journeys[gid]['history'][-1]['feed']
1038
+ unique_journeys[gid]['last_seen'] = unique_journeys[gid]['history'][-1]['time']
1039
+
1040
+ return jsonify(list(unique_journeys.values()))
1041
+
1042
+
1043
+ @app.route('/video_feed')
1044
+ @app.route('/video_feed/<int:feed_id>')
1045
+ def video_feed(feed_id=0):
1046
+ return Response(generate_frames(feed_id), mimetype='multipart/x-mixed-replace; boundary=frame')
1047
+
1048
+
1049
+ @app.route('/toggle_module', methods=['POST'])
1050
+ def toggle_module():
1051
+ global active_modules
1052
+ data = request.get_json()
1053
+ module = data.get('module')
1054
+
1055
+ valid_modules = ['movement', 'facemask', 'weapon', 'public_safety', 'suspect_journey']
1056
+ if module not in valid_modules:
1057
+ return jsonify({'error': 'Invalid module'}), 400
1058
+
1059
+ if module in active_modules:
1060
+ active_modules.remove(module)
1061
+ log_audit("MODULE_DEACTIVATED", f"{module.upper()} detection disabled", "INFO")
1062
+ else:
1063
+ active_modules.add(module)
1064
+ log_audit("MODULE_ACTIVATED", f"{module.upper()} detection enabled", "INFO")
1065
+
1066
+ # When suspect_journey is activated, ensure movement is also active
1067
+ if module == 'suspect_journey':
1068
+ active_modules.add('movement')
1069
+ log_audit("MODULE_ACTIVATED", "MOVEMENT auto-enabled for journey tracking", "INFO")
1070
+
1071
+ return jsonify({
1072
+ 'success': True,
1073
+ 'active_modules': list(active_modules)
1074
+ })
1075
+
1076
+
1077
+ @app.route('/set_source', methods=['POST'])
1078
+ def set_source():
1079
+ data = request.get_json()
1080
+ source = data.get('source')
1081
+ feed_id = data.get('feed_id', 0)
1082
+ if source == 'camera' and feed_id == 0:
1083
+ feed_sources.pop(feed_id, None)
1084
+ restart_feed(feed_id)
1085
+ log_audit("SOURCE_CHANGE", f"Feed #{feed_id} set to live camera", "INFO")
1086
+ return jsonify({'success': True})
1087
+ return jsonify({'error': 'Invalid source'}), 400
1088
+
1089
+
1090
+ @app.route('/upload_video', methods=['POST'])
1091
+ @app.route('/upload_video/<int:feed_id>', methods=['POST'])
1092
+ def upload_video(feed_id=0):
1093
+ """Upload a video to a specific feed. Default: feed 0."""
1094
+ if 'file' not in request.files:
1095
+ return jsonify({'error': 'No file part'}), 400
1096
+ file = request.files['file']
1097
+ if file.filename == '':
1098
+ return jsonify({'error': 'No selected file'}), 400
1099
+
1100
+ if feed_id < 0 or feed_id >= MAX_FEEDS:
1101
+ return jsonify({'error': f'Invalid feed_id. Must be 0-{MAX_FEEDS-1}'}), 400
1102
+
1103
+ filename = secure_filename(file.filename)
1104
+
1105
+ allowed_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.gif'}
1106
+ file_ext = os.path.splitext(filename)[1].lower()
1107
+ if file_ext not in allowed_extensions:
1108
+ return jsonify({'error': f'Unsupported file format. Allowed: {", ".join(allowed_extensions)}'}), 400
1109
+
1110
+ # Save with feed-specific prefix to avoid collisions
1111
+ save_name = f"feed{feed_id}_{filename}"
1112
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], save_name)
1113
+ filepath = os.path.abspath(filepath)
1114
+ file.save(filepath)
1115
+
1116
+ # Register source for this feed and restart it
1117
+ feed_sources[feed_id] = filepath
1118
+ restart_feed(feed_id)
1119
+
1120
+ log_audit("FILE_UPLOAD", f"Feed #{feed_id}: {filename} uploaded", "INFO")
1121
+ return jsonify({
1122
+ 'success': True,
1123
+ 'message': f'Feed {feed_id}: {filename} loaded',
1124
+ 'feed_id': feed_id,
1125
+ 'feed_url': f'/video_feed/{feed_id}'
1126
+ })
1127
+
1128
+
1129
+ @app.route('/stats')
1130
+ def get_stats():
1131
+ return jsonify({
1132
+ 'threat_score': int(previous_score),
1133
+ 'details': latest_details,
1134
+ 'red_alert': red_alert,
1135
+ 'active_modules': list(active_modules)
1136
+ })
1137
+
1138
+
1139
+ @app.route('/audit_log')
1140
+ def get_audit_log():
1141
+ with audit_lock:
1142
+ entries = list(audit_log)
1143
+ return jsonify({'log': entries})
1144
+
1145
+
1146
+ @app.route('/generate_report', methods=['POST'])
1147
+ def generate_report():
1148
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1149
+ log_audit("REPORT_GENERATED", f"Operator requested AI incident report at threat score {int(previous_score)}", "INFO")
1150
+
1151
+ prompt = f"""
1152
+ You are the AI Agent for Project SENTINEL, a national security surveillance system.
1153
+ Generate a concise, professional security incident report based on the following real-time data.
1154
+
1155
+ TIMESTAMP: {timestamp}
1156
+ THREAT SCORE: {int(previous_score)}/100
1157
+ ACTIVE MODULES: {', '.join([m.upper() for m in active_modules])}
1158
+ DETECTION DETAILS: {latest_details}
1159
+
1160
+ Format the report with these sections:
1161
+ 1. INCIDENT SUMMARY (1-2 sentences)
1162
+ 2. THREAT ANALYSIS (Bullet points of specific risks)
1163
+ 3. RECOMMENDED ACTION (Clear directive for security personnel)
1164
+
1165
+ Keep it brief and authoritative.
1166
+ """
1167
+
1168
+ try:
1169
+ response = model_gemini.generate_content(prompt)
1170
+ report_text = response.text
1171
+ except Exception as e:
1172
+ print(f"Gemini Error: {e}")
1173
+ import traceback
1174
+ traceback.print_exc()
1175
+ report_text = f"ERROR: Could not generate AI report. System fallback.\n\nDetails: {latest_details}"
1176
+
1177
+ return jsonify({'report': report_text})
1178
+
1179
+
1180
+ @app.route('/reset_system', methods=['POST'])
1181
+ def reset_system():
1182
+ global active_modules, red_alert, previous_score, feed_sources
1183
+ global person_history, object_history, latest_details
1184
+
1185
+ active_modules = {'movement'}
1186
+ red_alert = False
1187
+ previous_score = 0.0
1188
+ feed_sources.clear()
1189
+
1190
+ person_history.clear()
1191
+ object_history.clear()
1192
+ latest_details.clear()
1193
+ journey_log.clear()
1194
+ identity_manager.global_subjects.clear()
1195
+ identity_manager.local_to_global.clear()
1196
+ identity_manager.next_global_id = 100
1197
+
1198
+ for fid in list(camera_feeds.keys()):
1199
+ restart_feed(fid)
1200
+
1201
+ log_audit("SYSTEM_RESET", "Operator initiated full system reset. All states cleared.", "WARNING")
1202
+
1203
+ return jsonify({'success': True})
1204
+
1205
+ # ════════════════════════════════════════════════════════════════
1206
+ # DISPATCH ROUTES
1207
+ # ════════════════════════════════════════════════════════════════
1208
+
1209
+ @app.route('/dispatch_log')
1210
+ def get_dispatch_log():
1211
+ """Return dispatch history and pending approvals."""
1212
+ with dispatch_lock:
1213
+ log_entries = list(dispatch_log)
1214
+ pending = list(pending_approvals.values())
1215
+ return jsonify({
1216
+ 'log': log_entries,
1217
+ 'pending': pending,
1218
+ 'settings': {
1219
+ 'auto_dispatch': dispatch_settings['auto_dispatch'],
1220
+ 'cooldown_seconds': dispatch_settings['cooldown_seconds'],
1221
+ 'enabled': dispatch_settings['enabled'],
1222
+ 'telegram_configured': telegram_dispatcher.is_configured(),
1223
+ 'chat_id': telegram_dispatcher.chat_id or '',
1224
+ }
1225
+ })
1226
+
1227
+
1228
+ @app.route('/dispatch_settings', methods=['GET', 'POST'])
1229
+ def handle_dispatch_settings():
1230
+ if request.method == 'GET':
1231
+ return jsonify({
1232
+ 'auto_dispatch': dispatch_settings['auto_dispatch'],
1233
+ 'cooldown_seconds': dispatch_settings['cooldown_seconds'],
1234
+ 'enabled': dispatch_settings['enabled'],
1235
+ 'bot_token': telegram_dispatcher.bot_token[:10] + '...' if telegram_dispatcher.bot_token else '',
1236
+ 'chat_id': telegram_dispatcher.chat_id or '',
1237
+ 'telegram_configured': telegram_dispatcher.is_configured(),
1238
+ })
1239
+
1240
+ data = request.get_json()
1241
+ if 'auto_dispatch' in data:
1242
+ dispatch_settings['auto_dispatch'] = bool(data['auto_dispatch'])
1243
+ mode = 'AUTO' if dispatch_settings['auto_dispatch'] else 'MANUAL APPROVAL'
1244
+ log_audit("DISPATCH_MODE", f"Dispatch mode set to {mode}", "INFO")
1245
+ if 'cooldown_seconds' in data:
1246
+ dispatch_settings['cooldown_seconds'] = max(10, min(600, int(data['cooldown_seconds'])))
1247
+ if 'enabled' in data:
1248
+ dispatch_settings['enabled'] = bool(data['enabled'])
1249
+ log_audit("DISPATCH_TOGGLE", f"Dispatch {'enabled' if dispatch_settings['enabled'] else 'disabled'}", "INFO")
1250
+ if 'bot_token' in data and data['bot_token']:
1251
+ telegram_dispatcher.configure(data['bot_token'], data.get('chat_id', telegram_dispatcher.chat_id))
1252
+ log_audit("DISPATCH_CONFIG", "Telegram bot token updated", "INFO")
1253
+ if 'chat_id' in data and data['chat_id']:
1254
+ telegram_dispatcher.chat_id = str(data['chat_id'])
1255
+ log_audit("DISPATCH_CONFIG", f"Chat ID set to {telegram_dispatcher.chat_id}", "INFO")
1256
+
1257
+ return jsonify({'success': True})
1258
+
1259
+
1260
+ @app.route('/approve_dispatch/<event_id>', methods=['POST'])
1261
+ def approve_dispatch(event_id):
1262
+ result = approve_dispatch_event(event_id)
1263
+ return jsonify(result)
1264
+
1265
+
1266
+ @app.route('/reject_dispatch/<event_id>', methods=['POST'])
1267
+ def reject_dispatch(event_id):
1268
+ result = reject_dispatch_event(event_id)
1269
+ return jsonify(result)
1270
+
1271
+
1272
+ @app.route('/test_dispatch', methods=['POST'])
1273
+ def test_dispatch():
1274
+ """Send a test message to verify Telegram connection."""
1275
+ # Try auto-detect chat_id if not set
1276
+ if not telegram_dispatcher.chat_id:
1277
+ detected = telegram_dispatcher.auto_detect_chat_id()
1278
+ if detected:
1279
+ log_audit("DISPATCH_CONFIG", f"Auto-detected chat ID: {detected}", "INFO")
1280
+
1281
+ if not telegram_dispatcher.is_configured():
1282
+ return jsonify({
1283
+ 'ok': False,
1284
+ 'error': 'Telegram not fully configured. Please set bot token and chat ID.'
1285
+ }), 400
1286
+
1287
+ result = telegram_dispatcher.test_connection()
1288
+ return jsonify(result)
1289
+
1290
+
1291
+ @app.route('/dispatch_alert', methods=['POST'])
1292
+ def manual_dispatch_alert():
1293
+ """Manually trigger a dispatch alert with current threat data."""
1294
+ event_id = str(uuid.uuid4())[:8]
1295
+ now = time.time()
1296
+ event = {
1297
+ 'id': event_id,
1298
+ 'timestamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
1299
+ 'threat_score': int(previous_score),
1300
+ 'details': dict(latest_details) if latest_details else {},
1301
+ 'active_modules': list(active_modules),
1302
+ 'status': 'pending',
1303
+ 'created_at': now,
1304
+ }
1305
+
1306
+ if not telegram_dispatcher.is_configured():
1307
+ return jsonify({'ok': False, 'error': 'Telegram not configured'}), 400
1308
+
1309
+ result = telegram_dispatcher.send_threat_alert(
1310
+ event['threat_score'], event['details'], event['active_modules']
1311
+ )
1312
+ event['status'] = 'sent' if result.get('ok') else 'failed'
1313
+ dispatch_settings['last_dispatch_time'] = now
1314
+
1315
+ with dispatch_lock:
1316
+ dispatch_log.appendleft(event)
1317
+ log_audit("MANUAL_DISPATCH", f"Operator manually dispatched alert (score: {event['threat_score']})", "CRITICAL")
1318
+ return jsonify({'success': True, 'telegram_result': result})
1319
+
1320
+
1321
+
1322
+
1323
+ # ════════════════════════════════════════════════════════════════
1324
+ # ENTRY POINT
1325
+ # ════════════════════════════════════════════════════════════════
1326
+ if __name__ == '__main__':
1327
+ print("\n" + "═"*60)
1328
+ print(" PROJECT SENTINEL V14 — DISPATCH INTEGRATION")
1329
+ print("═"*60)
1330
+ print("\n ✨ V14 FEATURES:")
1331
+ print(" • Automated Telegram alert dispatch on RED_ALERT")
1332
+ print(" • Human-in-the-loop approval/rejection queue")
1333
+ print(" • Dispatch Center dashboard with alert history")
1334
+ print(" • Configurable auto-dispatch / manual mode")
1335
+ print(" • All V13 features (Journey Tracker, Multi-Camera, Re-ID)")
1336
+ print("\n 👉 Open your browser: http://localhost:8080\n")
1337
+ app.run(host='0.0.0.0', port=8080, debug=False, threaded=True)
sort.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SORT: A Simple, Online and Realtime Tracker
3
+ Copyright (C) 2016-2020 Alex Bewley alex@bewley.ai
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+ """
18
+ from __future__ import print_function
19
+
20
+ import os
21
+ import numpy as np
22
+ import matplotlib
23
+ matplotlib.use('Agg')
24
+ import matplotlib.pyplot as plt
25
+ import matplotlib.patches as patches
26
+ from skimage import io
27
+
28
+ import glob
29
+ import time
30
+ import argparse
31
+ from filterpy.kalman import KalmanFilter
32
+
33
+ np.random.seed(0)
34
+
35
+
36
+ def linear_assignment(cost_matrix):
37
+ try:
38
+ import lap
39
+ _, x, y = lap.lapjv(cost_matrix, extend_cost=True)
40
+ return np.array([[y[i],i] for i in x if i >= 0]) #
41
+ except ImportError:
42
+ from scipy.optimize import linear_sum_assignment
43
+ x, y = linear_sum_assignment(cost_matrix)
44
+ return np.array(list(zip(x, y)))
45
+
46
+
47
+ def iou_batch(bb_test, bb_gt):
48
+ """
49
+ From SORT: Computes IOU between two bboxes in the form [x1,y1,x2,y2]
50
+ """
51
+ bb_gt = np.expand_dims(bb_gt, 0)
52
+ bb_test = np.expand_dims(bb_test, 1)
53
+
54
+ xx1 = np.maximum(bb_test[..., 0], bb_gt[..., 0])
55
+ yy1 = np.maximum(bb_test[..., 1], bb_gt[..., 1])
56
+ xx2 = np.minimum(bb_test[..., 2], bb_gt[..., 2])
57
+ yy2 = np.minimum(bb_test[..., 3], bb_gt[..., 3])
58
+ w = np.maximum(0., xx2 - xx1)
59
+ h = np.maximum(0., yy2 - yy1)
60
+ wh = w * h
61
+ o = wh / ((bb_test[..., 2] - bb_test[..., 0]) * (bb_test[..., 3] - bb_test[..., 1])
62
+ + (bb_gt[..., 2] - bb_gt[..., 0]) * (bb_gt[..., 3] - bb_gt[..., 1]) - wh)
63
+ return(o)
64
+
65
+
66
+ def convert_bbox_to_z(bbox):
67
+ """
68
+ Takes a bounding box in the form [x1,y1,x2,y2] and returns z in the form
69
+ [x,y,s,r] where x,y is the centre of the box and s is the scale/area and r is
70
+ the aspect ratio
71
+ """
72
+ w = bbox[2] - bbox[0]
73
+ h = bbox[3] - bbox[1]
74
+ x = bbox[0] + w/2.
75
+ y = bbox[1] + h/2.
76
+ s = w * h #scale is just area
77
+ r = w / float(h)
78
+ return np.array([x, y, s, r]).reshape((4, 1))
79
+
80
+
81
+ def convert_x_to_bbox(x,score=None):
82
+ """
83
+ Takes a bounding box in the centre form [x,y,s,r] and returns it in the form
84
+ [x1,y1,x2,y2] where x1,y1 is the top left and x2,y2 is the bottom right
85
+ """
86
+ w = np.sqrt(x[2] * x[3])
87
+ h = x[2] / w
88
+ if(score==None):
89
+ return np.array([x[0]-w/2.,x[1]-h/2.,x[0]+w/2.,x[1]+h/2.]).reshape((1,4))
90
+ else:
91
+ return np.array([x[0]-w/2.,x[1]-h/2.,x[0]+w/2.,x[1]+h/2.,score]).reshape((1,5))
92
+
93
+
94
+ class KalmanBoxTracker(object):
95
+ """
96
+ This class represents the internal state of individual tracked objects observed as bbox.
97
+ """
98
+ count = 0
99
+ def __init__(self,bbox):
100
+ """
101
+ Initialises a tracker using initial bounding box.
102
+ """
103
+ #define constant velocity model
104
+ self.kf = KalmanFilter(dim_x=7, dim_z=4)
105
+ self.kf.F = np.array([[1,0,0,0,1,0,0],[0,1,0,0,0,1,0],[0,0,1,0,0,0,1],[0,0,0,1,0,0,0], [0,0,0,0,1,0,0],[0,0,0,0,0,1,0],[0,0,0,0,0,0,1]])
106
+ self.kf.H = np.array([[1,0,0,0,0,0,0],[0,1,0,0,0,0,0],[0,0,1,0,0,0,0],[0,0,0,1,0,0,0]])
107
+
108
+ self.kf.R[2:,2:] *= 10.
109
+ self.kf.P[4:,4:] *= 1000. #give high uncertainty to the unobservable initial velocities
110
+ self.kf.P *= 10.
111
+ self.kf.Q[-1,-1] *= 0.01
112
+ self.kf.Q[4:,4:] *= 0.01
113
+
114
+ self.kf.x[:4] = convert_bbox_to_z(bbox)
115
+ self.time_since_update = 0
116
+ self.id = KalmanBoxTracker.count
117
+ KalmanBoxTracker.count += 1
118
+ self.history = []
119
+ self.hits = 0
120
+ self.hit_streak = 0
121
+ self.age = 0
122
+
123
+ def update(self,bbox):
124
+ """
125
+ Updates the state vector with observed bbox.
126
+ """
127
+ self.time_since_update = 0
128
+ self.history = []
129
+ self.hits += 1
130
+ self.hit_streak += 1
131
+ self.kf.update(convert_bbox_to_z(bbox))
132
+
133
+ def predict(self):
134
+ """
135
+ Advances the state vector and returns the predicted bounding box estimate.
136
+ """
137
+ if((self.kf.x[6]+self.kf.x[2])<=0):
138
+ self.kf.x[6] *= 0.0
139
+ self.kf.predict()
140
+ self.age += 1
141
+ if(self.time_since_update>0):
142
+ self.hit_streak = 0
143
+ self.time_since_update += 1
144
+ self.history.append(convert_x_to_bbox(self.kf.x))
145
+ return self.history[-1]
146
+
147
+ def get_state(self):
148
+ """
149
+ Returns the current bounding box estimate.
150
+ """
151
+ return convert_x_to_bbox(self.kf.x)
152
+
153
+
154
+ def associate_detections_to_trackers(detections,trackers,iou_threshold = 0.3):
155
+ """
156
+ Assigns detections to tracked object (both represented as bounding boxes)
157
+
158
+ Returns 3 lists of matches, unmatched_detections and unmatched_trackers
159
+ """
160
+ if(len(trackers)==0):
161
+ return np.empty((0,2),dtype=int), np.arange(len(detections)), np.empty((0,5),dtype=int)
162
+
163
+ iou_matrix = iou_batch(detections, trackers)
164
+
165
+ if min(iou_matrix.shape) > 0:
166
+ a = (iou_matrix > iou_threshold).astype(np.int32)
167
+ if a.sum(1).max() == 1 and a.sum(0).max() == 1:
168
+ matched_indices = np.stack(np.where(a), axis=1)
169
+ else:
170
+ matched_indices = linear_assignment(-iou_matrix)
171
+ else:
172
+ matched_indices = np.empty(shape=(0,2))
173
+
174
+ unmatched_detections = []
175
+ for d, det in enumerate(detections):
176
+ if(d not in matched_indices[:,0]):
177
+ unmatched_detections.append(d)
178
+ unmatched_trackers = []
179
+ for t, trk in enumerate(trackers):
180
+ if(t not in matched_indices[:,1]):
181
+ unmatched_trackers.append(t)
182
+
183
+ #filter out matched with low IOU
184
+ matches = []
185
+ for m in matched_indices:
186
+ if(iou_matrix[m[0], m[1]]<iou_threshold):
187
+ unmatched_detections.append(m[0])
188
+ unmatched_trackers.append(m[1])
189
+ else:
190
+ matches.append(m.reshape(1,2))
191
+ if(len(matches)==0):
192
+ matches = np.empty((0,2),dtype=int)
193
+ else:
194
+ matches = np.concatenate(matches,axis=0)
195
+
196
+ return matches, np.array(unmatched_detections), np.array(unmatched_trackers)
197
+
198
+
199
+ class Sort(object):
200
+ def __init__(self, max_age=1, min_hits=3, iou_threshold=0.3):
201
+ """
202
+ Sets key parameters for SORT
203
+ """
204
+ self.max_age = max_age
205
+ self.min_hits = min_hits
206
+ self.iou_threshold = iou_threshold
207
+ self.trackers = []
208
+ self.frame_count = 0
209
+
210
+ def update(self, dets=np.empty((0, 5))):
211
+ """
212
+ Params:
213
+ dets - a numpy array of detections in the format [[x1,y1,x2,y2,score],[x1,y1,x2,y2,score],...]
214
+ Requires: this method must be called once for each frame even with empty detections (use np.empty((0, 5)) for frames without detections).
215
+ Returns the a similar array, where the last column is the object ID.
216
+
217
+ NOTE: The number of objects returned may differ from the number of detections provided.
218
+ """
219
+ self.frame_count += 1
220
+ # get predicted locations from existing trackers.
221
+ trks = np.zeros((len(self.trackers), 5))
222
+ to_del = []
223
+ ret = []
224
+ for t, trk in enumerate(trks):
225
+ pos = self.trackers[t].predict()[0]
226
+ trk[:] = [pos[0], pos[1], pos[2], pos[3], 0]
227
+ if np.any(np.isnan(pos)):
228
+ to_del.append(t)
229
+ trks = np.ma.compress_rows(np.ma.masked_invalid(trks))
230
+ for t in reversed(to_del):
231
+ self.trackers.pop(t)
232
+ matched, unmatched_dets, unmatched_trks = associate_detections_to_trackers(dets,trks, self.iou_threshold)
233
+
234
+ # update matched trackers with assigned detections
235
+ for m in matched:
236
+ self.trackers[m[1]].update(dets[m[0], :])
237
+
238
+ # create and initialise new trackers for unmatched detections
239
+ for i in unmatched_dets:
240
+ trk = KalmanBoxTracker(dets[i,:])
241
+ self.trackers.append(trk)
242
+ i = len(self.trackers)
243
+ for trk in reversed(self.trackers):
244
+ d = trk.get_state()[0]
245
+ if (trk.time_since_update < 1) and (trk.hit_streak >= self.min_hits or self.frame_count <= self.min_hits):
246
+ ret.append(np.concatenate((d,[trk.id+1])).reshape(1,-1)) # +1 as MOT benchmark requires positive
247
+ i -= 1
248
+ # remove dead tracklet
249
+ if(trk.time_since_update > self.max_age):
250
+ self.trackers.pop(i)
251
+ if(len(ret)>0):
252
+ return np.concatenate(ret)
253
+ return np.empty((0,5))
254
+
255
+ def parse_args():
256
+ """Parse input arguments."""
257
+ parser = argparse.ArgumentParser(description='SORT demo')
258
+ parser.add_argument('--display', dest='display', help='Display online tracker output (slow) [False]',action='store_true')
259
+ parser.add_argument("--seq_path", help="Path to detections.", type=str, default='data')
260
+ parser.add_argument("--phase", help="Subdirectory in seq_path.", type=str, default='train')
261
+ parser.add_argument("--max_age",
262
+ help="Maximum number of frames to keep alive a track without associated detections.",
263
+ type=int, default=1)
264
+ parser.add_argument("--min_hits",
265
+ help="Minimum number of associated detections before track is initialised.",
266
+ type=int, default=3)
267
+ parser.add_argument("--iou_threshold", help="Minimum IOU for match.", type=float, default=0.3)
268
+ args = parser.parse_args()
269
+ return args
270
+
271
+ if __name__ == '__main__':
272
+ # all train
273
+ args = parse_args()
274
+ display = args.display
275
+ phase = args.phase
276
+ total_time = 0.0
277
+ total_frames = 0
278
+ colours = np.random.rand(32, 3) #used only for display
279
+ if(display):
280
+ if not os.path.exists('mot_benchmark'):
281
+ print('\n\tERROR: mot_benchmark link not found!\n\n Create a symbolic link to the MOT benchmark\n (https://motchallenge.net/data/2D_MOT_2015/#download). E.g.:\n\n $ ln -s /path/to/MOT2015_challenge/2DMOT2015 mot_benchmark\n\n')
282
+ exit()
283
+ plt.ion()
284
+ fig = plt.figure()
285
+ ax1 = fig.add_subplot(111, aspect='equal')
286
+
287
+ if not os.path.exists('output'):
288
+ os.makedirs('output')
289
+ pattern = os.path.join(args.seq_path, phase, '*', 'det', 'det.txt')
290
+ for seq_dets_fn in glob.glob(pattern):
291
+ mot_tracker = Sort(max_age=args.max_age,
292
+ min_hits=args.min_hits,
293
+ iou_threshold=args.iou_threshold) #create instance of the SORT tracker
294
+ seq_dets = np.loadtxt(seq_dets_fn, delimiter=',')
295
+ seq = seq_dets_fn[pattern.find('*'):].split(os.path.sep)[0]
296
+
297
+ with open(os.path.join('output', '%s.txt'%(seq)),'w') as out_file:
298
+ print("Processing %s."%(seq))
299
+ for frame in range(int(seq_dets[:,0].max())):
300
+ frame += 1 #detection and frame numbers begin at 1
301
+ dets = seq_dets[seq_dets[:, 0]==frame, 2:7]
302
+ dets[:, 2:4] += dets[:, 0:2] #convert to [x1,y1,w,h] to [x1,y1,x2,y2]
303
+ total_frames += 1
304
+
305
+ if(display):
306
+ fn = os.path.join('mot_benchmark', phase, seq, 'img1', '%06d.jpg'%(frame))
307
+ im =io.imread(fn)
308
+ ax1.imshow(im)
309
+ plt.title(seq + ' Tracked Targets')
310
+
311
+ start_time = time.time()
312
+ trackers = mot_tracker.update(dets)
313
+ cycle_time = time.time() - start_time
314
+ total_time += cycle_time
315
+
316
+ for d in trackers:
317
+ print('%d,%d,%.2f,%.2f,%.2f,%.2f,1,-1,-1,-1'%(frame,d[4],d[0],d[1],d[2]-d[0],d[3]-d[1]),file=out_file)
318
+ if(display):
319
+ d = d.astype(np.int32)
320
+ ax1.add_patch(patches.Rectangle((d[0],d[1]),d[2]-d[0],d[3]-d[1],fill=False,lw=3,ec=colours[d[4]%32,:]))
321
+
322
+ if(display):
323
+ fig.canvas.flush_events()
324
+ plt.draw()
325
+ ax1.cla()
326
+
327
+ print("Total Tracking took: %.3f seconds for %d frames or %.1f FPS" % (total_time, total_frames, total_frames / total_time))
328
+
329
+ if(display):
330
+ print("Note: to get real runtime results run without the option: --display")
templates/gun_index.html ADDED
@@ -0,0 +1,581 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>ATM ThreatVision - Gun Detection</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --ios-bg: #F2F2F7;
12
+ --ios-card: rgba(255, 255, 255, 0.8);
13
+ --ios-text: #000000;
14
+ --ios-secondary: #8E8E93;
15
+ --ios-orange: #FF6B00;
16
+ --ios-red: #FF3B30;
17
+ --ios-green: #34C759;
18
+ --ios-blue: #007AFF;
19
+ --ios-blur: blur(20px);
20
+ }
21
+
22
+ * {
23
+ margin: 0;
24
+ padding: 0;
25
+ box-sizing: border-box;
26
+ -webkit-font-smoothing: antialiased;
27
+ }
28
+
29
+ body {
30
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
31
+ background-color: var(--ios-bg);
32
+ color: var(--ios-text);
33
+ min-height: 100vh;
34
+ padding: 40px 20px;
35
+ }
36
+
37
+ .container {
38
+ max-width: 1200px;
39
+ margin: 0 auto;
40
+ }
41
+
42
+ /* Header */
43
+ .header {
44
+ display: flex;
45
+ justify-content: space-between;
46
+ align-items: center;
47
+ margin-bottom: 40px;
48
+ }
49
+
50
+ .logo h1 {
51
+ font-size: 28px;
52
+ font-weight: 700;
53
+ letter-spacing: -0.5px;
54
+ }
55
+
56
+ .logo h1 span {
57
+ color: var(--ios-orange);
58
+ }
59
+
60
+ .logo p {
61
+ color: var(--ios-secondary);
62
+ font-size: 14px;
63
+ margin-top: 4px;
64
+ }
65
+
66
+ /* Controls */
67
+ .controls {
68
+ background: rgba(255, 255, 255, 0.6);
69
+ backdrop-filter: var(--ios-blur);
70
+ padding: 6px;
71
+ border-radius: 999px;
72
+ display: flex;
73
+ gap: 5px;
74
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
75
+ }
76
+
77
+ .btn {
78
+ padding: 10px 20px;
79
+ border-radius: 999px;
80
+ border: none;
81
+ font-size: 14px;
82
+ font-weight: 600;
83
+ cursor: pointer;
84
+ transition: all 0.3s ease;
85
+ background: transparent;
86
+ color: var(--ios-secondary);
87
+ position: relative;
88
+ }
89
+
90
+ .btn.active {
91
+ background: #fff;
92
+ color: #000;
93
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
94
+ }
95
+
96
+ .btn:hover:not(.active) {
97
+ color: #000;
98
+ transform: scale(1.02);
99
+ }
100
+
101
+ .btn:active {
102
+ transform: scale(0.98);
103
+ }
104
+
105
+ #upload-input {
106
+ display: none;
107
+ }
108
+
109
+ /* Loading Spinner */
110
+ .spinner {
111
+ display: none;
112
+ width: 16px;
113
+ height: 16px;
114
+ border: 2px solid var(--ios-secondary);
115
+ border-top-color: transparent;
116
+ border-radius: 50%;
117
+ animation: spin 0.8s linear infinite;
118
+ margin-left: 8px;
119
+ }
120
+
121
+ .btn.loading .spinner {
122
+ display: inline-block;
123
+ }
124
+
125
+ @keyframes spin {
126
+ to {
127
+ transform: rotate(360deg);
128
+ }
129
+ }
130
+
131
+ /* Main Grid */
132
+ .main-grid {
133
+ display: grid;
134
+ grid-template-columns: 1fr 320px;
135
+ gap: 30px;
136
+ }
137
+
138
+ /* Video Feed */
139
+ .video-card {
140
+ background: #fff;
141
+ border-radius: 30px;
142
+ overflow: hidden;
143
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.08);
144
+ position: relative;
145
+ aspect-ratio: 16/9;
146
+ transition: box-shadow 0.3s ease;
147
+ }
148
+
149
+ .video-card:hover {
150
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.12);
151
+ }
152
+
153
+ #video-stream {
154
+ width: 100%;
155
+ height: 100%;
156
+ object-fit: cover;
157
+ }
158
+
159
+ .live-badge {
160
+ position: absolute;
161
+ top: 20px;
162
+ left: 20px;
163
+ background: rgba(255, 255, 255, 0.9);
164
+ backdrop-filter: blur(10px);
165
+ padding: 6px 12px;
166
+ border-radius: 999px;
167
+ font-size: 12px;
168
+ font-weight: 600;
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 6px;
172
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
173
+ z-index: 10;
174
+ }
175
+
176
+ .dot {
177
+ width: 8px;
178
+ height: 8px;
179
+ background: var(--ios-red);
180
+ border-radius: 50%;
181
+ animation: pulse 2s infinite;
182
+ }
183
+
184
+ @keyframes pulse {
185
+ 0% {
186
+ opacity: 1;
187
+ }
188
+
189
+ 50% {
190
+ opacity: 0.5;
191
+ }
192
+
193
+ 100% {
194
+ opacity: 1;
195
+ }
196
+ }
197
+
198
+ /* FPS Badge */
199
+ .fps-badge {
200
+ position: absolute;
201
+ top: 20px;
202
+ right: 20px;
203
+ background: rgba(0, 0, 0, 0.7);
204
+ backdrop-filter: blur(10px);
205
+ padding: 6px 12px;
206
+ border-radius: 999px;
207
+ font-size: 12px;
208
+ font-weight: 600;
209
+ color: #fff;
210
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
211
+ z-index: 10;
212
+ }
213
+
214
+ /* Stats Panel */
215
+ .stats-panel {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 20px;
219
+ }
220
+
221
+ .stat-card {
222
+ background: rgba(255, 255, 255, 0.7);
223
+ backdrop-filter: var(--ios-blur);
224
+ border-radius: 24px;
225
+ padding: 24px;
226
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
227
+ transition: all 0.3s ease;
228
+ position: relative;
229
+ overflow: hidden;
230
+ }
231
+
232
+ .stat-card::before {
233
+ content: '';
234
+ position: absolute;
235
+ top: 0;
236
+ left: 0;
237
+ right: 0;
238
+ height: 3px;
239
+ background: linear-gradient(90deg, transparent, var(--accent-color), transparent);
240
+ opacity: 0;
241
+ transition: opacity 0.3s ease;
242
+ }
243
+
244
+ .stat-card:hover {
245
+ transform: translateY(-4px);
246
+ box-shadow: 0 15px 40px rgba(0, 0, 0, 0.08);
247
+ }
248
+
249
+ .stat-card:hover::before {
250
+ opacity: 1;
251
+ }
252
+
253
+ .stat-card h3 {
254
+ font-size: 13px;
255
+ text-transform: uppercase;
256
+ letter-spacing: 0.5px;
257
+ color: var(--ios-secondary);
258
+ margin-bottom: 12px;
259
+ font-weight: 600;
260
+ }
261
+
262
+ .stat-value {
263
+ font-size: 42px;
264
+ font-weight: 700;
265
+ letter-spacing: -1px;
266
+ line-height: 1;
267
+ transition: transform 0.3s ease;
268
+ }
269
+
270
+ .stat-card:hover .stat-value {
271
+ transform: scale(1.05);
272
+ }
273
+
274
+ .stat-card.threat {
275
+ --accent-color: var(--ios-red);
276
+ }
277
+
278
+ .stat-card.threat .stat-value {
279
+ color: var(--ios-red);
280
+ }
281
+
282
+ .stat-card.warning {
283
+ --accent-color: var(--ios-orange);
284
+ }
285
+
286
+ .stat-card.warning .stat-value {
287
+ color: var(--ios-orange);
288
+ }
289
+
290
+ .stat-card.total {
291
+ --accent-color: var(--ios-text);
292
+ background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7));
293
+ }
294
+
295
+ .stat-card.total .stat-value {
296
+ color: var(--ios-text);
297
+ }
298
+
299
+ .legend {
300
+ margin-top: auto;
301
+ background: #fff;
302
+ border-radius: 24px;
303
+ padding: 24px;
304
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.05);
305
+ }
306
+
307
+ .legend-item {
308
+ display: flex;
309
+ align-items: center;
310
+ gap: 12px;
311
+ margin-bottom: 12px;
312
+ transition: transform 0.2s ease;
313
+ }
314
+
315
+ .legend-item:hover {
316
+ transform: translateX(4px);
317
+ }
318
+
319
+ .legend-item:last-child {
320
+ margin-bottom: 0;
321
+ }
322
+
323
+ .legend-color {
324
+ width: 12px;
325
+ height: 12px;
326
+ border-radius: 4px;
327
+ transition: transform 0.2s ease;
328
+ }
329
+
330
+ .legend-item:hover .legend-color {
331
+ transform: scale(1.2);
332
+ }
333
+
334
+ .legend-text {
335
+ font-size: 14px;
336
+ font-weight: 500;
337
+ color: var(--ios-secondary);
338
+ }
339
+
340
+ /* Toast */
341
+ .toast {
342
+ position: fixed;
343
+ top: 20px;
344
+ left: 50%;
345
+ transform: translateX(-50%) translateY(-100px);
346
+ background: rgba(0, 0, 0, 0.8);
347
+ color: #fff;
348
+ padding: 12px 24px;
349
+ border-radius: 999px;
350
+ font-size: 14px;
351
+ font-weight: 500;
352
+ transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
353
+ z-index: 1000;
354
+ backdrop-filter: blur(10px);
355
+ }
356
+
357
+ .toast.show {
358
+ transform: translateX(-50%) translateY(0);
359
+ }
360
+
361
+ .toast.success {
362
+ background: rgba(52, 199, 89, 0.9);
363
+ }
364
+
365
+ .toast.error {
366
+ background: rgba(255, 59, 48, 0.9);
367
+ }
368
+
369
+ /* Upload Progress */
370
+ .upload-progress {
371
+ position: fixed;
372
+ top: 50%;
373
+ left: 50%;
374
+ transform: translate(-50%, -50%);
375
+ background: rgba(0, 0, 0, 0.9);
376
+ color: #fff;
377
+ padding: 32px;
378
+ border-radius: 24px;
379
+ font-size: 16px;
380
+ font-weight: 500;
381
+ z-index: 2000;
382
+ backdrop-filter: blur(20px);
383
+ display: none;
384
+ flex-direction: column;
385
+ align-items: center;
386
+ gap: 16px;
387
+ }
388
+
389
+ .upload-progress.show {
390
+ display: flex;
391
+ }
392
+
393
+ .upload-spinner {
394
+ width: 40px;
395
+ height: 40px;
396
+ border: 3px solid rgba(255, 255, 255, 0.3);
397
+ border-top-color: var(--ios-orange);
398
+ border-radius: 50%;
399
+ animation: spin 0.8s linear infinite;
400
+ }
401
+
402
+ @media (max-width: 900px) {
403
+ .main-grid {
404
+ grid-template-columns: 1fr;
405
+ }
406
+
407
+ .stats-panel {
408
+ flex-direction: row;
409
+ flex-wrap: wrap;
410
+ }
411
+
412
+ .stat-card {
413
+ flex: 1;
414
+ min-width: 140px;
415
+ }
416
+ }
417
+ </style>
418
+ </head>
419
+
420
+ <body>
421
+ <div class="container">
422
+ <header class="header">
423
+ <div class="logo">
424
+ <h1>ATM <span>ThreatVision</span></h1>
425
+ <p>Weapon Detection System</p>
426
+ </div>
427
+ <div class="controls">
428
+ <button class="btn active" id="btn-camera" onclick="setSource('camera')">Camera</button>
429
+ <button class="btn" id="btn-upload"
430
+ onclick="document.getElementById('upload-input').click()">Upload</button>
431
+ <input type="file" id="upload-input" accept="video/*" onchange="handleFileUpload(this)">
432
+ </div>
433
+ </header>
434
+
435
+ <div class="main-grid">
436
+ <div class="video-card">
437
+ <div class="live-badge">
438
+ <span class="dot"></span>
439
+ <span id="source-label">LIVE</span>
440
+ </div>
441
+ <div class="fps-badge" id="fps-display">FPS: --</div>
442
+ <img id="video-stream" src="{{ url_for('video_feed') }}" alt="Video Stream">
443
+ </div>
444
+
445
+ <div class="stats-panel">
446
+ <div class="stat-card threat">
447
+ <h3>Guns Detected</h3>
448
+ <div class="stat-value" id="guns-count">0</div>
449
+ </div>
450
+
451
+ <div class="stat-card warning">
452
+ <h3>Knives Detected</h3>
453
+ <div class="stat-value" id="knives-count">0</div>
454
+ </div>
455
+
456
+ <div class="stat-card total">
457
+ <h3>Total Threats</h3>
458
+ <div class="stat-value" id="total-threats">0</div>
459
+ </div>
460
+
461
+ <div class="legend">
462
+ <div class="legend-item">
463
+ <div class="legend-color" style="background: var(--ios-red)"></div>
464
+ <div class="legend-text">Gun (High Threat)</div>
465
+ </div>
466
+ <div class="legend-item">
467
+ <div class="legend-color" style="background: var(--ios-orange)"></div>
468
+ <div class="legend-text">Knife (Medium Threat)</div>
469
+ </div>
470
+ </div>
471
+ </div>
472
+ </div>
473
+ </div>
474
+
475
+ <div id="toast" class="toast">Action Successful</div>
476
+ <div id="upload-progress" class="upload-progress">
477
+ <div class="upload-spinner"></div>
478
+ <div>Uploading video...</div>
479
+ </div>
480
+
481
+ <script>
482
+ let lastUpdateTime = Date.now();
483
+ let frameCount = 0;
484
+
485
+ function showToast(message, type = 'success') {
486
+ const toast = document.getElementById('toast');
487
+ toast.textContent = message;
488
+ toast.className = 'toast show ' + type;
489
+ setTimeout(() => toast.classList.remove('show'), 3000);
490
+ }
491
+
492
+ function showUploadProgress() {
493
+ document.getElementById('upload-progress').classList.add('show');
494
+ }
495
+
496
+ function hideUploadProgress() {
497
+ document.getElementById('upload-progress').classList.remove('show');
498
+ }
499
+
500
+ function setActiveButton(type) {
501
+ document.querySelectorAll('.btn').forEach(btn => btn.classList.remove('active'));
502
+ if (type === 'camera') document.getElementById('btn-camera').classList.add('active');
503
+ else document.getElementById('btn-upload').classList.add('active');
504
+ }
505
+
506
+ function setSource(source) {
507
+ fetch('/set_source', {
508
+ method: 'POST',
509
+ headers: { 'Content-Type': 'application/json' },
510
+ body: JSON.stringify({ source: source }),
511
+ })
512
+ .then(response => response.json())
513
+ .then(data => {
514
+ if (data.success) {
515
+ document.getElementById('source-label').textContent = source === 'camera' ? 'LIVE' : 'PLAYBACK';
516
+ setActiveButton(source === 'camera' ? 'camera' : 'upload');
517
+ showToast('Switched to ' + source, 'success');
518
+ }
519
+ })
520
+ .catch(error => {
521
+ console.error('Error:', error);
522
+ showToast('Failed to switch source', 'error');
523
+ });
524
+ }
525
+
526
+ function handleFileUpload(input) {
527
+ if (input.files && input.files[0]) {
528
+ const formData = new FormData();
529
+ formData.append('file', input.files[0]);
530
+
531
+ showUploadProgress();
532
+
533
+ fetch('/upload_video', {
534
+ method: 'POST',
535
+ body: formData
536
+ })
537
+ .then(response => response.json())
538
+ .then(data => {
539
+ hideUploadProgress();
540
+ if (data.success) {
541
+ document.getElementById('source-label').textContent = 'PLAYBACK';
542
+ setActiveButton('upload');
543
+ showToast('Video uploaded successfully', 'success');
544
+ } else {
545
+ showToast(data.error || 'Upload failed', 'error');
546
+ }
547
+ })
548
+ .catch(error => {
549
+ hideUploadProgress();
550
+ console.error('Error:', error);
551
+ showToast('Upload failed', 'error');
552
+ });
553
+ }
554
+ }
555
+
556
+ function updateStats() {
557
+ fetch('/stats')
558
+ .then(response => response.json())
559
+ .then(data => {
560
+ document.getElementById('guns-count').textContent = data.guns;
561
+ document.getElementById('knives-count').textContent = data.knives;
562
+ document.getElementById('total-threats').textContent = data.total_threats;
563
+
564
+ // Calculate approximate FPS from update frequency
565
+ frameCount++;
566
+ const now = Date.now();
567
+ if (now - lastUpdateTime >= 1000) {
568
+ const fps = Math.round((frameCount * 1000) / (now - lastUpdateTime));
569
+ document.getElementById('fps-display').textContent = `FPS: ${fps}`;
570
+ frameCount = 0;
571
+ lastUpdateTime = now;
572
+ }
573
+ })
574
+ .catch(error => console.error('Error fetching stats:', error));
575
+ }
576
+
577
+ setInterval(updateStats, 100); // Update more frequently for smoother FPS display
578
+ </script>
579
+ </body>
580
+
581
+ </html>
templates/index.html ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>ATM ThreatVision - Facemask Detection</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
9
+ rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --primary-orange: #FF6B00;
13
+ --dark-bg: #000000;
14
+ --card-bg: #111111;
15
+ --text-white: #FFFFFF;
16
+ --text-gray: #888888;
17
+ --border-color: #333333;
18
+ --success-green: #00FF88;
19
+ --danger-red: #FF4757;
20
+ }
21
+
22
+ * {
23
+ margin: 0;
24
+ padding: 0;
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ body {
29
+ font-family: 'Outfit', sans-serif;
30
+ background-color: var(--dark-bg);
31
+ color: var(--text-white);
32
+ min-height: 100vh;
33
+ padding: 20px;
34
+ }
35
+
36
+ .container {
37
+ max-width: 1400px;
38
+ margin: 0 auto;
39
+ }
40
+
41
+ .header {
42
+ display: flex;
43
+ justify-content: space-between;
44
+ align-items: center;
45
+ padding: 20px 0;
46
+ margin-bottom: 40px;
47
+ border-bottom: 1px solid var(--border-color);
48
+ }
49
+
50
+ .logo-area h1 {
51
+ font-size: 2rem;
52
+ font-weight: 800;
53
+ letter-spacing: -1px;
54
+ }
55
+
56
+ .logo-area h1 span {
57
+ color: var(--primary-orange);
58
+ }
59
+
60
+ .logo-area p {
61
+ color: var(--text-gray);
62
+ font-size: 0.9rem;
63
+ margin-top: 5px;
64
+ }
65
+
66
+ .controls {
67
+ display: flex;
68
+ gap: 15px;
69
+ }
70
+
71
+ .btn {
72
+ padding: 12px 24px;
73
+ border-radius: 8px;
74
+ border: none;
75
+ font-weight: 600;
76
+ cursor: pointer;
77
+ transition: all 0.3s ease;
78
+ font-family: 'Outfit', sans-serif;
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 8px;
82
+ }
83
+
84
+ .btn-primary {
85
+ background-color: var(--primary-orange);
86
+ color: var(--text-white);
87
+ }
88
+
89
+ .btn-primary:hover {
90
+ background-color: #e65a00;
91
+ transform: translateY(-2px);
92
+ }
93
+
94
+ .btn-outline {
95
+ background-color: transparent;
96
+ border: 1px solid var(--border-color);
97
+ color: var(--text-white);
98
+ }
99
+
100
+ .btn-outline:hover {
101
+ border-color: var(--primary-orange);
102
+ color: var(--primary-orange);
103
+ }
104
+
105
+ .main-grid {
106
+ display: grid;
107
+ grid-template-columns: 1fr 350px;
108
+ gap: 30px;
109
+ }
110
+
111
+ .video-container {
112
+ background-color: var(--card-bg);
113
+ border-radius: 20px;
114
+ padding: 20px;
115
+ border: 1px solid var(--border-color);
116
+ position: relative;
117
+ overflow: hidden;
118
+ }
119
+
120
+ .video-header {
121
+ display: flex;
122
+ justify-content: space-between;
123
+ align-items: center;
124
+ margin-bottom: 20px;
125
+ }
126
+
127
+ .video-header h2 {
128
+ font-size: 1.2rem;
129
+ font-weight: 600;
130
+ display: flex;
131
+ align-items: center;
132
+ gap: 10px;
133
+ }
134
+
135
+ .live-indicator {
136
+ width: 8px;
137
+ height: 8px;
138
+ background-color: var(--primary-orange);
139
+ border-radius: 50%;
140
+ box-shadow: 0 0 10px var(--primary-orange);
141
+ animation: pulse 2s infinite;
142
+ }
143
+
144
+ @keyframes pulse {
145
+ 0% {
146
+ opacity: 1;
147
+ }
148
+
149
+ 50% {
150
+ opacity: 0.5;
151
+ }
152
+
153
+ 100% {
154
+ opacity: 1;
155
+ }
156
+ }
157
+
158
+ #video-stream {
159
+ width: 100%;
160
+ border-radius: 12px;
161
+ background-color: #000;
162
+ min-height: 480px;
163
+ object-fit: contain;
164
+ }
165
+
166
+ .stats-panel {
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: 20px;
170
+ }
171
+
172
+ .stat-card {
173
+ background-color: var(--card-bg);
174
+ border-radius: 16px;
175
+ padding: 25px;
176
+ border: 1px solid var(--border-color);
177
+ position: relative;
178
+ overflow: hidden;
179
+ }
180
+
181
+ .stat-card::before {
182
+ content: '';
183
+ position: absolute;
184
+ top: 0;
185
+ left: 0;
186
+ width: 4px;
187
+ height: 100%;
188
+ }
189
+
190
+ .stat-card.orange::before {
191
+ background-color: var(--primary-orange);
192
+ }
193
+
194
+ .stat-card.green::before {
195
+ background-color: var(--success-green);
196
+ }
197
+
198
+ .stat-card.red::before {
199
+ background-color: var(--danger-red);
200
+ }
201
+
202
+ .stat-card h3 {
203
+ font-size: 0.8rem;
204
+ text-transform: uppercase;
205
+ letter-spacing: 1.5px;
206
+ color: var(--text-gray);
207
+ margin-bottom: 15px;
208
+ }
209
+
210
+ .stat-value {
211
+ font-size: 3rem;
212
+ font-weight: 700;
213
+ line-height: 1;
214
+ margin-bottom: 5px;
215
+ }
216
+
217
+ .stat-label {
218
+ font-size: 0.9rem;
219
+ color: var(--text-gray);
220
+ }
221
+
222
+ .legend-panel {
223
+ background-color: var(--card-bg);
224
+ border-radius: 16px;
225
+ padding: 25px;
226
+ border: 1px solid var(--border-color);
227
+ }
228
+
229
+ .legend-item {
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 12px;
233
+ margin-bottom: 15px;
234
+ padding: 10px;
235
+ background-color: rgba(255, 255, 255, 0.03);
236
+ border-radius: 8px;
237
+ }
238
+
239
+ .legend-color {
240
+ width: 16px;
241
+ height: 16px;
242
+ border-radius: 4px;
243
+ }
244
+
245
+ .legend-text {
246
+ font-size: 0.95rem;
247
+ color: var(--text-white);
248
+ }
249
+
250
+ #upload-input {
251
+ display: none;
252
+ }
253
+
254
+ /* Toast Notification */
255
+ .toast {
256
+ position: fixed;
257
+ bottom: 30px;
258
+ right: 30px;
259
+ background-color: var(--card-bg);
260
+ border: 1px solid var(--primary-orange);
261
+ color: var(--text-white);
262
+ padding: 15px 25px;
263
+ border-radius: 8px;
264
+ transform: translateY(100px);
265
+ opacity: 0;
266
+ transition: all 0.3s ease;
267
+ z-index: 1000;
268
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
269
+ }
270
+
271
+ .toast.show {
272
+ transform: translateY(0);
273
+ opacity: 1;
274
+ }
275
+
276
+ @media (max-width: 1024px) {
277
+ .main-grid {
278
+ grid-template-columns: 1fr;
279
+ }
280
+
281
+ .stats-panel {
282
+ flex-direction: row;
283
+ flex-wrap: wrap;
284
+ }
285
+
286
+ .stat-card {
287
+ flex: 1;
288
+ min-width: 200px;
289
+ }
290
+ }
291
+ </style>
292
+ </head>
293
+
294
+ <body>
295
+ <div class="container">
296
+ <header class="header">
297
+ <div class="logo-area">
298
+ <h1>ATM <span>ThreatVision</span></h1>
299
+ <p>Advanced Facemask Detection System</p>
300
+ </div>
301
+ <div class="controls">
302
+ <button class="btn btn-primary" onclick="setSource('camera')">
303
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
304
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
305
+ d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z">
306
+ </path>
307
+ </svg>
308
+ Live Camera
309
+ </button>
310
+ <button class="btn btn-outline" onclick="document.getElementById('upload-input').click()">
311
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
312
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
313
+ d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
314
+ </svg>
315
+ Upload Video
316
+ </button>
317
+ <input type="file" id="upload-input" accept="video/*" onchange="handleFileUpload(this)">
318
+ </div>
319
+ </header>
320
+
321
+ <div class="main-grid">
322
+ <div class="video-container">
323
+ <div class="video-header">
324
+ <h2><span class="live-indicator"></span> Live Feed</h2>
325
+ <span style="color: var(--text-gray); font-size: 0.9rem;" id="source-label">Source: Camera</span>
326
+ </div>
327
+ <img id="video-stream" src="{{ url_for('video_feed') }}" alt="Video Stream">
328
+ </div>
329
+
330
+ <div class="stats-panel">
331
+ <div class="stat-card red">
332
+ <h3>With Mask</h3>
333
+ <div class="stat-value" id="with-mask" style="color: var(--danger-red)">0</div>
334
+ <div class="stat-label">Threat</div>
335
+ </div>
336
+
337
+ <div class="stat-card green">
338
+ <h3>Without Mask</h3>
339
+ <div class="stat-value" id="without-mask" style="color: var(--success-green)">0</div>
340
+ <div class="stat-label">Safe</div>
341
+ </div>
342
+
343
+ <div class="stat-card orange">
344
+ <h3>Total Detections</h3>
345
+ <div class="stat-value" id="total-detections">0</div>
346
+ <div class="stat-label">In Current Frame</div>
347
+ </div>
348
+
349
+ <div class="legend-panel">
350
+ <div class="legend-item">
351
+ <div class="legend-color" style="background: #FF0000"></div>
352
+ <div class="legend-text">Mask Detected (Threat)</div>
353
+ </div>
354
+ <div class="legend-item">
355
+ <div class="legend-color" style="background: #00FF88"></div>
356
+ <div class="legend-text">No Mask (Safe)</div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ </div>
361
+ </div>
362
+
363
+ <div id="toast" class="toast">Action Successful</div>
364
+
365
+ <script>
366
+ function showToast(message) {
367
+ const toast = document.getElementById('toast');
368
+ toast.textContent = message;
369
+ toast.classList.add('show');
370
+ setTimeout(() => toast.classList.remove('show'), 3000);
371
+ }
372
+
373
+ function setSource(source) {
374
+ fetch('/set_source', {
375
+ method: 'POST',
376
+ headers: {
377
+ 'Content-Type': 'application/json',
378
+ },
379
+ body: JSON.stringify({ source: source }),
380
+ })
381
+ .then(response => response.json())
382
+ .then(data => {
383
+ if (data.success) {
384
+ document.getElementById('source-label').textContent = 'Source: ' + (source === 'camera' ? 'Live Camera' : 'Uploaded Video');
385
+ showToast('Switched to ' + source);
386
+ }
387
+ });
388
+ }
389
+
390
+ function handleFileUpload(input) {
391
+ if (input.files && input.files[0]) {
392
+ const formData = new FormData();
393
+ formData.append('file', input.files[0]);
394
+
395
+ fetch('/upload_video', {
396
+ method: 'POST',
397
+ body: formData
398
+ })
399
+ .then(response => response.json())
400
+ .then(data => {
401
+ if (data.success) {
402
+ document.getElementById('source-label').textContent = 'Source: Uploaded Video';
403
+ showToast('Video uploaded successfully');
404
+ }
405
+ })
406
+ .catch(error => {
407
+ console.error('Error:', error);
408
+ showToast('Upload failed');
409
+ });
410
+ }
411
+ }
412
+
413
+ function updateStats() {
414
+ fetch('/stats')
415
+ .then(response => response.json())
416
+ .then(data => {
417
+ document.getElementById('with-mask').textContent = data.with_mask;
418
+ document.getElementById('without-mask').textContent = data.without_mask;
419
+ document.getElementById('total-detections').textContent = data.total_detections;
420
+ })
421
+ .catch(error => console.error('Error fetching stats:', error));
422
+ }
423
+
424
+ setInterval(updateStats, 500);
425
+ </script>
426
+ </body>
427
+
428
+ </html>
templates/movement_index.html ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>ATM ThreatVision - Movement Analysis</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
9
+ rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --primary-orange: #FF6B00;
13
+ --dark-bg: #000000;
14
+ --card-bg: #111111;
15
+ --text-white: #FFFFFF;
16
+ --text-gray: #888888;
17
+ --border-color: #333333;
18
+ --success-green: #00FF88;
19
+ --danger-red: #FF4757;
20
+ --info-blue: #0088FF;
21
+ }
22
+
23
+ * {
24
+ margin: 0;
25
+ padding: 0;
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ body {
30
+ font-family: 'Outfit', sans-serif;
31
+ background-color: var(--dark-bg);
32
+ color: var(--text-white);
33
+ min-height: 100vh;
34
+ padding: 20px;
35
+ }
36
+
37
+ .container {
38
+ max-width: 1400px;
39
+ margin: 0 auto;
40
+ }
41
+
42
+ .header {
43
+ display: flex;
44
+ justify-content: space-between;
45
+ align-items: center;
46
+ padding: 20px 0;
47
+ margin-bottom: 40px;
48
+ border-bottom: 1px solid var(--border-color);
49
+ }
50
+
51
+ .logo-area h1 {
52
+ font-size: 2rem;
53
+ font-weight: 800;
54
+ letter-spacing: -1px;
55
+ }
56
+
57
+ .logo-area h1 span {
58
+ color: var(--primary-orange);
59
+ }
60
+
61
+ .logo-area p {
62
+ color: var(--text-gray);
63
+ font-size: 0.9rem;
64
+ margin-top: 5px;
65
+ }
66
+
67
+ .controls {
68
+ display: flex;
69
+ gap: 15px;
70
+ }
71
+
72
+ .btn {
73
+ padding: 12px 24px;
74
+ border-radius: 8px;
75
+ border: none;
76
+ font-weight: 600;
77
+ cursor: pointer;
78
+ transition: all 0.3s ease;
79
+ font-family: 'Outfit', sans-serif;
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 8px;
83
+ }
84
+
85
+ .btn-primary {
86
+ background-color: var(--primary-orange);
87
+ color: var(--text-white);
88
+ }
89
+
90
+ .btn-primary:hover {
91
+ background-color: #e65a00;
92
+ transform: translateY(-2px);
93
+ }
94
+
95
+ .btn-outline {
96
+ background-color: transparent;
97
+ border: 1px solid var(--border-color);
98
+ color: var(--text-white);
99
+ }
100
+
101
+ .btn-outline:hover {
102
+ border-color: var(--primary-orange);
103
+ color: var(--primary-orange);
104
+ }
105
+
106
+ .main-grid {
107
+ display: grid;
108
+ grid-template-columns: 1fr 350px;
109
+ gap: 30px;
110
+ }
111
+
112
+ .video-container {
113
+ background-color: var(--card-bg);
114
+ border-radius: 20px;
115
+ padding: 20px;
116
+ border: 1px solid var(--border-color);
117
+ position: relative;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .video-header {
122
+ display: flex;
123
+ justify-content: space-between;
124
+ align-items: center;
125
+ margin-bottom: 20px;
126
+ }
127
+
128
+ .video-header h2 {
129
+ font-size: 1.2rem;
130
+ font-weight: 600;
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 10px;
134
+ }
135
+
136
+ .live-indicator {
137
+ width: 8px;
138
+ height: 8px;
139
+ background-color: var(--primary-orange);
140
+ border-radius: 50%;
141
+ box-shadow: 0 0 10px var(--primary-orange);
142
+ animation: pulse 2s infinite;
143
+ }
144
+
145
+ @keyframes pulse {
146
+ 0% {
147
+ opacity: 1;
148
+ }
149
+
150
+ 50% {
151
+ opacity: 0.5;
152
+ }
153
+
154
+ 100% {
155
+ opacity: 1;
156
+ }
157
+ }
158
+
159
+ #video-stream {
160
+ width: 100%;
161
+ border-radius: 12px;
162
+ background-color: #000;
163
+ min-height: 480px;
164
+ object-fit: contain;
165
+ }
166
+
167
+ .stats-panel {
168
+ display: flex;
169
+ flex-direction: column;
170
+ gap: 20px;
171
+ }
172
+
173
+ .stat-card {
174
+ background-color: var(--card-bg);
175
+ border-radius: 16px;
176
+ padding: 25px;
177
+ border: 1px solid var(--border-color);
178
+ position: relative;
179
+ overflow: hidden;
180
+ }
181
+
182
+ .stat-card::before {
183
+ content: '';
184
+ position: absolute;
185
+ top: 0;
186
+ left: 0;
187
+ width: 4px;
188
+ height: 100%;
189
+ }
190
+
191
+ .stat-card.orange::before {
192
+ background-color: var(--primary-orange);
193
+ }
194
+
195
+ .stat-card.blue::before {
196
+ background-color: var(--info-blue);
197
+ }
198
+
199
+ .stat-card.red::before {
200
+ background-color: var(--danger-red);
201
+ }
202
+
203
+ .stat-card h3 {
204
+ font-size: 0.8rem;
205
+ text-transform: uppercase;
206
+ letter-spacing: 1.5px;
207
+ color: var(--text-gray);
208
+ margin-bottom: 15px;
209
+ }
210
+
211
+ .stat-value {
212
+ font-size: 3rem;
213
+ font-weight: 700;
214
+ line-height: 1;
215
+ margin-bottom: 5px;
216
+ }
217
+
218
+ .stat-label {
219
+ font-size: 0.9rem;
220
+ color: var(--text-gray);
221
+ }
222
+
223
+ .legend-panel {
224
+ background-color: var(--card-bg);
225
+ border-radius: 16px;
226
+ padding: 25px;
227
+ border: 1px solid var(--border-color);
228
+ }
229
+
230
+ .legend-item {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 12px;
234
+ margin-bottom: 15px;
235
+ padding: 10px;
236
+ background-color: rgba(255, 255, 255, 0.03);
237
+ border-radius: 8px;
238
+ }
239
+
240
+ .legend-color {
241
+ width: 16px;
242
+ height: 16px;
243
+ border-radius: 4px;
244
+ }
245
+
246
+ .legend-text {
247
+ font-size: 0.95rem;
248
+ color: var(--text-white);
249
+ }
250
+
251
+ #upload-input {
252
+ display: none;
253
+ }
254
+
255
+ /* Toast Notification */
256
+ .toast {
257
+ position: fixed;
258
+ bottom: 30px;
259
+ right: 30px;
260
+ background-color: var(--card-bg);
261
+ border: 1px solid var(--primary-orange);
262
+ color: var(--text-white);
263
+ padding: 15px 25px;
264
+ border-radius: 8px;
265
+ transform: translateY(100px);
266
+ opacity: 0;
267
+ transition: all 0.3s ease;
268
+ z-index: 1000;
269
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
270
+ }
271
+
272
+ .toast.show {
273
+ transform: translateY(0);
274
+ opacity: 1;
275
+ }
276
+
277
+ @media (max-width: 1024px) {
278
+ .main-grid {
279
+ grid-template-columns: 1fr;
280
+ }
281
+
282
+ .stats-panel {
283
+ flex-direction: row;
284
+ flex-wrap: wrap;
285
+ }
286
+
287
+ .stat-card {
288
+ flex: 1;
289
+ min-width: 200px;
290
+ }
291
+ }
292
+ </style>
293
+ </head>
294
+
295
+ <body>
296
+ <div class="container">
297
+ <header class="header">
298
+ <div class="logo-area">
299
+ <h1>ATM <span>ThreatVision</span></h1>
300
+ <p>Advanced Movement Analysis System</p>
301
+ </div>
302
+ <div class="controls">
303
+ <button class="btn btn-primary" onclick="setSource('camera')">
304
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
305
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
306
+ d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z">
307
+ </path>
308
+ </svg>
309
+ Live Camera
310
+ </button>
311
+ <button class="btn btn-outline" onclick="document.getElementById('upload-input').click()">
312
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
313
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
314
+ d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
315
+ </svg>
316
+ Upload Video
317
+ </button>
318
+ <input type="file" id="upload-input" accept="video/*" onchange="handleFileUpload(this)">
319
+ </div>
320
+ </header>
321
+
322
+ <div class="main-grid">
323
+ <div class="video-container">
324
+ <div class="video-header">
325
+ <h2><span class="live-indicator"></span> Live Feed</h2>
326
+ <span style="color: var(--text-gray); font-size: 0.9rem;" id="source-label">Source: Camera</span>
327
+ </div>
328
+ <img id="video-stream" src="{{ url_for('video_feed') }}" alt="Video Stream">
329
+ </div>
330
+
331
+ <div class="stats-panel">
332
+ <div class="stat-card orange">
333
+ <h3>Current People</h3>
334
+ <div class="stat-value" id="current-people">0</div>
335
+ <div class="stat-label">In Frame</div>
336
+ </div>
337
+
338
+ <div class="stat-card blue">
339
+ <h3>Total People</h3>
340
+ <div class="stat-value" id="total-people" style="color: var(--info-blue)">0</div>
341
+ <div class="stat-label">Detected Session</div>
342
+ </div>
343
+
344
+ <div class="stat-card red">
345
+ <h3>Long Stays (>10s)</h3>
346
+ <div class="stat-value" id="long-stays" style="color: var(--danger-red)">0</div>
347
+ <div class="stat-label">Potential Loitering</div>
348
+ </div>
349
+
350
+ <div class="legend-panel">
351
+ <div class="legend-item">
352
+ <div class="legend-color" style="background: #00FF00"></div>
353
+ <div class="legend-text">Past Path (5s)</div>
354
+ </div>
355
+ <div class="legend-item">
356
+ <div class="legend-color" style="background: #FFFF00"></div>
357
+ <div class="legend-text">Predicted Path (4s)</div>
358
+ </div>
359
+ </div>
360
+ </div>
361
+ </div>
362
+ </div>
363
+
364
+ <div id="toast" class="toast">Action Successful</div>
365
+
366
+ <script>
367
+ function showToast(message) {
368
+ const toast = document.getElementById('toast');
369
+ toast.textContent = message;
370
+ toast.classList.add('show');
371
+ setTimeout(() => toast.classList.remove('show'), 3000);
372
+ }
373
+
374
+ function setSource(source) {
375
+ fetch('/set_source', {
376
+ method: 'POST',
377
+ headers: {
378
+ 'Content-Type': 'application/json',
379
+ },
380
+ body: JSON.stringify({ source: source }),
381
+ })
382
+ .then(response => response.json())
383
+ .then(data => {
384
+ if (data.success) {
385
+ document.getElementById('source-label').textContent = 'Source: ' + (source === 'camera' ? 'Live Camera' : 'Uploaded Video');
386
+ showToast('Switched to ' + source);
387
+ }
388
+ });
389
+ }
390
+
391
+ function handleFileUpload(input) {
392
+ if (input.files && input.files[0]) {
393
+ const formData = new FormData();
394
+ formData.append('file', input.files[0]);
395
+
396
+ fetch('/upload_video', {
397
+ method: 'POST',
398
+ body: formData
399
+ })
400
+ .then(response => response.json())
401
+ .then(data => {
402
+ if (data.success) {
403
+ document.getElementById('source-label').textContent = 'Source: Uploaded Video';
404
+ showToast('Video uploaded successfully');
405
+ }
406
+ })
407
+ .catch(error => {
408
+ console.error('Error:', error);
409
+ showToast('Upload failed');
410
+ });
411
+ }
412
+ }
413
+
414
+ function updateStats() {
415
+ fetch('/stats')
416
+ .then(response => response.json())
417
+ .then(data => {
418
+ document.getElementById('current-people').textContent = data.current_people;
419
+ document.getElementById('total-people').textContent = data.total_people;
420
+ document.getElementById('long-stays').textContent = data.long_stays;
421
+ })
422
+ .catch(error => console.error('Error fetching stats:', error));
423
+ }
424
+
425
+ setInterval(updateStats, 500);
426
+ </script>
427
+ </body>
428
+
429
+ </html>
templates/sentinel_dashboard.html ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Project SENTINEL - National Security Grid</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&display=swap"
9
+ rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --primary-orange: #FF6B00;
13
+ --dark-bg: #050505;
14
+ --panel-bg: #111111;
15
+ --text-white: #FFFFFF;
16
+ --text-gray: #888888;
17
+ --border-color: #333333;
18
+ --danger-red: #FF4757;
19
+ --success-green: #00FF88;
20
+ }
21
+
22
+ * {
23
+ margin: 0;
24
+ padding: 0;
25
+ box-sizing: border-box;
26
+ }
27
+
28
+ body {
29
+ font-family: 'Outfit', sans-serif;
30
+ background-color: var(--dark-bg);
31
+ color: var(--text-white);
32
+ height: 100vh;
33
+ overflow: hidden;
34
+ display: flex;
35
+ }
36
+
37
+ /* Sidebar */
38
+ .sidebar {
39
+ width: 250px;
40
+ background-color: var(--panel-bg);
41
+ border-right: 1px solid var(--border-color);
42
+ display: flex;
43
+ flex-direction: column;
44
+ padding: 20px;
45
+ }
46
+
47
+ .logo {
48
+ font-size: 1.5rem;
49
+ font-weight: 800;
50
+ margin-bottom: 40px;
51
+ letter-spacing: -1px;
52
+ }
53
+
54
+ .logo span {
55
+ color: var(--primary-orange);
56
+ }
57
+
58
+ .nav-item {
59
+ padding: 15px;
60
+ margin-bottom: 10px;
61
+ border-radius: 8px;
62
+ cursor: pointer;
63
+ transition: all 0.3s;
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 10px;
67
+ color: var(--text-gray);
68
+ }
69
+
70
+ .nav-item:hover,
71
+ .nav-item.active {
72
+ background-color: rgba(255, 107, 0, 0.1);
73
+ color: var(--primary-orange);
74
+ }
75
+
76
+ .nav-item.active {
77
+ border-left: 3px solid var(--primary-orange);
78
+ }
79
+
80
+ /* Main Content */
81
+ .main-content {
82
+ flex: 1;
83
+ padding: 20px;
84
+ display: grid;
85
+ grid-template-columns: 1fr 350px;
86
+ gap: 20px;
87
+ }
88
+
89
+ /* Video Feed */
90
+ .video-panel {
91
+ background-color: var(--panel-bg);
92
+ border-radius: 16px;
93
+ border: 1px solid var(--border-color);
94
+ overflow: hidden;
95
+ display: flex;
96
+ flex-direction: column;
97
+ }
98
+
99
+ .panel-header {
100
+ padding: 15px 20px;
101
+ border-bottom: 1px solid var(--border-color);
102
+ display: flex;
103
+ justify-content: space-between;
104
+ align-items: center;
105
+ }
106
+
107
+ .live-badge {
108
+ background-color: var(--danger-red);
109
+ color: white;
110
+ padding: 4px 8px;
111
+ border-radius: 4px;
112
+ font-size: 0.7rem;
113
+ font-weight: 700;
114
+ animation: pulse 2s infinite;
115
+ }
116
+
117
+ #video-stream {
118
+ width: 100%;
119
+ height: 100%;
120
+ object-fit: contain;
121
+ background: black;
122
+ }
123
+
124
+ /* Intelligence Panel */
125
+ .intel-panel {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 20px;
129
+ }
130
+
131
+ .card {
132
+ background-color: var(--panel-bg);
133
+ border-radius: 16px;
134
+ padding: 20px;
135
+ border: 1px solid var(--border-color);
136
+ }
137
+
138
+ .card h3 {
139
+ font-size: 0.9rem;
140
+ color: var(--text-gray);
141
+ text-transform: uppercase;
142
+ letter-spacing: 1px;
143
+ margin-bottom: 15px;
144
+ }
145
+
146
+ .threat-score {
147
+ text-align: center;
148
+ padding: 20px 0;
149
+ }
150
+
151
+ .score-value {
152
+ font-size: 4rem;
153
+ font-weight: 800;
154
+ line-height: 1;
155
+ }
156
+
157
+ .score-label {
158
+ color: var(--text-gray);
159
+ font-size: 0.9rem;
160
+ margin-top: 5px;
161
+ }
162
+
163
+ .stats-grid {
164
+ display: grid;
165
+ grid-template-columns: 1fr 1fr;
166
+ gap: 10px;
167
+ }
168
+
169
+ .stat-item {
170
+ background: rgba(255, 255, 255, 0.05);
171
+ padding: 10px;
172
+ border-radius: 8px;
173
+ }
174
+
175
+ .stat-num {
176
+ font-size: 1.5rem;
177
+ font-weight: 700;
178
+ }
179
+
180
+ .stat-desc {
181
+ font-size: 0.8rem;
182
+ color: var(--text-gray);
183
+ }
184
+
185
+ .btn {
186
+ width: 100%;
187
+ padding: 12px;
188
+ border: none;
189
+ border-radius: 8px;
190
+ font-weight: 600;
191
+ cursor: pointer;
192
+ margin-top: 10px;
193
+ font-family: 'Outfit', sans-serif;
194
+ }
195
+
196
+ .btn-primary {
197
+ background-color: var(--primary-orange);
198
+ color: white;
199
+ }
200
+
201
+ .btn-outline {
202
+ background-color: transparent;
203
+ border: 1px solid var(--border-color);
204
+ color: var(--text-gray);
205
+ }
206
+
207
+ .btn:hover {
208
+ opacity: 0.9;
209
+ }
210
+
211
+ /* Report Modal */
212
+ .modal {
213
+ display: none;
214
+ position: fixed;
215
+ top: 0;
216
+ left: 0;
217
+ width: 100%;
218
+ height: 100%;
219
+ background: rgba(0, 0, 0, 0.8);
220
+ z-index: 1000;
221
+ justify-content: center;
222
+ align-items: center;
223
+ }
224
+
225
+ .modal-content {
226
+ background: var(--panel-bg);
227
+ width: 500px;
228
+ padding: 30px;
229
+ border-radius: 16px;
230
+ border: 1px solid var(--primary-orange);
231
+ }
232
+
233
+ .report-text {
234
+ white-space: pre-wrap;
235
+ font-family: monospace;
236
+ color: #00FF88;
237
+ background: rgba(0, 0, 0, 0.5);
238
+ padding: 15px;
239
+ border-radius: 8px;
240
+ margin: 20px 0;
241
+ max-height: 300px;
242
+ overflow-y: auto;
243
+ }
244
+
245
+ @keyframes pulse {
246
+ 0% {
247
+ opacity: 1;
248
+ }
249
+
250
+ 50% {
251
+ opacity: 0.5;
252
+ }
253
+
254
+ 100% {
255
+ opacity: 1;
256
+ }
257
+ }
258
+ </style>
259
+ </head>
260
+
261
+ <body>
262
+ <div class="sidebar">
263
+ <div class="logo">PROJECT <span>SENTINEL</span></div>
264
+ <div class="nav-item active" onclick="setMode('movement', this)">
265
+ <span>🏃</span> Movement Analysis
266
+ </div>
267
+ <div class="nav-item" onclick="setMode('facemask', this)">
268
+ <span>😷</span> Facemask Detection
269
+ </div>
270
+ <div class="nav-item" onclick="setMode('weapon', this)">
271
+ <span>🔫</span> Weapon Detection
272
+ </div>
273
+
274
+ <div style="margin-top: auto;">
275
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
276
+ <span>📤</span> Upload Video
277
+ </div>
278
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
279
+ onchange="handleFileUpload(this)">
280
+ <div class="nav-item" onclick="setSource('camera')">
281
+ <span>📷</span> Live Camera
282
+ </div>
283
+ </div>
284
+ </div>
285
+
286
+ <div class="main-content">
287
+ <div class="video-panel">
288
+ <div class="panel-header">
289
+ <span id="mode-title">Movement Analysis Feed</span>
290
+ <span class="live-badge">LIVE</span>
291
+ </div>
292
+ <img id="video-stream" src="{{ url_for('video_feed') }}">
293
+ </div>
294
+
295
+ <div class="intel-panel">
296
+ <div class="card">
297
+ <h3>Dynamic Threat Score</h3>
298
+ <div class="threat-score">
299
+ <div class="score-value" id="threat-score" style="color: var(--success-green)">0</div>
300
+ <div class="score-label">Low Risk</div>
301
+ </div>
302
+ </div>
303
+
304
+ <div class="card">
305
+ <h3>Live Intelligence</h3>
306
+ <div class="stats-grid" id="stats-container">
307
+ <!-- Populated by JS -->
308
+ </div>
309
+ </div>
310
+
311
+ <div class="card">
312
+ <h3>AI Agent</h3>
313
+ <p style="font-size: 0.9rem; color: var(--text-gray); margin-bottom: 15px;">
314
+ Generate automated incident report based on current threat assessment.
315
+ </p>
316
+ <button class="btn btn-primary" onclick="generateReport()">Generate Report</button>
317
+ </div>
318
+ </div>
319
+ </div>
320
+
321
+ <div id="report-modal" class="modal">
322
+ <div class="modal-content">
323
+ <h2>Incident Report</h2>
324
+ <div class="report-text" id="report-content">Generating...</div>
325
+ <button class="btn btn-outline"
326
+ onclick="document.getElementById('report-modal').style.display='none'">Close</button>
327
+ </div>
328
+ </div>
329
+
330
+ <script>
331
+ function setMode(mode, element) {
332
+ // Update UI
333
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
334
+ element.classList.add('active');
335
+
336
+ const titles = { 'movement': 'Movement Analysis Feed', 'facemask': 'Facemask Detection Feed', 'weapon': 'Weapon Detection Feed' };
337
+ document.getElementById('mode-title').textContent = titles[mode];
338
+
339
+ // Call Backend
340
+ fetch('/set_mode', {
341
+ method: 'POST',
342
+ headers: { 'Content-Type': 'application/json' },
343
+ body: JSON.stringify({ mode: mode })
344
+ });
345
+ }
346
+
347
+ function setSource(source) {
348
+ fetch('/set_source', {
349
+ method: 'POST',
350
+ headers: { 'Content-Type': 'application/json' },
351
+ body: JSON.stringify({ source: source })
352
+ });
353
+ }
354
+
355
+ function handleFileUpload(input) {
356
+ if (input.files[0]) {
357
+ const formData = new FormData();
358
+ formData.append('file', input.files[0]);
359
+ fetch('/upload_video', { method: 'POST', body: formData });
360
+ }
361
+ }
362
+
363
+ function generateReport() {
364
+ document.getElementById('report-modal').style.display = 'flex';
365
+ document.getElementById('report-content').textContent = "Analyzing data...";
366
+
367
+ fetch('/generate_report', { method: 'POST' })
368
+ .then(r => r.json())
369
+ .then(data => {
370
+ document.getElementById('report-content').textContent = data.report;
371
+ });
372
+ }
373
+
374
+ function updateStats() {
375
+ fetch('/stats')
376
+ .then(r => r.json())
377
+ .then(data => {
378
+ // Update Score
379
+ const score = data.threat_score;
380
+ const scoreEl = document.getElementById('threat-score');
381
+ scoreEl.textContent = score;
382
+ if (score > 75) { scoreEl.style.color = 'var(--danger-red)'; scoreEl.nextElementSibling.textContent = 'CRITICAL'; }
383
+ else if (score > 40) { scoreEl.style.color = '#FFA500'; scoreEl.nextElementSibling.textContent = 'ELEVATED'; }
384
+ else { scoreEl.style.color = 'var(--success-green)'; scoreEl.nextElementSibling.textContent = 'LOW RISK'; }
385
+
386
+ // Update Stats Grid
387
+ const container = document.getElementById('stats-container');
388
+ container.innerHTML = '';
389
+
390
+ for (const [key, value] of Object.entries(data.details)) {
391
+ const div = document.createElement('div');
392
+ div.className = 'stat-item';
393
+ div.innerHTML = `<div class="stat-num">${value}</div><div class="stat-desc">${key.replace('_', ' ').toUpperCase()}</div>`;
394
+ container.appendChild(div);
395
+ }
396
+ });
397
+ }
398
+
399
+ setInterval(updateStats, 1000);
400
+ </script>
401
+ </body>
402
+
403
+ </html>
templates/sentinel_dashboard_v10.html ADDED
@@ -0,0 +1,1385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V10 — Intelligence Grid</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ══════════════════════════════════════════��════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+ </style>
882
+ </head>
883
+
884
+ <body>
885
+ <!-- Red Alert Overlay -->
886
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
887
+ <div class="red-alert-banner" id="red-alert-banner">⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠</div>
888
+
889
+ <!-- SIDEBAR -->
890
+ <div class="sidebar">
891
+ <div class="logo">
892
+ <i data-feather="shield"></i>
893
+ SENTINEL
894
+ </div>
895
+ <div class="version-tag">V10.0 // STANDBY FIX</div>
896
+
897
+ <div class="nav-group">
898
+ <div class="nav-label">Detection Modules</div>
899
+ <div class="nav-item" onclick="setMode('standby', this)" id="nav-standby">
900
+ <i data-feather="pause-circle"></i> Standby
901
+ </div>
902
+ <div class="nav-item active" onclick="setMode('movement', this)" id="nav-movement">
903
+ <i data-feather="activity"></i> Movement
904
+ </div>
905
+ <div class="nav-item" onclick="setMode('facemask', this)" id="nav-facemask">
906
+ <i data-feather="eye"></i> Facemask
907
+ </div>
908
+ <div class="nav-item" onclick="setMode('weapon', this)" id="nav-weapon">
909
+ <i data-feather="crosshair"></i> Weapon
910
+ </div>
911
+ <div class="nav-item" onclick="setMode('public_safety', this)" id="nav-public_safety">
912
+ <i data-feather="users"></i> Public Safety
913
+ </div>
914
+ </div>
915
+
916
+ <div class="nav-group">
917
+ <div class="nav-label">Input Source</div>
918
+ <div class="nav-item" onclick="setSource('camera')">
919
+ <i data-feather="video"></i> Live Camera
920
+ </div>
921
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
922
+ <i data-feather="upload-cloud"></i> Upload Video
923
+ </div>
924
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
925
+ onchange="handleFileUpload(this)">
926
+ </div>
927
+
928
+ <div class="nav-group">
929
+ <div class="nav-label">Grid Layout</div>
930
+ <div class="nav-item" onclick="setGridLayout('quad')">
931
+ <i data-feather="grid"></i> 2×2 Grid
932
+ </div>
933
+ <div class="nav-item" onclick="setGridLayout('single')">
934
+ <i data-feather="maximize-2"></i> Single View
935
+ </div>
936
+ </div>
937
+
938
+ <!-- Audit Log -->
939
+ <div class="audit-section">
940
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
941
+ <div class="audit-log-container" id="audit-log-container">
942
+ <div class="audit-entry">
943
+ <div class="audit-time">--:--:--</div>
944
+ SYSTEM STANDBY
945
+ </div>
946
+ </div>
947
+ </div>
948
+ </div>
949
+
950
+ <!-- MAIN CONTENT -->
951
+ <div class="main-content">
952
+ <!-- Multi-Camera Grid -->
953
+ <div class="camera-grid" id="camera-grid">
954
+ <!-- Feed 0 — Primary -->
955
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
956
+ <div class="feed-header">
957
+ <div class="feed-badge">
958
+ <div class="live-dot"></div>
959
+ <span>FEED 01 // PRIMARY</span>
960
+ </div>
961
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
962
+ </div>
963
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
964
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
965
+ <i data-feather="maximize-2"></i>
966
+ </button>
967
+ </div>
968
+
969
+ <!-- Feed 1 -->
970
+ <div class="feed-cell" id="feed-1">
971
+ <div class="feed-header">
972
+ <div class="feed-badge">
973
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
974
+ <span>FEED 02</span>
975
+ </div>
976
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
977
+ </div>
978
+ <div class="feed-offline" id="offline-1">
979
+ <i data-feather="video-off"></i>
980
+ NO SIGNAL
981
+ </div>
982
+ </div>
983
+
984
+ <!-- Feed 2 -->
985
+ <div class="feed-cell" id="feed-2">
986
+ <div class="feed-header">
987
+ <div class="feed-badge">
988
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
989
+ <span>FEED 03</span>
990
+ </div>
991
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
992
+ </div>
993
+ <div class="feed-offline" id="offline-2">
994
+ <i data-feather="video-off"></i>
995
+ NO SIGNAL
996
+ </div>
997
+ </div>
998
+
999
+ <!-- Feed 3 -->
1000
+ <div class="feed-cell" id="feed-3">
1001
+ <div class="feed-header">
1002
+ <div class="feed-badge">
1003
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1004
+ <span>FEED 04</span>
1005
+ </div>
1006
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1007
+ </div>
1008
+ <div class="feed-offline" id="offline-3">
1009
+ <i data-feather="video-off"></i>
1010
+ NO SIGNAL
1011
+ </div>
1012
+ </div>
1013
+ </div>
1014
+
1015
+ <!-- Intel Panel -->
1016
+ <div class="intel-panel">
1017
+ <!-- Active Mode -->
1018
+ <div class="card">
1019
+ <div class="card-header">
1020
+ <div class="card-title">Active Mode</div>
1021
+ </div>
1022
+ <div class="mode-indicator">
1023
+ <div class="mode-dot"></div>
1024
+ <div class="mode-name" id="mode-title">MOVEMENT ANALYSIS</div>
1025
+ </div>
1026
+ </div>
1027
+
1028
+ <!-- Threat Assessment -->
1029
+ <div class="card" id="threat-card">
1030
+ <div class="card-header">
1031
+ <div class="card-title">Threat Assessment</div>
1032
+ <i data-feather="alert-triangle" class="card-icon"></i>
1033
+ </div>
1034
+ <div class="threat-gauge">
1035
+ <div class="score-ring">
1036
+ <svg viewBox="0 0 130 130">
1037
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1038
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1039
+ </svg>
1040
+ <div class="score-text">
1041
+ <div class="score-value" id="threat-score">0</div>
1042
+ <div class="score-label">THREAT LEVEL</div>
1043
+ </div>
1044
+ </div>
1045
+ <div class="status-text" id="status-text">SECURE</div>
1046
+ </div>
1047
+ </div>
1048
+
1049
+ <!-- Live Metrics -->
1050
+ <div class="card">
1051
+ <div class="card-header">
1052
+ <div class="card-title">Live Metrics</div>
1053
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1054
+ </div>
1055
+ <div class="stats-list" id="stats-container">
1056
+ <div class="stat-row">
1057
+ <span class="stat-name">System Status</span>
1058
+ <span class="stat-val">Initializing</span>
1059
+ </div>
1060
+ </div>
1061
+ </div>
1062
+
1063
+ <!-- Actions -->
1064
+ <div class="card">
1065
+ <div class="card-header">
1066
+ <div class="card-title">Actions</div>
1067
+ </div>
1068
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1069
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1070
+ Log</button>
1071
+ </div>
1072
+ </div>
1073
+ </div>
1074
+
1075
+ <!-- Report Modal -->
1076
+ <div id="report-modal" class="modal-overlay">
1077
+ <div class="modal-card">
1078
+ <div class="modal-title">Incident Report</div>
1079
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1080
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1081
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <!-- ═══════════════════════════════════════════════
1086
+ JAVASCRIPT
1087
+ ═══════════════════════════════════════════════ -->
1088
+ <script>
1089
+ feather.replace();
1090
+
1091
+ // ─── State ───
1092
+ let currentLayout = 'quad'; // 'quad' or 'single'
1093
+ let expandedFeed = 0;
1094
+ let isRedAlert = false;
1095
+
1096
+ // ─── Mode Switching ───
1097
+ const modeTitles = {
1098
+ 'standby': 'SYSTEM STANDBY',
1099
+ 'movement': 'MOVEMENT ANALYSIS',
1100
+ 'facemask': 'FACEMASK DETECTION',
1101
+ 'weapon': 'WEAPON DETECTION',
1102
+ 'public_safety': 'PUBLIC SAFETY'
1103
+ };
1104
+
1105
+ function setMode(mode, element) {
1106
+ // Radio-style selection: always activate the clicked mode
1107
+ // Remove active from all module buttons
1108
+ document.querySelectorAll('.nav-group:first-of-type .nav-item').forEach(el => el.classList.remove('active'));
1109
+
1110
+ // Add active to the clicked button
1111
+ element.classList.add('active');
1112
+
1113
+ // Update UI display
1114
+ document.getElementById('mode-title').textContent = modeTitles[mode] || mode.toUpperCase();
1115
+
1116
+ // Send to backend
1117
+ fetch('/set_mode', {
1118
+ method: 'POST',
1119
+ headers: { 'Content-Type': 'application/json' },
1120
+ body: JSON.stringify({ mode: mode })
1121
+ });
1122
+ }
1123
+
1124
+ function setSource(source) {
1125
+ fetch('/set_source', {
1126
+ method: 'POST',
1127
+ headers: { 'Content-Type': 'application/json' },
1128
+ body: JSON.stringify({ source: source })
1129
+ })
1130
+ .then(r => r.json())
1131
+ .then(data => {
1132
+ if (data.success) {
1133
+ // Force the browser to reconnect to the restarted MJPEG stream
1134
+ refreshFeedStream(0);
1135
+ }
1136
+ });
1137
+ }
1138
+
1139
+ function handleFileUpload(input) {
1140
+ if (input.files[0]) {
1141
+ const formData = new FormData();
1142
+ formData.append('file', input.files[0]);
1143
+
1144
+ // Show uploading state
1145
+ const statusEl = document.getElementById('feed-0-status');
1146
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1147
+
1148
+ fetch('/upload_video', { method: 'POST', body: formData })
1149
+ .then(r => r.json())
1150
+ .then(data => {
1151
+ if (data.success) {
1152
+ // Force the browser to reconnect to the restarted MJPEG stream
1153
+ refreshFeedStream(0);
1154
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1155
+ }
1156
+ })
1157
+ .catch(() => {
1158
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1159
+ });
1160
+
1161
+ // Reset file input so the same file can be re-uploaded
1162
+ input.value = '';
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1168
+ * This drops the old HTTP connection and establishes a fresh one.
1169
+ */
1170
+ function refreshFeedStream(feedId) {
1171
+ const img = document.getElementById('stream-' + feedId);
1172
+ if (img) {
1173
+ // Brief blank to visually signal the switch
1174
+ img.src = '';
1175
+ // Small delay lets the backend fully initialize the new feed
1176
+ setTimeout(() => {
1177
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1178
+ }, 300);
1179
+ }
1180
+ }
1181
+
1182
+ // ─── Grid Layout ───
1183
+ function setGridLayout(layout) {
1184
+ const grid = document.getElementById('camera-grid');
1185
+ currentLayout = layout;
1186
+
1187
+ if (layout === 'single') {
1188
+ grid.classList.add('single-view');
1189
+ // Show only the expanded feed
1190
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1191
+ cell.classList.toggle('expanded', i === expandedFeed);
1192
+ });
1193
+ } else {
1194
+ grid.classList.remove('single-view');
1195
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1196
+ cell.classList.remove('expanded');
1197
+ });
1198
+ }
1199
+ }
1200
+
1201
+ function expandFeed(feedId) {
1202
+ expandedFeed = feedId;
1203
+ if (currentLayout === 'single') {
1204
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1205
+ cell.classList.toggle('expanded', i === feedId);
1206
+ });
1207
+ }
1208
+ }
1209
+
1210
+ // ─── Stats & Red Alert Updates ───
1211
+ function updateStats() {
1212
+ fetch('/stats')
1213
+ .then(r => r.json())
1214
+ .then(data => {
1215
+ const score = data.threat_score;
1216
+ const scoreEl = document.getElementById('threat-score');
1217
+ const statusEl = document.getElementById('status-text');
1218
+ const ringFill = document.getElementById('score-ring-fill');
1219
+ const threatCard = document.getElementById('threat-card');
1220
+
1221
+ scoreEl.textContent = score;
1222
+
1223
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1224
+ const circumference = 377;
1225
+ const offset = circumference - (circumference * score / 100);
1226
+ ringFill.style.strokeDashoffset = offset;
1227
+
1228
+ // Color based on score
1229
+ let color, status, glow;
1230
+ if (score >= 80) {
1231
+ color = '#ff2040';
1232
+ status = 'CRITICAL';
1233
+ glow = 'rgba(255, 32, 64, 0.4)';
1234
+ } else if (score >= 50) {
1235
+ color = '#ffaa00';
1236
+ status = 'ELEVATED';
1237
+ glow = 'rgba(255, 170, 0, 0.3)';
1238
+ } else if (score >= 25) {
1239
+ color = '#00d4ff';
1240
+ status = 'GUARDED';
1241
+ glow = 'rgba(0, 200, 255, 0.3)';
1242
+ } else {
1243
+ color = '#00ff88';
1244
+ status = 'SECURE';
1245
+ glow = 'rgba(0, 255, 136, 0.3)';
1246
+ }
1247
+
1248
+ statusEl.textContent = status;
1249
+ statusEl.style.color = color;
1250
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1251
+ ringFill.style.stroke = color;
1252
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1253
+
1254
+ // Red Alert state
1255
+ const alertOverlay = document.getElementById('red-alert-overlay');
1256
+ const alertBanner = document.getElementById('red-alert-banner');
1257
+ const feedCells = document.querySelectorAll('.feed-cell');
1258
+
1259
+ if (data.red_alert) {
1260
+ alertOverlay.classList.add('active');
1261
+ alertBanner.classList.add('active');
1262
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1263
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1264
+
1265
+ if (!isRedAlert) {
1266
+ playAlertTone();
1267
+ isRedAlert = true;
1268
+ }
1269
+ } else {
1270
+ alertOverlay.classList.remove('active');
1271
+ alertBanner.classList.remove('active');
1272
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1273
+ threatCard.style.borderColor = '';
1274
+ isRedAlert = false;
1275
+ }
1276
+
1277
+ // Update mode display
1278
+ if (data.mode) {
1279
+ document.getElementById('mode-title').textContent = modeTitles[data.mode] || data.mode.toUpperCase();
1280
+ }
1281
+
1282
+ // Update live metrics
1283
+ const container = document.getElementById('stats-container');
1284
+ container.innerHTML = '';
1285
+
1286
+ if (!data.details || Object.keys(data.details).length === 0) {
1287
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1288
+ } else {
1289
+ for (const [key, value] of Object.entries(data.details)) {
1290
+ const div = document.createElement('div');
1291
+ div.className = 'stat-row';
1292
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1293
+ let displayVal = value;
1294
+ if (typeof value === 'boolean') {
1295
+ displayVal = value ? '⚠ YES' : 'No';
1296
+ }
1297
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1298
+ container.appendChild(div);
1299
+ }
1300
+ }
1301
+ })
1302
+ .catch(() => { });
1303
+ }
1304
+
1305
+ // ─── Alert Tone (Web Audio API) ───
1306
+ function playAlertTone() {
1307
+ try {
1308
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1309
+ const oscillator = audioCtx.createOscillator();
1310
+ const gainNode = audioCtx.createGain();
1311
+
1312
+ oscillator.connect(gainNode);
1313
+ gainNode.connect(audioCtx.destination);
1314
+
1315
+ oscillator.type = 'square';
1316
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1317
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1318
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1319
+
1320
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1321
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1322
+
1323
+ oscillator.start(audioCtx.currentTime);
1324
+ oscillator.stop(audioCtx.currentTime + 0.5);
1325
+ } catch (e) {
1326
+ // Audio not available — silent fallback
1327
+ }
1328
+ }
1329
+
1330
+ // ─── AI Report ───
1331
+ function generateReport() {
1332
+ const modal = document.getElementById('report-modal');
1333
+ modal.classList.add('show');
1334
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1335
+
1336
+ fetch('/generate_report', { method: 'POST' })
1337
+ .then(r => r.json())
1338
+ .then(data => {
1339
+ document.getElementById('report-content').textContent = data.report;
1340
+ })
1341
+ .catch(() => {
1342
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1343
+ });
1344
+ }
1345
+
1346
+ function closeModal() {
1347
+ document.getElementById('report-modal').classList.remove('show');
1348
+ }
1349
+
1350
+ // ─── Audit Log Refresh ───
1351
+ function refreshAuditLog() {
1352
+ fetch('/audit_log')
1353
+ .then(r => r.json())
1354
+ .then(data => {
1355
+ const container = document.getElementById('audit-log-container');
1356
+ container.innerHTML = '';
1357
+
1358
+ if (data.log.length === 0) {
1359
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1360
+ return;
1361
+ }
1362
+
1363
+ data.log.slice(0, 30).forEach(entry => {
1364
+ const div = document.createElement('div');
1365
+ div.className = `audit-entry severity-${entry.severity}`;
1366
+ div.innerHTML = `
1367
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1368
+ ${entry.action}: ${entry.details}
1369
+ `;
1370
+ container.appendChild(div);
1371
+ });
1372
+ })
1373
+ .catch(() => { });
1374
+ }
1375
+
1376
+ // ─── Intervals ───
1377
+ setInterval(updateStats, 1000);
1378
+ setInterval(refreshAuditLog, 5000);
1379
+
1380
+ // Initial load
1381
+ setTimeout(refreshAuditLog, 1500);
1382
+ </script>
1383
+ </body>
1384
+
1385
+ </html>
templates/sentinel_dashboard_v11.html ADDED
@@ -0,0 +1,1413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V11 — Intelligence Grid</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ══════════════════════════════════════════��════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+ </style>
882
+ </head>
883
+
884
+ <body>
885
+ <!-- Red Alert Overlay -->
886
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
887
+ <div class="red-alert-banner" id="red-alert-banner">
888
+ ⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠
889
+ <button class="btn btn-secondary" onclick="resetSystem()"
890
+ style="width: auto; margin-top: 10px; background: rgba(0,0,0,0.5); border: 1px solid #ff2040; color: #ff2040;">RESET
891
+ SYSTEM</button>
892
+ </div>
893
+
894
+ <!-- SIDEBAR -->
895
+ <div class="sidebar">
896
+ <div class="logo">
897
+ <i data-feather="shield"></i>
898
+ SENTINEL
899
+ </div>
900
+ <div class="version-tag">V11.0 // MULTI-MODULE</div>
901
+
902
+ <div class="nav-group">
903
+ <div class="nav-label">Detection Modules</div>
904
+ <div class="nav-item active" onclick="toggleModule('movement', this)" id="nav-movement">
905
+ <i data-feather="activity"></i> Movement
906
+ </div>
907
+ <div class="nav-item" onclick="toggleModule('facemask', this)" id="nav-facemask">
908
+ <i data-feather="eye"></i> Facemask
909
+ </div>
910
+ <div class="nav-item" onclick="toggleModule('weapon', this)" id="nav-weapon">
911
+ <i data-feather="crosshair"></i> Weapon
912
+ </div>
913
+ <div class="nav-item" onclick="toggleModule('public_safety', this)" id="nav-public_safety">
914
+ <i data-feather="users"></i> Public Safety
915
+ </div>
916
+ </div>
917
+
918
+ <div class="nav-group">
919
+ <div class="nav-label">Input Source</div>
920
+ <div class="nav-item" onclick="setSource('camera')">
921
+ <i data-feather="video"></i> Live Camera
922
+ </div>
923
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
924
+ <i data-feather="upload-cloud"></i> Upload Video
925
+ </div>
926
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
927
+ onchange="handleFileUpload(this)">
928
+ </div>
929
+
930
+ <div class="nav-group">
931
+ <div class="nav-label">Grid Layout</div>
932
+ <div class="nav-item" onclick="setGridLayout('quad')">
933
+ <i data-feather="grid"></i> 2×2 Grid
934
+ </div>
935
+ <div class="nav-item" onclick="setGridLayout('single')">
936
+ <i data-feather="maximize-2"></i> Single View
937
+ </div>
938
+ </div>
939
+
940
+ <!-- Audit Log -->
941
+ <div class="audit-section">
942
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
943
+ <div class="audit-log-container" id="audit-log-container">
944
+ <div class="audit-entry">
945
+ <div class="audit-time">--:--:--</div>
946
+ SYSTEM STANDBY
947
+ </div>
948
+ </div>
949
+ </div>
950
+ </div>
951
+
952
+ <!-- MAIN CONTENT -->
953
+ <div class="main-content">
954
+ <!-- Multi-Camera Grid -->
955
+ <div class="camera-grid" id="camera-grid">
956
+ <!-- Feed 0 — Primary -->
957
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
958
+ <div class="feed-header">
959
+ <div class="feed-badge">
960
+ <div class="live-dot"></div>
961
+ <span>FEED 01 // PRIMARY</span>
962
+ </div>
963
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
964
+ </div>
965
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
966
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
967
+ <i data-feather="maximize-2"></i>
968
+ </button>
969
+ </div>
970
+
971
+ <!-- Feed 1 -->
972
+ <div class="feed-cell" id="feed-1">
973
+ <div class="feed-header">
974
+ <div class="feed-badge">
975
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
976
+ <span>FEED 02</span>
977
+ </div>
978
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
979
+ </div>
980
+ <div class="feed-offline" id="offline-1">
981
+ <i data-feather="video-off"></i>
982
+ NO SIGNAL
983
+ </div>
984
+ </div>
985
+
986
+ <!-- Feed 2 -->
987
+ <div class="feed-cell" id="feed-2">
988
+ <div class="feed-header">
989
+ <div class="feed-badge">
990
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
991
+ <span>FEED 03</span>
992
+ </div>
993
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
994
+ </div>
995
+ <div class="feed-offline" id="offline-2">
996
+ <i data-feather="video-off"></i>
997
+ NO SIGNAL
998
+ </div>
999
+ </div>
1000
+
1001
+ <!-- Feed 3 -->
1002
+ <div class="feed-cell" id="feed-3">
1003
+ <div class="feed-header">
1004
+ <div class="feed-badge">
1005
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1006
+ <span>FEED 04</span>
1007
+ </div>
1008
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1009
+ </div>
1010
+ <div class="feed-offline" id="offline-3">
1011
+ <i data-feather="video-off"></i>
1012
+ NO SIGNAL
1013
+ </div>
1014
+ </div>
1015
+ </div>
1016
+
1017
+ <!-- Intel Panel -->
1018
+ <div class="intel-panel">
1019
+ <!-- Active Mode -->
1020
+ <div class="card">
1021
+ <div class="card-header">
1022
+ <div class="card-title">Active Mode</div>
1023
+ </div>
1024
+ <div class="mode-indicator">
1025
+ <div class="mode-dot"></div>
1026
+ <div class="mode-name" id="mode-title">1 MODULE ACTIVE</div>
1027
+ </div>
1028
+ </div>
1029
+
1030
+ <!-- Threat Assessment -->
1031
+ <div class="card" id="threat-card">
1032
+ <div class="card-header">
1033
+ <div class="card-title">Threat Assessment</div>
1034
+ <i data-feather="alert-triangle" class="card-icon"></i>
1035
+ </div>
1036
+ <div class="threat-gauge">
1037
+ <div class="score-ring">
1038
+ <svg viewBox="0 0 130 130">
1039
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1040
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1041
+ </svg>
1042
+ <div class="score-text">
1043
+ <div class="score-value" id="threat-score">0</div>
1044
+ <div class="score-label">THREAT LEVEL</div>
1045
+ </div>
1046
+ </div>
1047
+ <div class="status-text" id="status-text">SECURE</div>
1048
+ </div>
1049
+ </div>
1050
+
1051
+ <!-- Live Metrics -->
1052
+ <div class="card">
1053
+ <div class="card-header">
1054
+ <div class="card-title">Live Metrics</div>
1055
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1056
+ </div>
1057
+ <div class="stats-list" id="stats-container">
1058
+ <div class="stat-row">
1059
+ <span class="stat-name">System Status</span>
1060
+ <span class="stat-val">Initializing</span>
1061
+ </div>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <!-- Actions -->
1066
+ <div class="card">
1067
+ <div class="card-header">
1068
+ <div class="card-title">Actions</div>
1069
+ </div>
1070
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1071
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1072
+ Log</button>
1073
+ <button class="btn btn-secondary" onclick="resetSystem()"
1074
+ style="margin-top: 8px; border-color: #ff2040; color: #ff2040;">⚠ SYSTEM RESET</button>
1075
+ </div>
1076
+ </div>
1077
+ </div>
1078
+
1079
+ <!-- Report Modal -->
1080
+ <div id="report-modal" class="modal-overlay">
1081
+ <div class="modal-card">
1082
+ <div class="modal-title">Incident Report</div>
1083
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1084
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1085
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1086
+ </div>
1087
+ </div>
1088
+
1089
+ <!-- ════════════════════���══════════════════════════
1090
+ JAVASCRIPT
1091
+ ═══════════════════════════════════════════════ -->
1092
+ <script>
1093
+ feather.replace();
1094
+
1095
+ // ─── State ───
1096
+ let currentLayout = 'quad'; // 'quad' or 'single'
1097
+ let expandedFeed = 0;
1098
+ let isRedAlert = false;
1099
+
1100
+ // ─── Module Toggling ───
1101
+ const modeTitles = {
1102
+ 'movement': 'MOVEMENT ANALYSIS',
1103
+ 'facemask': 'FACEMASK DETECTION',
1104
+ 'weapon': 'WEAPON DETECTION',
1105
+ 'public_safety': 'PUBLIC SAFETY'
1106
+ };
1107
+
1108
+ function toggleModule(module, buttonElement) {
1109
+ buttonElement.classList.toggle('active');
1110
+
1111
+ fetch('/toggle_module', {
1112
+ method: 'POST',
1113
+ headers: { 'Content-Type': 'application/json' },
1114
+ body: JSON.stringify({ module: module })
1115
+ })
1116
+ .then(r => r.json())
1117
+ .then(data => {
1118
+ updateActiveModulesDisplay(data.active_modules);
1119
+ });
1120
+ }
1121
+
1122
+ function updateActiveModulesDisplay(activeModules) {
1123
+ const modeTitle = document.getElementById('mode-title');
1124
+ const count = activeModules.length;
1125
+
1126
+ if (count === 0) {
1127
+ modeTitle.textContent = 'NO MODULES ACTIVE';
1128
+ } else if (count === 1) {
1129
+ modeTitle.textContent = modeTitles[activeModules[0]] || activeModules[0].toUpperCase();
1130
+ } else {
1131
+ modeTitle.textContent = `${count} MODULES ACTIVE`;
1132
+ }
1133
+ }
1134
+
1135
+
1136
+ function setSource(source) {
1137
+ fetch('/set_source', {
1138
+ method: 'POST',
1139
+ headers: { 'Content-Type': 'application/json' },
1140
+ body: JSON.stringify({ source: source })
1141
+ })
1142
+ .then(r => r.json())
1143
+ .then(data => {
1144
+ if (data.success) {
1145
+ // Force the browser to reconnect to the restarted MJPEG stream
1146
+ refreshFeedStream(0);
1147
+ }
1148
+ });
1149
+ }
1150
+
1151
+ function handleFileUpload(input) {
1152
+ if (input.files[0]) {
1153
+ const formData = new FormData();
1154
+ formData.append('file', input.files[0]);
1155
+
1156
+ // Show uploading state
1157
+ const statusEl = document.getElementById('feed-0-status');
1158
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1159
+
1160
+ fetch('/upload_video', { method: 'POST', body: formData })
1161
+ .then(r => r.json())
1162
+ .then(data => {
1163
+ if (data.success) {
1164
+ // Force the browser to reconnect to the restarted MJPEG stream
1165
+ refreshFeedStream(0);
1166
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1167
+ }
1168
+ })
1169
+ .catch(() => {
1170
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1171
+ });
1172
+
1173
+ // Reset file input so the same file can be re-uploaded
1174
+ input.value = '';
1175
+ }
1176
+ }
1177
+
1178
+ /**
1179
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1180
+ * This drops the old HTTP connection and establishes a fresh one.
1181
+ */
1182
+ function refreshFeedStream(feedId) {
1183
+ const img = document.getElementById('stream-' + feedId);
1184
+ if (img) {
1185
+ // Brief blank to visually signal the switch
1186
+ img.src = '';
1187
+ // Small delay lets the backend fully initialize the new feed
1188
+ setTimeout(() => {
1189
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1190
+ }, 300);
1191
+ }
1192
+ }
1193
+
1194
+ // ─── Grid Layout ───
1195
+ function setGridLayout(layout) {
1196
+ const grid = document.getElementById('camera-grid');
1197
+ currentLayout = layout;
1198
+
1199
+ if (layout === 'single') {
1200
+ grid.classList.add('single-view');
1201
+ // Show only the expanded feed
1202
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1203
+ cell.classList.toggle('expanded', i === expandedFeed);
1204
+ });
1205
+ } else {
1206
+ grid.classList.remove('single-view');
1207
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1208
+ cell.classList.remove('expanded');
1209
+ });
1210
+ }
1211
+ }
1212
+
1213
+ function expandFeed(feedId) {
1214
+ expandedFeed = feedId;
1215
+ if (currentLayout === 'single') {
1216
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1217
+ cell.classList.toggle('expanded', i === feedId);
1218
+ });
1219
+ }
1220
+ }
1221
+
1222
+ // ─── Stats & Red Alert Updates ───
1223
+ function updateStats() {
1224
+ fetch('/stats')
1225
+ .then(r => r.json())
1226
+ .then(data => {
1227
+ const score = data.threat_score;
1228
+ const scoreEl = document.getElementById('threat-score');
1229
+ const statusEl = document.getElementById('status-text');
1230
+ const ringFill = document.getElementById('score-ring-fill');
1231
+ const threatCard = document.getElementById('threat-card');
1232
+
1233
+ scoreEl.textContent = score;
1234
+
1235
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1236
+ const circumference = 377;
1237
+ const offset = circumference - (circumference * score / 100);
1238
+ ringFill.style.strokeDashoffset = offset;
1239
+
1240
+ // Color based on score
1241
+ let color, status, glow;
1242
+ if (score >= 80) {
1243
+ color = '#ff2040';
1244
+ status = 'CRITICAL';
1245
+ glow = 'rgba(255, 32, 64, 0.4)';
1246
+ } else if (score >= 50) {
1247
+ color = '#ffaa00';
1248
+ status = 'ELEVATED';
1249
+ glow = 'rgba(255, 170, 0, 0.3)';
1250
+ } else if (score >= 25) {
1251
+ color = '#00d4ff';
1252
+ status = 'GUARDED';
1253
+ glow = 'rgba(0, 200, 255, 0.3)';
1254
+ } else {
1255
+ color = '#00ff88';
1256
+ status = 'SECURE';
1257
+ glow = 'rgba(0, 255, 136, 0.3)';
1258
+ }
1259
+
1260
+ statusEl.textContent = status;
1261
+ statusEl.style.color = color;
1262
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1263
+ ringFill.style.stroke = color;
1264
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1265
+
1266
+ // Red Alert state
1267
+ const alertOverlay = document.getElementById('red-alert-overlay');
1268
+ const alertBanner = document.getElementById('red-alert-banner');
1269
+ const feedCells = document.querySelectorAll('.feed-cell');
1270
+
1271
+ if (data.red_alert) {
1272
+ alertOverlay.classList.add('active');
1273
+ alertBanner.classList.add('active');
1274
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1275
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1276
+
1277
+ if (!isRedAlert) {
1278
+ playAlertTone();
1279
+ isRedAlert = true;
1280
+ }
1281
+ } else {
1282
+ alertOverlay.classList.remove('active');
1283
+ alertBanner.classList.remove('active');
1284
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1285
+ threatCard.style.borderColor = '';
1286
+ isRedAlert = false;
1287
+ }
1288
+
1289
+ // Update mode display
1290
+ if (data.active_modules) {
1291
+ updateActiveModulesDisplay(data.active_modules);
1292
+ }
1293
+
1294
+ // Update live metrics
1295
+ const container = document.getElementById('stats-container');
1296
+ container.innerHTML = '';
1297
+
1298
+ if (!data.details || Object.keys(data.details).length === 0) {
1299
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1300
+ } else {
1301
+ for (const [key, value] of Object.entries(data.details)) {
1302
+ const div = document.createElement('div');
1303
+ div.className = 'stat-row';
1304
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1305
+ let displayVal = value;
1306
+ if (typeof value === 'boolean') {
1307
+ displayVal = value ? '⚠ YES' : 'No';
1308
+ }
1309
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1310
+ container.appendChild(div);
1311
+ }
1312
+ }
1313
+ })
1314
+ .catch(() => { });
1315
+ }
1316
+
1317
+ // ─── Alert Tone (Web Audio API) ───
1318
+ function playAlertTone() {
1319
+ try {
1320
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1321
+ const oscillator = audioCtx.createOscillator();
1322
+ const gainNode = audioCtx.createGain();
1323
+
1324
+ oscillator.connect(gainNode);
1325
+ gainNode.connect(audioCtx.destination);
1326
+
1327
+ oscillator.type = 'square';
1328
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1329
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1330
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1331
+
1332
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1333
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1334
+
1335
+ oscillator.start(audioCtx.currentTime);
1336
+ oscillator.stop(audioCtx.currentTime + 0.5);
1337
+ } catch (e) {
1338
+ // Audio not available — silent fallback
1339
+ }
1340
+ }
1341
+
1342
+ // ─── AI Report ───
1343
+ function generateReport() {
1344
+ const modal = document.getElementById('report-modal');
1345
+ modal.classList.add('show');
1346
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1347
+
1348
+ fetch('/generate_report', { method: 'POST' })
1349
+ .then(r => r.json())
1350
+ .then(data => {
1351
+ document.getElementById('report-content').textContent = data.report;
1352
+ })
1353
+ .catch(() => {
1354
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1355
+ });
1356
+ }
1357
+
1358
+ function closeModal() {
1359
+ document.getElementById('report-modal').classList.remove('show');
1360
+ }
1361
+
1362
+ // ─── Audit Log Refresh ───
1363
+ function refreshAuditLog() {
1364
+ fetch('/audit_log')
1365
+ .then(r => r.json())
1366
+ .then(data => {
1367
+ const container = document.getElementById('audit-log-container');
1368
+ container.innerHTML = '';
1369
+
1370
+ if (data.log.length === 0) {
1371
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1372
+ return;
1373
+ }
1374
+
1375
+ data.log.slice(0, 30).forEach(entry => {
1376
+ const div = document.createElement('div');
1377
+ div.className = `audit-entry severity-${entry.severity}`;
1378
+ div.innerHTML = `
1379
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1380
+ ${entry.action}: ${entry.details}
1381
+ `;
1382
+ container.appendChild(div);
1383
+ });
1384
+ })
1385
+ .catch(() => { });
1386
+ }
1387
+
1388
+ // ─── System Reset ───
1389
+ function resetSystem() {
1390
+ if (!confirm("⚠ CONFIRM SYSTEM RESET ⚠\n\nThis will clear all tracking history, threat scores, and active alerts.\nThe system will revert to default state.")) {
1391
+ return;
1392
+ }
1393
+
1394
+ fetch('/reset_system', { method: 'POST' })
1395
+ .then(r => r.json())
1396
+ .then(data => {
1397
+ if (data.success) {
1398
+ // Reload page to refresh all UI states cleanly
1399
+ window.location.reload();
1400
+ }
1401
+ });
1402
+ }
1403
+
1404
+ // ─── Intervals ───
1405
+ setInterval(updateStats, 1000);
1406
+ setInterval(refreshAuditLog, 5000);
1407
+
1408
+ // Initial load
1409
+ setTimeout(refreshAuditLog, 1500);
1410
+ </script>
1411
+ </body>
1412
+
1413
+ </html>
templates/sentinel_dashboard_v11_partial.html ADDED
@@ -0,0 +1,1382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V11 — Intelligence Grid</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ══════════════════════════════════════════��════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+ </style>
882
+ </head>
883
+
884
+ <body>
885
+ <!-- Red Alert Overlay -->
886
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
887
+ <div class="red-alert-banner" id="red-alert-banner">⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠</div>
888
+
889
+ <!-- SIDEBAR -->
890
+ <div class="sidebar">
891
+ <div class="logo">
892
+ <i data-feather="shield"></i>
893
+ SENTINEL
894
+ </div>
895
+ <div class="version-tag">V11.0 // MULTI-MODULE</div>
896
+
897
+ <div class="nav-group">
898
+ <div class="nav-label">Detection Modules</div>
899
+ <div class="nav-item active" onclick="toggleModule('movement', this)" id="nav-movement">
900
+ <i data-feather="activity"></i> Movement
901
+ </div>
902
+ <div class="nav-item" onclick="toggleModule('facemask', this)" id="nav-facemask">
903
+ <i data-feather="eye"></i> Facemask
904
+ </div>
905
+ <div class="nav-item" onclick="toggleModule('weapon', this)" id="nav-weapon">
906
+ <i data-feather="crosshair"></i> Weapon
907
+ </div>
908
+ <div class="nav-item" onclick="toggleModule('public_safety', this)" id="nav-public_safety">
909
+ <i data-feather="users"></i> Public Safety
910
+ </div>
911
+ </div>
912
+
913
+ <div class="nav-group">
914
+ <div class="nav-label">Input Source</div>
915
+ <div class="nav-item" onclick="setSource('camera')">
916
+ <i data-feather="video"></i> Live Camera
917
+ </div>
918
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
919
+ <i data-feather="upload-cloud"></i> Upload Video
920
+ </div>
921
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
922
+ onchange="handleFileUpload(this)">
923
+ </div>
924
+
925
+ <div class="nav-group">
926
+ <div class="nav-label">Grid Layout</div>
927
+ <div class="nav-item" onclick="setGridLayout('quad')">
928
+ <i data-feather="grid"></i> 2×2 Grid
929
+ </div>
930
+ <div class="nav-item" onclick="setGridLayout('single')">
931
+ <i data-feather="maximize-2"></i> Single View
932
+ </div>
933
+ </div>
934
+
935
+ <!-- Audit Log -->
936
+ <div class="audit-section">
937
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
938
+ <div class="audit-log-container" id="audit-log-container">
939
+ <div class="audit-entry">
940
+ <div class="audit-time">--:--:--</div>
941
+ SYSTEM STANDBY
942
+ </div>
943
+ </div>
944
+ </div>
945
+ </div>
946
+
947
+ <!-- MAIN CONTENT -->
948
+ <div class="main-content">
949
+ <!-- Multi-Camera Grid -->
950
+ <div class="camera-grid" id="camera-grid">
951
+ <!-- Feed 0 — Primary -->
952
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
953
+ <div class="feed-header">
954
+ <div class="feed-badge">
955
+ <div class="live-dot"></div>
956
+ <span>FEED 01 // PRIMARY</span>
957
+ </div>
958
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
959
+ </div>
960
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
961
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
962
+ <i data-feather="maximize-2"></i>
963
+ </button>
964
+ </div>
965
+
966
+ <!-- Feed 1 -->
967
+ <div class="feed-cell" id="feed-1">
968
+ <div class="feed-header">
969
+ <div class="feed-badge">
970
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
971
+ <span>FEED 02</span>
972
+ </div>
973
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
974
+ </div>
975
+ <div class="feed-offline" id="offline-1">
976
+ <i data-feather="video-off"></i>
977
+ NO SIGNAL
978
+ </div>
979
+ </div>
980
+
981
+ <!-- Feed 2 -->
982
+ <div class="feed-cell" id="feed-2">
983
+ <div class="feed-header">
984
+ <div class="feed-badge">
985
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
986
+ <span>FEED 03</span>
987
+ </div>
988
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
989
+ </div>
990
+ <div class="feed-offline" id="offline-2">
991
+ <i data-feather="video-off"></i>
992
+ NO SIGNAL
993
+ </div>
994
+ </div>
995
+
996
+ <!-- Feed 3 -->
997
+ <div class="feed-cell" id="feed-3">
998
+ <div class="feed-header">
999
+ <div class="feed-badge">
1000
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1001
+ <span>FEED 04</span>
1002
+ </div>
1003
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1004
+ </div>
1005
+ <div class="feed-offline" id="offline-3">
1006
+ <i data-feather="video-off"></i>
1007
+ NO SIGNAL
1008
+ </div>
1009
+ </div>
1010
+ </div>
1011
+
1012
+ <!-- Intel Panel -->
1013
+ <div class="intel-panel">
1014
+ <!-- Active Mode -->
1015
+ <div class="card">
1016
+ <div class="card-header">
1017
+ <div class="card-title">Active Mode</div>
1018
+ </div>
1019
+ <div class="mode-indicator">
1020
+ <div class="mode-dot"></div>
1021
+ <div class="mode-name" id="mode-title">1 MODULE ACTIVE</div>
1022
+ </div>
1023
+ </div>
1024
+
1025
+ <!-- Threat Assessment -->
1026
+ <div class="card" id="threat-card">
1027
+ <div class="card-header">
1028
+ <div class="card-title">Threat Assessment</div>
1029
+ <i data-feather="alert-triangle" class="card-icon"></i>
1030
+ </div>
1031
+ <div class="threat-gauge">
1032
+ <div class="score-ring">
1033
+ <svg viewBox="0 0 130 130">
1034
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1035
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1036
+ </svg>
1037
+ <div class="score-text">
1038
+ <div class="score-value" id="threat-score">0</div>
1039
+ <div class="score-label">THREAT LEVEL</div>
1040
+ </div>
1041
+ </div>
1042
+ <div class="status-text" id="status-text">SECURE</div>
1043
+ </div>
1044
+ </div>
1045
+
1046
+ <!-- Live Metrics -->
1047
+ <div class="card">
1048
+ <div class="card-header">
1049
+ <div class="card-title">Live Metrics</div>
1050
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1051
+ </div>
1052
+ <div class="stats-list" id="stats-container">
1053
+ <div class="stat-row">
1054
+ <span class="stat-name">System Status</span>
1055
+ <span class="stat-val">Initializing</span>
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+
1060
+ <!-- Actions -->
1061
+ <div class="card">
1062
+ <div class="card-header">
1063
+ <div class="card-title">Actions</div>
1064
+ </div>
1065
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1066
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1067
+ Log</button>
1068
+ </div>
1069
+ </div>
1070
+ </div>
1071
+
1072
+ <!-- Report Modal -->
1073
+ <div id="report-modal" class="modal-overlay">
1074
+ <div class="modal-card">
1075
+ <div class="modal-title">Incident Report</div>
1076
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1077
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1078
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1079
+ </div>
1080
+ </div>
1081
+
1082
+ <!-- ═══════════════════════════════════════════════
1083
+ JAVASCRIPT
1084
+ ═══════════════════════════════════════════════ -->
1085
+ <script>
1086
+ feather.replace();
1087
+
1088
+ // ─── State ───
1089
+ let currentLayout = 'quad'; // 'quad' or 'single'
1090
+ let expandedFeed = 0;
1091
+ let isRedAlert = false;
1092
+
1093
+ // ─── Mode Switching ───
1094
+ const modeTitles = {
1095
+ 'standby': 'SYSTEM STANDBY',
1096
+ 'movement': 'MOVEMENT ANALYSIS',
1097
+ 'facemask': 'FACEMASK DETECTION',
1098
+ 'weapon': 'WEAPON DETECTION',
1099
+ 'public_safety': 'PUBLIC SAFETY'
1100
+ };
1101
+
1102
+ function setMode(mode, element) {
1103
+ // Radio-style selection: always activate the clicked mode
1104
+ // Remove active from all module buttons
1105
+ document.querySelectorAll('.nav-group:first-of-type .nav-item').forEach(el => el.classList.remove('active'));
1106
+
1107
+ // Add active to the clicked button
1108
+ element.classList.add('active');
1109
+
1110
+ // Update UI display
1111
+ document.getElementById('mode-title').textContent = modeTitles[mode] || mode.toUpperCase();
1112
+
1113
+ // Send to backend
1114
+ fetch('/set_mode', {
1115
+ method: 'POST',
1116
+ headers: { 'Content-Type': 'application/json' },
1117
+ body: JSON.stringify({ mode: mode })
1118
+ });
1119
+ }
1120
+
1121
+ function setSource(source) {
1122
+ fetch('/set_source', {
1123
+ method: 'POST',
1124
+ headers: { 'Content-Type': 'application/json' },
1125
+ body: JSON.stringify({ source: source })
1126
+ })
1127
+ .then(r => r.json())
1128
+ .then(data => {
1129
+ if (data.success) {
1130
+ // Force the browser to reconnect to the restarted MJPEG stream
1131
+ refreshFeedStream(0);
1132
+ }
1133
+ });
1134
+ }
1135
+
1136
+ function handleFileUpload(input) {
1137
+ if (input.files[0]) {
1138
+ const formData = new FormData();
1139
+ formData.append('file', input.files[0]);
1140
+
1141
+ // Show uploading state
1142
+ const statusEl = document.getElementById('feed-0-status');
1143
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1144
+
1145
+ fetch('/upload_video', { method: 'POST', body: formData })
1146
+ .then(r => r.json())
1147
+ .then(data => {
1148
+ if (data.success) {
1149
+ // Force the browser to reconnect to the restarted MJPEG stream
1150
+ refreshFeedStream(0);
1151
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1152
+ }
1153
+ })
1154
+ .catch(() => {
1155
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1156
+ });
1157
+
1158
+ // Reset file input so the same file can be re-uploaded
1159
+ input.value = '';
1160
+ }
1161
+ }
1162
+
1163
+ /**
1164
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1165
+ * This drops the old HTTP connection and establishes a fresh one.
1166
+ */
1167
+ function refreshFeedStream(feedId) {
1168
+ const img = document.getElementById('stream-' + feedId);
1169
+ if (img) {
1170
+ // Brief blank to visually signal the switch
1171
+ img.src = '';
1172
+ // Small delay lets the backend fully initialize the new feed
1173
+ setTimeout(() => {
1174
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1175
+ }, 300);
1176
+ }
1177
+ }
1178
+
1179
+ // ─── Grid Layout ───
1180
+ function setGridLayout(layout) {
1181
+ const grid = document.getElementById('camera-grid');
1182
+ currentLayout = layout;
1183
+
1184
+ if (layout === 'single') {
1185
+ grid.classList.add('single-view');
1186
+ // Show only the expanded feed
1187
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1188
+ cell.classList.toggle('expanded', i === expandedFeed);
1189
+ });
1190
+ } else {
1191
+ grid.classList.remove('single-view');
1192
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1193
+ cell.classList.remove('expanded');
1194
+ });
1195
+ }
1196
+ }
1197
+
1198
+ function expandFeed(feedId) {
1199
+ expandedFeed = feedId;
1200
+ if (currentLayout === 'single') {
1201
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1202
+ cell.classList.toggle('expanded', i === feedId);
1203
+ });
1204
+ }
1205
+ }
1206
+
1207
+ // ─── Stats & Red Alert Updates ───
1208
+ function updateStats() {
1209
+ fetch('/stats')
1210
+ .then(r => r.json())
1211
+ .then(data => {
1212
+ const score = data.threat_score;
1213
+ const scoreEl = document.getElementById('threat-score');
1214
+ const statusEl = document.getElementById('status-text');
1215
+ const ringFill = document.getElementById('score-ring-fill');
1216
+ const threatCard = document.getElementById('threat-card');
1217
+
1218
+ scoreEl.textContent = score;
1219
+
1220
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1221
+ const circumference = 377;
1222
+ const offset = circumference - (circumference * score / 100);
1223
+ ringFill.style.strokeDashoffset = offset;
1224
+
1225
+ // Color based on score
1226
+ let color, status, glow;
1227
+ if (score >= 80) {
1228
+ color = '#ff2040';
1229
+ status = 'CRITICAL';
1230
+ glow = 'rgba(255, 32, 64, 0.4)';
1231
+ } else if (score >= 50) {
1232
+ color = '#ffaa00';
1233
+ status = 'ELEVATED';
1234
+ glow = 'rgba(255, 170, 0, 0.3)';
1235
+ } else if (score >= 25) {
1236
+ color = '#00d4ff';
1237
+ status = 'GUARDED';
1238
+ glow = 'rgba(0, 200, 255, 0.3)';
1239
+ } else {
1240
+ color = '#00ff88';
1241
+ status = 'SECURE';
1242
+ glow = 'rgba(0, 255, 136, 0.3)';
1243
+ }
1244
+
1245
+ statusEl.textContent = status;
1246
+ statusEl.style.color = color;
1247
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1248
+ ringFill.style.stroke = color;
1249
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1250
+
1251
+ // Red Alert state
1252
+ const alertOverlay = document.getElementById('red-alert-overlay');
1253
+ const alertBanner = document.getElementById('red-alert-banner');
1254
+ const feedCells = document.querySelectorAll('.feed-cell');
1255
+
1256
+ if (data.red_alert) {
1257
+ alertOverlay.classList.add('active');
1258
+ alertBanner.classList.add('active');
1259
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1260
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1261
+
1262
+ if (!isRedAlert) {
1263
+ playAlertTone();
1264
+ isRedAlert = true;
1265
+ }
1266
+ } else {
1267
+ alertOverlay.classList.remove('active');
1268
+ alertBanner.classList.remove('active');
1269
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1270
+ threatCard.style.borderColor = '';
1271
+ isRedAlert = false;
1272
+ }
1273
+
1274
+ // Update mode display
1275
+ if (data.active_modules) {
1276
+ updateActiveModulesDisplay(data.active_modules);
1277
+ }
1278
+
1279
+ // Update live metrics
1280
+ const container = document.getElementById('stats-container');
1281
+ container.innerHTML = '';
1282
+
1283
+ if (!data.details || Object.keys(data.details).length === 0) {
1284
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1285
+ } else {
1286
+ for (const [key, value] of Object.entries(data.details)) {
1287
+ const div = document.createElement('div');
1288
+ div.className = 'stat-row';
1289
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1290
+ let displayVal = value;
1291
+ if (typeof value === 'boolean') {
1292
+ displayVal = value ? '⚠ YES' : 'No';
1293
+ }
1294
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1295
+ container.appendChild(div);
1296
+ }
1297
+ }
1298
+ })
1299
+ .catch(() => { });
1300
+ }
1301
+
1302
+ // ─── Alert Tone (Web Audio API) ───
1303
+ function playAlertTone() {
1304
+ try {
1305
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1306
+ const oscillator = audioCtx.createOscillator();
1307
+ const gainNode = audioCtx.createGain();
1308
+
1309
+ oscillator.connect(gainNode);
1310
+ gainNode.connect(audioCtx.destination);
1311
+
1312
+ oscillator.type = 'square';
1313
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1314
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1315
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1316
+
1317
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1318
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1319
+
1320
+ oscillator.start(audioCtx.currentTime);
1321
+ oscillator.stop(audioCtx.currentTime + 0.5);
1322
+ } catch (e) {
1323
+ // Audio not available — silent fallback
1324
+ }
1325
+ }
1326
+
1327
+ // ─── AI Report ───
1328
+ function generateReport() {
1329
+ const modal = document.getElementById('report-modal');
1330
+ modal.classList.add('show');
1331
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1332
+
1333
+ fetch('/generate_report', { method: 'POST' })
1334
+ .then(r => r.json())
1335
+ .then(data => {
1336
+ document.getElementById('report-content').textContent = data.report;
1337
+ })
1338
+ .catch(() => {
1339
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1340
+ });
1341
+ }
1342
+
1343
+ function closeModal() {
1344
+ document.getElementById('report-modal').classList.remove('show');
1345
+ }
1346
+
1347
+ // ─── Audit Log Refresh ───
1348
+ function refreshAuditLog() {
1349
+ fetch('/audit_log')
1350
+ .then(r => r.json())
1351
+ .then(data => {
1352
+ const container = document.getElementById('audit-log-container');
1353
+ container.innerHTML = '';
1354
+
1355
+ if (data.log.length === 0) {
1356
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1357
+ return;
1358
+ }
1359
+
1360
+ data.log.slice(0, 30).forEach(entry => {
1361
+ const div = document.createElement('div');
1362
+ div.className = `audit-entry severity-${entry.severity}`;
1363
+ div.innerHTML = `
1364
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1365
+ ${entry.action}: ${entry.details}
1366
+ `;
1367
+ container.appendChild(div);
1368
+ });
1369
+ })
1370
+ .catch(() => { });
1371
+ }
1372
+
1373
+ // ─── Intervals ───
1374
+ setInterval(updateStats, 1000);
1375
+ setInterval(refreshAuditLog, 5000);
1376
+
1377
+ // Initial load
1378
+ setTimeout(refreshAuditLog, 1500);
1379
+ </script>
1380
+ </body>
1381
+
1382
+ </html>
templates/sentinel_dashboard_v12.html ADDED
@@ -0,0 +1,1454 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V11 — Intelligence Grid</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ══════════════════════════════════════════��════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+ </style>
882
+ </head>
883
+
884
+ <body>
885
+ <!-- Red Alert Overlay -->
886
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
887
+ <div class="red-alert-banner" id="red-alert-banner">
888
+ ⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠
889
+ <button class="btn btn-secondary" onclick="resetSystem()"
890
+ style="width: auto; margin-top: 10px; background: rgba(0,0,0,0.5); border: 1px solid #ff2040; color: #ff2040;">RESET
891
+ SYSTEM</button>
892
+ </div>
893
+
894
+ <!-- SIDEBAR -->
895
+ <div class="sidebar">
896
+ <div class="logo">
897
+ <i data-feather="shield"></i>
898
+ SENTINEL
899
+ </div>
900
+ <div class="version-tag">V12.0 // RE-ID INTELLIGENCE</div>
901
+
902
+ <div class="nav-group">
903
+ <div class="nav-label">Detection Modules</div>
904
+ <div class="nav-item active" onclick="toggleModule('movement', this)" id="nav-movement">
905
+ <i data-feather="activity"></i> Movement
906
+ </div>
907
+ <div class="nav-item" onclick="toggleModule('facemask', this)" id="nav-facemask">
908
+ <i data-feather="eye"></i> Facemask
909
+ </div>
910
+ <div class="nav-item" onclick="toggleModule('weapon', this)" id="nav-weapon">
911
+ <i data-feather="crosshair"></i> Weapon
912
+ </div>
913
+ <div class="nav-item" onclick="toggleModule('public_safety', this)" id="nav-public_safety">
914
+ <i data-feather="users"></i> Public Safety
915
+ </div>
916
+ </div>
917
+
918
+ <div class="nav-group">
919
+ <div class="nav-label">Input Source</div>
920
+ <div class="nav-item" onclick="setSource('camera')">
921
+ <i data-feather="video"></i> Live Camera
922
+ </div>
923
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
924
+ <i data-feather="upload-cloud"></i> Upload Video
925
+ </div>
926
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
927
+ onchange="handleFileUpload(this)">
928
+ </div>
929
+
930
+ <div class="nav-group">
931
+ <div class="nav-label">Grid Layout</div>
932
+ <div class="nav-item" onclick="setGridLayout('quad')">
933
+ <i data-feather="grid"></i> 2×2 Grid
934
+ </div>
935
+ <div class="nav-item" onclick="setGridLayout('single')">
936
+ <i data-feather="maximize-2"></i> Single View
937
+ </div>
938
+ </div>
939
+
940
+ <!-- Audit Log -->
941
+ <div class="audit-section">
942
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
943
+ <div class="audit-log-container" id="audit-log-container">
944
+ <div class="audit-entry">
945
+ <div class="audit-time">--:--:--</div>
946
+ SYSTEM STANDBY
947
+ </div>
948
+ </div>
949
+ </div>
950
+ </div>
951
+
952
+ <!-- MAIN CONTENT -->
953
+ <div class="main-content">
954
+ <!-- Multi-Camera Grid -->
955
+ <div class="camera-grid" id="camera-grid">
956
+ <!-- Feed 0 — Primary -->
957
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
958
+ <div class="feed-header">
959
+ <div class="feed-badge">
960
+ <div class="live-dot"></div>
961
+ <span>FEED 01 // PRIMARY</span>
962
+ </div>
963
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
964
+ </div>
965
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
966
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
967
+ <i data-feather="maximize-2"></i>
968
+ </button>
969
+ </div>
970
+
971
+ <!-- Feed 1 -->
972
+ <div class="feed-cell" id="feed-1">
973
+ <div class="feed-header">
974
+ <div class="feed-badge">
975
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
976
+ <span>FEED 02</span>
977
+ </div>
978
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
979
+ </div>
980
+ <div class="feed-offline" id="offline-1">
981
+ <i data-feather="video-off"></i>
982
+ NO SIGNAL
983
+ </div>
984
+ </div>
985
+
986
+ <!-- Feed 2 -->
987
+ <div class="feed-cell" id="feed-2">
988
+ <div class="feed-header">
989
+ <div class="feed-badge">
990
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
991
+ <span>FEED 03</span>
992
+ </div>
993
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
994
+ </div>
995
+ <div class="feed-offline" id="offline-2">
996
+ <i data-feather="video-off"></i>
997
+ NO SIGNAL
998
+ </div>
999
+ </div>
1000
+
1001
+ <!-- Feed 3 -->
1002
+ <div class="feed-cell" id="feed-3">
1003
+ <div class="feed-header">
1004
+ <div class="feed-badge">
1005
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1006
+ <span>FEED 04</span>
1007
+ </div>
1008
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1009
+ </div>
1010
+ <div class="feed-offline" id="offline-3">
1011
+ <i data-feather="video-off"></i>
1012
+ NO SIGNAL
1013
+ </div>
1014
+ </div>
1015
+ </div>
1016
+
1017
+ <!-- Intel Panel -->
1018
+ <div class="intel-panel">
1019
+ + <!-- Suspect Journey -->
1020
+ + <div class="card" id="journey-card">
1021
+ + <div class="card-header">
1022
+ + <div class="card-title">Suspect Journey</div>
1023
+ + <i data-feather="map" class="card-icon"></i>
1024
+ + </div>
1025
+ + <div class="audit-log-container" id="journey-container" style="max-height: 250px;">
1026
+ + <div class="audit-entry">
1027
+ + <div class="audit-time">--:--:--</div>
1028
+ + SCANNING FOR SUBJECTS...
1029
+ + </div>
1030
+ + </div>
1031
+ + </div>
1032
+ +
1033
+ <!-- Active Mode -->
1034
+ <div class="card">
1035
+ <div class="card-header">
1036
+ <div class="card-title">Active Mode</div>
1037
+ </div>
1038
+ <div class="mode-indicator">
1039
+ <div class="mode-dot"></div>
1040
+ <div class="mode-name" id="mode-title">1 MODULE ACTIVE</div>
1041
+ </div>
1042
+ </div>
1043
+
1044
+ <!-- Threat Assessment -->
1045
+ <div class="card" id="threat-card">
1046
+ <div class="card-header">
1047
+ <div class="card-title">Threat Assessment</div>
1048
+ <i data-feather="alert-triangle" class="card-icon"></i>
1049
+ </div>
1050
+ <div class="threat-gauge">
1051
+ <div class="score-ring">
1052
+ <svg viewBox="0 0 130 130">
1053
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1054
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1055
+ </svg>
1056
+ <div class="score-text">
1057
+ <div class="score-value" id="threat-score">0</div>
1058
+ <div class="score-label">THREAT LEVEL</div>
1059
+ </div>
1060
+ </div>
1061
+ <div class="status-text" id="status-text">SECURE</div>
1062
+ </div>
1063
+ </div>
1064
+
1065
+ <!-- Live Metrics -->
1066
+ <div class="card">
1067
+ <div class="card-header">
1068
+ <div class="card-title">Live Metrics</div>
1069
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1070
+ </div>
1071
+ <div class="stats-list" id="stats-container">
1072
+ <div class="stat-row">
1073
+ <span class="stat-name">System Status</span>
1074
+ <span class="stat-val">Initializing</span>
1075
+ </div>
1076
+ </div>
1077
+ </div>
1078
+
1079
+ <!-- Actions -->
1080
+ <div class="card">
1081
+ <div class="card-header">
1082
+ <div class="card-title">Actions</div>
1083
+ </div>
1084
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1085
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1086
+ Log</button>
1087
+ <button class="btn btn-secondary" onclick="resetSystem()"
1088
+ style="margin-top: 8px; border-color: #ff2040; color: #ff2040;">⚠ SYSTEM RESET</button>
1089
+ </div>
1090
+ </div>
1091
+ </div>
1092
+
1093
+ <!-- Report Modal -->
1094
+ <div id="report-modal" class="modal-overlay">
1095
+ <div class="modal-card">
1096
+ <div class="modal-title">Incident Report</div>
1097
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1098
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1099
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1100
+ </div>
1101
+ </div>
1102
+
1103
+ <!-- ═══════════════════════════════════════════════
1104
+ JAVASCRIPT
1105
+ ═══════════════════════════════════════════════ -->
1106
+ <script>
1107
+ feather.replace();
1108
+
1109
+ // ─── State ───
1110
+ let currentLayout = 'quad'; // 'quad' or 'single'
1111
+ let expandedFeed = 0;
1112
+ let isRedAlert = false;
1113
+
1114
+ // ─── Module Toggling ───
1115
+ const modeTitles = {
1116
+ 'movement': 'MOVEMENT ANALYSIS',
1117
+ 'facemask': 'FACEMASK DETECTION',
1118
+ 'weapon': 'WEAPON DETECTION',
1119
+ 'public_safety': 'PUBLIC SAFETY'
1120
+ };
1121
+
1122
+ function toggleModule(module, buttonElement) {
1123
+ buttonElement.classList.toggle('active');
1124
+
1125
+ fetch('/toggle_module', {
1126
+ method: 'POST',
1127
+ headers: { 'Content-Type': 'application/json' },
1128
+ body: JSON.stringify({ module: module })
1129
+ })
1130
+ .then(r => r.json())
1131
+ .then(data => {
1132
+ updateActiveModulesDisplay(data.active_modules);
1133
+ });
1134
+ }
1135
+
1136
+ function updateActiveModulesDisplay(activeModules) {
1137
+ const modeTitle = document.getElementById('mode-title');
1138
+ const count = activeModules.length;
1139
+
1140
+ if (count === 0) {
1141
+ modeTitle.textContent = 'NO MODULES ACTIVE';
1142
+ } else if (count === 1) {
1143
+ modeTitle.textContent = modeTitles[activeModules[0]] || activeModules[0].toUpperCase();
1144
+ } else {
1145
+ modeTitle.textContent = `${count} MODULES ACTIVE`;
1146
+ }
1147
+ }
1148
+
1149
+
1150
+ function setSource(source) {
1151
+ fetch('/set_source', {
1152
+ method: 'POST',
1153
+ headers: { 'Content-Type': 'application/json' },
1154
+ body: JSON.stringify({ source: source })
1155
+ })
1156
+ .then(r => r.json())
1157
+ .then(data => {
1158
+ if (data.success) {
1159
+ // Force the browser to reconnect to the restarted MJPEG stream
1160
+ refreshFeedStream(0);
1161
+ }
1162
+ });
1163
+ }
1164
+
1165
+ function handleFileUpload(input) {
1166
+ if (input.files[0]) {
1167
+ const formData = new FormData();
1168
+ formData.append('file', input.files[0]);
1169
+
1170
+ // Show uploading state
1171
+ const statusEl = document.getElementById('feed-0-status');
1172
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1173
+
1174
+ fetch('/upload_video', { method: 'POST', body: formData })
1175
+ .then(r => r.json())
1176
+ .then(data => {
1177
+ if (data.success) {
1178
+ // Force the browser to reconnect to the restarted MJPEG stream
1179
+ refreshFeedStream(0);
1180
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1181
+ }
1182
+ })
1183
+ .catch(() => {
1184
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1185
+ });
1186
+
1187
+ // Reset file input so the same file can be re-uploaded
1188
+ input.value = '';
1189
+ }
1190
+ }
1191
+
1192
+ /**
1193
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1194
+ * This drops the old HTTP connection and establishes a fresh one.
1195
+ */
1196
+ function refreshFeedStream(feedId) {
1197
+ const img = document.getElementById('stream-' + feedId);
1198
+ if (img) {
1199
+ // Brief blank to visually signal the switch
1200
+ img.src = '';
1201
+ // Small delay lets the backend fully initialize the new feed
1202
+ setTimeout(() => {
1203
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1204
+ }, 300);
1205
+ }
1206
+ }
1207
+
1208
+ // ─── Grid Layout ───
1209
+ function setGridLayout(layout) {
1210
+ const grid = document.getElementById('camera-grid');
1211
+ currentLayout = layout;
1212
+
1213
+ if (layout === 'single') {
1214
+ grid.classList.add('single-view');
1215
+ // Show only the expanded feed
1216
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1217
+ cell.classList.toggle('expanded', i === expandedFeed);
1218
+ });
1219
+ } else {
1220
+ grid.classList.remove('single-view');
1221
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1222
+ cell.classList.remove('expanded');
1223
+ });
1224
+ }
1225
+ }
1226
+
1227
+ function expandFeed(feedId) {
1228
+ expandedFeed = feedId;
1229
+ if (currentLayout === 'single') {
1230
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1231
+ cell.classList.toggle('expanded', i === feedId);
1232
+ });
1233
+ }
1234
+ }
1235
+
1236
+ // ─── Stats & Red Alert Updates ───
1237
+ function updateStats() {
1238
+ fetch('/stats')
1239
+ .then(r => r.json())
1240
+ .then(data => {
1241
+ const score = data.threat_score;
1242
+ const scoreEl = document.getElementById('threat-score');
1243
+ const statusEl = document.getElementById('status-text');
1244
+ const ringFill = document.getElementById('score-ring-fill');
1245
+ const threatCard = document.getElementById('threat-card');
1246
+
1247
+ scoreEl.textContent = score;
1248
+
1249
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1250
+ const circumference = 377;
1251
+ const offset = circumference - (circumference * score / 100);
1252
+ ringFill.style.strokeDashoffset = offset;
1253
+
1254
+ // Color based on score
1255
+ let color, status, glow;
1256
+ if (score >= 80) {
1257
+ color = '#ff2040';
1258
+ status = 'CRITICAL';
1259
+ glow = 'rgba(255, 32, 64, 0.4)';
1260
+ } else if (score >= 50) {
1261
+ color = '#ffaa00';
1262
+ status = 'ELEVATED';
1263
+ glow = 'rgba(255, 170, 0, 0.3)';
1264
+ } else if (score >= 25) {
1265
+ color = '#00d4ff';
1266
+ status = 'GUARDED';
1267
+ glow = 'rgba(0, 200, 255, 0.3)';
1268
+ } else {
1269
+ color = '#00ff88';
1270
+ status = 'SECURE';
1271
+ glow = 'rgba(0, 255, 136, 0.3)';
1272
+ }
1273
+
1274
+ statusEl.textContent = status;
1275
+ statusEl.style.color = color;
1276
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1277
+ ringFill.style.stroke = color;
1278
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1279
+
1280
+ updateJourneyData();
1281
+
1282
+ // Red Alert state
1283
+ const alertOverlay = document.getElementById('red-alert-overlay');
1284
+ const alertBanner = document.getElementById('red-alert-banner');
1285
+ const feedCells = document.querySelectorAll('.feed-cell');
1286
+
1287
+ if (data.red_alert) {
1288
+ alertOverlay.classList.add('active');
1289
+ alertBanner.classList.add('active');
1290
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1291
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1292
+
1293
+ if (!isRedAlert) {
1294
+ playAlertTone();
1295
+ isRedAlert = true;
1296
+ }
1297
+ } else {
1298
+ alertOverlay.classList.remove('active');
1299
+ alertBanner.classList.remove('active');
1300
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1301
+ threatCard.style.borderColor = '';
1302
+ isRedAlert = false;
1303
+ }
1304
+
1305
+ // Update mode display
1306
+ if (data.active_modules) {
1307
+ updateActiveModulesDisplay(data.active_modules);
1308
+ }
1309
+
1310
+ // Update live metrics
1311
+ const container = document.getElementById('stats-container');
1312
+ container.innerHTML = '';
1313
+
1314
+ if (!data.details || Object.keys(data.details).length === 0) {
1315
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1316
+ } else {
1317
+ for (const [key, value] of Object.entries(data.details)) {
1318
+ const div = document.createElement('div');
1319
+ div.className = 'stat-row';
1320
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1321
+ let displayVal = value;
1322
+ if (typeof value === 'boolean') {
1323
+ displayVal = value ? '⚠ YES' : 'No';
1324
+ }
1325
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1326
+ container.appendChild(div);
1327
+ }
1328
+ }
1329
+ })
1330
+ .catch(() => { });
1331
+ }
1332
+
1333
+ // ─── Alert Tone (Web Audio API) ───
1334
+ function playAlertTone() {
1335
+ try {
1336
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1337
+ const oscillator = audioCtx.createOscillator();
1338
+ const gainNode = audioCtx.createGain();
1339
+
1340
+ oscillator.connect(gainNode);
1341
+ gainNode.connect(audioCtx.destination);
1342
+
1343
+ oscillator.type = 'square';
1344
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1345
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1346
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1347
+
1348
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1349
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1350
+
1351
+ oscillator.start(audioCtx.currentTime);
1352
+ oscillator.stop(audioCtx.currentTime + 0.5);
1353
+ } catch (e) {
1354
+ // Audio not available — silent fallback
1355
+ }
1356
+ }
1357
+
1358
+ // ─── AI Report ───
1359
+ function generateReport() {
1360
+ const modal = document.getElementById('report-modal');
1361
+ modal.classList.add('show');
1362
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1363
+
1364
+ fetch('/generate_report', { method: 'POST' })
1365
+ .then(r => r.json())
1366
+ .then(data => {
1367
+ document.getElementById('report-content').textContent = data.report;
1368
+ })
1369
+ .catch(() => {
1370
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1371
+ });
1372
+ }
1373
+
1374
+ function closeModal() {
1375
+ document.getElementById('report-modal').classList.remove('show');
1376
+ }
1377
+
1378
+ // ─── Audit Log Refresh ───
1379
+ function refreshAuditLog() {
1380
+ fetch('/audit_log')
1381
+ .then(r => r.json())
1382
+ .then(data => {
1383
+ const container = document.getElementById('audit-log-container');
1384
+ container.innerHTML = '';
1385
+
1386
+ if (data.log.length === 0) {
1387
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1388
+ return;
1389
+ }
1390
+
1391
+ data.log.slice(0, 30).forEach(entry => {
1392
+ const div = document.createElement('div');
1393
+ div.className = `audit-entry severity-${entry.severity}`;
1394
+ div.innerHTML = `
1395
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1396
+ ${entry.action}: ${entry.details}
1397
+ `;
1398
+ container.appendChild(div);
1399
+ });
1400
+ })
1401
+ .catch(() => { });
1402
+ }
1403
+
1404
+ // ─── System Reset ───
1405
+ function resetSystem() {
1406
+ if (!confirm("⚠ CONFIRM SYSTEM RESET ⚠\n\nThis will clear all tracking history, threat scores, and active alerts.\nThe system will revert to default state.")) {
1407
+ return;
1408
+ }
1409
+
1410
+ fetch('/reset_system', { method: 'POST' })
1411
+ .then(r => r.json())
1412
+ .then(data => {
1413
+ if (data.success) {
1414
+ // Reload page to refresh all UI states cleanly
1415
+ window.location.reload();
1416
+ }
1417
+ });
1418
+ }
1419
+
1420
+ function updateJourneyData() {
1421
+ fetch('/journey_data')
1422
+ .then(r => r.json())
1423
+ .then(data => {
1424
+ const container = document.getElementById('journey-container');
1425
+ if (data.length === 0) {
1426
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>SCANNING...</div>';
1427
+ return;
1428
+ }
1429
+
1430
+ let html = '';
1431
+ data.forEach(subject => {
1432
+ const historyHtml = subject.history.map(h => `F${h.feed}`).join(' → ');
1433
+ html += `
1434
+ <div class="audit-entry">
1435
+ <div class="audit-time">${subject.last_seen}</div>
1436
+ <span style="color:var(--cyan)">SUBJ #${subject.id}</span> [FEED ${subject.last_feed}]
1437
+ <div style="font-size: 0.6rem; color: var(--text-dim); margin-top:2px;">${historyHtml}</div>
1438
+ </div>
1439
+ `;
1440
+ });
1441
+ container.innerHTML = html;
1442
+ });
1443
+ }
1444
+
1445
+ // ─── Intervals ───
1446
+ setInterval(updateStats, 1000);
1447
+ setInterval(refreshAuditLog, 5000);
1448
+
1449
+ // Initial load
1450
+ setTimeout(refreshAuditLog, 1500);
1451
+ </script>
1452
+ </body>
1453
+
1454
+ </html>
templates/sentinel_dashboard_v13.html ADDED
@@ -0,0 +1,2154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V14 — Dispatch Integration</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ═════════════════════════════════════════��═════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+
882
+ /* ═══════════════════════════════════════════════
883
+ DISPATCH CENTER STYLES
884
+ ═══════════════════════════════════════════════ */
885
+ .dispatch-badge {
886
+ display: inline-block;
887
+ padding: 2px 8px;
888
+ border-radius: 3px;
889
+ font-family: var(--font-mono);
890
+ font-size: 0.55rem;
891
+ font-weight: 600;
892
+ letter-spacing: 1px;
893
+ text-transform: uppercase;
894
+ }
895
+
896
+ .dispatch-badge.sent {
897
+ background: rgba(0, 255, 136, 0.15);
898
+ color: var(--neon-green);
899
+ border: 1px solid rgba(0, 255, 136, 0.3);
900
+ }
901
+
902
+ .dispatch-badge.pending {
903
+ background: rgba(255, 170, 0, 0.15);
904
+ color: var(--neon-amber);
905
+ border: 1px solid rgba(255, 170, 0, 0.3);
906
+ animation: pulse-dot 2s infinite;
907
+ }
908
+
909
+ .dispatch-badge.rejected {
910
+ background: rgba(255, 32, 64, 0.15);
911
+ color: var(--neon-red);
912
+ border: 1px solid rgba(255, 32, 64, 0.3);
913
+ }
914
+
915
+ .dispatch-badge.failed {
916
+ background: rgba(255, 32, 64, 0.1);
917
+ color: #ff6080;
918
+ border: 1px solid rgba(255, 96, 128, 0.3);
919
+ }
920
+
921
+ .dispatch-entry {
922
+ font-family: var(--font-mono);
923
+ font-size: 0.65rem;
924
+ padding: 8px 10px;
925
+ margin-bottom: 4px;
926
+ border-radius: 4px;
927
+ background: rgba(0, 0, 0, 0.3);
928
+ border-left: 2px solid var(--cyan-dim);
929
+ line-height: 1.5;
930
+ color: var(--text-secondary);
931
+ display: flex;
932
+ justify-content: space-between;
933
+ align-items: flex-start;
934
+ gap: 8px;
935
+ }
936
+
937
+ .dispatch-entry.pending-entry {
938
+ border-left-color: var(--neon-amber);
939
+ background: rgba(255, 170, 0, 0.05);
940
+ }
941
+
942
+ .dispatch-entry .dispatch-info {
943
+ flex: 1;
944
+ }
945
+
946
+ .dispatch-entry .dispatch-score {
947
+ font-family: var(--font-display);
948
+ font-size: 0.8rem;
949
+ font-weight: 700;
950
+ min-width: 30px;
951
+ text-align: right;
952
+ }
953
+
954
+ .dispatch-actions {
955
+ display: flex;
956
+ gap: 4px;
957
+ margin-top: 6px;
958
+ }
959
+
960
+ .dispatch-actions button {
961
+ padding: 4px 12px;
962
+ border-radius: 4px;
963
+ font-family: var(--font-display);
964
+ font-size: 0.6rem;
965
+ font-weight: 600;
966
+ letter-spacing: 1px;
967
+ cursor: pointer;
968
+ transition: all 0.2s;
969
+ border: 1px solid;
970
+ }
971
+
972
+ .btn-approve {
973
+ background: rgba(0, 255, 136, 0.1);
974
+ border-color: rgba(0, 255, 136, 0.4) !important;
975
+ color: var(--neon-green);
976
+ }
977
+
978
+ .btn-approve:hover {
979
+ background: rgba(0, 255, 136, 0.25);
980
+ box-shadow: 0 0 12px rgba(0, 255, 136, 0.2);
981
+ }
982
+
983
+ .btn-reject {
984
+ background: rgba(255, 32, 64, 0.1);
985
+ border-color: rgba(255, 32, 64, 0.4) !important;
986
+ color: var(--neon-red);
987
+ }
988
+
989
+ .btn-reject:hover {
990
+ background: rgba(255, 32, 64, 0.25);
991
+ box-shadow: 0 0 12px rgba(255, 32, 64, 0.2);
992
+ }
993
+
994
+ .dispatch-config-row {
995
+ display: flex;
996
+ justify-content: space-between;
997
+ align-items: center;
998
+ padding: 10px 0;
999
+ border-bottom: 1px solid rgba(0, 200, 255, 0.05);
1000
+ }
1001
+
1002
+ .dispatch-config-row:last-child {
1003
+ border-bottom: none;
1004
+ }
1005
+
1006
+ .dispatch-config-label {
1007
+ font-size: 0.8rem;
1008
+ color: var(--text-secondary);
1009
+ font-weight: 500;
1010
+ }
1011
+
1012
+ .toggle-switch {
1013
+ position: relative;
1014
+ width: 42px;
1015
+ height: 22px;
1016
+ cursor: pointer;
1017
+ }
1018
+
1019
+ .toggle-switch input {
1020
+ opacity: 0;
1021
+ width: 0;
1022
+ height: 0;
1023
+ }
1024
+
1025
+ .toggle-slider {
1026
+ position: absolute;
1027
+ top: 0;
1028
+ left: 0;
1029
+ right: 0;
1030
+ bottom: 0;
1031
+ background: rgba(255, 255, 255, 0.1);
1032
+ border-radius: 22px;
1033
+ transition: 0.3s;
1034
+ border: 1px solid rgba(255, 255, 255, 0.15);
1035
+ }
1036
+
1037
+ .toggle-slider::before {
1038
+ content: '';
1039
+ position: absolute;
1040
+ width: 16px;
1041
+ height: 16px;
1042
+ left: 2px;
1043
+ bottom: 2px;
1044
+ background: var(--text-secondary);
1045
+ border-radius: 50%;
1046
+ transition: 0.3s;
1047
+ }
1048
+
1049
+ .toggle-switch input:checked+.toggle-slider {
1050
+ background: rgba(0, 200, 255, 0.3);
1051
+ border-color: var(--cyan);
1052
+ }
1053
+
1054
+ .toggle-switch input:checked+.toggle-slider::before {
1055
+ transform: translateX(20px);
1056
+ background: var(--cyan);
1057
+ }
1058
+
1059
+ .config-input {
1060
+ background: rgba(0, 0, 0, 0.4);
1061
+ border: 1px solid var(--border-glow);
1062
+ border-radius: 4px;
1063
+ color: var(--text-primary);
1064
+ font-family: var(--font-mono);
1065
+ font-size: 0.75rem;
1066
+ padding: 6px 10px;
1067
+ width: 100%;
1068
+ transition: border-color 0.3s;
1069
+ }
1070
+
1071
+ .config-input:focus {
1072
+ outline: none;
1073
+ border-color: var(--cyan);
1074
+ box-shadow: 0 0 8px rgba(0, 200, 255, 0.15);
1075
+ }
1076
+
1077
+ .config-input::placeholder {
1078
+ color: var(--text-dim);
1079
+ }
1080
+
1081
+ .cooldown-slider {
1082
+ -webkit-appearance: none;
1083
+ appearance: none;
1084
+ width: 100%;
1085
+ height: 4px;
1086
+ border-radius: 2px;
1087
+ background: rgba(255, 255, 255, 0.1);
1088
+ outline: none;
1089
+ margin: 8px 0;
1090
+ }
1091
+
1092
+ .cooldown-slider::-webkit-slider-thumb {
1093
+ -webkit-appearance: none;
1094
+ width: 14px;
1095
+ height: 14px;
1096
+ border-radius: 50%;
1097
+ background: var(--cyan);
1098
+ cursor: pointer;
1099
+ box-shadow: 0 0 8px rgba(0, 200, 255, 0.4);
1100
+ }
1101
+
1102
+ .pending-count-badge {
1103
+ background: var(--neon-amber);
1104
+ color: #000;
1105
+ font-family: var(--font-mono);
1106
+ font-size: 0.55rem;
1107
+ font-weight: 700;
1108
+ width: 16px;
1109
+ height: 16px;
1110
+ border-radius: 50%;
1111
+ display: none;
1112
+ align-items: center;
1113
+ justify-content: center;
1114
+ margin-left: auto;
1115
+ }
1116
+
1117
+ .pending-count-badge.visible {
1118
+ display: flex;
1119
+ }
1120
+
1121
+ .dispatch-status-bar {
1122
+ display: flex;
1123
+ align-items: center;
1124
+ gap: 6px;
1125
+ padding: 6px 10px;
1126
+ border-radius: 4px;
1127
+ font-family: var(--font-mono);
1128
+ font-size: 0.6rem;
1129
+ margin-bottom: 8px;
1130
+ }
1131
+
1132
+ .dispatch-status-bar.configured {
1133
+ background: rgba(0, 255, 136, 0.05);
1134
+ border: 1px solid rgba(0, 255, 136, 0.15);
1135
+ color: var(--neon-green);
1136
+ }
1137
+
1138
+ .dispatch-status-bar.not-configured {
1139
+ background: rgba(255, 170, 0, 0.05);
1140
+ border: 1px solid rgba(255, 170, 0, 0.15);
1141
+ color: var(--neon-amber);
1142
+ }
1143
+
1144
+ .dispatch-dot {
1145
+ width: 6px;
1146
+ height: 6px;
1147
+ border-radius: 50%;
1148
+ }
1149
+
1150
+ .dispatch-dot.online {
1151
+ background: var(--neon-green);
1152
+ box-shadow: 0 0 6px var(--neon-green);
1153
+ }
1154
+
1155
+ .dispatch-dot.offline {
1156
+ background: var(--neon-amber);
1157
+ box-shadow: 0 0 6px var(--neon-amber);
1158
+ }
1159
+ </style>
1160
+ </head>
1161
+
1162
+ <body>
1163
+ <!-- Red Alert Overlay -->
1164
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
1165
+ <div class="red-alert-banner" id="red-alert-banner">
1166
+ ⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠
1167
+ <button class="btn btn-secondary" onclick="resetSystem()"
1168
+ style="width: auto; margin-top: 10px; background: rgba(0,0,0,0.5); border: 1px solid #ff2040; color: #ff2040;">RESET
1169
+ SYSTEM</button>
1170
+ </div>
1171
+
1172
+ <!-- SIDEBAR -->
1173
+ <div class="sidebar">
1174
+ <div class="logo">
1175
+ <i data-feather="shield"></i>
1176
+ SENTINEL
1177
+ </div>
1178
+ <div class="version-tag">V14.0 // DISPATCH INTEGRATION</div>
1179
+
1180
+ <div class="nav-group">
1181
+ <div class="nav-label">Detection Modules</div>
1182
+ <div class="nav-item active" onclick="toggleModule('movement', this)" id="nav-movement">
1183
+ <i data-feather="activity"></i> Movement
1184
+ </div>
1185
+ <div class="nav-item" onclick="toggleModule('facemask', this)" id="nav-facemask">
1186
+ <i data-feather="eye"></i> Facemask
1187
+ </div>
1188
+ <div class="nav-item" onclick="toggleModule('weapon', this)" id="nav-weapon">
1189
+ <i data-feather="crosshair"></i> Weapon
1190
+ </div>
1191
+ <div class="nav-item" onclick="toggleModule('public_safety', this)" id="nav-public_safety">
1192
+ <i data-feather="users"></i> Public Safety
1193
+ </div>
1194
+ <div class="nav-item" onclick="toggleModule('suspect_journey', this)" id="nav-suspect_journey"
1195
+ style="border-color: rgba(255,255,0,0.3);">
1196
+ <i data-feather="map-pin"></i> Suspect Journey
1197
+ </div>
1198
+ </div>
1199
+
1200
+ <div class="nav-group">
1201
+ <div class="nav-label">Dispatch</div>
1202
+ <div class="nav-item" onclick="openDispatchSettings()" id="nav-dispatch">
1203
+ <i data-feather="bell"></i> Dispatch Settings
1204
+ <span class="pending-count-badge" id="pending-badge">0</span>
1205
+ </div>
1206
+ </div>
1207
+
1208
+ <div class="nav-group">
1209
+ <div class="nav-label">Grid Layout</div>
1210
+ <div class="nav-item" onclick="setGridLayout('quad')">
1211
+ <i data-feather="grid"></i> 2×2 Grid
1212
+ </div>
1213
+ <div class="nav-item" onclick="setGridLayout('single')">
1214
+ <i data-feather="maximize-2"></i> Single View
1215
+ </div>
1216
+ </div>
1217
+
1218
+ <!-- Audit Log -->
1219
+ <div class="audit-section">
1220
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
1221
+ <div class="audit-log-container" id="audit-log-container">
1222
+ <div class="audit-entry">
1223
+ <div class="audit-time">--:--:--</div>
1224
+ SYSTEM STANDBY
1225
+ </div>
1226
+ </div>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <!-- MAIN CONTENT -->
1231
+ <div class="main-content">
1232
+ <!-- Multi-Camera Grid -->
1233
+ <div class="camera-grid" id="camera-grid">
1234
+ <!-- Feed 0 — Primary -->
1235
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
1236
+ <div class="feed-header">
1237
+ <div class="feed-badge">
1238
+ <div class="live-dot"></div>
1239
+ <span>FEED 01 // PRIMARY</span>
1240
+ </div>
1241
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
1242
+ </div>
1243
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
1244
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
1245
+ <i data-feather="maximize-2"></i>
1246
+ </button>
1247
+ <button class="expand-btn"
1248
+ style="right:40px; background: rgba(0,200,255,0.2); border-color: var(--cyan);"
1249
+ onclick="event.stopPropagation(); triggerFeedUpload(0)" title="Upload to this feed">
1250
+ <i data-feather="upload-cloud"></i>
1251
+ </button>
1252
+ </div>
1253
+
1254
+ <!-- Feed 1 -->
1255
+ <div class="feed-cell" id="feed-1">
1256
+ <div class="feed-header">
1257
+ <div class="feed-badge">
1258
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1259
+ <span>FEED 02</span>
1260
+ </div>
1261
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
1262
+ </div>
1263
+ <div class="feed-offline" id="offline-1">
1264
+ <i data-feather="video-off"></i>
1265
+ <span>NO SIGNAL</span>
1266
+ <button class="btn btn-primary"
1267
+ style="width:auto; margin-top:10px; padding:8px 16px; font-size:0.7rem;"
1268
+ onclick="event.stopPropagation(); triggerFeedUpload(1)">
1269
+ <i data-feather="upload-cloud" style="width:14px;height:14px;"></i> UPLOAD CLIP
1270
+ </button>
1271
+ </div>
1272
+ </div>
1273
+
1274
+ <!-- Feed 2 -->
1275
+ <div class="feed-cell" id="feed-2">
1276
+ <div class="feed-header">
1277
+ <div class="feed-badge">
1278
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1279
+ <span>FEED 03</span>
1280
+ </div>
1281
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
1282
+ </div>
1283
+ <div class="feed-offline" id="offline-2">
1284
+ <i data-feather="video-off"></i>
1285
+ <span>NO SIGNAL</span>
1286
+ <button class="btn btn-primary"
1287
+ style="width:auto; margin-top:10px; padding:8px 16px; font-size:0.7rem;"
1288
+ onclick="event.stopPropagation(); triggerFeedUpload(2)">
1289
+ <i data-feather="upload-cloud" style="width:14px;height:14px;"></i> UPLOAD CLIP
1290
+ </button>
1291
+ </div>
1292
+ </div>
1293
+
1294
+ <!-- Feed 3 -->
1295
+ <div class="feed-cell" id="feed-3">
1296
+ <div class="feed-header">
1297
+ <div class="feed-badge">
1298
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1299
+ <span>FEED 04</span>
1300
+ </div>
1301
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1302
+ </div>
1303
+ <div class="feed-offline" id="offline-3">
1304
+ <i data-feather="video-off"></i>
1305
+ <span>NO SIGNAL</span>
1306
+ <button class="btn btn-primary"
1307
+ style="width:auto; margin-top:10px; padding:8px 16px; font-size:0.7rem;"
1308
+ onclick="event.stopPropagation(); triggerFeedUpload(3)">
1309
+ <i data-feather="upload-cloud" style="width:14px;height:14px;"></i> UPLOAD CLIP
1310
+ </button>
1311
+ </div>
1312
+ </div>
1313
+ </div>
1314
+
1315
+ <!-- Hidden file input for per-feed upload -->
1316
+ <input type="file" id="feed-upload-input" style="display: none" accept="video/*,.gif">
1317
+
1318
+
1319
+ <!-- Intel Panel -->
1320
+ <div class="intel-panel">
1321
+ <!-- Suspect Journey -->
1322
+ <div class="card" id="journey-card">
1323
+ <div class="card-header">
1324
+ <div class="card-title">Suspect Journey</div>
1325
+ <i data-feather="map" class="card-icon"></i>
1326
+ </div>
1327
+ <div class="audit-log-container" id="journey-container" style="max-height: 250px;">
1328
+ <div class="audit-entry">
1329
+ <div class="audit-time">--:--:--</div>
1330
+ ACTIVATE 'SUSPECT JOURNEY' MODULE TO BEGIN
1331
+ </div>
1332
+ </div>
1333
+ </div>
1334
+
1335
+ <!-- Active Mode -->
1336
+ <div class="card">
1337
+ <div class="card-header">
1338
+ <div class="card-title">Active Mode</div>
1339
+ </div>
1340
+ <div class="mode-indicator">
1341
+ <div class="mode-dot"></div>
1342
+ <div class="mode-name" id="mode-title">1 MODULE ACTIVE</div>
1343
+ </div>
1344
+ </div>
1345
+
1346
+ <!-- Threat Assessment -->
1347
+ <div class="card" id="threat-card">
1348
+ <div class="card-header">
1349
+ <div class="card-title">Threat Assessment</div>
1350
+ <i data-feather="alert-triangle" class="card-icon"></i>
1351
+ </div>
1352
+ <div class="threat-gauge">
1353
+ <div class="score-ring">
1354
+ <svg viewBox="0 0 130 130">
1355
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1356
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1357
+ </svg>
1358
+ <div class="score-text">
1359
+ <div class="score-value" id="threat-score">0</div>
1360
+ <div class="score-label">THREAT LEVEL</div>
1361
+ </div>
1362
+ </div>
1363
+ <div class="status-text" id="status-text">SECURE</div>
1364
+ </div>
1365
+ </div>
1366
+
1367
+ <!-- Live Metrics -->
1368
+ <div class="card">
1369
+ <div class="card-header">
1370
+ <div class="card-title">Live Metrics</div>
1371
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1372
+ </div>
1373
+ <div class="stats-list" id="stats-container">
1374
+ <div class="stat-row">
1375
+ <span class="stat-name">System Status</span>
1376
+ <span class="stat-val">Initializing</span>
1377
+ </div>
1378
+ </div>
1379
+ </div>
1380
+
1381
+ <!-- Dispatch Center -->
1382
+ <div class="card" id="dispatch-card">
1383
+ <div class="card-header">
1384
+ <div class="card-title">Dispatch Center</div>
1385
+ <i data-feather="send" class="card-icon"></i>
1386
+ </div>
1387
+ <div class="dispatch-status-bar not-configured" id="dispatch-status-bar">
1388
+ <div class="dispatch-dot offline" id="dispatch-dot"></div>
1389
+ <span id="dispatch-status-text">TELEGRAM NOT CONFIGURED</span>
1390
+ </div>
1391
+
1392
+ <!-- Pending Approvals -->
1393
+ <div id="pending-section" style="display:none; margin-bottom: 10px;">
1394
+ <div
1395
+ style="font-family: var(--font-display); font-size: 0.6rem; color: var(--neon-amber); letter-spacing: 1px; margin-bottom: 6px;">
1396
+ ⏳ PENDING APPROVAL</div>
1397
+ <div id="pending-container"></div>
1398
+ </div>
1399
+
1400
+ <!-- Dispatch Log -->
1401
+ <div class="audit-log-container" id="dispatch-log-container" style="max-height: 180px;">
1402
+ <div class="dispatch-entry">
1403
+ <div class="dispatch-info">
1404
+ <div class="audit-time">--:--:--</div>
1405
+ NO DISPATCH HISTORY
1406
+ </div>
1407
+ </div>
1408
+ </div>
1409
+
1410
+ <!-- Quick Actions -->
1411
+ <div style="display: flex; gap: 6px; margin-top: 10px;">
1412
+ <button class="btn btn-primary" onclick="manualDispatch()"
1413
+ style="font-size: 0.65rem; padding: 8px;">📡 SEND ALERT</button>
1414
+ <button class="btn btn-secondary" onclick="testDispatch()"
1415
+ style="font-size: 0.65rem; padding: 8px;">🧪 TEST</button>
1416
+ <button class="btn btn-secondary" onclick="openDispatchSettings()"
1417
+ style="font-size: 0.65rem; padding: 8px;">⚙</button>
1418
+ </div>
1419
+ </div>
1420
+
1421
+ <!-- Actions -->
1422
+ <div class="card">
1423
+ <div class="card-header">
1424
+ <div class="card-title">Actions</div>
1425
+ </div>
1426
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1427
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1428
+ Log</button>
1429
+ <button class="btn btn-secondary" onclick="resetSystem()"
1430
+ style="margin-top: 8px; border-color: #ff2040; color: #ff2040;">⚠ SYSTEM RESET</button>
1431
+ </div>
1432
+ </div>
1433
+ </div>
1434
+
1435
+ <!-- Report Modal -->
1436
+ <div id="report-modal" class="modal-overlay">
1437
+ <div class="modal-card">
1438
+ <div class="modal-title">Incident Report</div>
1439
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1440
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1441
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1442
+ </div>
1443
+ </div>
1444
+
1445
+ <!-- Dispatch Settings Modal -->
1446
+ <div id="dispatch-settings-modal" class="modal-overlay">
1447
+ <div class="modal-card" style="width: 480px;">
1448
+ <div class="modal-title">⚙ Dispatch Settings</div>
1449
+ <div class="modal-subtitle">Telegram Bot Integration // Automated Alert System</div>
1450
+
1451
+ <div style="margin-bottom: 16px;">
1452
+ <div class="dispatch-config-row">
1453
+ <span class="dispatch-config-label">Master Switch</span>
1454
+ <label class="toggle-switch">
1455
+ <input type="checkbox" id="cfg-enabled" checked onchange="saveDispatchSettings()">
1456
+ <span class="toggle-slider"></span>
1457
+ </label>
1458
+ </div>
1459
+
1460
+ <div class="dispatch-config-row">
1461
+ <span class="dispatch-config-label">Auto-Dispatch (skip approval)</span>
1462
+ <label class="toggle-switch">
1463
+ <input type="checkbox" id="cfg-auto-dispatch" onchange="saveDispatchSettings()">
1464
+ <span class="toggle-slider"></span>
1465
+ </label>
1466
+ </div>
1467
+
1468
+ <div class="dispatch-config-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
1469
+ <span class="dispatch-config-label">Cooldown: <span id="cooldown-display"
1470
+ style="color: var(--cyan);">60s</span></span>
1471
+ <input type="range" class="cooldown-slider" id="cfg-cooldown" min="10" max="300" value="60"
1472
+ oninput="document.getElementById('cooldown-display').textContent = this.value + 's'"
1473
+ onchange="saveDispatchSettings()">
1474
+ </div>
1475
+ </div>
1476
+
1477
+ <div
1478
+ style="font-family: var(--font-display); font-size: 0.6rem; color: var(--cyan); letter-spacing: 1px; margin-bottom: 10px;">
1479
+ TELEGRAM CONFIGURATION</div>
1480
+
1481
+ <div style="margin-bottom: 10px;">
1482
+ <label style="font-size: 0.7rem; color: var(--text-secondary); display: block; margin-bottom: 4px;">Bot
1483
+ Token</label>
1484
+ <input type="text" class="config-input" id="cfg-bot-token" placeholder="e.g. 8659917680:AAFHai-..."
1485
+ style="margin-bottom: 8px;">
1486
+
1487
+ <label style="font-size: 0.7rem; color: var(--text-secondary); display: block; margin-bottom: 4px;">Chat
1488
+ ID <span style="color: var(--text-dim);">(send /start to your bot, then click
1489
+ Auto-Detect)</span></label>
1490
+ <div style="display: flex; gap: 6px;">
1491
+ <input type="text" class="config-input" id="cfg-chat-id" placeholder="e.g. 123456789"
1492
+ style="flex: 1;">
1493
+ <button class="btn btn-secondary" onclick="autoDetectChatId()"
1494
+ style="width: auto; padding: 6px 12px; font-size: 0.65rem;">AUTO-DETECT</button>
1495
+ </div>
1496
+ </div>
1497
+
1498
+ <div style="display: flex; gap: 8px; margin-top: 16px;">
1499
+ <button class="btn btn-primary" onclick="saveTelegramConfig()" style="flex: 1;">SAVE CONFIG</button>
1500
+ <button class="btn btn-primary" onclick="testDispatch()"
1501
+ style="flex: 1; background: rgba(0, 255, 136, 0.1); border-color: rgba(0, 255, 136, 0.3); color: var(--neon-green);">🧪
1502
+ SEND TEST</button>
1503
+ </div>
1504
+ <button class="btn btn-secondary" onclick="closeDispatchSettings()" style="margin-top: 8px;">Close</button>
1505
+ </div>
1506
+ </div>
1507
+
1508
+ <!-- ═══════════════════════════════════════════════
1509
+ JAVASCRIPT
1510
+ ═══════════════════════════════════════════════ -->
1511
+ <script>
1512
+ feather.replace();
1513
+
1514
+ // ─── State ───
1515
+ let currentLayout = 'quad'; // 'quad' or 'single'
1516
+ let expandedFeed = 0;
1517
+ let isRedAlert = false;
1518
+
1519
+ // ─── Module Toggling ───
1520
+ const modeTitles = {
1521
+ 'movement': 'MOVEMENT ANALYSIS',
1522
+ 'facemask': 'FACEMASK DETECTION',
1523
+ 'weapon': 'WEAPON DETECTION',
1524
+ 'public_safety': 'PUBLIC SAFETY',
1525
+ 'suspect_journey': 'SUSPECT JOURNEY'
1526
+ };
1527
+
1528
+ function toggleModule(module, buttonElement) {
1529
+ buttonElement.classList.toggle('active');
1530
+
1531
+ fetch('/toggle_module', {
1532
+ method: 'POST',
1533
+ headers: { 'Content-Type': 'application/json' },
1534
+ body: JSON.stringify({ module: module })
1535
+ })
1536
+ .then(r => r.json())
1537
+ .then(data => {
1538
+ updateActiveModulesDisplay(data.active_modules);
1539
+ });
1540
+ }
1541
+
1542
+ function updateActiveModulesDisplay(activeModules) {
1543
+ const modeTitle = document.getElementById('mode-title');
1544
+ const count = activeModules.length;
1545
+
1546
+ if (count === 0) {
1547
+ modeTitle.textContent = 'NO MODULES ACTIVE';
1548
+ } else if (count === 1) {
1549
+ modeTitle.textContent = modeTitles[activeModules[0]] || activeModules[0].toUpperCase();
1550
+ } else {
1551
+ modeTitle.textContent = `${count} MODULES ACTIVE`;
1552
+ }
1553
+ }
1554
+
1555
+
1556
+ // ── Per-Feed Upload ──
1557
+ let uploadTargetFeed = 0;
1558
+
1559
+ function triggerFeedUpload(feedId) {
1560
+ uploadTargetFeed = feedId;
1561
+ document.getElementById('feed-upload-input').click();
1562
+ }
1563
+
1564
+ document.addEventListener('DOMContentLoaded', function () {
1565
+ const feedInput = document.getElementById('feed-upload-input');
1566
+ if (feedInput) {
1567
+ feedInput.addEventListener('change', function () {
1568
+ if (this.files[0]) {
1569
+ const feedId = uploadTargetFeed;
1570
+ const formData = new FormData();
1571
+ formData.append('file', this.files[0]);
1572
+
1573
+ const statusEl = document.getElementById('feed-' + feedId + '-status');
1574
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1575
+
1576
+ fetch('/upload_video/' + feedId, { method: 'POST', body: formData })
1577
+ .then(r => r.json())
1578
+ .then(data => {
1579
+ if (data.success) {
1580
+ activateFeedUI(feedId);
1581
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1582
+ }
1583
+ })
1584
+ .catch(() => {
1585
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1586
+ });
1587
+
1588
+ this.value = '';
1589
+ }
1590
+ });
1591
+ }
1592
+ });
1593
+
1594
+ function activateFeedUI(feedId) {
1595
+ // Hide the "NO SIGNAL" overlay and show the stream
1596
+ const offline = document.getElementById('offline-' + feedId);
1597
+ if (offline) offline.style.display = 'none';
1598
+
1599
+ const cell = document.getElementById('feed-' + feedId);
1600
+ // If no stream img exists yet, create one
1601
+ let img = document.getElementById('stream-' + feedId);
1602
+ if (!img) {
1603
+ img = document.createElement('img');
1604
+ img.className = 'feed-stream';
1605
+ img.id = 'stream-' + feedId;
1606
+ img.alt = 'Feed ' + feedId;
1607
+ cell.appendChild(img);
1608
+ }
1609
+
1610
+ // Force refresh
1611
+ img.src = '';
1612
+ setTimeout(() => {
1613
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1614
+ }, 400);
1615
+
1616
+ // Update the live dot
1617
+ const badge = cell.querySelector('.live-dot');
1618
+ if (badge) {
1619
+ badge.style.background = '';
1620
+ badge.style.boxShadow = '';
1621
+ }
1622
+
1623
+ feather.replace();
1624
+ }
1625
+
1626
+ /**
1627
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1628
+ * This drops the old HTTP connection and establishes a fresh one.
1629
+ */
1630
+ function refreshFeedStream(feedId) {
1631
+ const img = document.getElementById('stream-' + feedId);
1632
+ if (img) {
1633
+ // Brief blank to visually signal the switch
1634
+ img.src = '';
1635
+ // Small delay lets the backend fully initialize the new feed
1636
+ setTimeout(() => {
1637
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1638
+ }, 300);
1639
+ }
1640
+ }
1641
+
1642
+ // ─── Grid Layout ───
1643
+ function setGridLayout(layout) {
1644
+ const grid = document.getElementById('camera-grid');
1645
+ currentLayout = layout;
1646
+
1647
+ if (layout === 'single') {
1648
+ grid.classList.add('single-view');
1649
+ // Show only the expanded feed
1650
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1651
+ cell.classList.toggle('expanded', i === expandedFeed);
1652
+ });
1653
+ } else {
1654
+ grid.classList.remove('single-view');
1655
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1656
+ cell.classList.remove('expanded');
1657
+ });
1658
+ }
1659
+ }
1660
+
1661
+ function expandFeed(feedId) {
1662
+ expandedFeed = feedId;
1663
+ if (currentLayout === 'single') {
1664
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1665
+ cell.classList.toggle('expanded', i === feedId);
1666
+ });
1667
+ }
1668
+ }
1669
+
1670
+ // ─── Stats & Red Alert Updates ───
1671
+ function updateStats() {
1672
+ fetch('/stats')
1673
+ .then(r => r.json())
1674
+ .then(data => {
1675
+ const score = data.threat_score;
1676
+ const scoreEl = document.getElementById('threat-score');
1677
+ const statusEl = document.getElementById('status-text');
1678
+ const ringFill = document.getElementById('score-ring-fill');
1679
+ const threatCard = document.getElementById('threat-card');
1680
+
1681
+ scoreEl.textContent = score;
1682
+
1683
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1684
+ const circumference = 377;
1685
+ const offset = circumference - (circumference * score / 100);
1686
+ ringFill.style.strokeDashoffset = offset;
1687
+
1688
+ // Color based on score
1689
+ let color, status, glow;
1690
+ if (score >= 80) {
1691
+ color = '#ff2040';
1692
+ status = 'CRITICAL';
1693
+ glow = 'rgba(255, 32, 64, 0.4)';
1694
+ } else if (score >= 50) {
1695
+ color = '#ffaa00';
1696
+ status = 'ELEVATED';
1697
+ glow = 'rgba(255, 170, 0, 0.3)';
1698
+ } else if (score >= 25) {
1699
+ color = '#00d4ff';
1700
+ status = 'GUARDED';
1701
+ glow = 'rgba(0, 200, 255, 0.3)';
1702
+ } else {
1703
+ color = '#00ff88';
1704
+ status = 'SECURE';
1705
+ glow = 'rgba(0, 255, 136, 0.3)';
1706
+ }
1707
+
1708
+ statusEl.textContent = status;
1709
+ statusEl.style.color = color;
1710
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1711
+ ringFill.style.stroke = color;
1712
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1713
+
1714
+ updateJourneyData();
1715
+
1716
+ // Red Alert state
1717
+ const alertOverlay = document.getElementById('red-alert-overlay');
1718
+ const alertBanner = document.getElementById('red-alert-banner');
1719
+ const feedCells = document.querySelectorAll('.feed-cell');
1720
+
1721
+ if (data.red_alert) {
1722
+ alertOverlay.classList.add('active');
1723
+ alertBanner.classList.add('active');
1724
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1725
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1726
+
1727
+ if (!isRedAlert) {
1728
+ playAlertTone();
1729
+ isRedAlert = true;
1730
+ }
1731
+ } else {
1732
+ alertOverlay.classList.remove('active');
1733
+ alertBanner.classList.remove('active');
1734
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1735
+ threatCard.style.borderColor = '';
1736
+ isRedAlert = false;
1737
+ }
1738
+
1739
+ // Update mode display
1740
+ if (data.active_modules) {
1741
+ updateActiveModulesDisplay(data.active_modules);
1742
+ }
1743
+
1744
+ // Update live metrics
1745
+ const container = document.getElementById('stats-container');
1746
+ container.innerHTML = '';
1747
+
1748
+ if (!data.details || Object.keys(data.details).length === 0) {
1749
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1750
+ } else {
1751
+ for (const [key, value] of Object.entries(data.details)) {
1752
+ const div = document.createElement('div');
1753
+ div.className = 'stat-row';
1754
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1755
+ let displayVal = value;
1756
+ if (typeof value === 'boolean') {
1757
+ displayVal = value ? '⚠ YES' : 'No';
1758
+ }
1759
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1760
+ container.appendChild(div);
1761
+ }
1762
+ }
1763
+ })
1764
+ .catch(() => { });
1765
+ }
1766
+
1767
+ // ─── Alert Tone (Web Audio API) ───
1768
+ function playAlertTone() {
1769
+ try {
1770
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1771
+ const oscillator = audioCtx.createOscillator();
1772
+ const gainNode = audioCtx.createGain();
1773
+
1774
+ oscillator.connect(gainNode);
1775
+ gainNode.connect(audioCtx.destination);
1776
+
1777
+ oscillator.type = 'square';
1778
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1779
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1780
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1781
+
1782
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1783
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1784
+
1785
+ oscillator.start(audioCtx.currentTime);
1786
+ oscillator.stop(audioCtx.currentTime + 0.5);
1787
+ } catch (e) {
1788
+ // Audio not available — silent fallback
1789
+ }
1790
+ }
1791
+
1792
+ // ─── AI Report ───
1793
+ function generateReport() {
1794
+ const modal = document.getElementById('report-modal');
1795
+ modal.classList.add('show');
1796
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1797
+
1798
+ fetch('/generate_report', { method: 'POST' })
1799
+ .then(r => r.json())
1800
+ .then(data => {
1801
+ document.getElementById('report-content').textContent = data.report;
1802
+ })
1803
+ .catch(() => {
1804
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1805
+ });
1806
+ }
1807
+
1808
+ function closeModal() {
1809
+ document.getElementById('report-modal').classList.remove('show');
1810
+ }
1811
+
1812
+ // ─── Audit Log Refresh ───
1813
+ function refreshAuditLog() {
1814
+ fetch('/audit_log')
1815
+ .then(r => r.json())
1816
+ .then(data => {
1817
+ const container = document.getElementById('audit-log-container');
1818
+ container.innerHTML = '';
1819
+
1820
+ if (data.log.length === 0) {
1821
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1822
+ return;
1823
+ }
1824
+
1825
+ data.log.slice(0, 30).forEach(entry => {
1826
+ const div = document.createElement('div');
1827
+ div.className = `audit-entry severity-${entry.severity}`;
1828
+ div.innerHTML = `
1829
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1830
+ ${entry.action}: ${entry.details}
1831
+ `;
1832
+ container.appendChild(div);
1833
+ });
1834
+ })
1835
+ .catch(() => { });
1836
+ }
1837
+
1838
+ // ─── System Reset ───
1839
+ function resetSystem() {
1840
+ if (!confirm("⚠ CONFIRM SYSTEM RESET ⚠\n\nThis will clear all tracking history, threat scores, and active alerts.\nThe system will revert to default state.")) {
1841
+ return;
1842
+ }
1843
+
1844
+ fetch('/reset_system', { method: 'POST' })
1845
+ .then(r => r.json())
1846
+ .then(data => {
1847
+ if (data.success) {
1848
+ // Reload page to refresh all UI states cleanly
1849
+ window.location.reload();
1850
+ }
1851
+ });
1852
+ }
1853
+
1854
+ function updateJourneyData() {
1855
+ fetch('/journey_data')
1856
+ .then(r => r.json())
1857
+ .then(data => {
1858
+ const container = document.getElementById('journey-container');
1859
+ if (data.length === 0) {
1860
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>SCANNING...</div>';
1861
+ return;
1862
+ }
1863
+
1864
+ let html = '';
1865
+ data.forEach(subject => {
1866
+ const historyHtml = subject.history.map(h => `F${h.feed}`).join(' → ');
1867
+ html += `
1868
+ <div class="audit-entry">
1869
+ <div class="audit-time">${subject.last_seen}</div>
1870
+ <span style="color:var(--cyan)">SUBJ #${subject.id}</span> [FEED ${subject.last_feed}]
1871
+ <div style="font-size: 0.6rem; color: var(--text-dim); margin-top:2px;">${historyHtml}</div>
1872
+ </div>
1873
+ `;
1874
+ });
1875
+ container.innerHTML = html;
1876
+ });
1877
+ }
1878
+
1879
+ // ═══════════════════════════════════════════════════
1880
+ // DISPATCH CENTER
1881
+ // ═══════════════════════════════════════════════════
1882
+
1883
+ function updateDispatchCenter() {
1884
+ fetch('/dispatch_log')
1885
+ .then(r => r.json())
1886
+ .then(data => {
1887
+ // Update status bar
1888
+ const statusBar = document.getElementById('dispatch-status-bar');
1889
+ const statusDot = document.getElementById('dispatch-dot');
1890
+ const statusText = document.getElementById('dispatch-status-text');
1891
+
1892
+ if (data.settings.telegram_configured) {
1893
+ statusBar.className = 'dispatch-status-bar configured';
1894
+ statusDot.className = 'dispatch-dot online';
1895
+ const mode = data.settings.auto_dispatch ? 'AUTO' : 'MANUAL';
1896
+ statusText.textContent = `TELEGRAM ONLINE // ${mode} MODE`;
1897
+ } else {
1898
+ statusBar.className = 'dispatch-status-bar not-configured';
1899
+ statusDot.className = 'dispatch-dot offline';
1900
+ statusText.textContent = 'TELEGRAM NOT CONFIGURED';
1901
+ }
1902
+
1903
+ // Update pending approvals
1904
+ const pendingSection = document.getElementById('pending-section');
1905
+ const pendingContainer = document.getElementById('pending-container');
1906
+ const pendingBadge = document.getElementById('pending-badge');
1907
+
1908
+ if (data.pending && data.pending.length > 0) {
1909
+ pendingSection.style.display = 'block';
1910
+ pendingBadge.textContent = data.pending.length;
1911
+ pendingBadge.classList.add('visible');
1912
+
1913
+ let pendingHtml = '';
1914
+ data.pending.forEach(evt => {
1915
+ const scoreColor = evt.threat_score >= 80 ? 'var(--neon-red)' :
1916
+ evt.threat_score >= 50 ? 'var(--neon-amber)' : 'var(--cyan)';
1917
+ pendingHtml += `
1918
+ <div class="dispatch-entry pending-entry">
1919
+ <div class="dispatch-info">
1920
+ <div class="audit-time">${evt.timestamp}</div>
1921
+ <span class="dispatch-badge pending">PENDING</span>
1922
+ Threat Score: ${evt.threat_score} // ${evt.active_modules.join(', ').toUpperCase()}
1923
+ <div class="dispatch-actions">
1924
+ <button class="btn-approve" onclick="approveDispatch('${evt.id}')">✓ APPROVE</button>
1925
+ <button class="btn-reject" onclick="rejectDispatch('${evt.id}')">✗ REJECT</button>
1926
+ </div>
1927
+ </div>
1928
+ <div class="dispatch-score" style="color: ${scoreColor};">${evt.threat_score}</div>
1929
+ </div>
1930
+ `;
1931
+ });
1932
+ pendingContainer.innerHTML = pendingHtml;
1933
+ } else {
1934
+ pendingSection.style.display = 'none';
1935
+ pendingBadge.classList.remove('visible');
1936
+ }
1937
+
1938
+ // Update dispatch log
1939
+ const logContainer = document.getElementById('dispatch-log-container');
1940
+ if (data.log.length === 0 && (!data.pending || data.pending.length === 0)) {
1941
+ logContainer.innerHTML = '<div class="dispatch-entry"><div class="dispatch-info"><div class="audit-time">--:--:--</div>NO DISPATCH HISTORY</div></div>';
1942
+ } else {
1943
+ let logHtml = '';
1944
+ data.log.slice(0, 15).forEach(evt => {
1945
+ const badgeClass = evt.status;
1946
+ const statusLabel = evt.status.toUpperCase();
1947
+ const scoreColor = evt.threat_score >= 80 ? 'var(--neon-red)' :
1948
+ evt.threat_score >= 50 ? 'var(--neon-amber)' : 'var(--cyan)';
1949
+ logHtml += `
1950
+ <div class="dispatch-entry">
1951
+ <div class="dispatch-info">
1952
+ <div class="audit-time">${evt.timestamp}</div>
1953
+ <span class="dispatch-badge ${badgeClass}">${statusLabel}</span>
1954
+ Score: ${evt.threat_score} // ${(evt.active_modules || []).join(', ').toUpperCase() || 'N/A'}
1955
+ </div>
1956
+ <div class="dispatch-score" style="color: ${scoreColor};">${evt.threat_score}</div>
1957
+ </div>
1958
+ `;
1959
+ });
1960
+ logContainer.innerHTML = logHtml;
1961
+ }
1962
+ })
1963
+ .catch(() => { });
1964
+ }
1965
+
1966
+ function approveDispatch(eventId) {
1967
+ fetch('/approve_dispatch/' + eventId, { method: 'POST' })
1968
+ .then(r => r.json())
1969
+ .then(data => {
1970
+ updateDispatchCenter();
1971
+ if (data.status === 'sent') {
1972
+ showDispatchToast('✅ Alert dispatched via Telegram');
1973
+ } else {
1974
+ showDispatchToast('⚠ Dispatch failed — check Telegram config');
1975
+ }
1976
+ });
1977
+ }
1978
+
1979
+ function rejectDispatch(eventId) {
1980
+ fetch('/reject_dispatch/' + eventId, { method: 'POST' })
1981
+ .then(r => r.json())
1982
+ .then(() => {
1983
+ updateDispatchCenter();
1984
+ showDispatchToast('🔴 Alert rejected');
1985
+ });
1986
+ }
1987
+
1988
+ function manualDispatch() {
1989
+ if (!confirm('📡 DISPATCH ALERT NOW?\n\nThis will send the current threat status to Telegram immediately.')) return;
1990
+ fetch('/dispatch_alert', { method: 'POST' })
1991
+ .then(r => r.json())
1992
+ .then(data => {
1993
+ if (data.success) {
1994
+ showDispatchToast('✅ Alert dispatched via Telegram');
1995
+ } else {
1996
+ showDispatchToast('⚠ ' + (data.error || 'Dispatch failed'));
1997
+ }
1998
+ updateDispatchCenter();
1999
+ })
2000
+ .catch(() => showDispatchToast('⚠ Network error'));
2001
+ }
2002
+
2003
+ function testDispatch() {
2004
+ showDispatchToast('🧪 Sending test message...');
2005
+ fetch('/test_dispatch', { method: 'POST' })
2006
+ .then(r => r.json())
2007
+ .then(data => {
2008
+ if (data.ok) {
2009
+ showDispatchToast('✅ Test message sent to Telegram!');
2010
+ updateDispatchCenter();
2011
+ } else {
2012
+ showDispatchToast('⚠ ' + (data.error || 'Test failed'));
2013
+ }
2014
+ })
2015
+ .catch(() => showDispatchToast('⚠ Connection error'));
2016
+ }
2017
+
2018
+ // ─── Dispatch Settings Modal ───
2019
+ function openDispatchSettings() {
2020
+ const modal = document.getElementById('dispatch-settings-modal');
2021
+ modal.classList.add('show');
2022
+
2023
+ // Load current settings
2024
+ fetch('/dispatch_settings')
2025
+ .then(r => r.json())
2026
+ .then(data => {
2027
+ document.getElementById('cfg-enabled').checked = data.enabled;
2028
+ document.getElementById('cfg-auto-dispatch').checked = data.auto_dispatch;
2029
+ document.getElementById('cfg-cooldown').value = data.cooldown_seconds;
2030
+ document.getElementById('cooldown-display').textContent = data.cooldown_seconds + 's';
2031
+ if (data.chat_id) document.getElementById('cfg-chat-id').value = data.chat_id;
2032
+ // Don't populate bot token for security (show masked version in placeholder)
2033
+ const tokenInput = document.getElementById('cfg-bot-token');
2034
+ if (data.bot_token) tokenInput.placeholder = data.bot_token;
2035
+ });
2036
+ }
2037
+
2038
+ function closeDispatchSettings() {
2039
+ document.getElementById('dispatch-settings-modal').classList.remove('show');
2040
+ }
2041
+
2042
+ function saveDispatchSettings() {
2043
+ fetch('/dispatch_settings', {
2044
+ method: 'POST',
2045
+ headers: { 'Content-Type': 'application/json' },
2046
+ body: JSON.stringify({
2047
+ enabled: document.getElementById('cfg-enabled').checked,
2048
+ auto_dispatch: document.getElementById('cfg-auto-dispatch').checked,
2049
+ cooldown_seconds: parseInt(document.getElementById('cfg-cooldown').value)
2050
+ })
2051
+ }).then(() => updateDispatchCenter());
2052
+ }
2053
+
2054
+ function saveTelegramConfig() {
2055
+ const token = document.getElementById('cfg-bot-token').value.trim();
2056
+ const chatId = document.getElementById('cfg-chat-id').value.trim();
2057
+
2058
+ if (!token && !chatId) {
2059
+ showDispatchToast('⚠ Please enter bot token and/or chat ID');
2060
+ return;
2061
+ }
2062
+
2063
+ const payload = {};
2064
+ if (token) payload.bot_token = token;
2065
+ if (chatId) payload.chat_id = chatId;
2066
+
2067
+ fetch('/dispatch_settings', {
2068
+ method: 'POST',
2069
+ headers: { 'Content-Type': 'application/json' },
2070
+ body: JSON.stringify(payload)
2071
+ })
2072
+ .then(r => r.json())
2073
+ .then(() => {
2074
+ showDispatchToast('✅ Telegram config saved!');
2075
+ updateDispatchCenter();
2076
+ });
2077
+ }
2078
+
2079
+ function autoDetectChatId() {
2080
+ showDispatchToast('🔍 Scanning for chat ID...');
2081
+ // First save any token that was entered
2082
+ const token = document.getElementById('cfg-bot-token').value.trim();
2083
+ const savePromise = token ?
2084
+ fetch('/dispatch_settings', {
2085
+ method: 'POST',
2086
+ headers: { 'Content-Type': 'application/json' },
2087
+ body: JSON.stringify({ bot_token: token })
2088
+ }) : Promise.resolve();
2089
+
2090
+ savePromise.then(() => {
2091
+ return fetch('/test_dispatch', { method: 'POST' });
2092
+ })
2093
+ .then(r => r.json())
2094
+ .then(data => {
2095
+ if (data.ok) {
2096
+ showDispatchToast('✅ Chat ID detected & test sent!');
2097
+ // Reload settings to show detected chat ID
2098
+ fetch('/dispatch_settings')
2099
+ .then(r => r.json())
2100
+ .then(s => {
2101
+ if (s.chat_id) document.getElementById('cfg-chat-id').value = s.chat_id;
2102
+ });
2103
+ } else {
2104
+ showDispatchToast('⚠ Could not detect chat ID. Send /start to the bot from Telegram, then try again.');
2105
+ }
2106
+ })
2107
+ .catch(() => showDispatchToast('⚠ Detection failed'));
2108
+ }
2109
+
2110
+ // ─── Toast Notification ───
2111
+ function showDispatchToast(message) {
2112
+ // Remove existing toast
2113
+ const existing = document.getElementById('dispatch-toast');
2114
+ if (existing) existing.remove();
2115
+
2116
+ const toast = document.createElement('div');
2117
+ toast.id = 'dispatch-toast';
2118
+ toast.textContent = message;
2119
+ toast.style.cssText = `
2120
+ position: fixed;
2121
+ bottom: 24px;
2122
+ right: 24px;
2123
+ background: var(--bg-panel);
2124
+ color: var(--text-primary);
2125
+ font-family: var(--font-mono);
2126
+ font-size: 0.75rem;
2127
+ padding: 12px 20px;
2128
+ border-radius: 8px;
2129
+ border: 1px solid var(--border-glow);
2130
+ box-shadow: 0 4px 30px rgba(0, 200, 255, 0.15);
2131
+ z-index: 12000;
2132
+ animation: fadeIn 0.3s ease;
2133
+ backdrop-filter: blur(10px);
2134
+ `;
2135
+ document.body.appendChild(toast);
2136
+ setTimeout(() => {
2137
+ toast.style.opacity = '0';
2138
+ toast.style.transition = 'opacity 0.3s';
2139
+ setTimeout(() => toast.remove(), 300);
2140
+ }, 3000);
2141
+ }
2142
+
2143
+ // ─── Intervals ───
2144
+ setInterval(updateStats, 1000);
2145
+ setInterval(refreshAuditLog, 5000);
2146
+ setInterval(updateDispatchCenter, 3000);
2147
+
2148
+ // Initial load
2149
+ setTimeout(refreshAuditLog, 1500);
2150
+ setTimeout(updateDispatchCenter, 1000);
2151
+ </script>
2152
+ </body>
2153
+
2154
+ </html>
templates/sentinel_dashboard_v15.html ADDED
@@ -0,0 +1,2253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V14 — Dispatch Integration</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ═════════════════════════════════════════��═════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+
882
+ /* ═══════════════════════════════════════════════
883
+ DISPATCH CENTER STYLES
884
+ ═══════════════════════════════════════════════ */
885
+ .dispatch-badge {
886
+ display: inline-block;
887
+ padding: 2px 8px;
888
+ border-radius: 3px;
889
+ font-family: var(--font-mono);
890
+ font-size: 0.55rem;
891
+ font-weight: 600;
892
+ letter-spacing: 1px;
893
+ text-transform: uppercase;
894
+ }
895
+
896
+ .dispatch-badge.sent {
897
+ background: rgba(0, 255, 136, 0.15);
898
+ color: var(--neon-green);
899
+ border: 1px solid rgba(0, 255, 136, 0.3);
900
+ }
901
+
902
+ .dispatch-badge.pending {
903
+ background: rgba(255, 170, 0, 0.15);
904
+ color: var(--neon-amber);
905
+ border: 1px solid rgba(255, 170, 0, 0.3);
906
+ animation: pulse-dot 2s infinite;
907
+ }
908
+
909
+ .dispatch-badge.rejected {
910
+ background: rgba(255, 32, 64, 0.15);
911
+ color: var(--neon-red);
912
+ border: 1px solid rgba(255, 32, 64, 0.3);
913
+ }
914
+
915
+ .dispatch-badge.failed {
916
+ background: rgba(255, 32, 64, 0.1);
917
+ color: #ff6080;
918
+ border: 1px solid rgba(255, 96, 128, 0.3);
919
+ }
920
+
921
+ .dispatch-entry {
922
+ font-family: var(--font-mono);
923
+ font-size: 0.65rem;
924
+ padding: 8px 10px;
925
+ margin-bottom: 4px;
926
+ border-radius: 4px;
927
+ background: rgba(0, 0, 0, 0.3);
928
+ border-left: 2px solid var(--cyan-dim);
929
+ line-height: 1.5;
930
+ color: var(--text-secondary);
931
+ display: flex;
932
+ justify-content: space-between;
933
+ align-items: flex-start;
934
+ gap: 8px;
935
+ }
936
+
937
+ .dispatch-entry.pending-entry {
938
+ border-left-color: var(--neon-amber);
939
+ background: rgba(255, 170, 0, 0.05);
940
+ }
941
+
942
+ .dispatch-entry .dispatch-info {
943
+ flex: 1;
944
+ }
945
+
946
+ .dispatch-entry .dispatch-score {
947
+ font-family: var(--font-display);
948
+ font-size: 0.8rem;
949
+ font-weight: 700;
950
+ min-width: 30px;
951
+ text-align: right;
952
+ }
953
+
954
+ .dispatch-actions {
955
+ display: flex;
956
+ gap: 4px;
957
+ margin-top: 6px;
958
+ }
959
+
960
+ .dispatch-actions button {
961
+ padding: 4px 12px;
962
+ border-radius: 4px;
963
+ font-family: var(--font-display);
964
+ font-size: 0.6rem;
965
+ font-weight: 600;
966
+ letter-spacing: 1px;
967
+ cursor: pointer;
968
+ transition: all 0.2s;
969
+ border: 1px solid;
970
+ }
971
+
972
+ .btn-approve {
973
+ background: rgba(0, 255, 136, 0.1);
974
+ border-color: rgba(0, 255, 136, 0.4) !important;
975
+ color: var(--neon-green);
976
+ }
977
+
978
+ .btn-approve:hover {
979
+ background: rgba(0, 255, 136, 0.25);
980
+ box-shadow: 0 0 12px rgba(0, 255, 136, 0.2);
981
+ }
982
+
983
+ .btn-reject {
984
+ background: rgba(255, 32, 64, 0.1);
985
+ border-color: rgba(255, 32, 64, 0.4) !important;
986
+ color: var(--neon-red);
987
+ }
988
+
989
+ .btn-reject:hover {
990
+ background: rgba(255, 32, 64, 0.25);
991
+ box-shadow: 0 0 12px rgba(255, 32, 64, 0.2);
992
+ }
993
+
994
+ .dispatch-config-row {
995
+ display: flex;
996
+ justify-content: space-between;
997
+ align-items: center;
998
+ padding: 10px 0;
999
+ border-bottom: 1px solid rgba(0, 200, 255, 0.05);
1000
+ }
1001
+
1002
+ .dispatch-config-row:last-child {
1003
+ border-bottom: none;
1004
+ }
1005
+
1006
+ .dispatch-config-label {
1007
+ font-size: 0.8rem;
1008
+ color: var(--text-secondary);
1009
+ font-weight: 500;
1010
+ }
1011
+
1012
+ .toggle-switch {
1013
+ position: relative;
1014
+ width: 42px;
1015
+ height: 22px;
1016
+ cursor: pointer;
1017
+ }
1018
+
1019
+ .toggle-switch input {
1020
+ opacity: 0;
1021
+ width: 0;
1022
+ height: 0;
1023
+ }
1024
+
1025
+ .toggle-slider {
1026
+ position: absolute;
1027
+ top: 0;
1028
+ left: 0;
1029
+ right: 0;
1030
+ bottom: 0;
1031
+ background: rgba(255, 255, 255, 0.1);
1032
+ border-radius: 22px;
1033
+ transition: 0.3s;
1034
+ border: 1px solid rgba(255, 255, 255, 0.15);
1035
+ }
1036
+
1037
+ .toggle-slider::before {
1038
+ content: '';
1039
+ position: absolute;
1040
+ width: 16px;
1041
+ height: 16px;
1042
+ left: 2px;
1043
+ bottom: 2px;
1044
+ background: var(--text-secondary);
1045
+ border-radius: 50%;
1046
+ transition: 0.3s;
1047
+ }
1048
+
1049
+ .toggle-switch input:checked+.toggle-slider {
1050
+ background: rgba(0, 200, 255, 0.3);
1051
+ border-color: var(--cyan);
1052
+ }
1053
+
1054
+ .toggle-switch input:checked+.toggle-slider::before {
1055
+ transform: translateX(20px);
1056
+ background: var(--cyan);
1057
+ }
1058
+
1059
+ .config-input {
1060
+ background: rgba(0, 0, 0, 0.4);
1061
+ border: 1px solid var(--border-glow);
1062
+ border-radius: 4px;
1063
+ color: var(--text-primary);
1064
+ font-family: var(--font-mono);
1065
+ font-size: 0.75rem;
1066
+ padding: 6px 10px;
1067
+ width: 100%;
1068
+ transition: border-color 0.3s;
1069
+ }
1070
+
1071
+ .config-input:focus {
1072
+ outline: none;
1073
+ border-color: var(--cyan);
1074
+ box-shadow: 0 0 8px rgba(0, 200, 255, 0.15);
1075
+ }
1076
+
1077
+ .config-input::placeholder {
1078
+ color: var(--text-dim);
1079
+ }
1080
+
1081
+ .cooldown-slider {
1082
+ -webkit-appearance: none;
1083
+ appearance: none;
1084
+ width: 100%;
1085
+ height: 4px;
1086
+ border-radius: 2px;
1087
+ background: rgba(255, 255, 255, 0.1);
1088
+ outline: none;
1089
+ margin: 8px 0;
1090
+ }
1091
+
1092
+ .cooldown-slider::-webkit-slider-thumb {
1093
+ -webkit-appearance: none;
1094
+ width: 14px;
1095
+ height: 14px;
1096
+ border-radius: 50%;
1097
+ background: var(--cyan);
1098
+ cursor: pointer;
1099
+ box-shadow: 0 0 8px rgba(0, 200, 255, 0.4);
1100
+ }
1101
+
1102
+ .pending-count-badge {
1103
+ background: var(--neon-amber);
1104
+ color: #000;
1105
+ font-family: var(--font-mono);
1106
+ font-size: 0.55rem;
1107
+ font-weight: 700;
1108
+ width: 16px;
1109
+ height: 16px;
1110
+ border-radius: 50%;
1111
+ display: none;
1112
+ align-items: center;
1113
+ justify-content: center;
1114
+ margin-left: auto;
1115
+ }
1116
+
1117
+ .pending-count-badge.visible {
1118
+ display: flex;
1119
+ }
1120
+
1121
+ .dispatch-status-bar {
1122
+ display: flex;
1123
+ align-items: center;
1124
+ gap: 6px;
1125
+ padding: 6px 10px;
1126
+ border-radius: 4px;
1127
+ font-family: var(--font-mono);
1128
+ font-size: 0.6rem;
1129
+ margin-bottom: 8px;
1130
+ }
1131
+
1132
+ .dispatch-status-bar.configured {
1133
+ background: rgba(0, 255, 136, 0.05);
1134
+ border: 1px solid rgba(0, 255, 136, 0.15);
1135
+ color: var(--neon-green);
1136
+ }
1137
+
1138
+ .dispatch-status-bar.not-configured {
1139
+ background: rgba(255, 170, 0, 0.05);
1140
+ border: 1px solid rgba(255, 170, 0, 0.15);
1141
+ color: var(--neon-amber);
1142
+ }
1143
+
1144
+ .dispatch-dot {
1145
+ width: 6px;
1146
+ height: 6px;
1147
+ border-radius: 50%;
1148
+ }
1149
+
1150
+ .dispatch-dot.online {
1151
+ background: var(--neon-green);
1152
+ box-shadow: 0 0 6px var(--neon-green);
1153
+ }
1154
+
1155
+ .dispatch-dot.offline {
1156
+ background: var(--neon-amber);
1157
+ box-shadow: 0 0 6px var(--neon-amber);
1158
+ }
1159
+ </style>
1160
+ </head>
1161
+
1162
+ <body>
1163
+ <!-- Red Alert Overlay -->
1164
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
1165
+ <div class="red-alert-banner" id="red-alert-banner">
1166
+ ⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠
1167
+ <button class="btn btn-secondary" onclick="resetSystem()"
1168
+ style="width: auto; margin-top: 10px; background: rgba(0,0,0,0.5); border: 1px solid #ff2040; color: #ff2040;">RESET
1169
+ SYSTEM</button>
1170
+ </div>
1171
+
1172
+ <!-- SIDEBAR -->
1173
+ <div class="sidebar">
1174
+ <div class="logo">
1175
+ <i data-feather="shield"></i>
1176
+ SENTINEL
1177
+ </div>
1178
+ <div class="version-tag">V15.0 // AI + ETHICS INTEGRATION</div>
1179
+
1180
+ <div class="nav-group">
1181
+ <div class="nav-label">Detection Modules</div>
1182
+ <div class="nav-item active" onclick="toggleModule('movement', this)" id="nav-movement">
1183
+ <i data-feather="activity"></i> Movement
1184
+ </div>
1185
+ <div class="nav-item" onclick="toggleModule('facemask', this)" id="nav-facemask">
1186
+ <i data-feather="eye"></i> Facemask
1187
+ </div>
1188
+ <div class="nav-item" onclick="toggleModule('weapon', this)" id="nav-weapon">
1189
+ <i data-feather="crosshair"></i> Weapon
1190
+ </div>
1191
+ <div class="nav-item" onclick="toggleModule('public_safety', this)" id="nav-public_safety">
1192
+ <i data-feather="users"></i> Public Safety
1193
+ </div>
1194
+ <div class="nav-item" onclick="toggleModule('suspect_journey', this)" id="nav-suspect_journey"
1195
+ style="border-color: rgba(255,255,0,0.3);">
1196
+ <i data-feather="map-pin"></i> Suspect Journey
1197
+ </div>
1198
+ </div>
1199
+
1200
+ <div class="nav-group">
1201
+ <div class="nav-label">Dispatch</div>
1202
+ <div class="nav-item" onclick="openDispatchSettings()" id="nav-dispatch">
1203
+ <i data-feather="bell"></i> Dispatch Settings
1204
+ <span class="pending-count-badge" id="pending-badge">0</span>
1205
+ </div>
1206
+ </div>
1207
+
1208
+ <div class="nav-group">
1209
+ <div class="nav-label">Ethics & AI</div>
1210
+ <div class="nav-item" onclick="openPrivacySettings()">
1211
+ <i data-feather="lock"></i> Privacy Controls
1212
+ </div>
1213
+ <div class="nav-item" onclick="openFairnessMetrics()">
1214
+ <i data-feather="pie-chart"></i> Fairness Metrics
1215
+ </div>
1216
+ </div>
1217
+
1218
+ <div class="nav-group">
1219
+ <div class="nav-label">Grid Layout</div>
1220
+ <div class="nav-item" onclick="setGridLayout('quad')">
1221
+ <i data-feather="grid"></i> 2×2 Grid
1222
+ </div>
1223
+ <div class="nav-item" onclick="setGridLayout('single')">
1224
+ <i data-feather="maximize-2"></i> Single View
1225
+ </div>
1226
+ </div>
1227
+
1228
+ <!-- Audit Log -->
1229
+ <div class="audit-section">
1230
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
1231
+ <div class="audit-log-container" id="audit-log-container">
1232
+ <div class="audit-entry">
1233
+ <div class="audit-time">--:--:--</div>
1234
+ SYSTEM STANDBY
1235
+ </div>
1236
+ </div>
1237
+ </div>
1238
+ </div>
1239
+
1240
+ <!-- MAIN CONTENT -->
1241
+ <div class="main-content">
1242
+ <!-- Multi-Camera Grid -->
1243
+ <div class="camera-grid" id="camera-grid">
1244
+ <!-- Feed 0 — Primary -->
1245
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
1246
+ <div class="feed-header">
1247
+ <div class="feed-badge">
1248
+ <div class="live-dot"></div>
1249
+ <span>FEED 01 // PRIMARY</span>
1250
+ </div>
1251
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
1252
+ </div>
1253
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
1254
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
1255
+ <i data-feather="maximize-2"></i>
1256
+ </button>
1257
+ <button class="expand-btn"
1258
+ style="right:40px; background: rgba(0,200,255,0.2); border-color: var(--cyan);"
1259
+ onclick="event.stopPropagation(); triggerFeedUpload(0)" title="Upload to this feed">
1260
+ <i data-feather="upload-cloud"></i>
1261
+ </button>
1262
+ </div>
1263
+
1264
+ <!-- Feed 1 -->
1265
+ <div class="feed-cell" id="feed-1">
1266
+ <div class="feed-header">
1267
+ <div class="feed-badge">
1268
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1269
+ <span>FEED 02</span>
1270
+ </div>
1271
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
1272
+ </div>
1273
+ <div class="feed-offline" id="offline-1">
1274
+ <i data-feather="video-off"></i>
1275
+ <span>NO SIGNAL</span>
1276
+ <button class="btn btn-primary"
1277
+ style="width:auto; margin-top:10px; padding:8px 16px; font-size:0.7rem;"
1278
+ onclick="event.stopPropagation(); triggerFeedUpload(1)">
1279
+ <i data-feather="upload-cloud" style="width:14px;height:14px;"></i> UPLOAD CLIP
1280
+ </button>
1281
+ </div>
1282
+ </div>
1283
+
1284
+ <!-- Feed 2 -->
1285
+ <div class="feed-cell" id="feed-2">
1286
+ <div class="feed-header">
1287
+ <div class="feed-badge">
1288
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1289
+ <span>FEED 03</span>
1290
+ </div>
1291
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
1292
+ </div>
1293
+ <div class="feed-offline" id="offline-2">
1294
+ <i data-feather="video-off"></i>
1295
+ <span>NO SIGNAL</span>
1296
+ <button class="btn btn-primary"
1297
+ style="width:auto; margin-top:10px; padding:8px 16px; font-size:0.7rem;"
1298
+ onclick="event.stopPropagation(); triggerFeedUpload(2)">
1299
+ <i data-feather="upload-cloud" style="width:14px;height:14px;"></i> UPLOAD CLIP
1300
+ </button>
1301
+ </div>
1302
+ </div>
1303
+
1304
+ <!-- Feed 3 -->
1305
+ <div class="feed-cell" id="feed-3">
1306
+ <div class="feed-header">
1307
+ <div class="feed-badge">
1308
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1309
+ <span>FEED 04</span>
1310
+ </div>
1311
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1312
+ </div>
1313
+ <div class="feed-offline" id="offline-3">
1314
+ <i data-feather="video-off"></i>
1315
+ <span>NO SIGNAL</span>
1316
+ <button class="btn btn-primary"
1317
+ style="width:auto; margin-top:10px; padding:8px 16px; font-size:0.7rem;"
1318
+ onclick="event.stopPropagation(); triggerFeedUpload(3)">
1319
+ <i data-feather="upload-cloud" style="width:14px;height:14px;"></i> UPLOAD CLIP
1320
+ </button>
1321
+ </div>
1322
+ </div>
1323
+ </div>
1324
+
1325
+ <!-- Hidden file input for per-feed upload -->
1326
+ <input type="file" id="feed-upload-input" style="display: none" accept="video/*,.gif">
1327
+
1328
+
1329
+ <!-- Intel Panel -->
1330
+ <div class="intel-panel">
1331
+ <!-- Suspect Journey -->
1332
+ <div class="card" id="journey-card">
1333
+ <div class="card-header">
1334
+ <div class="card-title">Suspect Journey</div>
1335
+ <i data-feather="map" class="card-icon"></i>
1336
+ </div>
1337
+ <div class="audit-log-container" id="journey-container" style="max-height: 250px;">
1338
+ <div class="audit-entry">
1339
+ <div class="audit-time">--:--:--</div>
1340
+ ACTIVATE 'SUSPECT JOURNEY' MODULE TO BEGIN
1341
+ </div>
1342
+ </div>
1343
+ </div>
1344
+
1345
+ <!-- V15 AI Situational Assessment -->
1346
+ <div class="card" id="ai-assessment-card" style="border-color: var(--cyan);">
1347
+ <div class="card-header">
1348
+ <div class="card-title">AI Assessment <span id="ai-severity" class="dispatch-badge" style="margin-left:8px;">LOW</span></div>
1349
+ <i data-feather="cpu" class="card-icon" style="color: var(--cyan);"></i>
1350
+ </div>
1351
+ <div style="font-size: 0.8rem; color: var(--text-primary); margin-bottom: 8px;" id="ai-summary">
1352
+ System initializing — awaiting first AI assessment.
1353
+ </div>
1354
+ <div style="font-family: var(--font-mono); font-size: 0.65rem; color: var(--text-dim); margin-bottom: 8px;">
1355
+ <div id="ai-patterns"></div>
1356
+ </div>
1357
+ <div style="padding: 6px; background: rgba(0,200,255,0.1); border-left: 2px solid var(--cyan); font-size: 0.75rem; color: var(--text-primary);" id="ai-action">
1358
+ Monitor feeds and enable detection modules.
1359
+ </div>
1360
+ <div style="font-family: var(--font-mono); font-size: 0.6rem; color: var(--text-dim); text-align: right; margin-top: 6px;" id="ai-metadata">
1361
+ Confidence: 0% | Last updated: --:--:--
1362
+ </div>
1363
+ </div>
1364
+
1365
+ <!-- Active Mode -->
1366
+ <div class="card">
1367
+ <div class="card-header">
1368
+ <div class="card-title">Active Mode</div>
1369
+ </div>
1370
+ <div class="mode-indicator">
1371
+ <div class="mode-dot"></div>
1372
+ <div class="mode-name" id="mode-title">1 MODULE ACTIVE</div>
1373
+ </div>
1374
+ </div>
1375
+
1376
+ <!-- Threat Assessment -->
1377
+ <div class="card" id="threat-card">
1378
+ <div class="card-header">
1379
+ <div class="card-title">Threat Assessment</div>
1380
+ <i data-feather="alert-triangle" class="card-icon"></i>
1381
+ </div>
1382
+ <div class="threat-gauge">
1383
+ <div class="score-ring">
1384
+ <svg viewBox="0 0 130 130">
1385
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1386
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1387
+ </svg>
1388
+ <div class="score-text">
1389
+ <div class="score-value" id="threat-score">0</div>
1390
+ <div class="score-label">THREAT LEVEL</div>
1391
+ </div>
1392
+ </div>
1393
+ <div class="status-text" id="status-text">SECURE</div>
1394
+ </div>
1395
+ </div>
1396
+
1397
+ <!-- V15 Explainability Panel -->
1398
+ <div class="card" id="explain-card">
1399
+ <div class="card-header">
1400
+ <div class="card-title">Threat Explanation</div>
1401
+ <i data-feather="info" class="card-icon"></i>
1402
+ </div>
1403
+ <div class="audit-log-container" id="explain-container" style="max-height: 150px; background: rgba(0,0,0,0.2);">
1404
+ <div class="audit-entry">
1405
+ <span style="color:var(--text-dim);">No active threats contributing to score.</span>
1406
+ </div>
1407
+ </div>
1408
+ </div>
1409
+
1410
+ <!-- Live Metrics -->
1411
+ <div class="card">
1412
+ <div class="card-header">
1413
+ <div class="card-title">Live Metrics</div>
1414
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1415
+ </div>
1416
+ <div class="stats-list" id="stats-container">
1417
+ <div class="stat-row">
1418
+ <span class="stat-name">System Status</span>
1419
+ <span class="stat-val">Initializing</span>
1420
+ </div>
1421
+ </div>
1422
+ </div>
1423
+
1424
+ <!-- Dispatch Center -->
1425
+ <div class="card" id="dispatch-card">
1426
+ <div class="card-header">
1427
+ <div class="card-title">Dispatch Center</div>
1428
+ <i data-feather="send" class="card-icon"></i>
1429
+ </div>
1430
+ <div class="dispatch-status-bar not-configured" id="dispatch-status-bar">
1431
+ <div class="dispatch-dot offline" id="dispatch-dot"></div>
1432
+ <span id="dispatch-status-text">TELEGRAM NOT CONFIGURED</span>
1433
+ </div>
1434
+
1435
+ <!-- Pending Approvals -->
1436
+ <div id="pending-section" style="display:none; margin-bottom: 10px;">
1437
+ <div
1438
+ style="font-family: var(--font-display); font-size: 0.6rem; color: var(--neon-amber); letter-spacing: 1px; margin-bottom: 6px;">
1439
+ ⏳ PENDING APPROVAL</div>
1440
+ <div id="pending-container"></div>
1441
+ </div>
1442
+
1443
+ <!-- Dispatch Log -->
1444
+ <div class="audit-log-container" id="dispatch-log-container" style="max-height: 180px;">
1445
+ <div class="dispatch-entry">
1446
+ <div class="dispatch-info">
1447
+ <div class="audit-time">--:--:--</div>
1448
+ NO DISPATCH HISTORY
1449
+ </div>
1450
+ </div>
1451
+ </div>
1452
+
1453
+ <!-- Quick Actions -->
1454
+ <div style="display: flex; gap: 6px; margin-top: 10px;">
1455
+ <button class="btn btn-primary" onclick="manualDispatch()"
1456
+ style="font-size: 0.65rem; padding: 8px;">📡 SEND ALERT</button>
1457
+ <button class="btn btn-secondary" onclick="testDispatch()"
1458
+ style="font-size: 0.65rem; padding: 8px;">🧪 TEST</button>
1459
+ <button class="btn btn-secondary" onclick="openDispatchSettings()"
1460
+ style="font-size: 0.65rem; padding: 8px;">⚙</button>
1461
+ </div>
1462
+ </div>
1463
+
1464
+ <!-- Actions -->
1465
+ <div class="card">
1466
+ <div class="card-header">
1467
+ <div class="card-title">Actions</div>
1468
+ </div>
1469
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1470
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1471
+ Log</button>
1472
+ <button class="btn btn-secondary" onclick="resetSystem()"
1473
+ style="margin-top: 8px; border-color: #ff2040; color: #ff2040;">⚠ SYSTEM RESET</button>
1474
+ </div>
1475
+ </div>
1476
+ </div>
1477
+
1478
+ <!-- Report Modal -->
1479
+ <div id="report-modal" class="modal-overlay">
1480
+ <div class="modal-card">
1481
+ <div class="modal-title">Incident Report</div>
1482
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1483
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1484
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1485
+ </div>
1486
+ </div>
1487
+
1488
+ <!-- V15 Privacy Settings Modal -->
1489
+ <div id="privacy-modal" class="modal-overlay">
1490
+ <div class="modal-card" style="width: 450px;">
1491
+ <div class="modal-title">🛡 Privacy & Security (D1)</div>
1492
+ <div class="modal-subtitle">Data Protection & Retention Controls</div>
1493
+
1494
+ <div class="dispatch-config-row">
1495
+ <span class="dispatch-config-label">Auto-Redact Faces (Blur)</span>
1496
+ <label class="toggle-switch">
1497
+ <input type="checkbox" id="priv-redact" onchange="savePrivacySettings()">
1498
+ <span class="toggle-slider"></span>
1499
+ </label>
1500
+ </div>
1501
+
1502
+ <div class="dispatch-config-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
1503
+ <span class="dispatch-config-label">Data Retention: <span id="retention-display" style="color: var(--cyan);">24h</span></span>
1504
+ <input type="range" class="cooldown-slider" id="priv-retention" min="1" max="168" value="24"
1505
+ oninput="document.getElementById('retention-display').textContent = this.value + 'h'"
1506
+ onchange="savePrivacySettings()">
1507
+ </div>
1508
+
1509
+ <div style="margin-top: 15px; padding: 10px; background: rgba(0,255,136,0.05); border-radius: 4px; font-size: 0.65rem; color: var(--text-dim);">
1510
+ <i data-feather="terminal" style="width:12px; height:12px; vertical-align: middle; margin-right:4px;"></i>
1511
+ Encryption: AES-256 enabled for all audit logs.
1512
+ <br>
1513
+ Purge: Background cleanup runs every 5 minutes.
1514
+ </div>
1515
+
1516
+ <button class="btn btn-secondary" onclick="document.getElementById('privacy-modal').classList.remove('show')" style="margin-top: 15px;">Close</button>
1517
+ </div>
1518
+ </div>
1519
+
1520
+ <!-- V15 Fairness Audit Modal -->
1521
+ <div id="fairness-modal" class="modal-overlay">
1522
+ <div class="modal-card" style="width: 500px;">
1523
+ <div class="modal-title">⚖ Fairness & Bias Audit (D3)</div>
1524
+ <div class="modal-subtitle">Systemic Detection Analysis // Threshold Tuning</div>
1525
+
1526
+ <div id="fairness-stats-container">
1527
+ <!-- Populated by JS -->
1528
+ </div>
1529
+
1530
+ <div style="margin-top: 15px; padding: 10px; background: rgba(0,200,255,0.05); border-radius: 4px; font-size: 0.65rem; color: var(--text-dim);">
1531
+ <i data-feather="info" style="width:12px; height:12px; vertical-align: middle; margin-right:4px;"></i>
1532
+ Thresholds define minimum model confidence required to trigger a detection.
1533
+ Higher values reduce false positives (Bias Mitigation).
1534
+ </div>
1535
+
1536
+ <button class="btn btn-secondary" onclick="document.getElementById('fairness-modal').classList.remove('show')" style="margin-top: 15px;">Close</button>
1537
+ </div>
1538
+ </div>
1539
+
1540
+ <!-- Dispatch Settings Modal -->
1541
+ <div id="dispatch-settings-modal" class="modal-overlay">
1542
+ <div class="modal-card" style="width: 480px;">
1543
+ <div class="modal-title">⚙ Dispatch Settings</div>
1544
+ <div class="modal-subtitle">Telegram Bot Integration // Automated Alert System</div>
1545
+
1546
+ <div style="margin-bottom: 16px;">
1547
+ <div class="dispatch-config-row">
1548
+ <span class="dispatch-config-label">Master Switch</span>
1549
+ <label class="toggle-switch">
1550
+ <input type="checkbox" id="cfg-enabled" checked onchange="saveDispatchSettings()">
1551
+ <span class="toggle-slider"></span>
1552
+ </label>
1553
+ </div>
1554
+
1555
+ <div class="dispatch-config-row">
1556
+ <span class="dispatch-config-label">Auto-Dispatch (skip approval)</span>
1557
+ <label class="toggle-switch">
1558
+ <input type="checkbox" id="cfg-auto-dispatch" onchange="saveDispatchSettings()">
1559
+ <span class="toggle-slider"></span>
1560
+ </label>
1561
+ </div>
1562
+
1563
+ <div class="dispatch-config-row" style="flex-direction: column; align-items: flex-start; gap: 6px;">
1564
+ <span class="dispatch-config-label">Cooldown: <span id="cooldown-display"
1565
+ style="color: var(--cyan);">60s</span></span>
1566
+ <input type="range" class="cooldown-slider" id="cfg-cooldown" min="10" max="300" value="60"
1567
+ oninput="document.getElementById('cooldown-display').textContent = this.value + 's'"
1568
+ onchange="saveDispatchSettings()">
1569
+ </div>
1570
+ </div>
1571
+
1572
+ <div
1573
+ style="font-family: var(--font-display); font-size: 0.6rem; color: var(--cyan); letter-spacing: 1px; margin-bottom: 10px;">
1574
+ TELEGRAM CONFIGURATION</div>
1575
+
1576
+ <div style="margin-bottom: 10px;">
1577
+ <label style="font-size: 0.7rem; color: var(--text-secondary); display: block; margin-bottom: 4px;">Bot
1578
+ Token</label>
1579
+ <input type="text" class="config-input" id="cfg-bot-token" placeholder="e.g. 8659917680:AAFHai-..."
1580
+ style="margin-bottom: 8px;">
1581
+
1582
+ <label style="font-size: 0.7rem; color: var(--text-secondary); display: block; margin-bottom: 4px;">Chat
1583
+ ID <span style="color: var(--text-dim);">(send /start to your bot, then click
1584
+ Auto-Detect)</span></label>
1585
+ <div style="display: flex; gap: 6px;">
1586
+ <input type="text" class="config-input" id="cfg-chat-id" placeholder="e.g. 123456789"
1587
+ style="flex: 1;">
1588
+ <button class="btn btn-secondary" onclick="autoDetectChatId()"
1589
+ style="width: auto; padding: 6px 12px; font-size: 0.65rem;">AUTO-DETECT</button>
1590
+ </div>
1591
+ </div>
1592
+
1593
+ <div style="display: flex; gap: 8px; margin-top: 16px;">
1594
+ <button class="btn btn-primary" onclick="saveTelegramConfig()" style="flex: 1;">SAVE CONFIG</button>
1595
+ <button class="btn btn-primary" onclick="testDispatch()"
1596
+ style="flex: 1; background: rgba(0, 255, 136, 0.1); border-color: rgba(0, 255, 136, 0.3); color: var(--neon-green);">🧪
1597
+ SEND TEST</button>
1598
+ </div>
1599
+ <button class="btn btn-secondary" onclick="closeDispatchSettings()" style="margin-top: 8px;">Close</button>
1600
+ </div>
1601
+ </div>
1602
+
1603
+ <!-- ═══════════════════════════════════════════════
1604
+ JAVASCRIPT
1605
+ ═══════════════════════════════════════════════ -->
1606
+ <script>
1607
+ feather.replace();
1608
+
1609
+ // ─── State ───
1610
+ let currentLayout = 'quad'; // 'quad' or 'single'
1611
+ let expandedFeed = 0;
1612
+ let isRedAlert = false;
1613
+
1614
+ // ─── Module Toggling ───
1615
+ const modeTitles = {
1616
+ 'movement': 'MOVEMENT ANALYSIS',
1617
+ 'facemask': 'FACEMASK DETECTION',
1618
+ 'weapon': 'WEAPON DETECTION',
1619
+ 'public_safety': 'PUBLIC SAFETY',
1620
+ 'suspect_journey': 'SUSPECT JOURNEY'
1621
+ };
1622
+
1623
+ function toggleModule(module, buttonElement) {
1624
+ buttonElement.classList.toggle('active');
1625
+
1626
+ fetch('/toggle_module', {
1627
+ method: 'POST',
1628
+ headers: { 'Content-Type': 'application/json' },
1629
+ body: JSON.stringify({ module: module })
1630
+ })
1631
+ .then(r => r.json())
1632
+ .then(data => {
1633
+ updateActiveModulesDisplay(data.active_modules);
1634
+ });
1635
+ }
1636
+
1637
+ function updateActiveModulesDisplay(activeModules) {
1638
+ const modeTitle = document.getElementById('mode-title');
1639
+ const count = activeModules.length;
1640
+
1641
+ if (count === 0) {
1642
+ modeTitle.textContent = 'NO MODULES ACTIVE';
1643
+ } else if (count === 1) {
1644
+ modeTitle.textContent = modeTitles[activeModules[0]] || activeModules[0].toUpperCase();
1645
+ } else {
1646
+ modeTitle.textContent = `${count} MODULES ACTIVE`;
1647
+ }
1648
+ }
1649
+
1650
+
1651
+ // ── Per-Feed Upload ──
1652
+ let uploadTargetFeed = 0;
1653
+
1654
+ function triggerFeedUpload(feedId) {
1655
+ uploadTargetFeed = feedId;
1656
+ document.getElementById('feed-upload-input').click();
1657
+ }
1658
+
1659
+ document.addEventListener('DOMContentLoaded', function () {
1660
+ const feedInput = document.getElementById('feed-upload-input');
1661
+ if (feedInput) {
1662
+ feedInput.addEventListener('change', function () {
1663
+ if (this.files[0]) {
1664
+ const feedId = uploadTargetFeed;
1665
+ const formData = new FormData();
1666
+ formData.append('file', this.files[0]);
1667
+
1668
+ const statusEl = document.getElementById('feed-' + feedId + '-status');
1669
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1670
+
1671
+ fetch('/upload_video/' + feedId, { method: 'POST', body: formData })
1672
+ .then(r => r.json())
1673
+ .then(data => {
1674
+ if (data.success) {
1675
+ activateFeedUI(feedId);
1676
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1677
+ }
1678
+ })
1679
+ .catch(() => {
1680
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1681
+ });
1682
+
1683
+ this.value = '';
1684
+ }
1685
+ });
1686
+ }
1687
+ });
1688
+
1689
+ function activateFeedUI(feedId) {
1690
+ // Hide the "NO SIGNAL" overlay and show the stream
1691
+ const offline = document.getElementById('offline-' + feedId);
1692
+ if (offline) offline.style.display = 'none';
1693
+
1694
+ const cell = document.getElementById('feed-' + feedId);
1695
+ // If no stream img exists yet, create one
1696
+ let img = document.getElementById('stream-' + feedId);
1697
+ if (!img) {
1698
+ img = document.createElement('img');
1699
+ img.className = 'feed-stream';
1700
+ img.id = 'stream-' + feedId;
1701
+ img.alt = 'Feed ' + feedId;
1702
+ cell.appendChild(img);
1703
+ }
1704
+
1705
+ // Force refresh
1706
+ img.src = '';
1707
+ setTimeout(() => {
1708
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1709
+ }, 400);
1710
+
1711
+ // Update the live dot
1712
+ const badge = cell.querySelector('.live-dot');
1713
+ if (badge) {
1714
+ badge.style.background = '';
1715
+ badge.style.boxShadow = '';
1716
+ }
1717
+
1718
+ feather.replace();
1719
+ }
1720
+
1721
+ /**
1722
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1723
+ * This drops the old HTTP connection and establishes a fresh one.
1724
+ */
1725
+ function refreshFeedStream(feedId) {
1726
+ const img = document.getElementById('stream-' + feedId);
1727
+ if (img) {
1728
+ // Brief blank to visually signal the switch
1729
+ img.src = '';
1730
+ // Small delay lets the backend fully initialize the new feed
1731
+ setTimeout(() => {
1732
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1733
+ }, 300);
1734
+ }
1735
+ }
1736
+
1737
+ // ─── Grid Layout ───
1738
+ function setGridLayout(layout) {
1739
+ const grid = document.getElementById('camera-grid');
1740
+ currentLayout = layout;
1741
+
1742
+ if (layout === 'single') {
1743
+ grid.classList.add('single-view');
1744
+ // Show only the expanded feed
1745
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1746
+ cell.classList.toggle('expanded', i === expandedFeed);
1747
+ });
1748
+ } else {
1749
+ grid.classList.remove('single-view');
1750
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1751
+ cell.classList.remove('expanded');
1752
+ });
1753
+ }
1754
+ }
1755
+
1756
+ function expandFeed(feedId) {
1757
+ expandedFeed = feedId;
1758
+ if (currentLayout === 'single') {
1759
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1760
+ cell.classList.toggle('expanded', i === feedId);
1761
+ });
1762
+ }
1763
+ }
1764
+
1765
+ // ─── Stats & Red Alert Updates ───
1766
+ function updateStats() {
1767
+ fetch('/stats')
1768
+ .then(r => r.json())
1769
+ .then(data => {
1770
+ const score = data.threat_score;
1771
+ const scoreEl = document.getElementById('threat-score');
1772
+ const statusEl = document.getElementById('status-text');
1773
+ const ringFill = document.getElementById('score-ring-fill');
1774
+ const threatCard = document.getElementById('threat-card');
1775
+
1776
+ scoreEl.textContent = score;
1777
+
1778
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1779
+ const circumference = 377;
1780
+ const offset = circumference - (circumference * score / 100);
1781
+ ringFill.style.strokeDashoffset = offset;
1782
+
1783
+ // Color based on score
1784
+ let color, status, glow;
1785
+ if (score >= 80) {
1786
+ color = '#ff2040';
1787
+ status = 'CRITICAL';
1788
+ glow = 'rgba(255, 32, 64, 0.4)';
1789
+ } else if (score >= 50) {
1790
+ color = '#ffaa00';
1791
+ status = 'ELEVATED';
1792
+ glow = 'rgba(255, 170, 0, 0.3)';
1793
+ } else if (score >= 25) {
1794
+ color = '#00d4ff';
1795
+ status = 'GUARDED';
1796
+ glow = 'rgba(0, 200, 255, 0.3)';
1797
+ } else {
1798
+ color = '#00ff88';
1799
+ status = 'SECURE';
1800
+ glow = 'rgba(0, 255, 136, 0.3)';
1801
+ }
1802
+
1803
+ statusEl.textContent = status;
1804
+ statusEl.style.color = color;
1805
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1806
+ ringFill.style.stroke = color;
1807
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1808
+
1809
+ // V15 Updates
1810
+ updateAIAssessment();
1811
+ updateExplainability();
1812
+
1813
+ updateJourneyData();
1814
+
1815
+ // Red Alert state
1816
+ const alertOverlay = document.getElementById('red-alert-overlay');
1817
+ const alertBanner = document.getElementById('red-alert-banner');
1818
+ const feedCells = document.querySelectorAll('.feed-cell');
1819
+
1820
+ if (data.red_alert) {
1821
+ alertOverlay.classList.add('active');
1822
+ alertBanner.classList.add('active');
1823
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1824
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1825
+
1826
+ if (!isRedAlert) {
1827
+ playAlertTone();
1828
+ isRedAlert = true;
1829
+ }
1830
+ } else {
1831
+ alertOverlay.classList.remove('active');
1832
+ alertBanner.classList.remove('active');
1833
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1834
+ threatCard.style.borderColor = '';
1835
+ isRedAlert = false;
1836
+ }
1837
+
1838
+ // Update mode display
1839
+ if (data.active_modules) {
1840
+ updateActiveModulesDisplay(data.active_modules);
1841
+ }
1842
+
1843
+ // Update live metrics
1844
+ const container = document.getElementById('stats-container');
1845
+ container.innerHTML = '';
1846
+
1847
+ if (!data.details || Object.keys(data.details).length === 0) {
1848
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1849
+ } else {
1850
+ for (const [key, value] of Object.entries(data.details)) {
1851
+ const div = document.createElement('div');
1852
+ div.className = 'stat-row';
1853
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1854
+ let displayVal = value;
1855
+ if (typeof value === 'boolean') {
1856
+ displayVal = value ? '⚠ YES' : 'No';
1857
+ }
1858
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1859
+ container.appendChild(div);
1860
+ }
1861
+ }
1862
+ })
1863
+ .catch(() => { });
1864
+ }
1865
+
1866
+ // ─── Alert Tone (Web Audio API) ───
1867
+ function playAlertTone() {
1868
+ try {
1869
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1870
+ const oscillator = audioCtx.createOscillator();
1871
+ const gainNode = audioCtx.createGain();
1872
+
1873
+ oscillator.connect(gainNode);
1874
+ gainNode.connect(audioCtx.destination);
1875
+
1876
+ oscillator.type = 'square';
1877
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1878
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1879
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1880
+
1881
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1882
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1883
+
1884
+ oscillator.start(audioCtx.currentTime);
1885
+ oscillator.stop(audioCtx.currentTime + 0.5);
1886
+ } catch (e) {
1887
+ // Audio not available — silent fallback
1888
+ }
1889
+ }
1890
+
1891
+ // ─── AI Report ───
1892
+ function generateReport() {
1893
+ const modal = document.getElementById('report-modal');
1894
+ modal.classList.add('show');
1895
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1896
+
1897
+ fetch('/generate_report', { method: 'POST' })
1898
+ .then(r => r.json())
1899
+ .then(data => {
1900
+ document.getElementById('report-content').textContent = data.report;
1901
+ })
1902
+ .catch(() => {
1903
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1904
+ });
1905
+ }
1906
+
1907
+ function closeModal() {
1908
+ document.getElementById('report-modal').classList.remove('show');
1909
+ }
1910
+
1911
+ // ─── Audit Log Refresh ───
1912
+ function refreshAuditLog() {
1913
+ fetch('/audit_log')
1914
+ .then(r => r.json())
1915
+ .then(data => {
1916
+ const container = document.getElementById('audit-log-container');
1917
+ container.innerHTML = '';
1918
+
1919
+ if (data.log.length === 0) {
1920
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1921
+ return;
1922
+ }
1923
+
1924
+ data.log.slice(0, 30).forEach(entry => {
1925
+ const div = document.createElement('div');
1926
+ div.className = `audit-entry severity-${entry.severity}`;
1927
+ div.innerHTML = `
1928
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1929
+ ${entry.action}: ${entry.details}
1930
+ `;
1931
+ container.appendChild(div);
1932
+ });
1933
+ })
1934
+ .catch(() => { });
1935
+ }
1936
+
1937
+ // ─── System Reset ───
1938
+ function resetSystem() {
1939
+ if (!confirm("⚠ CONFIRM SYSTEM RESET ⚠\n\nThis will clear all tracking history, threat scores, and active alerts.\nThe system will revert to default state.")) {
1940
+ return;
1941
+ }
1942
+
1943
+ fetch('/reset_system', { method: 'POST' })
1944
+ .then(r => r.json())
1945
+ .then(data => {
1946
+ if (data.success) {
1947
+ // Reload page to refresh all UI states cleanly
1948
+ window.location.reload();
1949
+ }
1950
+ });
1951
+ }
1952
+
1953
+ function updateJourneyData() {
1954
+ fetch('/journey_data')
1955
+ .then(r => r.json())
1956
+ .then(data => {
1957
+ const container = document.getElementById('journey-container');
1958
+ if (data.length === 0) {
1959
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>SCANNING...</div>';
1960
+ return;
1961
+ }
1962
+
1963
+ let html = '';
1964
+ data.forEach(subject => {
1965
+ const historyHtml = subject.history.map(h => `F${h.feed}`).join(' → ');
1966
+ html += `
1967
+ <div class="audit-entry">
1968
+ <div class="audit-time">${subject.last_seen}</div>
1969
+ <span style="color:var(--cyan)">SUBJ #${subject.id}</span> [FEED ${subject.last_feed}]
1970
+ <div style="font-size: 0.6rem; color: var(--text-dim); margin-top:2px;">${historyHtml}</div>
1971
+ </div>
1972
+ `;
1973
+ });
1974
+ container.innerHTML = html;
1975
+ });
1976
+ }
1977
+
1978
+ // ═══════════════════════════════════════════════════
1979
+ // DISPATCH CENTER
1980
+ // ═══════════════════════════════════════════════════
1981
+
1982
+ function updateDispatchCenter() {
1983
+ fetch('/dispatch_log')
1984
+ .then(r => r.json())
1985
+ .then(data => {
1986
+ // Update status bar
1987
+ const statusBar = document.getElementById('dispatch-status-bar');
1988
+ const statusDot = document.getElementById('dispatch-dot');
1989
+ const statusText = document.getElementById('dispatch-status-text');
1990
+
1991
+ if (data.settings.telegram_configured) {
1992
+ statusBar.className = 'dispatch-status-bar configured';
1993
+ statusDot.className = 'dispatch-dot online';
1994
+ const mode = data.settings.auto_dispatch ? 'AUTO' : 'MANUAL';
1995
+ statusText.textContent = `TELEGRAM ONLINE // ${mode} MODE`;
1996
+ } else {
1997
+ statusBar.className = 'dispatch-status-bar not-configured';
1998
+ statusDot.className = 'dispatch-dot offline';
1999
+ statusText.textContent = 'TELEGRAM NOT CONFIGURED';
2000
+ }
2001
+
2002
+ // Update pending approvals
2003
+ const pendingSection = document.getElementById('pending-section');
2004
+ const pendingContainer = document.getElementById('pending-container');
2005
+ const pendingBadge = document.getElementById('pending-badge');
2006
+
2007
+ if (data.pending && data.pending.length > 0) {
2008
+ pendingSection.style.display = 'block';
2009
+ pendingBadge.textContent = data.pending.length;
2010
+ pendingBadge.classList.add('visible');
2011
+
2012
+ let pendingHtml = '';
2013
+ data.pending.forEach(evt => {
2014
+ const scoreColor = evt.threat_score >= 80 ? 'var(--neon-red)' :
2015
+ evt.threat_score >= 50 ? 'var(--neon-amber)' : 'var(--cyan)';
2016
+ pendingHtml += `
2017
+ <div class="dispatch-entry pending-entry">
2018
+ <div class="dispatch-info">
2019
+ <div class="audit-time">${evt.timestamp}</div>
2020
+ <span class="dispatch-badge pending">PENDING</span>
2021
+ Threat Score: ${evt.threat_score} // ${evt.active_modules.join(', ').toUpperCase()}
2022
+ <div class="dispatch-actions">
2023
+ <button class="btn-approve" onclick="approveDispatch('${evt.id}')">✓ APPROVE</button>
2024
+ <button class="btn-reject" onclick="rejectDispatch('${evt.id}')">✗ REJECT</button>
2025
+ </div>
2026
+ </div>
2027
+ <div class="dispatch-score" style="color: ${scoreColor};">${evt.threat_score}</div>
2028
+ </div>
2029
+ `;
2030
+ });
2031
+ pendingContainer.innerHTML = pendingHtml;
2032
+ } else {
2033
+ pendingSection.style.display = 'none';
2034
+ pendingBadge.classList.remove('visible');
2035
+ }
2036
+
2037
+ // Update dispatch log
2038
+ const logContainer = document.getElementById('dispatch-log-container');
2039
+ if (data.log.length === 0 && (!data.pending || data.pending.length === 0)) {
2040
+ logContainer.innerHTML = '<div class="dispatch-entry"><div class="dispatch-info"><div class="audit-time">--:--:--</div>NO DISPATCH HISTORY</div></div>';
2041
+ } else {
2042
+ let logHtml = '';
2043
+ data.log.slice(0, 15).forEach(evt => {
2044
+ const badgeClass = evt.status;
2045
+ const statusLabel = evt.status.toUpperCase();
2046
+ const scoreColor = evt.threat_score >= 80 ? 'var(--neon-red)' :
2047
+ evt.threat_score >= 50 ? 'var(--neon-amber)' : 'var(--cyan)';
2048
+ logHtml += `
2049
+ <div class="dispatch-entry">
2050
+ <div class="dispatch-info">
2051
+ <div class="audit-time">${evt.timestamp}</div>
2052
+ <span class="dispatch-badge ${badgeClass}">${statusLabel}</span>
2053
+ Score: ${evt.threat_score} // ${(evt.active_modules || []).join(', ').toUpperCase() || 'N/A'}
2054
+ </div>
2055
+ <div class="dispatch-score" style="color: ${scoreColor};">${evt.threat_score}</div>
2056
+ </div>
2057
+ `;
2058
+ });
2059
+ logContainer.innerHTML = logHtml;
2060
+ }
2061
+ })
2062
+ .catch(() => { });
2063
+ }
2064
+
2065
+ function approveDispatch(eventId) {
2066
+ fetch('/approve_dispatch/' + eventId, { method: 'POST' })
2067
+ .then(r => r.json())
2068
+ .then(data => {
2069
+ updateDispatchCenter();
2070
+ if (data.status === 'sent') {
2071
+ showDispatchToast('✅ Alert dispatched via Telegram');
2072
+ } else {
2073
+ showDispatchToast('⚠ Dispatch failed — check Telegram config');
2074
+ }
2075
+ });
2076
+ }
2077
+
2078
+ function rejectDispatch(eventId) {
2079
+ fetch('/reject_dispatch/' + eventId, { method: 'POST' })
2080
+ .then(r => r.json())
2081
+ .then(() => {
2082
+ updateDispatchCenter();
2083
+ showDispatchToast('🔴 Alert rejected');
2084
+ });
2085
+ }
2086
+
2087
+ function manualDispatch() {
2088
+ if (!confirm('📡 DISPATCH ALERT NOW?\n\nThis will send the current threat status to Telegram immediately.')) return;
2089
+ fetch('/dispatch_alert', { method: 'POST' })
2090
+ .then(r => r.json())
2091
+ .then(data => {
2092
+ if (data.success) {
2093
+ showDispatchToast('✅ Alert dispatched via Telegram');
2094
+ } else {
2095
+ showDispatchToast('⚠ ' + (data.error || 'Dispatch failed'));
2096
+ }
2097
+ updateDispatchCenter();
2098
+ })
2099
+ .catch(() => showDispatchToast('⚠ Network error'));
2100
+ }
2101
+
2102
+ function testDispatch() {
2103
+ showDispatchToast('🧪 Sending test message...');
2104
+ fetch('/test_dispatch', { method: 'POST' })
2105
+ .then(r => r.json())
2106
+ .then(data => {
2107
+ if (data.ok) {
2108
+ showDispatchToast('✅ Test message sent to Telegram!');
2109
+ updateDispatchCenter();
2110
+ } else {
2111
+ showDispatchToast('⚠ ' + (data.error || 'Test failed'));
2112
+ }
2113
+ })
2114
+ .catch(() => showDispatchToast('⚠ Connection error'));
2115
+ }
2116
+
2117
+ // ─── Dispatch Settings Modal ───
2118
+ function openDispatchSettings() {
2119
+ const modal = document.getElementById('dispatch-settings-modal');
2120
+ modal.classList.add('show');
2121
+
2122
+ // Load current settings
2123
+ fetch('/dispatch_settings')
2124
+ .then(r => r.json())
2125
+ .then(data => {
2126
+ document.getElementById('cfg-enabled').checked = data.enabled;
2127
+ document.getElementById('cfg-auto-dispatch').checked = data.auto_dispatch;
2128
+ document.getElementById('cfg-cooldown').value = data.cooldown_seconds;
2129
+ document.getElementById('cooldown-display').textContent = data.cooldown_seconds + 's';
2130
+ if (data.chat_id) document.getElementById('cfg-chat-id').value = data.chat_id;
2131
+ // Don't populate bot token for security (show masked version in placeholder)
2132
+ const tokenInput = document.getElementById('cfg-bot-token');
2133
+ if (data.bot_token) tokenInput.placeholder = data.bot_token;
2134
+ });
2135
+ }
2136
+
2137
+ function closeDispatchSettings() {
2138
+ document.getElementById('dispatch-settings-modal').classList.remove('show');
2139
+ }
2140
+
2141
+ function saveDispatchSettings() {
2142
+ fetch('/dispatch_settings', {
2143
+ method: 'POST',
2144
+ headers: { 'Content-Type': 'application/json' },
2145
+ body: JSON.stringify({
2146
+ enabled: document.getElementById('cfg-enabled').checked,
2147
+ auto_dispatch: document.getElementById('cfg-auto-dispatch').checked,
2148
+ cooldown_seconds: parseInt(document.getElementById('cfg-cooldown').value)
2149
+ })
2150
+ }).then(() => updateDispatchCenter());
2151
+ }
2152
+
2153
+ function saveTelegramConfig() {
2154
+ const token = document.getElementById('cfg-bot-token').value.trim();
2155
+ const chatId = document.getElementById('cfg-chat-id').value.trim();
2156
+
2157
+ if (!token && !chatId) {
2158
+ showDispatchToast('⚠ Please enter bot token and/or chat ID');
2159
+ return;
2160
+ }
2161
+
2162
+ const payload = {};
2163
+ if (token) payload.bot_token = token;
2164
+ if (chatId) payload.chat_id = chatId;
2165
+
2166
+ fetch('/dispatch_settings', {
2167
+ method: 'POST',
2168
+ headers: { 'Content-Type': 'application/json' },
2169
+ body: JSON.stringify(payload)
2170
+ })
2171
+ .then(r => r.json())
2172
+ .then(() => {
2173
+ showDispatchToast('✅ Telegram config saved!');
2174
+ updateDispatchCenter();
2175
+ });
2176
+ }
2177
+
2178
+ function autoDetectChatId() {
2179
+ showDispatchToast('🔍 Scanning for chat ID...');
2180
+ // First save any token that was entered
2181
+ const token = document.getElementById('cfg-bot-token').value.trim();
2182
+ const savePromise = token ?
2183
+ fetch('/dispatch_settings', {
2184
+ method: 'POST',
2185
+ headers: { 'Content-Type': 'application/json' },
2186
+ body: JSON.stringify({ bot_token: token })
2187
+ }) : Promise.resolve();
2188
+
2189
+ savePromise.then(() => {
2190
+ return fetch('/test_dispatch', { method: 'POST' });
2191
+ })
2192
+ .then(r => r.json())
2193
+ .then(data => {
2194
+ if (data.ok) {
2195
+ showDispatchToast('✅ Chat ID detected & test sent!');
2196
+ // Reload settings to show detected chat ID
2197
+ fetch('/dispatch_settings')
2198
+ .then(r => r.json())
2199
+ .then(s => {
2200
+ if (s.chat_id) document.getElementById('cfg-chat-id').value = s.chat_id;
2201
+ });
2202
+ } else {
2203
+ showDispatchToast('⚠ Could not detect chat ID. Send /start to the bot from Telegram, then try again.');
2204
+ }
2205
+ })
2206
+ .catch(() => showDispatchToast('⚠ Detection failed'));
2207
+ }
2208
+
2209
+ // ─── Toast Notification ───
2210
+ function showDispatchToast(message) {
2211
+ // Remove existing toast
2212
+ const existing = document.getElementById('dispatch-toast');
2213
+ if (existing) existing.remove();
2214
+
2215
+ const toast = document.createElement('div');
2216
+ toast.id = 'dispatch-toast';
2217
+ toast.textContent = message;
2218
+ toast.style.cssText = `
2219
+ position: fixed;
2220
+ bottom: 24px;
2221
+ right: 24px;
2222
+ background: var(--bg-panel);
2223
+ color: var(--text-primary);
2224
+ font-family: var(--font-mono);
2225
+ font-size: 0.75rem;
2226
+ padding: 12px 20px;
2227
+ border-radius: 8px;
2228
+ border: 1px solid var(--border-glow);
2229
+ box-shadow: 0 4px 30px rgba(0, 200, 255, 0.15);
2230
+ z-index: 12000;
2231
+ animation: fadeIn 0.3s ease;
2232
+ backdrop-filter: blur(10px);
2233
+ `;
2234
+ document.body.appendChild(toast);
2235
+ setTimeout(() => {
2236
+ toast.style.opacity = '0';
2237
+ toast.style.transition = 'opacity 0.3s';
2238
+ setTimeout(() => toast.remove(), 300);
2239
+ }, 3000);
2240
+ }
2241
+
2242
+ // ─── Intervals ───
2243
+ setInterval(updateStats, 1000);
2244
+ setInterval(refreshAuditLog, 5000);
2245
+ setInterval(updateDispatchCenter, 3000);
2246
+
2247
+ // Initial load
2248
+ setTimeout(refreshAuditLog, 1500);
2249
+ setTimeout(updateDispatchCenter, 1000);
2250
+ </script>
2251
+ </body>
2252
+
2253
+ </html>
templates/sentinel_dashboard_v2.html ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL - Security Intelligence Platform</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
9
+ rel="stylesheet">
10
+ <style>
11
+ * {
12
+ margin: 0;
13
+ padding: 0;
14
+ box-sizing: border-box;
15
+ }
16
+
17
+ :root {
18
+ --primary: #007AFF;
19
+ --primary-hover: #0051D5;
20
+ --accent: #FF6B00;
21
+ --bg-primary: #000000;
22
+ --bg-secondary: #0A0A0A;
23
+ --bg-tertiary: #161616;
24
+ --surface: #1C1C1E;
25
+ --surface-elevated: #2C2C2E;
26
+ --text-primary: #FFFFFF;
27
+ --text-secondary: #98989D;
28
+ --text-tertiary: #636366;
29
+ --border: rgba(255, 255, 255, 0.08);
30
+ --border-hover: rgba(255, 255, 255, 0.15);
31
+ --danger: #FF453A;
32
+ --warning: #FF9F0A;
33
+ --success: #30D158;
34
+ --shadow: rgba(0, 0, 0, 0.5);
35
+ }
36
+
37
+ body {
38
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
39
+ background: var(--bg-primary);
40
+ color: var(--text-primary);
41
+ height: 100vh;
42
+ overflow: hidden;
43
+ display: flex;
44
+ -webkit-font-smoothing: antialiased;
45
+ -moz-osx-font-smoothing: grayscale;
46
+ }
47
+
48
+ /* Sidebar */
49
+ .sidebar {
50
+ width: 260px;
51
+ background: var(--bg-secondary);
52
+ border-right: 1px solid var(--border);
53
+ display: flex;
54
+ flex-direction: column;
55
+ padding: 24px 16px;
56
+ }
57
+
58
+ .logo {
59
+ font-size: 20px;
60
+ font-weight: 700;
61
+ margin-bottom: 36px;
62
+ padding: 0 12px;
63
+ letter-spacing: -0.5px;
64
+ color: var(--text-primary);
65
+ }
66
+
67
+ .logo span {
68
+ color: var(--accent);
69
+ font-weight: 800;
70
+ }
71
+
72
+ .nav-section {
73
+ margin-bottom: 24px;
74
+ }
75
+
76
+ .nav-label {
77
+ font-size: 11px;
78
+ font-weight: 600;
79
+ text-transform: uppercase;
80
+ letter-spacing: 0.8px;
81
+ color: var(--text-tertiary);
82
+ padding: 0 12px;
83
+ margin-bottom: 8px;
84
+ }
85
+
86
+ .nav-item {
87
+ padding: 10px 12px;
88
+ margin-bottom: 2px;
89
+ border-radius: 8px;
90
+ cursor: pointer;
91
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
92
+ display: flex;
93
+ align-items: center;
94
+ gap: 12px;
95
+ color: var(--text-secondary);
96
+ font-size: 14px;
97
+ font-weight: 500;
98
+ position: relative;
99
+ }
100
+
101
+ .nav-item:hover {
102
+ background: var(--surface);
103
+ color: var(--text-primary);
104
+ }
105
+
106
+ .nav-item.active {
107
+ background: var(--surface);
108
+ color: var(--primary);
109
+ }
110
+
111
+ .nav-item.active::before {
112
+ content: '';
113
+ position: absolute;
114
+ left: 0;
115
+ top: 50%;
116
+ transform: translateY(-50%);
117
+ width: 3px;
118
+ height: 20px;
119
+ background: var(--primary);
120
+ border-radius: 0 2px 2px 0;
121
+ }
122
+
123
+ .nav-icon {
124
+ width: 20px;
125
+ height: 20px;
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ }
130
+
131
+ .nav-icon svg {
132
+ width: 18px;
133
+ height: 18px;
134
+ stroke: currentColor;
135
+ fill: none;
136
+ stroke-width: 2;
137
+ stroke-linecap: round;
138
+ stroke-linejoin: round;
139
+ }
140
+
141
+ .spacer {
142
+ flex: 1;
143
+ }
144
+
145
+ /* Main Content */
146
+ .main-content {
147
+ flex: 1;
148
+ padding: 24px;
149
+ display: grid;
150
+ grid-template-columns: 1fr 340px;
151
+ gap: 24px;
152
+ overflow: hidden;
153
+ }
154
+
155
+ /* Video Panel */
156
+ .video-container {
157
+ background: var(--bg-secondary);
158
+ border-radius: 12px;
159
+ border: 1px solid var(--border);
160
+ overflow: hidden;
161
+ display: flex;
162
+ flex-direction: column;
163
+ }
164
+
165
+ .panel-header {
166
+ padding: 16px 20px;
167
+ border-bottom: 1px solid var(--border);
168
+ display: flex;
169
+ justify-content: space-between;
170
+ align-items: center;
171
+ background: var(--bg-secondary);
172
+ }
173
+
174
+ .panel-title {
175
+ font-size: 15px;
176
+ font-weight: 600;
177
+ color: var(--text-primary);
178
+ letter-spacing: -0.2px;
179
+ }
180
+
181
+ .status-badge {
182
+ display: flex;
183
+ align-items: center;
184
+ gap: 6px;
185
+ padding: 5px 10px;
186
+ border-radius: 6px;
187
+ font-size: 12px;
188
+ font-weight: 600;
189
+ background: rgba(255, 69, 58, 0.15);
190
+ color: var(--danger);
191
+ }
192
+
193
+ .status-dot {
194
+ width: 6px;
195
+ height: 6px;
196
+ border-radius: 50%;
197
+ background: var(--danger);
198
+ animation: pulse 2s ease-in-out infinite;
199
+ }
200
+
201
+ @keyframes pulse {
202
+ 0%, 100% { opacity: 1; }
203
+ 50% { opacity: 0.4; }
204
+ }
205
+
206
+ .video-wrapper {
207
+ flex: 1;
208
+ background: #000;
209
+ display: flex;
210
+ align-items: center;
211
+ justify-content: center;
212
+ position: relative;
213
+ }
214
+
215
+ #video-stream {
216
+ max-width: 100%;
217
+ max-height: 100%;
218
+ object-fit: contain;
219
+ }
220
+
221
+ /* Sidebar Panel */
222
+ .intel-panel {
223
+ display: flex;
224
+ flex-direction: column;
225
+ gap: 16px;
226
+ overflow-y: auto;
227
+ }
228
+
229
+ .intel-panel::-webkit-scrollbar {
230
+ width: 6px;
231
+ }
232
+
233
+ .intel-panel::-webkit-scrollbar-track {
234
+ background: transparent;
235
+ }
236
+
237
+ .intel-panel::-webkit-scrollbar-thumb {
238
+ background: var(--surface);
239
+ border-radius: 3px;
240
+ }
241
+
242
+ .card {
243
+ background: var(--bg-secondary);
244
+ border-radius: 12px;
245
+ padding: 20px;
246
+ border: 1px solid var(--border);
247
+ }
248
+
249
+ .card-header {
250
+ font-size: 12px;
251
+ font-weight: 600;
252
+ text-transform: uppercase;
253
+ letter-spacing: 0.5px;
254
+ color: var(--text-tertiary);
255
+ margin-bottom: 16px;
256
+ }
257
+
258
+ /* Threat Score */
259
+ .threat-score {
260
+ text-align: center;
261
+ padding: 12px 0;
262
+ }
263
+
264
+ .score-value {
265
+ font-size: 72px;
266
+ font-weight: 700;
267
+ line-height: 1;
268
+ letter-spacing: -2px;
269
+ background: linear-gradient(135deg, var(--success), #00C853);
270
+ -webkit-background-clip: text;
271
+ -webkit-text-fill-color: transparent;
272
+ background-clip: text;
273
+ }
274
+
275
+ .score-value.warning {
276
+ background: linear-gradient(135deg, var(--warning), #FF6D00);
277
+ -webkit-background-clip: text;
278
+ -webkit-text-fill-color: transparent;
279
+ }
280
+
281
+ .score-value.danger {
282
+ background: linear-gradient(135deg, var(--danger), #D50000);
283
+ -webkit-background-clip: text;
284
+ -webkit-text-fill-color: transparent;
285
+ }
286
+
287
+ .score-label {
288
+ margin-top: 8px;
289
+ font-size: 13px;
290
+ font-weight: 600;
291
+ color: var(--text-secondary);
292
+ letter-spacing: 0.5px;
293
+ }
294
+
295
+ /* Stats */
296
+ .stats-grid {
297
+ display: grid;
298
+ grid-template-columns: 1fr 1fr;
299
+ gap: 10px;
300
+ }
301
+
302
+ .stat-item {
303
+ background: var(--surface);
304
+ padding: 14px;
305
+ border-radius: 8px;
306
+ transition: all 0.2s ease;
307
+ }
308
+
309
+ .stat-item:hover {
310
+ background: var(--surface-elevated);
311
+ }
312
+
313
+ .stat-num {
314
+ font-size: 24px;
315
+ font-weight: 700;
316
+ letter-spacing: -0.5px;
317
+ color: var(--text-primary);
318
+ }
319
+
320
+ .stat-desc {
321
+ font-size: 11px;
322
+ font-weight: 500;
323
+ color: var(--text-secondary);
324
+ margin-top: 4px;
325
+ text-transform: uppercase;
326
+ letter-spacing: 0.5px;
327
+ }
328
+
329
+ /* Buttons */
330
+ .btn {
331
+ width: 100%;
332
+ padding: 11px 16px;
333
+ border: none;
334
+ border-radius: 8px;
335
+ font-weight: 600;
336
+ font-size: 14px;
337
+ cursor: pointer;
338
+ font-family: 'Inter', sans-serif;
339
+ transition: all 0.2s ease;
340
+ }
341
+
342
+ .btn-primary {
343
+ background: var(--primary);
344
+ color: white;
345
+ }
346
+
347
+ .btn-primary:hover {
348
+ background: var(--primary-hover);
349
+ transform: translateY(-1px);
350
+ }
351
+
352
+ .btn-primary:active {
353
+ transform: translateY(0);
354
+ }
355
+
356
+ .btn-secondary {
357
+ background: var(--surface);
358
+ color: var(--text-primary);
359
+ border: 1px solid var(--border);
360
+ }
361
+
362
+ .btn-secondary:hover {
363
+ background: var(--surface-elevated);
364
+ border-color: var(--border-hover);
365
+ }
366
+
367
+ .card-description {
368
+ font-size: 13px;
369
+ line-height: 1.5;
370
+ color: var(--text-secondary);
371
+ margin-bottom: 16px;
372
+ }
373
+
374
+ /* Modal */
375
+ .modal {
376
+ display: none;
377
+ position: fixed;
378
+ top: 0;
379
+ left: 0;
380
+ width: 100%;
381
+ height: 100%;
382
+ background: rgba(0, 0, 0, 0.75);
383
+ backdrop-filter: blur(8px);
384
+ z-index: 1000;
385
+ justify-content: center;
386
+ align-items: center;
387
+ }
388
+
389
+ .modal-content {
390
+ background: var(--bg-secondary);
391
+ width: 500px;
392
+ max-width: 90%;
393
+ padding: 28px;
394
+ border-radius: 16px;
395
+ border: 1px solid var(--border);
396
+ box-shadow: 0 20px 60px var(--shadow);
397
+ }
398
+
399
+ .modal-title {
400
+ font-size: 20px;
401
+ font-weight: 700;
402
+ margin-bottom: 20px;
403
+ letter-spacing: -0.5px;
404
+ }
405
+
406
+ .report-text {
407
+ white-space: pre-wrap;
408
+ font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
409
+ font-size: 13px;
410
+ color: var(--success);
411
+ background: var(--surface);
412
+ padding: 16px;
413
+ border-radius: 8px;
414
+ margin-bottom: 20px;
415
+ max-height: 300px;
416
+ overflow-y: auto;
417
+ line-height: 1.6;
418
+ }
419
+
420
+ .report-text::-webkit-scrollbar {
421
+ width: 6px;
422
+ }
423
+
424
+ .report-text::-webkit-scrollbar-track {
425
+ background: transparent;
426
+ }
427
+
428
+ .report-text::-webkit-scrollbar-thumb {
429
+ background: var(--surface-elevated);
430
+ border-radius: 3px;
431
+ }
432
+
433
+ input[type="file"] {
434
+ display: none;
435
+ }
436
+ </style>
437
+ </head>
438
+
439
+ <body>
440
+ <div class="sidebar">
441
+ <div class="logo">PROJECT <span>SENTINEL</span></div>
442
+
443
+ <div class="nav-section">
444
+ <div class="nav-label">Detection Modes</div>
445
+ <div class="nav-item active" onclick="setMode('movement', this)">
446
+ <div class="nav-icon">
447
+ <svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
448
+ </div>
449
+ <span>Movement Analysis</span>
450
+ </div>
451
+ <div class="nav-item" onclick="setMode('facemask', this)">
452
+ <div class="nav-icon">
453
+ <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
454
+ </div>
455
+ <span>Facemask Detection</span>
456
+ </div>
457
+ <div class="nav-item" onclick="setMode('weapon', this)">
458
+ <div class="nav-icon">
459
+ <svg viewBox="0 0 24 24"><path d="M12 2v20M2 12h20"/></svg>
460
+ </div>
461
+ <span>Weapon Detection</span>
462
+ </div>
463
+ </div>
464
+
465
+ <div class="spacer"></div>
466
+
467
+ <div class="nav-section">
468
+ <div class="nav-label">Video Source</div>
469
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
470
+ <div class="nav-icon">
471
+ <svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
472
+ </div>
473
+ <span>Upload Video</span>
474
+ </div>
475
+ <input type="file" id="upload-input" accept="video/*" onchange="handleFileUpload(this)">
476
+ <div class="nav-item" onclick="setSource('camera')">
477
+ <div class="nav-icon">
478
+ <svg viewBox="0 0 24 24"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>
479
+ </div>
480
+ <span>Live Camera</span>
481
+ </div>
482
+ </div>
483
+ </div>
484
+
485
+ <div class="main-content">
486
+ <div class="video-container">
487
+ <div class="panel-header">
488
+ <span class="panel-title" id="mode-title">Movement Analysis Feed</span>
489
+ <div class="status-badge">
490
+ <span class="status-dot"></span>
491
+ <span>LIVE</span>
492
+ </div>
493
+ </div>
494
+ <div class="video-wrapper">
495
+ <img id="video-stream" src="{{ url_for('video_feed') }}" alt="Video Feed">
496
+ </div>
497
+ </div>
498
+
499
+ <div class="intel-panel">
500
+ <div class="card">
501
+ <div class="card-header">Threat Assessment</div>
502
+ <div class="threat-score">
503
+ <div class="score-value" id="threat-score">0</div>
504
+ <div class="score-label" id="threat-label">Low Risk</div>
505
+ </div>
506
+ </div>
507
+
508
+ <div class="card">
509
+ <div class="card-header">Live Intelligence</div>
510
+ <div class="stats-grid" id="stats-container">
511
+ <!-- Populated by JavaScript -->
512
+ </div>
513
+ </div>
514
+
515
+ <div class="card">
516
+ <div class="card-header">Report Generation</div>
517
+ <p class="card-description">
518
+ Generate an automated incident report based on current threat assessment data.
519
+ </p>
520
+ <button class="btn btn-primary" onclick="generateReport()">Generate Report</button>
521
+ </div>
522
+ </div>
523
+ </div>
524
+
525
+ <div id="report-modal" class="modal">
526
+ <div class="modal-content">
527
+ <h2 class="modal-title">Incident Report</h2>
528
+ <div class="report-text" id="report-content">Generating...</div>
529
+ <button class="btn btn-secondary"
530
+ onclick="document.getElementById('report-modal').style.display='none'">Close</button>
531
+ </div>
532
+ </div>
533
+
534
+ <script>
535
+ function setMode(mode, element) {
536
+ // Update UI
537
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
538
+ element.classList.add('active');
539
+
540
+ const titles = {
541
+ 'movement': 'Movement Analysis Feed',
542
+ 'facemask': 'Facemask Detection Feed',
543
+ 'weapon': 'Weapon Detection Feed'
544
+ };
545
+ document.getElementById('mode-title').textContent = titles[mode];
546
+
547
+ // Call Backend
548
+ fetch('/set_mode', {
549
+ method: 'POST',
550
+ headers: { 'Content-Type': 'application/json' },
551
+ body: JSON.stringify({ mode: mode })
552
+ });
553
+ }
554
+
555
+ function setSource(source) {
556
+ fetch('/set_source', {
557
+ method: 'POST',
558
+ headers: { 'Content-Type': 'application/json' },
559
+ body: JSON.stringify({ source: source })
560
+ });
561
+ }
562
+
563
+ function handleFileUpload(input) {
564
+ if (input.files[0]) {
565
+ const formData = new FormData();
566
+ formData.append('file', input.files[0]);
567
+ fetch('/upload_video', { method: 'POST', body: formData });
568
+ }
569
+ }
570
+
571
+ function generateReport() {
572
+ document.getElementById('report-modal').style.display = 'flex';
573
+ document.getElementById('report-content').textContent = "Analyzing data...";
574
+
575
+ fetch('/generate_report', { method: 'POST' })
576
+ .then(r => r.json())
577
+ .then(data => {
578
+ document.getElementById('report-content').textContent = data.report;
579
+ });
580
+ }
581
+
582
+ function updateStats() {
583
+ fetch('/stats')
584
+ .then(r => r.json())
585
+ .then(data => {
586
+ // Update Score
587
+ const score = data.threat_score;
588
+ const scoreEl = document.getElementById('threat-score');
589
+ const labelEl = document.getElementById('threat-label');
590
+
591
+ scoreEl.textContent = score;
592
+ scoreEl.className = 'score-value';
593
+
594
+ if (score > 75) {
595
+ scoreEl.classList.add('danger');
596
+ labelEl.textContent = 'Critical';
597
+ } else if (score > 40) {
598
+ scoreEl.classList.add('warning');
599
+ labelEl.textContent = 'Elevated';
600
+ } else {
601
+ labelEl.textContent = 'Low Risk';
602
+ }
603
+
604
+ // Update Stats Grid
605
+ const container = document.getElementById('stats-container');
606
+ container.innerHTML = '';
607
+
608
+ for (const [key, value] of Object.entries(data.details)) {
609
+ const div = document.createElement('div');
610
+ div.className = 'stat-item';
611
+ const label = key.replace(/_/g, ' ').split(' ').map(w =>
612
+ w.charAt(0).toUpperCase() + w.slice(1)
613
+ ).join(' ');
614
+ div.innerHTML = `<div class="stat-num">${value}</div><div class="stat-desc">${label}</div>`;
615
+ container.appendChild(div);
616
+ }
617
+ });
618
+ }
619
+
620
+ setInterval(updateStats, 1000);
621
+ </script>
622
+ </body>
623
+
624
+ </html>
templates/sentinel_dashboard_v3.html ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Sentinel V3</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <script src="https://unpkg.com/feather-icons"></script>
10
+ <style>
11
+ :root {
12
+ --bg-color: #000000;
13
+ --sidebar-bg: rgba(28, 28, 30, 0.8);
14
+ --card-bg: rgba(28, 28, 30, 0.6);
15
+ --border-color: rgba(255, 255, 255, 0.1);
16
+ --text-primary: #F5F5F7;
17
+ --text-secondary: #86868B;
18
+ --accent-blue: #0A84FF;
19
+ --accent-red: #FF453A;
20
+ --accent-green: #30D158;
21
+ --accent-orange: #FF9F0A;
22
+ }
23
+
24
+ * {
25
+ margin: 0;
26
+ padding: 0;
27
+ box-sizing: border-box;
28
+ -webkit-font-smoothing: antialiased;
29
+ }
30
+
31
+ body {
32
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
33
+ background-color: var(--bg-color);
34
+ color: var(--text-primary);
35
+ height: 100vh;
36
+ overflow: hidden;
37
+ display: flex;
38
+ }
39
+
40
+ /* Sidebar */
41
+ .sidebar {
42
+ width: 260px;
43
+ background-color: var(--sidebar-bg);
44
+ backdrop-filter: blur(20px);
45
+ -webkit-backdrop-filter: blur(20px);
46
+ border-right: 1px solid var(--border-color);
47
+ display: flex;
48
+ flex-direction: column;
49
+ padding: 24px;
50
+ z-index: 10;
51
+ }
52
+
53
+ .logo {
54
+ font-size: 1.2rem;
55
+ font-weight: 600;
56
+ margin-bottom: 40px;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 10px;
60
+ color: var(--text-primary);
61
+ letter-spacing: -0.5px;
62
+ }
63
+
64
+ .nav-group {
65
+ margin-bottom: 30px;
66
+ }
67
+
68
+ .nav-label {
69
+ font-size: 0.75rem;
70
+ font-weight: 600;
71
+ color: var(--text-secondary);
72
+ margin-bottom: 10px;
73
+ text-transform: uppercase;
74
+ letter-spacing: 0.5px;
75
+ }
76
+
77
+ .nav-item {
78
+ padding: 10px 12px;
79
+ margin-bottom: 4px;
80
+ border-radius: 8px;
81
+ cursor: pointer;
82
+ transition: all 0.2s ease;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 12px;
86
+ color: var(--text-secondary);
87
+ font-size: 0.9rem;
88
+ font-weight: 500;
89
+ }
90
+
91
+ .nav-item:hover {
92
+ background-color: rgba(255, 255, 255, 0.05);
93
+ color: var(--text-primary);
94
+ }
95
+
96
+ .nav-item.active {
97
+ background-color: rgba(10, 132, 255, 0.15);
98
+ color: var(--accent-blue);
99
+ }
100
+
101
+ .nav-item svg {
102
+ width: 18px;
103
+ height: 18px;
104
+ }
105
+
106
+ /* Main Content */
107
+ .main-content {
108
+ flex: 1;
109
+ padding: 24px;
110
+ display: grid;
111
+ grid-template-columns: 1fr 360px;
112
+ gap: 24px;
113
+ background: radial-gradient(circle at top right, #1a1a1a 0%, #000000 100%);
114
+ }
115
+
116
+ /* Video Feed */
117
+ .video-container {
118
+ background-color: var(--card-bg);
119
+ border-radius: 18px;
120
+ border: 1px solid var(--border-color);
121
+ overflow: hidden;
122
+ display: flex;
123
+ flex-direction: column;
124
+ position: relative;
125
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
126
+ }
127
+
128
+ .video-header {
129
+ position: absolute;
130
+ top: 20px;
131
+ left: 20px;
132
+ right: 20px;
133
+ display: flex;
134
+ justify-content: space-between;
135
+ align-items: center;
136
+ z-index: 5;
137
+ }
138
+
139
+ .mode-badge {
140
+ background: rgba(0, 0, 0, 0.6);
141
+ backdrop-filter: blur(10px);
142
+ -webkit-backdrop-filter: blur(10px);
143
+ padding: 6px 12px;
144
+ border-radius: 20px;
145
+ font-size: 0.8rem;
146
+ font-weight: 600;
147
+ border: 1px solid rgba(255, 255, 255, 0.1);
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 6px;
151
+ }
152
+
153
+ .live-indicator {
154
+ width: 8px;
155
+ height: 8px;
156
+ background-color: var(--accent-red);
157
+ border-radius: 50%;
158
+ box-shadow: 0 0 10px var(--accent-red);
159
+ animation: pulse 2s infinite;
160
+ }
161
+
162
+ #video-stream {
163
+ width: 100%;
164
+ height: 100%;
165
+ object-fit: contain;
166
+ background: #000;
167
+ }
168
+
169
+ /* Intelligence Panel */
170
+ .intel-panel {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 24px;
174
+ }
175
+
176
+ .card {
177
+ background-color: var(--card-bg);
178
+ backdrop-filter: blur(20px);
179
+ -webkit-backdrop-filter: blur(20px);
180
+ border-radius: 18px;
181
+ padding: 24px;
182
+ border: 1px solid var(--border-color);
183
+ }
184
+
185
+ .card-header {
186
+ display: flex;
187
+ justify-content: space-between;
188
+ align-items: center;
189
+ margin-bottom: 20px;
190
+ }
191
+
192
+ .card-title {
193
+ font-size: 0.95rem;
194
+ font-weight: 600;
195
+ color: var(--text-primary);
196
+ }
197
+
198
+ .threat-meter {
199
+ display: flex;
200
+ flex-direction: column;
201
+ align-items: center;
202
+ padding: 10px 0;
203
+ }
204
+
205
+ .score-circle {
206
+ width: 140px;
207
+ height: 140px;
208
+ border-radius: 50%;
209
+ border: 4px solid rgba(255, 255, 255, 0.1);
210
+ display: flex;
211
+ flex-direction: column;
212
+ justify-content: center;
213
+ align-items: center;
214
+ margin-bottom: 16px;
215
+ position: relative;
216
+ }
217
+
218
+ .score-circle::after {
219
+ content: '';
220
+ position: absolute;
221
+ top: -4px;
222
+ left: -4px;
223
+ right: -4px;
224
+ bottom: -4px;
225
+ border-radius: 50%;
226
+ border: 4px solid var(--accent-green);
227
+ border-top-color: transparent;
228
+ border-left-color: transparent;
229
+ transform: rotate(-45deg);
230
+ transition: all 0.5s ease;
231
+ filter: drop-shadow(0 0 8px rgba(48, 209, 88, 0.3));
232
+ }
233
+
234
+ .score-value {
235
+ font-size: 3rem;
236
+ font-weight: 700;
237
+ line-height: 1;
238
+ letter-spacing: -1px;
239
+ }
240
+
241
+ .score-label {
242
+ font-size: 0.8rem;
243
+ color: var(--text-secondary);
244
+ margin-top: 4px;
245
+ }
246
+
247
+ .status-text {
248
+ font-size: 1.1rem;
249
+ font-weight: 600;
250
+ color: var(--accent-green);
251
+ }
252
+
253
+ .stats-list {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 12px;
257
+ }
258
+
259
+ .stat-row {
260
+ display: flex;
261
+ justify-content: space-between;
262
+ align-items: center;
263
+ padding: 12px;
264
+ background: rgba(255, 255, 255, 0.03);
265
+ border-radius: 12px;
266
+ }
267
+
268
+ .stat-name {
269
+ font-size: 0.9rem;
270
+ color: var(--text-secondary);
271
+ }
272
+
273
+ .stat-val {
274
+ font-size: 1rem;
275
+ font-weight: 600;
276
+ color: var(--text-primary);
277
+ }
278
+
279
+ .btn {
280
+ width: 100%;
281
+ padding: 14px;
282
+ border: none;
283
+ border-radius: 12px;
284
+ font-size: 0.95rem;
285
+ font-weight: 600;
286
+ cursor: pointer;
287
+ transition: all 0.2s;
288
+ font-family: inherit;
289
+ }
290
+
291
+ .btn-primary {
292
+ background-color: var(--accent-blue);
293
+ color: white;
294
+ }
295
+
296
+ .btn-primary:hover {
297
+ background-color: #0071e3;
298
+ transform: scale(1.02);
299
+ }
300
+
301
+ .btn-secondary {
302
+ background-color: rgba(255, 255, 255, 0.1);
303
+ color: var(--text-primary);
304
+ margin-top: 10px;
305
+ }
306
+
307
+ .btn-secondary:hover {
308
+ background-color: rgba(255, 255, 255, 0.15);
309
+ }
310
+
311
+ /* Modal */
312
+ .modal-overlay {
313
+ position: fixed;
314
+ top: 0;
315
+ left: 0;
316
+ right: 0;
317
+ bottom: 0;
318
+ background: rgba(0, 0, 0, 0.7);
319
+ backdrop-filter: blur(5px);
320
+ display: none;
321
+ justify-content: center;
322
+ align-items: center;
323
+ z-index: 1000;
324
+ opacity: 0;
325
+ transition: opacity 0.3s ease;
326
+ }
327
+
328
+ .modal-overlay.show {
329
+ display: flex;
330
+ opacity: 1;
331
+ }
332
+
333
+ .modal-card {
334
+ background: #1c1c1e;
335
+ width: 480px;
336
+ border-radius: 20px;
337
+ padding: 30px;
338
+ border: 1px solid var(--border-color);
339
+ transform: scale(0.95);
340
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
341
+ }
342
+
343
+ .modal-overlay.show .modal-card {
344
+ transform: scale(1);
345
+ }
346
+
347
+ .report-content {
348
+ background: #000;
349
+ padding: 16px;
350
+ border-radius: 12px;
351
+ font-family: 'SF Mono', 'Menlo', monospace;
352
+ font-size: 0.85rem;
353
+ color: var(--accent-green);
354
+ margin: 20px 0;
355
+ line-height: 1.5;
356
+ border: 1px solid var(--border-color);
357
+ }
358
+
359
+ @keyframes pulse {
360
+ 0% {
361
+ opacity: 1;
362
+ transform: scale(1);
363
+ }
364
+
365
+ 50% {
366
+ opacity: 0.5;
367
+ transform: scale(1.2);
368
+ }
369
+
370
+ 100% {
371
+ opacity: 1;
372
+ transform: scale(1);
373
+ }
374
+ }
375
+ </style>
376
+ </head>
377
+
378
+ <body>
379
+ <div class="sidebar">
380
+ <div class="logo">
381
+ <i data-feather="shield"></i>
382
+ SENTINEL V3
383
+ </div>
384
+
385
+ <div class="nav-group">
386
+ <div class="nav-label">Detection Modes</div>
387
+ <div class="nav-item active" onclick="setMode('movement', this)">
388
+ <i data-feather="activity"></i> Movement
389
+ </div>
390
+ <div class="nav-item" onclick="setMode('facemask', this)">
391
+ <i data-feather="user"></i> Facemask
392
+ </div>
393
+ <div class="nav-item" onclick="setMode('weapon', this)">
394
+ <i data-feather="target"></i> Weapon
395
+ </div>
396
+ <div class="nav-item" onclick="setMode('public_safety', this)">
397
+ <i data-feather="users"></i> Public Safety
398
+ </div>
399
+ </div>
400
+
401
+ <div class="nav-group" style="margin-top: auto;">
402
+ <div class="nav-label">Input Source</div>
403
+ <div class="nav-item" onclick="setSource('camera')">
404
+ <i data-feather="camera"></i> Live Camera
405
+ </div>
406
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
407
+ <i data-feather="upload-cloud"></i> Upload Video
408
+ </div>
409
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
410
+ onchange="handleFileUpload(this)">
411
+ </div>
412
+ </div>
413
+
414
+ <div class="main-content">
415
+ <div class="video-container">
416
+ <div class="video-header">
417
+ <div class="mode-badge">
418
+ <div class="live-indicator"></div>
419
+ <span id="mode-title">MOVEMENT ANALYSIS</span>
420
+ </div>
421
+ </div>
422
+ <img id="video-stream" src="{{ url_for('video_feed') }}">
423
+ </div>
424
+
425
+ <div class="intel-panel">
426
+ <div class="card">
427
+ <div class="card-header">
428
+ <div class="card-title">Threat Assessment</div>
429
+ <i data-feather="alert-circle" style="width: 18px; color: var(--text-secondary);"></i>
430
+ </div>
431
+ <div class="threat-meter">
432
+ <div class="score-circle" id="score-circle">
433
+ <div class="score-value" id="threat-score">0</div>
434
+ <div class="score-label">RISK LEVEL</div>
435
+ </div>
436
+ <div class="status-text" id="status-text">SECURE</div>
437
+ </div>
438
+ </div>
439
+
440
+ <div class="card">
441
+ <div class="card-header">
442
+ <div class="card-title">Live Metrics</div>
443
+ <i data-feather="bar-chart-2" style="width: 18px; color: var(--text-secondary);"></i>
444
+ </div>
445
+ <div class="stats-list" id="stats-container">
446
+ <!-- Populated by JS -->
447
+ </div>
448
+ </div>
449
+
450
+ <div class="card">
451
+ <div class="card-header">
452
+ <div class="card-title">Actions</div>
453
+ </div>
454
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+
459
+ <div id="report-modal" class="modal-overlay">
460
+ <div class="modal-card">
461
+ <h2 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 10px;">Incident Report</h2>
462
+ <p style="color: var(--text-secondary); font-size: 0.9rem;">Generated by Sentinel AI Agent</p>
463
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
464
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
465
+ </div>
466
+ </div>
467
+
468
+ <script>
469
+ feather.replace();
470
+
471
+ function setMode(mode, element) {
472
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
473
+ element.classList.add('active');
474
+
475
+ const titles = {
476
+ 'movement': 'MOVEMENT ANALYSIS',
477
+ 'facemask': 'FACEMASK DETECTION',
478
+ 'weapon': 'WEAPON DETECTION',
479
+ 'public_safety': 'PUBLIC SAFETY MONITORING'
480
+ };
481
+ document.getElementById('mode-title').textContent = titles[mode];
482
+
483
+ fetch('/set_mode', {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify({ mode: mode })
487
+ });
488
+ }
489
+
490
+ function setSource(source) {
491
+ fetch('/set_source', {
492
+ method: 'POST',
493
+ headers: { 'Content-Type': 'application/json' },
494
+ body: JSON.stringify({ source: source })
495
+ });
496
+ }
497
+
498
+ function handleFileUpload(input) {
499
+ if (input.files[0]) {
500
+ const formData = new FormData();
501
+ formData.append('file', input.files[0]);
502
+ fetch('/upload_video', { method: 'POST', body: formData });
503
+ }
504
+ }
505
+
506
+ function generateReport() {
507
+ const modal = document.getElementById('report-modal');
508
+ modal.classList.add('show');
509
+ document.getElementById('report-content').textContent = "Analyzing data stream...";
510
+
511
+ fetch('/generate_report', { method: 'POST' })
512
+ .then(r => r.json())
513
+ .then(data => {
514
+ document.getElementById('report-content').textContent = data.report;
515
+ });
516
+ }
517
+
518
+ function closeModal() {
519
+ document.getElementById('report-modal').classList.remove('show');
520
+ }
521
+
522
+ function updateStats() {
523
+ fetch('/stats')
524
+ .then(r => r.json())
525
+ .then(data => {
526
+ const score = data.threat_score;
527
+ const scoreEl = document.getElementById('threat-score');
528
+ const statusEl = document.getElementById('status-text');
529
+ const circle = document.getElementById('score-circle');
530
+
531
+ scoreEl.textContent = score;
532
+
533
+ let color = '#30D158'; // Green
534
+ let status = 'SECURE';
535
+
536
+ if (score > 75) {
537
+ color = '#FF453A'; // Red
538
+ status = 'CRITICAL THREAT';
539
+ } else if (score > 40) {
540
+ color = '#FF9F0A'; // Orange
541
+ status = 'ELEVATED RISK';
542
+ }
543
+
544
+ statusEl.textContent = status;
545
+ statusEl.style.color = color;
546
+ circle.style.setProperty('--accent-green', color); // Hack to update pseudo-element color via variable if I used one, but here I need to update the style rule or use a variable.
547
+ // Actually, let's fix the CSS to use a variable for the border color
548
+ // The CSS uses var(--accent-green) for the pseudo element. So I can just set that variable on the element.
549
+ circle.style.setProperty('--accent-green', color);
550
+
551
+ const container = document.getElementById('stats-container');
552
+ container.innerHTML = '';
553
+
554
+ if (Object.keys(data.details).length === 0) {
555
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
556
+ } else {
557
+ for (const [key, value] of Object.entries(data.details)) {
558
+ const div = document.createElement('div');
559
+ div.className = 'stat-row';
560
+ // Format key: replace underscores with spaces and capitalize words
561
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
562
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${value}</span>`;
563
+ container.appendChild(div);
564
+ }
565
+ }
566
+ });
567
+ }
568
+
569
+ setInterval(updateStats, 1000);
570
+ </script>
571
+ </body>
572
+
573
+ </html>
templates/sentinel_dashboard_v4.html ADDED
@@ -0,0 +1,573 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Sentinel V4</title>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
+ <script src="https://unpkg.com/feather-icons"></script>
10
+ <style>
11
+ :root {
12
+ --bg-color: #000000;
13
+ --sidebar-bg: rgba(28, 28, 30, 0.8);
14
+ --card-bg: rgba(28, 28, 30, 0.6);
15
+ --border-color: rgba(255, 255, 255, 0.1);
16
+ --text-primary: #F5F5F7;
17
+ --text-secondary: #86868B;
18
+ --accent-blue: #0A84FF;
19
+ --accent-red: #FF453A;
20
+ --accent-green: #30D158;
21
+ --accent-orange: #FF9F0A;
22
+ }
23
+
24
+ * {
25
+ margin: 0;
26
+ padding: 0;
27
+ box-sizing: border-box;
28
+ -webkit-font-smoothing: antialiased;
29
+ }
30
+
31
+ body {
32
+ font-family: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
33
+ background-color: var(--bg-color);
34
+ color: var(--text-primary);
35
+ height: 100vh;
36
+ overflow: hidden;
37
+ display: flex;
38
+ }
39
+
40
+ /* Sidebar */
41
+ .sidebar {
42
+ width: 260px;
43
+ background-color: var(--sidebar-bg);
44
+ backdrop-filter: blur(20px);
45
+ -webkit-backdrop-filter: blur(20px);
46
+ border-right: 1px solid var(--border-color);
47
+ display: flex;
48
+ flex-direction: column;
49
+ padding: 24px;
50
+ z-index: 10;
51
+ }
52
+
53
+ .logo {
54
+ font-size: 1.2rem;
55
+ font-weight: 600;
56
+ margin-bottom: 40px;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 10px;
60
+ color: var(--text-primary);
61
+ letter-spacing: -0.5px;
62
+ }
63
+
64
+ .nav-group {
65
+ margin-bottom: 30px;
66
+ }
67
+
68
+ .nav-label {
69
+ font-size: 0.75rem;
70
+ font-weight: 600;
71
+ color: var(--text-secondary);
72
+ margin-bottom: 10px;
73
+ text-transform: uppercase;
74
+ letter-spacing: 0.5px;
75
+ }
76
+
77
+ .nav-item {
78
+ padding: 10px 12px;
79
+ margin-bottom: 4px;
80
+ border-radius: 8px;
81
+ cursor: pointer;
82
+ transition: all 0.2s ease;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 12px;
86
+ color: var(--text-secondary);
87
+ font-size: 0.9rem;
88
+ font-weight: 500;
89
+ }
90
+
91
+ .nav-item:hover {
92
+ background-color: rgba(255, 255, 255, 0.05);
93
+ color: var(--text-primary);
94
+ }
95
+
96
+ .nav-item.active {
97
+ background-color: rgba(10, 132, 255, 0.15);
98
+ color: var(--accent-blue);
99
+ }
100
+
101
+ .nav-item svg {
102
+ width: 18px;
103
+ height: 18px;
104
+ }
105
+
106
+ /* Main Content */
107
+ .main-content {
108
+ flex: 1;
109
+ padding: 24px;
110
+ display: grid;
111
+ grid-template-columns: 1fr 360px;
112
+ gap: 24px;
113
+ background: radial-gradient(circle at top right, #1a1a1a 0%, #000000 100%);
114
+ }
115
+
116
+ /* Video Feed */
117
+ .video-container {
118
+ background-color: var(--card-bg);
119
+ border-radius: 18px;
120
+ border: 1px solid var(--border-color);
121
+ overflow: hidden;
122
+ display: flex;
123
+ flex-direction: column;
124
+ position: relative;
125
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
126
+ }
127
+
128
+ .video-header {
129
+ position: absolute;
130
+ top: 20px;
131
+ left: 20px;
132
+ right: 20px;
133
+ display: flex;
134
+ justify-content: space-between;
135
+ align-items: center;
136
+ z-index: 5;
137
+ }
138
+
139
+ .mode-badge {
140
+ background: rgba(0, 0, 0, 0.6);
141
+ backdrop-filter: blur(10px);
142
+ -webkit-backdrop-filter: blur(10px);
143
+ padding: 6px 12px;
144
+ border-radius: 20px;
145
+ font-size: 0.8rem;
146
+ font-weight: 600;
147
+ border: 1px solid rgba(255, 255, 255, 0.1);
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 6px;
151
+ }
152
+
153
+ .live-indicator {
154
+ width: 8px;
155
+ height: 8px;
156
+ background-color: var(--accent-red);
157
+ border-radius: 50%;
158
+ box-shadow: 0 0 10px var(--accent-red);
159
+ animation: pulse 2s infinite;
160
+ }
161
+
162
+ #video-stream {
163
+ width: 100%;
164
+ height: 100%;
165
+ object-fit: contain;
166
+ background: #000;
167
+ }
168
+
169
+ /* Intelligence Panel */
170
+ .intel-panel {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 24px;
174
+ }
175
+
176
+ .card {
177
+ background-color: var(--card-bg);
178
+ backdrop-filter: blur(20px);
179
+ -webkit-backdrop-filter: blur(20px);
180
+ border-radius: 18px;
181
+ padding: 24px;
182
+ border: 1px solid var(--border-color);
183
+ }
184
+
185
+ .card-header {
186
+ display: flex;
187
+ justify-content: space-between;
188
+ align-items: center;
189
+ margin-bottom: 20px;
190
+ }
191
+
192
+ .card-title {
193
+ font-size: 0.95rem;
194
+ font-weight: 600;
195
+ color: var(--text-primary);
196
+ }
197
+
198
+ .threat-meter {
199
+ display: flex;
200
+ flex-direction: column;
201
+ align-items: center;
202
+ padding: 10px 0;
203
+ }
204
+
205
+ .score-circle {
206
+ width: 140px;
207
+ height: 140px;
208
+ border-radius: 50%;
209
+ border: 4px solid rgba(255, 255, 255, 0.1);
210
+ display: flex;
211
+ flex-direction: column;
212
+ justify-content: center;
213
+ align-items: center;
214
+ margin-bottom: 16px;
215
+ position: relative;
216
+ }
217
+
218
+ .score-circle::after {
219
+ content: '';
220
+ position: absolute;
221
+ top: -4px;
222
+ left: -4px;
223
+ right: -4px;
224
+ bottom: -4px;
225
+ border-radius: 50%;
226
+ border: 4px solid var(--accent-green);
227
+ border-top-color: transparent;
228
+ border-left-color: transparent;
229
+ transform: rotate(-45deg);
230
+ transition: all 0.5s ease;
231
+ filter: drop-shadow(0 0 8px rgba(48, 209, 88, 0.3));
232
+ }
233
+
234
+ .score-value {
235
+ font-size: 3rem;
236
+ font-weight: 700;
237
+ line-height: 1;
238
+ letter-spacing: -1px;
239
+ }
240
+
241
+ .score-label {
242
+ font-size: 0.8rem;
243
+ color: var(--text-secondary);
244
+ margin-top: 4px;
245
+ }
246
+
247
+ .status-text {
248
+ font-size: 1.1rem;
249
+ font-weight: 600;
250
+ color: var(--accent-green);
251
+ }
252
+
253
+ .stats-list {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: 12px;
257
+ }
258
+
259
+ .stat-row {
260
+ display: flex;
261
+ justify-content: space-between;
262
+ align-items: center;
263
+ padding: 12px;
264
+ background: rgba(255, 255, 255, 0.03);
265
+ border-radius: 12px;
266
+ }
267
+
268
+ .stat-name {
269
+ font-size: 0.9rem;
270
+ color: var(--text-secondary);
271
+ }
272
+
273
+ .stat-val {
274
+ font-size: 1rem;
275
+ font-weight: 600;
276
+ color: var(--text-primary);
277
+ }
278
+
279
+ .btn {
280
+ width: 100%;
281
+ padding: 14px;
282
+ border: none;
283
+ border-radius: 12px;
284
+ font-size: 0.95rem;
285
+ font-weight: 600;
286
+ cursor: pointer;
287
+ transition: all 0.2s;
288
+ font-family: inherit;
289
+ }
290
+
291
+ .btn-primary {
292
+ background-color: var(--accent-blue);
293
+ color: white;
294
+ }
295
+
296
+ .btn-primary:hover {
297
+ background-color: #0071e3;
298
+ transform: scale(1.02);
299
+ }
300
+
301
+ .btn-secondary {
302
+ background-color: rgba(255, 255, 255, 0.1);
303
+ color: var(--text-primary);
304
+ margin-top: 10px;
305
+ }
306
+
307
+ .btn-secondary:hover {
308
+ background-color: rgba(255, 255, 255, 0.15);
309
+ }
310
+
311
+ /* Modal */
312
+ .modal-overlay {
313
+ position: fixed;
314
+ top: 0;
315
+ left: 0;
316
+ right: 0;
317
+ bottom: 0;
318
+ background: rgba(0, 0, 0, 0.7);
319
+ backdrop-filter: blur(5px);
320
+ display: none;
321
+ justify-content: center;
322
+ align-items: center;
323
+ z-index: 1000;
324
+ opacity: 0;
325
+ transition: opacity 0.3s ease;
326
+ }
327
+
328
+ .modal-overlay.show {
329
+ display: flex;
330
+ opacity: 1;
331
+ }
332
+
333
+ .modal-card {
334
+ background: #1c1c1e;
335
+ width: 480px;
336
+ border-radius: 20px;
337
+ padding: 30px;
338
+ border: 1px solid var(--border-color);
339
+ transform: scale(0.95);
340
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
341
+ }
342
+
343
+ .modal-overlay.show .modal-card {
344
+ transform: scale(1);
345
+ }
346
+
347
+ .report-content {
348
+ background: #000;
349
+ padding: 16px;
350
+ border-radius: 12px;
351
+ font-family: 'SF Mono', 'Menlo', monospace;
352
+ font-size: 0.85rem;
353
+ color: var(--accent-green);
354
+ margin: 20px 0;
355
+ line-height: 1.5;
356
+ border: 1px solid var(--border-color);
357
+ }
358
+
359
+ @keyframes pulse {
360
+ 0% {
361
+ opacity: 1;
362
+ transform: scale(1);
363
+ }
364
+
365
+ 50% {
366
+ opacity: 0.5;
367
+ transform: scale(1.2);
368
+ }
369
+
370
+ 100% {
371
+ opacity: 1;
372
+ transform: scale(1);
373
+ }
374
+ }
375
+ </style>
376
+ </head>
377
+
378
+ <body>
379
+ <div class="sidebar">
380
+ <div class="logo">
381
+ <i data-feather="shield"></i>
382
+ SENTINEL V4
383
+ </div>
384
+
385
+ <div class="nav-group">
386
+ <div class="nav-label">Detection Modes</div>
387
+ <div class="nav-item active" onclick="setMode('movement', this)">
388
+ <i data-feather="activity"></i> Movement
389
+ </div>
390
+ <div class="nav-item" onclick="setMode('facemask', this)">
391
+ <i data-feather="user"></i> Facemask
392
+ </div>
393
+ <div class="nav-item" onclick="setMode('weapon', this)">
394
+ <i data-feather="target"></i> Weapon
395
+ </div>
396
+ <div class="nav-item" onclick="setMode('public_safety', this)">
397
+ <i data-feather="users"></i> Public Safety
398
+ </div>
399
+ </div>
400
+
401
+ <div class="nav-group" style="margin-top: auto;">
402
+ <div class="nav-label">Input Source</div>
403
+ <div class="nav-item" onclick="setSource('camera')">
404
+ <i data-feather="camera"></i> Live Camera
405
+ </div>
406
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
407
+ <i data-feather="upload-cloud"></i> Upload Video
408
+ </div>
409
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
410
+ onchange="handleFileUpload(this)">
411
+ </div>
412
+ </div>
413
+
414
+ <div class="main-content">
415
+ <div class="video-container">
416
+ <div class="video-header">
417
+ <div class="mode-badge">
418
+ <div class="live-indicator"></div>
419
+ <span id="mode-title">MOVEMENT ANALYSIS</span>
420
+ </div>
421
+ </div>
422
+ <img id="video-stream" src="{{ url_for('video_feed') }}">
423
+ </div>
424
+
425
+ <div class="intel-panel">
426
+ <div class="card">
427
+ <div class="card-header">
428
+ <div class="card-title">Threat Assessment</div>
429
+ <i data-feather="alert-circle" style="width: 18px; color: var(--text-secondary);"></i>
430
+ </div>
431
+ <div class="threat-meter">
432
+ <div class="score-circle" id="score-circle">
433
+ <div class="score-value" id="threat-score">0</div>
434
+ <div class="score-label">RISK LEVEL</div>
435
+ </div>
436
+ <div class="status-text" id="status-text">SECURE</div>
437
+ </div>
438
+ </div>
439
+
440
+ <div class="card">
441
+ <div class="card-header">
442
+ <div class="card-title">Live Metrics</div>
443
+ <i data-feather="bar-chart-2" style="width: 18px; color: var(--text-secondary);"></i>
444
+ </div>
445
+ <div class="stats-list" id="stats-container">
446
+ <!-- Populated by JS -->
447
+ </div>
448
+ </div>
449
+
450
+ <div class="card">
451
+ <div class="card-header">
452
+ <div class="card-title">Actions</div>
453
+ </div>
454
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+
459
+ <div id="report-modal" class="modal-overlay">
460
+ <div class="modal-card">
461
+ <h2 style="font-size: 1.5rem; font-weight: 700; margin-bottom: 10px;">Incident Report</h2>
462
+ <p style="color: var(--text-secondary); font-size: 0.9rem;">Generated by Sentinel AI Agent</p>
463
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
464
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
465
+ </div>
466
+ </div>
467
+
468
+ <script>
469
+ feather.replace();
470
+
471
+ function setMode(mode, element) {
472
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
473
+ element.classList.add('active');
474
+
475
+ const titles = {
476
+ 'movement': 'MOVEMENT ANALYSIS',
477
+ 'facemask': 'FACEMASK DETECTION',
478
+ 'weapon': 'WEAPON DETECTION',
479
+ 'public_safety': 'PUBLIC SAFETY MONITORING'
480
+ };
481
+ document.getElementById('mode-title').textContent = titles[mode];
482
+
483
+ fetch('/set_mode', {
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify({ mode: mode })
487
+ });
488
+ }
489
+
490
+ function setSource(source) {
491
+ fetch('/set_source', {
492
+ method: 'POST',
493
+ headers: { 'Content-Type': 'application/json' },
494
+ body: JSON.stringify({ source: source })
495
+ });
496
+ }
497
+
498
+ function handleFileUpload(input) {
499
+ if (input.files[0]) {
500
+ const formData = new FormData();
501
+ formData.append('file', input.files[0]);
502
+ fetch('/upload_video', { method: 'POST', body: formData });
503
+ }
504
+ }
505
+
506
+ function generateReport() {
507
+ const modal = document.getElementById('report-modal');
508
+ modal.classList.add('show');
509
+ document.getElementById('report-content').textContent = "Analyzing data stream...";
510
+
511
+ fetch('/generate_report', { method: 'POST' })
512
+ .then(r => r.json())
513
+ .then(data => {
514
+ document.getElementById('report-content').textContent = data.report;
515
+ });
516
+ }
517
+
518
+ function closeModal() {
519
+ document.getElementById('report-modal').classList.remove('show');
520
+ }
521
+
522
+ function updateStats() {
523
+ fetch('/stats')
524
+ .then(r => r.json())
525
+ .then(data => {
526
+ const score = data.threat_score;
527
+ const scoreEl = document.getElementById('threat-score');
528
+ const statusEl = document.getElementById('status-text');
529
+ const circle = document.getElementById('score-circle');
530
+
531
+ scoreEl.textContent = score;
532
+
533
+ let color = '#30D158'; // Green
534
+ let status = 'SECURE';
535
+
536
+ if (score > 75) {
537
+ color = '#FF453A'; // Red
538
+ status = 'CRITICAL THREAT';
539
+ } else if (score > 40) {
540
+ color = '#FF9F0A'; // Orange
541
+ status = 'ELEVATED RISK';
542
+ }
543
+
544
+ statusEl.textContent = status;
545
+ statusEl.style.color = color;
546
+ circle.style.setProperty('--accent-green', color); // Hack to update pseudo-element color via variable if I used one, but here I need to update the style rule or use a variable.
547
+ // Actually, let's fix the CSS to use a variable for the border color
548
+ // The CSS uses var(--accent-green) for the pseudo element. So I can just set that variable on the element.
549
+ circle.style.setProperty('--accent-green', color);
550
+
551
+ const container = document.getElementById('stats-container');
552
+ container.innerHTML = '';
553
+
554
+ if (Object.keys(data.details).length === 0) {
555
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
556
+ } else {
557
+ for (const [key, value] of Object.entries(data.details)) {
558
+ const div = document.createElement('div');
559
+ div.className = 'stat-row';
560
+ // Format key: replace underscores with spaces and capitalize words
561
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
562
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${value}</span>`;
563
+ container.appendChild(div);
564
+ }
565
+ }
566
+ });
567
+ }
568
+
569
+ setInterval(updateStats, 1000);
570
+ </script>
571
+ </body>
572
+
573
+ </html>
templates/sentinel_dashboard_v7.html ADDED
@@ -0,0 +1,1386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V7 — Intelligence Grid</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ═══════════════════════════════════════════════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+ </style>
882
+ </head>
883
+
884
+ <body>
885
+ <!-- Red Alert Overlay -->
886
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
887
+ <div class="red-alert-banner" id="red-alert-banner">⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠</div>
888
+
889
+ <!-- SIDEBAR -->
890
+ <div class="sidebar">
891
+ <div class="logo">
892
+ <i data-feather="shield"></i>
893
+ SENTINEL
894
+ </div>
895
+ <div class="version-tag">V7.0 // MULTI-CAMERA GRID</div>
896
+
897
+ <div class="nav-group">
898
+ <div class="nav-label">Detection Modules</div>
899
+ <div class="nav-item active" onclick="setMode('movement', this)" id="nav-movement">
900
+ <i data-feather="activity"></i> Movement
901
+ </div>
902
+ <div class="nav-item" onclick="setMode('facemask', this)" id="nav-facemask">
903
+ <i data-feather="eye"></i> Facemask
904
+ </div>
905
+ <div class="nav-item" onclick="setMode('weapon', this)" id="nav-weapon">
906
+ <i data-feather="crosshair"></i> Weapon
907
+ </div>
908
+ <div class="nav-item" onclick="setMode('public_safety', this)" id="nav-public_safety">
909
+ <i data-feather="users"></i> Public Safety
910
+ </div>
911
+ </div>
912
+
913
+ <div class="nav-group">
914
+ <div class="nav-label">Input Source</div>
915
+ <div class="nav-item" onclick="setSource('camera')">
916
+ <i data-feather="video"></i> Live Camera
917
+ </div>
918
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
919
+ <i data-feather="upload-cloud"></i> Upload Video
920
+ </div>
921
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
922
+ onchange="handleFileUpload(this)">
923
+ </div>
924
+
925
+ <div class="nav-group">
926
+ <div class="nav-label">Grid Layout</div>
927
+ <div class="nav-item" onclick="setGridLayout('quad')">
928
+ <i data-feather="grid"></i> 2×2 Grid
929
+ </div>
930
+ <div class="nav-item" onclick="setGridLayout('single')">
931
+ <i data-feather="maximize-2"></i> Single View
932
+ </div>
933
+ </div>
934
+
935
+ <!-- Audit Log -->
936
+ <div class="audit-section">
937
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
938
+ <div class="audit-log-container" id="audit-log-container">
939
+ <div class="audit-entry">
940
+ <div class="audit-time">--:--:--</div>
941
+ SYSTEM STANDBY
942
+ </div>
943
+ </div>
944
+ </div>
945
+ </div>
946
+
947
+ <!-- MAIN CONTENT -->
948
+ <div class="main-content">
949
+ <!-- Multi-Camera Grid -->
950
+ <div class="camera-grid" id="camera-grid">
951
+ <!-- Feed 0 — Primary -->
952
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
953
+ <div class="feed-header">
954
+ <div class="feed-badge">
955
+ <div class="live-dot"></div>
956
+ <span>FEED 01 // PRIMARY</span>
957
+ </div>
958
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
959
+ </div>
960
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
961
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
962
+ <i data-feather="maximize-2"></i>
963
+ </button>
964
+ </div>
965
+
966
+ <!-- Feed 1 -->
967
+ <div class="feed-cell" id="feed-1">
968
+ <div class="feed-header">
969
+ <div class="feed-badge">
970
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
971
+ <span>FEED 02</span>
972
+ </div>
973
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
974
+ </div>
975
+ <div class="feed-offline" id="offline-1">
976
+ <i data-feather="video-off"></i>
977
+ NO SIGNAL
978
+ </div>
979
+ </div>
980
+
981
+ <!-- Feed 2 -->
982
+ <div class="feed-cell" id="feed-2">
983
+ <div class="feed-header">
984
+ <div class="feed-badge">
985
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
986
+ <span>FEED 03</span>
987
+ </div>
988
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
989
+ </div>
990
+ <div class="feed-offline" id="offline-2">
991
+ <i data-feather="video-off"></i>
992
+ NO SIGNAL
993
+ </div>
994
+ </div>
995
+
996
+ <!-- Feed 3 -->
997
+ <div class="feed-cell" id="feed-3">
998
+ <div class="feed-header">
999
+ <div class="feed-badge">
1000
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1001
+ <span>FEED 04</span>
1002
+ </div>
1003
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1004
+ </div>
1005
+ <div class="feed-offline" id="offline-3">
1006
+ <i data-feather="video-off"></i>
1007
+ NO SIGNAL
1008
+ </div>
1009
+ </div>
1010
+ </div>
1011
+
1012
+ <!-- Intel Panel -->
1013
+ <div class="intel-panel">
1014
+ <!-- Active Mode -->
1015
+ <div class="card">
1016
+ <div class="card-header">
1017
+ <div class="card-title">Active Mode</div>
1018
+ </div>
1019
+ <div class="mode-indicator">
1020
+ <div class="mode-dot"></div>
1021
+ <div class="mode-name" id="mode-title">MOVEMENT ANALYSIS</div>
1022
+ </div>
1023
+ </div>
1024
+
1025
+ <!-- Threat Assessment -->
1026
+ <div class="card" id="threat-card">
1027
+ <div class="card-header">
1028
+ <div class="card-title">Threat Assessment</div>
1029
+ <i data-feather="alert-triangle" class="card-icon"></i>
1030
+ </div>
1031
+ <div class="threat-gauge">
1032
+ <div class="score-ring">
1033
+ <svg viewBox="0 0 130 130">
1034
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1035
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1036
+ </svg>
1037
+ <div class="score-text">
1038
+ <div class="score-value" id="threat-score">0</div>
1039
+ <div class="score-label">THREAT LEVEL</div>
1040
+ </div>
1041
+ </div>
1042
+ <div class="status-text" id="status-text">SECURE</div>
1043
+ </div>
1044
+ </div>
1045
+
1046
+ <!-- Live Metrics -->
1047
+ <div class="card">
1048
+ <div class="card-header">
1049
+ <div class="card-title">Live Metrics</div>
1050
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1051
+ </div>
1052
+ <div class="stats-list" id="stats-container">
1053
+ <div class="stat-row">
1054
+ <span class="stat-name">System Status</span>
1055
+ <span class="stat-val">Initializing</span>
1056
+ </div>
1057
+ </div>
1058
+ </div>
1059
+
1060
+ <!-- Actions -->
1061
+ <div class="card">
1062
+ <div class="card-header">
1063
+ <div class="card-title">Actions</div>
1064
+ </div>
1065
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1066
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1067
+ Log</button>
1068
+ </div>
1069
+ </div>
1070
+ </div>
1071
+
1072
+ <!-- Report Modal -->
1073
+ <div id="report-modal" class="modal-overlay">
1074
+ <div class="modal-card">
1075
+ <div class="modal-title">Incident Report</div>
1076
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1077
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1078
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1079
+ </div>
1080
+ </div>
1081
+
1082
+ <!-- ═══════════════════════════════════════════════
1083
+ JAVASCRIPT
1084
+ ═══════════════════════════════════════════════ -->
1085
+ <script>
1086
+ feather.replace();
1087
+
1088
+ // ─── State ───
1089
+ let currentLayout = 'quad'; // 'quad' or 'single'
1090
+ let expandedFeed = 0;
1091
+ let isRedAlert = false;
1092
+
1093
+ // ─── Mode Switching ───
1094
+ const modeTitles = {
1095
+ 'movement': 'MOVEMENT ANALYSIS',
1096
+ 'facemask': 'FACEMASK DETECTION',
1097
+ 'weapon': 'WEAPON DETECTION',
1098
+ 'public_safety': 'PUBLIC SAFETY'
1099
+ };
1100
+
1101
+ function setMode(mode, element) {
1102
+ // Check if already active
1103
+ const isActive = element.classList.contains('active');
1104
+ let targetMode = mode;
1105
+
1106
+ if (isActive) {
1107
+ // Toggle off -> Standby
1108
+ targetMode = 'standby';
1109
+ element.classList.remove('active');
1110
+ document.getElementById('mode-title').textContent = 'SYSTEM STANDBY';
1111
+ } else {
1112
+ // Activate new mode
1113
+ document.querySelectorAll('.nav-group:first-of-type .nav-item').forEach(el => el.classList.remove('active'));
1114
+ element.classList.add('active');
1115
+ document.getElementById('mode-title').textContent = modeTitles[mode] || mode.toUpperCase();
1116
+ }
1117
+
1118
+ fetch('/set_mode', {
1119
+ method: 'POST',
1120
+ headers: { 'Content-Type': 'application/json' },
1121
+ body: JSON.stringify({ mode: targetMode })
1122
+ });
1123
+ }
1124
+
1125
+ function setSource(source) {
1126
+ fetch('/set_source', {
1127
+ method: 'POST',
1128
+ headers: { 'Content-Type': 'application/json' },
1129
+ body: JSON.stringify({ source: source })
1130
+ })
1131
+ .then(r => r.json())
1132
+ .then(data => {
1133
+ if (data.success) {
1134
+ // Force the browser to reconnect to the restarted MJPEG stream
1135
+ refreshFeedStream(0);
1136
+ }
1137
+ });
1138
+ }
1139
+
1140
+ function handleFileUpload(input) {
1141
+ if (input.files[0]) {
1142
+ const formData = new FormData();
1143
+ formData.append('file', input.files[0]);
1144
+
1145
+ // Show uploading state
1146
+ const statusEl = document.getElementById('feed-0-status');
1147
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1148
+
1149
+ fetch('/upload_video', { method: 'POST', body: formData })
1150
+ .then(r => r.json())
1151
+ .then(data => {
1152
+ if (data.success) {
1153
+ // Force the browser to reconnect to the restarted MJPEG stream
1154
+ refreshFeedStream(0);
1155
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1156
+ }
1157
+ })
1158
+ .catch(() => {
1159
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1160
+ });
1161
+
1162
+ // Reset file input so the same file can be re-uploaded
1163
+ input.value = '';
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1169
+ * This drops the old HTTP connection and establishes a fresh one.
1170
+ */
1171
+ function refreshFeedStream(feedId) {
1172
+ const img = document.getElementById('stream-' + feedId);
1173
+ if (img) {
1174
+ // Brief blank to visually signal the switch
1175
+ img.src = '';
1176
+ // Small delay lets the backend fully initialize the new feed
1177
+ setTimeout(() => {
1178
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1179
+ }, 300);
1180
+ }
1181
+ }
1182
+
1183
+ // ─── Grid Layout ───
1184
+ function setGridLayout(layout) {
1185
+ const grid = document.getElementById('camera-grid');
1186
+ currentLayout = layout;
1187
+
1188
+ if (layout === 'single') {
1189
+ grid.classList.add('single-view');
1190
+ // Show only the expanded feed
1191
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1192
+ cell.classList.toggle('expanded', i === expandedFeed);
1193
+ });
1194
+ } else {
1195
+ grid.classList.remove('single-view');
1196
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1197
+ cell.classList.remove('expanded');
1198
+ });
1199
+ }
1200
+ }
1201
+
1202
+ function expandFeed(feedId) {
1203
+ expandedFeed = feedId;
1204
+ if (currentLayout === 'single') {
1205
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1206
+ cell.classList.toggle('expanded', i === feedId);
1207
+ });
1208
+ }
1209
+ }
1210
+
1211
+ // ─── Stats & Red Alert Updates ───
1212
+ function updateStats() {
1213
+ fetch('/stats')
1214
+ .then(r => r.json())
1215
+ .then(data => {
1216
+ const score = data.threat_score;
1217
+ const scoreEl = document.getElementById('threat-score');
1218
+ const statusEl = document.getElementById('status-text');
1219
+ const ringFill = document.getElementById('score-ring-fill');
1220
+ const threatCard = document.getElementById('threat-card');
1221
+
1222
+ scoreEl.textContent = score;
1223
+
1224
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1225
+ const circumference = 377;
1226
+ const offset = circumference - (circumference * score / 100);
1227
+ ringFill.style.strokeDashoffset = offset;
1228
+
1229
+ // Color based on score
1230
+ let color, status, glow;
1231
+ if (score >= 80) {
1232
+ color = '#ff2040';
1233
+ status = 'CRITICAL';
1234
+ glow = 'rgba(255, 32, 64, 0.4)';
1235
+ } else if (score >= 50) {
1236
+ color = '#ffaa00';
1237
+ status = 'ELEVATED';
1238
+ glow = 'rgba(255, 170, 0, 0.3)';
1239
+ } else if (score >= 25) {
1240
+ color = '#00d4ff';
1241
+ status = 'GUARDED';
1242
+ glow = 'rgba(0, 200, 255, 0.3)';
1243
+ } else {
1244
+ color = '#00ff88';
1245
+ status = 'SECURE';
1246
+ glow = 'rgba(0, 255, 136, 0.3)';
1247
+ }
1248
+
1249
+ statusEl.textContent = status;
1250
+ statusEl.style.color = color;
1251
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1252
+ ringFill.style.stroke = color;
1253
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1254
+
1255
+ // Red Alert state
1256
+ const alertOverlay = document.getElementById('red-alert-overlay');
1257
+ const alertBanner = document.getElementById('red-alert-banner');
1258
+ const feedCells = document.querySelectorAll('.feed-cell');
1259
+
1260
+ if (data.red_alert) {
1261
+ alertOverlay.classList.add('active');
1262
+ alertBanner.classList.add('active');
1263
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1264
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1265
+
1266
+ if (!isRedAlert) {
1267
+ playAlertTone();
1268
+ isRedAlert = true;
1269
+ }
1270
+ } else {
1271
+ alertOverlay.classList.remove('active');
1272
+ alertBanner.classList.remove('active');
1273
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1274
+ threatCard.style.borderColor = '';
1275
+ isRedAlert = false;
1276
+ }
1277
+
1278
+ // Update mode display
1279
+ if (data.mode) {
1280
+ document.getElementById('mode-title').textContent = modeTitles[data.mode] || data.mode.toUpperCase();
1281
+ }
1282
+
1283
+ // Update live metrics
1284
+ const container = document.getElementById('stats-container');
1285
+ container.innerHTML = '';
1286
+
1287
+ if (!data.details || Object.keys(data.details).length === 0) {
1288
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1289
+ } else {
1290
+ for (const [key, value] of Object.entries(data.details)) {
1291
+ const div = document.createElement('div');
1292
+ div.className = 'stat-row';
1293
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1294
+ let displayVal = value;
1295
+ if (typeof value === 'boolean') {
1296
+ displayVal = value ? '⚠ YES' : 'No';
1297
+ }
1298
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1299
+ container.appendChild(div);
1300
+ }
1301
+ }
1302
+ })
1303
+ .catch(() => { });
1304
+ }
1305
+
1306
+ // ─── Alert Tone (Web Audio API) ───
1307
+ function playAlertTone() {
1308
+ try {
1309
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1310
+ const oscillator = audioCtx.createOscillator();
1311
+ const gainNode = audioCtx.createGain();
1312
+
1313
+ oscillator.connect(gainNode);
1314
+ gainNode.connect(audioCtx.destination);
1315
+
1316
+ oscillator.type = 'square';
1317
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1318
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1319
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1320
+
1321
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1322
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1323
+
1324
+ oscillator.start(audioCtx.currentTime);
1325
+ oscillator.stop(audioCtx.currentTime + 0.5);
1326
+ } catch (e) {
1327
+ // Audio not available — silent fallback
1328
+ }
1329
+ }
1330
+
1331
+ // ─── AI Report ───
1332
+ function generateReport() {
1333
+ const modal = document.getElementById('report-modal');
1334
+ modal.classList.add('show');
1335
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1336
+
1337
+ fetch('/generate_report', { method: 'POST' })
1338
+ .then(r => r.json())
1339
+ .then(data => {
1340
+ document.getElementById('report-content').textContent = data.report;
1341
+ })
1342
+ .catch(() => {
1343
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1344
+ });
1345
+ }
1346
+
1347
+ function closeModal() {
1348
+ document.getElementById('report-modal').classList.remove('show');
1349
+ }
1350
+
1351
+ // ─── Audit Log Refresh ───
1352
+ function refreshAuditLog() {
1353
+ fetch('/audit_log')
1354
+ .then(r => r.json())
1355
+ .then(data => {
1356
+ const container = document.getElementById('audit-log-container');
1357
+ container.innerHTML = '';
1358
+
1359
+ if (data.log.length === 0) {
1360
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1361
+ return;
1362
+ }
1363
+
1364
+ data.log.slice(0, 30).forEach(entry => {
1365
+ const div = document.createElement('div');
1366
+ div.className = `audit-entry severity-${entry.severity}`;
1367
+ div.innerHTML = `
1368
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1369
+ ${entry.action}: ${entry.details}
1370
+ `;
1371
+ container.appendChild(div);
1372
+ });
1373
+ })
1374
+ .catch(() => { });
1375
+ }
1376
+
1377
+ // ─── Intervals ───
1378
+ setInterval(updateStats, 1000);
1379
+ setInterval(refreshAuditLog, 5000);
1380
+
1381
+ // Initial load
1382
+ setTimeout(refreshAuditLog, 1500);
1383
+ </script>
1384
+ </body>
1385
+
1386
+ </html>
templates/sentinel_dashboard_v9.html ADDED
@@ -0,0 +1,1385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>SENTINEL V9 — Intelligence Grid</title>
8
+ <link
9
+ href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=Rajdhani:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap"
10
+ rel="stylesheet">
11
+ <script src="https://unpkg.com/feather-icons"></script>
12
+ <style>
13
+ /* ═══════════════════════════════════════════════
14
+ CSS VARIABLES — SCI-FI PALETTE
15
+ ═══════════════════════════════════════════════ */
16
+ :root {
17
+ --bg-deep: #05080f;
18
+ --bg-panel: rgba(8, 16, 32, 0.85);
19
+ --bg-card: rgba(10, 20, 40, 0.7);
20
+ --border-glow: rgba(0, 200, 255, 0.15);
21
+ --border-glow-hover: rgba(0, 200, 255, 0.4);
22
+ --cyan: #00d4ff;
23
+ --cyan-dim: #0090aa;
24
+ --neon-green: #00ff88;
25
+ --neon-red: #ff2040;
26
+ --neon-amber: #ffaa00;
27
+ --text-primary: #e0f0ff;
28
+ --text-secondary: #5a8aaa;
29
+ --text-dim: #2a4a5a;
30
+ --scanline-opacity: 0.03;
31
+ --font-display: 'Orbitron', sans-serif;
32
+ --font-body: 'Rajdhani', sans-serif;
33
+ --font-mono: 'Share Tech Mono', monospace;
34
+ }
35
+
36
+ /* ═══════════════════════════════════════════════
37
+ BASE RESET & BODY
38
+ ═══════════════════════════════════════════════ */
39
+ *,
40
+ *::before,
41
+ *::after {
42
+ margin: 0;
43
+ padding: 0;
44
+ box-sizing: border-box;
45
+ }
46
+
47
+ body {
48
+ font-family: var(--font-body);
49
+ background: var(--bg-deep);
50
+ color: var(--text-primary);
51
+ height: 100vh;
52
+ overflow: hidden;
53
+ display: flex;
54
+ }
55
+
56
+ /* Scanline overlay on body */
57
+ body::after {
58
+ content: '';
59
+ position: fixed;
60
+ top: 0;
61
+ left: 0;
62
+ right: 0;
63
+ bottom: 0;
64
+ background: repeating-linear-gradient(0deg,
65
+ transparent,
66
+ transparent 2px,
67
+ rgba(0, 200, 255, var(--scanline-opacity)) 2px,
68
+ rgba(0, 200, 255, var(--scanline-opacity)) 4px);
69
+ pointer-events: none;
70
+ z-index: 9999;
71
+ }
72
+
73
+ /* ═══════════════════════════════════════════════
74
+ RED ALERT OVERLAY
75
+ ═══════════════════════════════════════════════ */
76
+ .red-alert-overlay {
77
+ position: fixed;
78
+ top: 0;
79
+ left: 0;
80
+ right: 0;
81
+ bottom: 0;
82
+ pointer-events: none;
83
+ z-index: 9998;
84
+ opacity: 0;
85
+ transition: opacity 0.3s ease;
86
+ }
87
+
88
+ .red-alert-overlay.active {
89
+ opacity: 1;
90
+ animation: red-pulse-border 1s ease-in-out infinite;
91
+ }
92
+
93
+ .red-alert-overlay.active::before {
94
+ content: '';
95
+ position: absolute;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ border: 4px solid var(--neon-red);
101
+ box-shadow: inset 0 0 80px rgba(255, 32, 64, 0.15),
102
+ 0 0 40px rgba(255, 32, 64, 0.3);
103
+ }
104
+
105
+ .red-alert-banner {
106
+ position: fixed;
107
+ top: 0;
108
+ left: 50%;
109
+ transform: translateX(-50%);
110
+ background: rgba(255, 20, 40, 0.9);
111
+ color: #fff;
112
+ font-family: var(--font-display);
113
+ font-size: 0.85rem;
114
+ font-weight: 700;
115
+ letter-spacing: 4px;
116
+ padding: 8px 40px;
117
+ z-index: 10000;
118
+ border-bottom-left-radius: 8px;
119
+ border-bottom-right-radius: 8px;
120
+ box-shadow: 0 4px 30px rgba(255, 20, 40, 0.5);
121
+ display: none;
122
+ animation: alert-flash 0.8s ease-in-out infinite;
123
+ }
124
+
125
+ .red-alert-banner.active {
126
+ display: block;
127
+ }
128
+
129
+ @keyframes red-pulse-border {
130
+
131
+ 0%,
132
+ 100% {
133
+ opacity: 0.6;
134
+ }
135
+
136
+ 50% {
137
+ opacity: 1;
138
+ }
139
+ }
140
+
141
+ @keyframes alert-flash {
142
+
143
+ 0%,
144
+ 100% {
145
+ opacity: 1;
146
+ }
147
+
148
+ 50% {
149
+ opacity: 0.6;
150
+ }
151
+ }
152
+
153
+ /* ═══════════════════════════════════════════════
154
+ SIDEBAR
155
+ ═══════════════════════════════════════════════ */
156
+ .sidebar {
157
+ width: 260px;
158
+ min-width: 260px;
159
+ background: var(--bg-panel);
160
+ backdrop-filter: blur(20px);
161
+ -webkit-backdrop-filter: blur(20px);
162
+ border-right: 1px solid var(--border-glow);
163
+ display: flex;
164
+ flex-direction: column;
165
+ padding: 20px;
166
+ z-index: 100;
167
+ overflow-y: auto;
168
+ }
169
+
170
+ .logo {
171
+ font-family: var(--font-display);
172
+ font-size: 1rem;
173
+ font-weight: 700;
174
+ margin-bottom: 8px;
175
+ display: flex;
176
+ align-items: center;
177
+ gap: 10px;
178
+ color: var(--cyan);
179
+ letter-spacing: 2px;
180
+ }
181
+
182
+ .logo svg {
183
+ filter: drop-shadow(0 0 6px var(--cyan));
184
+ }
185
+
186
+ .version-tag {
187
+ font-family: var(--font-mono);
188
+ font-size: 0.65rem;
189
+ color: var(--text-dim);
190
+ margin-bottom: 24px;
191
+ letter-spacing: 1px;
192
+ }
193
+
194
+ .nav-group {
195
+ margin-bottom: 20px;
196
+ }
197
+
198
+ .nav-label {
199
+ font-family: var(--font-display);
200
+ font-size: 0.6rem;
201
+ font-weight: 600;
202
+ color: var(--text-dim);
203
+ margin-bottom: 8px;
204
+ text-transform: uppercase;
205
+ letter-spacing: 2px;
206
+ }
207
+
208
+ .nav-item {
209
+ padding: 10px 12px;
210
+ margin-bottom: 3px;
211
+ border-radius: 6px;
212
+ cursor: pointer;
213
+ transition: all 0.25s ease;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 10px;
217
+ color: var(--text-secondary);
218
+ font-size: 0.9rem;
219
+ font-weight: 500;
220
+ border: 1px solid transparent;
221
+ }
222
+
223
+ .nav-item:hover {
224
+ background: rgba(0, 200, 255, 0.05);
225
+ color: var(--text-primary);
226
+ border-color: var(--border-glow);
227
+ }
228
+
229
+ .nav-item.active {
230
+ background: rgba(0, 200, 255, 0.1);
231
+ color: var(--cyan);
232
+ border-color: rgba(0, 200, 255, 0.3);
233
+ box-shadow: 0 0 15px rgba(0, 200, 255, 0.05);
234
+ }
235
+
236
+ .nav-item svg {
237
+ width: 16px;
238
+ height: 16px;
239
+ }
240
+
241
+ /* Audit Log Panel in sidebar */
242
+ .audit-section {
243
+ margin-top: auto;
244
+ flex-shrink: 0;
245
+ }
246
+
247
+ .audit-log-container {
248
+ max-height: 200px;
249
+ overflow-y: auto;
250
+ scrollbar-width: thin;
251
+ scrollbar-color: var(--cyan-dim) transparent;
252
+ }
253
+
254
+ .audit-log-container::-webkit-scrollbar {
255
+ width: 4px;
256
+ }
257
+
258
+ .audit-log-container::-webkit-scrollbar-track {
259
+ background: transparent;
260
+ }
261
+
262
+ .audit-log-container::-webkit-scrollbar-thumb {
263
+ background: var(--cyan-dim);
264
+ border-radius: 2px;
265
+ }
266
+
267
+ .audit-entry {
268
+ font-family: var(--font-mono);
269
+ font-size: 0.65rem;
270
+ padding: 6px 8px;
271
+ margin-bottom: 2px;
272
+ border-radius: 4px;
273
+ background: rgba(0, 0, 0, 0.3);
274
+ border-left: 2px solid var(--cyan-dim);
275
+ line-height: 1.4;
276
+ color: var(--text-secondary);
277
+ }
278
+
279
+ .audit-entry.severity-WARNING {
280
+ border-left-color: var(--neon-amber);
281
+ }
282
+
283
+ .audit-entry.severity-CRITICAL {
284
+ border-left-color: var(--neon-red);
285
+ color: var(--neon-red);
286
+ }
287
+
288
+ .audit-time {
289
+ color: var(--text-dim);
290
+ font-size: 0.6rem;
291
+ }
292
+
293
+ /* ═══════════════════════════════════════════════
294
+ MAIN CONTENT AREA
295
+ ═══════════════════════════════════════════════ */
296
+ .main-content {
297
+ flex: 1;
298
+ padding: 16px;
299
+ display: grid;
300
+ grid-template-columns: 1fr 320px;
301
+ gap: 16px;
302
+ overflow: hidden;
303
+ background: radial-gradient(ellipse at 20% 0%, rgba(0, 60, 100, 0.15) 0%, transparent 60%),
304
+ radial-gradient(ellipse at 80% 100%, rgba(0, 40, 80, 0.1) 0%, transparent 60%),
305
+ var(--bg-deep);
306
+ }
307
+
308
+ /* ═══════════════════════════════════════════════
309
+ MULTI-CAMERA GRID
310
+ ═══════════════════════════════════════════════ */
311
+ .camera-grid {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ grid-template-rows: 1fr 1fr;
315
+ gap: 8px;
316
+ height: 100%;
317
+ }
318
+
319
+ .camera-grid.single-view {
320
+ grid-template-columns: 1fr;
321
+ grid-template-rows: 1fr;
322
+ }
323
+
324
+ .camera-grid.single-view .feed-cell:not(.expanded) {
325
+ display: none;
326
+ }
327
+
328
+ .feed-cell {
329
+ background: var(--bg-card);
330
+ border-radius: 10px;
331
+ border: 1px solid var(--border-glow);
332
+ overflow: hidden;
333
+ position: relative;
334
+ cursor: pointer;
335
+ transition: all 0.3s ease;
336
+ }
337
+
338
+ .feed-cell:hover {
339
+ border-color: var(--border-glow-hover);
340
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.1);
341
+ }
342
+
343
+ .feed-cell.red-alert-active {
344
+ border-color: var(--neon-red) !important;
345
+ box-shadow: 0 0 30px rgba(255, 32, 64, 0.3) !important;
346
+ animation: cell-red-pulse 1.5s ease-in-out infinite;
347
+ }
348
+
349
+ @keyframes cell-red-pulse {
350
+
351
+ 0%,
352
+ 100% {
353
+ box-shadow: 0 0 20px rgba(255, 32, 64, 0.2);
354
+ }
355
+
356
+ 50% {
357
+ box-shadow: 0 0 40px rgba(255, 32, 64, 0.5);
358
+ }
359
+ }
360
+
361
+ .feed-header {
362
+ position: absolute;
363
+ top: 8px;
364
+ left: 10px;
365
+ right: 10px;
366
+ display: flex;
367
+ justify-content: space-between;
368
+ align-items: center;
369
+ z-index: 5;
370
+ pointer-events: none;
371
+ }
372
+
373
+ .feed-badge {
374
+ background: rgba(0, 0, 0, 0.7);
375
+ backdrop-filter: blur(8px);
376
+ padding: 4px 10px;
377
+ border-radius: 4px;
378
+ font-family: var(--font-mono);
379
+ font-size: 0.65rem;
380
+ font-weight: 400;
381
+ border: 1px solid var(--border-glow);
382
+ display: flex;
383
+ align-items: center;
384
+ gap: 6px;
385
+ color: var(--cyan);
386
+ letter-spacing: 1px;
387
+ }
388
+
389
+ .live-dot {
390
+ width: 6px;
391
+ height: 6px;
392
+ background: var(--neon-red);
393
+ border-radius: 50%;
394
+ box-shadow: 0 0 8px var(--neon-red);
395
+ animation: pulse-dot 2s infinite;
396
+ }
397
+
398
+ @keyframes pulse-dot {
399
+
400
+ 0%,
401
+ 100% {
402
+ opacity: 1;
403
+ transform: scale(1);
404
+ }
405
+
406
+ 50% {
407
+ opacity: 0.4;
408
+ transform: scale(1.3);
409
+ }
410
+ }
411
+
412
+ .feed-status {
413
+ font-family: var(--font-mono);
414
+ font-size: 0.6rem;
415
+ color: var(--neon-green);
416
+ background: rgba(0, 0, 0, 0.6);
417
+ padding: 3px 8px;
418
+ border-radius: 4px;
419
+ }
420
+
421
+ .feed-stream {
422
+ width: 100%;
423
+ height: 100%;
424
+ object-fit: contain;
425
+ background: #000;
426
+ display: block;
427
+ }
428
+
429
+ .feed-offline {
430
+ display: flex;
431
+ flex-direction: column;
432
+ align-items: center;
433
+ justify-content: center;
434
+ height: 100%;
435
+ color: var(--text-dim);
436
+ font-family: var(--font-mono);
437
+ font-size: 0.75rem;
438
+ letter-spacing: 1px;
439
+ gap: 8px;
440
+ }
441
+
442
+ .feed-offline svg {
443
+ width: 24px;
444
+ height: 24px;
445
+ color: var(--text-dim);
446
+ }
447
+
448
+ /* Fullscreen expand button */
449
+ .expand-btn {
450
+ position: absolute;
451
+ bottom: 8px;
452
+ right: 8px;
453
+ background: rgba(0, 0, 0, 0.6);
454
+ border: 1px solid var(--border-glow);
455
+ color: var(--cyan);
456
+ width: 28px;
457
+ height: 28px;
458
+ border-radius: 4px;
459
+ cursor: pointer;
460
+ display: flex;
461
+ align-items: center;
462
+ justify-content: center;
463
+ transition: all 0.2s;
464
+ z-index: 5;
465
+ pointer-events: all;
466
+ }
467
+
468
+ .expand-btn:hover {
469
+ background: rgba(0, 200, 255, 0.15);
470
+ border-color: var(--cyan);
471
+ }
472
+
473
+ .expand-btn svg {
474
+ width: 14px;
475
+ height: 14px;
476
+ }
477
+
478
+ /* ═══════════════════════════════════════════════
479
+ INTEL PANEL (Right Column)
480
+ ═══════════════════════════════════════════════ */
481
+ .intel-panel {
482
+ display: flex;
483
+ flex-direction: column;
484
+ gap: 12px;
485
+ overflow-y: auto;
486
+ scrollbar-width: thin;
487
+ scrollbar-color: var(--cyan-dim) transparent;
488
+ }
489
+
490
+ .intel-panel::-webkit-scrollbar {
491
+ width: 4px;
492
+ }
493
+
494
+ .intel-panel::-webkit-scrollbar-thumb {
495
+ background: var(--cyan-dim);
496
+ border-radius: 2px;
497
+ }
498
+
499
+ .card {
500
+ background: var(--bg-card);
501
+ backdrop-filter: blur(15px);
502
+ border-radius: 10px;
503
+ padding: 18px;
504
+ border: 1px solid var(--border-glow);
505
+ transition: border-color 0.3s;
506
+ }
507
+
508
+ .card:hover {
509
+ border-color: var(--border-glow-hover);
510
+ }
511
+
512
+ .card-header {
513
+ display: flex;
514
+ justify-content: space-between;
515
+ align-items: center;
516
+ margin-bottom: 14px;
517
+ }
518
+
519
+ .card-title {
520
+ font-family: var(--font-display);
521
+ font-size: 0.65rem;
522
+ font-weight: 600;
523
+ color: var(--cyan);
524
+ text-transform: uppercase;
525
+ letter-spacing: 2px;
526
+ }
527
+
528
+ .card-icon {
529
+ color: var(--text-dim);
530
+ width: 16px;
531
+ height: 16px;
532
+ }
533
+
534
+ /* Threat gauge */
535
+ .threat-gauge {
536
+ display: flex;
537
+ flex-direction: column;
538
+ align-items: center;
539
+ padding: 10px 0;
540
+ }
541
+
542
+ .score-ring {
543
+ position: relative;
544
+ width: 130px;
545
+ height: 130px;
546
+ margin-bottom: 12px;
547
+ }
548
+
549
+ .score-ring svg {
550
+ width: 130px;
551
+ height: 130px;
552
+ transform: rotate(-90deg);
553
+ }
554
+
555
+ .score-ring-bg {
556
+ fill: none;
557
+ stroke: rgba(255, 255, 255, 0.05);
558
+ stroke-width: 6;
559
+ }
560
+
561
+ .score-ring-fill {
562
+ fill: none;
563
+ stroke: var(--neon-green);
564
+ stroke-width: 6;
565
+ stroke-linecap: round;
566
+ stroke-dasharray: 377;
567
+ stroke-dashoffset: 377;
568
+ transition: stroke-dashoffset 0.8s cubic-bezier(0.4, 0, 0.2, 1),
569
+ stroke 0.5s ease;
570
+ filter: drop-shadow(0 0 8px var(--neon-green));
571
+ }
572
+
573
+ .score-text {
574
+ position: absolute;
575
+ top: 50%;
576
+ left: 50%;
577
+ transform: translate(-50%, -50%);
578
+ text-align: center;
579
+ }
580
+
581
+ .score-value {
582
+ font-family: var(--font-display);
583
+ font-size: 2.2rem;
584
+ font-weight: 800;
585
+ line-height: 1;
586
+ color: var(--text-primary);
587
+ }
588
+
589
+ .score-label {
590
+ font-family: var(--font-mono);
591
+ font-size: 0.55rem;
592
+ color: var(--text-dim);
593
+ letter-spacing: 2px;
594
+ margin-top: 4px;
595
+ }
596
+
597
+ .status-text {
598
+ font-family: var(--font-display);
599
+ font-size: 0.85rem;
600
+ font-weight: 700;
601
+ color: var(--neon-green);
602
+ letter-spacing: 3px;
603
+ text-shadow: 0 0 20px rgba(0, 255, 136, 0.3);
604
+ transition: all 0.5s ease;
605
+ }
606
+
607
+ /* Stats */
608
+ .stats-list {
609
+ display: flex;
610
+ flex-direction: column;
611
+ gap: 6px;
612
+ }
613
+
614
+ .stat-row {
615
+ display: flex;
616
+ justify-content: space-between;
617
+ align-items: center;
618
+ padding: 10px 12px;
619
+ background: rgba(0, 200, 255, 0.03);
620
+ border-radius: 6px;
621
+ border: 1px solid rgba(0, 200, 255, 0.05);
622
+ }
623
+
624
+ .stat-name {
625
+ font-size: 0.8rem;
626
+ color: var(--text-secondary);
627
+ font-weight: 500;
628
+ }
629
+
630
+ .stat-val {
631
+ font-family: var(--font-mono);
632
+ font-size: 0.85rem;
633
+ font-weight: 600;
634
+ color: var(--text-primary);
635
+ }
636
+
637
+ /* Mode indicator */
638
+ .mode-indicator {
639
+ display: flex;
640
+ align-items: center;
641
+ gap: 8px;
642
+ padding: 10px 14px;
643
+ background: rgba(0, 200, 255, 0.05);
644
+ border-radius: 6px;
645
+ border: 1px solid var(--border-glow);
646
+ }
647
+
648
+ .mode-dot {
649
+ width: 8px;
650
+ height: 8px;
651
+ background: var(--cyan);
652
+ border-radius: 50%;
653
+ box-shadow: 0 0 10px var(--cyan);
654
+ }
655
+
656
+ .mode-name {
657
+ font-family: var(--font-display);
658
+ font-size: 0.7rem;
659
+ font-weight: 600;
660
+ letter-spacing: 2px;
661
+ color: var(--cyan);
662
+ }
663
+
664
+ /* Buttons */
665
+ .btn {
666
+ width: 100%;
667
+ padding: 12px;
668
+ border: 1px solid var(--border-glow);
669
+ border-radius: 6px;
670
+ font-size: 0.8rem;
671
+ font-weight: 600;
672
+ cursor: pointer;
673
+ transition: all 0.25s;
674
+ font-family: var(--font-display);
675
+ letter-spacing: 1px;
676
+ text-transform: uppercase;
677
+ }
678
+
679
+ .btn-primary {
680
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.2), rgba(0, 100, 200, 0.1));
681
+ color: var(--cyan);
682
+ border-color: rgba(0, 200, 255, 0.3);
683
+ }
684
+
685
+ .btn-primary:hover {
686
+ background: linear-gradient(135deg, rgba(0, 200, 255, 0.3), rgba(0, 100, 200, 0.2));
687
+ box-shadow: 0 0 20px rgba(0, 200, 255, 0.15);
688
+ transform: translateY(-1px);
689
+ }
690
+
691
+ .btn-secondary {
692
+ background: rgba(255, 255, 255, 0.03);
693
+ color: var(--text-secondary);
694
+ }
695
+
696
+ .btn-secondary:hover {
697
+ background: rgba(255, 255, 255, 0.08);
698
+ color: var(--text-primary);
699
+ }
700
+
701
+ /* ═══════════════════════════════════════════════
702
+ MODAL
703
+ ═══════════════════════════════════════════════ */
704
+ .modal-overlay {
705
+ position: fixed;
706
+ top: 0;
707
+ left: 0;
708
+ right: 0;
709
+ bottom: 0;
710
+ background: rgba(0, 0, 0, 0.8);
711
+ backdrop-filter: blur(10px);
712
+ display: none;
713
+ justify-content: center;
714
+ align-items: center;
715
+ z-index: 11000;
716
+ opacity: 0;
717
+ transition: opacity 0.3s ease;
718
+ }
719
+
720
+ .modal-overlay.show {
721
+ display: flex;
722
+ opacity: 1;
723
+ }
724
+
725
+ .modal-card {
726
+ background: var(--bg-panel);
727
+ width: 520px;
728
+ max-width: 90vw;
729
+ border-radius: 12px;
730
+ padding: 28px;
731
+ border: 1px solid var(--border-glow);
732
+ box-shadow: 0 0 60px rgba(0, 200, 255, 0.1);
733
+ transform: scale(0.95);
734
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
735
+ }
736
+
737
+ .modal-overlay.show .modal-card {
738
+ transform: scale(1);
739
+ }
740
+
741
+ .modal-title {
742
+ font-family: var(--font-display);
743
+ font-size: 1rem;
744
+ font-weight: 700;
745
+ color: var(--cyan);
746
+ letter-spacing: 2px;
747
+ margin-bottom: 6px;
748
+ }
749
+
750
+ .modal-subtitle {
751
+ font-family: var(--font-mono);
752
+ font-size: 0.7rem;
753
+ color: var(--text-dim);
754
+ margin-bottom: 16px;
755
+ }
756
+
757
+ .report-content {
758
+ background: rgba(0, 0, 0, 0.5);
759
+ padding: 16px;
760
+ border-radius: 8px;
761
+ font-family: var(--font-mono);
762
+ font-size: 0.75rem;
763
+ color: var(--neon-green);
764
+ margin-bottom: 16px;
765
+ line-height: 1.6;
766
+ border: 1px solid var(--border-glow);
767
+ max-height: 300px;
768
+ overflow-y: auto;
769
+ white-space: pre-wrap;
770
+ }
771
+
772
+ /* ═══════════════════════════════════════════════
773
+ MOBILE RESPONSIVE
774
+ ═══════════════════════════════════════════════ */
775
+ @media (max-width: 1024px) {
776
+ .main-content {
777
+ grid-template-columns: 1fr 280px;
778
+ }
779
+ }
780
+
781
+ @media (max-width: 768px) {
782
+ body {
783
+ flex-direction: column;
784
+ overflow-y: auto;
785
+ }
786
+
787
+ .sidebar {
788
+ width: 100%;
789
+ min-width: 100%;
790
+ flex-direction: row;
791
+ flex-wrap: wrap;
792
+ padding: 10px 16px;
793
+ gap: 6px;
794
+ order: 2;
795
+ border-right: none;
796
+ border-top: 1px solid var(--border-glow);
797
+ overflow-y: visible;
798
+ }
799
+
800
+ .logo {
801
+ width: 100%;
802
+ margin-bottom: 6px;
803
+ font-size: 0.85rem;
804
+ }
805
+
806
+ .version-tag {
807
+ display: none;
808
+ }
809
+
810
+ .nav-group {
811
+ display: flex;
812
+ flex-wrap: wrap;
813
+ gap: 4px;
814
+ margin-bottom: 0;
815
+ }
816
+
817
+ .nav-label {
818
+ width: 100%;
819
+ margin-bottom: 4px;
820
+ }
821
+
822
+ .nav-item {
823
+ flex: none;
824
+ padding: 8px 10px;
825
+ font-size: 0.75rem;
826
+ }
827
+
828
+ .audit-section {
829
+ display: none;
830
+ }
831
+
832
+ .main-content {
833
+ grid-template-columns: 1fr;
834
+ grid-template-rows: auto auto;
835
+ order: 1;
836
+ overflow-y: auto;
837
+ padding: 10px;
838
+ }
839
+
840
+ .camera-grid {
841
+ grid-template-columns: 1fr;
842
+ grid-template-rows: auto;
843
+ min-height: 250px;
844
+ }
845
+
846
+ .feed-cell:not(:first-child) {
847
+ display: none;
848
+ }
849
+
850
+ .intel-panel {
851
+ overflow-y: visible;
852
+ }
853
+ }
854
+
855
+ @media (max-width: 480px) {
856
+ .sidebar .nav-item {
857
+ font-size: 0.7rem;
858
+ padding: 6px 8px;
859
+ gap: 6px;
860
+ }
861
+
862
+ .sidebar .nav-item svg {
863
+ width: 14px;
864
+ height: 14px;
865
+ }
866
+
867
+ .score-ring {
868
+ width: 100px;
869
+ height: 100px;
870
+ }
871
+
872
+ .score-ring svg {
873
+ width: 100px;
874
+ height: 100px;
875
+ }
876
+
877
+ .score-value {
878
+ font-size: 1.6rem;
879
+ }
880
+ }
881
+ </style>
882
+ </head>
883
+
884
+ <body>
885
+ <!-- Red Alert Overlay -->
886
+ <div class="red-alert-overlay" id="red-alert-overlay"></div>
887
+ <div class="red-alert-banner" id="red-alert-banner">⚠ RED ALERT — CRITICAL THREAT DETECTED ⚠</div>
888
+
889
+ <!-- SIDEBAR -->
890
+ <div class="sidebar">
891
+ <div class="logo">
892
+ <i data-feather="shield"></i>
893
+ SENTINEL
894
+ </div>
895
+ <div class="version-tag">V9.0 // IMPROVED UI</div>
896
+
897
+ <div class="nav-group">
898
+ <div class="nav-label">Detection Modules</div>
899
+ <div class="nav-item" onclick="setMode('standby', this)" id="nav-standby">
900
+ <i data-feather="pause-circle"></i> Standby
901
+ </div>
902
+ <div class="nav-item active" onclick="setMode('movement', this)" id="nav-movement">
903
+ <i data-feather="activity"></i> Movement
904
+ </div>
905
+ <div class="nav-item" onclick="setMode('facemask', this)" id="nav-facemask">
906
+ <i data-feather="eye"></i> Facemask
907
+ </div>
908
+ <div class="nav-item" onclick="setMode('weapon', this)" id="nav-weapon">
909
+ <i data-feather="crosshair"></i> Weapon
910
+ </div>
911
+ <div class="nav-item" onclick="setMode('public_safety', this)" id="nav-public_safety">
912
+ <i data-feather="users"></i> Public Safety
913
+ </div>
914
+ </div>
915
+
916
+ <div class="nav-group">
917
+ <div class="nav-label">Input Source</div>
918
+ <div class="nav-item" onclick="setSource('camera')">
919
+ <i data-feather="video"></i> Live Camera
920
+ </div>
921
+ <div class="nav-item" onclick="document.getElementById('upload-input').click()">
922
+ <i data-feather="upload-cloud"></i> Upload Video
923
+ </div>
924
+ <input type="file" id="upload-input" style="display: none" accept="video/*"
925
+ onchange="handleFileUpload(this)">
926
+ </div>
927
+
928
+ <div class="nav-group">
929
+ <div class="nav-label">Grid Layout</div>
930
+ <div class="nav-item" onclick="setGridLayout('quad')">
931
+ <i data-feather="grid"></i> 2×2 Grid
932
+ </div>
933
+ <div class="nav-item" onclick="setGridLayout('single')">
934
+ <i data-feather="maximize-2"></i> Single View
935
+ </div>
936
+ </div>
937
+
938
+ <!-- Audit Log -->
939
+ <div class="audit-section">
940
+ <div class="nav-label" style="margin-bottom: 8px;">Operator Log</div>
941
+ <div class="audit-log-container" id="audit-log-container">
942
+ <div class="audit-entry">
943
+ <div class="audit-time">--:--:--</div>
944
+ SYSTEM STANDBY
945
+ </div>
946
+ </div>
947
+ </div>
948
+ </div>
949
+
950
+ <!-- MAIN CONTENT -->
951
+ <div class="main-content">
952
+ <!-- Multi-Camera Grid -->
953
+ <div class="camera-grid" id="camera-grid">
954
+ <!-- Feed 0 — Primary -->
955
+ <div class="feed-cell expanded" id="feed-0" onclick="expandFeed(0)">
956
+ <div class="feed-header">
957
+ <div class="feed-badge">
958
+ <div class="live-dot"></div>
959
+ <span>FEED 01 // PRIMARY</span>
960
+ </div>
961
+ <div class="feed-status" id="feed-0-status">ACTIVE</div>
962
+ </div>
963
+ <img class="feed-stream" id="stream-0" src="/video_feed/0" alt="Feed 0">
964
+ <button class="expand-btn" onclick="event.stopPropagation(); expandFeed(0)" title="Expand">
965
+ <i data-feather="maximize-2"></i>
966
+ </button>
967
+ </div>
968
+
969
+ <!-- Feed 1 -->
970
+ <div class="feed-cell" id="feed-1">
971
+ <div class="feed-header">
972
+ <div class="feed-badge">
973
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
974
+ <span>FEED 02</span>
975
+ </div>
976
+ <div class="feed-status" id="feed-1-status" style="color: var(--text-dim);">STANDBY</div>
977
+ </div>
978
+ <div class="feed-offline" id="offline-1">
979
+ <i data-feather="video-off"></i>
980
+ NO SIGNAL
981
+ </div>
982
+ </div>
983
+
984
+ <!-- Feed 2 -->
985
+ <div class="feed-cell" id="feed-2">
986
+ <div class="feed-header">
987
+ <div class="feed-badge">
988
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
989
+ <span>FEED 03</span>
990
+ </div>
991
+ <div class="feed-status" id="feed-2-status" style="color: var(--text-dim);">STANDBY</div>
992
+ </div>
993
+ <div class="feed-offline" id="offline-2">
994
+ <i data-feather="video-off"></i>
995
+ NO SIGNAL
996
+ </div>
997
+ </div>
998
+
999
+ <!-- Feed 3 -->
1000
+ <div class="feed-cell" id="feed-3">
1001
+ <div class="feed-header">
1002
+ <div class="feed-badge">
1003
+ <div class="live-dot" style="background: var(--text-dim); box-shadow: none;"></div>
1004
+ <span>FEED 04</span>
1005
+ </div>
1006
+ <div class="feed-status" id="feed-3-status" style="color: var(--text-dim);">STANDBY</div>
1007
+ </div>
1008
+ <div class="feed-offline" id="offline-3">
1009
+ <i data-feather="video-off"></i>
1010
+ NO SIGNAL
1011
+ </div>
1012
+ </div>
1013
+ </div>
1014
+
1015
+ <!-- Intel Panel -->
1016
+ <div class="intel-panel">
1017
+ <!-- Active Mode -->
1018
+ <div class="card">
1019
+ <div class="card-header">
1020
+ <div class="card-title">Active Mode</div>
1021
+ </div>
1022
+ <div class="mode-indicator">
1023
+ <div class="mode-dot"></div>
1024
+ <div class="mode-name" id="mode-title">MOVEMENT ANALYSIS</div>
1025
+ </div>
1026
+ </div>
1027
+
1028
+ <!-- Threat Assessment -->
1029
+ <div class="card" id="threat-card">
1030
+ <div class="card-header">
1031
+ <div class="card-title">Threat Assessment</div>
1032
+ <i data-feather="alert-triangle" class="card-icon"></i>
1033
+ </div>
1034
+ <div class="threat-gauge">
1035
+ <div class="score-ring">
1036
+ <svg viewBox="0 0 130 130">
1037
+ <circle class="score-ring-bg" cx="65" cy="65" r="60"></circle>
1038
+ <circle class="score-ring-fill" id="score-ring-fill" cx="65" cy="65" r="60"></circle>
1039
+ </svg>
1040
+ <div class="score-text">
1041
+ <div class="score-value" id="threat-score">0</div>
1042
+ <div class="score-label">THREAT LEVEL</div>
1043
+ </div>
1044
+ </div>
1045
+ <div class="status-text" id="status-text">SECURE</div>
1046
+ </div>
1047
+ </div>
1048
+
1049
+ <!-- Live Metrics -->
1050
+ <div class="card">
1051
+ <div class="card-header">
1052
+ <div class="card-title">Live Metrics</div>
1053
+ <i data-feather="bar-chart-2" class="card-icon"></i>
1054
+ </div>
1055
+ <div class="stats-list" id="stats-container">
1056
+ <div class="stat-row">
1057
+ <span class="stat-name">System Status</span>
1058
+ <span class="stat-val">Initializing</span>
1059
+ </div>
1060
+ </div>
1061
+ </div>
1062
+
1063
+ <!-- Actions -->
1064
+ <div class="card">
1065
+ <div class="card-header">
1066
+ <div class="card-title">Actions</div>
1067
+ </div>
1068
+ <button class="btn btn-primary" onclick="generateReport()">Generate Incident Report</button>
1069
+ <button class="btn btn-secondary" onclick="refreshAuditLog()" style="margin-top: 8px;">Refresh Audit
1070
+ Log</button>
1071
+ </div>
1072
+ </div>
1073
+ </div>
1074
+
1075
+ <!-- Report Modal -->
1076
+ <div id="report-modal" class="modal-overlay">
1077
+ <div class="modal-card">
1078
+ <div class="modal-title">Incident Report</div>
1079
+ <div class="modal-subtitle">Generated by Sentinel AI Agent // Gemini 1.5</div>
1080
+ <div class="report-content" id="report-content">Analyzing data stream...</div>
1081
+ <button class="btn btn-secondary" onclick="closeModal()">Dismiss</button>
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <!-- ═══════════════════════════════════════════════
1086
+ JAVASCRIPT
1087
+ ═══════════════════════════════════════════════ -->
1088
+ <script>
1089
+ feather.replace();
1090
+
1091
+ // ─── State ───
1092
+ let currentLayout = 'quad'; // 'quad' or 'single'
1093
+ let expandedFeed = 0;
1094
+ let isRedAlert = false;
1095
+
1096
+ // ─── Mode Switching ───
1097
+ const modeTitles = {
1098
+ 'standby': 'SYSTEM STANDBY',
1099
+ 'movement': 'MOVEMENT ANALYSIS',
1100
+ 'facemask': 'FACEMASK DETECTION',
1101
+ 'weapon': 'WEAPON DETECTION',
1102
+ 'public_safety': 'PUBLIC SAFETY'
1103
+ };
1104
+
1105
+ function setMode(mode, element) {
1106
+ // Radio-style selection: always activate the clicked mode
1107
+ // Remove active from all module buttons
1108
+ document.querySelectorAll('.nav-group:first-of-type .nav-item').forEach(el => el.classList.remove('active'));
1109
+
1110
+ // Add active to the clicked button
1111
+ element.classList.add('active');
1112
+
1113
+ // Update UI display
1114
+ document.getElementById('mode-title').textContent = modeTitles[mode] || mode.toUpperCase();
1115
+
1116
+ // Send to backend
1117
+ fetch('/set_mode', {
1118
+ method: 'POST',
1119
+ headers: { 'Content-Type': 'application/json' },
1120
+ body: JSON.stringify({ mode: mode })
1121
+ });
1122
+ }
1123
+
1124
+ function setSource(source) {
1125
+ fetch('/set_source', {
1126
+ method: 'POST',
1127
+ headers: { 'Content-Type': 'application/json' },
1128
+ body: JSON.stringify({ source: source })
1129
+ })
1130
+ .then(r => r.json())
1131
+ .then(data => {
1132
+ if (data.success) {
1133
+ // Force the browser to reconnect to the restarted MJPEG stream
1134
+ refreshFeedStream(0);
1135
+ }
1136
+ });
1137
+ }
1138
+
1139
+ function handleFileUpload(input) {
1140
+ if (input.files[0]) {
1141
+ const formData = new FormData();
1142
+ formData.append('file', input.files[0]);
1143
+
1144
+ // Show uploading state
1145
+ const statusEl = document.getElementById('feed-0-status');
1146
+ if (statusEl) statusEl.textContent = 'UPLOADING...';
1147
+
1148
+ fetch('/upload_video', { method: 'POST', body: formData })
1149
+ .then(r => r.json())
1150
+ .then(data => {
1151
+ if (data.success) {
1152
+ // Force the browser to reconnect to the restarted MJPEG stream
1153
+ refreshFeedStream(0);
1154
+ if (statusEl) statusEl.textContent = 'FILE FEED';
1155
+ }
1156
+ })
1157
+ .catch(() => {
1158
+ if (statusEl) statusEl.textContent = 'UPLOAD FAILED';
1159
+ });
1160
+
1161
+ // Reset file input so the same file can be re-uploaded
1162
+ input.value = '';
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Force-refresh an MJPEG stream by setting a new src with a cache-buster.
1168
+ * This drops the old HTTP connection and establishes a fresh one.
1169
+ */
1170
+ function refreshFeedStream(feedId) {
1171
+ const img = document.getElementById('stream-' + feedId);
1172
+ if (img) {
1173
+ // Brief blank to visually signal the switch
1174
+ img.src = '';
1175
+ // Small delay lets the backend fully initialize the new feed
1176
+ setTimeout(() => {
1177
+ img.src = '/video_feed/' + feedId + '?t=' + Date.now();
1178
+ }, 300);
1179
+ }
1180
+ }
1181
+
1182
+ // ─── Grid Layout ───
1183
+ function setGridLayout(layout) {
1184
+ const grid = document.getElementById('camera-grid');
1185
+ currentLayout = layout;
1186
+
1187
+ if (layout === 'single') {
1188
+ grid.classList.add('single-view');
1189
+ // Show only the expanded feed
1190
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1191
+ cell.classList.toggle('expanded', i === expandedFeed);
1192
+ });
1193
+ } else {
1194
+ grid.classList.remove('single-view');
1195
+ document.querySelectorAll('.feed-cell').forEach(cell => {
1196
+ cell.classList.remove('expanded');
1197
+ });
1198
+ }
1199
+ }
1200
+
1201
+ function expandFeed(feedId) {
1202
+ expandedFeed = feedId;
1203
+ if (currentLayout === 'single') {
1204
+ document.querySelectorAll('.feed-cell').forEach((cell, i) => {
1205
+ cell.classList.toggle('expanded', i === feedId);
1206
+ });
1207
+ }
1208
+ }
1209
+
1210
+ // ─── Stats & Red Alert Updates ───
1211
+ function updateStats() {
1212
+ fetch('/stats')
1213
+ .then(r => r.json())
1214
+ .then(data => {
1215
+ const score = data.threat_score;
1216
+ const scoreEl = document.getElementById('threat-score');
1217
+ const statusEl = document.getElementById('status-text');
1218
+ const ringFill = document.getElementById('score-ring-fill');
1219
+ const threatCard = document.getElementById('threat-card');
1220
+
1221
+ scoreEl.textContent = score;
1222
+
1223
+ // Compute ring dash offset (circumference = 2 * π * 60 ≈ 377)
1224
+ const circumference = 377;
1225
+ const offset = circumference - (circumference * score / 100);
1226
+ ringFill.style.strokeDashoffset = offset;
1227
+
1228
+ // Color based on score
1229
+ let color, status, glow;
1230
+ if (score >= 80) {
1231
+ color = '#ff2040';
1232
+ status = 'CRITICAL';
1233
+ glow = 'rgba(255, 32, 64, 0.4)';
1234
+ } else if (score >= 50) {
1235
+ color = '#ffaa00';
1236
+ status = 'ELEVATED';
1237
+ glow = 'rgba(255, 170, 0, 0.3)';
1238
+ } else if (score >= 25) {
1239
+ color = '#00d4ff';
1240
+ status = 'GUARDED';
1241
+ glow = 'rgba(0, 200, 255, 0.3)';
1242
+ } else {
1243
+ color = '#00ff88';
1244
+ status = 'SECURE';
1245
+ glow = 'rgba(0, 255, 136, 0.3)';
1246
+ }
1247
+
1248
+ statusEl.textContent = status;
1249
+ statusEl.style.color = color;
1250
+ statusEl.style.textShadow = `0 0 20px ${glow}`;
1251
+ ringFill.style.stroke = color;
1252
+ ringFill.style.filter = `drop-shadow(0 0 8px ${glow})`;
1253
+
1254
+ // Red Alert state
1255
+ const alertOverlay = document.getElementById('red-alert-overlay');
1256
+ const alertBanner = document.getElementById('red-alert-banner');
1257
+ const feedCells = document.querySelectorAll('.feed-cell');
1258
+
1259
+ if (data.red_alert) {
1260
+ alertOverlay.classList.add('active');
1261
+ alertBanner.classList.add('active');
1262
+ feedCells.forEach(cell => cell.classList.add('red-alert-active'));
1263
+ threatCard.style.borderColor = 'rgba(255, 32, 64, 0.5)';
1264
+
1265
+ if (!isRedAlert) {
1266
+ playAlertTone();
1267
+ isRedAlert = true;
1268
+ }
1269
+ } else {
1270
+ alertOverlay.classList.remove('active');
1271
+ alertBanner.classList.remove('active');
1272
+ feedCells.forEach(cell => cell.classList.remove('red-alert-active'));
1273
+ threatCard.style.borderColor = '';
1274
+ isRedAlert = false;
1275
+ }
1276
+
1277
+ // Update mode display
1278
+ if (data.mode) {
1279
+ document.getElementById('mode-title').textContent = modeTitles[data.mode] || data.mode.toUpperCase();
1280
+ }
1281
+
1282
+ // Update live metrics
1283
+ const container = document.getElementById('stats-container');
1284
+ container.innerHTML = '';
1285
+
1286
+ if (!data.details || Object.keys(data.details).length === 0) {
1287
+ container.innerHTML = '<div class="stat-row"><span class="stat-name">System Status</span><span class="stat-val">Standby</span></div>';
1288
+ } else {
1289
+ for (const [key, value] of Object.entries(data.details)) {
1290
+ const div = document.createElement('div');
1291
+ div.className = 'stat-row';
1292
+ const formattedKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
1293
+ let displayVal = value;
1294
+ if (typeof value === 'boolean') {
1295
+ displayVal = value ? '⚠ YES' : 'No';
1296
+ }
1297
+ div.innerHTML = `<span class="stat-name">${formattedKey}</span><span class="stat-val">${displayVal}</span>`;
1298
+ container.appendChild(div);
1299
+ }
1300
+ }
1301
+ })
1302
+ .catch(() => { });
1303
+ }
1304
+
1305
+ // ─── Alert Tone (Web Audio API) ───
1306
+ function playAlertTone() {
1307
+ try {
1308
+ const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
1309
+ const oscillator = audioCtx.createOscillator();
1310
+ const gainNode = audioCtx.createGain();
1311
+
1312
+ oscillator.connect(gainNode);
1313
+ gainNode.connect(audioCtx.destination);
1314
+
1315
+ oscillator.type = 'square';
1316
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime);
1317
+ oscillator.frequency.setValueAtTime(660, audioCtx.currentTime + 0.15);
1318
+ oscillator.frequency.setValueAtTime(880, audioCtx.currentTime + 0.3);
1319
+
1320
+ gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime);
1321
+ gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.5);
1322
+
1323
+ oscillator.start(audioCtx.currentTime);
1324
+ oscillator.stop(audioCtx.currentTime + 0.5);
1325
+ } catch (e) {
1326
+ // Audio not available — silent fallback
1327
+ }
1328
+ }
1329
+
1330
+ // ─── AI Report ───
1331
+ function generateReport() {
1332
+ const modal = document.getElementById('report-modal');
1333
+ modal.classList.add('show');
1334
+ document.getElementById('report-content').textContent = 'ANALYZING DATA STREAM...';
1335
+
1336
+ fetch('/generate_report', { method: 'POST' })
1337
+ .then(r => r.json())
1338
+ .then(data => {
1339
+ document.getElementById('report-content').textContent = data.report;
1340
+ })
1341
+ .catch(() => {
1342
+ document.getElementById('report-content').textContent = 'ERROR: Connection to AI Agent lost.';
1343
+ });
1344
+ }
1345
+
1346
+ function closeModal() {
1347
+ document.getElementById('report-modal').classList.remove('show');
1348
+ }
1349
+
1350
+ // ─── Audit Log Refresh ───
1351
+ function refreshAuditLog() {
1352
+ fetch('/audit_log')
1353
+ .then(r => r.json())
1354
+ .then(data => {
1355
+ const container = document.getElementById('audit-log-container');
1356
+ container.innerHTML = '';
1357
+
1358
+ if (data.log.length === 0) {
1359
+ container.innerHTML = '<div class="audit-entry"><div class="audit-time">--:--:--</div>NO ENTRIES</div>';
1360
+ return;
1361
+ }
1362
+
1363
+ data.log.slice(0, 30).forEach(entry => {
1364
+ const div = document.createElement('div');
1365
+ div.className = `audit-entry severity-${entry.severity}`;
1366
+ div.innerHTML = `
1367
+ <div class="audit-time">${entry.timestamp.split(' ')[1] || entry.timestamp}</div>
1368
+ ${entry.action}: ${entry.details}
1369
+ `;
1370
+ container.appendChild(div);
1371
+ });
1372
+ })
1373
+ .catch(() => { });
1374
+ }
1375
+
1376
+ // ─── Intervals ───
1377
+ setInterval(updateStats, 1000);
1378
+ setInterval(refreshAuditLog, 5000);
1379
+
1380
+ // Initial load
1381
+ setTimeout(refreshAuditLog, 1500);
1382
+ </script>
1383
+ </body>
1384
+
1385
+ </html>
yolov8n.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36
3
+ size 6549796