Skip to content

Commit 6af78d3

Browse files
committed
incidents: promote to standalone /incidents page + manual creation
The AI Incident Reports panel lived inside /mcp, but incidents are referenced from two pages — both /mcp and the SentinelPage RunDetailDrawer deep-link to /incidents/:id, which previously 404'd because no route existed. Conceptually incidents are their own surface, not an MCP sub-feature, so the panel moves out and the broken Sentinel deep-link finally lands somewhere real. While extracting, add the missing piece: operators can now manually file incidents through a new POST endpoint that mirrors the MCP create_incident tool's validation and notification behavior. Backend - POST /api/incidents (admin-only, all tiers). Same validation as the MCP create_incident tool — title/summary required, severity in the enum, camera_id must exist if provided. Stamps created_by="user:<id>" vs MCP's "mcp:<key_name>" so the dashboard can badge AI vs human. Fires the same incident_created notification (kind, audience, link) so notification preferences cover both author types. - 60/min rate limit (matches DELETE). Frontend - New /incidents and /incidents/:incidentId routes (same component, reads useParams to deep-link into the report modal). RequireAdmin wrapper, no plan gate — Free admins can file incidents too; AI agents only show up if Sentinel runs (Pro Plus). - New IncidentsPage: lifted state/loaders/polling from McpPage, full width, "+ New Incident" button, filter tabs (Open/All) preserved. Each row gets an AI / Human badge keyed off created_by prefix. - New NewIncidentModal: title + summary + severity dropdown + camera dropdown. On success refreshes list and immediately opens the report modal so the operator can add long-form report or evidence. - Sidebar: "Incidents" entry between MCP and Sentinel (admin only). - McpPage surgery: removed the AI Incident Reports panel, four state hooks, both loaders, the IncidentReportModal mount, the SEVERITY_ORDER + STATUS_LABELS constants, and the stale getIncidents / getIncidentCounts imports. The "Open Incidents" stat tile becomes a Link to /incidents (drops the live count — saves a 10 s poll for every /mcp viewer; the bell inbox already pings on creation). Existing IncidentReportModal is reused unchanged — view/edit semantics were already exactly what we need for both author types.
1 parent 553b8c1 commit 6af78d3

8 files changed

Lines changed: 805 additions & 140 deletions

File tree

backend/app/api/incidents.py

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
"""
2-
AI-generated incident reports.
2+
Incident reports.
33
4-
The MCP server writes here via the `create_incident`/`add_observation`/etc.
5-
tools (see app/mcp/server.py). The dashboard reads here through the
6-
`/api/incidents` endpoints, which are admin-only — incidents live alongside
7-
the rest of the MCP audit surface.
4+
Two write paths into the same `incidents` table:
5+
- MCP `create_incident` / `add_observation` / ... tools (agents). See
6+
`app/mcp/server.py` — agent rows land with ``created_by="mcp:<key_name>"``.
7+
- This module's `POST /api/incidents` (humans). Operator-filed rows
8+
land with ``created_by="user:<clerk_user_id>"`` so the dashboard can
9+
badge AI vs human at a glance.
10+
11+
Reads are dashboard-only (admin) and live under `/api/incidents`.
812
"""
913

14+
import logging
1015
from datetime import UTC, datetime
1116
from typing import Optional
1217

@@ -20,10 +25,12 @@
2025
from app.models.models import (
2126
INCIDENT_SEVERITIES,
2227
INCIDENT_STATUSES,
28+
Camera,
2329
Incident,
2430
IncidentEvidence,
2531
)
2632

33+
logger = logging.getLogger(__name__)
2734
router = APIRouter(prefix="/api/incidents", tags=["incidents"])
2835

2936

@@ -39,6 +46,112 @@ class IncidentPatch(BaseModel):
3946
report: Optional[str] = Field(default=None)
4047

4148

49+
class IncidentCreate(BaseModel):
50+
"""Operator-filed incident. Mirrors the MCP `create_incident`
51+
tool's input shape so the same DB row layout serves both authors."""
52+
53+
title: str = Field(..., min_length=1, max_length=200)
54+
summary: str = Field(..., min_length=1)
55+
severity: str = Field(default="medium")
56+
camera_id: Optional[str] = Field(default=None)
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Create (human-authored)
61+
# ---------------------------------------------------------------------------
62+
63+
64+
@router.post("", status_code=201)
65+
@limiter.limit("60/minute")
66+
async def create_incident(
67+
request: Request,
68+
body: IncidentCreate,
69+
user: AuthUser = Depends(require_admin),
70+
db: Session = Depends(get_db),
71+
):
72+
"""File a new incident manually.
73+
74+
Mirrors the MCP `create_incident` tool's validation (title and
75+
summary required, severity in the allowed enum, camera_id must
76+
exist within the org if provided) and fires the same
77+
`incident_created` notification so the inbox + email channels
78+
don't care which author wrote the row.
79+
80+
The only difference from the MCP path: ``created_by`` is stamped
81+
``user:<clerk_id>`` instead of ``mcp:<key_name>``. The dashboard
82+
keys off this prefix to badge AI- vs human-authored rows.
83+
"""
84+
if body.severity not in INCIDENT_SEVERITIES:
85+
raise HTTPException(
86+
status_code=400, detail=f"Invalid severity: {body.severity}"
87+
)
88+
89+
title = body.title.strip()
90+
summary = body.summary.strip()
91+
if not title:
92+
raise HTTPException(status_code=400, detail="title is required")
93+
if not summary:
94+
raise HTTPException(status_code=400, detail="summary is required")
95+
96+
if body.camera_id:
97+
cam = (
98+
db.query(Camera)
99+
.filter_by(org_id=user.org_id, camera_id=body.camera_id)
100+
.first()
101+
)
102+
if not cam:
103+
raise HTTPException(
104+
status_code=400,
105+
detail=f"Camera '{body.camera_id}' not found",
106+
)
107+
108+
incident = Incident(
109+
org_id=user.org_id,
110+
camera_id=body.camera_id,
111+
title=title[:200],
112+
summary=summary,
113+
severity=body.severity,
114+
status="open",
115+
created_by=f"user:{user.user_id}",
116+
)
117+
db.add(incident)
118+
db.commit()
119+
db.refresh(incident)
120+
121+
# Fire inbox + email notification. Same kind/audience the MCP
122+
# path uses (`mcp/server.py:1295-1306`) — we want both author
123+
# types to flow through identical notification preferences.
124+
try:
125+
from app.api.notifications import create_notification
126+
127+
notif_severity = (
128+
"critical" if body.severity in ("high", "critical") else "warning"
129+
)
130+
create_notification(
131+
org_id=user.org_id,
132+
kind="incident_created",
133+
title=f"Incident #{incident.id}: {incident.title}",
134+
body=f"[{body.severity.upper()}] {incident.summary}",
135+
severity=notif_severity,
136+
audience="all",
137+
link=f"/incidents/{incident.id}",
138+
camera_id=body.camera_id,
139+
meta={"incident_id": incident.id, "severity": body.severity},
140+
db=db,
141+
)
142+
except Exception: # noqa: BLE001
143+
# Notification failure must NEVER fail incident creation —
144+
# the row is already committed and the operator clicked
145+
# submit. Log and move on; an admin can retry the notify
146+
# path manually if needed.
147+
logger.exception(
148+
"[create_incident] notification emit failed for incident=%s",
149+
incident.id,
150+
)
151+
152+
return incident.to_dict(include_evidence=True)
153+
154+
42155
# ---------------------------------------------------------------------------
43156
# List + counts
44157
# ---------------------------------------------------------------------------

frontend/src/App.jsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const AdminPage = lazy(() => import("./pages/AdminPage.jsx"))
1717
const TestHlsPage = lazy(() => import("./pages/TestHlsPage.jsx"))
1818
const PricingPage = lazy(() => import("./pages/PricingPage.jsx"))
1919
const McpPage = lazy(() => import("./pages/McpPage.jsx"))
20+
const IncidentsPage = lazy(() => import("./pages/IncidentsPage.jsx"))
2021
const SentinelPage = lazy(() => import("./pages/SentinelPage.jsx"))
2122
const LegalPage = lazy(() => import("./pages/LegalPage.jsx"))
2223
const SecurityPage = lazy(() => import("./pages/SecurityPage.jsx"))
@@ -176,6 +177,26 @@ function App() {
176177
</RequireAdmin>
177178
}
178179
/>
180+
{/* /incidents and /incidents/:incidentId share the same page;
181+
the page reads useParams().incidentId to open the report
182+
modal on mount when present. Lets notification deep-links
183+
and the SentinelPage RunDetailDrawer link both resolve. */}
184+
<Route
185+
path="/incidents"
186+
element={
187+
<RequireAdmin>
188+
<IncidentsPage />
189+
</RequireAdmin>
190+
}
191+
/>
192+
<Route
193+
path="/incidents/:incidentId"
194+
element={
195+
<RequireAdmin>
196+
<IncidentsPage />
197+
</RequireAdmin>
198+
}
199+
/>
179200
</Route>
180201
</Routes>
181202
</Suspense>

frontend/src/components/AppSidebar.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ function AppSidebar({ open, onClose }) {
3434
...(hasAdminFeature ? {} : { locked: true, badge: "PRO", badgeClass: "nav-pro-badge" }),
3535
},
3636
{ to: "/mcp", label: "MCP" },
37+
isAdmin && { to: "/incidents", label: "Incidents" },
3738
{ to: "/sentinel", label: "Sentinel" },
3839
{ to: "/docs", label: "Help" },
3940
{ to: "/pricing", label: "Pricing" },
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useEffect, useState } from "react"
2+
import { useAuth } from "@clerk/clerk-react"
3+
4+
import { createIncident, getCameras } from "../services/api"
5+
import { useToasts } from "../hooks/useToasts.jsx"
6+
7+
const SEVERITY_OPTIONS = [
8+
{ value: "low", label: "Low" },
9+
{ value: "medium", label: "Medium" },
10+
{ value: "high", label: "High" },
11+
{ value: "critical", label: "Critical" },
12+
]
13+
14+
/**
15+
* Operator-filed incident creation form. Mirrors the MCP
16+
* `create_incident` tool's input shape so the resulting row renders
17+
* identically in the existing IncidentReportModal — title, summary,
18+
* severity (default medium), optional camera_id.
19+
*
20+
* On success: calls onCreated(incident) so the parent can refresh
21+
* the list AND open the just-created incident in the report modal
22+
* for follow-up edits (writing the long-form report, attaching
23+
* evidence later, etc).
24+
*/
25+
export default function NewIncidentModal({ onClose, onCreated }) {
26+
const { getToken } = useAuth()
27+
const { showToast } = useToasts()
28+
29+
const [title, setTitle] = useState("")
30+
const [summary, setSummary] = useState("")
31+
const [severity, setSeverity] = useState("medium")
32+
const [cameraId, setCameraId] = useState("")
33+
const [cameras, setCameras] = useState([])
34+
const [submitting, setSubmitting] = useState(false)
35+
const [error, setError] = useState(null)
36+
37+
// Load cameras for the dropdown. Failing soft is fine — operator
38+
// can still file an incident with no camera attached, the field is
39+
// optional.
40+
useEffect(() => {
41+
let cancelled = false
42+
getCameras(getToken)
43+
.then((data) => {
44+
if (!cancelled) setCameras(Array.isArray(data) ? data : [])
45+
})
46+
.catch(() => {
47+
if (!cancelled) setCameras([])
48+
})
49+
return () => {
50+
cancelled = true
51+
}
52+
// eslint-disable-next-line react-hooks/exhaustive-deps
53+
}, [])
54+
55+
const submit = async (event) => {
56+
event.preventDefault()
57+
if (submitting) return
58+
59+
const trimmedTitle = title.trim()
60+
const trimmedSummary = summary.trim()
61+
if (!trimmedTitle) {
62+
setError("Title is required.")
63+
return
64+
}
65+
if (!trimmedSummary) {
66+
setError("Summary is required.")
67+
return
68+
}
69+
70+
setError(null)
71+
setSubmitting(true)
72+
try {
73+
const created = await createIncident(getToken, {
74+
title: trimmedTitle,
75+
summary: trimmedSummary,
76+
severity,
77+
camera_id: cameraId || null,
78+
})
79+
showToast("Incident filed", "success")
80+
onCreated(created)
81+
} catch (err) {
82+
setError(err?.message || "Failed to create incident")
83+
} finally {
84+
setSubmitting(false)
85+
}
86+
}
87+
88+
return (
89+
<div className="modal-overlay" onClick={onClose}>
90+
<div
91+
className="modal-content new-incident-modal"
92+
onClick={(e) => e.stopPropagation()}
93+
>
94+
<div className="modal-header">
95+
<h2>File a new incident</h2>
96+
<button className="modal-close" onClick={onClose} aria-label="Close">
97+
&times;
98+
</button>
99+
</div>
100+
101+
<form className="modal-body" onSubmit={submit}>
102+
<div className="new-incident-field">
103+
<label htmlFor="new-incident-title">Title</label>
104+
<input
105+
id="new-incident-title"
106+
type="text"
107+
value={title}
108+
onChange={(e) => setTitle(e.target.value)}
109+
placeholder="Short headline (e.g. 'Front gate left open overnight')"
110+
maxLength={200}
111+
required
112+
autoFocus
113+
/>
114+
</div>
115+
116+
<div className="new-incident-field">
117+
<label htmlFor="new-incident-summary">Summary</label>
118+
<textarea
119+
id="new-incident-summary"
120+
value={summary}
121+
onChange={(e) => setSummary(e.target.value)}
122+
placeholder="One or two sentences describing what was observed."
123+
rows={4}
124+
required
125+
/>
126+
</div>
127+
128+
<div className="new-incident-row">
129+
<div className="new-incident-field">
130+
<label htmlFor="new-incident-severity">Severity</label>
131+
<select
132+
id="new-incident-severity"
133+
value={severity}
134+
onChange={(e) => setSeverity(e.target.value)}
135+
>
136+
{SEVERITY_OPTIONS.map((opt) => (
137+
<option key={opt.value} value={opt.value}>
138+
{opt.label}
139+
</option>
140+
))}
141+
</select>
142+
</div>
143+
144+
<div className="new-incident-field">
145+
<label htmlFor="new-incident-camera">Camera (optional)</label>
146+
<select
147+
id="new-incident-camera"
148+
value={cameraId}
149+
onChange={(e) => setCameraId(e.target.value)}
150+
>
151+
<option value="">— None —</option>
152+
{cameras.map((c) => (
153+
<option key={c.camera_id} value={c.camera_id}>
154+
{c.name || c.camera_id}
155+
</option>
156+
))}
157+
</select>
158+
</div>
159+
</div>
160+
161+
{error && <div className="new-incident-error">{error}</div>}
162+
163+
<div className="modal-actions">
164+
<button
165+
type="button"
166+
className="btn btn-secondary"
167+
onClick={onClose}
168+
disabled={submitting}
169+
>
170+
Cancel
171+
</button>
172+
<button
173+
type="submit"
174+
className="btn btn-primary"
175+
disabled={submitting}
176+
>
177+
{submitting ? "Filing…" : "File incident"}
178+
</button>
179+
</div>
180+
</form>
181+
</div>
182+
</div>
183+
)
184+
}

0 commit comments

Comments
 (0)