From 841a066c0263a51c54e9557bbdf2c8aaceec4341 Mon Sep 17 00:00:00 2001 From: S'Bussiso Dube <80188685+Sbussiso@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:05:28 -0700 Subject: [PATCH 1/2] Fix five first-run defects found by driving the app as a self-hoster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran Command Center in AUTH_PROVIDER=local — the exact path a first self-hosted install takes — and drove every page in a real browser. Everything below was visible within the first minute. 1. A self-hosted install's root redirected to our marketing site. RedirectToStandalone fired unconditionally, so an operator visiting http://their-host/ was bounced to sentinel-command.com and the root of their OWN deployment was unusable. Now skipped when IS_LOCAL_AUTH. 2. Every new org displayed the wrong timezone. Intl.supportedValuesOf("timeZone") returns 418 IANA zones and does NOT include a bare "UTC" — the backend defaults a new org to exactly "UTC", so +// matched no option and the browser silently fell back to rendering the +// FIRST one: every new org saw its timezone as "Africa/Abidjan" while +// the stored value was UTC. const TIMEZONE_OPTIONS = typeof Intl !== "undefined" && Intl.supportedValuesOf - ? Intl.supportedValuesOf("timeZone") + ? ["UTC", ...Intl.supportedValuesOf("timeZone").filter((z) => z !== "UTC")] : [ "UTC", "America/Los_Angeles", @@ -966,6 +972,51 @@ function SettingsPage() { + + {/* These two round out the set the backend has always + returned from GET /api/settings and accepted on POST + /api/settings/notifications. Only the motion toggle was + ever rendered, so an operator drowning in camera- or + node-transition bell notifications had no way to turn + them off from the UI — the setting existed, the control + did not. */} + + + )} From 2b9e234ffa48f144156f05d9f862c43e5d8cfa8f Mon Sep 17 00:00:00 2001 From: S'Bussiso Dube <80188685+Sbussiso@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:05:25 -0700 Subject: [PATCH 2/2] Surface motion history, and fix the broken camera icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motion was recorded but unreadable. GET /api/motion/events and /events/stats have shipped since motion ingestion landed — paginated, camera-filterable, require_view — and NOTHING in the SPA ever called them. The only motion surface was the live SSE toast in useMotionAlerts, so an operator could see motion happening right now but had no way to answer "what triggered overnight?" — which for a security product is the question the product exists to answer. Adds a Motion tab to the Admin dashboard alongside Stream Access / Organization Audit / MCP Activity, deliberately mirroring their shape (filter row → summary → table → pager) so it reads as part of the same surface. Per-camera rollup on top answers "which camera saw the most movement" before you read a single row; scores carry colour because that is the one number an operator actually scans for. Verified against 140 seeded events across two cameras: 3 pages, correct per-camera counts and peak scores, camera-name resolution, and zero console errors. Separately, the camera icon on every CameraCard was malformed. The path read "h16c0 1.1-.9-2-2V6", which the SVG parser rejects outright — it logged 'attribute d: Expected number' on every render and dropped the remainder of the path, so the icon drew clipped. Only found because the new panel's browser pass surfaced the console error. Restored to the canonical arc (h16c1.1 0 2-.9 2-2), and swept the rest of src/ for the same class of damage — none found. Note for later: CI does not lint the frontend (audit + test + build only), which is why 5 eslint errors have been sitting unnoticed. The backend gates on ruff. Not fixed here — it wants its own change. 99 frontend tests pass; production bundle builds. Co-Authored-By: Claude Opus 5 --- frontend/src/components/AdminTabs.jsx | 5 +- frontend/src/components/CameraCard.jsx | 7 +- frontend/src/components/MotionEventsPanel.jsx | 230 ++++++++++++++++++ frontend/src/index.css | 53 ++++ frontend/src/pages/AdminPage.jsx | 3 + frontend/src/services/api.js | 18 ++ 6 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/MotionEventsPanel.jsx diff --git a/frontend/src/components/AdminTabs.jsx b/frontend/src/components/AdminTabs.jsx index 1d1cc254..0074fd76 100644 --- a/frontend/src/components/AdminTabs.jsx +++ b/frontend/src/components/AdminTabs.jsx @@ -1,5 +1,5 @@ -// Tab strip for the Admin dashboard's three log surfaces (Stream -// Access / Organization Audit / MCP Activity). Cuts the page from +// Tab strip for the Admin dashboard's log surfaces (Stream Access / +// Organization Audit / MCP Activity / Motion). Cuts the page from // "five sections stacked vertically" down to "one section at a time" // and lets us put a red badge on the MCP tab when there are errors // the admin should look at. @@ -8,6 +8,7 @@ const TAB_DEFS = [ { id: "stream", label: "Stream Access", icon: "📺", accent: "green" }, { id: "audit", label: "Organization Audit", icon: "📋", accent: "amber" }, { id: "mcp", label: "MCP Activity", icon: "🤖", accent: "purple" }, + { id: "motion", label: "Motion", icon: "🎞️", accent: "blue" }, ] function AdminTabs({ activeTab, onTabChange, streamCount, mcpCount, mcpErrors = 0 }) { diff --git a/frontend/src/components/CameraCard.jsx b/frontend/src/components/CameraCard.jsx index f41265ef..af6fd2ab 100644 --- a/frontend/src/components/CameraCard.jsx +++ b/frontend/src/components/CameraCard.jsx @@ -244,7 +244,12 @@ function CameraCard({ > - + {/* The `h16c…` curve was malformed ("h16c0 1.1-.9-2-2V6"), + which the SVG parser rejects outright — it logged + 'attribute d: Expected number' on every camera card and + dropped the rest of the path, so the icon rendered + clipped. Restored to the canonical arc: h16c1.1 0 2-.9 2-2 */} + {snapshotLoading ? "Capturing…" : snapshotMsg || "Snapshot"} diff --git a/frontend/src/components/MotionEventsPanel.jsx b/frontend/src/components/MotionEventsPanel.jsx new file mode 100644 index 00000000..828f4df8 --- /dev/null +++ b/frontend/src/components/MotionEventsPanel.jsx @@ -0,0 +1,230 @@ +// Motion event history for the Admin dashboard. +// +// The backend has served GET /api/motion/events and /events/stats since +// motion ingestion shipped, and nothing in the SPA ever called them. The +// only motion surface was the live SSE toast in useMotionAlerts — so an +// operator could see motion happening *right now* but had no way to +// answer "what triggered overnight?", which for a security product is +// the question the product exists to answer. +// +// Mirrors the Stream Access tab's shape deliberately (filter row → +// summary → table → pager) so it reads as part of the same dashboard +// rather than a bolted-on view. + +import { useState, useEffect, useCallback } from "react" +import { useAuth } from "../auth/index.jsx" +import { getMotionEvents, getMotionStats, getCameras } from "../services/api" + +const PAGE_SIZE = 50 + +// Matches the backend's Query(le=168) ceiling — offering a window the +// API would reject is a worse experience than not offering it. +const WINDOWS = [ + { hours: 1, label: "Last hour" }, + { hours: 24, label: "Last 24 hours" }, + { hours: 72, label: "Last 3 days" }, + { hours: 168, label: "Last 7 days" }, +] + +function scoreClass(score) { + if (score == null) return "" + if (score >= 0.75) return "motion-score-high" + if (score >= 0.4) return "motion-score-mid" + return "motion-score-low" +} + +function MotionEventsPanel() { + const { getToken } = useAuth() + const [events, setEvents] = useState([]) + const [stats, setStats] = useState(null) + const [cameras, setCameras] = useState([]) + const [hours, setHours] = useState(24) + const [cameraId, setCameraId] = useState("") + const [offset, setOffset] = useState(0) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Camera list is fetched once — it populates the filter dropdown and + // lets the table show names instead of raw ids. + useEffect(() => { + let cancelled = false + getCameras(getToken) + .then((d) => { if (!cancelled) setCameras(d?.cameras || d || []) }) + .catch(() => { /* filter degrades to ids; not worth surfacing */ }) + return () => { cancelled = true } + }, [getToken]) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [ev, st] = await Promise.all([ + getMotionEvents(getToken, { + hours, + limit: PAGE_SIZE, + offset, + camera_id: cameraId || null, + }), + getMotionStats(getToken, hours), + ]) + setEvents(ev?.events || []) + setTotal(ev?.total || 0) + setStats(st) + } catch (e) { + setError(e?.message || "Could not load motion events.") + } finally { + setLoading(false) + } + }, [getToken, hours, offset, cameraId]) + + useEffect(() => { load() }, [load]) + + // Any filter change invalidates the current page — staying on offset + // 300 of a narrower result set shows an empty table that looks broken. + const changeWindow = (h) => { setHours(h); setOffset(0) } + const changeCamera = (id) => { setCameraId(id); setOffset(0) } + + const cameraName = (id) => { + const c = cameras.find((x) => String(x.camera_id) === String(id)) + return c?.name || id + } + + const page = Math.floor(offset / PAGE_SIZE) + 1 + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + + return ( +
+
+
+

Motion Events

+

+ Every motion detection recorded by your cameras. Recordings stay on + your CameraNode — this is the index of when something moved. +

+
+
+ +
+
+ + +
+
+ + +
+
+ + {stats?.cameras?.length > 0 && ( +
+ {stats.cameras + .slice() + .sort((a, b) => b.event_count - a.event_count) + .slice(0, 4) + .map((c) => ( +
+
{cameraName(c.camera_id)}
+
+ {c.event_count.toLocaleString()} +
+
+ events · peak{" "} + {c.peak_score != null ? c.peak_score.toFixed(2) : "—"} +
+
+ ))} +
+ )} + + {error &&
{error}
} + + {loading ? ( +
Loading motion events…
+ ) : events.length === 0 ? ( +
+ + No motion events in this window. +
+ ) : ( + <> +
+ + + + + + + + + + + {events.map((e) => ( + + + + + + + ))} + +
TimeCameraScoreSegment
+ {e.timestamp + ? new Date(e.timestamp + "Z").toLocaleString() + : "—"} + {cameraName(e.camera_id)} + + {e.score != null ? e.score.toFixed(2) : "—"} + + + {e.segment_seq != null ? `#${e.segment_seq}` : "—"} +
+
+ + {pages > 1 && ( +
+ + + Page {page} of {pages} · {total.toLocaleString()} events + + +
+ )} + + )} +
+ ) +} + +export default MotionEventsPanel diff --git a/frontend/src/index.css b/frontend/src/index.css index 4e21dbc3..3b8e4ac0 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -5918,6 +5918,59 @@ body { color: var(--accent-purple); } +.admin-tab-blue.active { + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.4); + color: var(--accent-blue, #3b82f6); +} + +/* ── Motion events panel ─────────────────────────────────────────── */ +/* Per-camera rollup above the table: answers "which camera saw the most + movement" before the operator has to read a single row. */ +.motion-stats-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; + margin: 1rem 0 1.25rem; +} + +.motion-stat-card { + background: var(--bg-secondary, #16181d); + border: 1px solid var(--border, #2a2d35); + border-radius: 8px; + padding: 0.75rem 0.9rem; +} + +.motion-stat-name { + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary, #8b93a1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.motion-stat-count { + font-size: 1.5rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text-primary, #fff); + line-height: 1.2; +} + +.motion-stat-meta { + font-size: 0.72rem; + color: var(--text-secondary, #8b93a1); + font-variant-numeric: tabular-nums; +} + +/* Score is the one number an operator scans for, so it carries colour + rather than sitting as another grey figure in the row. */ +.motion-score-high { color: var(--accent-red, #ef4444); font-weight: 600; } +.motion-score-mid { color: var(--accent-amber, #f59e0b); font-weight: 600; } +.motion-score-low { color: var(--text-secondary, #8b93a1); } + .admin-tab-icon { font-size: 1rem; opacity: 0.85; diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index 98fdc5a6..1df20ad9 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -5,6 +5,7 @@ import { getStreamLogs, getStreamStats, getCameras, getMcpLogs, getMcpLogStats, import { useToasts } from "../hooks/useToasts.jsx" import { usePlanInfo } from "../hooks/usePlanInfo.jsx" import OrgAuditLogPanel from "../components/OrgAuditLogPanel.jsx" +import MotionEventsPanel from "../components/MotionEventsPanel.jsx" import AdminKpiStrip from "../components/AdminKpiStrip.jsx" import AdminTabs from "../components/AdminTabs.jsx" import { BarList, DailyActivityChart } from "../components/AdminCharts.jsx" @@ -605,6 +606,8 @@ function AdminPage() { {activeTab === "audit" && } + {activeTab === "motion" && } + {activeTab === "mcp" && (<>
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 22ac1d7a..368bb769 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -331,6 +331,24 @@ export async function getStreamLogs(getToken, params = {}) { return fetchWithAuth(`/api/audit/stream-logs?${queryString}`, getToken) } +// Motion event history. The backend has served these since motion +// ingestion shipped, but nothing in the SPA called them — the UI +// surfaced motion ONLY as live SSE toasts, so "what triggered overnight?" +// was unanswerable from the dashboard even though every event was +// recorded. Returns {total, limit, offset, hours, events}. +export async function getMotionEvents(getToken, params = {}) { + const queryString = new URLSearchParams( + Object.entries(params).filter(([_, v]) => v != null && v !== "") + ).toString() + return fetchWithAuth(`/api/motion/events?${queryString}`, getToken) +} + +// Per-camera motion rollup for the same window: event_count, peak_score +// and latest timestamp. Returns {hours, cameras: [...]}. +export async function getMotionStats(getToken, hours = 24) { + return fetchWithAuth(`/api/motion/events/stats?hours=${hours}`, getToken) +} + // Organization audit log — write_audit() rows for member changes, // MCP key gen, settings changes, danger-zone actions, etc. // Returns {total, limit, offset, logs}.