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 }) {
+ )
+}
+
+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() {
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
)}
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 22ac1d7..368bb76 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}.