Files
video-surveillance-portal/frontend/index.html
T
2026-05-19 14:53:36 +02:00

383 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎥 Video Surveillance Portal</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Orbitron:wght@400;700&display=swap');
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--blood:#8b0000;--blood-bright:#cc0000;--crimson:#660000;
--ash:#2a2525;--bone:#c0b8b0;--dark:#050505;--panel:rgba(8,5,5,.94);
--border:rgba(139,0,0,.2);--text:#b0a8a0;--dim:#555;
}
body{background:var(--dark);color:var(--text);font-family:'Orbitron',sans-serif;min-height:100vh}
#app{max-width:1400px;margin:0 auto;padding:15px}
h1{font-family:'Cinzel',serif;font-weight:700;color:var(--blood-bright);text-shadow:0 0 8px var(--crimson);text-align:center;margin-bottom:5px;font-size:1.8rem;letter-spacing:3px}
.subtitle{text-align:center;color:#666;font-size:.7rem;margin-bottom:18px;font-family:'Cinzel',serif}
/* NAV */
nav{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-bottom:18px}
nav button{font-family:'Cinzel',serif;font-size:.7rem;font-weight:600;padding:8px 18px;background:transparent;color:var(--bone);border:1px solid var(--border);border-radius:5px;cursor:pointer;text-transform:uppercase;letter-spacing:1px;transition:all .25s}
nav button:hover,nav button.active{background:rgba(139,0,0,.3);border-color:var(--blood-bright);color:#fff;box-shadow:0 0 10px var(--crimson)}
.tab{display:none}.tab.active{display:block}
/* PANELS */
.panel{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:20px;margin-bottom:15px}
.panel h2{font-family:'Cinzel',serif;font-weight:700;color:var(--blood-bright);margin-bottom:12px;font-size:1rem;letter-spacing:2px}
/* CAMERA GRID */
.cam-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:12px}
.cam-card{background:rgba(0,0,0,.4);border:1px solid var(--border);border-radius:8px;overflow:hidden;position:relative}
.cam-card video,.cam-card img{width:100%;height:240px;object-fit:cover;background:#000;display:block}
.cam-card .cam-label{position:absolute;top:8px;left:8px;background:rgba(0,0,0,.75);color:var(--bone);padding:4px 10px;border-radius:4px;font-size:.65rem;font-family:'Cinzel',serif}
.cam-card .cam-status{position:absolute;top:8px;right:8px;width:10px;height:10px;border-radius:50%}
.cam-card .cam-status.online{background:#0f0;box-shadow:0 0 8px #0f0}
.cam-card .cam-status.offline{background:#f00;box-shadow:0 0 8px #f00}
.cam-card .cam-ptz{position:absolute;bottom:8px;right:8px;display:flex;gap:4px}
.cam-card .cam-ptz button{width:28px;height:28px;background:rgba(0,0,0,.6);border:1px solid rgba(255,255,255,.15);border-radius:4px;color:#fff;cursor:pointer;font-size:.7rem;display:flex;align-items:center;justify-content:center}
.cam-card .cam-ptz button:hover{background:rgba(139,0,0,.5)}
.cam-card .cam-actions{position:absolute;bottom:8px;left:8px;display:flex;gap:4px}
.cam-card .cam-actions button{font-family:'Cinzel',serif;font-size:.55rem;padding:4px 8px;background:rgba(139,0,0,.4);color:#fff;border:none;border-radius:3px;cursor:pointer}
/* PHONE CAMERA */
.phone-cam-section{text-align:center;padding:30px}
#phoneStreamBtn{font-family:'Cinzel',serif;font-weight:700;font-size:1rem;padding:14px 36px;background:rgba(139,0,0,.5);color:#fff;border:2px solid var(--blood-bright);border-radius:8px;cursor:pointer;text-transform:uppercase;letter-spacing:2px;box-shadow:0 0 15px var(--crimson);transition:all .3s}
#phoneStreamBtn:hover{background:rgba(139,0,0,.7);box-shadow:0 0 25px var(--blood)}
#phoneStreamBtn.streaming{background:rgba(0,100,0,.5);border-color:#0f0;box-shadow:0 0 15px #0f0}
#phonePreview{max-width:400px;margin:12px auto;border-radius:8px;overflow:hidden;display:none}
#phonePreview video{width:100%;height:auto;background:#000}
/* FORMS */
.form-row{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-bottom:12px}
.form-row input,.form-row select{font-family:'Orbitron',sans-serif;font-size:.7rem;padding:8px 12px;background:#000;color:var(--text);border:1px solid var(--border);border-radius:5px;min-width:140px}
.form-row input:focus{outline:none;border-color:var(--blood-bright)}
.form-row label{font-size:.6rem;color:var(--dim);text-transform:uppercase;letter-spacing:1px;display:block;margin-bottom:3px}
.form-row .field{display:flex;flex-direction:column}
.btn{font-family:'Cinzel',serif;font-weight:600;font-size:.7rem;padding:8px 18px;background:rgba(139,0,0,.4);color:#fff;border:1px solid var(--blood-bright);border-radius:5px;cursor:pointer;text-transform:uppercase;letter-spacing:1px;transition:all .2s}
.btn:hover{background:rgba(139,0,0,.7);box-shadow:0 0 12px var(--crimson)}
.btn.danger{background:rgba(80,0,0,.5);border-color:#600}
.btn.danger:hover{background:rgba(120,0,0,.7)}
/* TABLES */
table{width:100%;border-collapse:collapse;font-size:.7rem}
th{text-align:left;padding:8px 10px;background:rgba(139,0,0,.1);border-bottom:1px solid var(--border);color:var(--bone);font-family:'Cinzel',serif;text-transform:uppercase;letter-spacing:1px;font-size:.6rem}
td{padding:8px 10px;border-bottom:1px solid rgba(255,255,255,.03);color:var(--text)}
tr:hover td{background:rgba(139,0,0,.04)}
.badge{padding:3px 8px;border-radius:3px;font-size:.55rem;font-family:'Cinzel',serif;text-transform:uppercase}
.badge.arrived{background:rgba(0,100,0,.3);color:#0f0}.badge.departed{background:rgba(100,0,0,.3);color:#f66}.badge.seen{background:rgba(100,100,0,.3);color:#ff0}
.badge.human{background:rgba(139,0,0,.3);color:#f66}
.snap-thumb{width:60px;height:40px;object-fit:cover;border-radius:3px;cursor:pointer;border:1px solid var(--border)}
/* TOAST */
.toast{position:fixed;bottom:20px;right:20px;z-index:999;background:var(--panel);border:1px solid var(--blood-bright);border-radius:8px;padding:12px 18px;box-shadow:0 0 20px var(--crimson);animation:toastIn .3s ease,toastOut .3s ease 3s forwards;font-family:'Cinzel',serif;font-size:.75rem}
@keyframes toastIn{from{transform:translateY(40px);opacity:0}to{transform:translateY(0);opacity:1}}
@keyframes toastOut{from{transform:translateY(0);opacity:1}to{transform:translateY(40px);opacity:0}}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:500;display:flex;align-items:center;justify-content:center}
.modal-overlay img{max-width:90vw;max-height:90vh;border-radius:8px;cursor:pointer}
@media(max-width:600px){.cam-grid{grid-template-columns:1fr}.form-row{flex-direction:column}}
</style>
</head>
<body>
<div id="app">
<h1>🎥 VIDEO SURVEILLANCE</h1>
<p class="subtitle">— Dark Portal —</p>
<nav>
<button class="active" data-tab="live">📹 Live View</button>
<button data-tab="alerts">🚨 Alerts</button>
<button data-tab="plates">🚗 Plates</button>
<button data-tab="settings">⚙️ Settings</button>
</nav>
<!-- LIVE VIEW TAB -->
<section class="tab active" id="tab-live">
<div class="panel" id="phonePanel">
<h2>📱 Phone Camera Ingest</h2>
<div class="phone-cam-section">
<button id="phoneStreamBtn">📱 Start Phone Camera</button>
<div id="phonePreview"><video id="phoneLocalVideo" muted autoplay playsinline></video></div>
<p style="color:var(--dim);font-size:.6rem;margin-top:8px">Opens your phone/PC camera and streams to the portal</p>
</div>
</div>
<div class="panel">
<h2>📹 Cameras</h2>
<div class="cam-grid" id="camGrid">
<p style="color:var(--dim);grid-column:1/-1;text-align:center;padding:40px">No cameras added yet. Go to Settings to add one.</p>
</div>
</div>
</section>
<!-- ALERTS TAB -->
<section class="tab" id="tab-alerts">
<div class="panel">
<h2>🚨 Alert History</h2>
<div style="overflow-x:auto">
<table><thead><tr><th>Time</th><th>Camera</th><th>Type</th><th>Snapshot</th><th>Channels</th></tr></thead>
<tbody id="alertTable"><tr><td colspan="5" style="color:var(--dim);text-align:center">No alerts yet</td></tr></tbody></table>
</div>
</div>
</section>
<!-- PLATES TAB -->
<section class="tab" id="tab-plates">
<div class="panel">
<h2>🚗 License Plate Log</h2>
<div style="overflow-x:auto">
<table><thead><tr><th>Time</th><th>Camera</th><th>Plate</th><th>Event</th><th>Confidence</th><th>Snapshot</th></tr></thead>
<tbody id="plateTable"><tr><td colspan="6" style="color:var(--dim);text-align:center">No plates logged yet</td></tr></tbody></table>
</div>
</div>
</section>
<!-- SETTINGS TAB -->
<section class="tab" id="tab-settings">
<div class="panel">
<h2> Add Camera</h2>
<div class="form-row">
<div class="field"><label>Name</label><input id="camName" placeholder="Front Door"></div>
<div class="field"><label>RTSP URL</label><input id="camRtsp" placeholder="rtsp://192.168.1.100:554/stream1" style="min-width:260px"></div>
<div class="field"><label>ONVIF Host</label><input id="camOnvifHost" placeholder="192.168.1.100"></div>
<div class="field"><label>ONVIF Port</label><input id="camOnvifPort" value="80" style="min-width:60px"></div>
<div class="field"><label>ONVIF User</label><input id="camOnvifUser" value="admin"></div>
<div class="field"><label>ONVIF Pass</label><input id="camOnvifPass" type="password"></div>
<button class="btn" id="addCamBtn" style="align-self:flex-end">Add Camera</button>
</div>
</div>
<div class="panel">
<h2>📋 Manage Cameras</h2>
<div id="camList"></div>
</div>
<div class="panel">
<h2>🔑 API Keys</h2>
<p style="color:var(--dim);font-size:.65rem;margin-bottom:10px">Set these in <code>.env</code> file or environment variables</p>
<div class="form-row">
<div class="field"><label>PlateRecognizer Token</label><input id="prToken" placeholder="token_..."></div>
<div class="field"><label>Twilio SID</label><input id="twSid" placeholder="AC..."></div>
<div class="field"><label>Twilio Token</label><input id="twToken" type="password"></div>
<div class="field"><label>Twilio From</label><input id="twFrom" placeholder="+1234567890"></div>
<div class="field"><label>SMS To</label><input id="smsTo" placeholder="+1234567890"></div>
<button class="btn" id="saveKeysBtn">Save Keys</button>
</div>
</div>
</section>
</div>
<script>
// ═══════════════════════════════════════════
// VSP FRONTEND — Single Page App
// ═══════════════════════════════════════════
const API = '/api';
let cameras = [];
let phoneStreaming = false;
let phonePC = null;
let phoneStream = null;
// === NAVIGATION ===
document.querySelectorAll('nav button').forEach(b=>b.addEventListener('click',()=>{
document.querySelectorAll('nav button').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
document.getElementById('tab-'+b.dataset.tab).classList.add('active');
if(b.dataset.tab==='alerts') loadAlerts();
if(b.dataset.tab==='plates') loadPlates();
if(b.dataset.tab==='live') loadCameras();
if(b.dataset.tab==='settings') loadSettings();
}));
// === TOAST ===
function toast(msg){const t=document.createElement('div');t.className='toast';t.textContent=msg;document.body.appendChild(t);setTimeout(()=>t.remove(),3300)}
// === API HELPERS ===
async function api(path,opts={}){const r=await fetch(API+path,{headers:{'Content-Type':'application/json'},...opts});if(!r.ok){const e=await r.text();throw new Error(e)}if(r.status===204)return null;return r.json()}
// ═══════════════════════════════════════════
// LIVE VIEW — WebRTC playback
// ═══════════════════════════════════════════
async function loadCameras(){
try{
cameras = await api('/cameras/');
renderCamGrid();
}catch(e){console.error(e)}
}
async function renderCamGrid(){
const grid = document.getElementById('camGrid');
if(!cameras.length){
grid.innerHTML='<p style="color:var(--dim);grid-column:1/-1;text-align:center;padding:40px">No cameras added. Go to Settings → Add Camera.</p>';
return;
}
grid.innerHTML = cameras.map(c=>`
<div class="cam-card" id="card-${c.id}">
<div class="cam-label">${esc(c.name)}</div>
<div class="cam-status online" id="status-${c.id}"></div>
<video id="vid-${c.id}" muted autoplay playsinline style="width:100%;height:240px;background:#000"></video>
<div class="cam-actions">
<button onclick="snapshotCam(${c.id})" title="Snapshot">📸</button>
<button onclick="deleteCam(${c.id})" title="Delete">🗑️</button>
</div>
<div class="cam-ptz">
<button onclick="ptz(${c.id},'up')">▲</button>
<button onclick="ptz(${c.id},'down')">▼</button>
<button onclick="ptz(${c.id},'left')">◀</button>
<button onclick="ptz(${c.id},'right')">▶</button>
<button onclick="ptzZoom(${c.id},true)" title="Zoom in">+</button>
<button onclick="ptzZoom(${c.id},false)" title="Zoom out"></button>
</div>
</div>
`).join('');
// Connect WebRTC for each camera
cameras.forEach(c=>startWHEP(c.id));
}
async function startWHEP(camId){
try{
const stream = await api(`/streams/camera/${camId}`);
const video = document.getElementById('vid-'+camId);
if(!video) return;
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
pc.addTransceiver('video',{direction:'recvonly'});
pc.addTransceiver('audio',{direction:'recvonly'});
pc.ontrack = e => {video.srcObject = e.streams[0]};
pc.oniceconnectionstatechange = ()=>{
const status = document.getElementById('status-'+camId);
if(status)status.className='cam-status '+(pc.iceConnectionState==='connected'?'online':'offline');
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(stream.whep_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:pc.localDescription.sdp});
if(!resp.ok) throw new Error('WHEP failed');
await pc.setRemoteDescription({type:'answer',sdp:await resp.text()});
}catch(e){console.error('WHEP for cam',camId,e)}
}
// === PHONE CAMERA INGEST (WHIP) ===
document.getElementById('phoneStreamBtn').addEventListener('click',togglePhoneStream);
async function togglePhoneStream(){
if(phoneStreaming){stopPhoneStream();return}
try{
const stream = await navigator.mediaDevices.getUserMedia({video:{width:1280,height:720},audio:false});
phoneStream = stream;
const localVid = document.getElementById('phoneLocalVideo');
localVid.srcObject = stream;
document.getElementById('phonePreview').style.display='block';
const streamInfo = await api('/streams/phone');
const pc = new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
phonePC = pc;
stream.getTracks().forEach(t=>pc.addTrack(t,stream));
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(streamInfo.whip_url,{method:'POST',headers:{'Content-Type':'application/sdp'},body:pc.localDescription.sdp});
if(!resp.ok) throw new Error('WHIP failed: '+resp.status);
await pc.setRemoteDescription({type:'answer',sdp:await resp.text()});
phoneStreaming = true;
const btn = document.getElementById('phoneStreamBtn');
btn.textContent = '⏹️ Stop Phone Camera';
btn.classList.add('streaming');
toast('📱 Phone camera streaming LIVE');
}catch(e){toast('Error: '+e.message);console.error(e)}
}
function stopPhoneStream(){
if(phonePC){phonePC.close();phonePC=null}
if(phoneStream){phoneStream.getTracks().forEach(t=>t.stop());phoneStream=null}
phoneStreaming=false;
const btn=document.getElementById('phoneStreamBtn');
btn.textContent='📱 Start Phone Camera';
btn.classList.remove('streaming');
document.getElementById('phonePreview').style.display='none';
toast('Phone camera stopped');
}
// === PTZ ===
async function ptz(camId,dir){
try{await api(`/cameras/${camId}/ptz`,{method:'POST',body:JSON.stringify({direction:dir})})}
catch(e){toast('PTZ: '+e.message)}
}
async function ptzZoom(camId,zin){
try{await api(`/cameras/${camId}/ptz/zoom`,{method:'POST',body:JSON.stringify({zoom_in:zin})})}
catch(e){toast('Zoom: '+e.message)}
}
async function snapshotCam(camId){
try{await api(`/cameras/${camId}/snapshot`,{method:'POST'});toast('Snapshot saved')}
catch(e){toast('Snapshot: '+e.message)}
}
async function deleteCam(camId){
if(!confirm('Delete camera?'))return;
try{await api(`/cameras/${camId}`,{method:'DELETE'});loadCameras();toast('Camera deleted')}
catch(e){toast(e.message)}
}
// === ADD CAMERA ===
document.getElementById('addCamBtn').addEventListener('click',async()=>{
const data={
name:document.getElementById('camName').value,
rtsp_url:document.getElementById('camRtsp').value,
onvif_host:document.getElementById('camOnvifHost').value||null,
onvif_port:parseInt(document.getElementById('camOnvifPort').value)||80,
onvif_user:document.getElementById('camOnvifUser').value||null,
onvif_pass:document.getElementById('camOnvifPass').value||null,
};
if(!data.name||!data.rtsp_url)return toast('Name and RTSP URL required');
try{await api('/cameras/',{method:'POST',body:JSON.stringify(data)});loadCameras();toast('Camera added!')}
catch(e){toast(e.message)}
});
// === SETTINGS ===
async function loadSettings(){
const camList=document.getElementById('camList');
try{
const cams=await api('/cameras/');
camList.innerHTML=cams.length?cams.map(c=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid rgba(255,255,255,.03)"><span>📹 ${esc(c.name)} <span style="color:var(--dim);font-size:.6rem">${esc(c.rtsp_url)}</span></span><button class="btn danger" onclick="deleteCam(${c.id})">Delete</button></div>`).join(''):'<p style="color:var(--dim)">No cameras</p>';
}catch(e){}
}
async function loadAlerts(){
try{
const alerts=await api('/alerts/?limit=50');
const tbody=document.getElementById('alertTable');
if(!alerts.length){tbody.innerHTML='<tr><td colspan="5" style="color:var(--dim);text-align:center">No alerts yet</td></tr>';return}
tbody.innerHTML=alerts.map(a=>`<tr><td>${fmtTime(a.created_at)}</td><td>${esc(a.camera_name||'?')}</td><td><span class="badge human">${a.type}</span></td><td>${a.snapshot_url?`<img class="snap-thumb" src="${a.snapshot_url}" onclick="showImage(this.src)">`:'—'}</td><td>${esc(a.channels_sent||'—')}</td></tr>`).join('');
}catch(e){}
}
async function loadPlates(){
try{
const plates=await api('/plates/?limit=50');
const tbody=document.getElementById('plateTable');
if(!plates.length){tbody.innerHTML='<tr><td colspan="6" style="color:var(--dim);text-align:center">No plates yet</td></tr>';return}
tbody.innerHTML=plates.map(p=>`<tr><td>${fmtTime(p.created_at)}</td><td>${esc(p.camera_name||'?')}</td><td><strong>${esc(p.plate)}</strong></td><td><span class="badge ${p.event_type}">${p.event_type}</span></td><td>${p.confidence?Math.round(p.confidence*100)+'%':'—'}</td><td>${p.snapshot_url?`<img class="snap-thumb" src="${p.snapshot_url}" onclick="showImage(this.src)">`:'—'}</td></tr>`).join('');
}catch(e){}
}
function showImage(src){
const ov=document.createElement('div');ov.className='modal-overlay';
ov.innerHTML=`<img src="${src}">`;
ov.addEventListener('click',()=>ov.remove());
document.body.appendChild(ov);
}
function fmtTime(iso){if(!iso)return'';const d=new Date(iso);return d.toLocaleString()}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// === INIT ===
loadCameras();
setInterval(()=>{if(document.querySelector('#tab-live.active'))loadCameras()},15000);
</script>
</body>
</html>