big ui and intel updates
This commit is contained in:
@@ -44,6 +44,7 @@ async def correlate_call(
|
||||
tags: list[str],
|
||||
incident_type: Optional[str],
|
||||
location: Optional[str] = None,
|
||||
location_coords: Optional[dict] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Link call_id to an existing incident or create a new one.
|
||||
@@ -113,10 +114,10 @@ async def correlate_call(
|
||||
# ----------------------------------------------------------------
|
||||
if matched_incident:
|
||||
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:
|
||||
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
|
||||
@@ -131,6 +132,7 @@ async def _update_incident(
|
||||
system_id: Optional[str],
|
||||
tags: list[str],
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
now: datetime,
|
||||
) -> None:
|
||||
incident_id = inc["incident_id"]
|
||||
@@ -151,11 +153,11 @@ async def _update_incident(
|
||||
merged_tags = list(dict.fromkeys(inc.get("tags", []) + tags))
|
||||
|
||||
# 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", [])
|
||||
if location and location not in location_mentions:
|
||||
location_mentions.append(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
|
||||
embedding_updates = await _merge_embedding(inc, call_id)
|
||||
@@ -172,6 +174,8 @@ async def _update_incident(
|
||||
}
|
||||
if best_location:
|
||||
updates["location"] = best_location
|
||||
if best_coords:
|
||||
updates["location_coords"] = best_coords
|
||||
|
||||
await fstore.doc_set("incidents", incident_id, updates)
|
||||
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],
|
||||
tags: list[str],
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
now: datetime,
|
||||
) -> str:
|
||||
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_embedding = call_doc.get("embedding") if call_doc else None
|
||||
@@ -202,6 +207,7 @@ async def _create_incident(
|
||||
"type": incident_type,
|
||||
"status": "active",
|
||||
"location": location,
|
||||
"location_coords": location_coords,
|
||||
"location_mentions": [location] if location else [],
|
||||
"call_ids": [call_id],
|
||||
"talkgroup_ids": [str(talkgroup_id)] if talkgroup_id is not None else [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 json
|
||||
@@ -36,6 +36,9 @@ System: {system_id}
|
||||
Talkgroup: {talkgroup_name}
|
||||
{transcript_block}"""
|
||||
|
||||
# Nominatim viewbox half-width in degrees (~35 km at mid-latitudes)
|
||||
_GEO_DELTA = 0.3
|
||||
|
||||
|
||||
async def extract_tags(
|
||||
call_id: str,
|
||||
@@ -44,17 +47,22 @@ async def extract_tags(
|
||||
talkgroup_id: Optional[int] = None,
|
||||
system_id: Optional[str] = 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:
|
||||
(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,
|
||||
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 []
|
||||
incident_type: Optional[str] = result.get("incident_type") or None
|
||||
@@ -67,15 +75,24 @@ async def extract_tags(
|
||||
if incident_type in ("unknown", "other", ""):
|
||||
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
|
||||
embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type))
|
||||
|
||||
updates: dict = {
|
||||
"tags": tags,
|
||||
"severity": severity,
|
||||
}
|
||||
updates: dict = {"tags": tags, "severity": severity}
|
||||
if location:
|
||||
updates["location"] = location
|
||||
if location_coords:
|
||||
updates["location_coords"] = location_coords
|
||||
if vehicles:
|
||||
updates["vehicles"] = vehicles
|
||||
if units:
|
||||
@@ -92,10 +109,49 @@ async def extract_tags(
|
||||
|
||||
logger.info(
|
||||
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}"
|
||||
)
|
||||
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:
|
||||
@@ -154,10 +210,7 @@ def _sync_embed(text: str) -> Optional[list[float]]:
|
||||
|
||||
try:
|
||||
client = OpenAI(api_key=settings.openai_api_key)
|
||||
result = client.embeddings.create(
|
||||
model="text-embedding-3-small",
|
||||
input=text,
|
||||
)
|
||||
result = client.embeddings.create(model="text-embedding-3-small", input=text)
|
||||
return result.data[0].embedding
|
||||
except Exception as 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:
|
||||
"""Build the text string to embed — transcript + type context."""
|
||||
prefix = f"[{incident_type}] " if incident_type else ""
|
||||
return f"{prefix}{transcript}"
|
||||
|
||||
@@ -95,9 +95,10 @@ async def _run_extraction_pipeline(
|
||||
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
||||
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,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
if incident_type:
|
||||
@@ -110,6 +111,7 @@ async def _run_extraction_pipeline(
|
||||
tags=tags,
|
||||
incident_type=incident_type,
|
||||
location=location,
|
||||
location_coords=location_coords,
|
||||
)
|
||||
|
||||
await alerter.check_and_dispatch(
|
||||
@@ -150,10 +152,12 @@ async def _run_intelligence_pipeline(
|
||||
tags: list[str] = []
|
||||
incident_type: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
location_coords: Optional[dict] = None
|
||||
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,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
# Step 3: Incident correlation
|
||||
@@ -167,6 +171,7 @@ async def _run_intelligence_pipeline(
|
||||
tags=tags,
|
||||
incident_type=incident_type,
|
||||
location=location,
|
||||
location_coords=location_coords,
|
||||
)
|
||||
|
||||
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useIncidents } from "@/lib/useIncidents";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
fire: "bg-red-900 text-red-300",
|
||||
@@ -32,13 +33,12 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||
@@ -65,32 +65,6 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
)}
|
||||
</td>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +136,40 @@ 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() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { incidents, loading } = useIncidents();
|
||||
@@ -171,15 +179,8 @@ export default function IncidentsPage() {
|
||||
const resolved = incidents.filter((i) => i.status === "resolved");
|
||||
|
||||
async function handleResolve(id: string) {
|
||||
try {
|
||||
await c2api.updateIncident(id, { status: "resolved" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(body: object) {
|
||||
await c2api.createIncident(body);
|
||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||
catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -207,67 +208,17 @@ export default function IncidentsPage() {
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Active incidents */}
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<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">
|
||||
<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>
|
||||
<IncidentTable incidents={active} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Resolved incidents */}
|
||||
{resolved.length > 0 && (
|
||||
<section>
|
||||
<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">
|
||||
<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>
|
||||
<IncidentTable incidents={resolved} isAdmin={isAdmin} onResolve={handleResolve} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -278,7 +229,7 @@ export default function IncidentsPage() {
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateModal onClose={() => setShowCreate(false)} onCreate={handleCreate} />
|
||||
<CreateModal onClose={() => setShowCreate(false)} onCreate={(b) => c2api.createIncident(b)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useActiveCalls } from "@/lib/useCalls";
|
||||
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 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() {
|
||||
const { nodes, loading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
@@ -34,10 +69,24 @@ export default function MapPage() {
|
||||
Loading map…
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: "calc(100vh - 160px)" }}>
|
||||
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,9 +59,11 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
activeCalls.map((c) => [c.node_id, c])
|
||||
);
|
||||
|
||||
// Only show incidents that have coordinates
|
||||
const mappableIncidents = incidents.filter(
|
||||
(i) => i.location && i.location.lat != null && i.location.lng != null
|
||||
// Only show incidents that have been geocoded (location_coords set by the server).
|
||||
const plottedIncidents = incidents.flatMap((inc) =>
|
||||
inc.location_coords
|
||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
||||
: []
|
||||
);
|
||||
|
||||
const center: [number, number] =
|
||||
@@ -102,22 +104,26 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* Incident markers */}
|
||||
{mappableIncidents.map((inc) => (
|
||||
{/* Incident markers — positioned at the node covering the incident's system */}
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
position={[inc.location!.lat, inc.location!.lng]}
|
||||
position={pos}
|
||||
icon={incidentIcon(inc.type)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<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"}
|
||||
</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>
|
||||
{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>
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
||||
@@ -72,6 +72,8 @@ export const c2api = {
|
||||
request(`/incidents/${id}`, { method: "DELETE" }),
|
||||
linkCallToIncident: (incidentId: string, callId: string) =>
|
||||
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||
summarizeIncident: (id: string) =>
|
||||
request(`/incidents/${id}/summarize`, { method: "POST" }),
|
||||
|
||||
// Alerts
|
||||
getAlerts: (acknowledged?: boolean) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface CallRecord {
|
||||
transcript_corrected: string | null;
|
||||
segments: TranscriptSegment[] | null;
|
||||
incident_id: string | null;
|
||||
location: { lat: number; lng: number } | null;
|
||||
location: string | null;
|
||||
tags: string[];
|
||||
status: "active" | "ended";
|
||||
}
|
||||
@@ -50,8 +50,11 @@ export interface IncidentRecord {
|
||||
title: string | null;
|
||||
type: string | null;
|
||||
status: "active" | "resolved";
|
||||
location: { lat: number; lng: number } | null;
|
||||
location: string | null;
|
||||
location_coords: { lat: number; lng: number } | null;
|
||||
call_ids: string[];
|
||||
system_ids: string[];
|
||||
talkgroup_ids: string[];
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
summary: string | null;
|
||||
|
||||
@@ -48,6 +48,39 @@ export function useCalls(limitCount = 50) {
|
||||
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() {
|
||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { db, auth } from "@/lib/firebase";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
@@ -58,6 +58,43 @@ export function useIncidents(limitCount = 100) {
|
||||
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() {
|
||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user