feat: initial commit

This commit is contained in:
nikola
2026-05-19 14:53:36 +02:00
commit 6536b5fa23
39 changed files with 2257 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
<!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>