| |
|
|
| let detectionCount = 0; |
| let alertCount = 0; |
| let personDetectedCount = 0; |
| let personPresent = false; |
| let availableModels = {}; |
| let selectedModels = []; |
| let cameraRunning = false; |
| let sessionStartTime = null; |
| let sessionData = { |
| startTime: null, |
| endTime: null, |
| duration: 0, |
| selectedModels: [], |
| detections: [], |
| alerts: [], |
| totalDetections: 0, |
| totalAlerts: 0, |
| personDetected: 0, |
| personPresent: false, |
| }; |
|
|
| |
| document.addEventListener("DOMContentLoaded", async () => { |
| await loadAvailableModels(); |
| await loadSelectedModels(); |
| await loadAvailableCameras(); |
| startDetectionHistoryPolling(); |
| |
| |
| }); |
|
|
| |
| async function loadAvailableCameras() { |
| try { |
| const response = await fetch("/api/cameras"); |
| const result = await response.json(); |
|
|
| if (!result.success) { |
| console.error("Failed to load cameras:", result.error); |
| document.getElementById("camera-select").innerHTML = |
| '<option value="">β Error loading cameras</option>'; |
| return; |
| } |
|
|
| const cameras = result.cameras || []; |
| const selected = result.selected || 0; |
| const cameraSelect = document.getElementById("camera-select"); |
|
|
| if (cameras.length === 0) { |
| cameraSelect.innerHTML = |
| '<option value="">β No cameras detected - please connect a USB camera</option>'; |
| return; |
| } |
|
|
| |
| cameraSelect.innerHTML = cameras |
| .map( |
| (cam) => |
| `<option value="${cam.index}" ${cam.index === selected ? "selected" : ""}> |
| π· ${cam.name} (${cam.resolution} @ ${cam.fps} FPS) |
| </option>`, |
| ) |
| .join(""); |
|
|
| |
| cameraSelect.addEventListener("change", handleCameraChange); |
| } catch (error) { |
| console.error("Error loading cameras:", error); |
| document.getElementById("camera-select").innerHTML = |
| '<option value="">β Error loading cameras</option>'; |
| } |
| } |
|
|
| |
| async function handleCameraChange(event) { |
| const cameraIndex = parseInt(event.target.value); |
|
|
| if (isNaN(cameraIndex)) return; |
|
|
| |
| if (cameraRunning) { |
| await stopCamera(); |
| } |
|
|
| try { |
| const response = await fetch("/api/select-camera", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ camera_index: cameraIndex }), |
| }); |
|
|
| const result = await response.json(); |
|
|
| if (result.success) { |
| showToast(`π· Camera ${cameraIndex} selected`, "success"); |
| console.log("Camera selected:", result.message); |
| } else { |
| showToast("Failed to select camera: " + result.error, "error"); |
| |
| await loadAvailableCameras(); |
| } |
| } catch (error) { |
| console.error("Error selecting camera:", error); |
| showToast("Error selecting camera", "error"); |
| } |
| } |
|
|
| |
| async function startCamera() { |
| if (cameraRunning) { |
| showToast("Camera is already running", "warning"); |
| return; |
| } |
|
|
| |
| const checkboxes = document.querySelectorAll(".model-checkbox:checked"); |
| const selected = Array.from(checkboxes).map((cb) => cb.dataset.modelId); |
|
|
| if (selected.length === 0) { |
| showToast( |
| "Please select at least one model before starting the camera", |
| "warning", |
| ); |
| return; |
| } |
|
|
| try { |
| |
| await initializePersonDetection(); |
|
|
| |
| const response = await fetch("/api/set-models", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ models: selected }), |
| }); |
|
|
| const data = await response.json(); |
| if (!data.success) { |
| showToast("Failed to apply model selection", "error"); |
| return; |
| } |
|
|
| |
| const sessionResponse = await fetch("/api/start-camera", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ selectedModels: selected }), |
| }); |
|
|
| const sessionData = await sessionResponse.json(); |
| if (!sessionData.success) { |
| showToast("Failed to start camera session", "error"); |
| return; |
| } |
|
|
| |
| cameraRunning = true; |
| sessionStartTime = new Date(); |
| sessionData.startTime = sessionStartTime; |
| sessionData.selectedModels = selected; |
|
|
| |
| const cameraFeed = document.getElementById("camera-feed"); |
| const cameraPlaceholder = document.getElementById("camera-placeholder"); |
| const videoOverlay = document.getElementById("video-overlay"); |
|
|
| cameraFeed.style.display = "block"; |
| cameraFeed.src = "/camera_feed?" + new Date().getTime(); |
| cameraPlaceholder.style.display = "none"; |
| videoOverlay.style.display = "block"; |
|
|
| |
| document.getElementById("start-btn").style.display = "none"; |
| document.getElementById("stop-btn").style.display = "block"; |
|
|
| |
| document.getElementById("camera-status-info").style.display = "block"; |
| document.getElementById("session-models").textContent = selected.join(", "); |
|
|
| |
| initializeCameraFeed(); |
|
|
| |
| try { |
| const recordResponse = await fetch("/api/start-recording", { |
| method: "POST", |
| }); |
| const recordData = await recordResponse.json(); |
| if (recordData.success) { |
| isRecording = true; |
| showToast("Recording live session...", "info"); |
| } |
| } catch (error) { |
| console.error("Error starting recording:", error); |
| } |
|
|
| |
| setInterval(() => { |
| if (cameraRunning) { |
| const elapsed = Math.floor((new Date() - sessionStartTime) / 1000); |
| const minutes = Math.floor(elapsed / 60); |
| const seconds = elapsed % 60; |
| document.getElementById("session-duration").textContent = |
| `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; |
| } |
| }, 1000); |
|
|
| showToast("Camera started with selected models", "success"); |
| } catch (error) { |
| console.error("Error starting camera:", error); |
| showToast("Failed to start camera", "error"); |
| } |
| } |
|
|
| |
| function initializeCameraFeed() { |
| const cameraFeed = document.getElementById("camera-feed"); |
| if (!cameraFeed) return; |
|
|
| |
| cameraFeed.addEventListener("error", function () { |
| console.error("Camera feed error - reloading..."); |
| if (cameraRunning) { |
| document.getElementById("status-badge").innerHTML = ` |
| <span class="status-dot"></span> |
| <span>RECONNECTING...</span> |
| `; |
| |
| setTimeout(() => { |
| cameraFeed.src = "/camera_feed?" + new Date().getTime(); |
| }, 2000); |
| } |
| }); |
|
|
| |
| const feedRefreshInterval = setInterval(() => { |
| if (!cameraRunning) { |
| clearInterval(feedRefreshInterval); |
| return; |
| } |
|
|
| const isStreaming = |
| cameraFeed.src && cameraFeed.src.includes("/camera_feed"); |
| if (!isStreaming && cameraRunning) { |
| console.log("Restarting camera feed..."); |
| cameraFeed.src = "/camera_feed?" + new Date().getTime(); |
| } |
| }, 30000); |
|
|
| |
| updateLiveStats(); |
| const statsInterval = setInterval(() => { |
| if (cameraRunning) { |
| updateLiveStats(); |
| } else { |
| clearInterval(statsInterval); |
| } |
| }, 1000); |
| } |
|
|
| async function loadAvailableModels() { |
| try { |
| const response = await fetch("/api/available-models"); |
| const data = await response.json(); |
| availableModels = data.models || {}; |
| renderModelSelection(); |
| } catch (error) { |
| console.error("Error loading models:", error); |
| showToast("Failed to load available models", "error"); |
| } |
| } |
|
|
| async function loadSelectedModels() { |
| try { |
| const response = await fetch("/api/get-selected-models"); |
| const data = await response.json(); |
| selectedModels = data.selected_models || []; |
| updateModelCheckboxes(); |
| } catch (error) { |
| console.error("Error loading selected models:", error); |
| } |
| } |
|
|
| function renderModelSelection() { |
| const grid = document.getElementById("models-grid"); |
| if (!grid) return; |
|
|
| const modelCards = Object.entries(availableModels).map( |
| ([modelKey, modelInfo]) => { |
| const isSelected = selectedModels.includes(modelKey); |
| return ` |
| <div class="model-card"> |
| <label class="model-checkbox-label"> |
| <input |
| type="checkbox" |
| class="model-checkbox" |
| data-model-id="${modelKey}" |
| ${isSelected ? "checked" : ""} |
| > |
| <div class="checkbox-content"> |
| <strong>${modelInfo.name}</strong> |
| <p>${modelInfo.description}</p> |
| <span class="model-badge">${modelKey}</span> |
| </div> |
| </label> |
| </div> |
| `; |
| }, |
| ); |
|
|
| grid.innerHTML = |
| modelCards.length > 0 ? modelCards.join("") : "<p>No models available</p>"; |
| } |
|
|
| function updateModelCheckboxes() { |
| const checkboxes = document.querySelectorAll(".model-checkbox"); |
| checkboxes.forEach((checkbox) => { |
| checkbox.checked = selectedModels.includes(checkbox.dataset.modelId); |
| }); |
| } |
|
|
| function toggleModelPanel() { |
| const content = document.getElementById("model-panel-content"); |
| const icon = document.getElementById("model-toggle-icon"); |
|
|
| if (!content) return; |
|
|
| content.classList.toggle("open"); |
| icon.textContent = content.classList.contains("open") ? "β²" : "βΌ"; |
| } |
|
|
| function selectAllModels() { |
| const checkboxes = document.querySelectorAll(".model-checkbox"); |
| checkboxes.forEach((checkbox) => { |
| checkbox.checked = true; |
| }); |
| } |
|
|
| function deselectAllModels() { |
| const checkboxes = document.querySelectorAll(".model-checkbox"); |
| checkboxes.forEach((checkbox) => { |
| checkbox.checked = false; |
| }); |
| } |
|
|
| async function applyModelSelection() { |
| const checkboxes = document.querySelectorAll(".model-checkbox:checked"); |
| const selected = Array.from(checkboxes).map((cb) => cb.dataset.modelId); |
|
|
| try { |
| const response = await fetch("/api/set-models", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ models: selected }), |
| }); |
|
|
| const data = await response.json(); |
| if (data.success) { |
| selectedModels = selected; |
| showToast( |
| `Applied ${selected.length > 0 ? selected.length + " model(s)" : "all models"}`, |
| "success", |
| ); |
| } else { |
| showToast("Failed to apply model selection", "error"); |
| } |
| } catch (error) { |
| console.error("Error applying model selection:", error); |
| showToast("Error applying model selection", "error"); |
| } |
| } |
|
|
| function showToast(message, type = "info") { |
| const container = document.getElementById("toast-container"); |
| if (!container) return; |
|
|
| const toast = document.createElement("div"); |
| toast.className = `toast ${type}`; |
| toast.textContent = message; |
| container.appendChild(toast); |
|
|
| setTimeout(() => { |
| toast.remove(); |
| }, 2600); |
| } |
|
|
| |
|
|
| |
| async function loadPersonDetectionHistory() { |
| try { |
| const response = await fetch("/api/person-detection-history"); |
| const data = await response.json(); |
| if (data.success && data.data) { |
| personDetectedCount = data.data.total_detections || 0; |
| personPresent = data.data.currently_present || false; |
| updatePersonDetectionUI(); |
| } |
| } catch (error) { |
| console.error("Error loading person detection history:", error); |
| } |
| } |
|
|
| |
| async function savePersonDetection( |
| count, |
| isPresent, |
| confidence = 0.0, |
| detailsObj = null, |
| ) { |
| try { |
| const detailsData = detailsObj || { |
| timestamp: new Date().toISOString(), |
| session_active: cameraRunning, |
| }; |
|
|
| const response = await fetch("/api/save-person-detection", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify({ |
| person_count: count, |
| is_present: isPresent, |
| confidence: confidence, |
| detection_details: JSON.stringify(detailsData), |
| }), |
| }); |
|
|
| const data = await response.json(); |
| if (!data.success) { |
| console.error("Failed to save person detection:", data.message); |
| } |
| } catch (error) { |
| console.error("Error saving person detection:", error); |
| } |
| } |
|
|
| |
| function updatePersonDetectionUI() { |
| const detectedEl = document.getElementById("person-detected"); |
| const presentEl = document.getElementById("person-present"); |
|
|
| if (detectedEl) { |
| detectedEl.textContent = personDetectedCount; |
| } |
|
|
| if (presentEl) { |
| presentEl.textContent = personPresent ? "β
Yes" : "β No"; |
| presentEl.style.color = personPresent ? "var(--ok)" : "var(--danger)"; |
| } |
| } |
|
|
| |
| |
| function recordPersonDetection(detectionData) { |
| let hasPersonDetection = false; |
| let personCount = 0; |
| let boundingBoxes = []; |
| let avgConfidence = 0.0; |
|
|
| if (detectionData && typeof detectionData === "object") { |
| |
| if (Array.isArray(detectionData)) { |
| for (const detection of detectionData) { |
| if ( |
| detection.class && |
| (detection.class.toLowerCase() === "person" || |
| detection.class.toLowerCase() === "human") |
| ) { |
| hasPersonDetection = true; |
| personCount++; |
| if (detection.bbox) { |
| boundingBoxes.push({ |
| bbox: detection.bbox, |
| confidence: detection.confidence || 0.0, |
| type: detection.type || "object", |
| }); |
| } |
| if (detection.confidence) { |
| avgConfidence += detection.confidence; |
| } |
| } |
| } |
| if (personCount > 0) { |
| avgConfidence = avgConfidence / personCount; |
| } |
| } |
| |
| else if (detectionData.objects) { |
| for (const obj of detectionData.objects) { |
| if ( |
| obj.label && |
| (obj.label.toLowerCase() === "person" || |
| obj.label.toLowerCase() === "human") |
| ) { |
| hasPersonDetection = true; |
| personCount++; |
| if (obj.bbox) { |
| boundingBoxes.push({ |
| bbox: obj.bbox, |
| confidence: obj.confidence || 0.0, |
| label: obj.label, |
| }); |
| } |
| if (obj.confidence) { |
| avgConfidence += obj.confidence; |
| } |
| } |
| } |
| if (personCount > 0) { |
| avgConfidence = avgConfidence / personCount; |
| } |
| } |
|
|
| |
| if (hasPersonDetection) { |
| personDetectedCount++; |
| personPresent = true; |
| updatePersonDetectionUI(); |
|
|
| |
| savePersonDetection(personDetectedCount, true, avgConfidence, { |
| person_count: personCount, |
| bounding_boxes: boundingBoxes, |
| timestamp: new Date().toISOString(), |
| session_active: cameraRunning, |
| }); |
| } |
| } |
| } |
|
|
| |
| async function initializePersonDetection() { |
| await loadPersonDetectionHistory(); |
| } |
|
|
| async function stopCamera() { |
| if (!cameraRunning) { |
| showToast("Camera is not running", "warning"); |
| return; |
| } |
|
|
| if (!confirm("Stop camera and save the monitoring session?")) { |
| return; |
| } |
|
|
| |
| let recordingFilename = null; |
| if (isRecording) { |
| isRecording = false; |
| clearInterval(recordingInterval); |
| try { |
| const stopResponse = await fetch("/api/stop-recording", { |
| method: "POST", |
| }); |
| const stopData = await stopResponse.json(); |
| if (stopData.success) { |
| recordingFilename = stopData.filename; |
| sessionData.recordingFilename = recordingFilename; |
| showToast("Recording saved", "success"); |
| } |
| } catch (error) { |
| console.error("Error stopping recording:", error); |
| } |
| } |
|
|
| |
| sessionData.endTime = new Date(); |
| sessionData.duration = Math.floor( |
| (sessionData.endTime - sessionData.startTime) / 1000, |
| ); |
| sessionData.totalDetections = detectionCount; |
| sessionData.totalAlerts = alertCount; |
|
|
| |
| cameraRunning = false; |
| const cameraFeed = document.getElementById("camera-feed"); |
| cameraFeed.src = ""; |
| cameraFeed.style.display = "none"; |
|
|
| |
| document.getElementById("stop-btn").style.display = "none"; |
| document.getElementById("record-btn").style.display = "none"; |
| document.getElementById("start-btn").style.display = "block"; |
| document.getElementById("camera-status-info").style.display = "none"; |
|
|
| |
| document.getElementById("camera-placeholder").style.display = "block"; |
| document.getElementById("video-overlay").style.display = "none"; |
|
|
| |
| saveMonitoringSession(sessionData); |
| } |
|
|
| async function saveMonitoringSession(session) { |
| try { |
| const response = await fetch("/api/save-monitoring-session", { |
| method: "POST", |
| headers: { |
| "Content-Type": "application/json", |
| }, |
| body: JSON.stringify(session), |
| }); |
|
|
| const data = await response.json(); |
| if (data.success) { |
| showToast("Monitoring session saved successfully", "success"); |
|
|
| |
| const summary = ` |
| <h3>π Session Summary</h3> |
| <p><strong>Duration:</strong> ${Math.floor(session.duration / 60)}:${String(session.duration % 60).padStart(2, "0")}</p> |
| <p><strong>Total Detections:</strong> ${session.totalDetections}</p> |
| <p><strong>Total Alerts:</strong> ${session.totalAlerts}</p> |
| <p><strong>Models Used:</strong> ${session.selectedModels.join(", ")}</p> |
| `; |
|
|
| |
| setTimeout(() => { |
| alert( |
| `Session saved!\n\nDuration: ${Math.floor(session.duration / 60)}:${String(session.duration % 60).padStart(2, "0")}\nDetections: ${session.totalDetections}\nAlerts: ${session.totalAlerts}`, |
| ); |
| }, 500); |
|
|
| |
| detectionCount = 0; |
| alertCount = 0; |
| document.getElementById("total-detections").textContent = "0"; |
| document.getElementById("total-alerts").textContent = "0"; |
| } else { |
| showToast("Failed to save monitoring session: " + data.error, "error"); |
| } |
| } catch (error) { |
| console.error("Error saving monitoring session:", error); |
| showToast("Error saving session to database", "error"); |
| } |
| } |
|
|
| function toggleFullscreen() { |
| const videoContainer = document.querySelector(".video-container"); |
|
|
| if (!document.fullscreenElement) { |
| if (videoContainer.requestFullscreen) { |
| videoContainer.requestFullscreen(); |
| } else if (videoContainer.webkitRequestFullscreen) { |
| videoContainer.webkitRequestFullscreen(); |
| } else if (videoContainer.msRequestFullscreen) { |
| videoContainer.msRequestFullscreen(); |
| } |
| } else { |
| if (document.exitFullscreen) { |
| document.exitFullscreen(); |
| } |
| } |
| } |
|
|
| function addAlert(alertData) { |
| const alertsContainer = document.getElementById("alerts-container"); |
|
|
| |
| const placeholder = alertsContainer.querySelector(".alert-placeholder"); |
| if (placeholder) { |
| placeholder.remove(); |
| } |
|
|
| const alertDiv = document.createElement("div"); |
| alertDiv.className = "alert-item"; |
|
|
| if (alertData.severity === "HIGH" || alertData.severity === "HIGH RISK") { |
| alertDiv.classList.add("high"); |
| } |
|
|
| const timestamp = new Date().toLocaleTimeString(); |
|
|
| alertDiv.innerHTML = ` |
| <div style="display: flex; justify-content: space-between; margin-bottom: 5px;"> |
| <strong>${alertData.type || "Alert"}</strong> |
| <span style="font-size: 0.9em; color: #666;">${timestamp}</span> |
| </div> |
| <div>${alertData.message}</div> |
| ${alertData.confidence ? `<div style="margin-top: 5px; font-size: 0.9em;">Confidence: ${(alertData.confidence * 100).toFixed(1)}%</div>` : ""} |
| `; |
|
|
| |
| alertsContainer.insertBefore(alertDiv, alertsContainer.firstChild); |
|
|
| |
| alertCount++; |
| document.getElementById("total-alerts").textContent = alertCount; |
|
|
| |
| while (alertsContainer.children.length > 10) { |
| alertsContainer.removeChild(alertsContainer.lastChild); |
| } |
| } |
|
|
| |
| document.addEventListener("DOMContentLoaded", function () { |
| const cameraFeedElement = document.getElementById("camera-feed"); |
| if (cameraFeedElement) { |
| cameraFeedElement.addEventListener("error", function () { |
| console.error("Camera feed error"); |
| const statusBadge = document.getElementById("status-badge"); |
| if (statusBadge) { |
| statusBadge.innerHTML = ` |
| <span class="status-dot"></span> |
| <span>DISCONNECTED</span> |
| `; |
| } |
| }); |
| } |
| }); |
|
|
| |
| async function updateLiveStats() { |
| try { |
| const response = await fetch("/api/live-stats"); |
| const data = await response.json(); |
|
|
| if (data.error) { |
| console.error("Stats error:", data.error); |
| return; |
| } |
|
|
| |
| detectionCount = data.person_detections + data.weapon_detections; |
| document.getElementById("total-detections").textContent = detectionCount; |
|
|
| |
| if (data.alert_count > 0) { |
| document.getElementById("total-alerts").textContent = data.alert_count; |
| alertCount = data.alert_count; |
| } |
|
|
| |
| if (data.pose_analysis && Object.keys(data.pose_analysis).length > 0) { |
| const pose = data.pose_analysis; |
| document.getElementById("pose-risk-level").textContent = |
| pose.risk_level || "SAFE"; |
| document.getElementById("pose-action").textContent = pose.action || "β"; |
| document.getElementById("pose-score").textContent = ( |
| pose.risk_score || 0 |
| ).toFixed(2); |
|
|
| |
| const riskElement = document.getElementById("pose-risk-level"); |
| if (pose.risk_level === "HIGH_RISK") { |
| riskElement.style.color = "var(--danger)"; |
| } else if (pose.risk_level === "LOW_RISK") { |
| riskElement.style.color = "var(--orange-600)"; |
| } else { |
| riskElement.style.color = "var(--ok)"; |
| } |
| } |
|
|
| |
| updateDetectionIndicators(data); |
| } catch (error) { |
| console.error("Error fetching live stats:", error); |
| } |
| } |
|
|
| function updateDetectionIndicators(data) { |
| |
| const cameraFeed = document.getElementById("camera-feed"); |
|
|
| if (data.person_visible || data.weapon_visible) { |
| if (!cameraFeed.classList.contains("detection-active")) { |
| cameraFeed.classList.add("detection-active"); |
| } |
| } else { |
| cameraFeed.classList.remove("detection-active"); |
| } |
| } |
|
|
| |
| |
|
|
| |
| async function loadDetectionHistory() { |
| try { |
| const response = await fetch("/api/detection-history?limit=12"); |
| const result = await response.json(); |
|
|
| if (!result.success) { |
| console.error("Failed to load detection history"); |
| return; |
| } |
|
|
| const detections = result.data || []; |
| const gallery = document.getElementById("detection-gallery"); |
|
|
| |
| document.getElementById("detection-count-badge").textContent = |
| detections.length; |
|
|
| if (detections.length === 0) { |
| gallery.innerHTML = ` |
| <div style="grid-column: 1 / -1; text-align: center; color: var(--slate-500); padding: 2rem;"> |
| <p style="margin: 0;">πΈ No detections yet</p> |
| <small>Detected threats will appear here</small> |
| </div> |
| `; |
| return; |
| } |
|
|
| |
| gallery.innerHTML = detections |
| .map((det) => { |
| const typeEmoji = |
| { |
| weapon: "π«", |
| risk: "β οΈ", |
| unusual: "β", |
| }[det.detection_type] || "πΈ"; |
|
|
| const levelColor = |
| { |
| LOW: "var(--blue-600)", |
| MEDIUM: "var(--orange-600)", |
| HIGH: "var(--danger)", |
| CRITICAL: "var(--danger)", |
| }[det.alert_level] || "var(--slate-600)"; |
|
|
| const timestamp = new Date(det.detected_at).toLocaleTimeString(); |
|
|
| return ` |
| <div style="position: relative; cursor: pointer; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); transition: transform 0.2s ease;" onclick="openDetectionDetail(${det.id}, '${det.image_filename}', '${det.detection_type}', '${det.alert_level}', '${timestamp}')"> |
| <img src="/api/detection-image/${det.image_filename}" alt="Detection" style="width: 100%; height: 100px; object-fit: cover; display: block;"> |
| <div style="position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); padding: 0.5rem; color: white; font-size: 0.75rem;"> |
| <div style="font-weight: 600;">${typeEmoji} ${det.detection_type.toUpperCase()}</div> |
| <div style="color: #ccc; font-size: 0.7rem;">${timestamp}</div> |
| </div> |
| <div style="position: absolute; top: 4px; right: 4px; background: ${levelColor}; color: white; padding: 2px 6px; border-radius: 4px; font-weight: 600; font-size: 0.7rem;">${det.alert_level}</div> |
| </div> |
| `; |
| }) |
| .join(""); |
| } catch (error) { |
| console.error("Error loading detection history:", error); |
| } |
| } |
|
|
| function openDetectionDetail(id, filename, type, level, timestamp) { |
| |
| const modal = ` |
| <div style="text-align: center;"> |
| <img src="/api/detection-image/${filename}" alt="Detection" style="max-width: 100%; max-height: 400px; border-radius: 8px; margin-bottom: 1rem;"> |
| <div style="background: var(--slate-100); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;"> |
| <p><strong>Type:</strong> ${type.toUpperCase()}</p> |
| <p><strong>Alert Level:</strong> <span style="color: var(--${level === "CRITICAL" || level === "HIGH" ? "danger" : "orange-600"});">${level}</span></p> |
| <p><strong>Detected:</strong> ${timestamp}</p> |
| </div> |
| </div> |
| `; |
|
|
| new Modal(`Detection #${id}`, modal, { footer: false }).show(); |
| } |
|
|
| |
| function startDetectionHistoryPolling() { |
| |
| loadDetectionHistory(); |
|
|
| |
| setInterval(loadDetectionHistory, 5000); |
| } |
|
|
| |
| let isRecording = false; |
| let recordingStartTime = null; |
| let recordingInterval = null; |
|
|
| async function toggleRecording() { |
| if (!cameraRunning) { |
| showToast("β Camera is not running. Start the camera first.", "error"); |
| return; |
| } |
|
|
| if (isRecording) { |
| |
| try { |
| const response = await fetch("/api/stop-recording", { method: "POST" }); |
| const result = await response.json(); |
|
|
| if (result.success) { |
| isRecording = false; |
| clearInterval(recordingInterval); |
| document.getElementById("record-btn").classList.remove("btn-danger"); |
| document.getElementById("record-btn").textContent = "π΄ Record"; |
| document.getElementById("record-btn").classList.add("btn-secondary"); |
|
|
| const duration = result.duration ? ` (${result.duration}s)` : ""; |
| showToast(`β
Recording stopped${duration}`, "success"); |
| } else { |
| showToast("β Error stopping recording: " + result.error, "error"); |
| } |
| } catch (error) { |
| console.error("Error stopping recording:", error); |
| showToast("β Error stopping recording", "error"); |
| } |
| } else { |
| |
| try { |
| const response = await fetch("/api/start-recording", { method: "POST" }); |
| const result = await response.json(); |
|
|
| if (result.success) { |
| isRecording = true; |
| recordingStartTime = new Date(); |
| document.getElementById("record-btn").classList.remove("btn-secondary"); |
| document.getElementById("record-btn").classList.add("btn-danger"); |
| document.getElementById("record-btn").innerHTML = "βΉοΈ Stop Recording"; |
|
|
| showToast("π΄ Recording started", "success"); |
|
|
| |
| recordingInterval = setInterval(() => { |
| if (isRecording && recordingStartTime) { |
| const elapsed = Math.round( |
| (new Date() - recordingStartTime) / 1000, |
| ); |
| document.getElementById("record-btn").innerHTML = |
| `βΉοΈ Stop (${elapsed}s)`; |
| } |
| }, 1000); |
| } else { |
| showToast("β Error starting recording: " + result.error, "error"); |
| } |
| } catch (error) { |
| console.error("Error starting recording:", error); |
| showToast("β Error starting recording", "error"); |
| } |
| } |
| } |
|
|
| |
|
|
| let _storageData = { sessions: [], videos: [], images: [] }; |
| let _activeStorageTab = "sessions"; |
|
|
| async function openStorageManager() { |
| document.getElementById("storage-drawer").classList.add("open"); |
| document.getElementById("storage-backdrop").classList.add("open"); |
| await refreshStorageDrawer(); |
| } |
|
|
| function closeStorageDrawer() { |
| document.getElementById("storage-drawer").classList.remove("open"); |
| document.getElementById("storage-backdrop").classList.remove("open"); |
| closeInlinePlayer(); |
| } |
|
|
| async function refreshStorageDrawer() { |
| document.getElementById("sd-content").innerHTML = |
| '<div class="sd-empty"><div class="sd-empty-icon">β³</div><p>Loadingβ¦</p></div>'; |
|
|
| try { |
| const [sessRes, vidRes, imgRes] = await Promise.all([ |
| fetch("/api/detection-history?type=session&limit=50").then((r) => r.json()), |
| fetch("/api/videos").then((r) => r.json()), |
| fetch("/api/images").then((r) => r.json()), |
| ]); |
|
|
| _storageData.sessions = (sessRes.data || []).filter((d) => d.type === "session"); |
| _storageData.videos = vidRes.videos || []; |
| _storageData.images = imgRes.images || []; |
|
|
| |
| document.getElementById("sd-tab-sessions").textContent = `π₯ Sessions (${_storageData.sessions.length})`; |
| document.getElementById("sd-tab-recordings").textContent = `π¬ Recordings (${_storageData.videos.length})`; |
| document.getElementById("sd-tab-images").textContent = `π· Images (${_storageData.images.length})`; |
|
|
| |
| const totalSecs = _storageData.sessions.reduce((a, s) => a + (s.duration || 0), 0); |
| const totalMins = Math.floor(totalSecs / 60); |
| document.getElementById("sd-stats-row").innerHTML = ` |
| <div class="sd-stat"><strong>${_storageData.sessions.length}</strong> sessions</div> |
| <div class="sd-stat"><strong>${totalMins}m</strong> recorded</div> |
| <div class="sd-stat"><strong>${_storageData.images.length}</strong> detection shots</div> |
| `; |
|
|
| renderStorageTab(_activeStorageTab); |
| } catch (e) { |
| document.getElementById("sd-content").innerHTML = |
| '<div class="sd-empty"><div class="sd-empty-icon">β οΈ</div><p>Failed to load storage data.</p></div>'; |
| } |
| } |
|
|
| function switchStorageTab(tab) { |
| _activeStorageTab = tab; |
| ["sessions", "recordings", "images"].forEach((t) => { |
| document.getElementById("sd-tab-" + t).classList.toggle("active", t === tab); |
| }); |
| closeInlinePlayer(); |
| renderStorageTab(tab); |
| } |
|
|
| function renderStorageTab(tab) { |
| const el = document.getElementById("sd-content"); |
| if (tab === "sessions") el.innerHTML = buildSessionsHTML(_storageData.sessions); |
| if (tab === "recordings") el.innerHTML = buildRecordingsHTML(_storageData.videos); |
| if (tab === "images") el.innerHTML = buildImagesHTML(_storageData.images); |
| } |
|
|
| |
|
|
| function buildSessionsHTML(sessions) { |
| if (sessions.length === 0) return ` |
| <div class="sd-empty"> |
| <div class="sd-empty-icon">π₯</div> |
| <p>No sessions saved yet.<br><small>Sessions are recorded automatically when you stop the camera.</small></p> |
| </div>`; |
|
|
| return `<div class="sd-sessions-grid">${sessions.map(renderSessionCard).join("")}</div>`; |
| } |
|
|
| function renderSessionCard(s) { |
| const dur = s.duration || 0; |
| const durStr = `${Math.floor(dur / 60)}m ${String(dur % 60).padStart(2, "0")}s`; |
| const ts = new Date(s.created_at).toLocaleString(); |
| const videoUrl = s.video_filename ? `/processed/${s.video_filename}` : null; |
| const previewUrl = s.preview_image ? `/processed/${s.preview_image}` : null; |
| const criticalBadge = s.is_critical ? `<span class="sd-badge critical">β‘ CRITICAL</span>` : ""; |
|
|
| return ` |
| <div class="sd-session-card" id="sess-card-${s.id}"> |
| ${previewUrl |
| ? `<img class="sd-session-thumb" src="${previewUrl}" onerror="this.style.display='none'">` |
| : `<div class="sd-session-thumb-placeholder">π₯</div>`} |
| <div class="sd-session-body"> |
| <div class="sd-session-name" title="${s.session_name || "Session"}">${s.session_name || "Monitoring Session"}</div> |
| <div class="sd-session-meta">β± ${durStr} β’ ${ts}</div> |
| <div class="sd-session-badges"> |
| <span class="sd-badge blue">π ${s.detection_count} detections</span> |
| ${s.alert_count > 0 ? `<span class="sd-badge danger">π¨ ${s.alert_count} alerts</span>` : `<span class="sd-badge">β No alerts</span>`} |
| ${criticalBadge} |
| </div> |
| <div class="sd-session-actions"> |
| ${videoUrl |
| ? `<button class="sd-btn primary" onclick="playInline('${videoUrl}')">βΆ Play</button> |
| <a class="sd-btn" href="${videoUrl}" download>β¬ Download</a>` |
| : `<span class="sd-btn" style="opacity:.4;cursor:default;">No video</span>`} |
| <button class="sd-btn danger" onclick="deleteSession(${s.id})">π</button> |
| </div> |
| </div> |
| </div>`; |
| } |
|
|
| async function deleteSession(id) { |
| if (!confirm("Delete this session and its video file?")) return; |
| const card = document.getElementById("sess-card-" + id); |
| if (card) card.style.opacity = "0.4"; |
| try { |
| const r = await fetch(`/api/sessions/${id}`, { method: "DELETE" }); |
| const d = await r.json(); |
| if (d.success) { |
| showToast("Session deleted", "success"); |
| _storageData.sessions = _storageData.sessions.filter((s) => s.id !== id); |
| document.getElementById("sd-tab-sessions").textContent = `π₯ Sessions (${_storageData.sessions.length})`; |
| document.getElementById("sd-content").innerHTML = buildSessionsHTML(_storageData.sessions); |
| } else { |
| showToast("Delete failed: " + (d.error || "error"), "error"); |
| if (card) card.style.opacity = "1"; |
| } |
| } catch (e) { |
| showToast("Delete error", "error"); |
| if (card) card.style.opacity = "1"; |
| } |
| } |
|
|
| |
|
|
| function buildRecordingsHTML(videos) { |
| if (videos.length === 0) return ` |
| <div class="sd-empty"> |
| <div class="sd-empty-icon">π¬</div> |
| <p>No raw recordings yet.<br><small>Use the Record button during live monitoring.</small></p> |
| </div>`; |
|
|
| return videos.map((v) => { |
| const ts = new Date(v.created).toLocaleString(); |
| return ` |
| <div class="sd-recording-item" id="rec-${v.filename.replace(/\./g, '_')}"> |
| <div class="sd-rec-icon">π¬</div> |
| <div style="flex:1;min-width:0;"> |
| <div class="sd-rec-name">${v.filename}</div> |
| <div class="sd-rec-meta">${v.size} MB β’ ${ts}</div> |
| </div> |
| <div class="sd-rec-actions"> |
| <button class="sd-icon-btn" title="Play" onclick="playInline('/api/download-video/${v.filename}')">βΆ</button> |
| <a class="sd-icon-btn" href="/api/download-video/${v.filename}" download title="Download">β¬</a> |
| <button class="sd-icon-btn danger" title="Delete" onclick="deleteVideo('${v.filename}')">π</button> |
| </div> |
| </div>`; |
| }).join(""); |
| } |
|
|
| async function deleteVideo(filename) { |
| if (!confirm(`Delete "${filename}"?`)) return; |
| try { |
| const r = await fetch(`/api/videos/${encodeURIComponent(filename)}`, { method: "DELETE" }); |
| const d = await r.json(); |
| if (d.success) { |
| showToast("Recording deleted", "success"); |
| _storageData.videos = _storageData.videos.filter((v) => v.filename !== filename); |
| document.getElementById("sd-tab-recordings").textContent = `π¬ Recordings (${_storageData.videos.length})`; |
| document.getElementById("sd-content").innerHTML = buildRecordingsHTML(_storageData.videos); |
| } else { |
| showToast("Delete failed", "error"); |
| } |
| } catch (e) { |
| showToast("Delete error", "error"); |
| } |
| } |
|
|
| |
|
|
| function buildImagesHTML(images) { |
| if (images.length === 0) return ` |
| <div class="sd-empty"> |
| <div class="sd-empty-icon">π·</div> |
| <p>No detection images yet.<br><small>High-risk detections are automatically captured.</small></p> |
| </div>`; |
|
|
| const tiles = images.map((img) => { |
| const levelColor = img.filename.includes('weapon') ? 'var(--danger)' : |
| img.filename.includes('violence') ? '#f97316' : 'var(--blue-600)'; |
| return ` |
| <div class="sd-img-tile" id="img-${img.filename.replace(/\./g,'_')}"> |
| <img src="${img.path}" alt="Detection" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 1 1%22><rect fill=%22%23e2e8f0%22/></svg>'"> |
| <div class="sd-img-overlay"> |
| <button class="sd-img-overlay-btn" title="View" onclick="viewStorageImage('${img.filename}')">π</button> |
| <a class="sd-img-overlay-btn" href="/api/download-image/${img.filename}" download title="Download">β¬</a> |
| <button class="sd-img-overlay-btn" title="Delete" onclick="deleteImage('${img.filename}')">π</button> |
| </div> |
| <div class="sd-img-level" style="background:${levelColor};">${img.size}KB</div> |
| </div>`; |
| }).join(""); |
|
|
| return `<div class="sd-img-grid">${tiles}</div>`; |
| } |
|
|
| function viewStorageImage(filename) { |
| const url = `/api/detection-image/${filename}`; |
| new Modal("π· Detection Image", ` |
| <img src="${url}" alt="Detection" style="max-width:100%;max-height:500px;border-radius:8px;display:block;margin:0 auto;"> |
| <div style="text-align:center;margin-top:1rem;"> |
| <a href="/api/download-image/${filename}" class="btn btn-primary" download>β¬οΈ Download</a> |
| </div>`, { width: "580px" }).show(); |
| } |
|
|
| async function deleteImage(filename) { |
| if (!confirm("Delete this detection image?")) return; |
| try { |
| const r = await fetch(`/api/images/${encodeURIComponent(filename)}`, { method: "DELETE" }); |
| const d = await r.json(); |
| if (d.success) { |
| showToast("Image deleted", "success"); |
| _storageData.images = _storageData.images.filter((i) => i.filename !== filename); |
| document.getElementById("sd-tab-images").textContent = `π· Images (${_storageData.images.length})`; |
| document.getElementById("sd-content").innerHTML = buildImagesHTML(_storageData.images); |
| } else { |
| showToast("Delete failed", "error"); |
| } |
| } catch (e) { |
| showToast("Delete error", "error"); |
| } |
| } |
|
|
| |
|
|
| function playInline(url) { |
| const player = document.getElementById("sd-inline-player"); |
| const vid = document.getElementById("sd-video-el"); |
| vid.src = url; |
| player.style.display = "block"; |
| vid.play().catch(() => {}); |
| |
| player.scrollIntoView({ behavior: "smooth", block: "start" }); |
| } |
|
|
| function closeInlinePlayer() { |
| const player = document.getElementById("sd-inline-player"); |
| const vid = document.getElementById("sd-video-el"); |
| vid.pause(); |
| vid.src = ""; |
| player.style.display = "none"; |
| } |
|
|
| function viewImage(filename) { |
| viewStorageImage(filename); |
| } |
|
|
| function downloadAllImages() { |
| showToast("π‘ Download images individually from the Images tab", "info"); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|