Compare commits
4 Commits
97f4286810
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4006232c85 | |||
| 4c3b1fcc84 | |||
| 8b660d8e10 | |||
| 7e1b01a275 |
@@ -30,7 +30,7 @@ class Settings(BaseSettings):
|
||||
location_proximity_km: float = 0.5 # radius for location-proximity matching
|
||||
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls
|
||||
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
||||
tg_fast_path_idle_minutes: int = 30 # fast path: max minutes since incident last updated
|
||||
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||
|
||||
# Vocabulary learning
|
||||
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import time as _time
|
||||
from typing import Optional, Any
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, firestore as fs
|
||||
@@ -6,6 +7,12 @@ from google.cloud.firestore_v1.base_query import FieldFilter
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory TTL cache for rarely-changing documents (systems, nodes config)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key: "collection/doc_id" → (expires_at_monotonic, data_or_None)
|
||||
_doc_cache: dict[str, tuple[float, Optional[dict]]] = {}
|
||||
|
||||
|
||||
def _init_firebase():
|
||||
if firebase_admin._apps:
|
||||
@@ -79,3 +86,19 @@ async def collection_where(
|
||||
async def doc_delete(collection: str, doc_id: str) -> None:
|
||||
ref = db.collection(collection).document(doc_id)
|
||||
await asyncio.to_thread(ref.delete)
|
||||
|
||||
|
||||
async def doc_get_cached(collection: str, doc_id: str, ttl: float = 300.0) -> Optional[dict]:
|
||||
"""
|
||||
Like doc_get but backed by a short-lived in-memory TTL cache.
|
||||
Use for documents that change rarely (systems config, node assignments).
|
||||
Default TTL is 5 minutes — a write will be visible within that window.
|
||||
"""
|
||||
key = f"{collection}/{doc_id}"
|
||||
now = _time.monotonic()
|
||||
entry = _doc_cache.get(key)
|
||||
if entry and now < entry[0]:
|
||||
return entry[1]
|
||||
data = await doc_get(collection, doc_id)
|
||||
_doc_cache[key] = (now + ttl, data)
|
||||
return data
|
||||
|
||||
@@ -48,6 +48,84 @@ from app.config import settings
|
||||
|
||||
_DISPATCH_TG_RE = re.compile(r"\bdispatch\b|\bdisp\b", re.IGNORECASE)
|
||||
|
||||
# Matches route/road identifiers in location strings for cross-system parent detection.
|
||||
# Groups: numbered routes (Route 202, NY-9, US-6, I-87, CR-35) and named parkways/highways.
|
||||
_ROAD_RE = re.compile(
|
||||
r"\b(?:route|rt\.?|rte\.?|us[-\s]?|state\s*route\s*|ny[-\s]?|i[-\s]?|cr[-\s]?|county\s*road\s*)\s*\d+\b"
|
||||
r"|\b(?:tsp|taconic|thruway|parkway|turnpike|interstate)\b"
|
||||
r"|\b\w+(?:\s+\w+)?\s+(?:street|avenue|road|drive|boulevard|lane|court|place|highway|pkwy|blvd|ave|rd|st|dr)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _extract_road_ids(text: str) -> set[str]:
|
||||
"""
|
||||
Extract normalised road/route identifiers from a location string.
|
||||
e.g. "suspect east on Route 202" → {"route 202"}
|
||||
"at Main Street and Oak Ave" → {"main street", "oak ave"}
|
||||
"""
|
||||
return {
|
||||
re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
|
||||
for m in _ROAD_RE.finditer(text)
|
||||
}
|
||||
|
||||
|
||||
def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool:
|
||||
"""True if the new call's location shares any road identifier with the incident's history."""
|
||||
if not new_location or not inc_mentions:
|
||||
return False
|
||||
new_roads = _extract_road_ids(new_location)
|
||||
if not new_roads:
|
||||
return False
|
||||
inc_roads: set[str] = set()
|
||||
for mention in inc_mentions:
|
||||
inc_roads |= _extract_road_ids(mention)
|
||||
return bool(new_roads & inc_roads)
|
||||
|
||||
|
||||
def _operational_types_compatible(type_a: Optional[str], type_b: Optional[str]) -> bool:
|
||||
"""Police+police, ems+ems, fire+fire all match. Police+ems for co-response. Fire+anything for mutual aid."""
|
||||
if not type_a or not type_b:
|
||||
return False
|
||||
if type_a == type_b:
|
||||
return True
|
||||
compatible_pairs = {frozenset({"police", "ems"}), frozenset({"fire", "ems"}), frozenset({"fire", "police"})}
|
||||
return frozenset({type_a, type_b}) in compatible_pairs
|
||||
|
||||
# Tags that unambiguously imply a specific incident type, used as a fallback
|
||||
# when GPT returns incident_type=None (typically due to missing talkgroup context).
|
||||
# Only high-confidence, type-specific tags are listed — generic tags like
|
||||
# "welfare-check" or "suspicious-activity" are omitted to avoid false typing.
|
||||
_TAG_TYPE_HINTS: dict[str, str] = {
|
||||
"active-fire": "fire",
|
||||
"working-fire": "fire",
|
||||
"structure-fire": "fire",
|
||||
"brush-fire": "fire",
|
||||
"smoke-investigation": "fire",
|
||||
"fire-alarm": "fire",
|
||||
"cardiac-arrest": "ems",
|
||||
"unresponsive": "ems",
|
||||
"medical-assistance": "ems",
|
||||
"transport": "ems",
|
||||
"courtesy-transport": "ems",
|
||||
"mvc": "accident",
|
||||
"mva": "accident",
|
||||
"two-car-mva": "accident",
|
||||
"traffic-stop": "police",
|
||||
"shots-fired": "police",
|
||||
"vehicle-pursuit": "police",
|
||||
"pursuit": "police",
|
||||
}
|
||||
|
||||
|
||||
def _infer_type_from_tags(tags: list[str]) -> Optional[str]:
|
||||
"""Return an incident type inferred from tags, or None if ambiguous."""
|
||||
for tag in tags:
|
||||
t = _TAG_TYPE_HINTS.get(tag.lower())
|
||||
if t:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def _tag_to_title(tag: str) -> str:
|
||||
"""
|
||||
@@ -94,6 +172,7 @@ async def correlate_call(
|
||||
create_if_new: bool = True,
|
||||
units: Optional[list[str]] = None,
|
||||
vehicles: Optional[list[str]] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Link call_id to an existing incident or create a new one.
|
||||
@@ -123,6 +202,7 @@ async def correlate_call(
|
||||
# scene-level breakdown.
|
||||
call_units: list[str] = units if units is not None else (call_doc.get("units") or [])
|
||||
call_vehicles: list[str] = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||
call_cleared: list[str] = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||
call_severity: str = call_doc.get("severity") or "unknown"
|
||||
# Use passed coords first (freshly geocoded), fall back to what's on the call doc
|
||||
coords: Optional[dict] = location_coords or call_doc.get("location_coords")
|
||||
@@ -199,18 +279,67 @@ async def correlate_call(
|
||||
f"from {candidate['incident_id']}; will attempt new incident"
|
||||
)
|
||||
elif len(tg_recent) > 1:
|
||||
matched_incident = _disambiguate(
|
||||
candidate = _disambiguate(
|
||||
tg_recent, call_units, call_vehicles, coords, call_embedding
|
||||
)
|
||||
corr_debug = {
|
||||
"corr_path": "fast/disambig",
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(matched_incident, now), 1),
|
||||
"corr_candidates": len(tg_recent),
|
||||
}
|
||||
logger.info(
|
||||
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
|
||||
f"call {call_id} → {matched_incident['incident_id']}"
|
||||
# Disambiguate picks the best candidate, but still verify the call
|
||||
# actually fits before committing — a new unrelated call on a busy
|
||||
# dispatch channel should create its own incident, not be force-merged.
|
||||
if _call_fits_incident(
|
||||
candidate, call_units, call_vehicles, coords,
|
||||
settings.location_proximity_km, is_dispatch=is_dispatch,
|
||||
):
|
||||
matched_incident = candidate
|
||||
corr_debug = {
|
||||
"corr_path": "fast/disambig",
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(candidate, now), 1),
|
||||
"corr_candidates": len(tg_recent),
|
||||
}
|
||||
logger.info(
|
||||
f"Correlator fast-path (disambig {len(tg_recent)} candidates): "
|
||||
f"call {call_id} → {candidate['incident_id']}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Correlator fast-path disambig: no candidate fits call {call_id} "
|
||||
f"across {len(tg_recent)} incidents — will attempt new incident"
|
||||
)
|
||||
|
||||
# ── 1.5. Unit-continuity path: same officer, not reassigned ─────────────────
|
||||
#
|
||||
# Handles long calls (bookings, transports, late scene clearance) where the
|
||||
# 90-min idle gate has fired but the officer is still on the same call.
|
||||
# Searches ALL active incidents — no idle gate, no time limit.
|
||||
#
|
||||
# Reassignment guard: if the same unit appears in a MORE recently updated
|
||||
# incident, the officer has moved on and we don't link back to the old one.
|
||||
# This correctly handles officers dispatched to a second call mid-shift.
|
||||
if not matched_incident and call_units and system_id:
|
||||
call_unit_set = set(call_units)
|
||||
unit_candidates = [
|
||||
inc for inc in all_active
|
||||
if system_id in (inc.get("system_ids") or [])
|
||||
and call_unit_set & set(inc.get("units") or [])
|
||||
]
|
||||
if unit_candidates:
|
||||
best_unit_inc = max(unit_candidates, key=lambda i: i.get("updated_at", ""))
|
||||
reassigned_away = any(
|
||||
inc["incident_id"] != best_unit_inc["incident_id"]
|
||||
and call_unit_set & set(inc.get("units") or [])
|
||||
and inc.get("updated_at", "") > best_unit_inc.get("updated_at", "")
|
||||
for inc in all_active
|
||||
)
|
||||
if not reassigned_away:
|
||||
matched_incident = best_unit_inc
|
||||
corr_debug = {
|
||||
"corr_path": "unit-continuity",
|
||||
"corr_incident_idle_min": round(_incident_idle_minutes(best_unit_inc, now), 1),
|
||||
}
|
||||
logger.info(
|
||||
f"Correlator unit-continuity: call {call_id} → "
|
||||
f"{best_unit_inc['incident_id']} "
|
||||
f"(idle {_incident_idle_minutes(best_unit_inc, now):.0f}min)"
|
||||
)
|
||||
|
||||
# ── 2. Location path: proximity match (time-limited, cross-type) ─────────
|
||||
if not matched_incident and coords:
|
||||
@@ -331,16 +460,87 @@ async def correlate_call(
|
||||
matched_incident, call_id, talkgroup_id, system_id, tags,
|
||||
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
||||
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
||||
cleared_units=call_cleared,
|
||||
)
|
||||
elif incident_type and create_if_new:
|
||||
incident_id = await _create_incident(
|
||||
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||
tags, location, location_coords,
|
||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||
)
|
||||
corr_debug["corr_path"] = "new"
|
||||
elif create_if_new:
|
||||
# If GPT returned no type (missing talkgroup context is common), attempt
|
||||
# to recover a type from the extracted tags before giving up on creation.
|
||||
if not incident_type and tags:
|
||||
incident_type = _infer_type_from_tags(tags)
|
||||
if incident_type:
|
||||
logger.info(
|
||||
f"Correlator: inferred incident_type={incident_type!r} from tags "
|
||||
f"{tags} for call {call_id} (no GPT type)"
|
||||
)
|
||||
if not incident_type:
|
||||
# No type and none inferred — nothing to create
|
||||
return None
|
||||
|
||||
# ── Cross-system parent detection ─────────────────────────────────────
|
||||
# Before creating a standalone incident, check whether this call belongs
|
||||
# to an incident already opened by a different agency (multi-agency chase,
|
||||
# mutual aid, etc.). If a parent candidate is found:
|
||||
# • The existing candidate is demoted to a child (incident_type → "child")
|
||||
# • A new master shell is created linking both children
|
||||
# • The new call's incident is created as a second child of the master
|
||||
cross_parent: Optional[dict] = None
|
||||
if system_id:
|
||||
cross_parent = await _find_cross_system_parent(
|
||||
system_id=system_id,
|
||||
incident_type=incident_type,
|
||||
location=location,
|
||||
location_coords=coords,
|
||||
call_embedding=call_embedding,
|
||||
recent=recent,
|
||||
)
|
||||
|
||||
if cross_parent:
|
||||
existing_child_id = cross_parent["incident_id"]
|
||||
existing_master_id = cross_parent.get("parent_incident_id")
|
||||
|
||||
# Create the new agency's child incident first
|
||||
incident_id = await _create_incident(
|
||||
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||
tags, location, location_coords,
|
||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||
)
|
||||
|
||||
if existing_master_id:
|
||||
# Candidate is already a child — link new child to the existing master
|
||||
await _demote_to_child(incident_id, existing_master_id)
|
||||
await _add_child_to_master(existing_master_id, incident_id, now)
|
||||
corr_debug["corr_path"] = "new/cross-system-child"
|
||||
logger.info(
|
||||
f"Correlator cross-system: call {call_id} → new child {incident_id} "
|
||||
f"under existing master {existing_master_id}"
|
||||
)
|
||||
else:
|
||||
# Candidate is a standalone master — create master shell, demote both
|
||||
master_id = await _create_master_incident(
|
||||
first_child_id=existing_child_id,
|
||||
second_child_id=incident_id,
|
||||
operational_type=incident_type,
|
||||
location=cross_parent.get("location") or location,
|
||||
location_coords=cross_parent.get("location_coords") or coords,
|
||||
now=now,
|
||||
)
|
||||
await _demote_to_child(existing_child_id, master_id)
|
||||
await _demote_to_child(incident_id, master_id)
|
||||
corr_debug["corr_path"] = "new/cross-system-master"
|
||||
logger.info(
|
||||
f"Correlator cross-system: created master {master_id}, "
|
||||
f"demoted {existing_child_id} + new {incident_id} as children"
|
||||
)
|
||||
else:
|
||||
# Normal single-agency incident creation
|
||||
incident_id = await _create_incident(
|
||||
call_id, incident_type, talkgroup_id, talkgroup_name, system_id,
|
||||
tags, location, location_coords,
|
||||
call_units, call_vehicles, call_embedding, call_severity, now,
|
||||
)
|
||||
corr_debug["corr_path"] = "new"
|
||||
else:
|
||||
# No match and either no type or creation suppressed — nothing to do
|
||||
# Creation suppressed (re-correlation sweep) — nothing to do
|
||||
return None
|
||||
|
||||
# Persist the correlation decision to the call document so it can be
|
||||
@@ -508,6 +708,7 @@ async def _update_incident(
|
||||
now: datetime,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
incident_type: Optional[str] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
incident_id = inc["incident_id"]
|
||||
|
||||
@@ -527,6 +728,19 @@ async def _update_incident(
|
||||
merged_units = list(dict.fromkeys((inc.get("units") or []) + call_units))
|
||||
merged_vehicles = list(dict.fromkeys((inc.get("vehicles") or []) + call_vehicles))
|
||||
|
||||
# Unit activity tracking: units_active / units_cleared
|
||||
# units_active = units currently on scene; units_cleared = units back in service
|
||||
units_active = list(inc.get("units_active") or [])
|
||||
units_cleared = list(inc.get("units_cleared") or [])
|
||||
for u in call_units:
|
||||
if u not in units_cleared and u not in units_active:
|
||||
units_active.append(u)
|
||||
for u in (cleared_units or []):
|
||||
if u in units_active:
|
||||
units_active.remove(u)
|
||||
if u not in units_cleared:
|
||||
units_cleared.append(u)
|
||||
|
||||
location_mentions = list(inc.get("location_mentions") or [])
|
||||
if location and location not in location_mentions:
|
||||
location_mentions.append(location)
|
||||
@@ -543,6 +757,8 @@ async def _update_incident(
|
||||
"tags": merged_tags,
|
||||
"units": merged_units,
|
||||
"vehicles": merged_vehicles,
|
||||
"units_active": units_active,
|
||||
"units_cleared": units_cleared,
|
||||
"location_mentions": location_mentions,
|
||||
"updated_at": now.isoformat(),
|
||||
"summary_stale": True,
|
||||
@@ -574,6 +790,19 @@ async def _update_incident(
|
||||
elif primary_tag:
|
||||
updates["title"] = primary_tag
|
||||
|
||||
# Signal-based auto-resolve: every tracked unit has cleared, none still active.
|
||||
# Requires at least one unit to have explicitly signalled back-in-service so we
|
||||
# don't fire on incidents where units were never tracked (no unit mentions at all).
|
||||
if units_cleared and not units_active:
|
||||
updates["status"] = "resolved"
|
||||
await fstore.doc_set("incidents", incident_id, updates)
|
||||
logger.info(
|
||||
f"Correlator: signal-resolved incident {incident_id} "
|
||||
f"(call {call_id} — all {len(units_cleared)} unit(s) clear)"
|
||||
)
|
||||
await maybe_resolve_parent(incident_id)
|
||||
return
|
||||
|
||||
await fstore.doc_set("incidents", incident_id, updates)
|
||||
logger.info(f"Correlator: linked call {call_id} to incident {incident_id}")
|
||||
|
||||
@@ -612,6 +841,7 @@ async def _create_incident(
|
||||
doc = {
|
||||
"incident_id": incident_id,
|
||||
"title": title,
|
||||
"incident_type": "master", # structural role; "child" set on demotion
|
||||
"type": incident_type,
|
||||
"status": "active",
|
||||
"location": location,
|
||||
@@ -622,6 +852,8 @@ async def _create_incident(
|
||||
"system_ids": [system_id] if system_id else [],
|
||||
"tags": tags + ["auto-generated"],
|
||||
"units": call_units,
|
||||
"units_active": list(call_units),
|
||||
"units_cleared": [],
|
||||
"vehicles": call_vehicles,
|
||||
"severity": call_severity,
|
||||
"summary": None,
|
||||
@@ -652,6 +884,190 @@ def _merge_embedding_vecs(inc: dict, call_embedding: list[float]) -> dict:
|
||||
return {"embedding": call_embedding, "embedding_count": 1}
|
||||
|
||||
|
||||
async def _create_master_incident(
|
||||
first_child_id: str,
|
||||
second_child_id: str,
|
||||
operational_type: str,
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
now: datetime,
|
||||
) -> str:
|
||||
"""
|
||||
Create a master shell incident linking two child incidents.
|
||||
The master owns no calls directly — it is a grouping record.
|
||||
Returns the new master incident_id.
|
||||
"""
|
||||
master_id = str(uuid.uuid4())
|
||||
doc = {
|
||||
"incident_id": master_id,
|
||||
"title": f"Multi-agency {operational_type} incident",
|
||||
"incident_type": "master",
|
||||
"type": operational_type,
|
||||
"status": "active",
|
||||
"location": location,
|
||||
"location_coords": location_coords,
|
||||
"child_incident_ids": [first_child_id, second_child_id],
|
||||
"parent_incident_id": None,
|
||||
"call_ids": [],
|
||||
"talkgroup_ids": [],
|
||||
"system_ids": [],
|
||||
"tags": [],
|
||||
"units": [],
|
||||
"vehicles": [],
|
||||
"severity": "unknown",
|
||||
"summary": None,
|
||||
"summary_stale": True,
|
||||
"summary_last_run": None,
|
||||
"embedding": None,
|
||||
"embedding_count": 0,
|
||||
"has_updates": False,
|
||||
"started_at": now.isoformat(),
|
||||
"updated_at": now.isoformat(),
|
||||
}
|
||||
await fstore.doc_set("incidents", master_id, doc, merge=False)
|
||||
logger.info(f"Correlator: created master incident {master_id} linking {first_child_id} + {second_child_id}")
|
||||
return master_id
|
||||
|
||||
|
||||
async def _demote_to_child(incident_id: str, parent_id: str) -> None:
|
||||
"""Demote a standalone master incident to a child by setting incident_type and parent reference."""
|
||||
await fstore.doc_set("incidents", incident_id, {
|
||||
"incident_type": "child",
|
||||
"parent_incident_id": parent_id,
|
||||
})
|
||||
logger.info(f"Correlator: demoted incident {incident_id} → child of master {parent_id}")
|
||||
|
||||
|
||||
async def _add_child_to_master(master_id: str, child_id: str, now: datetime) -> None:
|
||||
"""Append a new child to an existing master's child_incident_ids list."""
|
||||
master = await fstore.doc_get("incidents", master_id)
|
||||
if not master:
|
||||
return
|
||||
children = list(master.get("child_incident_ids") or [])
|
||||
if child_id not in children:
|
||||
children.append(child_id)
|
||||
updates: dict = {"child_incident_ids": children, "updated_at": now.isoformat()}
|
||||
# Re-open a resolved master when a new child is added (retroactive link)
|
||||
if master.get("status") == "resolved":
|
||||
updates["has_updates"] = True
|
||||
await fstore.doc_set("incidents", master_id, updates)
|
||||
|
||||
|
||||
async def maybe_resolve_parent(incident_id: str) -> None:
|
||||
"""
|
||||
Called after resolving a child incident.
|
||||
If all siblings under the same master are also resolved, auto-resolve the master.
|
||||
Safe to call on non-child incidents — exits immediately when there's no parent.
|
||||
"""
|
||||
inc = await fstore.doc_get("incidents", incident_id)
|
||||
if not inc:
|
||||
return
|
||||
parent_id = inc.get("parent_incident_id")
|
||||
if not parent_id:
|
||||
return # standalone or already a master — nothing to propagate
|
||||
|
||||
parent = await fstore.doc_get("incidents", parent_id)
|
||||
if not parent or parent.get("status") == "resolved":
|
||||
return # master already closed
|
||||
|
||||
child_ids: list[str] = parent.get("child_incident_ids") or []
|
||||
if not child_ids:
|
||||
return
|
||||
|
||||
for cid in child_ids:
|
||||
if cid == incident_id:
|
||||
continue # the one we just resolved
|
||||
child = await fstore.doc_get("incidents", cid)
|
||||
if not child or child.get("status") != "resolved":
|
||||
return # at least one sibling still active
|
||||
|
||||
# All children resolved — close the master
|
||||
await fstore.doc_set("incidents", parent_id, {"status": "resolved"})
|
||||
logger.info(
|
||||
f"Auto-resolved master incident {parent_id} "
|
||||
f"(all {len(child_ids)} child(ren) resolved)"
|
||||
)
|
||||
|
||||
|
||||
async def _find_cross_system_parent(
|
||||
system_id: str,
|
||||
incident_type: Optional[str],
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
call_embedding: Optional[list],
|
||||
recent: list[dict],
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Scan active incidents from OTHER systems for a cross-agency parent candidate.
|
||||
|
||||
Match criteria (need at least two signals firing together):
|
||||
A. Road/route identifier overlap between the new call's location and the
|
||||
incident's accumulated location_mentions. Any shared route number or
|
||||
road name is a strong positive — two agencies don't randomly share the
|
||||
same road name in the same window.
|
||||
B. Content embedding similarity ≥ 0.78 (lower than same-system slow path
|
||||
because we're linking, not merging).
|
||||
C. Geocoded proximity — 1 km for static scenes (≤2 location_mentions),
|
||||
3 km for dynamic/moving scenes (chase, expanding perimeter).
|
||||
|
||||
Returns the best matching incident (master or standalone), or None.
|
||||
Child incidents are resolved to their parent before matching so we always
|
||||
attach to the master level.
|
||||
"""
|
||||
best_inc: Optional[dict] = None
|
||||
best_score = 0.0
|
||||
|
||||
for inc in recent:
|
||||
# Only cross-system candidates
|
||||
if system_id in (inc.get("system_ids") or []):
|
||||
continue
|
||||
if not _operational_types_compatible(incident_type, inc.get("type")):
|
||||
continue
|
||||
|
||||
# If the candidate is already a child, resolve to its parent
|
||||
if inc.get("incident_type") == "child" and inc.get("parent_incident_id"):
|
||||
parent = await fstore.doc_get("incidents", inc["parent_incident_id"])
|
||||
if parent and parent.get("status") == "active":
|
||||
inc = parent
|
||||
else:
|
||||
continue
|
||||
|
||||
score = 0.0
|
||||
inc_mentions: list[str] = inc.get("location_mentions") or []
|
||||
|
||||
# Signal A — road/route identifier overlap (0.4).
|
||||
# Shared route numbers are a strong signal but not conclusive alone.
|
||||
if location and _location_mentions_road_overlap(location, inc_mentions):
|
||||
score += 0.4
|
||||
|
||||
# Signal B — content embedding similarity ≥ 0.78 (0.3 flat bonus).
|
||||
inc_embedding = inc.get("embedding")
|
||||
if call_embedding and inc_embedding:
|
||||
if _cosine_similarity(call_embedding, inc_embedding) >= 0.78:
|
||||
score += 0.3
|
||||
|
||||
# Signal C — geocoded proximity (0.3).
|
||||
# Dynamic scenes (3+ location mentions = chase/moving perimeter) use 3 km;
|
||||
# static mutual aid (≤2 mentions) uses 1 km.
|
||||
inc_coords = inc.get("location_coords")
|
||||
if location_coords and inc_coords:
|
||||
dist_km = _haversine_km(
|
||||
location_coords["lat"], location_coords["lng"],
|
||||
inc_coords["lat"], inc_coords["lng"],
|
||||
)
|
||||
radius = 3.0 if len(inc_mentions) >= 3 else 1.0
|
||||
if dist_km <= radius:
|
||||
score += 0.3
|
||||
|
||||
# threshold = 0.5 → requires at least two signals (A+B, A+C, or B+C).
|
||||
# No single signal alone can clear the bar.
|
||||
if score >= 0.5 and score > best_score:
|
||||
best_score = score
|
||||
best_inc = inc
|
||||
|
||||
return best_inc
|
||||
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
import numpy as np
|
||||
va, vb = np.array(a, dtype=float), np.array(b, dtype=float)
|
||||
|
||||
@@ -29,6 +29,7 @@ Response format — a JSON object with a "scenes" array. Each scene:
|
||||
location: most specific location string found, or empty string
|
||||
vehicles: list of vehicle descriptions mentioned
|
||||
units: list of unit IDs or officer numbers explicitly mentioned
|
||||
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
|
||||
severity: one of "minor" | "moderate" | "major" | "unknown"
|
||||
resolved: true if this scene explicitly signals incident closure, false otherwise
|
||||
reassignment: true if dispatch is actively pulling a unit away from their current assignment to respond to a new, different call — e.g. "Baker, can you clear and respond to...", "Adam, break from that and go to...". False if the unit is simply reporting in, updating status, or continuing their current assignment.
|
||||
@@ -42,6 +43,7 @@ Rules:
|
||||
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire".
|
||||
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
||||
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
||||
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
||||
- reassignment: only true when a unit is explicitly being pulled to a completely new call or location. A unit going en route to their first dispatch is NOT a reassignment. Routine status updates, acknowledgements, and scene updates are NOT reassignments.
|
||||
- transcript_corrected: fix only clear STT/vocoder errors (e.g. "Several" → "10-4", misheard street names, garbled unit IDs). Keep all radio language as-is — do NOT decode codes into plain English. Return null if accurate.
|
||||
|
||||
@@ -99,12 +101,11 @@ async def extract_scenes(
|
||||
vocabulary: list[str] = []
|
||||
ten_codes: dict[str, str] = {}
|
||||
if system_id:
|
||||
from app.internal.vocabulary_learner import get_vocabulary
|
||||
vocab_data = await get_vocabulary(system_id)
|
||||
vocabulary = vocab_data.get("vocabulary") or []
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
# Single cached read — vocabulary and ten_codes live on the same document.
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
ten_codes = system_doc.get("ten_codes") or {}
|
||||
vocabulary = system_doc.get("vocabulary") or []
|
||||
ten_codes = system_doc.get("ten_codes") or {}
|
||||
|
||||
raw_scenes: list[dict] = await asyncio.to_thread(
|
||||
_sync_extract,
|
||||
@@ -118,7 +119,7 @@ async def extract_scenes(
|
||||
node_lat: Optional[float] = None
|
||||
node_lon: Optional[float] = None
|
||||
if node_id:
|
||||
node_doc = await fstore.doc_get("nodes", node_id)
|
||||
node_doc = await fstore.doc_get_cached("nodes", node_id)
|
||||
if node_doc:
|
||||
node_lat = node_doc.get("lat")
|
||||
node_lon = node_doc.get("lon")
|
||||
@@ -130,6 +131,7 @@ async def extract_scenes(
|
||||
location: Optional[str] = scene.get("location") or None
|
||||
vehicles: list[str] = scene.get("vehicles") or []
|
||||
units: list[str] = scene.get("units") or []
|
||||
cleared_units: list[str] = scene.get("cleared_units") or []
|
||||
severity: str = scene.get("severity") or "unknown"
|
||||
resolved: bool = bool(scene.get("resolved", False))
|
||||
reassignment: bool = bool(scene.get("reassignment", False))
|
||||
@@ -161,6 +163,7 @@ async def extract_scenes(
|
||||
"location_coords": location_coords,
|
||||
"vehicles": vehicles,
|
||||
"units": units,
|
||||
"cleared_units": cleared_units,
|
||||
"severity": severity,
|
||||
"resolved": resolved,
|
||||
"reassignment": reassignment,
|
||||
@@ -176,6 +179,7 @@ async def extract_scenes(
|
||||
all_tags = list(dict.fromkeys(t for s in processed for t in s["tags"]))
|
||||
all_units = list(dict.fromkeys(u for s in processed for u in s["units"]))
|
||||
all_vehicles = list(dict.fromkeys(v for s in processed for v in s["vehicles"]))
|
||||
all_cleared = list(dict.fromkeys(u for s in processed for u in s["cleared_units"]))
|
||||
|
||||
updates: dict = {"tags": all_tags, "severity": primary["severity"]}
|
||||
if primary["location"]:
|
||||
@@ -184,6 +188,8 @@ async def extract_scenes(
|
||||
updates["location_coords"] = primary["location_coords"]
|
||||
if all_units:
|
||||
updates["units"] = all_units
|
||||
if all_cleared:
|
||||
updates["cleared_units"] = all_cleared
|
||||
if all_vehicles:
|
||||
updates["vehicles"] = all_vehicles
|
||||
if primary["embedding"]:
|
||||
|
||||
@@ -109,10 +109,11 @@ class MQTTHandler:
|
||||
updates["status"] = "online"
|
||||
await fstore.doc_update("nodes", node_id, updates)
|
||||
|
||||
# Release any orphaned Discord token when the node explicitly reports disconnected
|
||||
if payload.get("discord_connected") is False:
|
||||
from app.routers.tokens import release_token
|
||||
await release_token(node_id)
|
||||
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||
# token here. The bot watchdog reconnects on transient Discord drops, so a single
|
||||
# checkin with discord_connected=False during a brief reconnect window would
|
||||
# incorrectly free the token while the bot is still active. Token release is
|
||||
# handled exclusively by the discord_leave command and the node offline sweeper.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status update
|
||||
@@ -143,8 +144,8 @@ class MQTTHandler:
|
||||
if not call_id:
|
||||
return
|
||||
|
||||
# Look up assigned system for this node
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
# Look up assigned system for this node (cached — assignment rarely changes)
|
||||
node = await fstore.doc_get_cached("nodes", node_id)
|
||||
system_id = node.get("assigned_system_id") if node else None
|
||||
|
||||
started_at_raw = payload.get("started_at")
|
||||
@@ -157,7 +158,7 @@ class MQTTHandler:
|
||||
# Prefer the name from OP25 metadata; fall back to the system config
|
||||
tgid_name = payload.get("tgid_name") or ""
|
||||
if not tgid_name and system_id and payload.get("tgid"):
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
tgid_int = int(payload["tgid"])
|
||||
for tg in system_doc.get("config", {}).get("talkgroups", []):
|
||||
|
||||
@@ -4,7 +4,7 @@ from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
SWEEP_INTERVAL = 30 # seconds
|
||||
SWEEP_INTERVAL = 90 # seconds — matches node_offline_threshold; no gain in checking faster
|
||||
|
||||
|
||||
async def sweeper_loop():
|
||||
|
||||
@@ -46,9 +46,15 @@ async def _run_sweep_pass() -> None:
|
||||
("status", "==", "ended"),
|
||||
("ended_at", ">=", cutoff),
|
||||
])
|
||||
# corr_path="unlinked" is written after MAX_SWEEP_ATTEMPTS failures.
|
||||
# Allows a few retries so a welfare-check call can link to an escalation
|
||||
# incident that is created a few minutes later, without sweeping 30× forever.
|
||||
MAX_SWEEP_ATTEMPTS = 3
|
||||
orphans = [
|
||||
c for c in recent_ended
|
||||
if not c.get("incident_ids") and not c.get("incident_id")
|
||||
and not c.get("corr_path") # skip calls already exhausted
|
||||
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
|
||||
]
|
||||
|
||||
if not orphans:
|
||||
@@ -87,6 +93,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
incident_type = call.get("incident_type"),
|
||||
location = call.get("location"),
|
||||
location_coords= call.get("location_coords"),
|
||||
cleared_units = call.get("cleared_units") or [],
|
||||
reference_time = started_at, # anchor window to when the call happened
|
||||
create_if_new = False, # never create — link-only
|
||||
)
|
||||
@@ -97,6 +104,15 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}"
|
||||
)
|
||||
return True
|
||||
|
||||
# Increment the attempt counter. Once MAX_SWEEP_ATTEMPTS is reached the
|
||||
# orphan filter above will stop picking this call up, and we write
|
||||
# corr_path="unlinked" as a permanent tombstone.
|
||||
attempts = call.get("corr_sweep_count", 0) + 1
|
||||
update: dict = {"corr_sweep_count": attempts}
|
||||
if attempts >= 3:
|
||||
update["corr_path"] = "unlinked"
|
||||
await fstore.doc_set("calls", call_id, update)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ async def _resolve_stale_incidents() -> None:
|
||||
idle_minutes = (now - updated_dt).total_seconds() / 60
|
||||
if idle_minutes > settings.incident_auto_resolve_minutes:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
from app.internal.incident_correlator import maybe_resolve_parent
|
||||
await maybe_resolve_parent(incident_id)
|
||||
logger.info(
|
||||
f"Auto-resolved stale incident {incident_id} "
|
||||
f"(idle {idle_minutes:.0f}m)"
|
||||
|
||||
@@ -196,8 +196,8 @@ async def remove_term(system_id: str, term: str) -> None:
|
||||
|
||||
|
||||
async def get_vocabulary(system_id: str) -> dict:
|
||||
"""Return vocabulary and pending terms for a system."""
|
||||
doc = await fstore.doc_get("systems", system_id)
|
||||
"""Return vocabulary and pending terms for a system (TTL-cached, 5 min)."""
|
||||
doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if not doc:
|
||||
return {"vocabulary": [], "vocabulary_pending": [], "vocabulary_bootstrapped": False}
|
||||
return {
|
||||
@@ -281,8 +281,14 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
system_name = system_doc.get("name", "Unknown")
|
||||
existing_vocab: list[str] = system_doc.get("vocabulary") or []
|
||||
|
||||
# Fetch recent ended calls for this system
|
||||
all_calls = await fstore.collection_list("calls", system_id=system_id, status="ended")
|
||||
# Fetch calls from the last 7 days only — avoids scanning the entire history.
|
||||
# Active calls have ended_at=None and are excluded by the range filter automatically.
|
||||
# Needs a composite index on (system_id ASC, ended_at ASC).
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
all_calls = await fstore.collection_where("calls", [
|
||||
("system_id", "==", system_id),
|
||||
("ended_at", ">=", cutoff),
|
||||
])
|
||||
if not all_calls:
|
||||
return
|
||||
|
||||
|
||||
@@ -53,12 +53,24 @@ async def send_command(node_id: str, cmd: CommandPayload):
|
||||
payload = cmd.model_dump(exclude_none=True)
|
||||
|
||||
if cmd.action == "discord_join":
|
||||
preferred = payload.pop("preferred_token_id", None)
|
||||
# Resolve system doc once — used for preferred token and presence name.
|
||||
system_doc = None
|
||||
system_id = node.get("assigned_system_id")
|
||||
if system_id:
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
|
||||
# Explicit preferred_token_id in the request beats the system-level preference.
|
||||
preferred = payload.pop("preferred_token_id", None) or (system_doc or {}).get("preferred_token_id")
|
||||
token = await assign_token(node_id, preferred_token_id=preferred)
|
||||
if not token:
|
||||
raise HTTPException(503, "No Discord bot tokens available in the pool.")
|
||||
payload["token"] = token
|
||||
|
||||
# Pass system name so the bot can set its Discord presence on join.
|
||||
system_name = (system_doc or {}).get("name")
|
||||
if system_name:
|
||||
payload["system_name"] = system_name
|
||||
|
||||
elif cmd.action == "discord_leave":
|
||||
await release_token(node_id)
|
||||
|
||||
|
||||
@@ -60,6 +60,34 @@ async def flush_tokens():
|
||||
return {"released": len(results)}
|
||||
|
||||
|
||||
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
||||
async def set_preferred_system(token_id: str, system_id: str):
|
||||
"""
|
||||
Mark this token as the preferred bot for a system.
|
||||
When a discord_join is issued for any node in that system, this token
|
||||
is tried first before falling back to the general pool.
|
||||
Pass system_id="_none" to clear the preference.
|
||||
"""
|
||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
if system_id == "_none":
|
||||
# Clear any existing preference on the system that pointed to this token.
|
||||
system_doc = await fstore.doc_get("systems", existing.get("preferred_for_system_id", ""))
|
||||
if system_doc:
|
||||
await fstore.doc_set("systems", existing["preferred_for_system_id"], {"preferred_token_id": None})
|
||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
|
||||
return {"ok": True, "preferred_for_system_id": None}
|
||||
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
if not system_doc:
|
||||
raise HTTPException(404, "System not found.")
|
||||
# Set preference on both sides for easy lookup in either direction.
|
||||
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
|
||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
|
||||
return {"ok": True, "preferred_for_system_id": system_id}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def delete_token(token_id: str):
|
||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||
|
||||
@@ -125,11 +125,13 @@ async def _run_extraction_pipeline(
|
||||
location_coords=scene["location_coords"],
|
||||
units=corr_units,
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
|
||||
if incident_ids:
|
||||
@@ -170,7 +172,7 @@ async def _run_intelligence_pipeline(
|
||||
# but global flag=False beats everything (master switch).
|
||||
system_ai_flags: dict = {}
|
||||
if system_id:
|
||||
sys_doc = await fstore.doc_get("systems", system_id)
|
||||
sys_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
|
||||
|
||||
def _flag(name: str) -> bool:
|
||||
@@ -224,11 +226,13 @@ async def _run_intelligence_pipeline(
|
||||
location_coords=scene["location_coords"],
|
||||
units=corr_units,
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
|
||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function AdminPage() {
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto space-y-8">
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||||
|
||||
<section className="space-y-3">
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function AlertsPage() {
|
||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
||||
{unacked.length > 0 && (
|
||||
|
||||
+179
-11
@@ -1,10 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCalls } from "@/lib/useCalls";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
const inputCls =
|
||||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
|
||||
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
|
||||
|
||||
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
|
||||
const q = filters.query.trim().toLowerCase();
|
||||
const tgid = filters.tgid.trim();
|
||||
const fromMs = filters.dateFrom ? new Date(filters.dateFrom).getTime() : null;
|
||||
const toMs = filters.dateTo ? new Date(filters.dateTo + "T23:59:59").getTime() : null;
|
||||
|
||||
return calls.filter((c) => {
|
||||
// System filter
|
||||
if (filters.systemId && c.system_id !== filters.systemId) return false;
|
||||
|
||||
// TGID filter (exact match on the number)
|
||||
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
|
||||
|
||||
// Date range
|
||||
const ts = new Date(c.started_at).getTime();
|
||||
if (fromMs !== null && ts < fromMs) return false;
|
||||
if (toMs !== null && ts > toMs) return false;
|
||||
|
||||
// Free-text: talkgroup name, node_id, transcript, tags
|
||||
if (q) {
|
||||
const hay = [
|
||||
c.talkgroup_name ?? "",
|
||||
c.node_id,
|
||||
c.transcript ?? "",
|
||||
c.transcript_corrected ?? "",
|
||||
...(c.tags ?? []),
|
||||
].join(" ").toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
query: string;
|
||||
tgid: string;
|
||||
systemId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: Filters = {
|
||||
query: "",
|
||||
tgid: "",
|
||||
systemId: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
function isActive(f: Filters) {
|
||||
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
|
||||
}
|
||||
|
||||
export default function CallsPage() {
|
||||
const [limitCount, setLimitCount] = useState(100);
|
||||
@@ -13,22 +72,128 @@ export default function CallsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
|
||||
const active = calls.filter((c) => c.status === "active");
|
||||
const ended = calls.filter((c) => c.status === "ended");
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
function set<K extends keyof Filters>(key: K, value: string) {
|
||||
setFilters((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
const active = calls.filter((c) => c.status === "active");
|
||||
const ended = calls.filter((c) => c.status === "ended");
|
||||
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
|
||||
|
||||
const activeFilters = isActive(filters);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
|
||||
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
activeFilters
|
||||
? "border-indigo-600 bg-indigo-950 text-indigo-300"
|
||||
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{showFilters ? "Hide filters" : "Filter"}
|
||||
{activeFilters && " •"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{showFilters && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{/* Text search */}
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.query}
|
||||
onChange={(e) => set("query", e.target.value)}
|
||||
placeholder="fire, Engine 5, dispatch…"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TGID */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.tgid}
|
||||
onChange={(e) => set("tgid", e.target.value)}
|
||||
placeholder="e.g. 9048"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">System</label>
|
||||
<select
|
||||
value={filters.systemId}
|
||||
onChange={(e) => set("systemId", e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">All systems</option>
|
||||
{systems.map((s) => (
|
||||
<option key={s.system_id} value={s.system_id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date from */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">From date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(e) => set("dateFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date to */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">To date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(e) => set("dateTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilters && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{filtered.length} of {ended.length} calls match
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setFilters(DEFAULT_FILTERS)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live calls — never filtered */}
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
||||
Live ({active.length})
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
@@ -51,17 +216,20 @@ export default function CallsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
History
|
||||
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
|
||||
</h2>
|
||||
{loading ? (
|
||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||
) : ended.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">
|
||||
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
@@ -74,13 +242,13 @@ export default function CallsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ended.map((c) => (
|
||||
{filtered.map((c) => (
|
||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{ended.length >= limitCount && (
|
||||
{!activeFilters && ended.length >= limitCount && (
|
||||
<button
|
||||
onClick={() => setLimitCount((n) => n + 100)}
|
||||
className="mt-4 text-sm text-indigo-400 hover:text-indigo-300 font-mono transition-colors"
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function DashboardPage() {
|
||||
{calls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
|
||||
@@ -4,6 +4,105 @@
|
||||
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
|
||||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||
html, body {
|
||||
@apply bg-gray-950 text-gray-100 font-mono;
|
||||
}
|
||||
|
||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||
/*
|
||||
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
||||
* text-gray-xxx). Rather than adding dark: prefixes everywhere, we remap those
|
||||
* classes here when the html element doesn't carry the .dark class.
|
||||
*/
|
||||
|
||||
/* Structural backgrounds */
|
||||
html:not(.dark),
|
||||
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
||||
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
||||
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
||||
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||
|
||||
/* Borders */
|
||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||
|
||||
/* Text */
|
||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||
|
||||
/* Hover states */
|
||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
||||
|
||||
/* Hover text */
|
||||
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
||||
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
||||
|
||||
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
||||
|
||||
/* Fire / Error */
|
||||
html:not(.dark) .bg-red-900 { background-color: #fef2f2 !important; }
|
||||
html:not(.dark) .bg-red-950 { background-color: #fff1f2 !important; }
|
||||
html:not(.dark) .text-red-300 { color: #b91c1c !important; }
|
||||
html:not(.dark) .text-red-400 { color: #dc2626 !important; }
|
||||
html:not(.dark) .border-red-800 { border-color: #fca5a5 !important; }
|
||||
|
||||
/* Police */
|
||||
html:not(.dark) .bg-blue-900 { background-color: #eff6ff !important; }
|
||||
html:not(.dark) .bg-blue-950 { background-color: #eff6ff !important; }
|
||||
html:not(.dark) .text-blue-300 { color: #1d4ed8 !important; }
|
||||
html:not(.dark) .border-blue-800 { border-color: #93c5fd !important; }
|
||||
|
||||
/* EMS */
|
||||
html:not(.dark) .bg-yellow-900 { background-color: #fefce8 !important; }
|
||||
html:not(.dark) .bg-yellow-950 { background-color: #fefce8 !important; }
|
||||
html:not(.dark) .text-yellow-300 { color: #a16207 !important; }
|
||||
html:not(.dark) .text-yellow-400 { color: #ca8a04 !important; }
|
||||
|
||||
/* Accident / Recording */
|
||||
html:not(.dark) .bg-orange-900 { background-color: #fff7ed !important; }
|
||||
html:not(.dark) .bg-orange-950 { background-color: #fff7ed !important; }
|
||||
html:not(.dark) .text-orange-300 { color: #c2410c !important; }
|
||||
html:not(.dark) .text-orange-400 { color: #ea580c !important; }
|
||||
html:not(.dark) .border-orange-800 { border-color: #fdba74 !important; }
|
||||
|
||||
/* Active / Online */
|
||||
html:not(.dark) .bg-green-900 { background-color: #f0fdf4 !important; }
|
||||
html:not(.dark) .bg-green-950 { background-color: #f0fdf4 !important; }
|
||||
html:not(.dark) .text-green-300 { color: #15803d !important; }
|
||||
html:not(.dark) .text-green-400 { color: #16a34a !important; }
|
||||
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
||||
|
||||
/* Unconfigured / Info */
|
||||
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
||||
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
||||
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
|
||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||
html:not(.dark) select,
|
||||
html:not(.dark) textarea {
|
||||
color: #0f172a;
|
||||
}
|
||||
html:not(.dark) input::placeholder,
|
||||
html:not(.dark) textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export default function IncidentsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,12 +11,18 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Prevent flash of wrong theme before React hydrates */}
|
||||
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var t=localStorage.getItem('drb-theme');if(t!=='light')document.documentElement.classList.add('dark');}catch(e){}})();` }} />
|
||||
</head>
|
||||
<body className="min-h-screen bg-gray-950">
|
||||
<AuthProvider>
|
||||
<Nav />
|
||||
<main className="p-6">{children}</main>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Nav />
|
||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -50,26 +50,14 @@ export default function MapPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
||||
<div className="flex items-center gap-4 text-xs font-mono text-gray-400">
|
||||
<span><span className="text-green-400">●</span> Online</span>
|
||||
<span><span className="text-orange-400 animate-pulse">●</span> Recording</span>
|
||||
<span><span className="text-indigo-400">●</span> Unconfigured</span>
|
||||
<span><span className="text-gray-600">●</span> Offline</span>
|
||||
<span className="border-l border-gray-700 pl-4"><span className="text-red-500">■</span> Fire</span>
|
||||
<span><span className="text-blue-500">■</span> Police</span>
|
||||
<span><span className="text-yellow-500">■</span> EMS</span>
|
||||
<span><span className="text-orange-500">■</span> Accident</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
||||
Loading map…
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
||||
<div className="h-[50vh] sm:h-[65vh] min-h-[400px]">
|
||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -719,7 +719,7 @@ export default function SystemsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||
<button
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function TokensPage() {
|
||||
if (authLoading || !isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, Marker, Popup, LayersControl, FeatureGroup } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||
|
||||
@@ -59,7 +59,6 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
activeCalls.map((c) => [c.node_id, c])
|
||||
);
|
||||
|
||||
// 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] }]
|
||||
@@ -81,64 +80,104 @@ export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
: 4;
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
<div className="relative w-full h-full">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<LayersControl position="topright">
|
||||
{/* Base layers */}
|
||||
<LayersControl.BaseLayer checked name="Dark">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Light">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Streets">
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
|
||||
{/* Node markers */}
|
||||
{nodes.map((node) => (
|
||||
<Marker
|
||||
key={node.node_id}
|
||||
position={[node.lat, node.lon]}
|
||||
icon={nodeIcon(node.status)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs mt-1 capitalize">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
{activeByNode[node.node_id].talkgroup_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
{/* Overlay: Nodes */}
|
||||
<LayersControl.Overlay checked name="Nodes">
|
||||
<FeatureGroup>
|
||||
{nodes.map((node) => (
|
||||
<Marker
|
||||
key={node.node_id}
|
||||
position={[node.lat, node.lon]}
|
||||
icon={nodeIcon(node.status)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs mt-1 capitalize">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
{activeByNode[node.node_id].talkgroup_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Incident markers — positioned at the node covering the incident's system */}
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
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"] ?? 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>
|
||||
))}
|
||||
</MapContainer>
|
||||
{/* Overlay: Active Incidents */}
|
||||
<LayersControl.Overlay checked name="Active Incidents">
|
||||
<FeatureGroup>
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
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"] ?? 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>
|
||||
))}
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
|
||||
{/* Legend overlay — inside the map wrapper, above tiles */}
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
|
||||
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
||||
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
||||
<div className="border-t border-gray-800 my-0.5" />
|
||||
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
||||
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+131
-32
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
@@ -21,48 +23,145 @@ const adminLinks = [
|
||||
{ href: "/admin", label: "Admin" },
|
||||
];
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Nav() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const unackedAlerts = useUnacknowledgedAlerts();
|
||||
const { theme, toggle } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const allLinks = [...links, ...(isAdmin ? adminLinks : [])];
|
||||
|
||||
function navLinkClass(href: string) {
|
||||
return `text-sm font-mono transition-colors shrink-0 ${
|
||||
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
|
||||
}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
|
||||
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
|
||||
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-mono transition-colors shrink-0 ${
|
||||
pathname.startsWith(href)
|
||||
? "text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="ml-auto shrink-0">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||
{/* Main bar */}
|
||||
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
|
||||
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
||||
{allLinks.map(({ href, label }) => (
|
||||
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
{/* Sign out (desktop) */}
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="hidden md:block text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
|
||||
{/* Hamburger (mobile) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||
{allLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||
pathname.startsWith(href) ? "text-white" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
|
||||
theme: "dark",
|
||||
toggle: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("drb-theme") as Theme | null;
|
||||
if (saved === "light") setTheme("light");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
localStorage.setItem("drb-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle: () => setTheme((t) => (t === "dark" ? "light" : "dark")) }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ const config: Config = {
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
],
|
||||
darkMode: ["class"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
Reference in New Issue
Block a user