Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d67b2057e6 | ||
|
|
c1c3e89e1d | ||
|
|
968134f8ee | ||
|
|
b430cf32f2 | ||
|
|
93fa3a6054 | ||
|
|
de03f5bcaf | ||
|
|
0651bfe07a | ||
|
|
c4656a9607 | ||
|
|
a9d1d2475a |
@@ -668,10 +668,17 @@ async def correlate_call(
|
|||||||
vehicles: Optional[list[str]] = None,
|
vehicles: Optional[list[str]] = None,
|
||||||
cleared_units: Optional[list[str]] = None,
|
cleared_units: Optional[list[str]] = None,
|
||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
|
embedding: Optional[list] = None,
|
||||||
|
severity: Optional[str] = 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.
|
||||||
Thin wrapper: builds context → runs rules decision → commits.
|
Thin wrapper: builds context → runs rules decision → commits.
|
||||||
|
|
||||||
|
``embedding`` and ``severity`` are the SCENE's own values (server-26#80/#95).
|
||||||
|
Callers that re-correlate a whole call rather than a scene — the
|
||||||
|
recorrelation sweep — pass the call doc's stored values explicitly; they are
|
||||||
|
no longer read from the doc inside _build_context.
|
||||||
"""
|
"""
|
||||||
ctx = await _build_context(
|
ctx = await _build_context(
|
||||||
call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units,
|
call_id=call_id, units=units, vehicles=vehicles, cleared_units=cleared_units,
|
||||||
@@ -679,6 +686,7 @@ async def correlate_call(
|
|||||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||||
tags=tags, incident_type=incident_type, location=location,
|
tags=tags, incident_type=incident_type, location=location,
|
||||||
reassignment=reassignment, create_if_new=create_if_new,
|
reassignment=reassignment, create_if_new=create_if_new,
|
||||||
|
embedding=embedding, severity=severity,
|
||||||
)
|
)
|
||||||
decision = _run_decision(ctx)
|
decision = _run_decision(ctx)
|
||||||
return await _apply_and_log(decision, ctx)
|
return await _apply_and_log(decision, ctx)
|
||||||
@@ -700,6 +708,8 @@ async def preview_correlation(
|
|||||||
vehicles: Optional[list[str]] = None,
|
vehicles: Optional[list[str]] = None,
|
||||||
cleared_units: Optional[list[str]] = None,
|
cleared_units: Optional[list[str]] = None,
|
||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
|
embedding: Optional[list] = None,
|
||||||
|
severity: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Run the rules engine and return the decision WITHOUT committing to Firestore.
|
Run the rules engine and return the decision WITHOUT committing to Firestore.
|
||||||
@@ -720,6 +730,7 @@ async def preview_correlation(
|
|||||||
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
system_id=system_id, talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||||
tags=tags, incident_type=incident_type, location=location,
|
tags=tags, incident_type=incident_type, location=location,
|
||||||
reassignment=reassignment, create_if_new=create_if_new,
|
reassignment=reassignment, create_if_new=create_if_new,
|
||||||
|
embedding=embedding, severity=severity,
|
||||||
)
|
)
|
||||||
decision = _run_decision(ctx)
|
decision = _run_decision(ctx)
|
||||||
return {"decision": decision, "ctx": ctx}
|
return {"decision": decision, "ctx": ctx}
|
||||||
@@ -752,6 +763,8 @@ async def _build_context(
|
|||||||
location: Optional[str],
|
location: Optional[str],
|
||||||
reassignment: bool,
|
reassignment: bool,
|
||||||
create_if_new: bool,
|
create_if_new: bool,
|
||||||
|
embedding: Optional[list] = None,
|
||||||
|
severity: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
now = reference_time or datetime.now(timezone.utc)
|
now = reference_time or datetime.now(timezone.utc)
|
||||||
window = timedelta(hours=settings.correlation_window_hours)
|
window = timedelta(hours=settings.correlation_window_hours)
|
||||||
@@ -777,11 +790,20 @@ async def _build_context(
|
|||||||
all_active = _drop_capped(all_active, now)
|
all_active = _drop_capped(all_active, now)
|
||||||
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
recent = [inc for inc in all_active if _within_window_of(inc, now, window)]
|
||||||
|
|
||||||
call_embedding = call_doc.get("embedding")
|
# embedding and severity come from the SCENE being correlated, not the call
|
||||||
|
# doc — server-26#80 / #95. intelligence.py writes only the primary scene's
|
||||||
|
# embedding and severity to calls/{id}, so reading them back here handed
|
||||||
|
# every non-primary scene the primary scene's semantic vector and severity
|
||||||
|
# rung: a scene about a different event scored against the wrong incident on
|
||||||
|
# the embedding path (:1166/:1205/:1533) and could inherit a minor/moderate/
|
||||||
|
# major severity it never had, clearing the creation gate on borrowed
|
||||||
|
# weight. Same failure and same fix as the #87 coords leak directly below —
|
||||||
|
# a scene that passes none has none, and is judged thin on its own signal.
|
||||||
|
call_embedding = embedding
|
||||||
call_units = units if units is not None else (call_doc.get("units") or [])
|
call_units = units if units is not None else (call_doc.get("units") or [])
|
||||||
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||||
call_severity = call_doc.get("severity") or "routine"
|
call_severity = severity or "routine"
|
||||||
# A string that is not a place is not a location anywhere downstream — not
|
# A string that is not a place is not a location anywhere downstream — not
|
||||||
# in the fit tests, not in the thin-call test, not in the LLM prompt, and
|
# in the fit tests, not in the thin-call test, not in the LLM prompt, and
|
||||||
# not on the incident. Its coordinates go with it: coords are geocoded
|
# not on the incident. Its coordinates go with it: coords are geocoded
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ from app.internal.incident_correlator import clean_location, location_is_unit
|
|||||||
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
|
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
|
||||||
|
|
||||||
SCENE DETECTION:
|
SCENE DETECTION:
|
||||||
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Detect whether this recording contains ONE scene (all transmissions relate to a single event) or MULTIPLE scenes (clearly distinct dispatch conversations with different units being assigned, different locations, different event types). Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Your default is ONE scene. Return MULTIPLE scenes ONLY when the recording clearly contains two or more SEPARATE EVENTS — different incidents at different places, with no shared units, no shared subject, and no conversational thread connecting them.
|
||||||
|
|
||||||
|
These do NOT make a new scene — keep them in the same scene:
|
||||||
|
- a different unit or speaker joining the same event
|
||||||
|
- a follow-up transmission about the same job (records check, case number, tow/mileage, a unit clearing, an ETA, a location correction)
|
||||||
|
- the same subject or location being discussed again minutes later
|
||||||
|
- an administrative or status exchange that follows an event on the same channel
|
||||||
|
If you are unsure whether two exchanges are one event or two, treat them as ONE.
|
||||||
|
|
||||||
|
Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
||||||
|
|
||||||
Always respond with the scenes array, even for a single scene.
|
Always respond with the scenes array, even for a single scene.
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# All data needed for correlation was stored by the first-pass extraction.
|
# All data needed for correlation was stored by the first-pass extraction.
|
||||||
|
# embedding/severity are no longer read from the call doc inside
|
||||||
|
# _build_context (server-26#80/#95) — the sweep re-links a whole call, not a
|
||||||
|
# scene, so it passes the call doc's stored (primary-scene) values here. It
|
||||||
|
# is link-only (create_if_new=False), so a borrowed severity cannot open a
|
||||||
|
# new incident off this path.
|
||||||
incident_id = await incident_correlator.correlate_call(
|
incident_id = await incident_correlator.correlate_call(
|
||||||
call_id = call_id,
|
call_id = call_id,
|
||||||
node_id = call.get("node_id", ""),
|
node_id = call.get("node_id", ""),
|
||||||
@@ -101,6 +106,8 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
|||||||
location = call.get("location"),
|
location = call.get("location"),
|
||||||
location_coords= call.get("location_coords"),
|
location_coords= call.get("location_coords"),
|
||||||
cleared_units = call.get("cleared_units") or [],
|
cleared_units = call.get("cleared_units") or [],
|
||||||
|
embedding = call.get("embedding"),
|
||||||
|
severity = call.get("severity"),
|
||||||
reference_time = started_at, # anchor window to when the call happened
|
reference_time = started_at, # anchor window to when the call happened
|
||||||
create_if_new = False, # never create — link-only
|
create_if_new = False, # never create — link-only
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ async def _correlate_with_consensus(
|
|||||||
vehicles: Optional[list] = None,
|
vehicles: Optional[list] = None,
|
||||||
cleared_units: Optional[list] = None,
|
cleared_units: Optional[list] = None,
|
||||||
reassignment: bool = False,
|
reassignment: bool = False,
|
||||||
|
embedding: Optional[list] = None,
|
||||||
|
severity: Optional[str] = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
||||||
@@ -131,6 +133,7 @@ async def _correlate_with_consensus(
|
|||||||
tags=tags, incident_type=incident_type, location=location,
|
tags=tags, incident_type=incident_type, location=location,
|
||||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||||
cleared_units=cleared_units, reassignment=reassignment,
|
cleared_units=cleared_units, reassignment=reassignment,
|
||||||
|
embedding=embedding, severity=severity,
|
||||||
)
|
)
|
||||||
ctx = preview["ctx"]
|
ctx = preview["ctx"]
|
||||||
rules_decision = preview["decision"]
|
rules_decision = preview["decision"]
|
||||||
@@ -221,6 +224,8 @@ async def _run_extraction_pipeline(
|
|||||||
vehicles=scene.get("vehicles"),
|
vehicles=scene.get("vehicles"),
|
||||||
cleared_units=scene.get("cleared_units"),
|
cleared_units=scene.get("cleared_units"),
|
||||||
reassignment=is_reassignment,
|
reassignment=is_reassignment,
|
||||||
|
embedding=scene.get("embedding"),
|
||||||
|
severity=scene.get("severity"),
|
||||||
)
|
)
|
||||||
if incident_id and incident_id not in incident_ids:
|
if incident_id and incident_id not in incident_ids:
|
||||||
incident_ids.append(incident_id)
|
incident_ids.append(incident_id)
|
||||||
@@ -336,6 +341,8 @@ async def _run_intelligence_pipeline(
|
|||||||
vehicles=scene.get("vehicles"),
|
vehicles=scene.get("vehicles"),
|
||||||
cleared_units=scene.get("cleared_units"),
|
cleared_units=scene.get("cleared_units"),
|
||||||
reassignment=is_reassignment,
|
reassignment=is_reassignment,
|
||||||
|
embedding=scene.get("embedding"),
|
||||||
|
severity=scene.get("severity"),
|
||||||
)
|
)
|
||||||
if incident_id and incident_id not in incident_ids:
|
if incident_id and incident_id not in incident_ids:
|
||||||
incident_ids.append(incident_id)
|
incident_ids.append(incident_id)
|
||||||
|
|||||||
@@ -254,6 +254,56 @@ async def test_a_scene_with_no_location_does_not_inherit_the_call_docs_pin():
|
|||||||
assert ctx["is_thin_call"] is True
|
assert ctx["is_thin_call"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_scene_does_not_inherit_the_call_docs_embedding_or_severity():
|
||||||
|
"""
|
||||||
|
server-26#80 / #95. Same shape as the #87 coords leak above:
|
||||||
|
intelligence.py writes only the PRIMARY scene's embedding and severity to
|
||||||
|
calls/{id}. A non-primary scene being correlated must be judged on its own
|
||||||
|
embedding (or none) and its own severity — not the call doc's — or a scene
|
||||||
|
about a different event scores against the wrong incident on the embedding
|
||||||
|
path and can inherit a minor/moderate/major rung it never had, clearing the
|
||||||
|
creation gate on borrowed weight.
|
||||||
|
"""
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_get = AsyncMock(
|
||||||
|
return_value={"embedding": [0.1] * 1536, "severity": "major"}
|
||||||
|
)
|
||||||
|
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||||
|
ctx = await _build_context(
|
||||||
|
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||||
|
location_coords=None, reference_time=NOW,
|
||||||
|
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||||
|
tags=[], incident_type="police", location=None,
|
||||||
|
reassignment=False, create_if_new=True,
|
||||||
|
embedding=None, severity=None,
|
||||||
|
)
|
||||||
|
assert ctx["call_embedding"] is None
|
||||||
|
assert ctx["call_severity"] == "routine"
|
||||||
|
assert ctx["is_thin_call"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_a_scene_is_judged_on_its_own_embedding_and_severity():
|
||||||
|
"""The other half of #80/#95: the scene's own values are what land in ctx."""
|
||||||
|
scene_vec = [0.9] * 1536
|
||||||
|
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_get = AsyncMock(
|
||||||
|
return_value={"embedding": [0.1] * 1536, "severity": "routine"}
|
||||||
|
)
|
||||||
|
mock_fstore.collection_list = AsyncMock(return_value=[])
|
||||||
|
ctx = await _build_context(
|
||||||
|
call_id="call-scene-2", units=None, vehicles=None, cleared_units=None,
|
||||||
|
location_coords=None, reference_time=NOW,
|
||||||
|
system_id="sys-1", talkgroup_id=383, talkgroup_name=DISPATCH_TG,
|
||||||
|
tags=[], incident_type="police", location=None,
|
||||||
|
reassignment=False, create_if_new=True,
|
||||||
|
embedding=scene_vec, severity="major",
|
||||||
|
)
|
||||||
|
assert ctx["call_embedding"] == scene_vec
|
||||||
|
assert ctx["call_severity"] == "major"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
async def test_a_bare_number_never_becomes_an_incident_location_or_title():
|
||||||
inc = await _create(tags=["flames"], location="49", coords=None,
|
inc = await _create(tags=["flames"], location="49", coords=None,
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
|
|
||||||
export default function AlertsPage() {
|
export default function AlertsPage() {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { alerts, loading } = useAlerts();
|
const { alerts, loading, error } = useAlerts();
|
||||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||||
|
|
||||||
async function handleAcknowledge(id: string) {
|
async function handleAcknowledge(id: string) {
|
||||||
@@ -226,6 +226,12 @@ export default function AlertsPage() {
|
|||||||
{tab === "events" && (
|
{tab === "events" && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-red-400 text-sm font-mono">
|
||||||
|
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||||
|
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
|
||||||
|
: `Couldn't load alerts: ${error}`}
|
||||||
|
</p>
|
||||||
) : alerts.length === 0 ? (
|
) : alerts.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
animation: pulse-ring 1.8s ease-out infinite;
|
animation: pulse-ring 1.8s ease-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
|
||||||
|
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
|
||||||
|
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
|
||||||
|
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
|
||||||
|
* map container to its own low stacking context keeps Leaflet's internal layer
|
||||||
|
* order intact while dropping the whole map (tiles + controls) below the app
|
||||||
|
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
|
||||||
|
* outside .leaflet-container, so it is unaffected and still renders on top.
|
||||||
|
*/
|
||||||
|
.leaflet-container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
|
|||||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||||
const unitsCleared = incident.units_cleared ?? [];
|
const unitsCleared = incident.units_cleared ?? [];
|
||||||
|
const vehicles = incident.vehicles ?? [];
|
||||||
const active = incident.status === "active";
|
const active = incident.status === "active";
|
||||||
|
|
||||||
const visible = newestFirst.slice(0, earlierShown);
|
const visible = newestFirst.slice(0, earlierShown);
|
||||||
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{incident.vehicles?.length > 0 && (
|
{vehicles.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{incident.vehicles.map((v) => (
|
{vehicles.map((v) => (
|
||||||
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
|
|||||||
|
|
||||||
type SortMode = "recent" | "severity";
|
type SortMode = "recent" | "severity";
|
||||||
|
|
||||||
|
// The Firestore client surfaces a missing composite index or an undeployed
|
||||||
|
// ruleset as a raw multi-line string with a console URL in it — not something
|
||||||
|
// to put in front of an operator. Collapse the known infra failures to a plain
|
||||||
|
// line; pass anything else straight through so a real bug still shows.
|
||||||
|
function friendlyIncidentsError(raw: string): string {
|
||||||
|
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
|
||||||
|
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
|
||||||
|
}
|
||||||
|
return `Couldn't load incidents: ${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
||||||
}
|
}
|
||||||
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
|
|||||||
recorded yet" over the top of it told the operator the radio was
|
recorded yet" over the top of it told the operator the radio was
|
||||||
quiet when the page had simply failed to load — server-26#13. */}
|
quiet when the page had simply failed to load — server-26#13. */}
|
||||||
{filtered.length === 0 && error && (
|
{filtered.length === 0 && error && (
|
||||||
<ErrorBanner message={`Couldn't load incidents: ${error}`} />
|
<ErrorBanner message={friendlyIncidentsError(error)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 && !error && (
|
{filtered.length === 0 && !error && (
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function DiscordJoinModal({
|
|||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function NodesPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pending.map((n) => (
|
{pending.map((n) => (
|
||||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
|
|||||||
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
||||||
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
||||||
// AuthProvider's own state (orgId) updates and the effect above
|
// AuthProvider's own state (orgId) updates and the effect above
|
||||||
// redirects to /dashboard.
|
// redirects to "/" (Live).
|
||||||
await refreshClaims();
|
await refreshClaims();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
||||||
|
|||||||
@@ -39,6 +39,30 @@ function EnrollmentTokensPanel() {
|
|||||||
const [label, setLabel] = useState("");
|
const [label, setLabel] = useState("");
|
||||||
const [minting, setMinting] = useState(false);
|
const [minting, setMinting] = useState(false);
|
||||||
const [justMinted, setJustMinted] = useState<string | null>(null);
|
const [justMinted, setJustMinted] = useState<string | null>(null);
|
||||||
|
const [cmdCopied, setCmdCopied] = useState(false);
|
||||||
|
const [tokenCopied, setTokenCopied] = useState(false);
|
||||||
|
// The label the operator typed for the token that was just minted — used as
|
||||||
|
// the node id in the install command below. Captured on mint because `label`
|
||||||
|
// itself is cleared afterward.
|
||||||
|
const [mintedLabel, setMintedLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The paste-ready one-shot install command for a fresh Pi. The node id comes
|
||||||
|
// from the label just entered (spaces → dashes; install.sh requires
|
||||||
|
// [A-Za-z0-9_-]); if that yields nothing it falls back to a node-XXX
|
||||||
|
// placeholder. The MQTT broker host is the documented mqtt.<domain> sibling
|
||||||
|
// of the api host (install.sh header) — a DNS assumption the operator checks.
|
||||||
|
const c2Url = (process.env.NEXT_PUBLIC_C2_URL ?? "https://api.example.net").replace(/\/$/, "");
|
||||||
|
const mqttBroker = (() => {
|
||||||
|
try { return `mqtt.${new URL(c2Url).hostname.replace(/^api\./, "")}`; }
|
||||||
|
catch { return "mqtt.example.net"; }
|
||||||
|
})();
|
||||||
|
const nodeIdForCmd =
|
||||||
|
(mintedLabel ?? "").trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_-]/g, "") || "node-XXX";
|
||||||
|
const installCmd = justMinted
|
||||||
|
? `curl -fsSL https://git.vpn.cusano.net/logan/node-26/raw/tag/v1/install.sh \\
|
||||||
|
| sudo bash -s -- --token ${justMinted} --node-id ${nodeIdForCmd} \\
|
||||||
|
--c2-url ${c2Url} --mqtt-broker ${mqttBroker}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
c2api.listEnrollmentTokens()
|
c2api.listEnrollmentTokens()
|
||||||
@@ -57,6 +81,7 @@ function EnrollmentTokensPanel() {
|
|||||||
try {
|
try {
|
||||||
const result = await c2api.mintEnrollmentToken(label.trim());
|
const result = await c2api.mintEnrollmentToken(label.trim());
|
||||||
setJustMinted(result.token);
|
setJustMinted(result.token);
|
||||||
|
setMintedLabel(label.trim());
|
||||||
setLabel("");
|
setLabel("");
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -87,11 +112,43 @@ function EnrollmentTokensPanel() {
|
|||||||
<p className="text-xs text-indigo-200 font-mono mb-1">
|
<p className="text-xs text-indigo-200 font-mono mb-1">
|
||||||
New token — copy it now, it won't be shown again:
|
New token — copy it now, it won't be shown again:
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setJustMinted(null)}
|
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
||||||
className="text-xs text-indigo-300 hover:text-indigo-200 mt-2 transition-colors"
|
setTokenCopied(true); setTimeout(() => setTokenCopied(false), 2000);
|
||||||
|
})}
|
||||||
|
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{tokenCopied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-indigo-200 font-mono mt-3 mb-1">
|
||||||
|
…or run this on a fresh Pi{" "}
|
||||||
|
{nodeIdForCmd === "node-XXX"
|
||||||
|
? <>(edit <span className="text-indigo-100">node-XXX</span> and check the broker host)</>
|
||||||
|
: <>(check the broker host)</>}:
|
||||||
|
</p>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
||||||
|
setCmdCopied(true); setTimeout(() => setCmdCopied(false), 2000);
|
||||||
|
})}
|
||||||
|
className="text-xs text-indigo-300 hover:text-indigo-200 px-2 py-1.5 transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
{cmdCopied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setJustMinted(null); setMintedLabel(null); }}
|
||||||
|
className="text-xs text-indigo-300 hover:text-indigo-200 mt-3 transition-colors"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</button>
|
</button>
|
||||||
@@ -105,7 +162,7 @@ function EnrollmentTokensPanel() {
|
|||||||
<input
|
<input
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="Label, e.g. 'node-003 field kit'"
|
placeholder="Node ID, e.g. node-003"
|
||||||
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
|||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const upcoming = trip.start_date >= today;
|
// Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
|
||||||
|
// so a trip isn't "Past" until it's over, not when it starts.
|
||||||
|
const upcoming = trip.end_date >= today;
|
||||||
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold">New Trip</h2>
|
<h2 className="text-white font-bold">New Trip</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
|
|||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) {
|
function InlinePlayer({ callId }: { callId: string }) {
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
|
|||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
if (!hasAudio) return null;
|
|
||||||
|
|
||||||
async function ensureUrl() {
|
async function ensureUrl() {
|
||||||
if (url || loading) return;
|
if (url || loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -169,7 +167,7 @@ export function CallSpineEntry({
|
|||||||
|
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
<InlinePlayer callId={call.call_id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||||
|
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
|
||||||
|
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
|
||||||
|
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
|
||||||
|
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
|
||||||
|
const MAP_TILE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
||||||
|
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||||
|
const MAP_TILE_ATTRIBUTION =
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>';
|
||||||
|
|
||||||
// ── Colour ────────────────────────────────────────────────────────────────────
|
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||||
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||||
// type is carried by the glyph knocked out of the pin, never by colour, and
|
// type is carried by the glyph knocked out of the pin, never by colour, and
|
||||||
@@ -540,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
{/* Base layers */}
|
{/* Base layers */}
|
||||||
<LayersControl.BaseLayer checked name="Dark">
|
<LayersControl.BaseLayer checked name="Dark">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
url={MAP_TILE_URL}
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer name="Light">
|
<LayersControl.BaseLayer name="Light">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer name="Streets">
|
<LayersControl.BaseLayer name="Streets">
|
||||||
|
|||||||
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
|
|||||||
interface Props {
|
interface Props {
|
||||||
node: NodeRecord;
|
node: NodeRecord;
|
||||||
system?: SystemRecord;
|
system?: SystemRecord;
|
||||||
|
/**
|
||||||
|
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
|
||||||
|
* parent click handler can take the interaction (pending nodes open the
|
||||||
|
* config modal instead of navigating). Defaults to true.
|
||||||
|
*/
|
||||||
|
linkToDetail?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeCard({ node, system }: Props) {
|
export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||||
const lastSeen = node.last_seen
|
const lastSeen = node.last_seen
|
||||||
? new Date(node.last_seen).toLocaleTimeString()
|
? new Date(node.last_seen).toLocaleTimeString()
|
||||||
: "never";
|
: "never";
|
||||||
|
|
||||||
return (
|
const body = (
|
||||||
<Link href={`/nodes/${node.node_id}`}>
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||||
<p className="text-gray-400 text-sm mb-5">
|
<p className="text-gray-400 text-sm mb-5">
|
||||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||||
|
|||||||
@@ -143,8 +143,9 @@ export interface IncidentRecord {
|
|||||||
call_ids: string[];
|
call_ids: string[];
|
||||||
system_ids: string[];
|
system_ids: string[];
|
||||||
talkgroup_ids: string[];
|
talkgroup_ids: string[];
|
||||||
units: string[];
|
/** Omitted on incident docs written before these fields existed. */
|
||||||
vehicles: string[];
|
units?: string[];
|
||||||
|
vehicles?: string[];
|
||||||
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
||||||
units_active?: string[];
|
units_active?: string[];
|
||||||
/** Units that reported clearing/back in service on this incident. */
|
/** Units that reported clearing/back in service on this incident. */
|
||||||
|
|||||||
Reference in New Issue
Block a user