Massive update
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# Top-level docker-compose environment variables
|
||||||
|
# Copy to .env and fill in values before running `docker compose up`
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# MQTT broker credentials
|
||||||
|
# These are injected into the mosquitto container at startup to build the
|
||||||
|
# password file. Use different values in production — do NOT reuse defaults.
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# C2-core service account (full broker access)
|
||||||
|
MQTT_C2_USER=drb-c2-core
|
||||||
|
MQTT_C2_PASS=change-me-c2
|
||||||
|
|
||||||
|
# Shared credential for all edge nodes (ACL scopes each node to its own
|
||||||
|
# nodes/<NODE_ID>/# namespace via the MQTT client ID)
|
||||||
|
MQTT_NODE_USER=drb-node
|
||||||
|
MQTT_NODE_PASS=change-me-node
|
||||||
+13
-1
@@ -4,8 +4,17 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
- "1883:1883"
|
||||||
|
entrypoint: ["/mosquitto/config/entrypoint.sh"]
|
||||||
|
environment:
|
||||||
|
- MQTT_C2_USER=${MQTT_C2_USER}
|
||||||
|
- MQTT_C2_PASS=${MQTT_C2_PASS}
|
||||||
|
- MQTT_NODE_USER=${MQTT_NODE_USER}
|
||||||
|
- MQTT_NODE_PASS=${MQTT_NODE_PASS}
|
||||||
volumes:
|
volumes:
|
||||||
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf
|
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||||
|
- ./drb-c2-core/mosquitto/acl.conf:/mosquitto/config/acl.conf:ro
|
||||||
|
- ./drb-c2-core/mosquitto/entrypoint.sh:/mosquitto/config/entrypoint.sh:ro
|
||||||
|
- mosquitto_data:/mosquitto/data
|
||||||
|
|
||||||
c2-core:
|
c2-core:
|
||||||
build: ./drb-c2-core
|
build: ./drb-c2-core
|
||||||
@@ -33,3 +42,6 @@ services:
|
|||||||
env_file: ./drb-frontend/.env
|
env_file: ./drb-frontend/.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- c2-core
|
- c2-core
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mosquitto_data:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# MQTT broker (usually the mosquitto container on this host)
|
# MQTT broker (usually the mosquitto container on this host)
|
||||||
MQTT_BROKER=mosquitto
|
MQTT_BROKER=mosquitto
|
||||||
MQTT_PORT=1883
|
MQTT_PORT=1883
|
||||||
MQTT_USER=
|
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the
|
||||||
MQTT_PASS=
|
# top-level .env (which is passed to the mosquitto entrypoint)
|
||||||
|
MQTT_USER=drb-c2-core
|
||||||
|
MQTT_PASS=change-me-c2
|
||||||
|
|
||||||
# GCP — path to service account JSON inside the container
|
# GCP — path to service account JSON inside the container
|
||||||
GCP_CREDENTIALS_PATH=/app/gcp-key.json
|
GCP_CREDENTIALS_PATH=/app/gcp-key.json
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
|
|||||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||||
service_key: Optional[str] = None
|
service_key: Optional[str] = None
|
||||||
|
|
||||||
|
# CORS — comma-separated list of allowed origins, or "*" for all
|
||||||
|
cors_origins: list[str] = ["*"]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
Alert dispatch engine.
|
||||||
|
|
||||||
|
Loads enabled alert rules from Firestore and checks each one against the call's
|
||||||
|
talkgroup ID, tags, and transcript. On a match:
|
||||||
|
1. Creates an AlertEvent document in Firestore.
|
||||||
|
2. Optionally POSTs a Discord webhook message if the rule has one configured.
|
||||||
|
|
||||||
|
Never raises — failures are logged as warnings so the pipeline always completes.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
|
async def check_and_dispatch(
|
||||||
|
call_id: str,
|
||||||
|
node_id: str,
|
||||||
|
talkgroup_id: Optional[int],
|
||||||
|
talkgroup_name: Optional[str],
|
||||||
|
tags: list[str],
|
||||||
|
transcript: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Check all enabled alert rules and fire events for any that match this call.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rules = await fstore.collection_list("alert_rules", enabled=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not load rules: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
matched_keywords = _match_rule(rule, talkgroup_id, tags, transcript)
|
||||||
|
if not matched_keywords:
|
||||||
|
continue
|
||||||
|
|
||||||
|
alert_id = str(uuid.uuid4())
|
||||||
|
snippet = _snippet(transcript)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
event = {
|
||||||
|
"alert_id": alert_id,
|
||||||
|
"rule_id": rule.get("rule_id", ""),
|
||||||
|
"rule_name": rule.get("name", ""),
|
||||||
|
"call_id": call_id,
|
||||||
|
"node_id": node_id,
|
||||||
|
"talkgroup_id": talkgroup_id,
|
||||||
|
"talkgroup_name": talkgroup_name or "",
|
||||||
|
"matched_keywords": matched_keywords,
|
||||||
|
"transcript_snippet": snippet,
|
||||||
|
"triggered_at": now,
|
||||||
|
"acknowledged": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await fstore.doc_set("alert_events", alert_id, event, merge=False)
|
||||||
|
logger.info(
|
||||||
|
f"Alert fired: rule='{rule.get('name')}' call={call_id} "
|
||||||
|
f"keywords={matched_keywords}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: could not save alert event: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
webhook_url = rule.get("discord_webhook")
|
||||||
|
if webhook_url:
|
||||||
|
await _post_webhook(webhook_url, rule.get("name", ""), talkgroup_name, matched_keywords, snippet)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_rule(
|
||||||
|
rule: dict,
|
||||||
|
talkgroup_id: Optional[int],
|
||||||
|
tags: list[str],
|
||||||
|
transcript: Optional[str],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return list of matched keywords/reasons, or empty list if no match."""
|
||||||
|
matched: list[str] = []
|
||||||
|
|
||||||
|
# Talkgroup ID match
|
||||||
|
rule_tg_ids = rule.get("talkgroup_ids", [])
|
||||||
|
if rule_tg_ids and talkgroup_id is not None and talkgroup_id in rule_tg_ids:
|
||||||
|
matched.append(f"talkgroup:{talkgroup_id}")
|
||||||
|
|
||||||
|
# Keyword match against tags + transcript
|
||||||
|
rule_keywords = [kw.lower() for kw in rule.get("keywords", [])]
|
||||||
|
for kw in rule_keywords:
|
||||||
|
if kw in tags:
|
||||||
|
matched.append(kw)
|
||||||
|
elif transcript and kw in transcript.lower():
|
||||||
|
matched.append(kw)
|
||||||
|
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _snippet(transcript: Optional[str], max_len: int = 200) -> Optional[str]:
|
||||||
|
if not transcript:
|
||||||
|
return None
|
||||||
|
return transcript[:max_len] + ("…" if len(transcript) > max_len else "")
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_webhook(
|
||||||
|
url: str,
|
||||||
|
rule_name: str,
|
||||||
|
talkgroup_name: Optional[str],
|
||||||
|
matched_keywords: list[str],
|
||||||
|
snippet: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
tg_label = talkgroup_name or "Unknown"
|
||||||
|
kw_str = ", ".join(matched_keywords)
|
||||||
|
body = (
|
||||||
|
f"**Alert: {rule_name}**\n"
|
||||||
|
f"Talkgroup: {tg_label}\n"
|
||||||
|
f"Matched: {kw_str}"
|
||||||
|
)
|
||||||
|
if snippet:
|
||||||
|
body += f"\n> {snippet}"
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
await client.post(url, json={"content": body})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Alerter: Discord webhook POST failed: {e}")
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Incident correlation engine.
|
||||||
|
|
||||||
|
After a call is transcribed and tagged, this module attempts to link it to an
|
||||||
|
existing open incident (same type, same node/system, within a 30-minute
|
||||||
|
window). If no match is found, a new incident is auto-created.
|
||||||
|
|
||||||
|
The result is written back to Firestore on both the call document
|
||||||
|
(call.incident_id) and the incident document (incident.call_ids).
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
|
_CORRELATION_WINDOW = timedelta(minutes=30)
|
||||||
|
|
||||||
|
|
||||||
|
async def correlate_call(
|
||||||
|
call_id: str,
|
||||||
|
node_id: str,
|
||||||
|
system_id: Optional[str],
|
||||||
|
talkgroup_name: Optional[str],
|
||||||
|
tags: list[str],
|
||||||
|
incident_type: Optional[str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Link call_id to an existing incident or create a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
call_id: ID of the call being processed.
|
||||||
|
node_id: Edge node that recorded the call.
|
||||||
|
system_id: Radio system ID (may be None).
|
||||||
|
talkgroup_name: Human-readable talkgroup name for auto-title generation.
|
||||||
|
tags: Tags extracted by intelligence.py.
|
||||||
|
incident_type: Primary incident category (fire/police/ems/accident) or None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The incident_id that was linked, or None if skipped.
|
||||||
|
"""
|
||||||
|
if not incident_type:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cutoff = (now - _CORRELATION_WINDOW).isoformat()
|
||||||
|
|
||||||
|
# Fetch active incidents of the same type
|
||||||
|
candidates = await fstore.collection_list("incidents", status="active", type=incident_type)
|
||||||
|
|
||||||
|
# Filter to incidents updated within the correlation window and on this node
|
||||||
|
matched_incident: Optional[dict] = None
|
||||||
|
for inc in candidates:
|
||||||
|
updated_raw = inc.get("updated_at", "")
|
||||||
|
try:
|
||||||
|
updated_dt = datetime.fromisoformat(str(updated_raw).replace("Z", "+00:00"))
|
||||||
|
if updated_dt.tzinfo is None:
|
||||||
|
updated_dt = updated_dt.replace(tzinfo=timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if updated_dt < (now - _CORRELATION_WINDOW):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check whether any call in this incident came from the same node
|
||||||
|
linked_call_ids = inc.get("call_ids", [])
|
||||||
|
if linked_call_ids:
|
||||||
|
for linked_id in linked_call_ids[:5]: # check last 5 calls to avoid slow queries
|
||||||
|
linked_call = await fstore.doc_get("calls", linked_id)
|
||||||
|
if linked_call and linked_call.get("node_id") == node_id:
|
||||||
|
matched_incident = inc
|
||||||
|
break
|
||||||
|
if matched_incident:
|
||||||
|
break
|
||||||
|
|
||||||
|
if matched_incident:
|
||||||
|
incident_id = matched_incident["incident_id"]
|
||||||
|
existing_ids = matched_incident.get("call_ids", [])
|
||||||
|
if call_id not in existing_ids:
|
||||||
|
existing_ids.append(call_id)
|
||||||
|
await fstore.doc_update("incidents", incident_id, {
|
||||||
|
"call_ids": existing_ids,
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
})
|
||||||
|
logger.info(f"Correlator: linked call {call_id} to existing incident {incident_id}")
|
||||||
|
else:
|
||||||
|
# Create a new incident
|
||||||
|
incident_id = str(uuid.uuid4())
|
||||||
|
tg_label = talkgroup_name or "Unknown Talkgroup"
|
||||||
|
title = f"Auto: {incident_type.title()} — {tg_label}"
|
||||||
|
doc = {
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"title": title,
|
||||||
|
"type": incident_type,
|
||||||
|
"status": "active",
|
||||||
|
"location": None,
|
||||||
|
"call_ids": [call_id],
|
||||||
|
"summary": None,
|
||||||
|
"tags": tags,
|
||||||
|
"started_at": now.isoformat(),
|
||||||
|
"updated_at": now.isoformat(),
|
||||||
|
}
|
||||||
|
await fstore.doc_set("incidents", incident_id, doc, merge=False)
|
||||||
|
logger.info(f"Correlator: created new incident {incident_id} for call {call_id} ({incident_type})")
|
||||||
|
|
||||||
|
# Back-link the call
|
||||||
|
await fstore.doc_update("calls", call_id, {"incident_id": incident_id})
|
||||||
|
return incident_id
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Rules-based intelligence extraction from call transcripts.
|
||||||
|
|
||||||
|
Scans a transcript for known incident keywords, categorises the call, and
|
||||||
|
extracts rough location hints (street/intersection mentions).
|
||||||
|
|
||||||
|
No external ML dependencies — fast and always available even when STT is
|
||||||
|
disabled. Designed to run as part of the post-upload background pipeline.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Keyword taxonomy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
INCIDENT_KEYWORDS: dict[str, list[str]] = {
|
||||||
|
"fire": [
|
||||||
|
"fire", "smoke", "flames", "burning", "structure fire", "brush fire",
|
||||||
|
"wildfire", "arson", "working fire", "fully involved",
|
||||||
|
],
|
||||||
|
"ems": [
|
||||||
|
"cardiac", "unconscious", "breathing", "overdose", "trauma",
|
||||||
|
"injury", "ambulance", "ems", "medic", "chest pain", "stroke",
|
||||||
|
"unresponsive", "fall", "laceration",
|
||||||
|
],
|
||||||
|
"police": [
|
||||||
|
"pursuit", "chase", "shots fired", "weapon", "suspect", "robbery",
|
||||||
|
"assault", "burglary", "stolen", "fleeing", "armed", "shooting",
|
||||||
|
"stabbing", "domestic",
|
||||||
|
],
|
||||||
|
"accident": [
|
||||||
|
"accident", "collision", "crash", "mvr", "vehicle", "rollover",
|
||||||
|
"hit and run", "ped", "pedestrian", "pi", "property damage",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Street suffix patterns for location extraction
|
||||||
|
_STREET_RE = re.compile(
|
||||||
|
r'\b(?:\d+\s+)?[A-Z][a-zA-Z]+(?: [A-Z][a-zA-Z]+)*'
|
||||||
|
r'\s+(?:Street|St|Avenue|Ave|Boulevard|Blvd|Drive|Dr|Road|Rd|Lane|Ln'
|
||||||
|
r'|Court|Ct|Place|Pl|Way|Circle|Cir|Highway|Hwy|Route|Rt)\b',
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def extract_tags(
|
||||||
|
call_id: str,
|
||||||
|
transcript: str,
|
||||||
|
) -> tuple[list[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Extract incident tags from a transcript.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(tags, primary_type) — e.g. (["fire", "structure fire"], "fire")
|
||||||
|
primary_type is the category with the most keyword hits, or None.
|
||||||
|
|
||||||
|
Side-effect: updates calls/{call_id}.tags in Firestore.
|
||||||
|
"""
|
||||||
|
lower = transcript.lower()
|
||||||
|
matched: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for category, keywords in INCIDENT_KEYWORDS.items():
|
||||||
|
hits = [kw for kw in keywords if kw in lower]
|
||||||
|
if hits:
|
||||||
|
matched[category] = hits
|
||||||
|
|
||||||
|
tags: list[str] = []
|
||||||
|
for category, hits in matched.items():
|
||||||
|
tags.append(category)
|
||||||
|
tags.extend(h for h in hits if h != category)
|
||||||
|
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique_tags: list[str] = []
|
||||||
|
for t in tags:
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t)
|
||||||
|
unique_tags.append(t)
|
||||||
|
|
||||||
|
# Primary type = category with most keyword hits
|
||||||
|
primary_type: Optional[str] = None
|
||||||
|
if matched:
|
||||||
|
primary_type = max(matched, key=lambda c: len(matched[c]))
|
||||||
|
|
||||||
|
if unique_tags:
|
||||||
|
try:
|
||||||
|
await fstore.doc_update("calls", call_id, {"tags": unique_tags})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not save tags for call {call_id}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Intelligence: call {call_id} → tags={unique_tags}, type={primary_type}")
|
||||||
|
return unique_tags, primary_type
|
||||||
|
|
||||||
|
|
||||||
|
def extract_location_hint(transcript: str) -> Optional[str]:
|
||||||
|
"""Return the first street-level location mention found in the transcript, or None."""
|
||||||
|
match = _STREET_RE.search(transcript)
|
||||||
|
return match.group(0) if match else None
|
||||||
@@ -30,9 +30,10 @@ class MQTTHandler:
|
|||||||
def _on_connect(self, client, userdata, flags, reason_code, properties):
|
def _on_connect(self, client, userdata, flags, reason_code, properties):
|
||||||
if reason_code == 0:
|
if reason_code == 0:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
client.subscribe("nodes/+/checkin", qos=1)
|
client.subscribe("nodes/+/checkin", qos=1)
|
||||||
client.subscribe("nodes/+/status", qos=1)
|
client.subscribe("nodes/+/status", qos=1)
|
||||||
client.subscribe("nodes/+/metadata", qos=1)
|
client.subscribe("nodes/+/metadata", qos=1)
|
||||||
|
client.subscribe("nodes/+/key_request", qos=1)
|
||||||
logger.info("MQTT connected — subscribed to node topics.")
|
logger.info("MQTT connected — subscribed to node topics.")
|
||||||
else:
|
else:
|
||||||
logger.error(f"MQTT connect refused: {reason_code}")
|
logger.error(f"MQTT connect refused: {reason_code}")
|
||||||
@@ -68,6 +69,8 @@ class MQTTHandler:
|
|||||||
await self._handle_status(node_id, payload)
|
await self._handle_status(node_id, payload)
|
||||||
elif msg_type == "metadata":
|
elif msg_type == "metadata":
|
||||||
await self._handle_metadata(node_id, payload)
|
await self._handle_metadata(node_id, payload)
|
||||||
|
elif msg_type == "key_request":
|
||||||
|
await self._handle_key_request(node_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"MQTT dispatch error [{msg_type}] from {node_id}: {e}")
|
logger.error(f"MQTT dispatch error [{msg_type}] from {node_id}: {e}")
|
||||||
|
|
||||||
@@ -188,6 +191,19 @@ class MQTTHandler:
|
|||||||
await fstore.doc_update("calls", call_id, updates)
|
await fstore.doc_update("calls", call_id, updates)
|
||||||
logger.info(f"Call end: {call_id}")
|
logger.info(f"Call end: {call_id}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Key request — re-deliver an existing approved key to a node that
|
||||||
|
# lost its credentials (e.g. after a directory move / fresh volume)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _handle_key_request(self, node_id: str):
|
||||||
|
key_doc = await fstore.doc_get("node_keys", node_id)
|
||||||
|
if not key_doc or not key_doc.get("api_key"):
|
||||||
|
logger.warning(f"Key request from {node_id} but no key found in Firestore — node may not be approved yet.")
|
||||||
|
return
|
||||||
|
self.publish_node_key(node_id, key_doc["api_key"])
|
||||||
|
logger.info(f"Re-delivered API key to {node_id} on request.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Outbound — send a command to a specific node
|
# Outbound — send a command to a specific node
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Speech-to-text transcription for recorded calls.
|
||||||
|
|
||||||
|
Uses Google Cloud Speech-to-Text v1 (authenticated via the same ADC / service
|
||||||
|
account used by firebase-admin and google-cloud-storage).
|
||||||
|
|
||||||
|
Triggered as a background task from the upload endpoint after a call audio
|
||||||
|
file has been successfully stored in GCS.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
|
async def transcribe_call(call_id: str, gcs_uri: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Transcribe audio at the given GCS URI and store the result in Firestore.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
call_id: Firestore document ID in the 'calls' collection.
|
||||||
|
gcs_uri: GCS URI of the audio file, e.g. gs://bucket/calls/xyz.mp3
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The transcript string, or None if transcription failed / was skipped.
|
||||||
|
"""
|
||||||
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
transcript = await asyncio.to_thread(_sync_transcribe, gcs_uri)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if transcript:
|
||||||
|
try:
|
||||||
|
await fstore.doc_update("calls", call_id, {"transcript": transcript})
|
||||||
|
logger.info(f"Transcript saved for call {call_id} ({len(transcript)} chars)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not save transcript for {call_id}: {e}")
|
||||||
|
|
||||||
|
return transcript
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_transcribe(gcs_uri: str) -> Optional[str]:
|
||||||
|
"""Synchronous STT call — run in a thread via asyncio.to_thread."""
|
||||||
|
from google.cloud import speech
|
||||||
|
|
||||||
|
client = speech.SpeechClient()
|
||||||
|
|
||||||
|
audio = speech.RecognitionAudio(uri=gcs_uri)
|
||||||
|
config = speech.RecognitionConfig(
|
||||||
|
encoding=speech.RecognitionConfig.AudioEncoding.MP3,
|
||||||
|
sample_rate_hertz=22050,
|
||||||
|
language_code="en-US",
|
||||||
|
enable_automatic_punctuation=True,
|
||||||
|
model="latest_long",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use long_running_recognize for reliability; it handles both short and long audio
|
||||||
|
operation = client.long_running_recognize(config=config, audio=audio)
|
||||||
|
response = operation.result(timeout=120)
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
result.alternatives[0].transcript
|
||||||
|
for result in response.results
|
||||||
|
if result.alternatives
|
||||||
|
]
|
||||||
|
return " ".join(parts).strip() or None
|
||||||
+11
-7
@@ -5,8 +5,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal.mqtt_handler import mqtt_handler
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
from app.internal.node_sweeper import sweeper_loop
|
from app.internal.node_sweeper import sweeper_loop
|
||||||
|
from app.config import settings
|
||||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||||
from app.routers import nodes, systems, calls, upload, tokens
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -27,16 +28,19 @@ app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=settings.cors_origins,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -78,3 +78,57 @@ class IncidentRecord(BaseModel):
|
|||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
tags: List[str] = []
|
tags: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class IncidentCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
type: str = "other"
|
||||||
|
status: str = "active"
|
||||||
|
location: Optional[Dict[str, float]] = None
|
||||||
|
call_ids: List[str] = []
|
||||||
|
summary: Optional[str] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class IncidentUpdate(BaseModel):
|
||||||
|
title: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
location: Optional[Dict[str, float]] = None
|
||||||
|
summary: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alerts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AlertRule(BaseModel):
|
||||||
|
rule_id: Optional[str] = None
|
||||||
|
name: str
|
||||||
|
keywords: List[str] = []
|
||||||
|
talkgroup_ids: List[int] = []
|
||||||
|
enabled: bool = True
|
||||||
|
discord_webhook: Optional[str] = None # POST here when rule fires
|
||||||
|
|
||||||
|
|
||||||
|
class AlertRuleUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
keywords: Optional[List[str]] = None
|
||||||
|
talkgroup_ids: Optional[List[int]] = None
|
||||||
|
enabled: Optional[bool] = None
|
||||||
|
discord_webhook: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AlertEvent(BaseModel):
|
||||||
|
alert_id: Optional[str] = None
|
||||||
|
rule_id: str
|
||||||
|
rule_name: str
|
||||||
|
call_id: str
|
||||||
|
node_id: str
|
||||||
|
talkgroup_id: Optional[int] = None
|
||||||
|
talkgroup_name: Optional[str] = None
|
||||||
|
matched_keywords: List[str] = []
|
||||||
|
transcript_snippet: Optional[str] = None
|
||||||
|
triggered_at: Optional[datetime] = None
|
||||||
|
acknowledged: bool = False
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from app.models import AlertRule, AlertRuleUpdate
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
|
||||||
|
router = APIRouter(tags=["alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alert events (triggered alerts)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/alerts")
|
||||||
|
async def list_alerts(acknowledged: Optional[bool] = None):
|
||||||
|
filters = {}
|
||||||
|
if acknowledged is not None:
|
||||||
|
filters["acknowledged"] = acknowledged
|
||||||
|
return await fstore.collection_list("alert_events", **filters)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/alerts/{alert_id}/acknowledge")
|
||||||
|
async def acknowledge_alert(alert_id: str):
|
||||||
|
doc = await fstore.doc_get("alert_events", alert_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Alert '{alert_id}' not found.")
|
||||||
|
await fstore.doc_update("alert_events", alert_id, {"acknowledged": True})
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Alert rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/alert-rules")
|
||||||
|
async def list_alert_rules():
|
||||||
|
return await fstore.collection_list("alert_rules")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/alert-rules")
|
||||||
|
async def create_alert_rule(body: AlertRule, _: dict = Depends(require_admin_token)):
|
||||||
|
rule_id = str(uuid.uuid4())
|
||||||
|
doc = {
|
||||||
|
"rule_id": rule_id,
|
||||||
|
"name": body.name,
|
||||||
|
"keywords": body.keywords,
|
||||||
|
"talkgroup_ids": body.talkgroup_ids,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
"discord_webhook": body.discord_webhook,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
await fstore.doc_set("alert_rules", rule_id, doc, merge=False)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/alert-rules/{rule_id}")
|
||||||
|
async def update_alert_rule(rule_id: str, body: AlertRuleUpdate, _: dict = Depends(require_admin_token)):
|
||||||
|
doc = await fstore.doc_get("alert_rules", rule_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Alert rule '{rule_id}' not found.")
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
await fstore.doc_update("alert_rules", rule_id, updates)
|
||||||
|
return {**doc, **updates}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/alert-rules/{rule_id}")
|
||||||
|
async def delete_alert_rule(rule_id: str, _: dict = Depends(require_admin_token)):
|
||||||
|
doc = await fstore.doc_get("alert_rules", rule_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Alert rule '{rule_id}' not found.")
|
||||||
|
await fstore.doc_delete("alert_rules", rule_id)
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from app.models import IncidentCreate, IncidentUpdate
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_incidents(status: Optional[str] = None, type: Optional[str] = None):
|
||||||
|
filters = {}
|
||||||
|
if status:
|
||||||
|
filters["status"] = status
|
||||||
|
if type:
|
||||||
|
filters["type"] = type
|
||||||
|
return await fstore.collection_list("incidents", **filters)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{incident_id}")
|
||||||
|
async def get_incident(incident_id: str):
|
||||||
|
doc = await fstore.doc_get("incidents", incident_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_incident(body: IncidentCreate, _: dict = Depends(require_admin_token)):
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
incident_id = str(uuid.uuid4())
|
||||||
|
doc = {
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"title": body.title,
|
||||||
|
"type": body.type,
|
||||||
|
"status": body.status,
|
||||||
|
"location": body.location,
|
||||||
|
"call_ids": body.call_ids,
|
||||||
|
"summary": body.summary,
|
||||||
|
"tags": body.tags,
|
||||||
|
"started_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
await fstore.doc_set("incidents", incident_id, doc, merge=False)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{incident_id}")
|
||||||
|
async def update_incident(incident_id: str, body: IncidentUpdate, _: dict = Depends(require_admin_token)):
|
||||||
|
doc = await fstore.doc_get("incidents", incident_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
updates = body.model_dump(exclude_none=True)
|
||||||
|
updates["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
await fstore.doc_update("incidents", incident_id, updates)
|
||||||
|
return {**doc, **updates}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{incident_id}")
|
||||||
|
async def delete_incident(incident_id: str, _: dict = Depends(require_admin_token)):
|
||||||
|
doc = await fstore.doc_get("incidents", incident_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
await fstore.doc_delete("incidents", incident_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{incident_id}/calls/{call_id}")
|
||||||
|
async def link_call_to_incident(incident_id: str, call_id: str, _: dict = Depends(require_admin_token)):
|
||||||
|
doc = await fstore.doc_get("incidents", incident_id)
|
||||||
|
if not doc:
|
||||||
|
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||||
|
call_ids = doc.get("call_ids", [])
|
||||||
|
if call_id not in call_ids:
|
||||||
|
call_ids.append(call_id)
|
||||||
|
await fstore.doc_update("incidents", incident_id, {
|
||||||
|
"call_ids": call_ids,
|
||||||
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
await fstore.doc_update("calls", call_id, {"incident_id": incident_id})
|
||||||
|
return {"ok": True}
|
||||||
@@ -66,6 +66,19 @@ async def send_command(node_id: str, cmd: CommandPayload):
|
|||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{node_id}/reissue-key")
|
||||||
|
async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token)):
|
||||||
|
"""Generate a new API key for the node and push it via MQTT (retained).
|
||||||
|
Use this to rotate a key or recover a node whose key was lost."""
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node:
|
||||||
|
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||||
|
api_key = secrets.token_hex(32)
|
||||||
|
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
||||||
|
mqtt_handler.publish_node_key(node_id, api_key)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/config/{system_id}")
|
@router.post("/{node_id}/config/{system_id}")
|
||||||
async def assign_system(node_id: str, system_id: str):
|
async def assign_system(node_id: str, system_id: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Security
|
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from app.internal.storage import upload_audio
|
from app.internal.storage import upload_audio
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
@@ -12,20 +12,32 @@ _bearer = HTTPBearer(auto_error=False)
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_call_audio(
|
async def upload_call_audio(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
call_id: str = Form(...),
|
call_id: str = Form(...),
|
||||||
node_id: str = Form(...),
|
node_id: str = Form(...),
|
||||||
|
talkgroup_id: Optional[int] = Form(None),
|
||||||
|
talkgroup_name: Optional[str] = Form(None),
|
||||||
|
system_id: Optional[str] = Form(None),
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Receive an audio recording from an edge node.
|
Receive an audio recording from an edge node.
|
||||||
Upload to GCS, update the call document in Firestore with the audio URL.
|
Upload to GCS, update the call document in Firestore with the audio URL,
|
||||||
|
then kick off the intelligence pipeline as a background task.
|
||||||
"""
|
"""
|
||||||
# Verify the per-node API key
|
# Verify the per-node API key
|
||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(401, "Missing authorization")
|
raise HTTPException(401, "Missing authorization")
|
||||||
key_doc = await fstore.doc_get("node_keys", node_id)
|
key_doc = await fstore.doc_get("node_keys", node_id)
|
||||||
if not key_doc or key_doc.get("api_key") != credentials.credentials:
|
if not key_doc:
|
||||||
|
logger.warning(f"Upload 401: no key_doc in Firestore for node_id={node_id!r}")
|
||||||
|
raise HTTPException(401, "Invalid node API key")
|
||||||
|
if key_doc.get("api_key") != credentials.credentials:
|
||||||
|
logger.warning(
|
||||||
|
f"Upload 401: key mismatch for node_id={node_id!r} "
|
||||||
|
f"(received prefix: {credentials.credentials[:8]}...)"
|
||||||
|
)
|
||||||
raise HTTPException(401, "Invalid node API key")
|
raise HTTPException(401, "Invalid node API key")
|
||||||
|
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
@@ -41,4 +53,81 @@ async def upload_call_audio(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
|
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
|
||||||
|
|
||||||
|
# Convert public GCS URL to gs:// URI for Speech-to-Text
|
||||||
|
gcs_uri = _public_url_to_gcs_uri(audio_url)
|
||||||
|
|
||||||
|
background_tasks.add_task(
|
||||||
|
_run_intelligence_pipeline,
|
||||||
|
call_id=call_id,
|
||||||
|
node_id=node_id,
|
||||||
|
system_id=system_id,
|
||||||
|
talkgroup_id=talkgroup_id,
|
||||||
|
talkgroup_name=talkgroup_name,
|
||||||
|
gcs_uri=gcs_uri,
|
||||||
|
)
|
||||||
|
|
||||||
return {"url": audio_url}
|
return {"url": audio_url}
|
||||||
|
|
||||||
|
|
||||||
|
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Convert a public GCS URL like
|
||||||
|
https://storage.googleapis.com/bucket/calls/file.mp3
|
||||||
|
to a gs:// URI usable by Speech-to-Text.
|
||||||
|
Returns None if the URL doesn't look like a GCS URL.
|
||||||
|
"""
|
||||||
|
prefix = "https://storage.googleapis.com/"
|
||||||
|
if url and url.startswith(prefix):
|
||||||
|
return "gs://" + url[len(prefix):]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_intelligence_pipeline(
|
||||||
|
call_id: str,
|
||||||
|
node_id: str,
|
||||||
|
system_id: Optional[str],
|
||||||
|
talkgroup_id: Optional[int],
|
||||||
|
talkgroup_name: Optional[str],
|
||||||
|
gcs_uri: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Post-upload intelligence pipeline (runs as a background task):
|
||||||
|
1. Transcribe audio via Google STT
|
||||||
|
2. Extract tags/incident type from transcript
|
||||||
|
3. Correlate with existing incidents (or create new one)
|
||||||
|
4. Check alert rules and dispatch notifications
|
||||||
|
"""
|
||||||
|
from app.internal import transcription, intelligence, incident_correlator, alerter
|
||||||
|
|
||||||
|
transcript: Optional[str] = None
|
||||||
|
|
||||||
|
# Step 1: Transcription
|
||||||
|
if gcs_uri:
|
||||||
|
transcript = await transcription.transcribe_call(call_id, gcs_uri)
|
||||||
|
|
||||||
|
# Step 2: Intelligence extraction
|
||||||
|
tags: list[str] = []
|
||||||
|
incident_type: Optional[str] = None
|
||||||
|
if transcript:
|
||||||
|
tags, incident_type = await intelligence.extract_tags(call_id, transcript)
|
||||||
|
|
||||||
|
# Step 3: Incident correlation
|
||||||
|
if incident_type:
|
||||||
|
await incident_correlator.correlate_call(
|
||||||
|
call_id=call_id,
|
||||||
|
node_id=node_id,
|
||||||
|
system_id=system_id,
|
||||||
|
talkgroup_name=talkgroup_name,
|
||||||
|
tags=tags,
|
||||||
|
incident_type=incident_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
||||||
|
await alerter.check_and_dispatch(
|
||||||
|
call_id=call_id,
|
||||||
|
node_id=node_id,
|
||||||
|
talkgroup_id=talkgroup_id,
|
||||||
|
talkgroup_name=talkgroup_name,
|
||||||
|
tags=tags,
|
||||||
|
transcript=transcript,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Mosquitto ACL — DRB C2 Server
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# Two principals:
|
||||||
|
# drb-c2-core — the backend service; needs full broker access
|
||||||
|
# drb-node — shared credential for all edge nodes; scoped to their
|
||||||
|
# own namespace via MQTT client ID (%c = NODE_ID)
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
# C2-core service — full read/write on every topic
|
||||||
|
user drb-c2-core
|
||||||
|
topic readwrite #
|
||||||
|
|
||||||
|
# Edge nodes — each node may only read/write topics under nodes/<its-own-ID>/
|
||||||
|
# Mosquitto substitutes %c with the connecting client's MQTT client ID at
|
||||||
|
# runtime. Edge nodes set client_id = NODE_ID in mqtt_manager.py, so this
|
||||||
|
# cryptographically prevents node-A from publishing to nodes/node-B/api_key
|
||||||
|
# or any other node's namespace.
|
||||||
|
pattern readwrite nodes/%c/#
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mosquitto entrypoint — generates /mosquitto/config/passwd from env vars
|
||||||
|
# before handing off to the broker process.
|
||||||
|
#
|
||||||
|
# Required environment variables (set in docker-compose.yml):
|
||||||
|
# MQTT_C2_USER — username for the drb-c2-core service
|
||||||
|
# MQTT_C2_PASS — password for the drb-c2-core service
|
||||||
|
# MQTT_NODE_USER — shared username for all edge nodes
|
||||||
|
# MQTT_NODE_PASS — shared password for all edge nodes
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PASSWD_FILE=/mosquitto/config/passwd
|
||||||
|
|
||||||
|
# Remove any stale file so we start clean on every container start
|
||||||
|
rm -f "$PASSWD_FILE"
|
||||||
|
|
||||||
|
if [ -z "$MQTT_C2_USER" ] || [ -z "$MQTT_C2_PASS" ]; then
|
||||||
|
echo "ERROR: MQTT_C2_USER and MQTT_C2_PASS must be set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$MQTT_NODE_USER" ] || [ -z "$MQTT_NODE_PASS" ]; then
|
||||||
|
echo "ERROR: MQTT_NODE_USER and MQTT_NODE_PASS must be set" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mosquitto_passwd -b "$PASSWD_FILE" "$MQTT_C2_USER" "$MQTT_C2_PASS"
|
||||||
|
mosquitto_passwd -b "$PASSWD_FILE" "$MQTT_NODE_USER" "$MQTT_NODE_PASS"
|
||||||
|
|
||||||
|
echo "Mosquitto: password file written for users: $MQTT_C2_USER, $MQTT_NODE_USER"
|
||||||
|
|
||||||
|
exec /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
listener 1883
|
listener 1883
|
||||||
allow_anonymous true
|
allow_anonymous false
|
||||||
|
|
||||||
# Persist messages across restarts
|
# Credentials and ACLs are generated/mounted at container startup
|
||||||
|
password_file /mosquitto/config/passwd
|
||||||
|
acl_file /mosquitto/config/acl.conf
|
||||||
|
|
||||||
|
# Persist retained messages (e.g. api_key, node status) across broker restarts
|
||||||
persistence true
|
persistence true
|
||||||
persistence_location /mosquitto/data/
|
persistence_location /mosquitto/data/
|
||||||
|
|
||||||
log_dest stdout
|
log_dest stdout
|
||||||
|
log_type error
|
||||||
|
log_type warning
|
||||||
|
log_type notice
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ pydantic-settings
|
|||||||
paho-mqtt>=2.0.0
|
paho-mqtt>=2.0.0
|
||||||
firebase-admin
|
firebase-admin
|
||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
|
google-cloud-speech
|
||||||
|
httpx
|
||||||
python-multipart
|
python-multipart
|
||||||
pytest
|
pytest
|
||||||
pytest-asyncio
|
pytest-asyncio
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { useAlerts } from "@/lib/useAlerts";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { AlertRule } from "@/lib/types";
|
||||||
|
|
||||||
|
function fmtTime(iso: string) {
|
||||||
|
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [keywords, setKeywords] = useState("");
|
||||||
|
const [tgIds, setTgIds] = useState("");
|
||||||
|
const [webhook, setWebhook] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (loaded) return;
|
||||||
|
try {
|
||||||
|
const data = await c2api.getAlertRules() as AlertRule[];
|
||||||
|
setRules(data);
|
||||||
|
setLoaded(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load on first render of this tab
|
||||||
|
if (!loaded) { load(); }
|
||||||
|
|
||||||
|
async function handleCreate(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
keywords: keywords.split(",").map((k) => k.trim()).filter(Boolean),
|
||||||
|
talkgroup_ids: tgIds.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)),
|
||||||
|
enabled: true,
|
||||||
|
discord_webhook: webhook || null,
|
||||||
|
};
|
||||||
|
const created = await c2api.createAlertRule(body) as AlertRule;
|
||||||
|
setRules((prev) => [created, ...prev]);
|
||||||
|
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
||||||
|
} catch {
|
||||||
|
setError("Failed to create rule.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggle(rule: AlertRule) {
|
||||||
|
try {
|
||||||
|
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
||||||
|
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
await c2api.deleteAlertRule(id);
|
||||||
|
setRules((prev) => prev.filter((r) => r.rule_id !== id));
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{isAdmin && (
|
||||||
|
<form onSubmit={handleCreate} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
||||||
|
<input
|
||||||
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Structure Fire"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Keywords (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
||||||
|
placeholder="fire, smoke, structure"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Talkgroup IDs (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
||||||
|
placeholder="9048, 9600"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Discord Webhook URL (optional)</label>
|
||||||
|
<input
|
||||||
|
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
||||||
|
placeholder="https://discord.com/api/webhooks/…"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit" disabled={saving}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Creating…" : "Create Rule"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
{rules.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Name</th>
|
||||||
|
<th className="px-4 py-3">Keywords</th>
|
||||||
|
<th className="px-4 py-3">Talkgroups</th>
|
||||||
|
<th className="px-4 py-3">Webhook</th>
|
||||||
|
<th className="px-4 py-3">Enabled</th>
|
||||||
|
{isAdmin && <th className="px-4 py-3"></th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<tr key={rule.rule_id} className="border-b border-gray-800">
|
||||||
|
<td className="px-4 py-3 text-white text-sm">{rule.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs">
|
||||||
|
{rule.discord_webhook ? (
|
||||||
|
<span className="text-green-400">configured</span>
|
||||||
|
) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isAdmin ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(rule)}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||||
|
rule.enabled
|
||||||
|
? "bg-green-900 text-green-300 hover:bg-green-800"
|
||||||
|
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{rule.enabled ? "enabled" : "disabled"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-gray-500"}`}>
|
||||||
|
{rule.enabled ? "enabled" : "disabled"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(rule.rule_id)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AlertsPage() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { alerts, loading } = useAlerts();
|
||||||
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||||
|
|
||||||
|
async function handleAcknowledge(id: string) {
|
||||||
|
try {
|
||||||
|
await c2api.acknowledgeAlert(id);
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto 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 && (
|
||||||
|
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
|
||||||
|
{unacked.length} unacknowledged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||||
|
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t as "events" | "rules")}
|
||||||
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||||
|
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "events" ? "Triggered Alerts" : "Rules"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "events" && (
|
||||||
|
loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Rule</th>
|
||||||
|
<th className="px-4 py-3">Talkgroup</th>
|
||||||
|
<th className="px-4 py-3">Matched</th>
|
||||||
|
<th className="px-4 py-3">Snippet</th>
|
||||||
|
<th className="px-4 py-3">Time</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<tr
|
||||||
|
key={alert.alert_id}
|
||||||
|
className={`border-b border-gray-800 ${alert.acknowledged ? "opacity-50" : ""}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">
|
||||||
|
{alert.talkgroup_name || alert.talkgroup_id || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{alert.matched_keywords.map((kw) => (
|
||||||
|
<span key={kw} className="text-xs bg-red-900 text-red-300 px-1.5 py-0.5 rounded">
|
||||||
|
{kw}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-xs truncate">
|
||||||
|
{alert.transcript_snippet || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{alert.acknowledged ? (
|
||||||
|
<span className="text-xs text-gray-500">acked</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleAcknowledge(alert.alert_id)}
|
||||||
|
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Acknowledge
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "rules" && <RulesTab isAdmin={isAdmin} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export default function CallsPage() {
|
|||||||
<th className="px-4 py-2 text-left">Node</th>
|
<th className="px-4 py-2 text-left">Node</th>
|
||||||
<th className="px-4 py-2 text-left">Duration</th>
|
<th className="px-4 py-2 text-left">Duration</th>
|
||||||
<th className="px-4 py-2 text-left">Audio</th>
|
<th className="px-4 py-2 text-left">Audio</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
fire: "bg-red-900 text-red-300",
|
||||||
|
police: "bg-blue-900 text-blue-300",
|
||||||
|
ems: "bg-yellow-900 text-yellow-300",
|
||||||
|
accident: "bg-orange-900 text-orange-300",
|
||||||
|
other: "bg-gray-800 text-gray-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeBadge(type: string | null) {
|
||||||
|
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||||
|
return (
|
||||||
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||||
|
{type ?? "other"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(iso: string) {
|
||||||
|
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||||
|
incident: IncidentRecord;
|
||||||
|
isAdmin: boolean;
|
||||||
|
onResolve: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
||||||
|
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||||
|
incident.status === "active"
|
||||||
|
? "bg-green-900 text-green-300"
|
||||||
|
: "bg-gray-800 text-gray-400"
|
||||||
|
}`}>
|
||||||
|
{incident.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.started_at)}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isAdmin && incident.status === "active" && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
||||||
|
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{expanded && (
|
||||||
|
<tr className="bg-gray-900 border-b border-gray-800">
|
||||||
|
<td colSpan={7} className="px-6 py-3">
|
||||||
|
{incident.summary && (
|
||||||
|
<p className="text-sm text-gray-300 mb-2">{incident.summary}</p>
|
||||||
|
)}
|
||||||
|
{incident.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{incident.tags.map((t) => (
|
||||||
|
<span key={t} className="text-xs bg-gray-800 text-gray-400 px-2 py-0.5 rounded-full">{t}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-500 font-mono">
|
||||||
|
<span className="text-gray-400">Linked calls: </span>
|
||||||
|
{incident.call_ids.length === 0 ? "none" : incident.call_ids.map((id, i) => (
|
||||||
|
<span key={id}>
|
||||||
|
<a href={`/calls?highlight=${id}`} className="text-indigo-400 hover:underline">{id.slice(0, 8)}…</a>
|
||||||
|
{i < incident.call_ids.length - 1 && ", "}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateModal({ onClose, onCreate }: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (body: object) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [type, setType] = useState("other");
|
||||||
|
const [summary, setSummary] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onCreate({ title, type, summary: summary || null, status: "active" });
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-white font-bold">Create Incident</h2>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||||
|
<input
|
||||||
|
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
value={type} onChange={(e) => setType(e.target.value)}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none"
|
||||||
|
>
|
||||||
|
{["fire", "police", "ems", "accident", "other"].map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Summary (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={summary} onChange={(e) => setSummary(e.target.value)} rows={2}
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit" disabled={saving}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
|
||||||
|
>
|
||||||
|
{saving ? "Creating…" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IncidentsPage() {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { incidents, loading } = useIncidents();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const active = incidents.filter((i) => i.status === "active");
|
||||||
|
const resolved = incidents.filter((i) => i.status === "resolved");
|
||||||
|
|
||||||
|
async function handleResolve(id: string) {
|
||||||
|
try {
|
||||||
|
await c2api.updateIncident(id, { status: "resolved" });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(body: object) {
|
||||||
|
await c2api.createIncident(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto 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>
|
||||||
|
{active.length > 0 && (
|
||||||
|
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
|
||||||
|
{active.length} active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
+ Create Incident
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Active incidents */}
|
||||||
|
{active.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3">Title</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Calls</th>
|
||||||
|
<th className="px-4 py-3">Started</th>
|
||||||
|
<th className="px-4 py-3">Updated</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{active.map((inc) => (
|
||||||
|
<IncidentRow
|
||||||
|
key={inc.incident_id}
|
||||||
|
incident={inc}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onResolve={handleResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolved incidents */}
|
||||||
|
{resolved.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Resolved</h2>
|
||||||
|
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3">Title</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Calls</th>
|
||||||
|
<th className="px-4 py-3">Started</th>
|
||||||
|
<th className="px-4 py-3">Updated</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resolved.map((inc) => (
|
||||||
|
<IncidentRow
|
||||||
|
key={inc.incident_id}
|
||||||
|
incident={inc}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
onResolve={handleResolve}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incidents.length === 0 && (
|
||||||
|
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateModal onClose={() => setShowCreate(false)} onCreate={handleCreate} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signInWithEmailAndPassword } from "firebase/auth";
|
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
@@ -26,44 +26,78 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGoogle() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||||
|
router.push("/dashboard");
|
||||||
|
} catch {
|
||||||
|
setError("Google sign-in failed. Try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm mx-auto pt-16">
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
<form
|
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"
|
|
||||||
>
|
|
||||||
<h1 className="text-white text-lg font-bold">DRB Portal</h1>
|
<h1 className="text-white text-lg font-bold">DRB Portal</h1>
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<input
|
<div>
|
||||||
type="email"
|
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||||
value={email}
|
<input
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
type="email"
|
||||||
required
|
value={email}
|
||||||
autoComplete="email"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
required
|
||||||
/>
|
autoComplete="email"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
|
<span className="text-xs text-gray-500">or</span>
|
||||||
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
autoComplete="current-password"
|
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
|
onClick={handleGoogle}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 disabled:opacity-50 text-gray-900 rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? "Signing in…" : "Sign in"}
|
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/>
|
||||||
|
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.859-3.048.859-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="#34A853"/>
|
||||||
|
<path d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z" fill="#FBBC05"/>
|
||||||
|
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.962L3.964 6.294C4.672 4.169 6.656 3.58 9 3.58z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
|
|
||||||
// Leaflet is browser-only — must be dynamically imported with no SSR
|
// Leaflet is browser-only — must be dynamically imported with no SSR
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
@@ -10,6 +11,7 @@ const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
|||||||
export default function MapPage() {
|
export default function MapPage() {
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
|
const incidents = useActiveIncidents();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -20,6 +22,10 @@ export default function MapPage() {
|
|||||||
<span><span className="text-orange-400 animate-pulse">●</span> Recording</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-indigo-400">●</span> Unconfigured</span>
|
||||||
<span><span className="text-gray-600">●</span> Offline</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,7 +35,7 @@ export default function MapPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: "calc(100vh - 160px)" }}>
|
<div style={{ height: "calc(100vh - 160px)" }}>
|
||||||
<MapView nodes={nodes} activeCalls={activeCalls} />
|
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord } from "@/lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,40 +15,102 @@ function duration(started: string, ended: string | null): string {
|
|||||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAG_COLORS: Record<string, string> = {
|
||||||
|
fire: "bg-red-900 text-red-300",
|
||||||
|
police: "bg-blue-900 text-blue-300",
|
||||||
|
ems: "bg-yellow-900 text-yellow-300",
|
||||||
|
accident: "bg-orange-900 text-orange-300",
|
||||||
|
};
|
||||||
|
|
||||||
export function CallRow({ call, systemName }: Props) {
|
export function CallRow({ call, systemName }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
const isActive = call.status === "active";
|
const isActive = call.status === "active";
|
||||||
|
const hasDetails = call.transcript || (call.tags && call.tags.length > 0) || call.incident_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-gray-800 hover:bg-gray-900/50 font-mono text-sm">
|
<>
|
||||||
<td className="px-4 py-2 text-gray-400 text-xs">
|
<tr
|
||||||
{new Date(call.started_at).toLocaleTimeString()}
|
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
|
||||||
</td>
|
onClick={() => hasDetails && setExpanded((v) => !v)}
|
||||||
<td className="px-4 py-2 text-gray-300">
|
>
|
||||||
{call.talkgroup_name || call.talkgroup_id || "—"}
|
<td className="px-4 py-2 text-gray-400 text-xs">
|
||||||
</td>
|
{new Date(call.started_at).toLocaleTimeString()}
|
||||||
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
|
<td className="px-4 py-2 text-gray-300">
|
||||||
<td className="px-4 py-2">
|
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||||
{isActive ? (
|
{call.tags && call.tags.length > 0 && (
|
||||||
<span className="text-orange-400 animate-pulse">● live</span>
|
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}>
|
||||||
) : (
|
{call.tags[0]}
|
||||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
|
||||||
{call.audio_url ? (
|
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
|
||||||
<a
|
<td className="px-4 py-2">
|
||||||
href={call.audio_url}
|
{isActive ? (
|
||||||
target="_blank"
|
<span className="text-orange-400 animate-pulse">● live</span>
|
||||||
rel="noopener noreferrer"
|
) : (
|
||||||
className="text-blue-400 hover:text-blue-300 text-xs"
|
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
||||||
>
|
)}
|
||||||
audio
|
</td>
|
||||||
</a>
|
<td className="px-4 py-2">
|
||||||
) : (
|
{call.audio_url ? (
|
||||||
<span className="text-gray-700 text-xs">—</span>
|
<a
|
||||||
)}
|
href={call.audio_url}
|
||||||
</td>
|
target="_blank"
|
||||||
</tr>
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||||
|
>
|
||||||
|
audio
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-gray-600 text-xs">
|
||||||
|
{hasDetails && (expanded ? "▲" : "▼")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{expanded && hasDetails && (
|
||||||
|
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||||
|
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||||
|
{/* Tags */}
|
||||||
|
{call.tags && call.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{call.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Incident link */}
|
||||||
|
{call.incident_id && (
|
||||||
|
<p className="text-xs font-mono text-indigo-400">
|
||||||
|
Incident:{" "}
|
||||||
|
<a href="/incidents" className="underline hover:text-indigo-300">
|
||||||
|
{call.incident_id.slice(0, 8)}…
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transcript */}
|
||||||
|
{call.transcript ? (
|
||||||
|
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
||||||
|
{call.transcript}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
|
||||||
import L from "leaflet";
|
import L from "leaflet";
|
||||||
import type { NodeRecord, CallRecord } from "@/lib/types";
|
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||||
|
|
||||||
// Fix Leaflet default icon paths broken by webpack
|
// Fix Leaflet default icon paths broken by webpack
|
||||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
@@ -25,16 +25,45 @@ const nodeIcon = (status: string) =>
|
|||||||
iconAnchor: [7, 7],
|
iconAnchor: [7, 7],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const INCIDENT_COLORS: Record<string, string> = {
|
||||||
|
fire: "#ef4444",
|
||||||
|
police: "#3b82f6",
|
||||||
|
ems: "#eab308",
|
||||||
|
accident: "#f97316",
|
||||||
|
other: "#6b7280",
|
||||||
|
};
|
||||||
|
|
||||||
|
const incidentIcon = (type: string | null) => {
|
||||||
|
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="
|
||||||
|
width:16px;height:16px;border-radius:3px;
|
||||||
|
background:${color};border:2px solid #111827;
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
font-size:9px;color:#111827;font-weight:bold;line-height:1;
|
||||||
|
">!</div>`,
|
||||||
|
iconSize: [16, 16],
|
||||||
|
iconAnchor: [8, 8],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
activeCalls: CallRecord[];
|
activeCalls: CallRecord[];
|
||||||
|
incidents?: IncidentRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({ nodes, activeCalls }: Props) {
|
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||||
const activeByNode = Object.fromEntries(
|
const activeByNode = Object.fromEntries(
|
||||||
activeCalls.map((c) => [c.node_id, c])
|
activeCalls.map((c) => [c.node_id, c])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only show incidents that have coordinates
|
||||||
|
const mappableIncidents = incidents.filter(
|
||||||
|
(i) => i.location && i.location.lat != null && i.location.lng != null
|
||||||
|
);
|
||||||
|
|
||||||
const center: [number, number] =
|
const center: [number, number] =
|
||||||
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35];
|
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35];
|
||||||
|
|
||||||
@@ -49,6 +78,8 @@ export default function MapView({ nodes, activeCalls }: Props) {
|
|||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Node markers */}
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={node.node_id}
|
key={node.node_id}
|
||||||
@@ -70,6 +101,27 @@ export default function MapView({ nodes, activeCalls }: Props) {
|
|||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Incident markers */}
|
||||||
|
{mappableIncidents.map((inc) => (
|
||||||
|
<Marker
|
||||||
|
key={inc.incident_id}
|
||||||
|
position={[inc.location!.lat, inc.location!.lng]}
|
||||||
|
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"] }}>
|
||||||
|
{inc.type ?? "other"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 capitalize">{inc.status}</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>}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||||
|
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/nodes", label: "Nodes" },
|
{ href: "/nodes", label: "Nodes" },
|
||||||
{ href: "/systems", label: "Systems" },
|
{ href: "/systems", label: "Systems" },
|
||||||
{ href: "/calls", label: "Calls" },
|
{ href: "/calls", label: "Calls" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
|
{ href: "/map", label: "Map" },
|
||||||
|
{ href: "/alerts", label: "Alerts" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
@@ -21,17 +24,18 @@ export function Nav() {
|
|||||||
const { user, isAdmin, signOut } = useAuth();
|
const { user, isAdmin, signOut } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { nodes: pending } = useUnconfiguredNodes();
|
const { nodes: pending } = useUnconfiguredNodes();
|
||||||
|
const unackedAlerts = useUnacknowledgedAlerts();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6">
|
<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">DRB</span>
|
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
|
||||||
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className={`text-sm font-mono transition-colors ${
|
className={`text-sm font-mono transition-colors shrink-0 ${
|
||||||
pathname.startsWith(href)
|
pathname.startsWith(href)
|
||||||
? "text-white"
|
? "text-white"
|
||||||
: "text-gray-500 hover:text-gray-300"
|
: "text-gray-500 hover:text-gray-300"
|
||||||
@@ -43,9 +47,14 @@ export function Nav() {
|
|||||||
{pending.length}
|
{pending.length}
|
||||||
</span>
|
</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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
|||||||
@@ -55,4 +55,38 @@ export const c2api = {
|
|||||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||||
return request<unknown[]>(`/calls${qs}`);
|
return request<unknown[]>(`/calls${qs}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Incidents
|
||||||
|
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||||
|
const qs = params ? "?" + new URLSearchParams(params as Record<string, string>).toString() : "";
|
||||||
|
return request<unknown[]>(`/incidents${qs}`);
|
||||||
|
},
|
||||||
|
getIncident: (id: string) => request<unknown>(`/incidents/${id}`),
|
||||||
|
createIncident: (body: object) =>
|
||||||
|
request("/incidents", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateIncident: (id: string, body: object) =>
|
||||||
|
request(`/incidents/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
deleteIncident: (id: string) =>
|
||||||
|
request(`/incidents/${id}`, { method: "DELETE" }),
|
||||||
|
linkCallToIncident: (incidentId: string, callId: string) =>
|
||||||
|
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
getAlerts: (acknowledged?: boolean) => {
|
||||||
|
const qs = acknowledged !== undefined ? `?acknowledged=${acknowledged}` : "";
|
||||||
|
return request<unknown[]>(`/alerts${qs}`);
|
||||||
|
},
|
||||||
|
acknowledgeAlert: (id: string) =>
|
||||||
|
request(`/alerts/${id}/acknowledge`, { method: "POST" }),
|
||||||
|
getAlertRules: () => request<unknown[]>("/alert-rules"),
|
||||||
|
createAlertRule: (body: object) =>
|
||||||
|
request("/alert-rules", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateAlertRule: (id: string, body: object) =>
|
||||||
|
request(`/alert-rules/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
deleteAlertRule: (id: string) =>
|
||||||
|
request(`/alert-rules/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
// Node key management
|
||||||
|
reissueNodeKey: (nodeId: string) =>
|
||||||
|
request(`/nodes/${nodeId}/reissue-key`, { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,3 +49,27 @@ export interface IncidentRecord {
|
|||||||
summary: string | null;
|
summary: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AlertRule {
|
||||||
|
rule_id: string;
|
||||||
|
name: string;
|
||||||
|
keywords: string[];
|
||||||
|
talkgroup_ids: number[];
|
||||||
|
enabled: boolean;
|
||||||
|
discord_webhook: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlertEvent {
|
||||||
|
alert_id: string;
|
||||||
|
rule_id: string;
|
||||||
|
rule_name: string;
|
||||||
|
call_id: string;
|
||||||
|
node_id: string;
|
||||||
|
talkgroup_id: number | null;
|
||||||
|
talkgroup_name: string | null;
|
||||||
|
matched_keywords: string[];
|
||||||
|
transcript_snippet: string | null;
|
||||||
|
triggered_at: string;
|
||||||
|
acknowledged: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
||||||
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import type { AlertEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
const toISO = (v: unknown): string =>
|
||||||
|
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
||||||
|
(typeof v === "string" ? v : new Date().toISOString());
|
||||||
|
|
||||||
|
export function useAlerts(limitCount = 50) {
|
||||||
|
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
setAlerts([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query(
|
||||||
|
collection(db, "alert_events"),
|
||||||
|
orderBy("triggered_at", "desc"),
|
||||||
|
limit(limitCount)
|
||||||
|
);
|
||||||
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
|
setAlerts(snap.docs.map((d) => {
|
||||||
|
const data = d.data();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
triggered_at: toISO(data.triggered_at),
|
||||||
|
} as AlertEvent;
|
||||||
|
}));
|
||||||
|
setLoading(false);
|
||||||
|
}, (err: FirestoreError) => {
|
||||||
|
console.error("useAlerts:", err);
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubAuth();
|
||||||
|
if (unsubFirestore) unsubFirestore();
|
||||||
|
};
|
||||||
|
}, [limitCount]);
|
||||||
|
|
||||||
|
return { alerts, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUnacknowledgedAlerts() {
|
||||||
|
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
setAlerts([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query(
|
||||||
|
collection(db, "alert_events"),
|
||||||
|
where("acknowledged", "==", false),
|
||||||
|
orderBy("triggered_at", "desc"),
|
||||||
|
limit(100)
|
||||||
|
);
|
||||||
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
|
setAlerts(snap.docs.map((d) => {
|
||||||
|
const data = d.data();
|
||||||
|
return { ...data, triggered_at: toISO(data.triggered_at) } as AlertEvent;
|
||||||
|
}));
|
||||||
|
}, (err: FirestoreError) => { console.error("useUnacknowledgedAlerts:", err); });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubAuth();
|
||||||
|
if (unsubFirestore) unsubFirestore();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
||||||
|
import { onAuthStateChanged } from "firebase/auth";
|
||||||
|
import { db, auth } from "@/lib/firebase";
|
||||||
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
const toISO = (v: unknown): string =>
|
||||||
|
(v as { toDate?: () => Date })?.toDate?.()?.toISOString?.() ??
|
||||||
|
(typeof v === "string" ? v : new Date().toISOString());
|
||||||
|
|
||||||
|
export function useIncidents(limitCount = 100) {
|
||||||
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
setIncidents([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query(
|
||||||
|
collection(db, "incidents"),
|
||||||
|
orderBy("started_at", "desc"),
|
||||||
|
limit(limitCount)
|
||||||
|
);
|
||||||
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
|
setIncidents(snap.docs.map((d) => {
|
||||||
|
const data = d.data();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
started_at: toISO(data.started_at),
|
||||||
|
updated_at: toISO(data.updated_at),
|
||||||
|
} as IncidentRecord;
|
||||||
|
}));
|
||||||
|
setLoading(false);
|
||||||
|
}, (err: FirestoreError) => {
|
||||||
|
console.error("useIncidents:", err);
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubAuth();
|
||||||
|
if (unsubFirestore) unsubFirestore();
|
||||||
|
};
|
||||||
|
}, [limitCount]);
|
||||||
|
|
||||||
|
return { incidents, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActiveIncidents() {
|
||||||
|
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
|
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||||
|
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
setIncidents([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = query(collection(db, "incidents"), where("status", "==", "active"));
|
||||||
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
|
setIncidents(snap.docs.map((d) => {
|
||||||
|
const data = d.data();
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
started_at: toISO(data.started_at),
|
||||||
|
updated_at: toISO(data.updated_at),
|
||||||
|
} as IncidentRecord;
|
||||||
|
}));
|
||||||
|
}, (err: FirestoreError) => { console.error("useActiveIncidents:", err); });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubAuth();
|
||||||
|
if (unsubFirestore) unsubFirestore();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return incidents;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user