diff --git a/.gitignore b/.gitignore index d08a7ca..559b78d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,16 @@ wheels/ .venv # Environment files +# Only bare `.env` was ignored, which missed every variant Vite and +# python-dotenv actually tell you to use. `.env.local` is the file Vite's +# own docs designate for secrets ("never commit this"), and AGENTS.md +# instructs operators to create env files for the self-hosted setup β€” so +# the one file most likely to hold a live APP_SECRET_KEY or admin +# password hash was the one git would have happily committed. .env +.env.* +!.env.example +!.env.*.example # IDE .idea/ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ae4f49b..c8cd5b2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,6 +1,6 @@ import { lazy, Suspense, useEffect } from "react" import { Routes, Route, Navigate, useNavigate } from "react-router-dom" -import { useAuth, useClerk, useOrganization, CreateOrganization } from "./auth/index.jsx" +import { useAuth, useClerk, useOrganization, CreateOrganization, IS_LOCAL_AUTH } from "./auth/index.jsx" import Layout from "./components/Layout.jsx" import LoadingSpinner from "./components/LoadingSpinner.jsx" import ErrorBoundary from "./components/ErrorBoundary.jsx" @@ -94,8 +94,17 @@ const STANDALONE_SITE = "https://sentinel-command.com" function RedirectToStandalone() { useEffect(() => { + // NEVER bounce a self-hosted install to our marketing site. An + // operator running Sentinel on their own box and visiting + // http://their-host/ was being sent to sentinel-command.com, so the + // root of their OWN deployment was unusable β€” a bad first + // impression at exactly the moment trust is being established. + if (IS_LOCAL_AUTH) return window.location.replace(STANDALONE_SITE) }, []) + + // Self-hosted: send them where they actually wanted to go. + if (IS_LOCAL_AUTH) return return (
diff --git a/frontend/src/components/AdminTabs.jsx b/frontend/src/components/AdminTabs.jsx index 1d1cc25..0074fd7 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/AppSidebar.jsx b/frontend/src/components/AppSidebar.jsx index 2dca15e..2c4987d 100644 --- a/frontend/src/components/AppSidebar.jsx +++ b/frontend/src/components/AppSidebar.jsx @@ -137,13 +137,20 @@ function AppSidebar({ open, onClose }) { let usageUsed = 0 let usageLimit = 0 + let usageUnlimited = false let usagePct = 0 let usageState = "ok" if (showUsage) { usageUsed = planInfo.usage.viewer_hours_used || 0 usageLimit = planInfo.usage.viewer_hours_limit - usagePct = usageLimit > 0 ? Math.min(100, (usageUsed / usageLimit) * 100) : 0 - usageState = usagePct >= 100 ? "full" : usagePct >= 80 ? "warn" : "ok" + // self_host encodes "unlimited" as the sentinel 999999 (see + // plans.py), the same way cameras/nodes use 999 β€” and those already + // render as ∞ a few lines below. Viewer-hours did not, so a + // self-hosted sidebar read "0.0 / 999999h" while the Settings page + // said "Unlimited" for the same plan. + usageUnlimited = usageLimit >= 999999 + usagePct = !usageUnlimited && usageLimit > 0 ? Math.min(100, (usageUsed / usageLimit) * 100) : 0 + usageState = usageUnlimited ? "ok" : usagePct >= 100 ? "full" : usagePct >= 80 ? "warn" : "ok" } return ( @@ -199,7 +206,7 @@ function AppSidebar({ open, onClose }) {
{usageUsed.toFixed(1)} / - {usageLimit}h + {usageUnlimited ? "∞" : `${usageLimit}h`}
diff --git a/frontend/src/components/CameraCard.jsx b/frontend/src/components/CameraCard.jsx index f41265e..af6fd2a 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 0000000..828f4df --- /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 4e21dbc..3b8e4ac 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 98fdc5a..1df20ad 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/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 1f221a2..15f9160 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -256,8 +256,14 @@ function DashboardPage() { c.status === "error") ).length const total = cameraList.length - const systemOk = total > 0 - return { active, total, systemOk } + // Three states, not two. `total === 0` is a BRAND-NEW install that + // has not added a camera yet β€” reporting "Offline" in amber told + // every first-time operator something was broken when nothing was, + // at exactly the moment they are deciding whether to trust this. + // "Offline" now means what it says: cameras exist and none is up. + const systemOk = total > 0 && active > 0 + const systemEmpty = total === 0 + return { active, total, systemOk, systemEmpty } } if (!organization) { @@ -351,8 +357,12 @@ function DashboardPage() {
System Status
-
- {stats.systemOk ? "Ready" : "Offline"} +
+ {stats.systemEmpty ? "No cameras yet" : stats.systemOk ? "Ready" : "Offline"}
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 16cc419..2233b10 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -17,9 +17,15 @@ import HelpTooltip from "../components/HelpTooltip.jsx" // toast and plan tick β€” re-invoked Intl.supportedValuesOf and re-diffed // the whole dropdown. The zone list cannot change without a browser // update, so compute it exactly once. +// "UTC" is prepended because Intl.supportedValuesOf("timeZone") does NOT +// include it β€” the 418-entry IANA list has Etc/* but no bare "UTC". The +// backend defaults a new org to exactly "UTC", so