añadido Argos Core
This commit is contained in:
@@ -5,83 +5,136 @@ metadata:
|
||||
namespace: argos-core
|
||||
data:
|
||||
app.py: |
|
||||
import os, sqlite3, time
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
import os
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
||||
from minio import Minio
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote
|
||||
from datetime import timezone
|
||||
import io
|
||||
|
||||
DB="/data/argos.db"
|
||||
mc=Minio(os.getenv("MINIO_ENDPOINT","s3.argos.interna"),
|
||||
access_key=os.getenv("MINIO_ACCESS_KEY"),
|
||||
secret_key=os.getenv("MINIO_SECRET_KEY"),
|
||||
secure=os.getenv("MINIO_SECURE","false").lower()=="true")
|
||||
app=FastAPI()
|
||||
BUCKET = os.getenv("MINIO_BUCKET", "argos")
|
||||
endpoint = os.getenv("MINIO_ENDPOINT", "minio.argos-core.svc.cluster.local:9000")
|
||||
access_key = os.getenv("MINIO_ACCESS_KEY")
|
||||
secret_key = os.getenv("MINIO_SECRET_KEY")
|
||||
secure = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
||||
|
||||
def rows(limit=100, camera=None, since=None):
|
||||
q="SELECT id, ts, edge, camera, label, s3url, thumb_s3 FROM events"
|
||||
cond=[]; args=[]
|
||||
if camera: cond.append("camera=?"); args.append(camera)
|
||||
if since: cond.append("ts>=?"); args.append(int(since))
|
||||
if cond: q+=" WHERE "+ " AND ".join(cond)
|
||||
q+=" ORDER BY ts DESC LIMIT ?"; args.append(limit)
|
||||
con=sqlite3.connect(DB); cur=con.cursor()
|
||||
cur.execute(q, tuple(args)); r=cur.fetchall(); con.close()
|
||||
return r
|
||||
mc = Minio(endpoint, access_key=access_key, secret_key=secret_key, secure=secure)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
# simple check contra MinIO
|
||||
try:
|
||||
mc.list_buckets()
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _iter_events(prefix: str | None, limit: int):
|
||||
# list_objects devuelve en orden “no garantizado”; recolectamos y ordenamos por last_modified desc
|
||||
objs = []
|
||||
for obj in mc.list_objects(BUCKET, prefix=prefix, recursive=True):
|
||||
# omitimos directorios “lógicos” y thumbs por defecto en el listado principal
|
||||
if obj.is_dir:
|
||||
continue
|
||||
if "/thumbs/" in obj.object_name:
|
||||
continue
|
||||
objs.append(obj)
|
||||
objs.sort(key=lambda o: o.last_modified or 0, reverse=True)
|
||||
return objs[:limit]
|
||||
|
||||
@app.get("/api/events")
|
||||
def api_events(limit:int=100, camera:str=None, since:int=None):
|
||||
return [dict(id=i, ts=t, edge=e, camera=c, label=l or "", s3url=s, thumb=th or "")
|
||||
for (i,t,e,c,l,s,th) in rows(limit,camera,since)]
|
||||
def api_events(prefix: str | None = Query(default=None), limit: int = 50):
|
||||
data = []
|
||||
for o in _iter_events(prefix, limit):
|
||||
ts = None
|
||||
if o.last_modified:
|
||||
ts = o.last_modified.astimezone(timezone.utc).isoformat()
|
||||
# si existe thumb, la inferimos (cam/... → cam/thumbs/...)
|
||||
thumb = None
|
||||
parts = o.object_name.split("/")
|
||||
if len(parts) >= 2:
|
||||
maybe = f"{parts[0]}/thumbs/" + parts[-1].rsplit(".",1)[0] + ".jpg"
|
||||
try:
|
||||
mc.stat_object(BUCKET, maybe)
|
||||
thumb = maybe
|
||||
except:
|
||||
pass
|
||||
data.append({
|
||||
"key": o.object_name,
|
||||
"size": o.size,
|
||||
"last_modified": ts,
|
||||
"thumb": thumb
|
||||
})
|
||||
return JSONResponse(data)
|
||||
|
||||
@app.get("/api/url/{event_id}")
|
||||
def presign(event_id: str, expires: int = 600):
|
||||
con=sqlite3.connect(DB); cur=con.cursor()
|
||||
cur.execute("SELECT s3url FROM events WHERE id=?", (event_id,))
|
||||
row=cur.fetchone(); con.close()
|
||||
if not row: raise HTTPException(404, "Not found")
|
||||
s3url=row[0]; p=urlparse(s3url); b=p.netloc; k=p.path.lstrip("/")
|
||||
return {"url": mc.presigned_get_object(b, k, expires=expires)}
|
||||
@app.get("/file")
|
||||
def file(key: str):
|
||||
try:
|
||||
resp = mc.get_object(BUCKET, key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=404, detail=f"not found: {e}")
|
||||
# stream al cliente sin exponer MinIO
|
||||
def gen():
|
||||
for d in resp.stream(32*1024):
|
||||
yield d
|
||||
# tipo de contenido básico por extensión
|
||||
ctype = "application/octet-stream"
|
||||
k = key.lower()
|
||||
if k.endswith(".mp4"): ctype = "video/mp4"
|
||||
elif k.endswith(".jpg") or k.endswith(".jpeg"): ctype = "image/jpeg"
|
||||
return StreamingResponse(gen(), media_type=ctype)
|
||||
|
||||
@app.get("/view", response_class=HTMLResponse)
|
||||
def view(key: str):
|
||||
keyq = quote(key)
|
||||
return f"""
|
||||
<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Ver {key}</title>
|
||||
<style>body{{font-family:sans-serif;padding:16px}} video{{max-width:100%;height:auto}}</style>
|
||||
</head><body>
|
||||
<h2>{key}</h2>
|
||||
<video controls src="/file?key={keyq}"></video>
|
||||
<p><a href="/file?key={keyq}" download>Descargar</a></p>
|
||||
<p><a href="/">Volver</a></p>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index():
|
||||
return """
|
||||
<!doctype html><meta charset="utf-8"><title>ARGOS Panel</title>
|
||||
<style>body{font-family:system-ui;margin:1.5rem} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
|
||||
.card{border:1px solid #ddd;border-radius:10px;padding:10px} img{width:100%;height:160px;object-fit:cover;border-radius:8px}
|
||||
button{padding:.4rem .6rem;margin-right:.3rem}</style>
|
||||
<h1>ARGOS – Alarmas</h1>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div style="margin-top:1rem"><video id="player" width="960" controls></video></div>
|
||||
<script>
|
||||
const fmt=t=>new Date(t*1000).toLocaleString();
|
||||
async function load(){
|
||||
const r=await fetch('/api/events?limit=100'); const data=await r.json();
|
||||
const g=document.getElementById('grid'); g.innerHTML='';
|
||||
for(const ev of data){
|
||||
const d=document.createElement('div'); d.className='card';
|
||||
const img = ev.thumb ? `<img src="${ev.thumb.replace('s3://','/api/url/THUMB?key=')}" alt="thumb">` : '';
|
||||
d.innerHTML = `${img}<div><b>${ev.camera}</b> — ${fmt(ev.ts)}<br>${ev.label||''}</div>
|
||||
<div style="margin-top:.4rem">
|
||||
<button data-id="${ev.id}" data-action="clip">Ver clip</button>
|
||||
<button data-path="${ev.camera}" data-action="live">En directo</button>
|
||||
</div>`;
|
||||
g.appendChild(d);
|
||||
}
|
||||
g.onclick=async (e)=>{
|
||||
if(e.target.tagName!=='BUTTON') return;
|
||||
const v=document.getElementById('player');
|
||||
if(e.target.dataset.action==='clip'){
|
||||
const id=e.target.dataset.id;
|
||||
const j=await (await fetch('/api/url/'+id)).json();
|
||||
v.src=j.url; v.play();
|
||||
}else if(e.target.dataset.action==='live'){
|
||||
const path=e.target.dataset.path;
|
||||
// usa MediaMTX web player
|
||||
window.open('http://mediamtx.argos.interna/?path='+encodeURIComponent(path),'_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
load(); setInterval(load,10000);
|
||||
</script>
|
||||
def home(prefix: str | None = None, limit: int = 20):
|
||||
# HTML mínimo que consume /api/events y pinta thumbs/links
|
||||
p = f"&prefix={quote(prefix)}" if prefix else ""
|
||||
items = []
|
||||
for e in _iter_events(prefix, limit):
|
||||
thumb_html = ""
|
||||
# intentar thumb
|
||||
parts = e.object_name.split("/")
|
||||
if len(parts) >= 2:
|
||||
maybe = f"{parts[0]}/thumbs/" + parts[-1].rsplit(".",1)[0] + ".jpg"
|
||||
try:
|
||||
mc.stat_object(BUCKET, maybe)
|
||||
thumb_html = f'<img src="/file?key={quote(maybe)}" style="height:80px;border-radius:6px;margin-right:8px" />'
|
||||
except:
|
||||
pass
|
||||
items.append(f"""
|
||||
<li style="margin:8px 0;list-style:none;display:flex;align-items:center">
|
||||
{thumb_html}
|
||||
<a href="/view?key={quote(e.object_name)}">{e.object_name}</a>
|
||||
</li>
|
||||
""")
|
||||
items_html = "\n".join(items) or "<p><i>Sin eventos aún.</i></p>"
|
||||
return f"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>Argos Panel</title>
|
||||
<style>body{{font-family:sans-serif;max-width:1000px;margin:24px auto;padding:0 16px}}</style>
|
||||
</head><body>
|
||||
<h1>Argos Panel</h1>
|
||||
<form>
|
||||
<label>Prefijo (cámara): <input type="text" name="prefix" value="{prefix or ""}"></label>
|
||||
<label style="margin-left:12px">Límite: <input type="number" name="limit" value="{limit}" min="1" max="200"></label>
|
||||
<button type="submit">Filtrar</button>
|
||||
</form>
|
||||
<ul style="padding:0">{items_html}</ul>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user