diff --git a/apps/backend/main.py b/apps/backend/main.py index 103b779..2be3000 100644 --- a/apps/backend/main.py +++ b/apps/backend/main.py @@ -1,13 +1,12 @@ from __future__ import annotations import logging -import os from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from prometheus_client import make_asgi_app from libs.config.settings import settings -from apps.backend.routes import alerts, ingest, snapshot, cameras, feedback +from apps.backend.routes import router as feature_routers logging.basicConfig( level = logging.INFO, @@ -30,11 +29,7 @@ def create_app() -> FastAPI: allow_headers = ["*"], ) - app.include_router(ingest.router, prefix="/ingest", tags=["ingest"]) - app.include_router(alerts.router, prefix="/alerts", tags=["alerts"]) - app.include_router(snapshot.router, prefix="/snapshot", tags=["snapshot"]) - app.include_router(cameras.router, prefix="/cameras", tags=["cameras"]) - app.include_router(feedback.router, prefix="/feedback", tags=["feedback"]) + app.include_router(feature_routers) # Prometheus metrics scrape endpoint metrics_app = make_asgi_app() diff --git a/apps/backend/routes/__init__.py b/apps/backend/routes/__init__.py index e69de29..9fe38d2 100644 --- a/apps/backend/routes/__init__.py +++ b/apps/backend/routes/__init__.py @@ -0,0 +1,32 @@ +""" +Auto-discover and register all routers in this package. +Each module may expose one or more APIRouter instances. +""" +from __future__ import annotations +import importlib +import pkgutil +import logging + +from fastapi import APIRouter + +logger = logging.getLogger(__name__) + +_discovered_router = APIRouter() + + +def _autodiscover(): + package_dir = __path__[0] + for _, module_name, _ in pkgutil.iter_modules([package_dir]): + if module_name in ("__init__",): + continue + module = importlib.import_module(f"{__name__}.{module_name}") + for attr_name in dir(module): + obj = getattr(module, attr_name) + if isinstance(obj, APIRouter): + _discovered_router.include_router(obj) + logger.debug("Discovered router %s from %s", attr_name, module_name) + + +_autodiscover() + +router = _discovered_router diff --git a/apps/backend/routes/alerts.py b/apps/backend/routes/alerts.py index ac35382..4855031 100644 --- a/apps/backend/routes/alerts.py +++ b/apps/backend/routes/alerts.py @@ -19,7 +19,7 @@ logger = logging.getLogger("eagle.alerts") -router = APIRouter() +router = APIRouter(prefix="/alerts", tags=["alerts"]) def _parse_alert(raw: str) -> AlertResponse | None: try: diff --git a/apps/backend/routes/bookmarks.py b/apps/backend/routes/bookmarks.py new file mode 100644 index 0000000..40c8b0e --- /dev/null +++ b/apps/backend/routes/bookmarks.py @@ -0,0 +1,324 @@ +""" +Event Bookmarking and Investigation Workspace API + +Endpoints: + POST /bookmarks - Bookmark an event + GET /bookmarks - List bookmarks + DELETE /bookmarks/{id} - Remove bookmark + POST /investigations - Create investigation folder + GET /investigations - List investigations + POST /investigations/{inv_id}/events - Add event to investigation + GET /investigations/{inv_id}/events - List events in investigation + POST /bookmarks/{id}/notes - Add notes to bookmark + GET /bookmarks/{id}/notes - List notes for bookmark + GET /investigations/{inv_id}/timeline - Timeline view + GET /bookmarks/search - Search bookmarks + GET /investigations/{id}/export - Export workspace +""" +from __future__ import annotations +import logging +import uuid +import time +import json +from typing import Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/bookmarks", tags=["bookmarks"]) + +# In-memory stores +_bookmarks: dict[str, dict] = {} +_investigations: dict[str, dict] = {} +_investigation_events: dict[str, list] = {} +_bookmark_notes: dict[str, list] = {} + + +# Schemas + +class BookmarkRequest(BaseModel): + alert_id: str + camera_id: str + track_id: int + label: str + notes: Optional[str] = None + + +class BookmarkResponse(BaseModel): + id: str + alert_id: str + camera_id: str + track_id: int + label: str + notes: Optional[str] = None + created_at: float + note_count: int = 0 + + +class InvestigationRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: str = "" + + +class InvestigationResponse(BaseModel): + id: str + name: str + description: str + owner_id: str + created_at: float + updated_at: float + event_count: int = 0 + + +class AddEventRequest(BaseModel): + alert_id: str + camera_id: str + track_id: int + label: str + notes: Optional[str] = None + + +class NoteRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=1000) + + +class NoteResponse(BaseModel): + id: str + bookmark_id: str + content: str + created_at: float + + +class TimelineEvent(BaseModel): + id: str + alert_id: str + camera_id: str + track_id: int + label: str + timestamp: float + notes: Optional[str] = None + + +class ExportResponse(BaseModel): + investigation_id: str + name: str + exported_at: float + format: str + data: str + + +# Helpers + +def _log_debug(msg, *args, **kwargs): + logger.debug(msg, *args, **kwargs) + + +def _get_bookmark(bookmark_id: str) -> dict: + bm = _bookmarks.get(bookmark_id) + if not bm: + raise HTTPException(status_code=404, detail=f"Bookmark {bookmark_id!r} not found") + return bm + + +def _get_investigation(inv_id: str) -> dict: + inv = _investigations.get(inv_id) + if not inv: + raise HTTPException(status_code=404, detail=f"Investigation {inv_id!r} not found") + return inv + + +# Routes + +@router.post("", response_model=BookmarkResponse) +def create_bookmark(body: BookmarkRequest, operator_id: str = Query(...)): + bm_id = str(uuid.uuid4()) + now = time.time() + bm = { + "id": bm_id, + "alert_id": body.alert_id, + "camera_id": body.camera_id, + "track_id": body.track_id, + "label": body.label, + "notes": body.notes, + "owner_id": operator_id, + "created_at": now, + } + _bookmarks[bm_id] = bm + _bookmark_notes[bm_id] = [] + _log_debug("Bookmark created id=%s alert=%s", bm_id, body.alert_id) + return BookmarkResponse(**bm, note_count=0) + + +@router.get("", response_model=list[BookmarkResponse]) +def list_bookmarks(operator_id: str = Query(...), limit: int = Query(50, ge=1, le=200)): + results = [] + for bm_id, bm in _bookmarks.items(): + if bm.get("owner_id") == operator_id: + results.append(BookmarkResponse(**bm, note_count=len(_bookmark_notes.get(bm_id, [])))) + return results[-limit:][::-1] + + +@router.delete("/{bookmark_id}") +def delete_bookmark(bookmark_id: str): + _get_bookmark(bookmark_id) + del _bookmarks[bookmark_id] + _bookmark_notes.pop(bookmark_id, None) + return {"deleted": bookmark_id} + + +@router.post("/investigations", response_model=InvestigationResponse, status_code=201) +def create_investigation(body: InvestigationRequest, operator_id: str = Query(...)): + inv_id = str(uuid.uuid4()) + now = time.time() + inv = { + "id": inv_id, + "name": body.name, + "description": body.description, + "owner_id": operator_id, + "created_at": now, + "updated_at": now, + } + _investigations[inv_id] = inv + _investigation_events[inv_id] = [] + _log_debug("Investigation created id=%s name=%s", inv_id, body.name) + return InvestigationResponse(**inv, event_count=0) + + +@router.get("/investigations", response_model=list[InvestigationResponse]) +def list_investigations(operator_id: str = Query(...)): + results = [] + for inv_id, inv in _investigations.items(): + if inv.get("owner_id") == operator_id: + results.append(InvestigationResponse(**inv, event_count=len(_investigation_events.get(inv_id, [])))) + return results + + +@router.get("/investigations/{inv_id}", response_model=InvestigationResponse) +def get_investigation(inv_id: str): + inv = _get_investigation(inv_id) + return InvestigationResponse(**inv, event_count=len(_investigation_events.get(inv_id, []))) + + +@router.post("/investigations/{inv_id}/events", response_model=BookmarkResponse, status_code=201) +def add_event_to_investigation(inv_id: str, body: AddEventRequest, operator_id: str = Query(...)): + _get_investigation(inv_id) + bm_id = str(uuid.uuid4()) + now = time.time() + bm = { + "id": bm_id, + "alert_id": body.alert_id, + "camera_id": body.camera_id, + "track_id": body.track_id, + "label": body.label, + "notes": body.notes, + "owner_id": operator_id, + "created_at": now, + } + _bookmarks[bm_id] = bm + _bookmark_notes[bm_id] = [] + _investigation_events[inv_id].append(bm_id) + _investigations[inv_id]["updated_at"] = now + _log_debug("Event added to investigation inv=%s bm=%s", inv_id, bm_id) + return BookmarkResponse(**bm, note_count=0) + + +@router.get("/investigations/{inv_id}/events", response_model=list[BookmarkResponse]) +def list_investigation_events(inv_id: str): + _get_investigation(inv_id) + bm_ids = _investigation_events.get(inv_id, []) + results = [] + for bm_id in bm_ids: + bm = _bookmarks.get(bm_id) + if bm: + results.append(BookmarkResponse(**bm, note_count=len(_bookmark_notes.get(bm_id, [])))) + return results + + +@router.post("/bookmarks/{bookmark_id}/notes", response_model=NoteResponse, status_code=201) +def add_note_to_bookmark(bookmark_id: str, body: NoteRequest): + _get_bookmark(bookmark_id) + note_id = str(uuid.uuid4()) + note = { + "id": note_id, + "bookmark_id": bookmark_id, + "content": body.content, + "created_at": time.time(), + } + _bookmark_notes[bookmark_id].append(note) + _log_debug("Note added to bookmark bm=%s note=%s", bookmark_id, note_id) + return NoteResponse(**note) + + +@router.get("/bookmarks/{bookmark_id}/notes", response_model=list[NoteResponse]) +def list_bookmark_notes(bookmark_id: str): + _get_bookmark(bookmark_id) + return _bookmark_notes.get(bookmark_id, []) + + +@router.get("/investigations/{inv_id}/timeline", response_model=list[TimelineEvent]) +def get_investigation_timeline(inv_id: str): + _get_investigation(inv_id) + bm_ids = _investigation_events.get(inv_id, []) + events = [] + for bm_id in bm_ids: + bm = _bookmarks.get(bm_id) + if bm: + events.append(TimelineEvent( + id=bm_id, + alert_id=bm["alert_id"], + camera_id=bm["camera_id"], + track_id=bm["track_id"], + label=bm["label"], + timestamp=bm["created_at"], + notes=bm.get("notes"), + )) + events.sort(key=lambda e: e.timestamp) + return events + + +@router.get("/search", response_model=list[BookmarkResponse]) +def search_bookmarks(q: str = Query(..., min_length=1), operator_id: str = Query(...)): + q_lower = q.lower() + results = [] + for bm_id, bm in _bookmarks.items(): + if bm.get("owner_id") != operator_id: + continue + haystack = f"{bm['label']} {bm.get('notes', '')} {bm['alert_id']} {bm['camera_id']}".lower() + if q_lower in haystack: + results.append(BookmarkResponse(**bm, note_count=len(_bookmark_notes.get(bm_id, [])))) + return results[::-1] + + +@router.get("/investigations/{inv_id}/export", response_model=ExportResponse) +def export_investigation(inv_id: str): + inv = _get_investigation(inv_id) + bm_ids = _investigation_events.get(inv_id, []) + events = [] + for bm_id in bm_ids: + bm = _bookmarks.get(bm_id) + if bm: + events.append({ + "id": bm_id, + "alert_id": bm["alert_id"], + "camera_id": bm["camera_id"], + "track_id": bm["track_id"], + "label": bm["label"], + "notes": bm.get("notes"), + "created_at": bm["created_at"], + }) + export_data = { + "investigation_id": inv_id, + "name": inv["name"], + "description": inv["description"], + "exported_at": time.time(), + "events": events, + } + return ExportResponse( + investigation_id=inv_id, + name=inv["name"], + exported_at=export_data["exported_at"], + format="json", + data=json.dumps(export_data, indent=2), + ) diff --git a/apps/backend/routes/feedback.py b/apps/backend/routes/feedback.py index 8847cfa..6a60585 100644 --- a/apps/backend/routes/feedback.py +++ b/apps/backend/routes/feedback.py @@ -14,7 +14,7 @@ logger = logging.getLogger("eagle.feedback") -router = APIRouter() +router = APIRouter(prefix="/feedback", tags=["feedback"]) @router.post("/{alert_id}", response_model=FeedbackResponse) def record_feedback( diff --git a/apps/backend/routes/ingest.py b/apps/backend/routes/ingest.py index a797e89..0c88537 100644 --- a/apps/backend/routes/ingest.py +++ b/apps/backend/routes/ingest.py @@ -20,7 +20,7 @@ logger = logging.getLogger("eagle.ingest") -router = APIRouter() +router = APIRouter(prefix="/ingest", tags=["ingest"]) def _decode_frame(b64: str | None) -> np.ndarray: """Decode base64 JPEG to BGR numpy array. Returns blank frame if None.""" diff --git a/apps/backend/routes/snapshot.py b/apps/backend/routes/snapshot.py index 6c2231c..38b5fcd 100644 --- a/apps/backend/routes/snapshot.py +++ b/apps/backend/routes/snapshot.py @@ -5,7 +5,7 @@ logger = logging.getLogger("eagle.snapshot") -router = APIRouter() +router = APIRouter(prefix="/snapshot", tags=["snapshot"]) @router.get("", tags=["snapshot"]) diff --git a/apps/dashboard/src/components/BookmarkPanel.jsx b/apps/dashboard/src/components/BookmarkPanel.jsx new file mode 100644 index 0000000..ea7e20c --- /dev/null +++ b/apps/dashboard/src/components/BookmarkPanel.jsx @@ -0,0 +1,57 @@ +import { useState, useEffect } from "react"; + +export default function BookmarkPanel({ operatorId = "operator-1" }) { + const [bookmarks, setBookmarks] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + fetchBookmarks(); + }, []); + + async function fetchBookmarks() { + setLoading(true); + try { + const res = await fetch(`/bookmarks?operator_id=${operatorId}`); + if (res.ok) { + const data = await res.json(); + setBookmarks(data); + } + } catch (e) { + console.error("Failed to load bookmarks", e); + } finally { + setLoading(false); + } + } + + async function removeBookmark(id) { + try { + const res = await fetch(`/bookmarks/${id}`, { method: "DELETE" }); + if (res.ok) { + setBookmarks((prev) => prev.filter((b) => b.id !== id)); + } + } catch (e) { + console.error("Failed to remove bookmark", e); + } + } + + return ( +
+

Bookmarks

+ {loading &&

Loading...

} + {bookmarks.length === 0 && !loading &&

No bookmarks yet.

} +
+ {bookmarks.map((bm) => ( +
+
+
{bm.label}
+
Camera: {bm.camera_id} | Track: {bm.track_id}
+
+ +
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/ExportWorkspace.jsx b/apps/dashboard/src/components/ExportWorkspace.jsx new file mode 100644 index 0000000..0e548e0 --- /dev/null +++ b/apps/dashboard/src/components/ExportWorkspace.jsx @@ -0,0 +1,61 @@ +import { useState } from "react"; + +export default function ExportWorkspace({ invId, name }) { + const [exportData, setExportData] = useState(null); + const [loading, setLoading] = useState(false); + + async function handleExport() { + setLoading(true); + try { + const res = await fetch(`/bookmarks/investigations/${invId}/export`); + if (res.ok) { + const data = await res.json(); + setExportData(data); + } + } catch (e) { + console.error("Failed to export workspace", e); + } finally { + setLoading(false); + } + } + + function downloadExport() { + if (!exportData) return; + const blob = new Blob([exportData.data], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${name || "investigation"}-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + return ( +
+

Export Workspace

+ + + {exportData && ( +
+
+ Export Ready + +
+
+            {exportData.data}
+          
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/InvestigationWorkspace.jsx b/apps/dashboard/src/components/InvestigationWorkspace.jsx new file mode 100644 index 0000000..e4c5ea4 --- /dev/null +++ b/apps/dashboard/src/components/InvestigationWorkspace.jsx @@ -0,0 +1,259 @@ +import { useState, useEffect } from "react"; + +export default function InvestigationWorkspace({ operatorId = "operator-1" }) { + const [investigations, setInvestigations] = useState([]); + const [selectedInv, setSelectedInv] = useState(null); + const [events, setEvents] = useState([]); + const [newName, setNewName] = useState(""); + const [newDesc, setNewDesc] = useState(""); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState("events"); + + useEffect(() => { + fetchInvestigations(); + }, []); + + async function fetchInvestigations() { + setLoading(true); + try { + const res = await fetch(`/bookmarks/investigations?operator_id=${operatorId}`); + if (res.ok) { + const data = await res.json(); + setInvestigations(data); + } + } catch (e) { + console.error("Failed to load investigations", e); + } finally { + setLoading(false); + } + } + + async function createInvestigation() { + if (!newName.trim()) return; + setLoading(true); + try { + const res = await fetch("/bookmarks/investigations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: newName, description: newDesc }), + }); + if (res.ok) { + const inv = await res.json(); + setInvestigations((prev) => [...prev, inv]); + setNewName(""); + setNewDesc(""); + } + } catch (e) { + console.error("Failed to create investigation", e); + } finally { + setLoading(false); + } + } + + async function loadEvents(invId) { + try { + const res = await fetch(`/bookmarks/investigations/${invId}/events`); + if (res.ok) { + const data = await res.json(); + setEvents(data); + } + } catch (e) { + console.error("Failed to load events", e); + } + } + + function handleSelectInv(inv) { + setSelectedInv(inv); + loadEvents(inv.id); + } + + return ( +
+

Investigation Workspace

+ +
+ setNewName(e.target.value)} + className="flex-1 px-3 py-2 rounded bg-zinc-800 text-white border border-zinc-700" + /> + setNewDesc(e.target.value)} + className="flex-1 px-3 py-2 rounded bg-zinc-800 text-white border border-zinc-700" + /> + +
+ +
+
+ {loading && investigations.length === 0 &&

Loading...

} + {investigations.map((inv) => ( +
handleSelectInv(inv)} + className={`p-3 rounded cursor-pointer mb-2 border ${ + selectedInv?.id === inv.id + ? "border-green-500 bg-green-500/10" + : "border-zinc-700 bg-zinc-800 hover:bg-zinc-700" + }`} + > +
{inv.name}
+
{inv.description || "No description"}
+
{inv.event_count || 0} events
+
+ ))} + {investigations.length === 0 && !loading && ( +

No investigations yet. Create one above.

+ )} +
+ +
+ {selectedInv ? ( + <> +
+ + + +
+ + {activeTab === "events" && } + {activeTab === "timeline" && } + {activeTab === "export" && } + + ) : ( +

Select an investigation to view details.

+ )} +
+
+
+ ); +} + +function EventList({ events, invId, operatorId, onRefresh }) { + const [selectedEvent, setSelectedEvent] = useState(null); + + async function addEvent() { + const alertId = prompt("Enter alert ID:"); + if (!alertId) return; + const cameraId = prompt("Enter camera ID:", "cam_01") || "cam_01"; + const trackId = parseInt(prompt("Enter track ID:", "1") || "1", 10); + const label = prompt("Enter event label:", "suspicious") || "suspicious"; + try { + const res = await fetch(`/bookmarks/investigations/${invId}/events`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alert_id: alertId, camera_id: cameraId, track_id: trackId, label }), + }); + if (res.ok) { + onRefresh(invId); + } + } catch (e) { + console.error("Failed to add event", e); + } + } + + return ( +
+
+

Events ({events.length})

+ +
+ {events.length === 0 &&

No events in this investigation.

} +
+ {events.map((evt) => ( +
setSelectedEvent(evt)} + className={`p-3 rounded border cursor-pointer ${ + selectedEvent?.id === evt.id ? "border-green-500 bg-green-500/10" : "border-zinc-700 bg-zinc-800" + }`} + > +
+ {evt.label} + {new Date(evt.created_at * 1000).toLocaleString()} +
+
Camera: {evt.camera_id} | Track: {evt.track_id}
+ {evt.notes &&
"{evt.notes}"
} +
+ ))} +
+ + {selectedEvent && ( +
+

Notes

+ {}} /> +
+ )} +
+ ); +} + +function AddNotes({ bookmarkId, onAdded }) { + const [note, setNote] = useState(""); + + async function submitNote() { + if (!note.trim()) return; + try { + const res = await fetch(`/bookmarks/${bookmarkId}/notes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: note }), + }); + if (res.ok) { + setNote(""); + onAdded(); + } + } catch (e) { + console.error("Failed to add note", e); + } + } + + return ( +
+ setNote(e.target.value)} + placeholder="Add a note..." + className="flex-1 px-3 py-2 rounded bg-zinc-900 text-white border border-zinc-700 text-sm" + /> + +
+ ); +} diff --git a/apps/dashboard/src/components/SearchBookmarks.jsx b/apps/dashboard/src/components/SearchBookmarks.jsx new file mode 100644 index 0000000..43d97c8 --- /dev/null +++ b/apps/dashboard/src/components/SearchBookmarks.jsx @@ -0,0 +1,52 @@ +import { useState } from "react"; + +export default function SearchBookmarks({ operatorId = "operator-1" }) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + + async function handleSearch(e) { + e.preventDefault(); + if (!query.trim()) return; + setSearching(true); + try { + const res = await fetch(`/bookmarks/search?operator_id=${operatorId}&q=${encodeURIComponent(query)}`); + if (res.ok) { + const data = await res.json(); + setResults(data); + } + } catch (e) { + console.error("Search failed", e); + } finally { + setSearching(false); + } + } + + return ( +
+

Search Bookmarks

+
+ setQuery(e.target.value)} + placeholder="Search bookmarks..." + className="flex-1 px-3 py-2 rounded bg-zinc-800 text-white border border-zinc-700 text-sm" + /> + +
+ {results.length === 0 && query && !searching &&

No results found.

} +
+ {results.map((bm) => ( +
+
{bm.label}
+
Camera: {bm.camera_id} | Track: {bm.track_id}
+ {bm.notes &&
"{bm.notes}"
} +
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/TimelineView.jsx b/apps/dashboard/src/components/TimelineView.jsx new file mode 100644 index 0000000..1924c00 --- /dev/null +++ b/apps/dashboard/src/components/TimelineView.jsx @@ -0,0 +1,40 @@ +import { useState, useEffect } from "react"; + +export default function TimelineView({ invId }) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!invId) return; + setLoading(true); + fetch(`/bookmarks/investigations/${invId}/timeline`) + .then((r) => r.ok ? r.json() : []) + .then((data) => setEvents(data)) + .catch((e) => console.error("Failed to load timeline", e)) + .finally(() => setLoading(false)); + }, [invId]); + + if (loading) return

Loading timeline...

; + if (events.length === 0) return

No events in timeline.

; + + return ( +
+

Timeline

+
+ {events.map((evt, idx) => ( +
+
+
+
+ {evt.label} + {new Date(evt.timestamp * 1000).toLocaleString()} +
+
Camera: {evt.camera_id} | Track: {evt.track_id}
+ {evt.notes &&
"{evt.notes}"
} +
+
+ ))} +
+
+ ); +} diff --git a/apps/dashboard/src/features/workspace.jsx b/apps/dashboard/src/features/workspace.jsx new file mode 100644 index 0000000..a24e47e --- /dev/null +++ b/apps/dashboard/src/features/workspace.jsx @@ -0,0 +1,5 @@ +import InvestigationWorkspace from "../components/InvestigationWorkspace" + +export { InvestigationWorkspace } +export default InvestigationWorkspace +export const label = "Investigation Workspace" diff --git a/apps/dashboard/src/pages/Dashboard.jsx b/apps/dashboard/src/pages/Dashboard.jsx index 3c0a3d2..3dd193b 100644 --- a/apps/dashboard/src/pages/Dashboard.jsx +++ b/apps/dashboard/src/pages/Dashboard.jsx @@ -1,81 +1,113 @@ -import { useState } from "react"; +import { useState, useEffect, useMemo } from "react"; import CameraCard from "../components/CameraCard" -export default function Dashboard() { +const CAMERA_VIEW = "__eagle_cameras__"; +export default function Dashboard() { const [selectedTrack, setSelectedTrack] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - const cameras = [ - { id: 1, title: "Camera 1", trackId: "P-101" }, - { id: 2, title: "Camera 2", trackId: "P-102" }, - { id: 3, title: "Camera 3", trackId: "P-101" }, - { id: 4, title: "Camera 4", trackId: "P-103" }, -]; - return ( -
-
+ const [view, setView] = useState(CAMERA_VIEW); - setSearchQuery(e.target.value)} - className="w-full mb-4 px-4 py-2 rounded bg-zinc-900 text-white" - /> + const featureModules = useMemo(() => { + try { + return import.meta.glob("./features/*.jsx", { eager: true }); + } catch { + return {}; + } + }, []); -
+ const featureTabs = useMemo(() => { + const tabs = [{ id: CAMERA_VIEW, label: "Cameras", component: null }]; + Object.entries(featureModules).forEach(([path, mod]) => { + const name = path.replace(/^\.\/features\//, "").replace(/\.jsx$/, ""); + const comp = mod.default || mod; + if (comp && typeof comp === "function") { + tabs.push({ id: name, label: mod.label || name, component: comp }); + } + }); + return tabs; + }, [featureModules]); - {cameras - .filter((cam) => - cam.trackId.toLowerCase().includes(searchQuery.toLowerCase()) - ) - .map((cam) => ( -
setSelectedTrack(cam)} - className={`cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl ${ - selectedTrack?.id === cam.id - ? "border-2 border-green-500 scale-105 rounded-lg shadow-green-500/40 shadow-2xl" - : "" - }`} - > - -
-)) -} -
-
-
+ const activeComponent = useMemo(() => { + const tab = featureTabs.find((t) => t.id === view); + return tab?.component || null; + }, [view, featureTabs]); - {selectedTrack !== null ? ( - <> -

- Identity Panel -

+ const cameras = [ + { id: 1, title: "Camera 1", trackId: "P-101" }, + { id: 2, title: "Camera 2", trackId: "P-102" }, + { id: 3, title: "Camera 3", trackId: "P-101" }, + { id: 4, title: "Camera 4", trackId: "P-103" }, + ]; -

- Camera:{" "} - {selectedTrack.title} -

+ return ( +
+
+
+ {featureTabs.map((tab) => ( + + ))} +
-

- Track ID:{" "} - {selectedTrack.trackId} -

+ {view === CAMERA_VIEW && ( + <> + setSearchQuery(e.target.value)} + className="w-full mb-4 px-4 py-2 rounded bg-zinc-900 text-white" + /> +
+ {cameras + .filter((cam) => + cam.trackId.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .map((cam) => ( +
setSelectedTrack(cam)} + className={`cursor-pointer transition-all duration-300 hover:scale-105 hover:shadow-2xl ${ + selectedTrack?.id === cam.id + ? "border-2 border-green-500 scale-105 rounded-lg shadow-green-500/40 shadow-2xl" + : "" + }`} + > + +
+ ))} +
+ + )} -

- ACTIVE TRACK -

- - ) : ( -

Select a camera track

- )} + {activeComponent && } +
-
-
- ) -} \ No newline at end of file +
+ {selectedTrack !== null && view === CAMERA_VIEW ? ( + <> +

Identity Panel

+

+ Camera: {selectedTrack.title} +

+

+ Track ID: {selectedTrack.trackId} +

+

ACTIVE TRACK

+ + ) : ( +

Select a camera track

+ )} +
+
+ ); +} diff --git a/tests/integration/test_bookmarks.py b/tests/integration/test_bookmarks.py new file mode 100644 index 0000000..ea2ea93 --- /dev/null +++ b/tests/integration/test_bookmarks.py @@ -0,0 +1,146 @@ +""" +tests/integration/test_bookmarks.py + +Integration tests for the bookmarks and investigation workspace API. +""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +import apps.backend.main as backend +from apps.backend.routes import bookmarks + + +@pytest.fixture() +def app(): + original = bookmarks._bookmarks, bookmarks._investigations, bookmarks._investigation_events, bookmarks._bookmark_notes + bookmarks._bookmarks = {} + bookmarks._investigations = {} + bookmarks._investigation_events = {} + bookmarks._bookmark_notes = {} + yield backend.app + bookmarks._bookmarks, bookmarks._investigations, bookmarks._investigation_events, bookmarks._bookmark_notes = original + + +@pytest.mark.asyncio +async def test_create_bookmark(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/bookmarks?operator_id=op-1", + json={"alert_id": "alert-1", "camera_id": "cam_01", "track_id": 1, "label": "suspicious"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["alert_id"] == "alert-1" + assert body["label"] == "suspicious" + assert "id" in body + + +@pytest.mark.asyncio +async def test_list_bookmarks(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + await client.post( + "/bookmarks?operator_id=op-1", + json={"alert_id": "alert-1", "camera_id": "cam_01", "track_id": 1, "label": "suspicious"}, + ) + response = await client.get("/bookmarks?operator_id=op-1") + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["label"] == "suspicious" + + +@pytest.mark.asyncio +async def test_delete_bookmark(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + create = await client.post( + "/bookmarks?operator_id=op-1", + json={"alert_id": "alert-1", "camera_id": "cam_01", "track_id": 1, "label": "suspicious"}, + ) + bm_id = create.json()["id"] + response = await client.delete(f"/bookmarks/{bm_id}") + assert response.status_code == 200 + assert response.json()["deleted"] == bm_id + + +@pytest.mark.asyncio +async def test_create_investigation(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.post( + "/bookmarks/investigations?operator_id=op-1", + json={"name": "Incident A", "description": "Test investigation"}, + ) + assert response.status_code == 201 + body = response.json() + assert body["name"] == "Incident A" + assert body["event_count"] == 0 + + +@pytest.mark.asyncio +async def test_add_event_to_investigation(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + inv = await client.post( + "/bookmarks/investigations?operator_id=op-1", + json={"name": "Incident A"}, + ) + inv_id = inv.json()["id"] + response = await client.post( + f"/bookmarks/investigations/{inv_id}/events?operator_id=op-1", + json={"alert_id": "alert-2", "camera_id": "cam_02", "track_id": 2, "label": "loitering"}, + ) + assert response.status_code == 201 + body = response.json() + assert body["alert_id"] == "alert-2" + + +@pytest.mark.asyncio +async def test_investigation_timeline(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + inv = await client.post( + "/bookmarks/investigations?operator_id=op-1", + json={"name": "Incident A"}, + ) + inv_id = inv.json()["id"] + await client.post( + f"/bookmarks/investigations/{inv_id}/events?operator_id=op-1", + json={"alert_id": "alert-2", "camera_id": "cam_02", "track_id": 2, "label": "loitering"}, + ) + response = await client.get(f"/bookmarks/investigations/{inv_id}/timeline") + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["label"] == "loitering" + + +@pytest.mark.asyncio +async def test_search_bookmarks(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + await client.post( + "/bookmarks?operator_id=op-1", + json={"alert_id": "alert-1", "camera_id": "cam_01", "track_id": 1, "label": "suspicious"}, + ) + response = await client.get("/bookmarks/search?operator_id=op-1&q=susp") + assert response.status_code == 200 + body = response.json() + assert len(body) == 1 + assert body[0]["label"] == "suspicious" + + +@pytest.mark.asyncio +async def test_export_investigation(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + inv = await client.post( + "/bookmarks/investigations?operator_id=op-1", + json={"name": "Incident A"}, + ) + inv_id = inv.json()["id"] + await client.post( + f"/bookmarks/investigations/{inv_id}/events?operator_id=op-1", + json={"alert_id": "alert-2", "camera_id": "cam_02", "track_id": 2, "label": "loitering"}, + ) + response = await client.get(f"/bookmarks/investigations/{inv_id}/export") + assert response.status_code == 200 + body = response.json() + assert body["format"] == "json" + assert "events" in body["data"]