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
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">Duration</th>
|
||||
<th className="px-4 py-2 text-left">Audio</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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