Massive update

This commit is contained in:
Logan
2026-04-11 13:44:08 -04:00
parent fd6c2fd8bf
commit 3b3a136d04
31 changed files with 1919 additions and 94 deletions
+17
View File
@@ -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
View File
@@ -4,8 +4,17 @@ services:
restart: unless-stopped
ports:
- "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:
- ./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:
build: ./drb-c2-core
@@ -33,3 +42,6 @@ services:
env_file: ./drb-frontend/.env
depends_on:
- c2-core
volumes:
mosquitto_data:
+4 -2
View File
@@ -1,8 +1,10 @@
# MQTT broker (usually the mosquitto container on this host)
MQTT_BROKER=mosquitto
MQTT_PORT=1883
MQTT_USER=
MQTT_PASS=
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the
# 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_CREDENTIALS_PATH=/app/gcp-key.json
+3
View File
@@ -20,6 +20,9 @@ class Settings(BaseSettings):
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
service_key: Optional[str] = None
# CORS — comma-separated list of allowed origins, or "*" for all
cors_origins: list[str] = ["*"]
class Config:
env_file = ".env"
+124
View File
@@ -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
+106
View File
@@ -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
+16
View File
@@ -33,6 +33,7 @@ class MQTTHandler:
client.subscribe("nodes/+/checkin", qos=1)
client.subscribe("nodes/+/status", qos=1)
client.subscribe("nodes/+/metadata", qos=1)
client.subscribe("nodes/+/key_request", qos=1)
logger.info("MQTT connected — subscribed to node topics.")
else:
logger.error(f"MQTT connect refused: {reason_code}")
@@ -68,6 +69,8 @@ class MQTTHandler:
await self._handle_status(node_id, payload)
elif msg_type == "metadata":
await self._handle_metadata(node_id, payload)
elif msg_type == "key_request":
await self._handle_key_request(node_id)
except Exception as 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)
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
# ------------------------------------------------------------------
+70
View File
@@ -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
+6 -2
View File
@@ -5,8 +5,9 @@ from fastapi.middleware.cors import CORSMiddleware
from app.internal.logger import logger
from app.internal.mqtt_handler import mqtt_handler
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.routers import nodes, systems, calls, upload, tokens
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts
@asynccontextmanager
@@ -27,15 +28,18 @@ app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=settings.cors_origins,
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True,
)
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(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
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
+54
View File
@@ -78,3 +78,57 @@ class IncidentRecord(BaseModel):
updated_at: datetime
summary: Optional[str] = None
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
+74
View File
@@ -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}
+83
View File
@@ -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}
+13
View File
@@ -66,6 +66,19 @@ async def send_command(node_id: str, cmd: CommandPayload):
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}")
async def assign_system(node_id: str, system_id: str):
"""
+92 -3
View File
@@ -1,5 +1,5 @@
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 app.internal.storage import upload_audio
from app.internal import firestore as fstore
@@ -12,20 +12,32 @@ _bearer = HTTPBearer(auto_error=False)
@router.post("/upload")
async def upload_call_audio(
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
call_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),
):
"""
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
if not credentials:
raise HTTPException(401, "Missing authorization")
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")
data = await file.read()
@@ -41,4 +53,81 @@ async def upload_call_audio(
except Exception as 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}
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,
)
+19
View File
@@ -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/#
+32
View File
@@ -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
+9 -2
View File
@@ -1,8 +1,15 @@
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_location /mosquitto/data/
log_dest stdout
log_type error
log_type warning
log_type notice
+2
View File
@@ -4,6 +4,8 @@ pydantic-settings
paho-mqtt>=2.0.0
firebase-admin
google-cloud-storage
google-cloud-speech
httpx
python-multipart
pytest
pytest-asyncio
+289
View File
@@ -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>
);
}
+1
View File
@@ -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">Duration</th>
<th className="px-4 py-2 text-left">Audio</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
+285
View File
@@ -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>
);
}
+39 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { signInWithEmailAndPassword } from "firebase/auth";
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
import { auth } from "@/lib/firebase";
import { useRouter } from "next/navigation";
@@ -26,13 +26,25 @@ 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 (
<div className="max-w-sm mx-auto pt-16">
<form
onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono"
>
<div 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>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-gray-400 block mb-1">Email</label>
<input
@@ -64,6 +76,28 @@ export default function LoginPage() {
{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>
<button
type="button"
onClick={handleGoogle}
disabled={loading}
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"
>
<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>
</div>
</div>
);
}
+7 -1
View File
@@ -3,6 +3,7 @@
import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes";
import { useActiveCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents";
// Leaflet is browser-only — must be dynamically imported with no SSR
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
@@ -10,6 +11,7 @@ const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
export default function MapPage() {
const { nodes, loading } = useNodes();
const activeCalls = useActiveCalls();
const incidents = useActiveIncidents();
return (
<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-indigo-400"></span> Unconfigured</span>
<span><span className="text-gray-600"></span> Offline</span>
<span className="border-l border-gray-700 pl-4"><span className="text-red-500"></span> Fire</span>
<span><span className="text-blue-500"></span> Police</span>
<span><span className="text-yellow-500"></span> EMS</span>
<span><span className="text-orange-500"></span> Accident</span>
</div>
</div>
@@ -29,7 +35,7 @@ export default function MapPage() {
</div>
) : (
<div style={{ height: "calc(100vh - 160px)" }}>
<MapView nodes={nodes} activeCalls={activeCalls} />
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
</div>
)}
</div>
+67 -2
View File
@@ -1,3 +1,6 @@
"use client";
import { useState } from "react";
import type { CallRecord } from "@/lib/types";
interface Props {
@@ -12,16 +15,34 @@ function duration(started: string, ended: string | null): string {
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) {
const [expanded, setExpanded] = useState(false);
const isActive = call.status === "active";
const hasDetails = call.transcript || (call.tags && call.tags.length > 0) || call.incident_id;
return (
<tr className="border-b border-gray-800 hover:bg-gray-900/50 font-mono text-sm">
<>
<tr
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
onClick={() => hasDetails && setExpanded((v) => !v)}
>
<td className="px-4 py-2 text-gray-400 text-xs">
{new Date(call.started_at).toLocaleTimeString()}
</td>
<td className="px-4 py-2 text-gray-300">
{call.talkgroup_name || call.talkgroup_id || "—"}
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
{call.tags && call.tags.length > 0 && (
<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>
)}
</td>
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
@@ -38,6 +59,7 @@ export function CallRow({ call, systemName }: Props) {
href={call.audio_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-400 hover:text-blue-300 text-xs"
>
audio
@@ -46,6 +68,49 @@ export function CallRow({ call, systemName }: Props) {
<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>
)}
</>
);
}
+56 -4
View File
@@ -1,9 +1,9 @@
"use client";
import { useEffect } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import { MapContainer, TileLayer, Marker, Popup } from "react-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
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
L.Icon.Default.mergeOptions({
@@ -25,16 +25,45 @@ const nodeIcon = (status: string) =>
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 {
nodes: NodeRecord[];
activeCalls: CallRecord[];
incidents?: IncidentRecord[];
}
export default function MapView({ nodes, activeCalls }: Props) {
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
const activeByNode = Object.fromEntries(
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] =
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"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
/>
{/* Node markers */}
{nodes.map((node) => (
<Marker
key={node.node_id}
@@ -70,6 +101,27 @@ export default function MapView({ nodes, activeCalls }: Props) {
</Popup>
</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>
);
}
+13 -4
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider";
const links = [
@@ -10,7 +11,9 @@ const links = [
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/calls", label: "Calls" },
{ href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" },
];
const adminLinks = [
@@ -21,17 +24,18 @@ export function Nav() {
const { user, isAdmin, signOut } = useAuth();
const pathname = usePathname();
const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts();
if (!user) return null;
return (
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6">
<span className="font-mono font-bold text-white tracking-tight mr-4">DRB</span>
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
<Link
key={href}
href={href}
className={`text-sm font-mono transition-colors ${
className={`text-sm font-mono transition-colors shrink-0 ${
pathname.startsWith(href)
? "text-white"
: "text-gray-500 hover:text-gray-300"
@@ -43,9 +47,14 @@ export function Nav() {
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
<div className="ml-auto">
<div className="ml-auto shrink-0">
<button
onClick={signOut}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
+34
View File
@@ -55,4 +55,38 @@ export const c2api = {
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
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" }),
};
+24
View File
@@ -49,3 +49,27 @@ export interface IncidentRecord {
summary: string | null;
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;
}
+95
View File
@@ -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;
}
+95
View File
@@ -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;
}