big ui and intel updates

This commit is contained in:
Logan
2026-04-19 16:48:55 -04:00
parent 0df53df92e
commit 303c5b13cf
11 changed files with 527 additions and 169 deletions
@@ -44,6 +44,7 @@ async def correlate_call(
tags: list[str], tags: list[str],
incident_type: Optional[str], incident_type: Optional[str],
location: Optional[str] = None, location: Optional[str] = None,
location_coords: Optional[dict] = None,
) -> Optional[str]: ) -> Optional[str]:
""" """
Link call_id to an existing incident or create a new one. Link call_id to an existing incident or create a new one.
@@ -113,10 +114,10 @@ async def correlate_call(
# ---------------------------------------------------------------- # ----------------------------------------------------------------
if matched_incident: if matched_incident:
incident_id = matched_incident["incident_id"] incident_id = matched_incident["incident_id"]
await _update_incident(matched_incident, call_id, talkgroup_id, system_id, tags, location, now) await _update_incident(matched_incident, call_id, talkgroup_id, system_id, tags, location, location_coords, now)
else: else:
incident_id = await _create_incident( incident_id = await _create_incident(
call_id, incident_type, talkgroup_id, talkgroup_name, system_id, tags, location, now call_id, incident_type, talkgroup_id, talkgroup_name, system_id, tags, location, location_coords, now
) )
# Back-link the call # Back-link the call
@@ -131,6 +132,7 @@ async def _update_incident(
system_id: Optional[str], system_id: Optional[str],
tags: list[str], tags: list[str],
location: Optional[str], location: Optional[str],
location_coords: Optional[dict],
now: datetime, now: datetime,
) -> None: ) -> None:
incident_id = inc["incident_id"] incident_id = inc["incident_id"]
@@ -151,11 +153,11 @@ async def _update_incident(
merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags)) merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags))
# Location — append to mentions; update display location if new one is non-null # Location — append to mentions; update display location if new one is non-null
# TODO: replace "newest wins" with Maps geocoding bbox comparison for true specificity
location_mentions = inc.get("location_mentions", []) location_mentions = inc.get("location_mentions", [])
if location and location not in location_mentions: if location and location not in location_mentions:
location_mentions.append(location) location_mentions.append(location)
best_location = location if location else inc.get("location") best_location = location if location else inc.get("location")
best_coords = location_coords if location_coords else inc.get("location_coords")
# Update centroid embedding # Update centroid embedding
embedding_updates = await _merge_embedding(inc, call_id) embedding_updates = await _merge_embedding(inc, call_id)
@@ -172,6 +174,8 @@ async def _update_incident(
} }
if best_location: if best_location:
updates["location"] = best_location updates["location"] = best_location
if best_coords:
updates["location_coords"] = best_coords
await fstore.doc_set("incidents", incident_id, updates) await fstore.doc_set("incidents", incident_id, updates)
logger.info(f"Correlator: linked call {call_id} to existing incident {incident_id}") logger.info(f"Correlator: linked call {call_id} to existing incident {incident_id}")
@@ -185,10 +189,11 @@ async def _create_incident(
system_id: Optional[str], system_id: Optional[str],
tags: list[str], tags: list[str],
location: Optional[str], location: Optional[str],
location_coords: Optional[dict],
now: datetime, now: datetime,
) -> str: ) -> str:
incident_id = str(uuid.uuid4()) incident_id = str(uuid.uuid4())
tg_label = talkgroup_name or "Unknown Talkgroup" tg_label = talkgroup_name or (f"TGID {talkgroup_id}" if talkgroup_id else "Unknown Talkgroup")
call_doc = await fstore.doc_get("calls", call_id) call_doc = await fstore.doc_get("calls", call_id)
call_embedding = call_doc.get("embedding") if call_doc else None call_embedding = call_doc.get("embedding") if call_doc else None
@@ -202,6 +207,7 @@ async def _create_incident(
"type": incident_type, "type": incident_type,
"status": "active", "status": "active",
"location": location, "location": location,
"location_coords": location_coords,
"location_mentions": [location] if location else [], "location_mentions": [location] if location else [],
"call_ids": [call_id], "call_ids": [call_id],
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [], "talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
+76 -24
View File
@@ -1,10 +1,10 @@
""" """
Gemini-powered intelligence extraction from call transcripts. GPT-4o-mini intelligence extraction from call transcripts.
Sends the transcript to Gemini Flash with a tight JSON schema prompt. Sends the transcript to GPT-4o mini with a tight JSON schema prompt.
Returns structured data: incident type, tags, location, vehicles, units, severity. Returns structured data: incident type, tags, location, vehicles, units, severity.
Falls back gracefully if Gemini is unavailable or returns malformed output. Falls back gracefully if the API is unavailable or returns malformed output.
""" """
import asyncio import asyncio
import json import json
@@ -36,6 +36,9 @@ System: {system_id}
Talkgroup: {talkgroup_name} Talkgroup: {talkgroup_name}
{transcript_block}""" {transcript_block}"""
# Nominatim viewbox half-width in degrees (~35 km at mid-latitudes)
_GEO_DELTA = 0.3
async def extract_tags( async def extract_tags(
call_id: str, call_id: str,
@@ -44,38 +47,52 @@ async def extract_tags(
talkgroup_id: Optional[int] = None, talkgroup_id: Optional[int] = None,
system_id: Optional[str] = None, system_id: Optional[str] = None,
segments: Optional[list[dict]] = None, segments: Optional[list[dict]] = None,
) -> tuple[list[str], Optional[str], Optional[str]]: node_id: Optional[str] = None,
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict]]:
""" """
Extract incident tags, type, location, and corrected transcript via Gemini. Extract incident tags, type, location, and corrected transcript via GPT-4o mini.
Geocodes the extracted location string via Nominatim using the node's position as bias.
Returns: Returns:
(tags, primary_type, location) (tags, primary_type, location_str, location_coords)
where location_coords is {"lat": float, "lng": float} or None.
Side-effect: updates calls/{call_id} in Firestore with tags, location, Side-effect: updates calls/{call_id} in Firestore with tags, location,
vehicles, units, severity, transcript_corrected; also stores the call embedding. location_coords, vehicles, units, severity, transcript_corrected; also stores embedding.
""" """
result = await asyncio.to_thread(_sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments) result = await asyncio.to_thread(
_sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments
)
tags: list[str] = result.get("tags") or [] tags: list[str] = result.get("tags") or []
incident_type: Optional[str] = result.get("incident_type") or None incident_type: Optional[str] = result.get("incident_type") or None
location: Optional[str] = result.get("location") or None location: Optional[str] = result.get("location") or None
vehicles: list[str] = result.get("vehicles") or [] vehicles: list[str] = result.get("vehicles") or []
units: list[str] = result.get("units") or [] units: list[str] = result.get("units") or []
severity: str = result.get("severity") or "unknown" severity: str = result.get("severity") or "unknown"
transcript_corrected: Optional[str] = result.get("transcript_corrected") or None transcript_corrected: Optional[str] = result.get("transcript_corrected") or None
if incident_type in ("unknown", "other", ""): if incident_type in ("unknown", "other", ""):
incident_type = None incident_type = None
# Geocode the location string if we have one and a node to bias toward
location_coords: Optional[dict] = None
if location and node_id:
node_doc = await fstore.doc_get("nodes", node_id)
if node_doc:
node_lat = node_doc.get("lat")
node_lon = node_doc.get("lon")
if node_lat is not None and node_lon is not None:
location_coords = await _geocode_location(location, node_lat, node_lon)
# Store embedding alongside structured data # Store embedding alongside structured data
embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type)) embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type))
updates: dict = { updates: dict = {"tags": tags, "severity": severity}
"tags": tags,
"severity": severity,
}
if location: if location:
updates["location"] = location updates["location"] = location
if location_coords:
updates["location_coords"] = location_coords
if vehicles: if vehicles:
updates["vehicles"] = vehicles updates["vehicles"] = vehicles
if units: if units:
@@ -92,10 +109,49 @@ async def extract_tags(
logger.info( logger.info(
f"Intelligence: call {call_id} → type={incident_type}, " f"Intelligence: call {call_id} → type={incident_type}, "
f"tags={tags}, location={location!r}, severity={severity}, " f"tags={tags}, location={location!r}, coords={location_coords}, severity={severity}, "
f"corrected={transcript_corrected is not None}" f"corrected={transcript_corrected is not None}"
) )
return tags, incident_type, location return tags, incident_type, location, location_coords
async def _geocode_location(
location_str: str, node_lat: float, node_lon: float
) -> Optional[dict]:
"""
Geocode a location string using Nominatim, biased toward the node's area.
Returns {"lat": float, "lng": float} or None if geocoding fails.
"""
import httpx
viewbox = (
f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA},"
f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}"
)
params = {
"q": location_str,
"format": "json",
"limit": 1,
"viewbox": viewbox,
"bounded": 1,
}
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
r = await client.get(
"https://nominatim.openstreetmap.org/search",
params=params,
headers=headers,
)
r.raise_for_status()
results = r.json()
if results:
coords = {"lat": float(results[0]["lat"]), "lng": float(results[0]["lon"])}
logger.info(f"Geocoded '{location_str}'{coords}")
return coords
except Exception as e:
logger.warning(f"Geocoding failed for '{location_str}': {e}")
return None
def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str: def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) -> str:
@@ -154,10 +210,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
try: try:
client = OpenAI(api_key=settings.openai_api_key) client = OpenAI(api_key=settings.openai_api_key)
result = client.embeddings.create( result = client.embeddings.create(model="text-embedding-3-small", input=text)
model="text-embedding-3-small",
input=text,
)
return result.data[0].embedding return result.data[0].embedding
except Exception as e: except Exception as e:
logger.warning(f"Embedding generation failed: {e}") logger.warning(f"Embedding generation failed: {e}")
@@ -165,6 +218,5 @@ def _sync_embed(text: str) -> Optional[list[float]]:
def _embed_text(transcript: str, incident_type: Optional[str]) -> str: def _embed_text(transcript: str, incident_type: Optional[str]) -> str:
"""Build the text string to embed — transcript + type context."""
prefix = f"[{incident_type}] " if incident_type else "" prefix = f"[{incident_type}] " if incident_type else ""
return f"{prefix}{transcript}" return f"{prefix}{transcript}"
+7 -2
View File
@@ -95,9 +95,10 @@ async def _run_extraction_pipeline(
"""Run steps 2-4 of the intelligence pipeline using an existing transcript.""" """Run steps 2-4 of the intelligence pipeline using an existing transcript."""
from app.internal import intelligence, incident_correlator, alerter from app.internal import intelligence, incident_correlator, alerter
tags, incident_type, location = await intelligence.extract_tags( tags, incident_type, location, location_coords = await intelligence.extract_tags(
call_id, transcript, talkgroup_name, call_id, transcript, talkgroup_name,
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
node_id=node_id,
) )
if incident_type: if incident_type:
@@ -110,6 +111,7 @@ async def _run_extraction_pipeline(
tags=tags, tags=tags,
incident_type=incident_type, incident_type=incident_type,
location=location, location=location,
location_coords=location_coords,
) )
await alerter.check_and_dispatch( await alerter.check_and_dispatch(
@@ -150,10 +152,12 @@ async def _run_intelligence_pipeline(
tags: list[str] = [] tags: list[str] = []
incident_type: Optional[str] = None incident_type: Optional[str] = None
location: Optional[str] = None location: Optional[str] = None
location_coords: Optional[dict] = None
if transcript: if transcript:
tags, incident_type, location = await intelligence.extract_tags( tags, incident_type, location, location_coords = await intelligence.extract_tags(
call_id, transcript, talkgroup_name, call_id, transcript, talkgroup_name,
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments, talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
node_id=node_id,
) )
# Step 3: Incident correlation # Step 3: Incident correlation
@@ -167,6 +171,7 @@ async def _run_intelligence_pipeline(
tags=tags, tags=tags,
incident_type=incident_type, incident_type=incident_type,
location=location, location=location,
location_coords=location_coords,
) )
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript) # Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useIncident } from "@/lib/useIncidents";
import { useCallsByIncident } from "@/lib/useCalls";
import { useSystems } from "@/lib/useSystems";
import { useAuth } from "@/components/AuthProvider";
import { CallRow } from "@/components/CallRow";
import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types";
const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300",
ems: "bg-yellow-900 text-yellow-300",
accident: "bg-orange-900 text-orange-300",
other: "bg-gray-800 text-gray-300",
};
function TypeBadge({ type }: { type: string | null }) {
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
return (
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
{type ?? "other"}
</span>
);
}
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
return (
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
}`}>
{status}
</span>
);
}
export default function IncidentDetailPage() {
const params = useParams();
const id = params.id as string;
const router = useRouter();
const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id);
const { systems } = useSystems();
const { isAdmin } = useAuth();
const [summarizing, setSummarizing] = useState(false);
const [resolving, setResolving] = useState(false);
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
async function handleResolve() {
setResolving(true);
try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); }
finally { setResolving(false); }
}
async function handleSummarize() {
setSummarizing(true);
try { await c2api.summarizeIncident(id); }
catch (e) { console.error(e); }
finally { setSummarizing(false); }
}
if (loading) {
return <p className="text-gray-500 text-sm font-mono p-6">Loading</p>;
}
if (!incident) {
return <p className="text-gray-500 text-sm font-mono p-6">Incident not found.</p>;
}
return (
<div className="space-y-6">
{/* Back */}
<button
onClick={() => router.back()}
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
>
Incidents
</button>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 flex-wrap">
<TypeBadge type={incident.type} />
<h1 className="text-xl font-bold text-white font-mono">
{incident.title ?? "Incident"}
</h1>
<StatusBadge status={incident.status} />
</div>
{isAdmin && (
<div className="flex gap-2 shrink-0">
<button
onClick={handleSummarize}
disabled={summarizing}
className="text-xs bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 text-white px-3 py-1.5 rounded-lg transition-colors"
>
{summarizing ? "Generating…" : "Regenerate summary"}
</button>
{incident.status === "active" && (
<button
onClick={handleResolve}
disabled={resolving}
className="text-xs bg-gray-800 hover:bg-gray-700 disabled:opacity-50 text-gray-300 px-3 py-1.5 rounded-lg transition-colors"
>
{resolving ? "Resolving…" : "Resolve"}
</button>
)}
</div>
)}
</div>
{/* Summary */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">
Summary
</h2>
{incident.summary ? (
<p className="text-sm text-gray-300 bg-gray-900 border border-gray-800 rounded-lg p-4 leading-relaxed">
{incident.summary}
</p>
) : (
<p className="text-sm text-gray-600 font-mono italic">
No summary yet.{" "}
{isAdmin && (
<button
onClick={handleSummarize}
disabled={summarizing}
className="text-indigo-400 hover:text-indigo-300 not-italic transition-colors"
>
Generate now
</button>
)}
</p>
)}
</section>
{/* Tags + Location */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Tags</h2>
{incident.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{incident.tags.map((t) => (
<span key={t} className="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
{t}
</span>
))}
</div>
) : (
<p className="text-gray-600 text-sm font-mono">No tags.</p>
)}
</section>
{incident.location && (
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Location</h2>
<p className="text-sm text-gray-300 font-mono">{incident.location}</p>
</section>
)}
</div>
{/* Metadata */}
<div className="text-xs text-gray-600 font-mono flex flex-wrap gap-x-6 gap-y-1">
<span>Started: <span className="text-gray-400">{new Date(incident.started_at).toLocaleString()}</span></span>
<span>Updated: <span className="text-gray-400">{new Date(incident.updated_at).toLocaleString()}</span></span>
<span>Calls: <span className="text-gray-400">{incident.call_ids.length}</span></span>
{incident.talkgroup_ids?.length > 0 && (
<span>Talkgroups: <span className="text-gray-400">{incident.talkgroup_ids.join(", ")}</span></span>
)}
</div>
{/* Calls */}
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Calls</h2>
{callsLoading ? (
<p className="text-gray-600 text-sm font-mono">Loading calls</p>
) : calls.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No calls linked yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
<th className="px-4 py-2 text-left">Time</th>
<th className="px-4 py-2 text-left">Talkgroup</th>
<th className="px-4 py-2 text-left">System</th>
<th className="px-4 py-2 text-left">Node</th>
<th className="px-4 py-2 text-left">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{calls.map((c) => (
<CallRow
key={c.call_id}
call={c}
systemName={systemMap[c.system_id ?? ""]?.name}
isAdmin={isAdmin}
/>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+77 -126
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents"; import { useIncidents } from "@/lib/useIncidents";
import { c2api } from "@/lib/c2api"; import { c2api } from "@/lib/c2api";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
import { useState } from "react";
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300", fire: "bg-red-900 text-red-300",
@@ -32,65 +33,38 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
isAdmin: boolean; isAdmin: boolean;
onResolve: (id: string) => void; onResolve: (id: string) => void;
}) { }) {
const [expanded, setExpanded] = useState(false); const router = useRouter();
return ( return (
<> <tr
<tr className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}
onClick={() => setExpanded((v) => !v)} >
> <td className="px-4 py-3">{typeBadge(incident.type)}</td>
<td className="px-4 py-3">{typeBadge(incident.type)}</td> <td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td> <td className="px-4 py-3">
<td className="px-4 py-3"> <span className={`text-xs px-2 py-0.5 rounded-full ${
<span className={`text-xs px-2 py-0.5 rounded-full ${ incident.status === "active"
incident.status === "active" ? "bg-green-900 text-green-300"
? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
: "bg-gray-800 text-gray-400" }`}>
}`}> {incident.status}
{incident.status} </span>
</span> </td>
</td> <td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td> <td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.started_at)}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.started_at)}</td> <td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td> <td className="px-4 py-3">
<td className="px-4 py-3"> {isAdmin && incident.status === "active" && (
{isAdmin && incident.status === "active" && ( <button
<button onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }} className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors" >
> Resolve
Resolve </button>
</button> )}
)} </td>
</td> </tr>
</tr>
{expanded && (
<tr className="bg-gray-900 border-b border-gray-800">
<td colSpan={7} className="px-6 py-3">
{incident.summary && (
<p className="text-sm text-gray-300 mb-2">{incident.summary}</p>
)}
{incident.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{incident.tags.map((t) => (
<span key={t} className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full">{t}</span>
))}
</div>
)}
<div className="text-xs text-gray-500 font-mono">
<span className="text-gray-400">Linked calls: </span>
{incident.call_ids.length === 0 ? "none" : incident.call_ids.map((id, i) => (
<span key={id}>
<a href={`/calls?highlight=${id}`} className="text-indigo-400 hover:underline">{id.slice(0, 8)}</a>
{i < incident.call_ids.length - 1 && ", "}
</span>
))}
</div>
</td>
</tr>
)}
</>
); );
} }
@@ -98,10 +72,10 @@ function CreateModal({ onClose, onCreate }: {
onClose: () => void; onClose: () => void;
onCreate: (body: object) => Promise<void>; onCreate: (body: object) => Promise<void>;
}) { }) {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [type, setType] = useState("other"); const [type, setType] = useState("other");
const [summary, setSummary] = useState(""); const [summary, setSummary] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -162,24 +136,51 @@ function CreateModal({ onClose, onCreate }: {
); );
} }
function IncidentTable({ incidents, isAdmin, onResolve }: {
incidents: IncidentRecord[];
isAdmin: boolean;
onResolve: (id: string) => void;
}) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{incidents.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
isAdmin={isAdmin}
onResolve={onResolve}
/>
))}
</tbody>
</table>
</div>
);
}
export default function IncidentsPage() { export default function IncidentsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { incidents, loading } = useIncidents(); const { incidents, loading } = useIncidents();
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const active = incidents.filter((i) => i.status === "active"); const active = incidents.filter((i) => i.status === "active");
const resolved = incidents.filter((i) => i.status === "resolved"); const resolved = incidents.filter((i) => i.status === "resolved");
async function handleResolve(id: string) { async function handleResolve(id: string) {
try { try { await c2api.updateIncident(id, { status: "resolved" }); }
await c2api.updateIncident(id, { status: "resolved" }); catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
}
async function handleCreate(body: object) {
await c2api.createIncident(body);
} }
return ( return (
@@ -207,67 +208,17 @@ export default function IncidentsPage() {
<p className="text-gray-500 text-sm font-mono">Loading</p> <p className="text-gray-500 text-sm font-mono">Loading</p>
) : ( ) : (
<> <>
{/* Active incidents */}
{active.length > 0 && ( {active.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2> <h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <IncidentTable incidents={active} isAdmin={isAdmin} onResolve={handleResolve} />
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{active.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
isAdmin={isAdmin}
onResolve={handleResolve}
/>
))}
</tbody>
</table>
</div>
</section> </section>
)} )}
{/* Resolved incidents */}
{resolved.length > 0 && ( {resolved.length > 0 && (
<section> <section>
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2> <h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2>
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden"> <IncidentTable incidents={resolved} isAdmin={isAdmin} onResolve={handleResolve} />
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Title</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Calls</th>
<th className="px-4 py-3">Started</th>
<th className="px-4 py-3">Updated</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{resolved.map((inc) => (
<IncidentRow
key={inc.incident_id}
incident={inc}
isAdmin={isAdmin}
onResolve={handleResolve}
/>
))}
</tbody>
</table>
</div>
</section> </section>
)} )}
@@ -278,7 +229,7 @@ export default function IncidentsPage() {
)} )}
{showCreate && ( {showCreate && (
<CreateModal onClose={() => setShowCreate(false)} onCreate={handleCreate} /> <CreateModal onClose={() => setShowCreate(false)} onCreate={(b) => c2api.createIncident(b)} />
)} )}
</div> </div>
); );
+52 -3
View File
@@ -1,17 +1,52 @@
"use client"; "use client";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Link from "next/link";
import { useNodes } from "@/lib/useNodes"; import { useNodes } from "@/lib/useNodes";
import { useActiveCalls } from "@/lib/useCalls"; import { useActiveCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents"; import { useActiveIncidents } from "@/lib/useIncidents";
import type { IncidentRecord } from "@/lib/types";
// Leaflet is browser-only — must be dynamically imported with no SSR
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TYPE_COLORS: Record<string, string> = {
fire: "border-red-800 bg-red-950 text-red-300",
police: "border-blue-800 bg-blue-950 text-blue-300",
ems: "border-yellow-800 bg-yellow-950 text-yellow-300",
accident: "border-orange-800 bg-orange-950 text-orange-300",
other: "border-gray-700 bg-gray-900 text-gray-300",
};
function IncidentCard({ incident }: { incident: IncidentRecord }) {
const cls = TYPE_COLORS[incident.type ?? "other"] ?? TYPE_COLORS.other;
return (
<Link
href={`/incidents/${incident.incident_id}`}
className={`block border rounded-lg p-3 hover:brightness-110 transition-all ${cls}`}
>
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-mono font-semibold uppercase tracking-wide">
{incident.type ?? "other"}
</span>
<span className="text-xs opacity-60 font-mono">
{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
</span>
</div>
<p className="text-sm font-bold leading-tight">{incident.title ?? "Incident"}</p>
{incident.location && (
<p className="text-xs opacity-70 mt-1 font-mono truncate">{incident.location}</p>
)}
{!incident.location_coords && (
<p className="text-xs opacity-40 mt-1 font-mono italic">location not geocoded yet</p>
)}
</Link>
);
}
export default function MapPage() { export default function MapPage() {
const { nodes, loading } = useNodes(); const { nodes, loading } = useNodes();
const activeCalls = useActiveCalls(); const activeCalls = useActiveCalls();
const incidents = useActiveIncidents(); const incidents = useActiveIncidents();
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -34,10 +69,24 @@ export default function MapPage() {
Loading map Loading map
</div> </div>
) : ( ) : (
<div style={{ height: "calc(100vh - 160px)" }}> <div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} /> <MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
</div> </div>
)} )}
{/* Active incidents — shown even without geocoded location */}
{incidents.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
Active Incidents ({incidents.length})
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{incidents.map((inc) => (
<IncidentCard key={inc.incident_id} incident={inc} />
))}
</div>
</section>
)}
</div> </div>
); );
} }
+13 -7
View File
@@ -59,9 +59,11 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
activeCalls.map((c) => [c.node_id, c]) activeCalls.map((c) => [c.node_id, c])
); );
// Only show incidents that have coordinates // Only show incidents that have been geocoded (location_coords set by the server).
const mappableIncidents = incidents.filter( const plottedIncidents = incidents.flatMap((inc) =>
(i) => i.location && i.location.lat != null && i.location.lng != null inc.location_coords
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
: []
); );
const center: [number, number] = const center: [number, number] =
@@ -102,22 +104,26 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
</Marker> </Marker>
))} ))}
{/* Incident markers */} {/* Incident markers — positioned at the node covering the incident's system */}
{mappableIncidents.map((inc) => ( {plottedIncidents.map(({ inc, pos }) => (
<Marker <Marker
key={inc.incident_id} key={inc.incident_id}
position={[inc.location!.lat, inc.location!.lng]} position={pos}
icon={incidentIcon(inc.type)} icon={incidentIcon(inc.type)}
> >
<Popup className="font-mono"> <Popup className="font-mono">
<div className="text-gray-900"> <div className="text-gray-900">
<p className="font-bold">{inc.title ?? "Incident"}</p> <p className="font-bold">{inc.title ?? "Incident"}</p>
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] }}> <p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}>
{inc.type ?? "other"} {inc.type ?? "other"}
</p> </p>
<p className="text-xs mt-1 capitalize">{inc.status}</p> <p className="text-xs mt-1 capitalize">{inc.status}</p>
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</p>}
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p> <p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>} {inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
View incident
</a>
</div> </div>
</Popup> </Popup>
</Marker> </Marker>
+2
View File
@@ -72,6 +72,8 @@ export const c2api = {
request(`/incidents/${id}`, { method: "DELETE" }), request(`/incidents/${id}`, { method: "DELETE" }),
linkCallToIncident: (incidentId: string, callId: string) => linkCallToIncident: (incidentId: string, callId: string) =>
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }), request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
summarizeIncident: (id: string) =>
request(`/incidents/${id}/summarize`, { method: "POST" }),
// Alerts // Alerts
getAlerts: (acknowledged?: boolean) => { getAlerts: (acknowledged?: boolean) => {
+5 -2
View File
@@ -40,7 +40,7 @@ export interface CallRecord {
transcript_corrected: string | null; transcript_corrected: string | null;
segments: TranscriptSegment[] | null; segments: TranscriptSegment[] | null;
incident_id: string | null; incident_id: string | null;
location: { lat: number; lng: number } | null; location: string | null;
tags: string[]; tags: string[];
status: "active" | "ended"; status: "active" | "ended";
} }
@@ -50,8 +50,11 @@ export interface IncidentRecord {
title: string | null; title: string | null;
type: string | null; type: string | null;
status: "active" | "resolved"; status: "active" | "resolved";
location: { lat: number; lng: number } | null; location: string | null;
location_coords: { lat: number; lng: number } | null;
call_ids: string[]; call_ids: string[];
system_ids: string[];
talkgroup_ids: string[];
started_at: string; started_at: string;
updated_at: string; updated_at: string;
summary: string | null; summary: string | null;
+33
View File
@@ -48,6 +48,39 @@ export function useCalls(limitCount = 50) {
return { calls, loading, error }; return { calls, loading, error };
} }
export function useCallsByIncident(incidentId: string | null) {
const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!incidentId) { setLoading(false); return; }
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; }
const toISO = (v: any): string | null =>
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
const q = query(collection(db, "calls"), where("incident_id", "==", incidentId));
unsubFirestore = onSnapshot(q, (snap) => {
const docs = snap.docs.map((d) => {
const data = d.data();
return { ...data, started_at: toISO(data.started_at) ?? "", ended_at: toISO(data.ended_at) } as CallRecord;
});
docs.sort((a, b) => a.started_at.localeCompare(b.started_at));
setCalls(docs);
setLoading(false);
}, (err: FirestoreError) => { console.error("useCallsByIncident:", err); setLoading(false); });
});
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId]);
return { calls, loading };
}
export function useActiveCalls() { export function useActiveCalls() {
const [calls, setCalls] = useState<CallRecord[]>([]); const [calls, setCalls] = useState<CallRecord[]>([]);
+38 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore"; import { collection, doc, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth"; import { onAuthStateChanged } from "firebase/auth";
import { db, auth } from "@/lib/firebase"; import { db, auth } from "@/lib/firebase";
import type { IncidentRecord } from "@/lib/types"; import type { IncidentRecord } from "@/lib/types";
@@ -58,6 +58,43 @@ export function useIncidents(limitCount = 100) {
return { incidents, loading, error }; return { incidents, loading, error };
} }
export function useIncident(incidentId: string | null) {
const [incident, setIncident] = useState<IncidentRecord | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!incidentId) { setLoading(false); return; }
let unsubFirestore: (() => void) | undefined;
const unsubAuth = onAuthStateChanged(auth, (user) => {
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
if (!user) { setLoading(false); return; }
const ref = doc(db, "incidents", incidentId);
unsubFirestore = onSnapshot(ref, (snap) => {
if (snap.exists()) {
const data = snap.data();
setIncident({
...data,
started_at: toISO(data.started_at),
updated_at: toISO(data.updated_at),
} as IncidentRecord);
} else {
setIncident(null);
}
setLoading(false);
}, (err: FirestoreError) => {
console.error("useIncident:", err);
setLoading(false);
});
});
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
}, [incidentId]);
return { incident, loading };
}
export function useActiveIncidents() { export function useActiveIncidents() {
const [incidents, setIncidents] = useState<IncidentRecord[]>([]); const [incidents, setIncidents] = useState<IncidentRecord[]>([]);