Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 <Navigate to="/dashboard" replace />
return (
<div className="loading-container">
<LoadingSpinner />
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/AdminTabs.jsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 }) {
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/AppSidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -199,7 +206,7 @@ function AppSidebar({ open, onClose }) {
<div className="usage-panel-count">
<strong>{usageUsed.toFixed(1)}</strong>
<span className="usage-panel-slash">/</span>
<span>{usageLimit}h</span>
<span>{usageUnlimited ? "∞" : `${usageLimit}h`}</span>
</div>
</div>
<div className="usage-panel-bar">
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/CameraCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,12 @@ function CameraCard({
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="3.2"/>
<path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c0 1.1-.9-2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/>
{/* 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 */}
<path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"/>
</svg>
{snapshotLoading ? "Capturing…" : snapshotMsg || "Snapshot"}
</button>
Expand Down
230 changes: 230 additions & 0 deletions frontend/src/components/MotionEventsPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="audit-section">
<div className="audit-section-header">
<div>
<h2>Motion Events</h2>
<p className="section-description">
Every motion detection recorded by your cameras. Recordings stay on
your CameraNode — this is the index of when something moved.
</p>
</div>
</div>

<div className="audit-filters">
<div className="filter-group">
<label htmlFor="motion-window">Window</label>
<select
id="motion-window"
value={hours}
onChange={(e) => changeWindow(Number(e.target.value))}
>
{WINDOWS.map((w) => (
<option key={w.hours} value={w.hours}>{w.label}</option>
))}
</select>
</div>
<div className="filter-group">
<label htmlFor="motion-camera">Camera</label>
<select
id="motion-camera"
value={cameraId}
onChange={(e) => changeCamera(e.target.value)}
>
<option value="">All Cameras</option>
{cameras.map((c) => (
<option key={c.camera_id} value={c.camera_id}>
{c.name || c.camera_id}
</option>
))}
</select>
</div>
</div>

{stats?.cameras?.length > 0 && (
<div className="motion-stats-strip">
{stats.cameras
.slice()
.sort((a, b) => b.event_count - a.event_count)
.slice(0, 4)
.map((c) => (
<div className="motion-stat-card" key={c.camera_id}>
<div className="motion-stat-name">{cameraName(c.camera_id)}</div>
<div className="motion-stat-count">
{c.event_count.toLocaleString()}
</div>
<div className="motion-stat-meta">
events · peak{" "}
{c.peak_score != null ? c.peak_score.toFixed(2) : "—"}
</div>
</div>
))}
</div>
)}

{error && <div className="audit-error">{error}</div>}

{loading ? (
<div className="audit-empty">Loading motion events…</div>
) : events.length === 0 ? (
<div className="audit-empty">
<div className="audit-empty-icon" aria-hidden="true">🎞️</div>
No motion events in this window.
</div>
) : (
<>
<div className="audit-table-wrap">
<table className="audit-table">
<thead>
<tr>
<th>Time</th>
<th>Camera</th>
<th>Score</th>
<th>Segment</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td>
{e.timestamp
? new Date(e.timestamp + "Z").toLocaleString()
: "—"}
</td>
<td>{cameraName(e.camera_id)}</td>
<td>
<span className={scoreClass(e.score)}>
{e.score != null ? e.score.toFixed(2) : "—"}
</span>
</td>
<td className="audit-mono">
{e.segment_seq != null ? `#${e.segment_seq}` : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>

{pages > 1 && (
<div className="audit-pager">
<button
type="button"
disabled={offset === 0}
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
>
← Previous
</button>
<span>
Page {page} of {pages} · {total.toLocaleString()} events
</span>
<button
type="button"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Next →
</button>
</div>
)}
</>
)}
</div>
)
}

export default MotionEventsPanel
53 changes: 53 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading