Features: - Service health monitoring with response times - Proxmox cluster integration (nodes, VMs, containers) - PBS backup server monitoring - Camera viewer with WebRTC (go2rtc) - Docker container monitoring - Uptime Kuma integration - Mobile-friendly responsive design - YAML-based configuration for easy setup
323 lines
14 KiB
HTML
323 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block content %}
|
|
<div class="cameras-page">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-dark-900/95 backdrop-blur py-2 z-10">
|
|
<div class="flex items-center gap-3">
|
|
<a href="/" class="text-gray-400 hover:text-white p-2 -ml-2">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
</svg>
|
|
</a>
|
|
<h1 class="text-lg font-semibold text-white">Cameras</h1>
|
|
<span class="text-xs text-gray-500">{{ cameras | length }} cameras</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- View Mode -->
|
|
<div class="flex bg-dark-700 rounded-lg p-0.5">
|
|
<button onclick="setViewMode('grid')" id="btn-grid" class="view-btn active px-2 py-1 text-xs rounded-md">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
|
|
</svg>
|
|
</button>
|
|
<button onclick="setViewMode('list')" id="btn-list" class="view-btn px-2 py-1 text-xs rounded-md">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<!-- Reconnect -->
|
|
<button onclick="reconnectAll()" class="p-2 text-gray-400 hover:text-white hover:bg-dark-700 rounded-lg" title="Reconnect all streams">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Camera Grid -->
|
|
<div id="camera-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
|
{% for camera in cameras %}
|
|
<div class="camera-card" data-camera="{{ camera }}">
|
|
<div class="aspect-video bg-dark-800 rounded-t-xl overflow-hidden relative cursor-pointer" onclick="openFullscreen('{{ camera }}')">
|
|
<video id="video-{{ camera }}"
|
|
class="w-full h-full object-cover"
|
|
autoplay muted playsinline
|
|
poster="{{ go2rtc_url }}/api/frame.jpeg?src={{ camera }}"></video>
|
|
|
|
<!-- Status indicator -->
|
|
<div id="status-{{ camera }}" class="absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></div>
|
|
|
|
<!-- Live indicator -->
|
|
<div class="absolute top-2 left-2">
|
|
<span class="live-badge flex items-center gap-1 px-1.5 py-0.5 bg-red-600/90 rounded text-[10px] text-white font-medium">
|
|
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
|
|
LIVE
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center justify-between bg-dark-700 rounded-b-xl px-3 py-2">
|
|
<span class="text-sm text-white font-medium truncate">{{ camera | replace("_", " ") }}</span>
|
|
<div class="flex gap-1">
|
|
<button onclick="event.stopPropagation(); reconnectCamera('{{ camera }}')" class="p-1.5 text-gray-400 hover:text-cyan-400 hover:bg-dark-600 rounded" title="Reconnect">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
|
</svg>
|
|
</button>
|
|
<button onclick="event.stopPropagation(); openFullscreen('{{ camera }}')" class="p-1.5 text-gray-400 hover:text-white hover:bg-dark-600 rounded" title="Fullscreen">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Fullscreen Modal -->
|
|
<div id="fullscreen-modal" class="fixed inset-0 bg-black z-50 hidden">
|
|
<div class="absolute top-4 left-4 right-4 flex items-center justify-between z-10">
|
|
<div class="flex items-center gap-3">
|
|
<h2 id="modal-title" class="text-white text-lg font-semibold drop-shadow-lg"></h2>
|
|
<span class="live-badge flex items-center gap-1 px-2 py-0.5 bg-red-600 rounded text-xs text-white font-medium">
|
|
<span class="w-1.5 h-1.5 bg-white rounded-full animate-pulse"></span>
|
|
LIVE
|
|
</span>
|
|
</div>
|
|
<button onclick="closeFullscreen()" class="p-2 bg-gray-800/80 hover:bg-gray-700 rounded-lg text-white">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="w-full h-full flex items-center justify-center">
|
|
<video id="modal-video" class="max-w-full max-h-full" autoplay playsinline></video>
|
|
</div>
|
|
<button onclick="prevCamera()" class="absolute left-2 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 rounded-full text-white">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
</svg>
|
|
</button>
|
|
<button onclick="nextCamera()" class="absolute right-2 top-1/2 -translate-y-1/2 p-3 bg-black/50 hover:bg-black/70 rounded-full text-white">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
|
</svg>
|
|
</button>
|
|
<div class="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 bg-black/60 rounded-full text-white text-sm">
|
|
<span id="modal-counter">1 / 12</span>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.cameras-page { padding-bottom: env(safe-area-inset-bottom); }
|
|
.view-btn { color: #9ca3af; transition: all 0.15s; }
|
|
.view-btn.active { background: #374151; color: white; }
|
|
.view-btn:hover { color: white; }
|
|
|
|
@supports (padding: max(0px)) {
|
|
.cameras-page {
|
|
padding-left: max(0.75rem, env(safe-area-inset-left));
|
|
padding-right: max(0.75rem, env(safe-area-inset-right));
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
const cameras = {{ cameras | tojson }};
|
|
let currentCameraIndex = 0;
|
|
let peerConnections = {};
|
|
let fullscreenPC = null;
|
|
|
|
// Initialize WebRTC stream for a camera
|
|
async function initCameraStream(cameraId) {
|
|
const video = document.getElementById('video-' + cameraId);
|
|
const status = document.getElementById('status-' + cameraId);
|
|
|
|
if (!video) return;
|
|
|
|
try {
|
|
// Close existing connection if any
|
|
if (peerConnections[cameraId]) {
|
|
peerConnections[cameraId].close();
|
|
}
|
|
|
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse';
|
|
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
|
|
peerConnections[cameraId] = pc;
|
|
|
|
pc.ontrack = (event) => {
|
|
video.srcObject = event.streams[0];
|
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-green-500';
|
|
};
|
|
|
|
pc.oniceconnectionstatechange = () => {
|
|
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
|
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-red-500';
|
|
setTimeout(() => initCameraStream(cameraId), 5000);
|
|
}
|
|
};
|
|
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
const response = await fetch('/api/webrtc?src=' + cameraId, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/sdp' },
|
|
body: offer.sdp
|
|
});
|
|
|
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
|
|
const answer = await response.text();
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
|
|
|
} catch (error) {
|
|
console.error('Failed to connect ' + cameraId + ':', error);
|
|
status.className = 'absolute top-2 right-2 w-2.5 h-2.5 rounded-full bg-red-500';
|
|
setTimeout(() => initCameraStream(cameraId), 10000);
|
|
}
|
|
}
|
|
|
|
function reconnectCamera(cameraId) {
|
|
initCameraStream(cameraId);
|
|
}
|
|
|
|
function reconnectAll() {
|
|
cameras.forEach(cam => initCameraStream(cam));
|
|
}
|
|
|
|
function setViewMode(mode) {
|
|
const grid = document.getElementById('camera-grid');
|
|
const btnGrid = document.getElementById('btn-grid');
|
|
const btnList = document.getElementById('btn-list');
|
|
|
|
if (mode === 'list') {
|
|
grid.className = 'flex flex-col gap-3';
|
|
btnList.classList.add('active');
|
|
btnGrid.classList.remove('active');
|
|
} else {
|
|
grid.className = 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3';
|
|
btnGrid.classList.add('active');
|
|
btnList.classList.remove('active');
|
|
}
|
|
localStorage.setItem('cameraViewMode', mode);
|
|
}
|
|
|
|
async function openFullscreen(camera) {
|
|
currentCameraIndex = cameras.indexOf(camera);
|
|
const modal = document.getElementById('fullscreen-modal');
|
|
const video = document.getElementById('modal-video');
|
|
const title = document.getElementById('modal-title');
|
|
const counter = document.getElementById('modal-counter');
|
|
|
|
title.textContent = camera.replace(/_/g, ' ');
|
|
counter.textContent = (currentCameraIndex + 1) + ' / ' + cameras.length;
|
|
|
|
modal.classList.remove('hidden');
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
await initFullscreenStream(camera, video);
|
|
}
|
|
|
|
async function initFullscreenStream(cameraId, video) {
|
|
if (fullscreenPC) {
|
|
fullscreenPC.close();
|
|
}
|
|
|
|
const pc = new RTCPeerConnection({
|
|
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
|
});
|
|
|
|
fullscreenPC = pc;
|
|
|
|
pc.ontrack = (event) => {
|
|
video.srcObject = event.streams[0];
|
|
};
|
|
|
|
pc.addTransceiver('video', { direction: 'recvonly' });
|
|
pc.addTransceiver('audio', { direction: 'recvonly' });
|
|
|
|
const offer = await pc.createOffer();
|
|
await pc.setLocalDescription(offer);
|
|
|
|
try {
|
|
const response = await fetch('/api/webrtc?src=' + cameraId, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/sdp' },
|
|
body: offer.sdp
|
|
});
|
|
|
|
const answer = await response.text();
|
|
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
|
} catch (error) {
|
|
console.error('Fullscreen stream error:', error);
|
|
}
|
|
}
|
|
|
|
function closeFullscreen() {
|
|
const modal = document.getElementById('fullscreen-modal');
|
|
const video = document.getElementById('modal-video');
|
|
|
|
modal.classList.add('hidden');
|
|
document.body.style.overflow = '';
|
|
|
|
if (fullscreenPC) {
|
|
fullscreenPC.close();
|
|
fullscreenPC = null;
|
|
}
|
|
video.srcObject = null;
|
|
}
|
|
|
|
function prevCamera() {
|
|
currentCameraIndex = (currentCameraIndex - 1 + cameras.length) % cameras.length;
|
|
openFullscreen(cameras[currentCameraIndex]);
|
|
}
|
|
|
|
function nextCamera() {
|
|
currentCameraIndex = (currentCameraIndex + 1) % cameras.length;
|
|
openFullscreen(cameras[currentCameraIndex]);
|
|
}
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!document.getElementById('fullscreen-modal').classList.contains('hidden')) {
|
|
if (e.key === 'Escape') closeFullscreen();
|
|
if (e.key === 'ArrowLeft') prevCamera();
|
|
if (e.key === 'ArrowRight') nextCamera();
|
|
}
|
|
});
|
|
|
|
let touchStartX = 0;
|
|
document.getElementById('fullscreen-modal').addEventListener('touchstart', (e) => {
|
|
touchStartX = e.touches[0].clientX;
|
|
});
|
|
document.getElementById('fullscreen-modal').addEventListener('touchend', (e) => {
|
|
const diff = touchStartX - e.changedTouches[0].clientX;
|
|
if (Math.abs(diff) > 50) {
|
|
if (diff > 0) nextCamera();
|
|
else prevCamera();
|
|
}
|
|
});
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const savedView = localStorage.getItem('cameraViewMode');
|
|
if (savedView) setViewMode(savedView);
|
|
|
|
// Stagger camera connections to avoid overwhelming the server
|
|
cameras.forEach((cam, index) => {
|
|
setTimeout(() => initCameraStream(cam), index * 500);
|
|
});
|
|
});
|
|
</script>
|
|
{% endblock %}
|