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 && (
+
+ )}
+
+ );
+}
+
+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
+
+ {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"]