Files
homelab-dashboard/app/templates/cameras.html
Dashboard 89cdb022f3 Initial commit: Homelab Dashboard with YAML configuration
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
2026-02-02 20:27:05 +00:00

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 %}