Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57ff9f8ea3 | |||
| 9fdcad1c46 | |||
| 33700448bf | |||
| 3defdf18dc | |||
| 1f17b6c0d2 | |||
| 961cc6f36e | |||
| d290b89736 | |||
| 758c6f4115 | |||
| 6ae4d398f8 | |||
| 981f03ac06 | |||
| 47430827d4 | |||
| 4dd3343026 | |||
| fce189d8c9 | |||
| 3fb3bca034 | |||
| a0fdf2486e | |||
| e7622c7e6d | |||
| 21d15d0426 | |||
| 21268ab477 | |||
| 522748f07a | |||
| af4079d648 | |||
| 39c002d090 | |||
| 4295bdf4d2 | |||
| 18d96193ab | |||
| a1c91c5ed3 | |||
| f0a0ea508a | |||
| d64259bb18 | |||
| 7b9aefbcc5 | |||
| 8edb717dd2 | |||
| fb096d582d | |||
| a4962d7b0e | |||
| 4e0e0fc79f | |||
| e55412d8c7 | |||
| 9842b18799 | |||
| fe6bf55c0e | |||
| f65873d690 | |||
| 913fe0cbee | |||
| 032eef311f | |||
| 3d51db80d0 | |||
| 683b05beb1 | |||
| cbcc85f7b1 | |||
| 6bf4333b72 | |||
| b77d2cce36 | |||
| f774be12b8 | |||
| 5eed4e08ce | |||
| fa5c53891c | |||
| c5932165d8 | |||
| 84ab72442f | |||
| adf10244b4 | |||
| 34ca1d0baf | |||
| 7d6e97fd4a | |||
| ef8e0d1bfa | |||
| 8a668e6a59 | |||
| dbacd9a9a8 | |||
| a6d841b280 | |||
| 96bba45ffa | |||
| 6a9fe5d26f | |||
| 0279a82b10 | |||
| 0db09d6bf7 | |||
| 4b7d9dd49a | |||
| 7dd090e8b2 | |||
| 92c9d8effc | |||
| 1071bcd3e8 | |||
| 4fc44dcc86 | |||
| 6397e24035 | |||
| 5a18a66d77 | |||
| 35ce8e911e | |||
| 9cf8fd4221 | |||
| 0ceb0227c8 | |||
| fc993fdfe6 | |||
| 9d73fc52fa | |||
| 97ed691cd2 | |||
| bcc3d3406d | |||
| 4006232c85 | |||
| 4c3b1fcc84 | |||
| 8b660d8e10 | |||
| 7e1b01a275 | |||
| 97f4286810 | |||
| e704df1a62 | |||
| f6897566f8 | |||
| 531ce64eeb | |||
| f8a9cda27e | |||
| 640667c9f9 | |||
| 5f83194420 | |||
| c959437059 | |||
| 92c8351864 | |||
| 64232279ca | |||
| 317f9d2a9d | |||
| bcd3406ae8 | |||
| e70e7c0be9 | |||
| 88103c8011 |
@@ -0,0 +1,89 @@
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
# REGISTRY secret = "git.vpn.cusano.net/logan" (full image prefix)
|
||||
REGISTRY: ${{ secrets.REGISTRY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & push images
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.vpn.cusano.net
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.BUILD_TOKEN }}
|
||||
|
||||
- name: Build & push c2-core
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./drb-c2-core
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/c2-core:latest
|
||||
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
||||
|
||||
- name: Build & push discord-bot
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./drb-server-discord-bot
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/discord-bot:latest
|
||||
${{ env.REGISTRY }}/discord-bot:${{ gitea.sha }}
|
||||
|
||||
- name: Build & push frontend
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./drb-frontend
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/frontend:latest
|
||||
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to VM
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Write SSH key
|
||||
run: |
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||
-i /tmp/deploy_key \
|
||||
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||
set -e
|
||||
cd /opt/drb
|
||||
|
||||
# Update compose files + mosquitto config
|
||||
git pull origin main
|
||||
|
||||
# Pull pre-built images and restart (no build on the VM)
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --remove-orphans
|
||||
docker image prune -f
|
||||
ENDSSH
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 20
|
||||
curl -f https://api.${{ secrets.DRB_DOMAIN }}/health || \
|
||||
(echo "Health check failed" && exit 1)
|
||||
+12
@@ -5,6 +5,18 @@ drb-server-discord-bot/.env
|
||||
drb-frontend/.env
|
||||
drb-c2-core/gcp-key.json
|
||||
|
||||
# Terraform
|
||||
infra/.terraform/
|
||||
infra/terraform.tfstate
|
||||
infra/terraform.tfstate.backup
|
||||
infra/terraform.tfstate.*.backup
|
||||
infra/.terraform.lock.hcl
|
||||
infra/terraform.tfvars
|
||||
infra/tf.log
|
||||
infra/ansible/inventory.ini
|
||||
infra/ansible/group_vars/all.yml
|
||||
infra/ansible/vault.yml
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
# DRB Server
|
||||
|
||||
Full-stack backend for the Discord Radio Bot (DRB) system. Receives telemetry from SDR edge nodes over MQTT, stores data in Firestore, orchestrates Discord voice bots through a token pool, and serves a real-time admin dashboard.
|
||||
Full-stack backend for the DRB (Distributed Radio Bot) platform — a community-powered, real-time situational awareness system for public safety radio monitoring. Receives telemetry and audio from SDR edge nodes, runs an AI intelligence pipeline on every call, correlates calls into incidents, dispatches alerts, and serves a live operational dashboard.
|
||||
|
||||
## What DRB Is
|
||||
|
||||
DRB turns radio scanners into a shared geographic intelligence picture. Anyone can contribute coverage by running an edge node (an RTL-SDR dongle on a small Linux machine). All nodes feed into a single platform where operators can:
|
||||
|
||||
- **Monitor in real time** — see active incidents on a map as they unfold, with calls grouped by incident as they come in
|
||||
- **Listen live** — stream audio to Discord voice channels or directly in the browser
|
||||
- **Get alerted** — receive push notifications when significant events are detected
|
||||
- **Investigate after the fact** — search transcribed call history, replay audio, and review incident timelines
|
||||
|
||||
The intended deployment is a Tactical Operations Center (TOC) display: a map showing active incidents across all covered jurisdictions, with live audio and alert feeds for operators monitoring an area during an event (severe weather, major incident, etc.).
|
||||
|
||||
## System Overview
|
||||
|
||||
DRB is a distributed SDR (Software Defined Radio) monitoring platform. Edge nodes (small Linux machines with RTL-SDR dongles) decode radio systems and stream audio. The server coordinates those nodes, manages which Discord voice channels receive audio, and stores call history.
|
||||
DRB is a distributed SDR (Software Defined Radio) monitoring platform. Edge nodes (small Linux machines with RTL-SDR dongles) decode radio systems and stream audio. The server coordinates those nodes, runs the intelligence pipeline on each call, manages Discord voice bot assignments, and serves the operational dashboard.
|
||||
|
||||
```
|
||||
Edge Node (client machine)
|
||||
@@ -28,9 +39,10 @@ Browser (admin)
|
||||
| Service | Container | Description | Port |
|
||||
|---|---|---|---|
|
||||
| `mosquitto` | `eclipse-mosquitto` | MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
|
||||
| `c2-core` | Python 3.11 / FastAPI | Command & control API — MQTT subscriber, Firestore writes, node/system/call/token management | 8000 |
|
||||
| `mosquitto` | `eclipse-mosquitto` | MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
|
||||
| `c2-core` | Python 3.11 / FastAPI | Command & control API — MQTT handler, intelligence pipeline, incident correlator, alert dispatch, node/system/token management | 8000 |
|
||||
| `discord-bot` | Python 3.11 / discord.py | Server-side Discord slash command bot — `/join`, `/leave`, `/status`, `/help` | — |
|
||||
| `frontend` | Node.js / Next.js 14 | Admin web dashboard — real-time node map, call logs, system & token management | 3000 |
|
||||
| `frontend` | Node.js / Next.js 14 | Operational web dashboard — live map, incident feed, call history, node and system management | 3000 |
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -49,19 +61,19 @@ Server/
|
||||
│ │ │ ├── systems.py # Radio system CRUD (P25/DMR/NBFM configs)
|
||||
│ │ │ ├── calls.py # Call log retrieval (read-only — calls created by MQTT handler)
|
||||
│ │ │ ├── tokens.py # Discord bot token pool — add/delete/list; assign_token/release_token helpers
|
||||
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes)
|
||||
│ │ │ ├── incidents.py # [PLANNED] Incident CRUD, manual link/unlink calls, resolve
|
||||
│ │ │ └── alerts.py # [PLANNED] Alert rule CRUD
|
||||
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes); triggers intelligence pipeline
|
||||
│ │ │ ├── incidents.py # Incident CRUD, manual link/unlink calls, resolve
|
||||
│ │ │ └── alerts.py # Alert rule CRUD
|
||||
│ │ └── internal/
|
||||
│ │ ├── auth.py # Auth — Firebase ID token OR service key (Bearer)
|
||||
│ │ ├── firestore.py # Async Firestore wrappers (doc_get, doc_set, doc_update, collection_list)
|
||||
│ │ ├── mqtt_handler.py # MQTT subscriber — call_start/call_end handlers, node checkin/status
|
||||
│ │ ├── node_sweeper.py # Background task — marks nodes offline after 90s without heartbeat
|
||||
│ │ ├── storage.py # GCS audio file uploads
|
||||
│ │ ├── transcription.py # [PLANNED] STT pipeline — Whisper or Google Speech API
|
||||
│ │ ├── intelligence.py # [PLANNED] Keyword/entity extraction, severity scoring
|
||||
│ │ ├── incident_correlator.py # [PLANNED] Match calls to incidents or create new ones
|
||||
│ │ └── alerter.py # [PLANNED] Alert dispatch (Discord webhook, push, etc.)
|
||||
│ │ ├── transcription.py # STT pipeline — OpenAI Whisper / GPT-4o transcribe
|
||||
│ │ ├── intelligence.py # GPT scene extraction: tags, incident type, location, units, vehicles; geocoding
|
||||
│ │ ├── incident_correlator.py # Hybrid correlator — matches calls to incidents or creates new ones
|
||||
│ │ └── alerter.py # Alert dispatch — evaluates rules, sends notifications
|
||||
│ ├── scripts/
|
||||
│ │ └── set_admin.py # CLI to grant/revoke Firebase admin custom claim
|
||||
│ ├── gcp-key.json # GCP service account key — place here, NOT committed to git
|
||||
@@ -80,13 +92,13 @@ Server/
|
||||
└── drb-frontend/ # Next.js 14 admin dashboard
|
||||
├── app/
|
||||
│ ├── dashboard/ # Overview: active node grid + live call feed
|
||||
│ ├── map/ # Leaflet map — node locations + [PLANNED] incident pins
|
||||
│ ├── calls/ # Call history — talkgroup, duration, audio, transcript (when populated)
|
||||
│ ├── map/ # Leaflet map — node locations, active incident pins
|
||||
│ ├── calls/ # Call history — talkgroup, duration, audio playback, transcript
|
||||
│ ├── nodes/ # Node list + per-node detail (approve, reject, assign system)
|
||||
│ ├── systems/ # Radio system CRUD (P25 form with talkgroup editor, DMR/NBFM JSON)
|
||||
│ ├── tokens/ # Discord bot token pool management
|
||||
│ ├── incidents/ # [PLANNED] Incident list/detail — linked calls, location, resolve
|
||||
│ ├── alerts/ # [PLANNED] Alert rule configuration
|
||||
│ ├── incidents/ # Incident list/detail — linked calls, location, summary, resolve
|
||||
│ ├── alerts/ # Alert rule configuration
|
||||
│ └── login/ # Firebase Auth login page
|
||||
├── components/
|
||||
│ ├── MapView.tsx # react-leaflet map with status-colored markers
|
||||
@@ -218,138 +230,60 @@ Edge nodes join Discord voice channels using bot tokens managed by the server. A
|
||||
|
||||
## Call & Intelligence Pipeline
|
||||
|
||||
This is the full intended data lifecycle for every radio call — from raw RF to searchable, cross-referenced intelligence. The data models and Firestore schema are already designed for this; several pipeline stages are stubs awaiting implementation.
|
||||
|
||||
### Call lifecycle (current — fully working)
|
||||
Every radio call flows through a four-stage pipeline triggered when the edge node uploads its recording:
|
||||
|
||||
```
|
||||
Edge node ──► MQTT call_start ──► c2-core creates CallRecord (status: active)
|
||||
│ talkgroup_id, talkgroup_name, freq,
|
||||
│ node_id, system_id, started_at
|
||||
▼
|
||||
Firestore "calls" collection
|
||||
Edge node ──► MQTT call_start ──► CallRecord created (active)
|
||||
│
|
||||
Edge node ──► audio upload ──► GCS storage
|
||||
│
|
||||
▼
|
||||
Frontend live call feed / map popups
|
||||
[1] TRANSCRIPTION
|
||||
OpenAI Whisper / GPT-4o transcribe
|
||||
→ CallRecord.transcript
|
||||
|
||||
│
|
||||
Edge node ──► MQTT call_end ──► c2-core updates CallRecord (status: ended)
|
||||
│ ended_at, audio_url (GCS link)
|
||||
▼
|
||||
Frontend call history / audio playback
|
||||
[2] INTELLIGENCE EXTRACTION (GPT-4o-mini)
|
||||
Scene detection — splits multi-incident recordings
|
||||
Speaker role inference — dispatch vs. unit patterns
|
||||
used to correctly attribute locations (dispatch-
|
||||
provided address vs. unit position report) and
|
||||
units (being dispatched vs. acknowledging)
|
||||
Entity extraction: tags, incident_type, location,
|
||||
units, vehicles, severity, resolved flag
|
||||
+ geocoding (Google Maps)
|
||||
+ embedding (text-embedding-3-small)
|
||||
→ CallRecord.tags, .location, .units, etc.
|
||||
|
||||
│
|
||||
▼
|
||||
[3] INCIDENT CORRELATION (hybrid engine)
|
||||
Fast path — talkgroup + recency
|
||||
Unit path — same officer continuity
|
||||
Location — proximity match
|
||||
Cross-TG — multi-agency / channel hop
|
||||
Slow path — embedding similarity
|
||||
→ IncidentRecord created or updated
|
||||
|
||||
│
|
||||
▼
|
||||
[4] ALERT DISPATCH
|
||||
Evaluate alert rules (keywords, talkgroups)
|
||||
→ notifications sent
|
||||
```
|
||||
|
||||
### Intelligence pipeline (designed — implementation pending)
|
||||
|
||||
After a call ends, the following stages should fire in order:
|
||||
|
||||
```
|
||||
CallRecord (ended, audio_url set)
|
||||
│
|
||||
▼
|
||||
[1] TRANSCRIPTION
|
||||
Speech-to-text on the GCS audio file (Whisper or Google Speech-to-Text)
|
||||
→ writes CallRecord.transcript
|
||||
|
||||
│
|
||||
▼
|
||||
[2] INTELLIGENCE EXTRACTION
|
||||
Analyze transcript for:
|
||||
- Named entities: unit IDs, street addresses, location references
|
||||
- Keywords / keyword sets: fire/EMS/police/hazmat/pursuit/shots fired/etc.
|
||||
- Radio codes (10-codes, signals) mapped to plain English
|
||||
- Severity scoring
|
||||
→ writes CallRecord.tags[], CallRecord.location (geocoded if address found)
|
||||
|
||||
│
|
||||
▼
|
||||
[3] INCIDENT CORRELATION
|
||||
Given the extracted entities + tags, decide:
|
||||
- Does this call match an existing active IncidentRecord?
|
||||
(same location ± radius, overlapping tags, recent time window)
|
||||
→ link: append CallRecord.call_id to IncidentRecord.call_ids,
|
||||
set CallRecord.incident_id, update IncidentRecord.summary
|
||||
- Or does this call describe a new event?
|
||||
→ create IncidentRecord (type, location, title, tags, status: active)
|
||||
link the call, set CallRecord.incident_id
|
||||
|
||||
│
|
||||
▼
|
||||
[4] ALERTS
|
||||
If incident is new OR severity exceeds threshold:
|
||||
- Trigger configured alert channels (Discord webhook, push notification, etc.)
|
||||
- Include: incident type, location, talkgroup, transcript excerpt
|
||||
```
|
||||
|
||||
### Data model fields involved
|
||||
|
||||
**`CallRecord`** (Firestore collection: `calls`):
|
||||
|
||||
| Field | Type | Populated by |
|
||||
|---|---|---|
|
||||
| `call_id` | string | MQTT handler on call_start |
|
||||
| `node_id` | string | MQTT handler |
|
||||
| `system_id` | string | MQTT handler (from node's assigned system) |
|
||||
| `talkgroup_id` | number | MQTT handler |
|
||||
| `talkgroup_name` | string | MQTT handler |
|
||||
| `freq` | number | MQTT handler |
|
||||
| `started_at` | timestamp | MQTT handler |
|
||||
| `ended_at` | timestamp | MQTT handler on call_end |
|
||||
| `status` | `active` \| `ended` | MQTT handler |
|
||||
| `audio_url` | string | Edge node upload → GCS |
|
||||
| `transcript` | string \| null | **[stub]** Transcription pipeline |
|
||||
| `incident_id` | string \| null | **[stub]** Incident correlation |
|
||||
| `location` | `{lat, lng}` \| null | **[stub]** Geocoding from transcript |
|
||||
| `tags` | string[] | **[stub]** Intelligence extraction |
|
||||
|
||||
**`IncidentRecord`** (Firestore collection: `incidents`):
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `incident_id` | string | UUID |
|
||||
| `title` | string | Auto-generated or manually set |
|
||||
| `type` | string | `fire`, `ems`, `police`, `hazmat`, `pursuit`, etc. |
|
||||
| `status` | `active` \| `resolved` | Updated as calls accumulate or manually resolved |
|
||||
| `location` | `{lat, lng}` | Geocoded from first call with a location |
|
||||
| `call_ids` | string[] | All linked CallRecord IDs |
|
||||
| `started_at` | timestamp | Timestamp of first linked call |
|
||||
| `updated_at` | timestamp | Updated on each new linked call |
|
||||
| `summary` | string \| null | Auto-generated from transcripts or manually written |
|
||||
| `tags` | string[] | Union of tags from all linked calls |
|
||||
|
||||
### Backend — what needs to be built
|
||||
|
||||
The following do **not exist yet** and need to be created:
|
||||
|
||||
- `drb-c2-core/app/routers/incidents.py` — CRUD + manual incident management
|
||||
- `drb-c2-core/app/routers/alerts.py` — Alert rule configuration
|
||||
- `drb-c2-core/app/internal/transcription.py` — STT integration (Whisper local or Google Speech API)
|
||||
- `drb-c2-core/app/internal/intelligence.py` — Keyword/entity extraction, severity scoring
|
||||
- `drb-c2-core/app/internal/incident_correlator.py` — Match calls to incidents or create new ones
|
||||
- `drb-c2-core/app/internal/alerter.py` — Dispatch alerts (Discord webhook, etc.)
|
||||
|
||||
The `_on_call_end()` handler in `mqtt_handler.py` is the natural trigger point — after updating the CallRecord, it should enqueue the transcription + intelligence pipeline.
|
||||
|
||||
### Frontend — what needs to be built
|
||||
|
||||
The following pages and nav items are not yet implemented:
|
||||
|
||||
- `/incidents` — Incident list and detail view (linked calls, map pin, transcript summary, tags, resolve button)
|
||||
- `/alerts` — Alert rule configuration (keyword sets, talkgroup filters, notification channels)
|
||||
- `Nav.tsx` should add **Calls** and **Incidents** as primary nav items; the current Calls page exists but isn't in the design's intended nav hierarchy
|
||||
|
||||
The **Map** page should eventually show both node markers and incident pins — incidents with `location` set should appear as color-coded markers (by type/severity) alongside the node status markers.
|
||||
|
||||
## Frontend Pages
|
||||
|
||||
| Page | URL | Status | Description |
|
||||
|---|---|---|---|
|
||||
| Dashboard | `/dashboard` | Working | Live node grid + active call stream |
|
||||
| Map | `/map` | Working | Leaflet map — nodes color-coded by status, active call popups |
|
||||
| Calls | `/calls` | Working | Full call history — talkgroup, duration, audio playback, transcript (when populated) |
|
||||
| Nodes | `/nodes` | Working | Node list; per-node detail for approve/reject/assign system |
|
||||
| Systems | `/systems` | Working | Create and manage P25/DMR/NBFM radio system configurations |
|
||||
| Tokens | `/tokens` | Working | Discord bot token pool management |
|
||||
| Incidents | `/incidents` | **Not built** | Incident list/detail — linked calls, location, summary, tags, resolve |
|
||||
| Page | URL | Description |
|
||||
|---|---|---|
|
||||
| Dashboard | `/dashboard` | Live node grid + active call stream |
|
||||
| Map | `/map` | Leaflet map — nodes color-coded by status, active call popups |
|
||||
| Calls | `/calls` | Full call history — talkgroup, duration, audio playback, transcript |
|
||||
| Nodes | `/nodes` | Node list; per-node detail for approve/reject/assign system |
|
||||
| Systems | `/systems` | Create and manage P25/DMR/NBFM radio system configurations |
|
||||
| Tokens | `/tokens` | Discord bot token pool management |
|
||||
| Incidents | `/incidents` | Incident list/detail — linked calls, location, summary, tags, resolve |
|
||||
| Alerts | `/alerts` | **Not built** | Alert rule configuration — keywords, talkgroups, notification channels |
|
||||
|
||||
## Makefile Targets
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Production overrides — used on the VM.
|
||||
# Run with: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Differences from dev:
|
||||
# - MQTT port 1883 is NOT published to the host (stays on the Docker bridge).
|
||||
# Edge nodes reach it via WireGuard tunnel to the Docker bridge IP.
|
||||
# - c2-core and frontend ports are only bound to localhost (Caddy proxies them).
|
||||
# - restart: always (instead of unless-stopped) for hard reboots.
|
||||
|
||||
services:
|
||||
mosquitto:
|
||||
restart: always
|
||||
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
|
||||
|
||||
c2-core:
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
||||
|
||||
discord-bot:
|
||||
restart: always
|
||||
|
||||
frontend:
|
||||
restart: always
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
||||
+3
-2
@@ -17,17 +17,17 @@ services:
|
||||
- mosquitto_data:/mosquitto/data
|
||||
|
||||
c2-core:
|
||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||
build: ./drb-c2-core
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8888:8000"
|
||||
env_file: ./drb-c2-core/.env
|
||||
volumes:
|
||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
||||
depends_on:
|
||||
- mosquitto
|
||||
|
||||
discord-bot:
|
||||
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||
build: ./drb-server-discord-bot
|
||||
restart: unless-stopped
|
||||
env_file: ./drb-server-discord-bot/.env
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
- c2-core
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||
build: ./drb-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
@@ -18,6 +18,10 @@ GCS_BUCKET=your-bucket-name
|
||||
# How long (seconds) before a node is marked offline if no checkin received
|
||||
NODE_OFFLINE_THRESHOLD=90
|
||||
|
||||
# Google Maps — for geocoding location strings extracted from transcripts
|
||||
# Enable "Geocoding API" in Cloud Console for this key
|
||||
GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
||||
OPENAI_API_KEY=
|
||||
SUMMARY_INTERVAL_MINUTES=15
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install uv && uv pip install --system --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
COPY tests/ ./tests/
|
||||
|
||||
@@ -17,17 +17,32 @@ class Settings(BaseSettings):
|
||||
# Node health
|
||||
node_offline_threshold: int = 90 # seconds without checkin before marking offline
|
||||
|
||||
# OpenAI (Whisper STT)
|
||||
# OpenAI (STT + intelligence)
|
||||
openai_api_key: Optional[str] = None
|
||||
stt_model: str = "whisper-1" # whisper-1 | gpt-4o-mini-transcribe | gpt-4o-transcribe
|
||||
|
||||
# Google Maps (geocoding)
|
||||
google_maps_api_key: Optional[str] = None
|
||||
|
||||
# Gemini (intelligence extraction, embeddings, incident summaries)
|
||||
gemini_api_key: Optional[str] = None
|
||||
# Correlation consensus models
|
||||
# corr_cheap_model — first-pass LLM correlator (runs on every call)
|
||||
# corr_smart_model — tiebreaker (only fires when rules and cheap LLM disagree)
|
||||
corr_cheap_model: str = "gemini-2.0-flash"
|
||||
corr_smart_model: str = "gemini-1.5-pro"
|
||||
summary_interval_minutes: int = 2 # how often the summary loop runs
|
||||
correlation_window_hours: int = 2 # slow/location path: max hours since last call
|
||||
embedding_similarity_threshold: float = 0.93 # slow-path cosine threshold (tiebreaker only)
|
||||
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
|
||||
embedding_no_location_threshold: float = 0.97 # slow-path: match without location (very high bar)
|
||||
embedding_cross_tg_threshold: float = 0.85 # cross-TG path: same dept + 2+ shared units
|
||||
location_proximity_km: float = 0.5 # radius for location-proximity matching
|
||||
geocode_max_km: float = 40.0 # reject geocode results farther than this from the node
|
||||
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls
|
||||
recorrelation_scan_minutes: int = 15 # re-examine orphaned calls ended within this window
|
||||
unit_continuity_max_idle_minutes: int = 20 # unit-continuity path: skip if incident idle > this
|
||||
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
||||
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||
tg_dispatch_thin_idle_minutes: int = 10 # dispatch channels only: thin calls only attach to incidents idle < this many minutes
|
||||
|
||||
# Vocabulary learning
|
||||
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
||||
@@ -36,7 +51,11 @@ 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
|
||||
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||
upload_max_bytes: int = 100 * 1024 * 1024
|
||||
|
||||
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"]
|
||||
# Defaults to "*" for local development only.
|
||||
cors_origins: list[str] = ["*"]
|
||||
|
||||
class Config:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
async def write_audit(
|
||||
actor_uid: str,
|
||||
actor_email: str,
|
||||
action: str,
|
||||
target_uid: Optional[str] = None,
|
||||
target_email: Optional[str] = None,
|
||||
details: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""Write an entry to the audit_log collection."""
|
||||
doc_id = str(uuid4())
|
||||
await fstore.doc_set("audit_log", doc_id, {
|
||||
"log_id": doc_id,
|
||||
"action": action,
|
||||
"actor_uid": actor_uid,
|
||||
"actor_email": actor_email,
|
||||
"target_uid": target_uid,
|
||||
"target_email": target_email,
|
||||
"details": details or {},
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}, merge=False)
|
||||
@@ -1,3 +1,6 @@
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.service_key and token == settings.service_key:
|
||||
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||
return {"service": True}
|
||||
try:
|
||||
return firebase_auth.verify_id_token(token)
|
||||
@@ -34,11 +37,96 @@ async def require_service_or_firebase_token(
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
|
||||
def get_role(decoded: dict) -> str:
|
||||
"""Extract the effective role from a decoded Firebase token.
|
||||
|
||||
Checks the granular ``role`` claim first, then falls back to the legacy
|
||||
``admin`` boolean so existing tokens continue to work during the transition.
|
||||
"""
|
||||
if decoded.get("role") == "admin" or decoded.get("admin"):
|
||||
return "admin"
|
||||
role = decoded.get("role", "viewer")
|
||||
return role if role in ("admin", "operator", "viewer") else "viewer"
|
||||
|
||||
|
||||
async def require_admin_token(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Verify a Firebase ID token AND require the admin custom claim."""
|
||||
"""Verify a Firebase ID token AND require the admin role.
|
||||
|
||||
Accepts both the legacy ``admin: True`` boolean claim and the newer
|
||||
``role: "admin"`` claim so tokens issued before the role migration still work.
|
||||
"""
|
||||
decoded = await require_firebase_token(credentials)
|
||||
if not decoded.get("admin"):
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
async def require_service_key(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept only the internal service key — used for bot-only endpoints."""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
if not settings.service_key:
|
||||
raise HTTPException(status_code=503, detail="Service key not configured")
|
||||
if not secrets.compare_digest(credentials.credentials, settings.service_key):
|
||||
raise HTTPException(status_code=403, detail="Service key required")
|
||||
return {"service": True}
|
||||
|
||||
|
||||
async def require_service_key_or_admin(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept either the internal service key or a Firebase admin token.
|
||||
|
||||
Used for endpoints that the Discord bot (service key) and dashboard admins
|
||||
(Firebase + admin claim) both need to call, but regular Firebase users must not.
|
||||
"""
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||
token = credentials.credentials
|
||||
if settings.service_key and secrets.compare_digest(token, settings.service_key):
|
||||
return {"service": True}
|
||||
try:
|
||||
decoded = firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
if get_role(decoded) != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Simple in-memory sliding-window rate limiter
|
||||
# ---------------------------------------------------------------------------
|
||||
# Not persistent across restarts; good enough for a single-instance deployment.
|
||||
# Key format is caller-defined (e.g. "{uid}:{endpoint}").
|
||||
|
||||
class _RateLimiter:
|
||||
def __init__(self, max_calls: int, window_seconds: int):
|
||||
self.max_calls = max_calls
|
||||
self.window = window_seconds
|
||||
self._log: dict[str, deque] = defaultdict(deque)
|
||||
|
||||
def check(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
q = self._log[key]
|
||||
while q and now - q[0] > self.window:
|
||||
q.popleft()
|
||||
if len(q) >= self.max_calls:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Rate limit exceeded. Please wait before trying again.",
|
||||
)
|
||||
q.append(now)
|
||||
|
||||
|
||||
# Shared limiter instances
|
||||
# trip chat: 20 requests per user per 5 minutes
|
||||
trip_chat_limiter = _RateLimiter(max_calls=20, window_seconds=300)
|
||||
# per-incident summarize: 5 per incident per 10 minutes
|
||||
summarize_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||
# vocabulary bootstrap: 2 per system per hour
|
||||
bootstrap_limiter = _RateLimiter(max_calls=2, window_seconds=3600)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Global AI feature flags stored in Firestore at config/ai_features.
|
||||
|
||||
Defaults to all-on when the document does not exist yet. Uses a short
|
||||
in-memory TTL cache so flag reads don't add a Firestore round-trip to every
|
||||
call upload.
|
||||
"""
|
||||
import time
|
||||
from typing import Any
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
_COLLECTION = "config"
|
||||
_DOC_ID = "ai_features"
|
||||
_TTL = 30.0 # seconds before re-reading from Firestore
|
||||
|
||||
_DEFAULTS: dict[str, bool] = {
|
||||
"stt_enabled": True,
|
||||
"correlation_enabled": True,
|
||||
"summaries_enabled": True,
|
||||
"vocabulary_learning_enabled": True,
|
||||
}
|
||||
|
||||
_cache: dict[str, Any] = {}
|
||||
_cache_ts: float = 0.0
|
||||
|
||||
|
||||
async def get_flags() -> dict[str, bool]:
|
||||
"""Return the current feature flags, using the TTL cache when fresh."""
|
||||
global _cache, _cache_ts
|
||||
|
||||
now = time.monotonic()
|
||||
if _cache and (now - _cache_ts) < _TTL:
|
||||
return dict(_cache)
|
||||
|
||||
try:
|
||||
doc = await fstore.doc_get(_COLLECTION, _DOC_ID)
|
||||
if doc:
|
||||
merged = {**_DEFAULTS, **{k: bool(v) for k, v in doc.items() if k in _DEFAULTS}}
|
||||
else:
|
||||
merged = dict(_DEFAULTS)
|
||||
except Exception as e:
|
||||
logger.warning(f"Feature flags: could not read from Firestore ({e}), using defaults")
|
||||
merged = dict(_DEFAULTS)
|
||||
|
||||
_cache = merged
|
||||
_cache_ts = now
|
||||
return dict(_cache)
|
||||
|
||||
|
||||
async def set_flags(updates: dict[str, bool]) -> dict[str, bool]:
|
||||
"""Write flag updates to Firestore and invalidate the cache."""
|
||||
global _cache, _cache_ts
|
||||
|
||||
clean = {k: bool(v) for k, v in updates.items() if k in _DEFAULTS}
|
||||
if not clean:
|
||||
raise ValueError(f"No recognised flag keys in update: {list(updates)}")
|
||||
|
||||
await fstore.doc_set(_COLLECTION, _DOC_ID, clean)
|
||||
_cache_ts = 0.0 # force re-read on next get_flags()
|
||||
logger.info(f"Feature flags updated: {clean}")
|
||||
return await get_flags()
|
||||
@@ -1,10 +1,18 @@
|
||||
import asyncio
|
||||
import time as _time
|
||||
from typing import Optional, Any
|
||||
import firebase_admin
|
||||
from firebase_admin import credentials, firestore as fs
|
||||
from google.cloud.firestore_v1.base_query import FieldFilter
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory TTL cache for rarely-changing documents (systems, nodes config)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key: "collection/doc_id" → (expires_at_monotonic, data_or_None)
|
||||
_doc_cache: dict[str, tuple[float, Optional[dict]]] = {}
|
||||
|
||||
|
||||
def _init_firebase():
|
||||
if firebase_admin._apps:
|
||||
@@ -51,7 +59,7 @@ async def collection_list(collection: str, **filters) -> list[dict]:
|
||||
def _query():
|
||||
ref = db.collection(collection)
|
||||
for field, value in filters.items():
|
||||
ref = ref.where(field, "==", value)
|
||||
ref = ref.where(filter=FieldFilter(field, "==", value))
|
||||
return [doc.to_dict() for doc in ref.stream()]
|
||||
|
||||
return await asyncio.to_thread(_query)
|
||||
@@ -69,7 +77,7 @@ async def collection_where(
|
||||
def _query():
|
||||
ref = db.collection(collection)
|
||||
for field, op, value in conditions:
|
||||
ref = ref.where(field, op, value)
|
||||
ref = ref.where(filter=FieldFilter(field, op, value))
|
||||
return [doc.to_dict() for doc in ref.stream()]
|
||||
|
||||
return await asyncio.to_thread(_query)
|
||||
@@ -78,3 +86,19 @@ async def collection_where(
|
||||
async def doc_delete(collection: str, doc_id: str) -> None:
|
||||
ref = db.collection(collection).document(doc_id)
|
||||
await asyncio.to_thread(ref.delete)
|
||||
|
||||
|
||||
async def doc_get_cached(collection: str, doc_id: str, ttl: float = 300.0) -> Optional[dict]:
|
||||
"""
|
||||
Like doc_get but backed by a short-lived in-memory TTL cache.
|
||||
Use for documents that change rarely (systems config, node assignments).
|
||||
Default TTL is 5 minutes — a write will be visible within that window.
|
||||
"""
|
||||
key = f"{collection}/{doc_id}"
|
||||
now = _time.monotonic()
|
||||
entry = _doc_cache.get(key)
|
||||
if entry and now < entry[0]:
|
||||
return entry[1]
|
||||
data = await doc_get(collection, doc_id)
|
||||
_doc_cache[key] = (now + ttl, data)
|
||||
return data
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +1,87 @@
|
||||
"""
|
||||
GPT-4o-mini intelligence extraction from call transcripts.
|
||||
|
||||
Sends the transcript to GPT-4o mini with a tight JSON schema prompt.
|
||||
Returns structured data: incident type, tags, location, vehicles, units, severity.
|
||||
Sends the transcript to GPT-4o-mini with a structured prompt that detects
|
||||
whether the recording contains one or multiple distinct scenes (back-to-back
|
||||
dispatch conversations on a busy channel). Returns a list of scene dicts —
|
||||
one per detected incident. Most calls produce a single scene.
|
||||
|
||||
Falls back gracefully if the API is unavailable or returns malformed output.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio. Extract structured information and respond ONLY with a single valid JSON object — no markdown, no explanation.
|
||||
_PROMPT_TEMPLATE = """You are analyzing a P25 public safety radio recording. The audio was transcribed by Whisper through a digital radio vocoder, which introduces errors. Each numbered transmission is a separate PTT press from a different radio.
|
||||
|
||||
Schema:
|
||||
{{
|
||||
"incident_type": one of "fire" | "ems" | "police" | "accident" | "other" | "unknown",
|
||||
"tags": [list of specific descriptive tags, max 6, e.g. "two-car mva", "property-damage-only", "working fire", "shots-fired"],
|
||||
"location": "most specific location string found, or empty string",
|
||||
"vehicles": [vehicle descriptions mentioned, e.g. "Hyundai Tucson", "black sedan"],
|
||||
"units": [unit IDs or officer numbers mentioned, e.g. "Unit 511", "Car 4"],
|
||||
"severity": one of "minor" | "moderate" | "major" | "unknown",
|
||||
"resolved": true if this call explicitly signals the incident is over ("Code 4", "in custody", "all clear", "fire out", "patient transported", "GOA", "scene clear", "10-42", "negative contact", "clear the scene"), false otherwise,
|
||||
"transcript_corrected": "corrected full transcript string, or null if no corrections needed"
|
||||
}}
|
||||
SCENE DETECTION:
|
||||
A busy dispatch channel sometimes captures back-to-back conversations about multiple concurrent incidents in a single recording. Detect whether this recording contains ONE scene (all transmissions relate to a single event) or MULTIPLE scenes (clearly distinct dispatch conversations with different units being assigned, different locations, different event types). Assign short status transmissions (10-4, en route, acknowledgements) with no clear scene context to the most recent scene before them in the list.
|
||||
|
||||
Always respond with the scenes array, even for a single scene.
|
||||
|
||||
SPEAKER ROLES:
|
||||
P25 radio follows a predictable call-and-response pattern. Use it to correctly attribute entities — you do not have explicit speaker labels, but you can infer roles from conversational structure:
|
||||
- Dispatch voice: opens by naming a unit then giving an assignment ("Unit 7, respond to 123 Main..."), provides incident addresses, says "be advised" / "stand by", reads back unit status. Dispatch speaks TO units.
|
||||
- Unit voice: opens with the unit's own callsign or a brief status ("Unit 7 en route", "Baker-1 on scene", "Unit 7, 10-97"), acknowledges with "copy" / "10-4", requests info about their assignment. Units speak TO dispatch.
|
||||
|
||||
Apply speaker inference to extraction:
|
||||
- A callsign at the start of a dispatch assignment ("Unit 7, go to...") — that unit is being dispatched. Include it in units.
|
||||
- A callsign that opens a short acknowledgment ("Unit 7 en route", "Baker-1 copies") — that is the speaker's own ID. Include it in units.
|
||||
- A location stated in a dispatch assignment is the incident address. Use it as location.
|
||||
- A location stated by a unit ("I'm at Route 202 and Main") is their current position — use it as location only when no dispatch-provided address is present in the scene.
|
||||
|
||||
Response format — a JSON object with a "scenes" array. Each scene:
|
||||
segment_indices: list of 0-based indices into the numbered transmissions (or null if no segments)
|
||||
incident_type: one of "fire" | "ems" | "police" | "accident" | "other" | "unknown"
|
||||
tags: list of specific descriptive tags, max 6, e.g. "two-car mva", "working fire", "shots-fired"
|
||||
location: most specific location string found, or empty string
|
||||
vehicles: list of vehicle descriptions mentioned
|
||||
units: list of unit IDs or officer numbers explicitly mentioned
|
||||
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
|
||||
severity: one of "minor" | "moderate" | "major" | "unknown"
|
||||
resolved: true if this scene explicitly signals incident closure, false otherwise
|
||||
reassignment: true if a unit is breaking from their current scene to respond to a completely different call — whether dispatch-initiated ("Baker, can you clear and respond to...", "Adam, break from that and go to...") OR unit-initiated ("Show me headed to the vehicle complaint", "Can you show me to that call", a unit going 10-8 and self-requesting a new assignment). False if the unit is reporting in on their current scene, giving a status update, or requesting information about their existing call.
|
||||
transcript_corrected: corrected text for this scene's transmissions only, or null
|
||||
|
||||
Rules:
|
||||
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Empty string if none.
|
||||
- tags: be specific and lowercase, hyphenated. Do not repeat incident_type as a tag.
|
||||
- units: only identifiers explicitly mentioned, not inferred.
|
||||
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Dispatch-provided addresses take priority over unit-reported positions. Empty string if none.
|
||||
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
||||
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
|
||||
- Do not invent details not present in the transcript.
|
||||
- transcript_corrected: fix only clear STT errors caused by vocoder distortion (e.g. "Several" → "10-4", misheard street names, garbled unit IDs). Use the back-and-forth context between transmissions to resolve ambiguities. Keep all radio language as-is — do NOT decode codes into plain English. Return null if the transcript looks accurate.
|
||||
- incident_type: let the talkgroup channel be your primary signal. Use "fire" ONLY if the talkgroup is clearly a fire/rescue channel OR the transcript explicitly describes active fire, smoke, flames, or structure fire activation. Police or EMS referencing a fire scene → use "police" or "ems". When uncertain, prefer "other" over "fire".
|
||||
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
||||
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
||||
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
||||
- reassignment: only true when a unit is explicitly being pulled to a completely new call or location. A unit going en route to their first dispatch is NOT a reassignment. Routine status updates, acknowledgements, and scene updates are NOT reassignments.
|
||||
- transcript_corrected: fix only clear STT/vocoder errors (e.g. "Several" → "10-4", misheard street names, garbled unit IDs). Keep all radio language as-is — do NOT decode codes into plain English. Return null if accurate.
|
||||
|
||||
System: {system_id}
|
||||
Talkgroup: {talkgroup_name}
|
||||
{vocabulary_block}{transcript_block}"""
|
||||
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
||||
|
||||
# Nominatim viewbox half-width in degrees (~11 km at mid-latitudes)
|
||||
_GEO_DELTA = 0.1
|
||||
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
||||
_GEO_DELTA = 0.5
|
||||
|
||||
# node_id → state abbreviation/name from one-time reverse geocode
|
||||
_node_state_cache: dict[str, str] = {}
|
||||
# Cache node state (e.g. "New York") and county (e.g. "Westchester County") per node
|
||||
_node_state_cache: dict[str, str] = {}
|
||||
_node_county_cache: dict[str, str] = {}
|
||||
|
||||
# Police/law-enforcement phonetic alphabet words (APCO + NATO).
|
||||
# A run of 5+ of these in a transcript is a strong Whisper hallucination signal.
|
||||
_PHONETIC_ALPHA_WORDS = frozenset({
|
||||
# APCO (law enforcement)
|
||||
"adam", "baker", "charles", "david", "edward", "frank", "george", "henry",
|
||||
"ida", "john", "king", "lincoln", "mary", "nora", "ocean", "paul", "queen",
|
||||
"robert", "sam", "tom", "union", "victor", "william", "x-ray", "young", "zebra",
|
||||
# NATO
|
||||
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel",
|
||||
"india", "juliet", "kilo", "lima", "mike", "november", "oscar", "papa",
|
||||
"quebec", "romeo", "sierra", "tango", "uniform", "whiskey", "yankee", "zulu",
|
||||
})
|
||||
|
||||
# Strip P25 service suffixes to extract the municipality name from a talkgroup
|
||||
_TG_SUFFIX_RE = re.compile(
|
||||
@@ -54,7 +93,45 @@ _TG_SUFFIX_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
async def extract_tags(
|
||||
def _is_garbage_transcript(transcript: str) -> bool:
|
||||
"""
|
||||
Detect Whisper hallucinations that should be discarded before GPT processing.
|
||||
|
||||
Two signals:
|
||||
1. Phonetic-alphabet run ≥ 5 consecutive words: Whisper hallucinated a
|
||||
training-data sequence (common on silent or noise-only audio).
|
||||
2. High comma density (> 15% of tokens) in long transcripts: list-dump
|
||||
hallucinations contain far more commas than real radio speech.
|
||||
"""
|
||||
words = re.findall(r"[\w\-]+", transcript.lower())
|
||||
if not words:
|
||||
return False
|
||||
|
||||
# Threshold of 12: well above any legitimate plate/name spellout (~6–8 words)
|
||||
# but catches the full-alphabet hallucination (26 words in sequence).
|
||||
run = 0
|
||||
for w in words:
|
||||
if w in _PHONETIC_ALPHA_WORDS:
|
||||
run += 1
|
||||
if run >= 12:
|
||||
return True
|
||||
else:
|
||||
run = 0
|
||||
|
||||
if len(words) > 30 and transcript.count(",") / len(words) > 0.15:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _build_ten_codes_block(ten_codes: dict[str, str]) -> str:
|
||||
if not ten_codes:
|
||||
return ""
|
||||
lines = "\n".join(f" {code}: {meaning}" for code, meaning in sorted(ten_codes.items()))
|
||||
return f"Department ten-codes:\n{lines}\n\n"
|
||||
|
||||
|
||||
async def extract_scenes(
|
||||
call_id: str,
|
||||
transcript: str,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
@@ -63,151 +140,289 @@ async def extract_tags(
|
||||
segments: Optional[list[dict]] = None,
|
||||
node_id: Optional[str] = None,
|
||||
preserve_transcript_correction: bool = False,
|
||||
) -> tuple[list[str], Optional[str], Optional[str], Optional[dict], bool]:
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Extract incident tags, type, location, corrected transcript, and closure signal via GPT-4o mini.
|
||||
Geocodes the extracted location string via Nominatim using the node's position as bias.
|
||||
Split the transcript into one or more scenes and extract structured
|
||||
intelligence for each. Most calls return a single scene; a busy dispatch
|
||||
channel capturing back-to-back conversations returns multiple.
|
||||
|
||||
Returns:
|
||||
(tags, primary_type, location_str, location_coords, resolved)
|
||||
where location_coords is {"lat": float, "lng": float} or None,
|
||||
and resolved is True when the transcript signals incident closure.
|
||||
Each scene dict contains:
|
||||
tags, incident_type, location, location_coords, resolved,
|
||||
severity, vehicles, units, transcript_corrected,
|
||||
segment_indices, embedding
|
||||
|
||||
Side-effect: updates calls/{call_id} in Firestore with tags, location,
|
||||
location_coords, vehicles, units, severity, transcript_corrected; also stores embedding.
|
||||
Side-effect: updates calls/{call_id} in Firestore with merged tags,
|
||||
location (primary scene), units/vehicles, severity, embedding, and
|
||||
optionally transcript_corrected.
|
||||
"""
|
||||
# Load per-system vocabulary for prompt injection
|
||||
vocabulary: list[str] = []
|
||||
ten_codes: dict[str, str] = {}
|
||||
if system_id:
|
||||
from app.internal.vocabulary_learner import get_vocabulary
|
||||
vocab_data = await get_vocabulary(system_id)
|
||||
vocabulary = vocab_data.get("vocabulary") or []
|
||||
# Single cached read — vocabulary and ten_codes live on the same document.
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
vocabulary = system_doc.get("vocabulary") or []
|
||||
ten_codes = system_doc.get("ten_codes") or {}
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
_sync_extract, transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary
|
||||
if _is_garbage_transcript(transcript):
|
||||
logger.warning(
|
||||
f"Intelligence: call {call_id} — garbage transcript detected "
|
||||
f"(Whisper hallucination), skipping extraction"
|
||||
)
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {"skip_reason": "garbage_transcript"})
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
# Transcripts with ≤5 words carry no extractable intelligence — GPT hallucinates
|
||||
# units and tags from thin context (e.g. "Main Lot", "10-4", "David").
|
||||
if len(transcript.split()) <= 5:
|
||||
logger.info(
|
||||
f"Intelligence: call {call_id} — transcript too short for extraction "
|
||||
f"({len(transcript.split())} words), skipping"
|
||||
)
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {"skip_reason": "transcript_too_short"})
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
raw_scenes: list[dict] = await asyncio.to_thread(
|
||||
_sync_extract,
|
||||
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
||||
)
|
||||
|
||||
tags: list[str] = result.get("tags") or []
|
||||
incident_type: Optional[str] = result.get("incident_type") or None
|
||||
location: Optional[str] = result.get("location") or None
|
||||
vehicles: list[str] = result.get("vehicles") or []
|
||||
units: list[str] = result.get("units") or []
|
||||
severity: str = result.get("severity") or "unknown"
|
||||
resolved: bool = bool(result.get("resolved", False))
|
||||
transcript_corrected: Optional[str] = result.get("transcript_corrected") or None
|
||||
if not raw_scenes:
|
||||
return []
|
||||
|
||||
if incident_type in ("unknown", "other", ""):
|
||||
incident_type = None
|
||||
|
||||
# Geocode the location string if we have one and a node to bias toward
|
||||
location_coords: Optional[dict] = None
|
||||
if location and node_id:
|
||||
node_doc = await fstore.doc_get("nodes", node_id)
|
||||
# Resolve node position once for geocoding all scenes
|
||||
node_lat: Optional[float] = None
|
||||
node_lon: Optional[float] = None
|
||||
if node_id:
|
||||
node_doc = await fstore.doc_get_cached("nodes", node_id)
|
||||
if node_doc:
|
||||
node_lat = node_doc.get("lat")
|
||||
node_lon = node_doc.get("lon")
|
||||
if node_lat is not None and node_lon is not None:
|
||||
state = await _get_node_state(node_id, node_lat, node_lon)
|
||||
muni = _municipality_from_tg(talkgroup_name)
|
||||
hint_parts = [p for p in [muni, state] if p]
|
||||
query = f"{location}, {', '.join(hint_parts)}" if hint_parts else location
|
||||
location_coords = await _geocode_location(query, node_lat, node_lon)
|
||||
|
||||
# Store embedding alongside structured data
|
||||
embedding = await asyncio.to_thread(_sync_embed, _embed_text(transcript, incident_type))
|
||||
processed: list[dict] = []
|
||||
for scene in raw_scenes:
|
||||
tags: list[str] = scene.get("tags") or []
|
||||
incident_type: Optional[str] = scene.get("incident_type") or None
|
||||
location: Optional[str] = scene.get("location") or None
|
||||
vehicles: list[str] = scene.get("vehicles") or []
|
||||
units: list[str] = scene.get("units") or []
|
||||
cleared_units: list[str] = scene.get("cleared_units") or []
|
||||
severity: str = scene.get("severity") or "unknown"
|
||||
resolved: bool = bool(scene.get("resolved", False))
|
||||
reassignment: bool = bool(scene.get("reassignment", False))
|
||||
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
||||
segment_indices: Optional[list] = scene.get("segment_indices")
|
||||
|
||||
updates: dict = {"tags": tags, "severity": severity}
|
||||
if location:
|
||||
updates["location"] = location
|
||||
if location_coords:
|
||||
updates["location_coords"] = location_coords
|
||||
if vehicles:
|
||||
updates["vehicles"] = vehicles
|
||||
if units:
|
||||
updates["units"] = units
|
||||
if embedding:
|
||||
updates["embedding"] = embedding
|
||||
if transcript_corrected and not preserve_transcript_correction:
|
||||
updates["transcript_corrected"] = transcript_corrected
|
||||
if incident_type in ("unknown", "other", ""):
|
||||
incident_type = None
|
||||
|
||||
# Geocode this scene's location.
|
||||
# Build the most specific query possible: location + municipality + state.
|
||||
# e.g. "High Street" → "High Street, Yorktown, New York"
|
||||
# This prevents generic street names from resolving to wrong-country results.
|
||||
location_coords: Optional[dict] = None
|
||||
if location and node_lat is not None and node_lon is not None:
|
||||
muni = _municipality_from_tg(talkgroup_name)
|
||||
state = await _get_node_state(node_id or "", node_lat, node_lon) if node_id else ""
|
||||
county = _node_county_cache.get(node_id or "") if node_id else ""
|
||||
parts = [location]
|
||||
if muni:
|
||||
parts.append(muni)
|
||||
if county:
|
||||
parts.append(county)
|
||||
if state:
|
||||
parts.append(state)
|
||||
query = ", ".join(parts)
|
||||
location_coords = await _geocode_location(query, node_lat, node_lon)
|
||||
|
||||
# Embed this scene's content
|
||||
scene_text = _build_scene_embed_text(
|
||||
transcript, segments, segment_indices, incident_type, transcript_corrected
|
||||
)
|
||||
embedding = await asyncio.to_thread(_sync_embed, scene_text)
|
||||
|
||||
processed.append({
|
||||
"tags": tags,
|
||||
"incident_type": incident_type,
|
||||
"location": location,
|
||||
"location_coords": location_coords,
|
||||
"vehicles": vehicles,
|
||||
"units": units,
|
||||
"cleared_units": cleared_units,
|
||||
"severity": severity,
|
||||
"resolved": resolved,
|
||||
"reassignment": reassignment,
|
||||
"transcript_corrected": transcript_corrected,
|
||||
"segment_indices": segment_indices,
|
||||
"embedding": embedding,
|
||||
})
|
||||
|
||||
# Merge across scenes for the call-level Firestore document.
|
||||
# Primary scene (first) owns location, severity, transcript_corrected.
|
||||
# Tags/units/vehicles are union-merged from all scenes.
|
||||
primary = processed[0]
|
||||
all_tags = list(dict.fromkeys(t for s in processed for t in s["tags"]))
|
||||
all_units = list(dict.fromkeys(u for s in processed for u in s["units"]))
|
||||
all_vehicles = list(dict.fromkeys(v for s in processed for v in s["vehicles"]))
|
||||
all_cleared = list(dict.fromkeys(u for s in processed for u in s["cleared_units"]))
|
||||
|
||||
updates: dict = {"tags": all_tags, "severity": primary["severity"]}
|
||||
if primary["location"]:
|
||||
updates["location"] = primary["location"]
|
||||
if primary["location_coords"]:
|
||||
updates["location_coords"] = primary["location_coords"]
|
||||
if all_units:
|
||||
updates["units"] = all_units
|
||||
if all_cleared:
|
||||
updates["cleared_units"] = all_cleared
|
||||
if all_vehicles:
|
||||
updates["vehicles"] = all_vehicles
|
||||
if primary["embedding"]:
|
||||
updates["embedding"] = primary["embedding"]
|
||||
if primary["transcript_corrected"] and not preserve_transcript_correction:
|
||||
updates["transcript_corrected"] = primary["transcript_corrected"]
|
||||
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, updates)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not save intelligence for call {call_id}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Intelligence: call {call_id} → type={incident_type}, "
|
||||
f"tags={tags}, location={location!r}, coords={location_coords}, severity={severity}, "
|
||||
f"corrected={transcript_corrected is not None}"
|
||||
scene_summary = (
|
||||
f"{len(processed)} scene(s): "
|
||||
+ ", ".join(
|
||||
f"[{s['incident_type'] or 'unclassified'} tags={s['tags'][:2]}]"
|
||||
for s in processed
|
||||
)
|
||||
)
|
||||
return tags, incident_type, location, location_coords, resolved
|
||||
logger.info(f"Intelligence: call {call_id} → {scene_summary}")
|
||||
return processed
|
||||
|
||||
|
||||
def _geo_dist_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Haversine distance in km between two lat/lon points."""
|
||||
R = 6371.0
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
|
||||
return R * 2 * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
async def _get_node_state(node_id: str, lat: float, lon: float) -> str:
|
||||
"""
|
||||
Return the US state name (e.g. "New York") for a node's position.
|
||||
Also populates _node_county_cache as a side-effect (same API call).
|
||||
Uses Google Maps Reverse Geocoding; cached for the process lifetime since nodes don't move.
|
||||
"""
|
||||
if node_id in _node_state_cache:
|
||||
return _node_state_cache[node_id]
|
||||
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
if not settings.google_maps_api_key:
|
||||
return ""
|
||||
|
||||
state = ""
|
||||
county = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
"https://maps.googleapis.com/maps/api/geocode/json",
|
||||
params={
|
||||
"latlng": f"{lat},{lon}",
|
||||
"result_type": "administrative_area_level_1|administrative_area_level_2",
|
||||
"key": settings.google_maps_api_key,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if data.get("status") == "OK" and data.get("results"):
|
||||
for result in data["results"]:
|
||||
for comp in result.get("address_components", []):
|
||||
types = comp.get("types", [])
|
||||
if "administrative_area_level_1" in types and not state:
|
||||
state = comp.get("long_name", "")
|
||||
if "administrative_area_level_2" in types and not county:
|
||||
county = comp.get("long_name", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"Node state lookup failed for {node_id}: {e}")
|
||||
|
||||
if state:
|
||||
_node_state_cache[node_id] = state
|
||||
if county:
|
||||
_node_county_cache[node_id] = county
|
||||
if state or county:
|
||||
logger.info(f"Node {node_id} geo resolved: county={county!r} state={state!r}")
|
||||
return state
|
||||
|
||||
|
||||
async def _geocode_location(
|
||||
location_str: str, node_lat: float, node_lon: float
|
||||
) -> Optional[dict]:
|
||||
"""
|
||||
Geocode a location string using Nominatim, biased toward the node's area.
|
||||
Returns {"lat": float, "lng": float} or None if geocoding fails.
|
||||
Geocode using Google Maps Geocoding API, biased toward the node's area.
|
||||
Returns {"lat": float, "lng": float} or None if geocoding fails or the
|
||||
result is farther than geocode_max_km from the node (wrong-jurisdiction guard).
|
||||
"""
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
viewbox = (
|
||||
f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA},"
|
||||
f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}"
|
||||
if not settings.google_maps_api_key:
|
||||
logger.warning("GOOGLE_MAPS_API_KEY not set — geocoding disabled")
|
||||
return None
|
||||
|
||||
bounds = (
|
||||
f"{node_lat - _GEO_DELTA},{node_lon - _GEO_DELTA}"
|
||||
f"|{node_lat + _GEO_DELTA},{node_lon + _GEO_DELTA}"
|
||||
)
|
||||
params = {
|
||||
"q": location_str,
|
||||
"format": "json",
|
||||
"limit": 1,
|
||||
"viewbox": viewbox,
|
||||
"bounded": 1,
|
||||
"address": location_str,
|
||||
"bounds": bounds,
|
||||
"region": "us",
|
||||
"key": settings.google_maps_api_key,
|
||||
}
|
||||
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
"https://nominatim.openstreetmap.org/search",
|
||||
"https://maps.googleapis.com/maps/api/geocode/json",
|
||||
params=params,
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
results = r.json()
|
||||
if results:
|
||||
coords = {"lat": float(results[0]["lat"]), "lng": float(results[0]["lon"])}
|
||||
logger.info(f"Geocoded '{location_str}' → {coords}")
|
||||
return coords
|
||||
except Exception as e:
|
||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _get_node_state(node_id: str, lat: float, lon: float) -> Optional[str]:
|
||||
"""
|
||||
Reverse geocode the node's position once to extract its state.
|
||||
Result is cached for the process lifetime — nodes don't move.
|
||||
"""
|
||||
if node_id in _node_state_cache:
|
||||
return _node_state_cache[node_id]
|
||||
|
||||
import httpx
|
||||
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
r = await client.get(
|
||||
"https://nominatim.openstreetmap.org/reverse",
|
||||
params={"lat": lat, "lon": lon, "format": "json", "zoom": 5},
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
state = data.get("address", {}).get("state", "")
|
||||
if state:
|
||||
_node_state_cache[node_id] = state
|
||||
logger.info(f"Node {node_id} reverse-geocoded to state: {state!r}")
|
||||
return state
|
||||
if data.get("status") != "OK" or not data.get("results"):
|
||||
return None
|
||||
result = data["results"][0]
|
||||
location_type = result.get("geometry", {}).get("location_type", "")
|
||||
# Only accept address-level precision. GEOMETRIC_CENTER (city/neighborhood
|
||||
# centroid) and APPROXIMATE (region boundary) produce coordinates that look
|
||||
# valid but are too vague for 0.5km proximity matching — they often resolve
|
||||
# to the same point as the node's position and create false proximity matches.
|
||||
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED"):
|
||||
logger.info(
|
||||
f"Geocoding rejected '{location_str}' — imprecise result "
|
||||
f"(location_type={location_type!r}), returning None"
|
||||
)
|
||||
return None
|
||||
loc = result["geometry"]["location"]
|
||||
lat, lng = float(loc["lat"]), float(loc["lng"])
|
||||
dist_km = _geo_dist_km(node_lat, node_lon, lat, lng)
|
||||
if dist_km > settings.geocode_max_km:
|
||||
logger.warning(
|
||||
f"Geocoding rejected '{location_str}' → ({lat:.4f}, {lng:.4f}) "
|
||||
f"— {dist_km:.1f}km from node exceeds geocode_max_km={settings.geocode_max_km}"
|
||||
)
|
||||
return None
|
||||
coords = {"lat": lat, "lng": lng}
|
||||
logger.info(f"Geocoded '{location_str}' → {coords} ({dist_km:.1f}km from node) [{location_type}]")
|
||||
return coords
|
||||
except Exception as e:
|
||||
logger.warning(f"Node state reverse geocode failed: {e}")
|
||||
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -220,7 +435,6 @@ def _municipality_from_tg(tg_name: Optional[str]) -> Optional[str]:
|
||||
if not tg_name:
|
||||
return None
|
||||
cleaned = _TG_SUFFIX_RE.sub("", tg_name).strip()
|
||||
# Discard if nothing left, purely numeric, or a short all-caps abbreviation (e.g. "WC", "TAC")
|
||||
if not cleaned or cleaned.isdigit() or (len(cleaned) <= 3 and cleaned.isupper()):
|
||||
return None
|
||||
return cleaned
|
||||
@@ -234,6 +448,23 @@ def _build_transcript_block(transcript: str, segments: Optional[list[dict]]) ->
|
||||
return f"Transcript:\n{transcript}"
|
||||
|
||||
|
||||
def _build_scene_embed_text(
|
||||
transcript: str,
|
||||
segments: Optional[list[dict]],
|
||||
segment_indices: Optional[list[int]],
|
||||
incident_type: Optional[str],
|
||||
transcript_corrected: Optional[str],
|
||||
) -> str:
|
||||
"""Build the text string to embed for a specific scene."""
|
||||
prefix = f"[{incident_type}] " if incident_type else ""
|
||||
if transcript_corrected:
|
||||
return f"{prefix}{transcript_corrected}"
|
||||
if segments and segment_indices:
|
||||
texts = [segments[i]["text"] for i in segment_indices if i < len(segments)]
|
||||
return f"{prefix}{' '.join(texts)}"
|
||||
return f"{prefix}{transcript}"
|
||||
|
||||
|
||||
def _sync_extract(
|
||||
transcript: str,
|
||||
talkgroup_name: Optional[str],
|
||||
@@ -241,14 +472,15 @@ def _sync_extract(
|
||||
system_id: Optional[str],
|
||||
segments: Optional[list[dict]],
|
||||
vocabulary: Optional[list[str]] = None,
|
||||
) -> dict:
|
||||
"""Call GPT-4o mini and parse the JSON response."""
|
||||
ten_codes: Optional[dict[str, str]] = None,
|
||||
) -> list[dict]:
|
||||
"""Call GPT-4o-mini and return a list of scene dicts."""
|
||||
from app.config import settings
|
||||
from openai import OpenAI
|
||||
|
||||
if not settings.openai_api_key:
|
||||
logger.warning("OPENAI_API_KEY not set — intelligence extraction disabled.")
|
||||
return {}
|
||||
return []
|
||||
|
||||
from app.internal.vocabulary_learner import build_gpt_vocab_block
|
||||
tg = f"{talkgroup_name} (TGID {talkgroup_id})" if talkgroup_id else (talkgroup_name or "unknown")
|
||||
@@ -256,6 +488,7 @@ def _sync_extract(
|
||||
transcript_block=_build_transcript_block(transcript, segments),
|
||||
talkgroup_name=tg,
|
||||
system_id=system_id or "unknown",
|
||||
ten_codes_block=_build_ten_codes_block(ten_codes or {}),
|
||||
vocabulary_block=build_gpt_vocab_block(vocabulary or []),
|
||||
)
|
||||
|
||||
@@ -266,13 +499,22 @@ def _sync_extract(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
return json.loads(response.choices[0].message.content)
|
||||
raw = json.loads(response.choices[0].message.content)
|
||||
|
||||
# New format: {"scenes": [...]}
|
||||
if "scenes" in raw and isinstance(raw["scenes"], list):
|
||||
return raw["scenes"]
|
||||
|
||||
# Fallback: GPT returned the old flat single-scene format
|
||||
logger.warning("GPT returned flat format instead of scenes array — wrapping")
|
||||
return [raw]
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"GPT-4o mini returned non-JSON: {e}")
|
||||
return {}
|
||||
logger.warning(f"GPT-4o-mini returned non-JSON: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"GPT-4o mini extraction failed: {e}")
|
||||
return {}
|
||||
logger.warning(f"GPT-4o-mini extraction failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _sync_embed(text: str) -> Optional[list[float]]:
|
||||
@@ -290,8 +532,3 @@ def _sync_embed(text: str) -> Optional[list[float]]:
|
||||
except Exception as e:
|
||||
logger.warning(f"Embedding generation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _embed_text(transcript: str, incident_type: Optional[str]) -> str:
|
||||
prefix = f"[{incident_type}] " if incident_type else ""
|
||||
return f"{prefix}{transcript}"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
LLM-based incident correlator using Gemini.
|
||||
|
||||
Two functions are exposed:
|
||||
decide(call_id, ctx) — cheap first-pass (corr_cheap_model)
|
||||
tiebreak(rules_decision, llm_decision, ctx) — smart tiebreaker (corr_smart_model)
|
||||
|
||||
Both return a decision dict compatible with _run_decision() in incident_correlator:
|
||||
{"action": "link"|"new"|"orphan",
|
||||
"matched_incident": dict|None,
|
||||
"incident_type": str|None,
|
||||
"corr_debug": dict,
|
||||
"reasoning": str} ← extra field for logging/tiebreak comparison
|
||||
|
||||
decide() is skipped for thin calls (no content to reason about) and when
|
||||
GEMINI_API_KEY is not set — in those cases returns None so the caller knows
|
||||
to fall back to the rules decision.
|
||||
|
||||
Error handling: any Gemini failure returns None from decide() and the
|
||||
rules_decision from tiebreak() so the pipeline never stalls.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Prompt helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _fmt_idle(inc: dict, now: datetime) -> str:
|
||||
try:
|
||||
raw = inc.get("updated_at") or inc.get("started_at") or ""
|
||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
minutes = int((now - dt).total_seconds() / 60)
|
||||
return f"{minutes}min ago" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m ago"
|
||||
except Exception:
|
||||
return "?"
|
||||
|
||||
|
||||
def _inc_summary(inc: dict, now: datetime) -> str:
|
||||
parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"]
|
||||
if inc.get("location"):
|
||||
parts.append(f"loc:{inc['location']}")
|
||||
units = inc.get("units") or []
|
||||
if units:
|
||||
parts.append(f"units:[{', '.join(units[:6])}]")
|
||||
tags = inc.get("tags") or []
|
||||
if tags:
|
||||
parts.append(f"tags:[{', '.join(tags[:4])}]")
|
||||
parts.append(f"idle:{_fmt_idle(inc, now)}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _call_block(ctx: dict) -> str:
|
||||
lines = []
|
||||
call_doc = ctx["call_doc"]
|
||||
transcript = call_doc.get("transcript_corrected") or call_doc.get("transcript")
|
||||
if transcript:
|
||||
lines.append(f"Transcript: {transcript[:700]}")
|
||||
if ctx["tags"]:
|
||||
lines.append(f"Tags: {ctx['tags']}")
|
||||
if ctx["incident_type"]:
|
||||
lines.append(f"Incident type: {ctx['incident_type']}")
|
||||
if ctx["location"]:
|
||||
lines.append(f"Location: {ctx['location']}")
|
||||
if ctx["call_units"]:
|
||||
lines.append(f"Units: {ctx['call_units']}")
|
||||
if ctx["call_vehicles"]:
|
||||
lines.append(f"Vehicles: {ctx['call_vehicles']}")
|
||||
if ctx["talkgroup_name"]:
|
||||
lines.append(f"Talkgroup: {ctx['talkgroup_name']}")
|
||||
return "\n".join(lines) if lines else "(no details)"
|
||||
|
||||
|
||||
_SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
|
||||
|
||||
_RULES = """
|
||||
Rules:
|
||||
- "link" only with clear positive evidence: same units, same geocoded location, or semantically identical scene on the same talkgroup within the last few minutes.
|
||||
- A call on a DIFFERENT talkgroup than an incident requires unit overlap or geocoded location match — topic similarity alone is not enough.
|
||||
- "new" only if the call has a clear incident_type AND describes a distinct, identifiable scene.
|
||||
- "orphan" when in doubt — conservative is always correct.
|
||||
- Do NOT link just because both calls involve police or both mention a road.
|
||||
"""
|
||||
|
||||
|
||||
def _build_decide_prompt(ctx: dict) -> str:
|
||||
now = ctx["now"]
|
||||
recent = ctx["recent"]
|
||||
inc_block = (
|
||||
"\n".join(_inc_summary(inc, now) for inc in recent[:20])
|
||||
if recent else "(none)"
|
||||
)
|
||||
return (
|
||||
"You are an incident correlator for a public safety radio monitoring system.\n\n"
|
||||
"A new radio call has arrived. Decide whether it belongs to an existing active incident, "
|
||||
"represents a new incident, or should be orphaned (not enough information).\n\n"
|
||||
f"NEW CALL:\n{_call_block(ctx)}\n\n"
|
||||
f"ACTIVE INCIDENTS ({len(recent)} recent):\n{inc_block}\n"
|
||||
f"{_RULES}\n"
|
||||
f"Respond with JSON only (no markdown):\n{_SCHEMA}"
|
||||
)
|
||||
|
||||
|
||||
def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict) -> str:
|
||||
now = ctx["now"]
|
||||
recent = ctx["recent"]
|
||||
inc_block = (
|
||||
"\n".join(_inc_summary(inc, now) for inc in recent[:20])
|
||||
if recent else "(none)"
|
||||
)
|
||||
|
||||
def _fmt(d: dict, name: str) -> str:
|
||||
action = d.get("action", "?")
|
||||
inc = d.get("matched_incident")
|
||||
inc_id = inc["incident_id"] if inc else (d.get("incident_id") or "null")
|
||||
reason = d.get("reasoning") or (d.get("corr_debug") or {}).get("corr_fit_signal") or "—"
|
||||
return f" {name}: action={action}, incident_id={inc_id}, reasoning={reason!r}"
|
||||
|
||||
return (
|
||||
"You are a senior incident correlator for a public safety radio monitoring system.\n\n"
|
||||
"Two correlation engines disagree. You must make the final decision.\n\n"
|
||||
f"NEW CALL:\n{_call_block(ctx)}\n\n"
|
||||
f"ACTIVE INCIDENTS ({len(recent)} recent):\n{inc_block}\n\n"
|
||||
"DISAGREEMENT:\n"
|
||||
f"{_fmt(rules_decision, 'Rules engine')}\n"
|
||||
f"{_fmt(llm_decision, 'LLM correlator')}\n"
|
||||
f"{_RULES}\n"
|
||||
f"Respond with JSON only (no markdown):\n{_SCHEMA}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Gemini API call (sync, runs in thread pool)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sync_gemini(model_name: str, prompt: str) -> dict:
|
||||
import google.generativeai as genai # lazy import — only when needed
|
||||
|
||||
genai.configure(api_key=settings.gemini_api_key)
|
||||
model = genai.GenerativeModel(
|
||||
model_name,
|
||||
generation_config={"response_mime_type": "application/json"},
|
||||
)
|
||||
response = model.generate_content(prompt)
|
||||
return json.loads(response.text)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Decision parsing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_response(raw: dict, ctx: dict) -> dict:
|
||||
"""
|
||||
Convert raw Gemini JSON output to a decision dict compatible with _run_decision().
|
||||
Resolves incident_id → full incident doc from ctx["all_active"].
|
||||
Handles type inference for "new" actions the same way as the rules engine.
|
||||
"""
|
||||
from app.internal.incident_correlator import _infer_type_from_tags # same-package import
|
||||
|
||||
action = raw.get("action", "orphan")
|
||||
reasoning = raw.get("reasoning", "")
|
||||
|
||||
if action not in ("link", "new", "orphan"):
|
||||
action = "orphan"
|
||||
|
||||
matched_incident: Optional[dict] = None
|
||||
|
||||
if action == "link":
|
||||
inc_id = raw.get("incident_id")
|
||||
if inc_id:
|
||||
matched_incident = next(
|
||||
(i for i in ctx["all_active"] if i.get("incident_id") == inc_id),
|
||||
None,
|
||||
)
|
||||
if not matched_incident:
|
||||
logger.warning(
|
||||
f"LLM correlator: incident_id={inc_id!r} not in active incidents — orphaning"
|
||||
)
|
||||
action = "orphan"
|
||||
|
||||
incident_type: Optional[str] = None
|
||||
if action in ("link", "new"):
|
||||
incident_type = ctx["incident_type"]
|
||||
if not incident_type:
|
||||
incident_type = _infer_type_from_tags(ctx["tags"])
|
||||
if action == "new" and not incident_type:
|
||||
# Can't create an incident without a type — demote to orphan
|
||||
action = "orphan"
|
||||
matched_incident = None
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"matched_incident": matched_incident,
|
||||
"incident_type": incident_type,
|
||||
"corr_debug": {"corr_llm_reasoning": reasoning},
|
||||
"reasoning": reasoning,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def decide(call_id: str, ctx: dict) -> Optional[dict]:
|
||||
"""
|
||||
Run the cheap LLM correlator (corr_cheap_model) on the call.
|
||||
|
||||
Returns a decision dict or None if:
|
||||
- GEMINI_API_KEY is not configured
|
||||
- the call is thin (content-free — no value from LLM)
|
||||
- there are no recent active incidents to reason about
|
||||
- Gemini fails
|
||||
|
||||
Callers should treat None as "fall back to rules decision".
|
||||
"""
|
||||
if not settings.gemini_api_key:
|
||||
return None
|
||||
|
||||
if ctx["is_thin_call"]:
|
||||
return None # thin calls have no transcript/units/coords to reason about
|
||||
|
||||
if not ctx["recent"]:
|
||||
return None # no incidents to correlate against — rules handles new-only
|
||||
|
||||
try:
|
||||
prompt = _build_decide_prompt(ctx)
|
||||
raw = await asyncio.to_thread(_sync_gemini, settings.corr_cheap_model, prompt)
|
||||
decision = _parse_response(raw, ctx)
|
||||
_id = (decision["matched_incident"] or {}).get("incident_id", "null")
|
||||
logger.info(
|
||||
f"LLM correlator ({settings.corr_cheap_model}): call {call_id} → "
|
||||
f"action={decision['action']} incident={_id} "
|
||||
f"reasoning={decision['reasoning']!r}"
|
||||
)
|
||||
return decision
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM correlator failed for call {call_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def tiebreak(rules_decision: dict, llm_decision: dict, ctx: dict) -> dict:
|
||||
"""
|
||||
Run the smart tiebreaker (corr_smart_model) when rules and LLM disagree.
|
||||
Falls back to rules_decision on any error.
|
||||
"""
|
||||
call_id = ctx["call_id"]
|
||||
try:
|
||||
prompt = _build_tiebreak_prompt(rules_decision, llm_decision, ctx)
|
||||
raw = await asyncio.to_thread(_sync_gemini, settings.corr_smart_model, prompt)
|
||||
decision = _parse_response(raw, ctx)
|
||||
_id = (decision["matched_incident"] or {}).get("incident_id", "null")
|
||||
logger.info(
|
||||
f"LLM tiebreak ({settings.corr_smart_model}): call {call_id} → "
|
||||
f"action={decision['action']} incident={_id} "
|
||||
f"reasoning={decision['reasoning']!r}"
|
||||
)
|
||||
return decision
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM tiebreak failed for call {call_id}: {e} — using rules decision")
|
||||
return rules_decision
|
||||
|
||||
|
||||
def decisions_agree(rules: dict, llm: dict) -> bool:
|
||||
"""True if both decisions agree on action and (when action=="link") on the target incident."""
|
||||
if rules["action"] != llm["action"]:
|
||||
return False
|
||||
if rules["action"] == "link":
|
||||
r_id = (rules.get("matched_incident") or {}).get("incident_id")
|
||||
l_id = (llm.get("matched_incident") or {}).get("incident_id")
|
||||
return r_id == l_id
|
||||
return True
|
||||
@@ -104,15 +104,20 @@ class MQTTHandler:
|
||||
"lat": payload.get("lat", existing.get("lat", 0.0)),
|
||||
"lon": payload.get("lon", existing.get("lon", 0.0)),
|
||||
}
|
||||
# Only promote to online if already configured (don't overwrite explicit status)
|
||||
if existing.get("configured") and existing.get("status") not in ("recording",):
|
||||
updates["status"] = "online"
|
||||
# Update status on checkin (don't clobber an active recording)
|
||||
if existing.get("status") not in ("recording",):
|
||||
if existing.get("configured"):
|
||||
updates["status"] = "online"
|
||||
elif existing.get("approval_status") == "approved":
|
||||
# Approved but not yet configured — restore reachable status after reboot
|
||||
updates["status"] = "unconfigured"
|
||||
await fstore.doc_update("nodes", node_id, updates)
|
||||
|
||||
# Release any orphaned Discord token when the node explicitly reports disconnected
|
||||
if payload.get("discord_connected") is False:
|
||||
from app.routers.tokens import release_token
|
||||
await release_token(node_id)
|
||||
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||
# token here. The bot watchdog reconnects on transient Discord drops, so a single
|
||||
# checkin with discord_connected=False during a brief reconnect window would
|
||||
# incorrectly free the token while the bot is still active. Token release is
|
||||
# handled exclusively by the discord_leave command and the node offline sweeper.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Status update
|
||||
@@ -122,10 +127,16 @@ class MQTTHandler:
|
||||
status = payload.get("status")
|
||||
if not status:
|
||||
return
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"status": status,
|
||||
"last_seen": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
try:
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"status": status,
|
||||
"last_seen": datetime.now(timezone.utc).isoformat(),
|
||||
})
|
||||
except Exception as e:
|
||||
if "No document to update" in str(e):
|
||||
logger.info(f"Status from deleted/unknown node {node_id} — ignoring (no Firestore doc)")
|
||||
else:
|
||||
raise
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metadata — call_start / call_end events
|
||||
@@ -143,8 +154,8 @@ class MQTTHandler:
|
||||
if not call_id:
|
||||
return
|
||||
|
||||
# Look up assigned system for this node
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
# Look up assigned system for this node (cached — assignment rarely changes)
|
||||
node = await fstore.doc_get_cached("nodes", node_id)
|
||||
system_id = node.get("assigned_system_id") if node else None
|
||||
|
||||
started_at_raw = payload.get("started_at")
|
||||
@@ -157,7 +168,7 @@ class MQTTHandler:
|
||||
# Prefer the name from OP25 metadata; fall back to the system config
|
||||
tgid_name = payload.get("tgid_name") or ""
|
||||
if not tgid_name and system_id and payload.get("tgid"):
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if system_doc:
|
||||
tgid_int = int(payload["tgid"])
|
||||
for tg in system_doc.get("config", {}).get("talkgroups", []):
|
||||
|
||||
@@ -4,7 +4,7 @@ from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
SWEEP_INTERVAL = 30 # seconds
|
||||
SWEEP_INTERVAL = 90 # seconds — matches node_offline_threshold; no gain in checking faster
|
||||
|
||||
|
||||
async def sweeper_loop():
|
||||
|
||||
@@ -46,7 +46,16 @@ async def _run_sweep_pass() -> None:
|
||||
("status", "==", "ended"),
|
||||
("ended_at", ">=", cutoff),
|
||||
])
|
||||
orphans = [c for c in recent_ended if not c.get("incident_id")]
|
||||
# corr_path="unlinked" is written after MAX_SWEEP_ATTEMPTS failures.
|
||||
# Allows a few retries so a welfare-check call can link to an escalation
|
||||
# incident that is created a few minutes later, without sweeping 30× forever.
|
||||
MAX_SWEEP_ATTEMPTS = 3
|
||||
orphans = [
|
||||
c for c in recent_ended
|
||||
if not c.get("incident_ids") and not c.get("incident_id")
|
||||
and not c.get("corr_path") # skip calls already exhausted
|
||||
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
|
||||
]
|
||||
|
||||
if not orphans:
|
||||
return
|
||||
@@ -84,15 +93,26 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
||||
incident_type = call.get("incident_type"),
|
||||
location = call.get("location"),
|
||||
location_coords= call.get("location_coords"),
|
||||
cleared_units = call.get("cleared_units") or [],
|
||||
reference_time = started_at, # anchor window to when the call happened
|
||||
create_if_new = False, # never create — link-only
|
||||
)
|
||||
|
||||
if incident_id:
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": [incident_id]})
|
||||
logger.info(
|
||||
f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}"
|
||||
)
|
||||
return True
|
||||
|
||||
# Increment the attempt counter. Once MAX_SWEEP_ATTEMPTS is reached the
|
||||
# orphan filter above will stop picking this call up, and we write
|
||||
# corr_path="unlinked" as a permanent tombstone.
|
||||
attempts = call.get("corr_sweep_count", 0) + 1
|
||||
update: dict = {"corr_sweep_count": attempts}
|
||||
if attempts >= 3:
|
||||
update["corr_path"] = "unlinked"
|
||||
await fstore.doc_set("calls", call_id, update)
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,21 @@ from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
||||
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
"""Return a safe GCS object name derived from the call_id.
|
||||
|
||||
We ignore the client-supplied filename entirely and derive the name from the
|
||||
call_id (which we control) to prevent path traversal via crafted filenames.
|
||||
The original extension is preserved only if it's a known audio type.
|
||||
"""
|
||||
import os
|
||||
ext = os.path.splitext(filename)[-1].lower() if filename else ""
|
||||
if ext not in (".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"):
|
||||
ext = ".mp3"
|
||||
return f"{call_id}{ext}"
|
||||
|
||||
|
||||
async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optional[str]:
|
||||
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
||||
if not settings.gcs_bucket:
|
||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||
@@ -21,7 +35,8 @@ async def upload_audio(data: bytes, filename: str) -> Optional[str]:
|
||||
client = storage.Client()
|
||||
signing_creds = None
|
||||
bucket = client.bucket(settings.gcs_bucket)
|
||||
blob = bucket.blob(f"calls/{filename}")
|
||||
safe_name = _safe_audio_filename(filename, call_id)
|
||||
blob = bucket.blob(f"calls/{safe_name}")
|
||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||
if signing_creds:
|
||||
return blob.generate_signed_url(
|
||||
|
||||
@@ -16,13 +16,18 @@ from app.config import settings
|
||||
|
||||
|
||||
async def summarizer_loop() -> None:
|
||||
from app.internal.feature_flags import get_flags
|
||||
interval = settings.summary_interval_minutes * 60
|
||||
logger.info(f"Summarizer started — interval: {settings.summary_interval_minutes}m")
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await _run_summary_pass()
|
||||
await _resolve_stale_incidents()
|
||||
flags = await get_flags()
|
||||
if flags["summaries_enabled"]:
|
||||
await _run_summary_pass()
|
||||
await _resolve_stale_incidents()
|
||||
else:
|
||||
logger.info("Summaries disabled — skipping summary pass and stale incident sweep")
|
||||
except Exception as e:
|
||||
logger.error(f"Summarizer pass failed: {e}")
|
||||
|
||||
@@ -97,6 +102,8 @@ async def _resolve_stale_incidents() -> None:
|
||||
idle_minutes = (now - updated_dt).total_seconds() / 60
|
||||
if idle_minutes > settings.incident_auto_resolve_minutes:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
from app.internal.incident_correlator import maybe_resolve_parent
|
||||
await maybe_resolve_parent(incident_id)
|
||||
logger.info(
|
||||
f"Auto-resolved stale incident {incident_id} "
|
||||
f"(idle {idle_minutes:.0f}m)"
|
||||
|
||||
@@ -40,16 +40,9 @@ async def transcribe_call(
|
||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||
return None, []
|
||||
|
||||
# Load vocabulary for this system (empty list if none yet)
|
||||
vocabulary: list[str] = []
|
||||
if system_id:
|
||||
from app.internal.vocabulary_learner import get_vocabulary
|
||||
vocab_data = await get_vocabulary(system_id)
|
||||
vocabulary = vocab_data.get("vocabulary") or []
|
||||
|
||||
try:
|
||||
transcript, segments = await asyncio.to_thread(
|
||||
_sync_transcribe, gcs_uri, talkgroup_name, vocabulary
|
||||
_sync_transcribe, gcs_uri, talkgroup_name
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
||||
@@ -74,7 +67,6 @@ async def transcribe_call(
|
||||
def _sync_transcribe(
|
||||
gcs_uri: str,
|
||||
talkgroup_name: Optional[str] = None,
|
||||
vocabulary: Optional[list[str]] = None,
|
||||
) -> tuple[Optional[str], list[dict]]:
|
||||
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
||||
from google.cloud import storage as gcs
|
||||
@@ -108,27 +100,57 @@ def _sync_transcribe(
|
||||
try:
|
||||
blob.download_to_filename(tmp_path)
|
||||
|
||||
from app.internal.vocabulary_learner import build_whisper_vocab_prompt
|
||||
vocab_prefix = build_whisper_vocab_prompt(vocabulary or [])
|
||||
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||
prompt = tg_prefix + vocab_prefix + _WHISPER_PROMPT
|
||||
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||
# Vocabulary is intentionally excluded from the Whisper prompt.
|
||||
# whisper-1 treats the prompt as a transcription prior and echoes
|
||||
# vocabulary terms into noise/silence, polluting downstream extraction.
|
||||
# Vocabulary context is applied in the GPT extraction step instead,
|
||||
# where it is used as reference rather than a transcription prior.
|
||||
prompt = tg_prefix + _WHISPER_PROMPT
|
||||
|
||||
# Only whisper-1 supports verbose_json (per-segment timestamps + no_speech_prob).
|
||||
# gpt-4o-transcribe and gpt-4o-mini-transcribe only support json/text.
|
||||
use_verbose = settings.stt_model == "whisper-1"
|
||||
|
||||
openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||
with open(tmp_path, "rb") as f:
|
||||
response = openai_client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
model=settings.stt_model,
|
||||
file=f,
|
||||
language="en",
|
||||
prompt=prompt,
|
||||
response_format="verbose_json",
|
||||
response_format="verbose_json" if use_verbose else "json",
|
||||
temperature=0,
|
||||
)
|
||||
text = response.text.strip() or None
|
||||
segments = [
|
||||
{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()}
|
||||
for s in (response.segments or [])
|
||||
if s.text.strip()
|
||||
]
|
||||
return text, segments
|
||||
|
||||
if use_verbose:
|
||||
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
||||
#
|
||||
# 1. Trailing silence / static — Whisper fills silence past real content with
|
||||
# sequential radio codes (10-4, 10-5...). Clamped by audio duration.
|
||||
#
|
||||
# 2. Leading silence — OP25 recordings typically have a short silence at the
|
||||
# start before the first PTT press. Whisper sometimes hallucinates filler
|
||||
# words or codes over this silence. Detected via no_speech_prob > 0.8
|
||||
# (Whisper's own confidence that a segment contains no real speech).
|
||||
audio_duration: float = getattr(response, "duration", None) or float("inf")
|
||||
segments = [
|
||||
{"start": round(s.start, 2), "end": round(s.end, 2), "text": s.text.strip()}
|
||||
for s in (response.segments or [])
|
||||
if s.text.strip()
|
||||
and s.start < audio_duration
|
||||
and getattr(s, "no_speech_prob", 0.0) < 0.8
|
||||
]
|
||||
# Reconstruct text from non-hallucinated segments only so the two stay
|
||||
# in sync. If every segment was filtered, text becomes None which prevents
|
||||
# the intelligence pipeline from running on hallucinated content.
|
||||
text = " ".join(s["text"] for s in segments) or None
|
||||
return text, segments
|
||||
else:
|
||||
# json format returns just {"text": "..."} — no segments or timestamps.
|
||||
# Intelligence extraction falls back to treating the whole transcript as one block.
|
||||
text = (response.text or "").strip() or None
|
||||
return text, []
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -18,7 +18,8 @@ import asyncio
|
||||
import difflib
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
@@ -196,8 +197,8 @@ async def remove_term(system_id: str, term: str) -> None:
|
||||
|
||||
|
||||
async def get_vocabulary(system_id: str) -> dict:
|
||||
"""Return vocabulary and pending terms for a system."""
|
||||
doc = await fstore.doc_get("systems", system_id)
|
||||
"""Return vocabulary and pending terms for a system (TTL-cached, 5 min)."""
|
||||
doc = await fstore.doc_get_cached("systems", system_id)
|
||||
if not doc:
|
||||
return {"vocabulary": [], "vocabulary_pending": [], "vocabulary_bootstrapped": False}
|
||||
return {
|
||||
@@ -243,18 +244,24 @@ def build_gpt_vocab_block(vocabulary: list[str]) -> str:
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def vocabulary_induction_loop() -> None:
|
||||
from app.internal.feature_flags import get_flags
|
||||
interval = settings.vocabulary_induction_interval_hours * 3600
|
||||
logger.info(
|
||||
f"Vocabulary induction loop started — "
|
||||
f"interval: {settings.vocabulary_induction_interval_hours}h, "
|
||||
f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
|
||||
)
|
||||
await asyncio.sleep(30) # short startup grace period before first pass
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await _run_induction_pass()
|
||||
flags = await get_flags()
|
||||
if flags["vocabulary_learning_enabled"]:
|
||||
await _run_induction_pass()
|
||||
else:
|
||||
logger.info("Vocabulary learning disabled — skipping induction pass")
|
||||
except Exception as e:
|
||||
logger.error(f"Vocabulary induction pass failed: {e}")
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
async def _run_induction_pass() -> None:
|
||||
@@ -276,8 +283,14 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
system_name = system_doc.get("name", "Unknown")
|
||||
existing_vocab: list[str] = system_doc.get("vocabulary") or []
|
||||
|
||||
# Fetch recent ended calls for this system
|
||||
all_calls = await fstore.collection_list("calls", system_id=system_id, status="ended")
|
||||
# Fetch calls from the last 7 days only — avoids scanning the entire history.
|
||||
# Active calls have ended_at=None and are excluded by the range filter automatically.
|
||||
# Needs a composite index on (system_id ASC, ended_at ASC).
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
all_calls = await fstore.collection_where("calls", [
|
||||
("system_id", "==", system_id),
|
||||
("ended_at", ">=", cutoff),
|
||||
])
|
||||
if not all_calls:
|
||||
return
|
||||
|
||||
@@ -285,6 +298,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
random.shuffle(all_calls)
|
||||
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
||||
transcript_block = ""
|
||||
sampled_call_docs: list[dict] = []
|
||||
sampled = 0
|
||||
for call in all_calls:
|
||||
text = call.get("transcript_corrected") or call.get("transcript") or ""
|
||||
@@ -294,6 +308,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
break
|
||||
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
||||
transcript_block += f"[{tg}] {text}\n"
|
||||
sampled_call_docs.append(call)
|
||||
sampled += 1
|
||||
|
||||
if sampled < 3:
|
||||
@@ -310,11 +325,16 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
pending_lower = {p["term"].lower() for p in existing_pending}
|
||||
vocab_lower = {t.lower() for t in existing_vocab}
|
||||
|
||||
to_queue = [
|
||||
{"term": t, "source": "induction", "added_at": now}
|
||||
for t in new_terms
|
||||
if t.lower() not in vocab_lower and t.lower() not in pending_lower
|
||||
]
|
||||
to_queue = []
|
||||
for t in new_terms:
|
||||
if t.lower() in vocab_lower or t.lower() in pending_lower:
|
||||
continue
|
||||
to_queue.append({
|
||||
"term": t,
|
||||
"source": "induction",
|
||||
"added_at": now,
|
||||
"source_call_ids": _find_source_calls(t, sampled_call_docs),
|
||||
})
|
||||
if not to_queue:
|
||||
return
|
||||
|
||||
@@ -331,6 +351,30 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
||||
# Internal sync helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _find_source_calls(term: str, sampled_calls: list[dict], max_results: int = 3) -> list[str]:
|
||||
"""
|
||||
Find which sampled calls most likely produced this induction suggestion.
|
||||
Splits the proposed term into tokens and searches call transcripts for overlap.
|
||||
Falls back to the first two sampled calls when no token match is found
|
||||
(e.g. fully garbled terms like "why vac" → "YVAC" have no word overlap).
|
||||
"""
|
||||
tokens = [t.lower() for t in re.split(r"[^a-zA-Z0-9]+", term) if len(t) >= 2]
|
||||
matched: list[str] = []
|
||||
if tokens:
|
||||
for call in sampled_calls:
|
||||
call_id = call.get("call_id")
|
||||
if not call_id:
|
||||
continue
|
||||
text = (call.get("transcript_corrected") or call.get("transcript") or "").lower()
|
||||
if any(tok in text for tok in tokens):
|
||||
matched.append(call_id)
|
||||
if len(matched) >= max_results:
|
||||
break
|
||||
if not matched:
|
||||
matched = [c["call_id"] for c in sampled_calls[:2] if c.get("call_id")]
|
||||
return matched
|
||||
|
||||
|
||||
_STOP_WORDS = {
|
||||
"the", "and", "for", "are", "was", "were", "this", "that", "with",
|
||||
"have", "has", "had", "but", "not", "from", "they", "will", "what",
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||
from app.internal.recorrelation_sweep import recorrelation_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, incidents, alerts
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
@@ -68,7 +68,12 @@ app.include_router(calls.router, dependencies=[Depends(require_service_or_fi
|
||||
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(trips.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(upload.router) # auth is per-node, handled inline
|
||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||
app.include_router(users.router) # auth: admin only
|
||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -33,12 +33,14 @@ class SystemRecord(BaseModel):
|
||||
name: str
|
||||
type: str # P25 / DMR / NBFM
|
||||
config: Dict[str, Any] = {} # OP25-compatible config blob
|
||||
ten_codes: Dict[str, str] = {} # {"10-10": "Commercial Alarm", ...}
|
||||
|
||||
|
||||
class SystemCreate(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
config: Dict[str, Any] = {}
|
||||
ten_codes: Dict[str, str] = {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -56,11 +58,11 @@ class CallRecord(BaseModel):
|
||||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
audio_url: Optional[str] = None
|
||||
transcript: Optional[str] = None # populated later by STT
|
||||
incident_id: Optional[str] = None # populated later by intelligence layer
|
||||
transcript: Optional[str] = None # populated later by STT
|
||||
incident_ids: List[str] = [] # one per scene detected in the recording
|
||||
location: Optional[Dict[str, float]] = None # {lat, lng}
|
||||
tags: List[str] = []
|
||||
status: str = "active" # active / ended
|
||||
status: str = "active" # active / ended
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -132,3 +134,48 @@ class AlertEvent(BaseModel):
|
||||
transcript_snippet: Optional[str] = None
|
||||
triggered_at: Optional[datetime] = None
|
||||
acknowledged: bool = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trips
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TripCreate(BaseModel):
|
||||
name: str
|
||||
location: str
|
||||
maps_link: Optional[str] = None
|
||||
start_date: str # YYYY-MM-DD
|
||||
end_date: str # YYYY-MM-DD
|
||||
available_tags: List[str] = [] # tag labels configured for this trip
|
||||
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
|
||||
visibility: str = "public" # "public" | "private"
|
||||
invited_discord_ids: List[str] = [] # discord user IDs allowed on private trips
|
||||
|
||||
|
||||
class TripEventCreate(BaseModel):
|
||||
title: str
|
||||
date: str # YYYY-MM-DD, must fall within parent trip range
|
||||
start_time: Optional[str] = None # HH:MM (24h)
|
||||
end_time: Optional[str] = None # HH:MM (24h)
|
||||
location: Optional[str] = None # inherits trip location if None
|
||||
maps_link: Optional[str] = None
|
||||
place_id: Optional[str] = None # Google Place ID
|
||||
notes: Optional[str] = None
|
||||
tags: List[str] = [] # tag labels applied to this event
|
||||
|
||||
|
||||
class TripEventUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
start_time: Optional[str] = None
|
||||
end_time: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
maps_link: Optional[str] = None
|
||||
place_id: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AttendeeAction(BaseModel):
|
||||
discord_user_id: str
|
||||
discord_username: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from app.internal.auth import require_admin_token, require_firebase_token
|
||||
from app.internal.feature_flags import get_flags, set_flags
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
async def _get_ai_enabled_system_ids(global_flags: dict) -> set[str]:
|
||||
"""Return system_ids where at least one AI function (STT or correlation) is effectively on."""
|
||||
global_stt = global_flags.get("stt_enabled", True)
|
||||
global_corr = global_flags.get("correlation_enabled", True)
|
||||
all_systems = await fstore.collection_list("systems")
|
||||
enabled: set[str] = set()
|
||||
for system in all_systems:
|
||||
sid = system.get("system_id")
|
||||
if not sid:
|
||||
continue
|
||||
ai_flags = system.get("ai_flags") or {}
|
||||
if ai_flags.get("stt_enabled", global_stt) or ai_flags.get("correlation_enabled", global_corr):
|
||||
enabled.add(sid)
|
||||
return enabled
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/features")
|
||||
async def get_feature_flags(_=Depends(require_firebase_token)):
|
||||
"""Return the current AI feature flag state. Any authenticated user can read."""
|
||||
return await get_flags()
|
||||
|
||||
|
||||
@router.put("/features")
|
||||
async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
|
||||
"""Update one or more AI feature flags. Admin only."""
|
||||
return await set_flags(body)
|
||||
|
||||
|
||||
@router.get("/debug/correlation")
|
||||
async def debug_correlation(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
orphan_hours: int = Query(48, ge=1, le=168),
|
||||
_=Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Return the last N incidents with full correlation debug detail, plus recent orphaned calls.
|
||||
|
||||
Each incident includes a calls_detail array with per-call corr_* fields so you can see
|
||||
exactly which correlation path fired (or didn't) for every call in the incident.
|
||||
|
||||
Embeddings are stripped — they're large float arrays and unreadable.
|
||||
|
||||
Query params:
|
||||
limit — number of incidents to return, sorted by updated_at desc (default 20, max 100)
|
||||
orphan_hours — how far back to scan for orphaned calls (default 48h, max 168h / 1 week)
|
||||
"""
|
||||
def _strip(doc: dict) -> dict:
|
||||
return {k: v for k, v in doc.items() if k != "embedding"}
|
||||
|
||||
def _call_summary(call: dict) -> dict:
|
||||
return {
|
||||
"call_id": call.get("call_id"),
|
||||
"started_at": call.get("started_at"),
|
||||
"ended_at": call.get("ended_at"),
|
||||
"duration_s": call.get("duration_s"),
|
||||
"talkgroup_id": call.get("talkgroup_id"),
|
||||
"talkgroup_name": call.get("talkgroup_name"),
|
||||
"system_id": call.get("system_id"),
|
||||
"node_id": call.get("node_id"),
|
||||
"incident_type": call.get("incident_type"),
|
||||
"tags": call.get("tags"),
|
||||
"location": call.get("location"),
|
||||
"location_coords": call.get("location_coords"),
|
||||
"units": call.get("units"),
|
||||
"vehicles": call.get("vehicles"),
|
||||
"cleared_units": call.get("cleared_units"),
|
||||
"severity": call.get("severity"),
|
||||
"transcript": call.get("transcript_corrected") or call.get("transcript"),
|
||||
# Correlation decision fields written back by incident_correlator
|
||||
"corr_path": call.get("corr_path"),
|
||||
"corr_incident_idle_min": call.get("corr_incident_idle_min"),
|
||||
"corr_distance_km": call.get("corr_distance_km"),
|
||||
"corr_score": call.get("corr_score"),
|
||||
"corr_candidates": call.get("corr_candidates"),
|
||||
"corr_shared_units": call.get("corr_shared_units"),
|
||||
"corr_fit_signal": call.get("corr_fit_signal"),
|
||||
"corr_matched_units": call.get("corr_matched_units"),
|
||||
"corr_sweep_count": call.get("corr_sweep_count"),
|
||||
"skip_reason": call.get("skip_reason"),
|
||||
}
|
||||
|
||||
# ── Determine which systems have AI active ────────────────────────────────
|
||||
global_flags = await get_flags()
|
||||
ai_systems = await _get_ai_enabled_system_ids(global_flags)
|
||||
|
||||
# ── Fetch recent incidents (AI-enabled systems only) ──────────────────────
|
||||
all_incidents = await fstore.collection_list("incidents")
|
||||
all_incidents.sort(key=lambda i: i.get("updated_at", ""), reverse=True)
|
||||
ai_incidents = [
|
||||
i for i in all_incidents
|
||||
if any(sid in ai_systems for sid in (i.get("system_ids") or []))
|
||||
]
|
||||
incidents = ai_incidents[:limit]
|
||||
|
||||
# ── Fetch all linked call docs in parallel ────────────────────────────────
|
||||
all_call_ids: list[str] = []
|
||||
for inc in incidents:
|
||||
all_call_ids.extend(inc.get("call_ids") or [])
|
||||
unique_call_ids = list(dict.fromkeys(all_call_ids)) # dedupe, preserve order
|
||||
|
||||
call_docs = await asyncio.gather(*(fstore.doc_get("calls", cid) for cid in unique_call_ids))
|
||||
call_map: dict[str, dict] = {doc["call_id"]: doc for doc in call_docs if doc}
|
||||
|
||||
# ── Build incident debug records ──────────────────────────────────────────
|
||||
incident_records = []
|
||||
for inc in incidents:
|
||||
rec = _strip(inc)
|
||||
rec["calls_detail"] = [
|
||||
_call_summary(call_map[cid])
|
||||
for cid in (inc.get("call_ids") or [])
|
||||
if cid in call_map
|
||||
]
|
||||
incident_records.append(rec)
|
||||
|
||||
# ── Recent orphaned calls (AI-enabled systems only) ───────────────────────
|
||||
# Use a single-field range query to avoid requiring a composite Firestore index;
|
||||
# filter status and system in Python.
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=orphan_hours)
|
||||
recent_calls = await fstore.collection_where("calls", [
|
||||
("ended_at", ">=", cutoff),
|
||||
])
|
||||
orphans = [
|
||||
_call_summary(c) for c in recent_calls
|
||||
if c.get("status") == "ended"
|
||||
and not c.get("incident_ids") and not c.get("incident_id")
|
||||
and c.get("system_id") in ai_systems
|
||||
]
|
||||
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
||||
|
||||
# Summarise orphans by talkgroup so the volume and source are immediately visible.
|
||||
orphans_by_tg: dict[str, dict] = {}
|
||||
for o in orphans:
|
||||
tg_key = str(o.get("talkgroup_id") or "unknown")
|
||||
if tg_key not in orphans_by_tg:
|
||||
orphans_by_tg[tg_key] = {
|
||||
"talkgroup_id": o.get("talkgroup_id"),
|
||||
"talkgroup_name": o.get("talkgroup_name") or "unknown",
|
||||
"count": 0,
|
||||
"no_type_count": 0,
|
||||
"sweep_exhausted_count": 0,
|
||||
}
|
||||
orphans_by_tg[tg_key]["count"] += 1
|
||||
if not o.get("incident_type") and not o.get("tags"):
|
||||
orphans_by_tg[tg_key]["no_type_count"] += 1
|
||||
if (o.get("corr_sweep_count") or 0) >= 3:
|
||||
orphans_by_tg[tg_key]["sweep_exhausted_count"] += 1
|
||||
|
||||
return {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"incident_count": len(incident_records),
|
||||
"orphaned_call_count": len(orphans),
|
||||
"orphans_by_talkgroup": sorted(orphans_by_tg.values(), key=lambda x: x["count"], reverse=True),
|
||||
"incidents": incident_records,
|
||||
"orphaned_calls": orphans[:250],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def get_audit_log(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_=Depends(require_admin_token),
|
||||
):
|
||||
"""Return paginated audit log entries, most recent first."""
|
||||
entries = await fstore.collection_list("audit_log")
|
||||
entries.sort(key=lambda e: e.get("timestamp", ""), reverse=True)
|
||||
return entries[offset: offset + limit]
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
@@ -59,6 +60,50 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
||||
return {"ok": True, "call_id": call_id}
|
||||
|
||||
|
||||
@router.post("/close-stale")
|
||||
async def close_stale_calls(
|
||||
older_than_minutes: int = Query(30, ge=1, le=1440, description="Close active calls started more than this many minutes ago."),
|
||||
dry_run: bool = Query(False, description="If true, return what would be closed without writing."),
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Find and close calls stuck in 'active' status — e.g. because a node rebooted
|
||||
before sending an end-call event. Returns the list of affected call IDs.
|
||||
"""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=older_than_minutes)
|
||||
active_calls = await fstore.collection_list("calls", status="active")
|
||||
|
||||
stale = []
|
||||
for call in active_calls:
|
||||
started_raw = call.get("started_at")
|
||||
if not started_raw:
|
||||
continue
|
||||
if isinstance(started_raw, datetime):
|
||||
started = started_raw if started_raw.tzinfo else started_raw.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
try:
|
||||
started = datetime.fromisoformat(str(started_raw).replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
continue
|
||||
if started < cutoff:
|
||||
stale.append(call)
|
||||
|
||||
if not dry_run:
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for call in stale:
|
||||
await fstore.doc_set("calls", call["call_id"], {
|
||||
"status": "ended",
|
||||
"ended_at": now_iso,
|
||||
})
|
||||
|
||||
return {
|
||||
"dry_run": dry_run,
|
||||
"older_than_minutes": older_than_minutes,
|
||||
"count": len(stale),
|
||||
"call_ids": [c["call_id"] for c in stale],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{call_id}/transcript")
|
||||
async def patch_transcript(
|
||||
call_id: str,
|
||||
@@ -83,6 +128,28 @@ async def patch_transcript(
|
||||
"embedding": None,
|
||||
})
|
||||
|
||||
# Unlink from ALL current incidents so re-correlation starts clean.
|
||||
# Handles both old single incident_id and new incident_ids list.
|
||||
old_ids: list[str] = call.get("incident_ids") or (
|
||||
[call["incident_id"]] if call.get("incident_id") else []
|
||||
)
|
||||
for old_incident_id in old_ids:
|
||||
old_incident = await fstore.doc_get("incidents", old_incident_id)
|
||||
if old_incident:
|
||||
remaining = [c for c in (old_incident.get("call_ids") or []) if c != call_id]
|
||||
if remaining:
|
||||
await fstore.doc_set("incidents", old_incident_id, {
|
||||
"call_ids": remaining,
|
||||
"summary_stale": True,
|
||||
})
|
||||
else:
|
||||
await fstore.doc_set("incidents", old_incident_id, {
|
||||
"call_ids": [],
|
||||
"status": "resolved",
|
||||
"summary_stale": True,
|
||||
})
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": [], "incident_id": None})
|
||||
|
||||
# Learn from the correction: diff original → corrected and add new tokens to vocabulary
|
||||
system_id = call.get("system_id")
|
||||
original_text = call.get("transcript_corrected") or call.get("transcript") or ""
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||
from app.models import IncidentCreate, IncidentUpdate
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.auth import require_admin_token, require_service_or_firebase_token, summarize_limiter
|
||||
|
||||
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
||||
|
||||
@@ -20,7 +20,10 @@ async def list_incidents(status: Optional[str] = None, type: Optional[str] = Non
|
||||
|
||||
|
||||
@router.post("/summarize")
|
||||
async def summarize_all_stale(background_tasks: BackgroundTasks):
|
||||
async def summarize_all_stale(
|
||||
background_tasks: BackgroundTasks,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
||||
from app.internal.summarizer import _run_summary_pass
|
||||
background_tasks.add_task(_run_summary_pass)
|
||||
@@ -76,12 +79,18 @@ async def delete_incident(incident_id: str, _: dict = Depends(require_admin_toke
|
||||
|
||||
|
||||
@router.post("/{incident_id}/summarize")
|
||||
async def summarize_incident(incident_id: str, background_tasks: BackgroundTasks):
|
||||
async def summarize_incident(
|
||||
incident_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
decoded: dict = Depends(require_service_or_firebase_token),
|
||||
):
|
||||
"""Immediately run the summarizer for a specific incident."""
|
||||
from app.internal.summarizer import _summarize_incident
|
||||
inc = await fstore.doc_get("incidents", incident_id)
|
||||
if not inc:
|
||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
||||
# Rate limit by incident ID to prevent repeated expensive LLM calls
|
||||
summarize_limiter.check(incident_id)
|
||||
background_tasks.add_task(_summarize_incident, inc)
|
||||
return {"ok": True, "incident_id": incident_id}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from uuid import uuid4
|
||||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_firebase_token, require_service_key
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
_CODE_TTL_MINUTES = 15
|
||||
|
||||
|
||||
def _gen_code() -> str:
|
||||
return "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web: generate a short-lived linking code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/link/generate")
|
||||
async def generate_link_code(decoded: dict = Depends(require_firebase_token)):
|
||||
"""Authenticated Firebase user generates a code to paste into Discord /link."""
|
||||
firebase_uid = decoded["uid"]
|
||||
|
||||
# Check if already linked
|
||||
existing = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
if existing and existing.get("discord_user_id"):
|
||||
return {
|
||||
"already_linked": True,
|
||||
"discord_user_id": existing["discord_user_id"],
|
||||
}
|
||||
|
||||
code = _gen_code()
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=_CODE_TTL_MINUTES)).isoformat()
|
||||
await fstore.doc_set("link_codes", code, {
|
||||
"firebase_uid": firebase_uid,
|
||||
"expires_at": expires_at,
|
||||
}, merge=False)
|
||||
|
||||
return {"code": code, "expires_minutes": _CODE_TTL_MINUTES}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discord bot: resolve a code and store the link
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LinkResolveBody(BaseModel):
|
||||
code: str
|
||||
discord_user_id: str
|
||||
discord_username: str = ""
|
||||
|
||||
|
||||
@router.post("/link")
|
||||
async def resolve_link_code(body: LinkResolveBody, _: dict = Depends(require_service_key)):
|
||||
"""Discord bot resolves a linking code and permanently links the accounts."""
|
||||
doc = await fstore.doc_get("link_codes", body.code.upper().strip())
|
||||
if not doc:
|
||||
raise HTTPException(404, "Invalid or expired code.")
|
||||
|
||||
expires_at = datetime.fromisoformat(doc["expires_at"])
|
||||
if datetime.now(timezone.utc) > expires_at:
|
||||
await fstore.doc_delete("link_codes", body.code)
|
||||
raise HTTPException(410, "Code has expired. Generate a new one from the web app.")
|
||||
|
||||
firebase_uid = doc["firebase_uid"]
|
||||
|
||||
# Check if this Discord account is already linked to a different Firebase UID
|
||||
existing = await fstore.doc_get("discord_links", body.discord_user_id)
|
||||
if existing and existing.get("firebase_uid") and existing["firebase_uid"] != firebase_uid:
|
||||
raise HTTPException(409, "This Discord account is already linked to a different account.")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Store both directions
|
||||
await fstore.doc_set("discord_links", body.discord_user_id, {
|
||||
"firebase_uid": firebase_uid,
|
||||
"discord_username": body.discord_username,
|
||||
"linked_at": now,
|
||||
}, merge=False)
|
||||
|
||||
await fstore.doc_set("firebase_discord_links", firebase_uid, {
|
||||
"discord_user_id": body.discord_user_id,
|
||||
"discord_username": body.discord_username,
|
||||
"linked_at": now,
|
||||
}, merge=False)
|
||||
|
||||
# Clean up the code
|
||||
await fstore.doc_delete("link_codes", body.code)
|
||||
|
||||
logger.info(f"Linked firebase_uid={firebase_uid} <-> discord_user_id={body.discord_user_id}")
|
||||
return {"ok": True, "firebase_uid": firebase_uid}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web: check current link status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/link/status")
|
||||
async def link_status(decoded: dict = Depends(require_firebase_token)):
|
||||
firebase_uid = decoded["uid"]
|
||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
if link and link.get("discord_user_id"):
|
||||
return {
|
||||
"linked": True,
|
||||
"discord_user_id": link["discord_user_id"],
|
||||
"discord_username": link.get("discord_username", ""),
|
||||
"linked_at": link.get("linked_at"),
|
||||
}
|
||||
return {"linked": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web: unlink
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/link")
|
||||
async def unlink(decoded: dict = Depends(require_firebase_token)):
|
||||
firebase_uid = decoded["uid"]
|
||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
if not link or not link.get("discord_user_id"):
|
||||
raise HTTPException(404, "No linked Discord account.")
|
||||
discord_user_id = link["discord_user_id"]
|
||||
await fstore.doc_delete("discord_links", discord_user_id)
|
||||
await fstore.doc_delete("firebase_discord_links", firebase_uid)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session recording — called by the frontend on each successful sign-in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/session")
|
||||
async def record_session(request: Request, decoded: dict = Depends(require_firebase_token)):
|
||||
"""Record a sign-in event for the authenticated user."""
|
||||
session_id = str(uuid4())
|
||||
ip = request.client.host if request.client else None
|
||||
user_agent = request.headers.get("user-agent", "")
|
||||
|
||||
await fstore.doc_set("user_sessions", session_id, {
|
||||
"session_id": session_id,
|
||||
"uid": decoded["uid"],
|
||||
"email": decoded.get("email", ""),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ip": ip,
|
||||
"user_agent": user_agent,
|
||||
}, merge=False)
|
||||
|
||||
return {"ok": True}
|
||||
@@ -1,9 +1,10 @@
|
||||
import secrets
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from app.models import CommandPayload
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||
from app.routers.tokens import assign_token, release_token
|
||||
|
||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
||||
@@ -35,6 +36,15 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{node_id}", status_code=204)
|
||||
async def delete_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
await fstore.doc_delete("node_keys", node_id)
|
||||
await fstore.doc_delete("nodes", node_id)
|
||||
|
||||
|
||||
@router.post("/{node_id}/reject")
|
||||
async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
@@ -45,7 +55,11 @@ async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||
|
||||
|
||||
@router.post("/{node_id}/command")
|
||||
async def send_command(node_id: str, cmd: CommandPayload):
|
||||
async def send_command(
|
||||
node_id: str,
|
||||
cmd: CommandPayload,
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
@@ -53,12 +67,24 @@ async def send_command(node_id: str, cmd: CommandPayload):
|
||||
payload = cmd.model_dump(exclude_none=True)
|
||||
|
||||
if cmd.action == "discord_join":
|
||||
preferred = payload.pop("preferred_token_id", None)
|
||||
# Resolve system doc once — used for preferred token and presence name.
|
||||
system_doc = None
|
||||
system_id = node.get("assigned_system_id")
|
||||
if system_id:
|
||||
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
|
||||
# Explicit preferred_token_id in the request beats the system-level preference.
|
||||
preferred = payload.pop("preferred_token_id", None) or (system_doc or {}).get("preferred_token_id")
|
||||
token = await assign_token(node_id, preferred_token_id=preferred)
|
||||
if not token:
|
||||
raise HTTPException(503, "No Discord bot tokens available in the pool.")
|
||||
payload["token"] = token
|
||||
|
||||
# Pass system name so the bot can set its Discord presence on join.
|
||||
system_name = (system_doc or {}).get("name")
|
||||
if system_name:
|
||||
payload["system_name"] = system_name
|
||||
|
||||
elif cmd.action == "discord_leave":
|
||||
await release_token(node_id)
|
||||
|
||||
@@ -81,7 +107,13 @@ async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token))
|
||||
|
||||
|
||||
@router.post("/{node_id}/config/{system_id}")
|
||||
async def assign_system(node_id: str, system_id: str):
|
||||
async def assign_system(
|
||||
node_id: str,
|
||||
system_id: str,
|
||||
hardware_preset: str = Query("rtl-sdr-v3"),
|
||||
ppm_override: Optional[float] = Query(None),
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
"""
|
||||
Assign a system to a node. Fetches the system config from Firestore
|
||||
and pushes it to the node via MQTT, then marks the node as configured.
|
||||
@@ -94,13 +126,22 @@ async def assign_system(node_id: str, system_id: str):
|
||||
if not system:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
|
||||
# Push config to the node via MQTT
|
||||
mqtt_handler.push_config(node_id, system)
|
||||
# Include hardware preset in the push so the edge node applies it when
|
||||
# generating the OP25 config. Strip it from the system doc first so it
|
||||
# doesn't collide with SystemConfig field validation on the node side.
|
||||
push_payload = {**system, "hardware_preset": hardware_preset}
|
||||
if ppm_override is not None:
|
||||
push_payload["ppm_override"] = ppm_override
|
||||
mqtt_handler.push_config(node_id, push_payload)
|
||||
|
||||
# Update Firestore
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
node_updates = {
|
||||
"assigned_system_id": system_id,
|
||||
"configured": True,
|
||||
})
|
||||
"hardware_preset": hardware_preset,
|
||||
}
|
||||
if ppm_override is not None:
|
||||
node_updates["ppm_override"] = ppm_override
|
||||
await fstore.doc_update("nodes", node_id, node_updates)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/places", tags=["places"])
|
||||
|
||||
_PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
|
||||
_ROUTES_URL = "https://routes.googleapis.com/directions/v2:computeRoutes"
|
||||
_PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri,places.location"
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def search_places(query: str = Query(...), near: str = Query("")):
|
||||
if not settings.google_maps_api_key:
|
||||
raise HTTPException(503, "Google Maps API not configured.")
|
||||
|
||||
full_query = f"{query} {near}".strip()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
_PLACES_SEARCH_URL,
|
||||
json={"textQuery": full_query},
|
||||
headers={
|
||||
"X-Goog-Api-Key": settings.google_maps_api_key,
|
||||
"X-Goog-FieldMask": _PLACES_FIELDS,
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Places search failed: {e}")
|
||||
raise HTTPException(502, "Places search failed.")
|
||||
|
||||
return [
|
||||
{
|
||||
"name": p.get("displayName", {}).get("text"),
|
||||
"address": p.get("formattedAddress"),
|
||||
"place_id": p.get("id"),
|
||||
"lat": p.get("location", {}).get("latitude"),
|
||||
"lng": p.get("location", {}).get("longitude"),
|
||||
"maps_link": p.get("googleMapsUri"),
|
||||
"rating": p.get("rating"),
|
||||
}
|
||||
for p in data.get("places", [])[:6]
|
||||
]
|
||||
|
||||
|
||||
@router.get("/directions")
|
||||
async def get_directions(
|
||||
origin: str = Query(...),
|
||||
destination: str = Query(...),
|
||||
):
|
||||
if not settings.google_maps_api_key:
|
||||
raise HTTPException(503, "Google Maps API not configured.")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
_ROUTES_URL,
|
||||
json={
|
||||
"origin": {"address": origin},
|
||||
"destination": {"address": destination},
|
||||
"travelMode": "DRIVE",
|
||||
},
|
||||
headers={
|
||||
"X-Goog-Api-Key": settings.google_maps_api_key,
|
||||
"X-Goog-FieldMask": "routes.duration,routes.distanceMeters",
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Directions failed: {e}")
|
||||
raise HTTPException(502, "Directions request failed.")
|
||||
|
||||
routes = data.get("routes", [])
|
||||
if not routes:
|
||||
return {"duration_text": None, "duration_seconds": None, "distance_text": None}
|
||||
|
||||
route = routes[0]
|
||||
duration_seconds = int(route.get("duration", "0s").rstrip("s") or 0)
|
||||
distance_m = route.get("distanceMeters", 0)
|
||||
|
||||
# Format human-readable strings
|
||||
hours, rem = divmod(duration_seconds, 3600)
|
||||
mins = rem // 60
|
||||
if hours:
|
||||
duration_text = f"{hours} hr {mins} min" if mins else f"{hours} hr"
|
||||
else:
|
||||
duration_text = f"{mins} min"
|
||||
|
||||
miles = distance_m / 1609.34
|
||||
distance_text = f"{miles:.1f} mi"
|
||||
|
||||
return {
|
||||
"duration_text": duration_text,
|
||||
"duration_seconds": duration_seconds,
|
||||
"distance_text": distance_text,
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
from app.models import SystemCreate, SystemRecord
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token, bootstrap_limiter
|
||||
|
||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||
|
||||
@@ -12,6 +13,15 @@ class VocabularyTermBody(BaseModel):
|
||||
term: str
|
||||
|
||||
|
||||
class TenCodesBody(BaseModel):
|
||||
ten_codes: Dict[str, str]
|
||||
|
||||
|
||||
class AiFlagsBody(BaseModel):
|
||||
stt_enabled: Optional[bool] = None
|
||||
correlation_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_systems():
|
||||
return await fstore.collection_list("systems")
|
||||
@@ -26,7 +36,7 @@ async def get_system(system_id: str):
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_system(body: SystemCreate):
|
||||
async def create_system(body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||
system_id = str(uuid.uuid4())
|
||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
||||
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||
@@ -34,7 +44,7 @@ async def create_system(body: SystemCreate):
|
||||
|
||||
|
||||
@router.put("/{system_id}")
|
||||
async def update_system(system_id: str, body: SystemCreate):
|
||||
async def update_system(system_id: str, body: SystemCreate, _: dict = Depends(require_admin_token)):
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
@@ -43,13 +53,66 @@ async def update_system(system_id: str, body: SystemCreate):
|
||||
|
||||
|
||||
@router.delete("/{system_id}", status_code=204)
|
||||
async def delete_system(system_id: str):
|
||||
async def delete_system(system_id: str, _: dict = Depends(require_admin_token)):
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
await fstore.doc_delete("systems", system_id)
|
||||
|
||||
|
||||
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
||||
|
||||
@router.put("/{system_id}/ai-flags")
|
||||
async def update_system_ai_flags(
|
||||
system_id: str,
|
||||
body: AiFlagsBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Set per-system AI flag overrides. Only fields included in the body are
|
||||
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
||||
Pass null to clear an override and fall back to the global flag.
|
||||
"""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
|
||||
current: dict = existing.get("ai_flags") or {}
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
if value is None:
|
||||
current.pop(field, None) # clear override → inherit global
|
||||
else:
|
||||
current[field] = value
|
||||
|
||||
await fstore.doc_update("systems", system_id, {"ai_flags": current})
|
||||
return {"ok": True, "ai_flags": current}
|
||||
|
||||
|
||||
# ── Ten-codes endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{system_id}/ten-codes")
|
||||
async def get_ten_codes(system_id: str):
|
||||
"""Return the ten-code dictionary for a system."""
|
||||
system = await fstore.doc_get("systems", system_id)
|
||||
if not system:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
return {"ten_codes": system.get("ten_codes") or {}}
|
||||
|
||||
|
||||
@router.put("/{system_id}/ten-codes")
|
||||
async def update_ten_codes(
|
||||
system_id: str,
|
||||
body: TenCodesBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Replace the ten-code dictionary for a system."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
await fstore.doc_update("systems", system_id, {"ten_codes": body.ten_codes})
|
||||
return {"ok": True, "ten_codes": body.ten_codes}
|
||||
|
||||
|
||||
# ── Vocabulary endpoints ───────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{system_id}/vocabulary")
|
||||
@@ -63,18 +126,26 @@ async def get_vocabulary(system_id: str):
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
||||
async def bootstrap_vocabulary(system_id: str):
|
||||
async def bootstrap_vocabulary(
|
||||
system_id: str,
|
||||
decoded: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
bootstrap_limiter.check(system_id)
|
||||
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
||||
terms = await bootstrap_system_vocabulary(system_id)
|
||||
return {"added": len(terms), "terms": terms}
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/terms")
|
||||
async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
async def add_vocabulary_term(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Manually add a term to the approved vocabulary."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -85,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@router.delete("/{system_id}/vocabulary/terms")
|
||||
async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
async def remove_vocabulary_term(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Remove a term from the approved vocabulary."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -96,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/pending/approve")
|
||||
async def approve_pending(system_id: str, body: VocabularyTermBody):
|
||||
async def approve_pending(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Move a pending induction suggestion into the approved vocabulary."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
@@ -107,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
|
||||
|
||||
|
||||
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
||||
async def dismiss_pending(system_id: str, body: VocabularyTermBody):
|
||||
async def dismiss_pending(
|
||||
system_id: str,
|
||||
body: VocabularyTermBody,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""Dismiss a pending induction suggestion without adding it."""
|
||||
existing = await fstore.doc_get("systems", system_id)
|
||||
if not existing:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
|
||||
@@ -22,13 +23,13 @@ async def list_tokens():
|
||||
"""List all tokens. The actual token string is masked for safety."""
|
||||
tokens = await fstore.collection_list("bot_tokens")
|
||||
return [
|
||||
{**t, "token": t["token"][:10] + "…" + t["token"][-4:]}
|
||||
{**t, "token": "•••" + t["token"][-4:]}
|
||||
for t in tokens
|
||||
]
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def add_token(body: TokenCreate):
|
||||
async def add_token(body: TokenCreate, _: dict = Depends(require_admin_token)):
|
||||
token_id = str(uuid.uuid4())
|
||||
doc = {
|
||||
"token_id": token_id,
|
||||
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
|
||||
|
||||
|
||||
@router.post("/flush", status_code=200)
|
||||
async def flush_tokens():
|
||||
async def flush_tokens(_: dict = Depends(require_admin_token)):
|
||||
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
||||
def _find():
|
||||
from app.internal.firestore import db
|
||||
@@ -60,8 +61,40 @@ async def flush_tokens():
|
||||
return {"released": len(results)}
|
||||
|
||||
|
||||
@router.put("/{token_id}/prefer/{system_id}", status_code=200)
|
||||
async def set_preferred_system(
|
||||
token_id: str,
|
||||
system_id: str,
|
||||
_: dict = Depends(require_admin_token),
|
||||
):
|
||||
"""
|
||||
Mark this token as the preferred bot for a system.
|
||||
When a discord_join is issued for any node in that system, this token
|
||||
is tried first before falling back to the general pool.
|
||||
Pass system_id="_none" to clear the preference.
|
||||
"""
|
||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
if system_id == "_none":
|
||||
# Clear any existing preference on the system that pointed to this token.
|
||||
system_doc = await fstore.doc_get("systems", existing.get("preferred_for_system_id", ""))
|
||||
if system_doc:
|
||||
await fstore.doc_set("systems", existing["preferred_for_system_id"], {"preferred_token_id": None})
|
||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": None})
|
||||
return {"ok": True, "preferred_for_system_id": None}
|
||||
|
||||
system_doc = await fstore.doc_get("systems", system_id)
|
||||
if not system_doc:
|
||||
raise HTTPException(404, "System not found.")
|
||||
# Set preference on both sides for easy lookup in either direction.
|
||||
await fstore.doc_set("systems", system_id, {"preferred_token_id": token_id})
|
||||
await fstore.doc_set("bot_tokens", token_id, {"preferred_for_system_id": system_id})
|
||||
return {"ok": True, "preferred_for_system_id": system_id}
|
||||
|
||||
|
||||
@router.delete("/{token_id}", status_code=204)
|
||||
async def delete_token(token_id: str):
|
||||
async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
||||
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||
if not existing:
|
||||
raise HTTPException(404, "Token not found.")
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
import uuid
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.models import TripCreate, TripEventCreate, TripEventUpdate, AttendeeAction
|
||||
from app.internal import firestore as fstore
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal.auth import (
|
||||
require_service_or_firebase_token,
|
||||
require_service_key,
|
||||
require_service_key_or_admin,
|
||||
trip_chat_limiter,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/trips", tags=["trips"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access control helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _discord_id_for_firebase(firebase_uid: str) -> Optional[str]:
|
||||
link = await fstore.doc_get("firebase_discord_links", firebase_uid)
|
||||
return (link or {}).get("discord_user_id")
|
||||
|
||||
|
||||
def _trip_is_accessible(trip: dict, *, is_service: bool, firebase_uid: Optional[str], discord_id: Optional[str]) -> bool:
|
||||
"""Return True if the caller may read this trip."""
|
||||
if is_service:
|
||||
return True # bot sees all; it filters client-side per-user
|
||||
if trip.get("visibility", "public") == "public":
|
||||
return True
|
||||
if not firebase_uid:
|
||||
return False
|
||||
# attendees keyed by discord_id — check linked discord_id
|
||||
if discord_id:
|
||||
if discord_id in trip.get("attendees", {}):
|
||||
return True
|
||||
if discord_id in trip.get("invited_discord_ids", []):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI assistant — tool definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_places",
|
||||
"description": (
|
||||
"Search Google Maps for places (restaurants, bars, attractions, hotels, venues). "
|
||||
"Use this whenever the user asks about specific places or you need to find options."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "What to search for, e.g. 'rooftop bars', 'Italian restaurants'",
|
||||
},
|
||||
"near": {
|
||||
"type": "string",
|
||||
"description": "Location to search near, e.g. 'downtown Nashville, TN'",
|
||||
},
|
||||
},
|
||||
"required": ["query", "near"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add_tag",
|
||||
"description": (
|
||||
"Add a new tag to the trip's available tag list so it can be used on events. "
|
||||
"Use this when you want to apply a tag that doesn't exist yet."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'",
|
||||
},
|
||||
},
|
||||
"required": ["tag"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "propose_event",
|
||||
"description": (
|
||||
"Propose a specific event to add to the itinerary. "
|
||||
"The user will see a card and can approve or dismiss it. "
|
||||
"Call this once per proposed event — do not bundle multiple events into one call."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"date": {"type": "string", "description": "YYYY-MM-DD — must be within the trip date range"},
|
||||
"start_time": {"type": "string", "description": "HH:MM (24h), e.g. '19:30'"},
|
||||
"end_time": {"type": "string", "description": "HH:MM (24h), e.g. '22:00'"},
|
||||
"location": {"type": "string", "description": "Full address or place name"},
|
||||
"maps_link": {"type": "string", "description": "Google Maps URL"},
|
||||
"notes": {"type": "string", "description": "Brief tips or reasoning"},
|
||||
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
_PLACES_SEARCH_URL = "https://places.googleapis.com/v1/places:searchText"
|
||||
_PLACES_FIELDS = "places.id,places.displayName,places.formattedAddress,places.rating,places.googleMapsUri"
|
||||
|
||||
|
||||
async def _places_search(query: str, near: str) -> list[dict]:
|
||||
if not settings.google_maps_api_key:
|
||||
return []
|
||||
full_query = f"{query} {near}".strip()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
r = await client.post(
|
||||
_PLACES_SEARCH_URL,
|
||||
json={"textQuery": full_query},
|
||||
headers={
|
||||
"X-Goog-Api-Key": settings.google_maps_api_key,
|
||||
"X-Goog-FieldMask": _PLACES_FIELDS,
|
||||
},
|
||||
)
|
||||
data = r.json()
|
||||
places = data.get("places", [])
|
||||
logger.info(f"Places search '{full_query}': count={len(places)}")
|
||||
if not places and "error" in data:
|
||||
logger.warning(f"Places API error: {data['error'].get('message', '')}")
|
||||
return [
|
||||
{
|
||||
"name": p.get("displayName", {}).get("text"),
|
||||
"address": p.get("formattedAddress"),
|
||||
"place_id": p.get("id"),
|
||||
"maps_link": p.get("googleMapsUri"),
|
||||
"rating": p.get("rating"),
|
||||
}
|
||||
for p in places[:5]
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Places search in assistant failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def _build_system_prompt(trip: dict, events: list[dict]) -> str:
|
||||
by_date: dict[str, list] = {}
|
||||
for e in sorted(events, key=lambda x: (x.get("date", ""), x.get("start_time") or "")):
|
||||
by_date.setdefault(e["date"], []).append(e)
|
||||
|
||||
lines = []
|
||||
for date, day_events in sorted(by_date.items()):
|
||||
lines.append(f"\n {date}:")
|
||||
for e in day_events:
|
||||
t = ""
|
||||
if e.get("start_time"):
|
||||
t = f" {e['start_time']}"
|
||||
if e.get("end_time"):
|
||||
t += f"–{e['end_time']}"
|
||||
loc = f" @ {e['location']}" if e.get("location") and not e.get("location_inherited") else ""
|
||||
lines.append(f" • {e['title']}{t}{loc}")
|
||||
if e.get("notes"):
|
||||
lines.append(f" Notes: {e['notes']}")
|
||||
|
||||
itinerary = "".join(lines) if lines else "\n (no events yet)"
|
||||
attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified"
|
||||
available_tags = trip.get("available_tags") or []
|
||||
tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else ""
|
||||
|
||||
return f"""You are a trip planning assistant for the following trip.
|
||||
|
||||
Trip: {trip["name"]}
|
||||
Destination: {trip["location"]}
|
||||
Dates: {trip["start_date"]} to {trip["end_date"]}
|
||||
Attendees: {attendees}{tags_section}
|
||||
|
||||
Current itinerary:{itinerary}
|
||||
|
||||
Guidelines:
|
||||
- Be conversational and concise — don't over-explain.
|
||||
- Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links.
|
||||
- When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing.
|
||||
- Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one.
|
||||
- When proposing events, apply relevant tags. Before using a tag, check if it exists in the available tags list. If it doesn't, call `add_tag` first to create it, then use it in `propose_event`.
|
||||
- Be mindful of the existing schedule when assigning times. Avoid obvious conflicts.
|
||||
- All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}.
|
||||
- If the user says something like "everyone should be there by 6", factor that into your time proposals.
|
||||
- If you don't know a specific address, search for the place first."""
|
||||
|
||||
|
||||
class ChatMsg(BaseModel):
|
||||
role: str
|
||||
content: str
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
history: list[ChatMsg] = []
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_trips(decoded: dict = Depends(require_service_or_firebase_token)):
|
||||
trips = await fstore.collection_list("trips")
|
||||
is_service = bool(decoded.get("service"))
|
||||
firebase_uid = decoded.get("uid")
|
||||
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
|
||||
return [t for t in trips if _trip_is_accessible(t, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id)]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_trip(body: TripCreate):
|
||||
if body.end_date < body.start_date:
|
||||
raise HTTPException(400, "end_date must be on or after start_date.")
|
||||
trip_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"trip_id": trip_id,
|
||||
"name": body.name,
|
||||
"location": body.location,
|
||||
"maps_link": body.maps_link,
|
||||
"start_date": body.start_date,
|
||||
"end_date": body.end_date,
|
||||
"attendees": {}, # {discord_user_id: discord_username}
|
||||
"available_tags": body.available_tags,
|
||||
"overlap_tags": body.overlap_tags,
|
||||
"visibility": body.visibility if body.visibility in ("public", "private") else "public",
|
||||
"invited_discord_ids": body.invited_discord_ids,
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trips", trip_id, doc, merge=False)
|
||||
return doc
|
||||
|
||||
|
||||
@router.get("/{trip_id}")
|
||||
async def get_trip(trip_id: str, decoded: dict = Depends(require_service_or_firebase_token)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
is_service = bool(decoded.get("service"))
|
||||
firebase_uid = decoded.get("uid")
|
||||
discord_id = await _discord_id_for_firebase(firebase_uid) if firebase_uid else None
|
||||
if not _trip_is_accessible(trip, is_service=is_service, firebase_uid=firebase_uid, discord_id=discord_id):
|
||||
raise HTTPException(403, "This trip is private.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
events.sort(key=lambda e: (e["date"], e.get("start_time") or ""))
|
||||
return {**trip, "events": events}
|
||||
|
||||
|
||||
@router.put("/{trip_id}/tags")
|
||||
async def update_trip_tags(trip_id: str, body: dict):
|
||||
"""Replace the trip's available tag list and overlap-allowed tag list."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
tags = [str(t) for t in body.get("available_tags", []) if t]
|
||||
overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
|
||||
await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
|
||||
return {"available_tags": tags, "overlap_tags": overlap}
|
||||
|
||||
|
||||
@router.delete("/{trip_id}")
|
||||
async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
for e in events:
|
||||
await fstore.doc_delete("trip_events", e["event_id"])
|
||||
await fstore.doc_delete("trips", trip_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/join")
|
||||
async def join_trip(
|
||||
trip_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Join a trip as an attendee. Only the Discord bot (service key) may call this."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if trip.get("visibility", "public") == "private":
|
||||
invited = trip.get("invited_discord_ids", [])
|
||||
attendees_existing = trip.get("attendees", {})
|
||||
if body.discord_user_id not in invited and body.discord_user_id not in attendees_existing:
|
||||
raise HTTPException(403, "This trip is private. You need an invite to join.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.put("/{trip_id}/visibility")
|
||||
async def set_visibility(trip_id: str, body: dict, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
visibility = body.get("visibility", "public")
|
||||
if visibility not in ("public", "private"):
|
||||
raise HTTPException(400, "visibility must be 'public' or 'private'.")
|
||||
await fstore.doc_update("trips", trip_id, {"visibility": visibility})
|
||||
return {"visibility": visibility}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/invite/{discord_user_id}")
|
||||
async def invite_user(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
invited = list(set(trip.get("invited_discord_ids", []) + [discord_user_id]))
|
||||
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
|
||||
return {"ok": True, "invited_discord_ids": invited}
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/invite/{discord_user_id}")
|
||||
async def revoke_invite(trip_id: str, discord_user_id: str, _: dict = Depends(require_service_key_or_admin)):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
invited = [u for u in trip.get("invited_discord_ids", []) if u != discord_user_id]
|
||||
await fstore.doc_update("trips", trip_id, {"invited_discord_ids": invited})
|
||||
return {"ok": True, "invited_discord_ids": invited}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/leave")
|
||||
async def leave_trip(
|
||||
trip_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Leave a trip. Only the Discord bot (service key) may call this."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
attendees = trip.get("attendees", {})
|
||||
attendees.pop(body.discord_user_id, None)
|
||||
await fstore.doc_update("trips", trip_id, {"attendees": attendees})
|
||||
# cascade: remove from all events in this trip
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
for e in events:
|
||||
event_attendees = e.get("attendees", {})
|
||||
if body.discord_user_id in event_attendees:
|
||||
event_attendees.pop(body.discord_user_id)
|
||||
await fstore.doc_update("trip_events", e["event_id"], {"attendees": event_attendees})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events")
|
||||
async def create_event(trip_id: str, body: TripEventCreate):
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if not (trip["start_date"] <= body.date <= trip["end_date"]):
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Event date {body.date} is outside the trip range "
|
||||
f"{trip['start_date']} – {trip['end_date']}.",
|
||||
)
|
||||
event_id = str(uuid.uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
doc = {
|
||||
"event_id": event_id,
|
||||
"trip_id": trip_id,
|
||||
"title": body.title,
|
||||
"date": body.date,
|
||||
"start_time": body.start_time,
|
||||
"end_time": body.end_time,
|
||||
"location": body.location if body.location is not None else trip["location"],
|
||||
"location_inherited": body.location is None,
|
||||
"maps_link": body.maps_link,
|
||||
"place_id": body.place_id,
|
||||
"notes": body.notes,
|
||||
"tags": body.tags,
|
||||
"attendees": {},
|
||||
"created_at": now,
|
||||
}
|
||||
await fstore.doc_set("trip_events", event_id, doc, merge=False)
|
||||
return doc
|
||||
|
||||
|
||||
@router.patch("/{trip_id}/events/{event_id}")
|
||||
async def update_event(trip_id: str, event_id: str, body: TripEventUpdate):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
|
||||
updates: dict = {}
|
||||
if body.title is not None:
|
||||
updates["title"] = body.title
|
||||
if body.date is not None:
|
||||
if not (trip["start_date"] <= body.date <= trip["end_date"]):
|
||||
raise HTTPException(400, f"Event date {body.date} is outside the trip range.")
|
||||
updates["date"] = body.date
|
||||
if body.start_time is not None:
|
||||
updates["start_time"] = body.start_time or None
|
||||
if body.end_time is not None:
|
||||
updates["end_time"] = body.end_time or None
|
||||
if body.location is not None:
|
||||
updates["location"] = body.location
|
||||
updates["location_inherited"] = False
|
||||
if body.maps_link is not None:
|
||||
updates["maps_link"] = body.maps_link or None
|
||||
if body.place_id is not None:
|
||||
updates["place_id"] = body.place_id or None
|
||||
if body.notes is not None:
|
||||
updates["notes"] = body.notes or None
|
||||
if body.tags is not None:
|
||||
updates["tags"] = body.tags
|
||||
|
||||
if updates:
|
||||
await fstore.doc_update("trip_events", event_id, updates)
|
||||
|
||||
return {**event, **updates}
|
||||
|
||||
|
||||
@router.delete("/{trip_id}/events/{event_id}")
|
||||
async def delete_event(
|
||||
trip_id: str,
|
||||
event_id: str,
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
await fstore.doc_delete("trip_events", event_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/join")
|
||||
async def join_event(
|
||||
trip_id: str,
|
||||
event_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Join an event. Only the Discord bot (service key) may call this."""
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
if body.discord_user_id not in trip.get("attendees", {}):
|
||||
raise HTTPException(403, "You must join the trip before joining an event.")
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
attendees = event.get("attendees", {})
|
||||
attendees[body.discord_user_id] = body.discord_username or body.discord_user_id
|
||||
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
|
||||
return {"ok": True, "attendees": attendees}
|
||||
|
||||
|
||||
@router.post("/{trip_id}/events/{event_id}/leave")
|
||||
async def leave_event(
|
||||
trip_id: str,
|
||||
event_id: str,
|
||||
body: AttendeeAction,
|
||||
_: dict = Depends(require_service_key),
|
||||
):
|
||||
"""Leave an event. Only the Discord bot (service key) may call this."""
|
||||
event = await fstore.doc_get("trip_events", event_id)
|
||||
if not event or event.get("trip_id") != trip_id:
|
||||
raise HTTPException(404, f"Event '{event_id}' not found in trip '{trip_id}'.")
|
||||
attendees = event.get("attendees", {})
|
||||
attendees.pop(body.discord_user_id, None)
|
||||
await fstore.doc_update("trip_events", event_id, {"attendees": attendees})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI trip planning assistant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{trip_id}/chat")
|
||||
async def trip_chat(
|
||||
trip_id: str,
|
||||
body: ChatRequest,
|
||||
decoded: dict = Depends(require_service_or_firebase_token),
|
||||
):
|
||||
if not settings.openai_api_key:
|
||||
raise HTTPException(503, "OpenAI not configured.")
|
||||
|
||||
# Rate limit by caller identity
|
||||
caller_key = decoded.get("uid") or ("service" if decoded.get("service") else "unknown")
|
||||
trip_chat_limiter.check(f"{caller_key}:{trip_id}")
|
||||
|
||||
trip = await fstore.doc_get("trips", trip_id)
|
||||
if not trip:
|
||||
raise HTTPException(404, f"Trip '{trip_id}' not found.")
|
||||
|
||||
events = await fstore.collection_list("trip_events", trip_id=trip_id)
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
oai = AsyncOpenAI(api_key=settings.openai_api_key)
|
||||
|
||||
# Strip history to only user/assistant roles to prevent prompt injection
|
||||
safe_history = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in body.history[-20:]
|
||||
if m.role in ("user", "assistant")
|
||||
]
|
||||
|
||||
# Truncate message to prevent oversized single requests
|
||||
user_message = body.message[:2000]
|
||||
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": _build_system_prompt(trip, events)},
|
||||
*safe_history,
|
||||
{"role": "user", "content": user_message},
|
||||
]
|
||||
|
||||
suggestions: list[dict] = []
|
||||
reply = ""
|
||||
|
||||
for _ in range(6): # max tool-call iterations
|
||||
response = await oai.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages,
|
||||
tools=_TOOLS,
|
||||
tool_choice="auto",
|
||||
max_tokens=1000,
|
||||
)
|
||||
msg = response.choices[0].message
|
||||
|
||||
if not msg.tool_calls:
|
||||
reply = msg.content or ""
|
||||
break
|
||||
|
||||
# Append assistant message with tool calls
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": msg.content,
|
||||
"tool_calls": [tc.model_dump() for tc in msg.tool_calls],
|
||||
})
|
||||
|
||||
for tc in msg.tool_calls:
|
||||
args = json.loads(tc.function.arguments)
|
||||
|
||||
if tc.function.name == "add_tag":
|
||||
new_tag = str(args.get("tag", "")).strip()[:50]
|
||||
if new_tag and new_tag not in trip.get("available_tags", []):
|
||||
updated_tags = list(trip.get("available_tags") or []) + [new_tag]
|
||||
trip["available_tags"] = updated_tags
|
||||
await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags})
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": json.dumps({"available_tags": trip.get("available_tags", [])}),
|
||||
})
|
||||
|
||||
elif tc.function.name == "search_places":
|
||||
# Limit query string lengths before hitting the Maps API
|
||||
query = str(args.get("query", ""))[:200]
|
||||
near = str(args.get("near", ""))[:200]
|
||||
results = await _places_search(query, near)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": json.dumps(results),
|
||||
})
|
||||
|
||||
elif tc.function.name == "propose_event":
|
||||
suggestion = {k: args.get(k) for k in (
|
||||
"title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags"
|
||||
)}
|
||||
if not isinstance(suggestion.get("tags"), list):
|
||||
suggestion["tags"] = []
|
||||
suggestions.append(suggestion)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"content": json.dumps({"proposed": True, "title": args.get("title")}),
|
||||
})
|
||||
|
||||
if not reply:
|
||||
reply = f"Here {'are' if len(suggestions) != 1 else 'is'} {len(suggestions) or 'my'} suggestion{'s' if len(suggestions) != 1 else ''} for your trip."
|
||||
|
||||
return {"reply": reply, "suggestions": suggestions}
|
||||
@@ -4,6 +4,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.internal.storage import upload_audio
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(tags=["upload"])
|
||||
|
||||
@@ -43,9 +44,10 @@ async def upload_call_audio(
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Empty file.")
|
||||
if len(data) > settings.upload_max_bytes:
|
||||
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||
|
||||
filename = file.filename
|
||||
audio_url = await upload_audio(data, filename)
|
||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
|
||||
if audio_url:
|
||||
try:
|
||||
@@ -83,6 +85,65 @@ def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
async def _correlate_with_consensus(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
system_id: Optional[str],
|
||||
talkgroup_id: Optional[int],
|
||||
talkgroup_name: Optional[str],
|
||||
tags: list[str],
|
||||
incident_type: Optional[str],
|
||||
location: Optional[str],
|
||||
location_coords: Optional[dict],
|
||||
units: Optional[list] = None,
|
||||
vehicles: Optional[list] = None,
|
||||
cleared_units: Optional[list] = None,
|
||||
reassignment: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Consensus correlator: runs the rules engine and the cheap LLM in sequence.
|
||||
If they agree the rules decision is committed directly.
|
||||
If they disagree a smarter tiebreaker LLM makes the final call.
|
||||
|
||||
Falls back to rules-only when GEMINI_API_KEY is absent, the call is
|
||||
content-free (thin), or any LLM call fails.
|
||||
"""
|
||||
from app.internal import incident_correlator, llm_correlator
|
||||
|
||||
preview = await incident_correlator.preview_correlation(
|
||||
call_id=call_id, node_id=node_id, system_id=system_id,
|
||||
talkgroup_id=talkgroup_id, talkgroup_name=talkgroup_name,
|
||||
tags=tags, incident_type=incident_type, location=location,
|
||||
location_coords=location_coords, units=units, vehicles=vehicles,
|
||||
cleared_units=cleared_units, reassignment=reassignment,
|
||||
)
|
||||
ctx = preview["ctx"]
|
||||
rules_decision = preview["decision"]
|
||||
|
||||
llm_decision = await llm_correlator.decide(call_id, ctx)
|
||||
|
||||
if llm_decision is None:
|
||||
# LLM unavailable, skipped (thin call), or errored — rules wins.
|
||||
rules_decision["corr_debug"]["corr_consensus"] = "rules_only"
|
||||
return await incident_correlator.apply_correlation(preview)
|
||||
|
||||
if llm_correlator.decisions_agree(rules_decision, llm_decision):
|
||||
rules_decision["corr_debug"]["corr_consensus"] = "agreed"
|
||||
rules_decision["corr_debug"]["corr_llm_reasoning"] = llm_decision.get("reasoning", "")
|
||||
return await incident_correlator.apply_correlation(preview)
|
||||
|
||||
# Disagree — escalate to the smarter tiebreaker.
|
||||
logger.info(
|
||||
f"Consensus disagreement for call {call_id}: "
|
||||
f"rules={rules_decision['action']} vs llm={llm_decision['action']} — tiebreak"
|
||||
)
|
||||
final = await llm_correlator.tiebreak(rules_decision, llm_decision, ctx)
|
||||
final["corr_debug"]["corr_consensus"] = "tiebreak"
|
||||
final["corr_debug"]["corr_rules_action"] = rules_decision["action"]
|
||||
final["corr_debug"]["corr_llm_action"] = llm_decision["action"]
|
||||
return await incident_correlator.apply_correlation({"decision": final, "ctx": ctx})
|
||||
|
||||
|
||||
async def _run_extraction_pipeline(
|
||||
call_id: str,
|
||||
node_id: str,
|
||||
@@ -96,35 +157,56 @@ async def _run_extraction_pipeline(
|
||||
"""Run steps 2-4 of the intelligence pipeline using an existing transcript."""
|
||||
from app.internal import intelligence, incident_correlator, alerter
|
||||
|
||||
tags, incident_type, location, location_coords, resolved = await intelligence.extract_tags(
|
||||
# Step 2: Scene detection + intelligence extraction.
|
||||
# Returns one scene per distinct incident detected in the recording.
|
||||
scenes = await intelligence.extract_scenes(
|
||||
call_id, transcript, talkgroup_name,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
preserve_transcript_correction=preserve_transcript_correction,
|
||||
)
|
||||
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=tags,
|
||||
incident_type=incident_type,
|
||||
location=location,
|
||||
location_coords=location_coords,
|
||||
)
|
||||
# Step 3: Correlate each scene to an incident independently.
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
for scene in scenes:
|
||||
all_tags.extend(scene["tags"])
|
||||
# When dispatch is pulling a unit to a NEW call (reassignment), suppress unit
|
||||
# overlap so the new scene doesn't chain into the unit's previous incident.
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
corr_units = [] if is_reassignment else scene.get("units")
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=scene["tags"],
|
||||
incident_type=scene["incident_type"],
|
||||
location=scene["location"],
|
||||
location_coords=scene["location_coords"],
|
||||
units=corr_units,
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
|
||||
if resolved and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
if incident_ids:
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
||||
|
||||
# Step 4: Alert dispatch — run once with merged tags from all scenes.
|
||||
await alerter.check_and_dispatch(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=tags,
|
||||
tags=list(dict.fromkeys(all_tags)),
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
@@ -140,50 +222,107 @@ async def _run_intelligence_pipeline(
|
||||
"""
|
||||
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)
|
||||
2. Detect scenes + extract intelligence (one result per incident in recording)
|
||||
3. Correlate each scene with existing incidents (or create new ones)
|
||||
4. Check alert rules and dispatch notifications
|
||||
"""
|
||||
from app.internal import transcription, intelligence, incident_correlator, alerter
|
||||
from app.internal.feature_flags import get_flags
|
||||
|
||||
flags = await get_flags()
|
||||
|
||||
# Resolve per-system overrides: system flag=False beats global flag=True,
|
||||
# but global flag=False beats everything (master switch).
|
||||
system_ai_flags: dict = {}
|
||||
if system_id:
|
||||
sys_doc = await fstore.doc_get_cached("systems", system_id)
|
||||
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
|
||||
|
||||
def _flag(name: str) -> bool:
|
||||
if not flags[name]: # global master off
|
||||
return False
|
||||
return system_ai_flags.get(name, True) # system override, default inherit
|
||||
|
||||
transcript: Optional[str] = None
|
||||
segments: list[dict] = []
|
||||
|
||||
# Step 1: Transcription
|
||||
if gcs_uri:
|
||||
transcript, segments = await transcription.transcribe_call(
|
||||
call_id, gcs_uri, talkgroup_name, system_id=system_id
|
||||
)
|
||||
if _flag("stt_enabled"):
|
||||
transcript, segments = await transcription.transcribe_call(
|
||||
call_id, gcs_uri, talkgroup_name, system_id=system_id
|
||||
)
|
||||
else:
|
||||
scope = "globally" if not flags["stt_enabled"] else f"system {system_id}"
|
||||
logger.info(f"STT disabled ({scope}) — skipping transcription for call {call_id}")
|
||||
|
||||
# Step 2: Intelligence extraction
|
||||
tags: list[str] = []
|
||||
incident_type: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
location_coords: Optional[dict] = None
|
||||
resolved: bool = False
|
||||
if transcript:
|
||||
tags, incident_type, location, location_coords, resolved = await intelligence.extract_tags(
|
||||
call_id, transcript, talkgroup_name,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
)
|
||||
# Step 2: Scene detection + intelligence extraction
|
||||
scenes: list[dict] = []
|
||||
if _flag("correlation_enabled"):
|
||||
if transcript:
|
||||
scenes = await intelligence.extract_scenes(
|
||||
call_id, transcript, talkgroup_name,
|
||||
talkgroup_id=talkgroup_id, system_id=system_id, segments=segments,
|
||||
node_id=node_id,
|
||||
)
|
||||
else:
|
||||
scope = "globally" if not flags["correlation_enabled"] else f"system {system_id}"
|
||||
logger.info(f"Correlation disabled ({scope}) — skipping scene extraction and correlation for call {call_id}")
|
||||
|
||||
# Step 3: Incident correlation (always runs — unclassified calls can still link via talkgroup)
|
||||
incident_id = await incident_correlator.correlate_call(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=tags,
|
||||
incident_type=incident_type,
|
||||
location=location,
|
||||
location_coords=location_coords,
|
||||
)
|
||||
# Step 3: Correlate each scene independently.
|
||||
# A single recording can produce multiple incidents on a busy channel.
|
||||
incident_ids: list[str] = []
|
||||
all_tags: list[str] = []
|
||||
if flags["correlation_enabled"]:
|
||||
for scene in scenes:
|
||||
all_tags.extend(scene["tags"])
|
||||
is_reassignment = bool(scene.get("reassignment"))
|
||||
corr_units = [] if is_reassignment else scene.get("units")
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=scene["tags"],
|
||||
incident_type=scene["incident_type"],
|
||||
location=scene["location"],
|
||||
location_coords=scene["location_coords"],
|
||||
units=corr_units,
|
||||
vehicles=scene.get("vehicles"),
|
||||
cleared_units=scene.get("cleared_units"),
|
||||
reassignment=is_reassignment,
|
||||
)
|
||||
if incident_id and incident_id not in incident_ids:
|
||||
incident_ids.append(incident_id)
|
||||
if scene["resolved"] and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
await incident_correlator.maybe_resolve_parent(incident_id)
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
|
||||
if resolved and incident_id:
|
||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
||||
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||
# talkgroup-based linking even when no transcript could be produced.
|
||||
# Skip when extraction flagged the call — garbage or too-short transcripts
|
||||
# carry no signal and would only attach spuriously via the thin path.
|
||||
if not scenes:
|
||||
_call_doc = await fstore.doc_get("calls", call_id)
|
||||
if not (_call_doc or {}).get("skip_reason"):
|
||||
incident_id = await _correlate_with_consensus(
|
||||
call_id=call_id,
|
||||
node_id=node_id,
|
||||
system_id=system_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=[],
|
||||
incident_type=None,
|
||||
location=None,
|
||||
location_coords=None,
|
||||
)
|
||||
if incident_id:
|
||||
incident_ids.append(incident_id)
|
||||
|
||||
if incident_ids:
|
||||
await fstore.doc_set("calls", call_id, {"incident_ids": incident_ids})
|
||||
|
||||
# Step 4: Alert dispatch (always runs — talkgroup ID rules don't need a transcript)
|
||||
await alerter.check_and_dispatch(
|
||||
@@ -191,6 +330,6 @@ async def _run_intelligence_pipeline(
|
||||
node_id=node_id,
|
||||
talkgroup_id=talkgroup_id,
|
||||
talkgroup_name=talkgroup_name,
|
||||
tags=tags,
|
||||
tags=list(dict.fromkeys(all_tags)),
|
||||
transcript=transcript,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from firebase_admin import auth as firebase_auth
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import audit
|
||||
|
||||
router = APIRouter(prefix="/admin/users", tags=["users"])
|
||||
|
||||
VALID_ROLES = {"admin", "operator", "viewer"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: str
|
||||
role: str = "viewer"
|
||||
display_name: Optional[str] = None
|
||||
owned_node_ids: list[str] = []
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
role: Optional[str] = None
|
||||
owned_node_ids: Optional[list[str]] = None
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
|
||||
if ms is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _extract_role_nodes(fb_user: firebase_auth.UserRecord) -> tuple[str, list[str]]:
|
||||
claims = fb_user.custom_claims or {}
|
||||
if claims.get("role") == "admin" or claims.get("admin"):
|
||||
role = "admin"
|
||||
else:
|
||||
role = claims.get("role", "viewer")
|
||||
if role not in VALID_ROLES:
|
||||
role = "viewer"
|
||||
owned_node_ids = claims.get("owned_node_ids") or []
|
||||
return role, owned_node_ids
|
||||
|
||||
|
||||
def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None) -> dict:
|
||||
role, owned_node_ids = _extract_role_nodes(fb_user)
|
||||
return {
|
||||
"uid": fb_user.uid,
|
||||
"email": fb_user.email,
|
||||
"display_name": fb_user.display_name,
|
||||
"role": role,
|
||||
"owned_node_ids": owned_node_ids,
|
||||
"disabled": fb_user.disabled,
|
||||
"creation_time": _ms_to_iso(fb_user.user_metadata.creation_timestamp),
|
||||
"last_sign_in": _ms_to_iso(fb_user.user_metadata.last_sign_in_timestamp),
|
||||
"discord_linked": bool(link and link.get("discord_user_id")),
|
||||
"discord_username": link.get("discord_username") if link else None,
|
||||
"discord_user_id": link.get("discord_user_id") if link else None,
|
||||
}
|
||||
|
||||
|
||||
def _list_fb_users() -> list[firebase_auth.UserRecord]:
|
||||
users: list[firebase_auth.UserRecord] = []
|
||||
page = firebase_auth.list_users()
|
||||
while page:
|
||||
users.extend(page.users)
|
||||
page = page.get_next_page()
|
||||
return users
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_users(decoded: dict = Depends(require_admin_token)):
|
||||
"""List all Firebase Auth users with role, node ownership, and Discord link status."""
|
||||
fb_users = await asyncio.to_thread(_list_fb_users)
|
||||
|
||||
links: list[Optional[dict]] = await asyncio.gather(*[
|
||||
fstore.doc_get("firebase_discord_links", u.uid) for u in fb_users
|
||||
])
|
||||
|
||||
return [_format_user(u, lnk) for u, lnk in zip(fb_users, links)]
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_token)):
|
||||
"""Create a new Firebase Auth user and set their role. Returns a one-time invite link."""
|
||||
if body.role not in VALID_ROLES:
|
||||
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
||||
if body.role == "operator" and not body.owned_node_ids:
|
||||
raise HTTPException(400, "Operator role requires at least one owned node.")
|
||||
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
|
||||
firebase_auth.create_user,
|
||||
email=body.email,
|
||||
display_name=body.display_name or "",
|
||||
email_verified=False,
|
||||
)
|
||||
except firebase_auth.EmailAlreadyExistsError:
|
||||
raise HTTPException(409, "A user with this email already exists.")
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Failed to create user: {e}")
|
||||
|
||||
# Set custom claims
|
||||
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
|
||||
if body.role == "admin":
|
||||
claims["admin"] = True
|
||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
|
||||
|
||||
# Write Firestore profile
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await fstore.doc_set("user_profiles", fb_user.uid, {
|
||||
"uid": fb_user.uid,
|
||||
"email": body.email,
|
||||
"display_name": body.display_name or "",
|
||||
"role": body.role,
|
||||
"owned_node_ids": body.owned_node_ids,
|
||||
"created_by_uid": decoded["uid"],
|
||||
"created_at": now,
|
||||
}, merge=False)
|
||||
|
||||
# Generate a one-time invite/password-reset link
|
||||
invite_link: Optional[str] = None
|
||||
try:
|
||||
invite_link = await asyncio.to_thread(firebase_auth.generate_password_reset_link, body.email)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await audit.write_audit(
|
||||
actor_uid=decoded["uid"],
|
||||
actor_email=decoded.get("email", ""),
|
||||
action="user.create",
|
||||
target_uid=fb_user.uid,
|
||||
target_email=body.email,
|
||||
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
|
||||
)
|
||||
|
||||
return {**_format_user(fb_user), "invite_link": invite_link}
|
||||
|
||||
|
||||
@router.get("/{uid}")
|
||||
async def get_user(uid: str, decoded: dict = Depends(require_admin_token)):
|
||||
"""Get a single user with full detail, including recent sessions."""
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
|
||||
except firebase_auth.UserNotFoundError:
|
||||
raise HTTPException(404, "User not found.")
|
||||
|
||||
link, raw_sessions = await asyncio.gather(
|
||||
fstore.doc_get("firebase_discord_links", uid),
|
||||
fstore.collection_where("user_sessions", [("uid", "==", uid)]),
|
||||
)
|
||||
|
||||
raw_sessions.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
|
||||
|
||||
return {
|
||||
**_format_user(fb_user, link),
|
||||
"sessions": raw_sessions[:20],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{uid}")
|
||||
async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(require_admin_token)):
|
||||
"""Update a user's role, owned nodes, or display name."""
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
|
||||
except firebase_auth.UserNotFoundError:
|
||||
raise HTTPException(404, "User not found.")
|
||||
|
||||
current_role, current_nodes = _extract_role_nodes(fb_user)
|
||||
new_role = body.role if body.role is not None else current_role
|
||||
new_nodes = body.owned_node_ids if body.owned_node_ids is not None else current_nodes
|
||||
|
||||
if new_role not in VALID_ROLES:
|
||||
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
||||
if new_role == "operator" and not new_nodes:
|
||||
raise HTTPException(400, "Operator role requires at least one owned node.")
|
||||
|
||||
# Merge with existing claims (preserve any other claims already set)
|
||||
existing_claims: dict = dict(fb_user.custom_claims or {})
|
||||
new_claims = {**existing_claims, "role": new_role, "owned_node_ids": new_nodes}
|
||||
if new_role == "admin":
|
||||
new_claims["admin"] = True
|
||||
else:
|
||||
new_claims.pop("admin", None)
|
||||
|
||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
|
||||
|
||||
if body.display_name is not None:
|
||||
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
|
||||
|
||||
profile_data: dict = {"uid": uid, "role": new_role, "owned_node_ids": new_nodes}
|
||||
if body.display_name is not None:
|
||||
profile_data["display_name"] = body.display_name
|
||||
await fstore.doc_set("user_profiles", uid, profile_data, merge=True)
|
||||
|
||||
await audit.write_audit(
|
||||
actor_uid=decoded["uid"],
|
||||
actor_email=decoded.get("email", ""),
|
||||
action="user.update",
|
||||
target_uid=uid,
|
||||
target_email=fb_user.email,
|
||||
details={
|
||||
"old_role": current_role,
|
||||
"new_role": new_role,
|
||||
"old_nodes": current_nodes,
|
||||
"new_nodes": new_nodes,
|
||||
},
|
||||
)
|
||||
|
||||
updated: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
|
||||
link = await fstore.doc_get("firebase_discord_links", uid)
|
||||
return _format_user(updated, link)
|
||||
|
||||
|
||||
@router.post("/{uid}/disable")
|
||||
async def disable_user(uid: str, decoded: dict = Depends(require_admin_token)):
|
||||
"""Disable a user — they can no longer sign in but their data is preserved."""
|
||||
if uid == decoded.get("uid"):
|
||||
raise HTTPException(400, "Cannot disable your own account.")
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
|
||||
except firebase_auth.UserNotFoundError:
|
||||
raise HTTPException(404, "User not found.")
|
||||
|
||||
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=True)
|
||||
|
||||
await audit.write_audit(
|
||||
actor_uid=decoded["uid"],
|
||||
actor_email=decoded.get("email", ""),
|
||||
action="user.disable",
|
||||
target_uid=uid,
|
||||
target_email=fb_user.email,
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{uid}/enable")
|
||||
async def enable_user(uid: str, decoded: dict = Depends(require_admin_token)):
|
||||
"""Re-enable a previously disabled user."""
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
|
||||
except firebase_auth.UserNotFoundError:
|
||||
raise HTTPException(404, "User not found.")
|
||||
|
||||
await asyncio.to_thread(firebase_auth.update_user, uid, disabled=False)
|
||||
|
||||
await audit.write_audit(
|
||||
actor_uid=decoded["uid"],
|
||||
actor_email=decoded.get("email", ""),
|
||||
action="user.enable",
|
||||
target_uid=uid,
|
||||
target_email=fb_user.email,
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/{uid}")
|
||||
async def delete_user(uid: str, decoded: dict = Depends(require_admin_token)):
|
||||
"""Permanently delete a user from Firebase Auth and clean up Firestore data."""
|
||||
if uid == decoded.get("uid"):
|
||||
raise HTTPException(400, "Cannot delete your own account.")
|
||||
|
||||
try:
|
||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(firebase_auth.get_user, uid)
|
||||
except firebase_auth.UserNotFoundError:
|
||||
raise HTTPException(404, "User not found.")
|
||||
|
||||
email = fb_user.email
|
||||
|
||||
# Clean up Discord link if present
|
||||
link = await fstore.doc_get("firebase_discord_links", uid)
|
||||
if link and link.get("discord_user_id"):
|
||||
await asyncio.gather(
|
||||
fstore.doc_delete("discord_links", link["discord_user_id"]),
|
||||
fstore.doc_delete("firebase_discord_links", uid),
|
||||
)
|
||||
|
||||
# Delete Firestore profile (sessions are kept for audit history)
|
||||
await fstore.doc_delete("user_profiles", uid)
|
||||
|
||||
# Delete from Firebase Auth
|
||||
await asyncio.to_thread(firebase_auth.delete_user, uid)
|
||||
|
||||
await audit.write_audit(
|
||||
actor_uid=decoded["uid"],
|
||||
actor_email=decoded.get("email", ""),
|
||||
action="user.delete",
|
||||
target_uid=uid,
|
||||
target_email=email,
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -197,7 +197,7 @@ export default function AlertsPage() {
|
||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
||||
{unacked.length > 0 && (
|
||||
|
||||
+176
-11
@@ -1,34 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useCalls } from "@/lib/useCalls";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
const inputCls =
|
||||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-white font-mono " +
|
||||
"placeholder:text-gray-600 focus:outline-none focus:border-indigo-500 w-full";
|
||||
|
||||
function filterCalls(calls: CallRecord[], filters: Filters): CallRecord[] {
|
||||
const q = filters.query.trim().toLowerCase();
|
||||
const tgid = filters.tgid.trim();
|
||||
|
||||
return calls.filter((c) => {
|
||||
// System filter
|
||||
if (filters.systemId && c.system_id !== filters.systemId) return false;
|
||||
|
||||
// TGID filter (exact match on the number)
|
||||
if (tgid && String(c.talkgroup_id ?? "") !== tgid) return false;
|
||||
|
||||
// Free-text: talkgroup name, node_id, transcript, tags
|
||||
if (q) {
|
||||
const hay = [
|
||||
c.talkgroup_name ?? "",
|
||||
c.node_id,
|
||||
c.transcript ?? "",
|
||||
c.transcript_corrected ?? "",
|
||||
...(c.tags ?? []),
|
||||
].join(" ").toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
query: string;
|
||||
tgid: string;
|
||||
systemId: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: Filters = {
|
||||
query: "",
|
||||
tgid: "",
|
||||
systemId: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
};
|
||||
|
||||
function isActive(f: Filters) {
|
||||
return f.query || f.tgid || f.systemId || f.dateFrom || f.dateTo;
|
||||
}
|
||||
|
||||
export default function CallsPage() {
|
||||
const [limitCount, setLimitCount] = useState(100);
|
||||
const { calls, loading } = useCalls(limitCount);
|
||||
const [filters, setFilters] = useState<Filters>(DEFAULT_FILTERS);
|
||||
|
||||
const dateFrom = filters.dateFrom ? new Date(filters.dateFrom + "T00:00:00") : undefined;
|
||||
const dateTo = filters.dateTo ? new Date(filters.dateTo + "T23:59:59") : undefined;
|
||||
|
||||
const { calls, loading } = useCalls(limitCount, dateFrom, dateTo);
|
||||
const { systems } = useSystems();
|
||||
const { isAdmin } = useAuth();
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
|
||||
const active = calls.filter((c) => c.status === "active");
|
||||
const ended = calls.filter((c) => c.status === "ended");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
function set<K extends keyof Filters>(key: K, value: string) {
|
||||
setFilters((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
const active = calls.filter((c) => c.status === "active");
|
||||
const ended = calls.filter((c) => c.status === "ended");
|
||||
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
|
||||
|
||||
const activeFilters = isActive(filters);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
|
||||
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
||||
<button
|
||||
onClick={() => setShowFilters((v) => !v)}
|
||||
className={`text-xs font-mono px-3 py-1.5 rounded-lg border transition-colors ${
|
||||
activeFilters
|
||||
? "border-indigo-600 bg-indigo-950 text-indigo-300"
|
||||
: "border-gray-700 bg-gray-900 text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{showFilters ? "Hide filters" : "Filter"}
|
||||
{activeFilters && " •"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
{showFilters && (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{/* Text search */}
|
||||
<div className="lg:col-span-2">
|
||||
<label className="text-xs text-gray-500 block mb-1">Search (talkgroup, node, transcript, tags)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.query}
|
||||
onChange={(e) => set("query", e.target.value)}
|
||||
placeholder="fire, Engine 5, dispatch…"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TGID */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">Talkgroup ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.tgid}
|
||||
onChange={(e) => set("tgid", e.target.value)}
|
||||
placeholder="e.g. 9048"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">System</label>
|
||||
<select
|
||||
value={filters.systemId}
|
||||
onChange={(e) => set("systemId", e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">All systems</option>
|
||||
{systems.map((s) => (
|
||||
<option key={s.system_id} value={s.system_id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date from */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">From date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom}
|
||||
onChange={(e) => set("dateFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date to */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 block mb-1">To date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo}
|
||||
onChange={(e) => set("dateTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeFilters && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{filtered.length} of {ended.length} calls match
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setFilters(DEFAULT_FILTERS)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live calls — never filtered */}
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
||||
Live ({active.length})
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
@@ -51,17 +213,20 @@ export default function CallsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
History
|
||||
History{activeFilters && <span className="ml-2 text-indigo-400">({filtered.length} filtered)</span>}
|
||||
</h2>
|
||||
{loading ? (
|
||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||
) : ended.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">
|
||||
{activeFilters ? "No calls match the current filters." : "No calls recorded yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
@@ -74,7 +239,7 @@ export default function CallsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ended.map((c) => (
|
||||
{filtered.map((c) => (
|
||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function DashboardPage() {
|
||||
{calls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
|
||||
@@ -4,6 +4,114 @@
|
||||
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
|
||||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||
html, body {
|
||||
@apply bg-gray-950 text-gray-100 font-mono;
|
||||
}
|
||||
|
||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||
/*
|
||||
* The app's components use hardcoded dark-palette Tailwind classes (bg-gray-9xx,
|
||||
* text-gray-xxx). Rather than adding dark: prefixes everywhere, we remap those
|
||||
* classes here when the html element doesn't carry the .dark class.
|
||||
*/
|
||||
|
||||
/* Structural backgrounds */
|
||||
html:not(.dark),
|
||||
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
||||
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
||||
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
||||
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||
|
||||
/* Borders */
|
||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||
|
||||
/* Text */
|
||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||
|
||||
/* Hover states */
|
||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
||||
|
||||
/* Hover text */
|
||||
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
||||
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
||||
|
||||
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
||||
|
||||
/* Fire / Error */
|
||||
html:not(.dark) .bg-red-900 { background-color: #fef2f2 !important; }
|
||||
html:not(.dark) .bg-red-950 { background-color: #fff1f2 !important; }
|
||||
html:not(.dark) .text-red-300 { color: #b91c1c !important; }
|
||||
html:not(.dark) .text-red-400 { color: #dc2626 !important; }
|
||||
html:not(.dark) .border-red-800 { border-color: #fca5a5 !important; }
|
||||
|
||||
/* Police */
|
||||
html:not(.dark) .bg-blue-900 { background-color: #eff6ff !important; }
|
||||
html:not(.dark) .bg-blue-950 { background-color: #eff6ff !important; }
|
||||
html:not(.dark) .text-blue-300 { color: #1d4ed8 !important; }
|
||||
html:not(.dark) .border-blue-800 { border-color: #93c5fd !important; }
|
||||
|
||||
/* EMS */
|
||||
html:not(.dark) .bg-yellow-900 { background-color: #fefce8 !important; }
|
||||
html:not(.dark) .bg-yellow-950 { background-color: #fefce8 !important; }
|
||||
html:not(.dark) .text-yellow-300 { color: #a16207 !important; }
|
||||
html:not(.dark) .text-yellow-400 { color: #ca8a04 !important; }
|
||||
|
||||
/* Accident / Recording */
|
||||
html:not(.dark) .bg-orange-900 { background-color: #fff7ed !important; }
|
||||
html:not(.dark) .bg-orange-950 { background-color: #fff7ed !important; }
|
||||
html:not(.dark) .text-orange-300 { color: #c2410c !important; }
|
||||
html:not(.dark) .text-orange-400 { color: #ea580c !important; }
|
||||
html:not(.dark) .border-orange-800 { border-color: #fdba74 !important; }
|
||||
|
||||
/* Active / Online */
|
||||
html:not(.dark) .bg-green-900 { background-color: #f0fdf4 !important; }
|
||||
html:not(.dark) .bg-green-950 { background-color: #f0fdf4 !important; }
|
||||
html:not(.dark) .text-green-300 { color: #15803d !important; }
|
||||
html:not(.dark) .text-green-400 { color: #16a34a !important; }
|
||||
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
||||
|
||||
/* Unconfigured / Info */
|
||||
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
||||
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
||||
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
|
||||
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(1); opacity: 0.85; }
|
||||
100% { transform: scale(2.4); opacity: 0; }
|
||||
}
|
||||
.node-pulse-ring {
|
||||
animation: pulse-ring 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||
html:not(.dark) select,
|
||||
html:not(.dark) textarea {
|
||||
color: #0f172a;
|
||||
}
|
||||
html:not(.dark) input::placeholder,
|
||||
html:not(.dark) textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,22 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
other: "bg-gray-800 text-gray-300",
|
||||
};
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
major: "bg-red-950 text-red-400",
|
||||
moderate: "bg-orange-950 text-orange-400",
|
||||
minor: "bg-gray-800 text-gray-400",
|
||||
};
|
||||
|
||||
function severityBadge(severity: string | null | undefined) {
|
||||
if (!severity || severity === "unknown") return null;
|
||||
const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400";
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function typeBadge(type: string | null) {
|
||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||
return (
|
||||
@@ -51,6 +67,7 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
{incident.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</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>
|
||||
@@ -167,9 +184,12 @@ function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<p className="text-gray-500 text-xs mt-1 font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -196,6 +216,7 @@ function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
<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">Severity</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>
|
||||
@@ -232,7 +253,7 @@ export default function IncidentsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -10,12 +11,18 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
{/* Prevent flash of wrong theme before React hydrates */}
|
||||
<script dangerouslySetInnerHTML={{ __html: `(function(){try{var t=localStorage.getItem('drb-theme');if(t!=='light')document.documentElement.classList.add('dark');}catch(e){}})();` }} />
|
||||
</head>
|
||||
<body className="min-h-screen bg-gray-950">
|
||||
<AuthProvider>
|
||||
<Nav />
|
||||
<main className="p-6">{children}</main>
|
||||
</AuthProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Nav />
|
||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||
import { auth } from "@/lib/firebase";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -18,6 +19,7 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithEmailAndPassword(auth, email, password);
|
||||
c2api.recordSession().catch(() => {});
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("Invalid email or password.");
|
||||
@@ -31,6 +33,7 @@ export default function LoginPage() {
|
||||
setError(null);
|
||||
try {
|
||||
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||
c2api.recordSession().catch(() => {});
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("Google sign-in failed. Try again.");
|
||||
|
||||
@@ -1,92 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useActiveCalls } from "@/lib/useCalls";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
fire: "border-red-800 bg-red-950 text-red-300",
|
||||
police: "border-blue-800 bg-blue-950 text-blue-300",
|
||||
ems: "border-yellow-800 bg-yellow-950 text-yellow-300",
|
||||
accident: "border-orange-800 bg-orange-950 text-orange-300",
|
||||
other: "border-gray-700 bg-gray-900 text-gray-300",
|
||||
};
|
||||
|
||||
function IncidentCard({ incident }: { incident: IncidentRecord }) {
|
||||
const cls = TYPE_COLORS[incident.type ?? "other"] ?? TYPE_COLORS.other;
|
||||
return (
|
||||
<Link
|
||||
href={`/incidents/${incident.incident_id}`}
|
||||
className={`block border rounded-lg p-3 hover:brightness-110 transition-all ${cls}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-mono font-semibold uppercase tracking-wide">
|
||||
{incident.type ?? "other"}
|
||||
</span>
|
||||
<span className="text-xs opacity-60 font-mono">
|
||||
{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-bold leading-tight">{incident.title ?? "Incident"}</p>
|
||||
{incident.location && (
|
||||
<p className="text-xs opacity-70 mt-1 font-mono truncate">{incident.location}</p>
|
||||
)}
|
||||
{!incident.location_coords && (
|
||||
<p className="text-xs opacity-40 mt-1 font-mono italic">location not geocoded yet</p>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MapPage() {
|
||||
const { nodes, loading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
const incidents = useActiveIncidents();
|
||||
const activeCalls = useActiveCalls();
|
||||
const incidents = useActiveIncidents();
|
||||
const [kiosk, setKiosk] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// Track when data last refreshed
|
||||
useEffect(() => {
|
||||
if (!loading) setLastUpdated(new Date());
|
||||
}, [nodes, activeCalls, incidents, loading]);
|
||||
|
||||
// Kiosk mode: full-viewport fixed overlay sits above the sticky nav (z-40 → z-50)
|
||||
if (kiosk) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-gray-950">
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={incidents}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setKiosk(false)}
|
||||
title="Exit fullscreen"
|
||||
className="absolute bottom-[5.5rem] left-3 z-[1002] bg-gray-950/90 border border-gray-700 rounded px-3 py-1.5 text-xs font-mono text-gray-300 hover:text-white hover:border-gray-500 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3"/>
|
||||
</svg>
|
||||
Exit fullscreen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
||||
<div className="flex items-center gap-4 text-xs font-mono text-gray-400">
|
||||
<span><span className="text-green-400">●</span> Online</span>
|
||||
<span><span className="text-orange-400 animate-pulse">●</span> Recording</span>
|
||||
<span><span className="text-indigo-400">●</span> Unconfigured</span>
|
||||
<span><span className="text-gray-600">●</span> Offline</span>
|
||||
<span className="border-l border-gray-700 pl-4"><span className="text-red-500">■</span> Fire</span>
|
||||
<span><span className="text-blue-500">■</span> Police</span>
|
||||
<span><span className="text-yellow-500">■</span> EMS</span>
|
||||
<span><span className="text-orange-500">■</span> Accident</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setKiosk(true)}
|
||||
title="Fullscreen / kiosk mode"
|
||||
className="text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
|
||||
</svg>
|
||||
Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-96 text-gray-600 font-mono text-sm">
|
||||
<div className="flex items-center justify-center h-[calc(100vh-10rem)] border border-gray-800 rounded-lg text-gray-600 font-mono text-sm">
|
||||
Loading map…
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
||||
<div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
|
||||
<MapView
|
||||
nodes={nodes}
|
||||
activeCalls={activeCalls}
|
||||
incidents={incidents}
|
||||
lastUpdated={lastUpdated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active incidents — shown even without geocoded location */}
|
||||
{incidents.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
Active Incidents ({incidents.length})
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{incidents.map((inc) => (
|
||||
<IncidentCard key={inc.incident_id} incident={inc} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { doc, onSnapshot } from "firebase/firestore";
|
||||
import { db } from "@/lib/firebase";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
@@ -111,11 +111,13 @@ function DiscordJoinModal({
|
||||
|
||||
export default function NodeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const router = useRouter();
|
||||
const [node, setNode] = useState<NodeRecord | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showDiscordJoin, setShowDiscordJoin] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { systems } = useSystems();
|
||||
const { calls } = useCalls(20);
|
||||
const { isAdmin } = useAuth();
|
||||
@@ -150,6 +152,18 @@ export default function NodeDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Permanently delete node "${node?.name ?? id}"? This cannot be undone.`)) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await c2api.deleteNode(id);
|
||||
router.push("/nodes");
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Delete failed.");
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!confirm("Reject this node? It will not be able to upload recordings.")) return;
|
||||
setApproving(true);
|
||||
@@ -257,6 +271,15 @@ export default function NodeDetailPage() {
|
||||
>
|
||||
Leave Discord
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
disabled={deleting}
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-red-300 rounded-lg text-sm font-mono transition-colors ml-auto"
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete Node"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent calls */}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { NodeCard } from "@/components/NodeCard";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
|
||||
export default function NodesPage() {
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { nodes, loading } = useNodes();
|
||||
const { systems } = useSystems();
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||
|
||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
|
||||
interface LinkStatus {
|
||||
linked: boolean;
|
||||
discord_user_id?: string;
|
||||
discord_username?: string;
|
||||
linked_at?: string;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function Initials({ name }: { name: string }) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
const letters = parts.length >= 2
|
||||
? parts[0][0] + parts[parts.length - 1][0]
|
||||
: name.slice(0, 2);
|
||||
return (
|
||||
<div className="w-16 h-16 rounded-full bg-indigo-700 flex items-center justify-center text-white text-xl font-bold select-none">
|
||||
{letters.toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, isAdmin, role, signOut } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [linkStatus, setLinkStatus] = useState<LinkStatus | null>(null);
|
||||
const [linkLoading, setLinkLoading] = useState(true);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [codeExpiry, setCodeExpiry] = useState<number | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [unlinking, setUnlinking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
c2api.getLinkStatus()
|
||||
.then(setLinkStatus)
|
||||
.catch(() => setLinkStatus({ linked: false }))
|
||||
.finally(() => setLinkLoading(false));
|
||||
}, [user]);
|
||||
|
||||
async function generateCode() {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const res = await c2api.generateLinkCode();
|
||||
if (res.already_linked) {
|
||||
setLinkStatus((prev) => prev ? { ...prev, linked: true } : prev);
|
||||
} else if (res.code) {
|
||||
setCode(res.code);
|
||||
setCodeExpiry(res.expires_minutes ?? 15);
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
setUnlinking(true);
|
||||
try {
|
||||
await c2api.unlinkDiscord();
|
||||
setLinkStatus({ linked: false });
|
||||
setCode(null);
|
||||
} finally {
|
||||
setUnlinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const displayName = user.displayName || user.email || "Account";
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Initials name={displayName} />
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
||||
{user.displayName && user.email && (
|
||||
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
|
||||
)}
|
||||
{role && (
|
||||
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
||||
role === "operator" ? "bg-green-900 text-green-300" :
|
||||
"bg-gray-800 text-gray-400"
|
||||
}`}>
|
||||
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Firebase account */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
|
||||
<div className="space-y-2">
|
||||
<Row label="Email" value={user.email ?? "—"} />
|
||||
<Row label="UID" value={user.uid} mono truncate />
|
||||
<Row label="Role" value={role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"} />
|
||||
{user.metadata.creationTime && (
|
||||
<Row label="Joined" value={fmtDate(user.metadata.creationTime)} />
|
||||
)}
|
||||
{user.metadata.lastSignInTime && (
|
||||
<Row label="Last sign-in" value={fmtDate(user.metadata.lastSignInTime)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Discord linking */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
|
||||
|
||||
{linkLoading ? (
|
||||
<p className="text-gray-500 text-sm">Loading…</p>
|
||||
) : linkStatus?.linked ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{linkStatus.discord_username && (
|
||||
<Row label="Username" value={`@${linkStatus.discord_username}`} />
|
||||
)}
|
||||
{linkStatus.discord_user_id && (
|
||||
<Row label="User ID" value={linkStatus.discord_user_id} mono />
|
||||
)}
|
||||
{linkStatus.linked_at && (
|
||||
<Row label="Linked" value={fmtDate(linkStatus.linked_at)} />
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={unlink}
|
||||
disabled={unlinking}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{unlinking ? "Unlinking…" : "Unlink Discord account"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-gray-400">
|
||||
Link your Discord account to access private trips from both the web and Discord.
|
||||
</p>
|
||||
{code ? (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
|
||||
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
||||
</p>
|
||||
<button
|
||||
onClick={generateCode}
|
||||
disabled={generating}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Generate new code
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={generateCode}
|
||||
disabled={generating}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
||||
>
|
||||
{generating ? "Generating…" : "Get link code"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sign out */}
|
||||
<section className="bg-gray-900 border border-gray-800 rounded-xl">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<p className="text-sm text-gray-400">Sign out of this device</p>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="text-sm text-red-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, mono = false, truncate = false }: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
truncate?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="text-xs text-gray-500 shrink-0">{label}</span>
|
||||
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState, Fragment } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||
|
||||
// ── P25 structured config types ──────────────────────────────────────────────
|
||||
// ── P25 structured config types ───────────────────────────────────────────────
|
||||
|
||||
interface TalkgroupEntry {
|
||||
id: string;
|
||||
@@ -72,6 +74,248 @@ function p25ConfigToRecord(p: P25Config): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
// ── RadioReference parser types ───────────────────────────────────────────────
|
||||
|
||||
interface RRTalkgroup {
|
||||
dec: number;
|
||||
alphaTag: string;
|
||||
description: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
interface RRCategory {
|
||||
name: string;
|
||||
talkgroups: RRTalkgroup[];
|
||||
}
|
||||
|
||||
interface RRSystem {
|
||||
name: string;
|
||||
location: string;
|
||||
sysIds: string;
|
||||
systemType: string;
|
||||
categories: RRCategory[];
|
||||
}
|
||||
|
||||
function mapRRTag(rrTag: string): string {
|
||||
const t = rrTag.toLowerCase();
|
||||
if (t.includes("fire")) return "fire";
|
||||
if (t.includes("law") || t.includes("police")) return "police";
|
||||
if (t.includes("ems") || t.includes("emergency medical")) return "ems";
|
||||
if (t.includes("transport") || t.includes("transit")) return "transit";
|
||||
if (t.includes("public works")) return "public works";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function parseRadioReference(html: string): RRSystem | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
// Validate: RadioReference system pages have rrlblue header cells
|
||||
if (!doc.querySelector(".rrlblue")) return null;
|
||||
|
||||
// System info table (first table with rrlblue headers)
|
||||
const infoMap: Record<string, string> = {};
|
||||
const infoTable = doc.querySelector("table.table-sm.table-bordered");
|
||||
if (infoTable) {
|
||||
infoTable.querySelectorAll("tr").forEach((row) => {
|
||||
const th = row.querySelector("th.rrlblue");
|
||||
const td = row.querySelector("td");
|
||||
if (th && td) infoMap[th.textContent?.trim() ?? ""] = td.textContent?.trim() ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
const name = infoMap["System Name"] ?? doc.title ?? "Unknown System";
|
||||
const location = infoMap["Location"] ?? "";
|
||||
const sysIds = infoMap["System IDs"] ?? "";
|
||||
const systemType = infoMap["System Type"] ?? "";
|
||||
|
||||
// Talkgroup tables — find all with class rrdbTable or datatable-lite
|
||||
// For each, find the nearest preceding h5 to use as category name
|
||||
const tgTables = Array.from(
|
||||
doc.querySelectorAll("table.rrdbTable, table.datatable-lite")
|
||||
) as HTMLTableElement[];
|
||||
|
||||
const allH5s = Array.from(doc.querySelectorAll("h5")) as HTMLElement[];
|
||||
|
||||
function categoryForTable(table: HTMLTableElement): string {
|
||||
// Find the last h5 that appears before this table in document order
|
||||
let best: HTMLElement | null = null;
|
||||
for (const h5 of allH5s) {
|
||||
const pos = h5.compareDocumentPosition(table);
|
||||
if (pos & Node.DOCUMENT_POSITION_FOLLOWING) best = h5;
|
||||
}
|
||||
if (!best) return "Uncategorized";
|
||||
const clone = best.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll("div, button, span.badge").forEach((el) => el.remove());
|
||||
return clone.textContent?.trim() || "Uncategorized";
|
||||
}
|
||||
|
||||
const categories: RRCategory[] = [];
|
||||
|
||||
for (const table of tgTables) {
|
||||
// Confirm it has the expected talkgroup columns (DEC, HEX, Mode, Alpha Tag, …)
|
||||
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
|
||||
th.textContent?.trim().toLowerCase()
|
||||
);
|
||||
if (!headers.includes("dec") && !headers.includes("hex")) continue;
|
||||
|
||||
const catName = categoryForTable(table);
|
||||
const talkgroups: RRTalkgroup[] = [];
|
||||
|
||||
table.querySelectorAll("tbody tr").forEach((row) => {
|
||||
const cells = Array.from(row.querySelectorAll("td"));
|
||||
if (cells.length < 6) return;
|
||||
|
||||
// DEC cell may wrap in a Broadcastify link
|
||||
const decText =
|
||||
cells[0].querySelector("a")?.textContent?.trim() ??
|
||||
cells[0].textContent?.trim() ??
|
||||
"";
|
||||
const dec = parseInt(decText.replace(/\D/g, ""), 10);
|
||||
if (isNaN(dec)) return;
|
||||
|
||||
const alphaTag = cells[3].textContent?.trim() ?? "";
|
||||
const description = cells[4].textContent?.trim() ?? "";
|
||||
const tag = cells[5].textContent?.trim() ?? "";
|
||||
|
||||
talkgroups.push({ dec, alphaTag, description, tag });
|
||||
});
|
||||
|
||||
if (talkgroups.length > 0) {
|
||||
// Merge into an existing category with same name if present
|
||||
const existing = categories.find((c) => c.name === catName);
|
||||
if (existing) {
|
||||
existing.talkgroups.push(...talkgroups);
|
||||
} else {
|
||||
categories.push({ name: catName, talkgroups });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (categories.length === 0) return null;
|
||||
|
||||
return { name, location, sysIds, systemType, categories };
|
||||
}
|
||||
|
||||
// ── RadioReference import modal ───────────────────────────────────────────────
|
||||
|
||||
function RRImportModal({
|
||||
system,
|
||||
onImport,
|
||||
onCancel,
|
||||
}: {
|
||||
system: RRSystem;
|
||||
onImport: (tgs: TalkgroupEntry[]) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<Set<string>>(
|
||||
() => new Set(system.categories.map((c) => c.name))
|
||||
);
|
||||
|
||||
function toggle(name: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name); else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
const tgs: TalkgroupEntry[] = [];
|
||||
for (const cat of system.categories) {
|
||||
if (!selected.has(cat.name)) continue;
|
||||
for (const tg of cat.talkgroups) {
|
||||
tgs.push({
|
||||
id: String(tg.dec),
|
||||
name: `${cat.name.split(" - ")[0]} - ${tg.description || tg.alphaTag}`,
|
||||
tag: mapRRTag(tg.tag),
|
||||
});
|
||||
}
|
||||
}
|
||||
onImport(tgs);
|
||||
}
|
||||
|
||||
const total = system.categories.reduce((s, c) => s + c.talkgroups.length, 0);
|
||||
const selectedCount = system.categories
|
||||
.filter((c) => selected.has(c.name))
|
||||
.reduce((s, c) => s + c.talkgroups.length, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
|
||||
<h2 className="text-white font-semibold">{system.name}</h2>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
||||
</p>
|
||||
{system.sysIds && (
|
||||
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Category list */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
|
||||
<div className="flex gap-3 text-xs text-indigo-400">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(new Set(system.categories.map((c) => c.name)))}
|
||||
className="hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{system.categories.map((cat) => (
|
||||
<label key={cat.name} className="flex items-center gap-3 cursor-pointer group py-0.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(cat.name)}
|
||||
onChange={() => toggle(cat.name)}
|
||||
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
|
||||
{cat.name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
disabled={selectedCount === 0}
|
||||
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
|
||||
>
|
||||
Import {selectedCount} talkgroup{selectedCount !== 1 ? "s" : ""}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
||||
|
||||
function TalkgroupEditor({
|
||||
@@ -83,6 +327,9 @@ function TalkgroupEditor({
|
||||
}) {
|
||||
const [showPaste, setShowPaste] = useState(false);
|
||||
const [pasteText, setPasteText] = useState("");
|
||||
const [rrSystem, setRrSystem] = useState<RRSystem | null>(null);
|
||||
const [rrError, setRrError] = useState<string | null>(null);
|
||||
const rrInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function addRow() {
|
||||
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
||||
@@ -115,13 +362,61 @@ function TalkgroupEditor({
|
||||
setShowPaste(false);
|
||||
}
|
||||
|
||||
async function handleRRFile(file: File) {
|
||||
setRrError(null);
|
||||
const html = await file.text();
|
||||
const parsed = parseRadioReference(html);
|
||||
if (!parsed) {
|
||||
setRrError(
|
||||
"This doesn't look like a RadioReference trunked system page. " +
|
||||
"Download the HTML from a system page on radioreference.com and try again."
|
||||
);
|
||||
return;
|
||||
}
|
||||
setRrSystem(parsed);
|
||||
}
|
||||
|
||||
function handleRRImport(newTgs: TalkgroupEntry[]) {
|
||||
onChange([...talkgroups, ...newTgs]);
|
||||
setRrSystem(null);
|
||||
setRrError(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{rrSystem && (
|
||||
<RRImportModal
|
||||
system={rrSystem}
|
||||
onImport={handleRRImport}
|
||||
onCancel={() => setRrSystem(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">
|
||||
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{/* RadioReference file import */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => rrInputRef.current?.click()}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
RadioReference import
|
||||
</button>
|
||||
<input
|
||||
ref={rrInputRef}
|
||||
type="file"
|
||||
accept=".html,.htm"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleRRFile(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPaste(!showPaste)}
|
||||
@@ -139,10 +434,17 @@ function TalkgroupEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rrError && (
|
||||
<p className="text-xs text-red-400 bg-red-950/30 border border-red-900/50 rounded px-3 py-2">
|
||||
{rrError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showPaste && (
|
||||
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<p className="text-xs text-gray-500">
|
||||
Paste rows from RadioReference — tab- or comma-separated: <span className="text-gray-400">ID, Name, Tag</span>
|
||||
Paste rows from RadioReference — tab- or comma-separated:{" "}
|
||||
<span className="text-gray-400">ID, Name, Tag</span>
|
||||
<br />Tags: fire · police · ems · transit · public works · other
|
||||
</p>
|
||||
<textarea
|
||||
@@ -219,7 +521,9 @@ function TalkgroupEditor({
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 italic py-1">No talkgroups — add rows or paste from RadioReference.</p>
|
||||
<p className="text-xs text-gray-600 italic py-1">
|
||||
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -293,10 +597,12 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
||||
|
||||
function SystemForm({
|
||||
initial,
|
||||
createOnly,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: SystemRecord;
|
||||
createOnly?: boolean;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
@@ -313,8 +619,8 @@ function SystemForm({
|
||||
: "{}"
|
||||
);
|
||||
const [showRaw, setShowRaw] = useState(initial?.type !== "P25");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function handleTypeChange(t: string) {
|
||||
setType(t);
|
||||
@@ -345,7 +651,7 @@ function SystemForm({
|
||||
} else {
|
||||
config = JSON.parse(rawJson);
|
||||
}
|
||||
if (initial) {
|
||||
if (initial && !createOnly) {
|
||||
await c2api.updateSystem(initial.system_id, { name, type, config });
|
||||
} else {
|
||||
await c2api.createSystem({ name, type, config });
|
||||
@@ -358,9 +664,11 @@ function SystemForm({
|
||||
}
|
||||
}
|
||||
|
||||
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
||||
<h3 className="text-white font-semibold">{initial ? "Edit System" : "New System"}</h3>
|
||||
<h3 className="text-white font-semibold">{title}</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -433,20 +741,275 @@ function SystemForm({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preferred bot token panel ─────────────────────────────────────────────────
|
||||
|
||||
interface TokenOption {
|
||||
token_id: string;
|
||||
name: string;
|
||||
in_use: boolean;
|
||||
}
|
||||
|
||||
function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; initialTokenId?: string | null }) {
|
||||
const [preferredId, setPreferredId] = useState<string | null>(initialTokenId ?? null);
|
||||
const [tokens, setTokens] = useState<TokenOption[] | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function load() {
|
||||
if (tokens !== null) return;
|
||||
try {
|
||||
const data = await c2api.getTokens();
|
||||
setTokens(data as TokenOption[]);
|
||||
} catch {
|
||||
setTokens([]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!open) load();
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
|
||||
async function handleSet(tokenId: string) {
|
||||
setSaving(true);
|
||||
try {
|
||||
await c2api.setPreferredToken(tokenId, systemId);
|
||||
setPreferredId(tokenId);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!preferredId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await c2api.setPreferredToken(preferredId, "_none");
|
||||
setPreferredId(null);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{open ? "▲" : "▼"}</span>
|
||||
<span>
|
||||
Preferred Bot Token
|
||||
{preferredId && <span className="ml-1.5 text-indigo-400">● set</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||
{tokens === null ? (
|
||||
<p className="text-gray-600 italic">Loading tokens…</p>
|
||||
) : tokens.length === 0 ? (
|
||||
<p className="text-gray-600 italic">No tokens in pool.</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600">
|
||||
When a node on this system joins a voice channel, this token is tried first.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{tokens.map((t) => (
|
||||
<label key={t.token_id} className="flex items-center gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={`preferred-token-${systemId}`}
|
||||
checked={preferredId === t.token_id}
|
||||
onChange={() => handleSet(t.token_id)}
|
||||
disabled={saving}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
|
||||
{t.name}
|
||||
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{preferredId && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
disabled={saving}
|
||||
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Clear preference (use any free token)
|
||||
</button>
|
||||
)}
|
||||
{!preferredId && (
|
||||
<p className="text-gray-700">No preference — any free token will be used.</p>
|
||||
)}
|
||||
{currentToken && (
|
||||
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
||||
|
||||
interface SystemAiFlags {
|
||||
stt_enabled?: boolean;
|
||||
correlation_enabled?: boolean;
|
||||
}
|
||||
|
||||
function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: SystemAiFlags }) {
|
||||
const [flags, setFlags] = useState<SystemAiFlags>(initial);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleToggle(key: keyof SystemAiFlags, value: boolean) {
|
||||
setSaving(key);
|
||||
try {
|
||||
const result = await c2api.setSystemAiFlags(systemId, { [key]: value });
|
||||
setFlags(result.ai_flags as SystemAiFlags);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear(key: keyof SystemAiFlags) {
|
||||
setSaving(key);
|
||||
try {
|
||||
const result = await c2api.setSystemAiFlags(systemId, { [key]: null });
|
||||
setFlags(result.ai_flags as SystemAiFlags);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: { key: keyof SystemAiFlags; label: string }[] = [
|
||||
{ key: "stt_enabled", label: "Speech-to-Text" },
|
||||
{ key: "correlation_enabled", label: "Incident Correlation" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{open ? "▲" : "▼"}</span>
|
||||
<span>AI Flags</span>
|
||||
{(flags.stt_enabled === false || flags.correlation_enabled === false) && (
|
||||
<span className="ml-1.5 text-yellow-600 font-bold">!</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||
{rows.map(({ key, label }) => {
|
||||
const override = flags[key];
|
||||
const isSet = override !== undefined;
|
||||
const isOn = override !== false;
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleToggle(key, !isOn)}
|
||||
disabled={saving === key}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||||
isOn ? "bg-indigo-600" : "bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||
</button>
|
||||
<span className="text-gray-300 flex-1">{label}</span>
|
||||
{isSet ? (
|
||||
<button
|
||||
onClick={() => handleClear(key)}
|
||||
disabled={saving === key}
|
||||
className="text-gray-600 hover:text-gray-400 transition-colors"
|
||||
title="Clear override (inherit global)"
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-700">inherits global</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Source call audio player ──────────────────────────────────────────────────
|
||||
|
||||
function SourceCallPlayer({ callId }: { callId: string }) {
|
||||
const [call, setCall] = useState<{ audio_url?: string | null; transcript?: string | null; transcript_corrected?: string | null } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function toggle() {
|
||||
if (!open && !call) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const c = await c2api.getCall(callId);
|
||||
setCall(c as unknown as typeof call);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
}
|
||||
|
||||
const transcript = call?.transcript_corrected || call?.transcript;
|
||||
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<button
|
||||
onClick={toggle}
|
||||
disabled={loading}
|
||||
className="text-indigo-500 hover:text-indigo-400 transition-colors disabled:opacity-50"
|
||||
title={callId}
|
||||
>
|
||||
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
||||
</button>
|
||||
{open && call && (
|
||||
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
|
||||
{call.audio_url ? (
|
||||
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
||||
) : (
|
||||
<p className="text-gray-600 italic">No audio</p>
|
||||
)}
|
||||
{transcript && (
|
||||
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||||
|
||||
function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
const [vocab, setVocab] = useState<string[] | null>(null);
|
||||
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
|
||||
const [vocab, setVocab] = useState<string[] | null>(null);
|
||||
const [pending, setPending] = useState<VocabularyPendingTerm[]>([]);
|
||||
const [bootstrapped, setBootstrapped] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
const [newTerm, setNewTerm] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newTerm, setNewTerm] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function load() {
|
||||
if (vocab !== null) return; // already loaded
|
||||
if (vocab !== null) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await c2api.getVocabulary(systemId);
|
||||
@@ -516,7 +1079,11 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
<span>{open ? "▲" : "▼"}</span>
|
||||
<span>
|
||||
Vocabulary
|
||||
{vocab !== null && <span className="text-gray-600 ml-1">({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})</span>}
|
||||
{vocab !== null && (
|
||||
<span className="text-gray-600 ml-1">
|
||||
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -526,7 +1093,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
|
||||
{!loading && vocab !== null && (
|
||||
<>
|
||||
{/* Bootstrap button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleBootstrap}
|
||||
@@ -538,16 +1104,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
||||
</div>
|
||||
|
||||
{/* Approved vocabulary chips */}
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
||||
{vocab.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{vocab.map((term) => (
|
||||
<span
|
||||
key={term}
|
||||
className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
||||
{term}
|
||||
<button
|
||||
onClick={() => handleRemove(term)}
|
||||
@@ -563,7 +1125,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add term */}
|
||||
<form onSubmit={handleAdd} className="flex gap-2">
|
||||
<input
|
||||
value={newTerm}
|
||||
@@ -580,29 +1141,29 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Pending induction suggestions */}
|
||||
{pending.length > 0 && (
|
||||
<div>
|
||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
||||
Induction suggestions ({pending.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
{pending.map((p) => (
|
||||
<div key={p.term} className="flex items-center gap-2">
|
||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||
<span className="text-gray-600">{p.source}</span>
|
||||
<button
|
||||
onClick={() => handleApprove(p.term)}
|
||||
className="text-green-500 hover:text-green-400 transition-colors px-1"
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDismiss(p.term)}
|
||||
className="text-gray-600 hover:text-red-400 transition-colors px-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div key={p.term} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||
<span className="text-gray-600">{p.source}</span>
|
||||
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
||||
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
||||
</div>
|
||||
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||||
<div className="pl-1 space-y-1">
|
||||
{p.source_call_ids.map((id: string) => (
|
||||
<Fragment key={id}>
|
||||
<SourceCallPlayer callId={id} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -619,20 +1180,44 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
// ── Systems list page ─────────────────────────────────────────────────────────
|
||||
|
||||
export default function SystemsPage() {
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { systems, loading } = useSystems();
|
||||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||
const [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("Delete this system?")) return;
|
||||
await c2api.deleteSystem(id);
|
||||
}
|
||||
|
||||
function openEdit(s: SystemRecord) {
|
||||
setEditing(s);
|
||||
setEditIsDuplicate(false);
|
||||
}
|
||||
|
||||
function openDuplicate(s: SystemRecord) {
|
||||
setEditing({ ...s, name: `Copy of ${s.name}` });
|
||||
setEditIsDuplicate(true);
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
setEditing(null);
|
||||
setEditIsDuplicate(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||
<button
|
||||
onClick={() => setEditing("new")}
|
||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||
>
|
||||
+ New System
|
||||
@@ -642,8 +1227,9 @@ export default function SystemsPage() {
|
||||
{editing && (
|
||||
<SystemForm
|
||||
initial={editing === "new" ? undefined : editing}
|
||||
onSave={() => setEditing(null)}
|
||||
onCancel={() => setEditing(null)}
|
||||
createOnly={editIsDuplicate}
|
||||
onSave={closeEdit}
|
||||
onCancel={closeEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -680,11 +1266,17 @@ export default function SystemsPage() {
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3">
|
||||
<button
|
||||
onClick={() => setEditing(s)}
|
||||
onClick={() => openEdit(s)}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDuplicate(s)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(s.system_id)}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||
@@ -692,6 +1284,8 @@ export default function SystemsPage() {
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />
|
||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||
<VocabularyPanel systemId={s.system_id} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ interface TokenRecord {
|
||||
}
|
||||
|
||||
export default function TokensPage() {
|
||||
const { isAdmin, loading: authLoading } = useAuth();
|
||||
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -26,8 +26,8 @@ export default function TokensPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAdmin) router.replace("/dashboard");
|
||||
}, [authLoading, isAdmin, router]);
|
||||
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||
}, [authLoading, isAdmin, isOperator, router]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
@@ -67,10 +67,10 @@ export default function TokensPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading || !isAdmin) return null;
|
||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTrips } from "@/lib/useTrips";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { TripRecord } from "@/lib/types";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function TripCard({ trip, isAdmin, onDelete }: {
|
||||
trip: TripRecord;
|
||||
isAdmin: boolean;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const upcoming = trip.start_date >= today;
|
||||
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
|
||||
onClick={() => router.push(`/trips/${trip.trip_id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
|
||||
}`}>
|
||||
{upcoming ? "Upcoming" : "Past"}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
|
||||
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
|
||||
<p className="text-gray-500 text-xs font-mono mt-1">
|
||||
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
||||
</p>
|
||||
{attendeeCount > 0 && (
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
{attendeeCount} going
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(trip.trip_id); }}
|
||||
className="text-xs text-red-500 hover:text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateModal({ onClose, onCreate }: {
|
||||
onClose: () => void;
|
||||
onCreate: (body: object) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [mapsLink, setMapsLink] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (end < start) { setError("End date must be on or after start date."); return; }
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onCreate({
|
||||
name,
|
||||
location,
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
maps_link: mapsLink || null,
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Failed to create trip.");
|
||||
} 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">New Trip</h2>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
||||
<input
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Road trip to Nashville"
|
||||
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">Location</label>
|
||||
<input
|
||||
required value={location} onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Nashville, TN"
|
||||
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 className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Start date</label>
|
||||
<input
|
||||
required type="date" value={start} onChange={(e) => setStart(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">End date</label>
|
||||
<input
|
||||
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
||||
min={start}
|
||||
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>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
|
||||
<input
|
||||
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
|
||||
placeholder="https://maps.google.com/…"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<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 Trip"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TripsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { trips, loading } = useTrips();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const upcoming = trips.filter((t) => t.end_date >= today);
|
||||
const past = trips.filter((t) => t.end_date < today);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try { await c2api.deleteTrip(id); }
|
||||
catch (e) { console.error(e); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Trips</h1>
|
||||
{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"
|
||||
>
|
||||
+ New Trip
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
{upcoming.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2>
|
||||
<div className="space-y-3">
|
||||
{upcoming.map((t) => (
|
||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{past.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2>
|
||||
<div className="space-y-3">
|
||||
{past.map((t) => (
|
||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{trips.length === 0 && (
|
||||
<p className="text-gray-600 text-sm font-mono">No trips yet.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<CreateModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreate={async (body) => { await c2api.createTrip(body); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,25 +3,33 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
||||
import { auth } from "@/lib/firebase";
|
||||
import type { UserRole } from "@/lib/types";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
role: UserRole | null;
|
||||
isAdmin: boolean;
|
||||
isOperator: boolean;
|
||||
ownedNodeIds: string[];
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
loading: true,
|
||||
role: null,
|
||||
isAdmin: false,
|
||||
isOperator: false,
|
||||
ownedNodeIds: [],
|
||||
signOut: async () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [role, setRole] = useState<UserRole | null>(null);
|
||||
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return onAuthStateChanged(auth, async (u) => {
|
||||
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (u) {
|
||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
||||
// Read custom claims to determine admin status
|
||||
const result = await u.getIdTokenResult(true);
|
||||
setIsAdmin(!!result.claims.admin);
|
||||
const claims = result.claims;
|
||||
|
||||
// Derive role: prefer granular "role" claim, fall back to legacy "admin" boolean
|
||||
let effectiveRole: UserRole = "viewer";
|
||||
if (claims.role === "admin" || claims.admin) {
|
||||
effectiveRole = "admin";
|
||||
} else if (claims.role === "operator") {
|
||||
effectiveRole = "operator";
|
||||
} else if (claims.role === "viewer") {
|
||||
effectiveRole = "viewer";
|
||||
}
|
||||
|
||||
setRole(effectiveRole);
|
||||
setOwnedNodeIds((claims.owned_node_ids as string[]) ?? []);
|
||||
} else {
|
||||
document.cookie = "drb_session=; path=/; max-age=0";
|
||||
setIsAdmin(false);
|
||||
setRole(null);
|
||||
setOwnedNodeIds([]);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
@@ -45,8 +66,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
document.cookie = "drb_session=; path=/; max-age=0";
|
||||
}
|
||||
|
||||
const isAdmin = role === "admin";
|
||||
const isOperator = role === "operator";
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
|
||||
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
@@ -31,8 +31,13 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
const [editText, setEditText] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// Resolve incident links: prefer new list, fall back to legacy single field.
|
||||
const incidentIds: string[] = (call.incident_ids?.length ?? 0) > 0
|
||||
? call.incident_ids
|
||||
: call.incident_id ? [call.incident_id] : [];
|
||||
|
||||
const isActive = call.status === "active";
|
||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || call.incident_id;
|
||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
|
||||
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
||||
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
||||
const hasSegments = call.segments && call.segments.length > 1;
|
||||
@@ -62,8 +67,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
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 className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
||||
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
||||
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-300">
|
||||
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||
@@ -82,19 +88,11 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{call.audio_url ? (
|
||||
<a
|
||||
href={call.audio_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-blue-400 hover:text-blue-300 text-xs"
|
||||
>
|
||||
audio
|
||||
</a>
|
||||
<span className="text-blue-400">▶</span>
|
||||
) : (
|
||||
<span className="text-gray-700 text-xs">—</span>
|
||||
<span className="text-gray-700">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-600 text-xs">
|
||||
@@ -105,6 +103,16 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{expanded && hasDetails && (
|
||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||
{/* Audio player */}
|
||||
{call.audio_url && (
|
||||
<audio
|
||||
controls
|
||||
src={call.audio_url}
|
||||
className="w-full max-w-sm h-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{call.tags && call.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -119,14 +127,39 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incident link */}
|
||||
{call.incident_id && (
|
||||
<p className="text-xs font-mono text-indigo-400">
|
||||
Incident:{" "}
|
||||
<a href={`/incidents/${call.incident_id}`} className="underline hover:text-indigo-300">
|
||||
{call.incident_id.slice(0, 8)}…
|
||||
</a>
|
||||
</p>
|
||||
{/* Incident links — one per scene detected in the recording */}
|
||||
{incidentIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
||||
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||
{incidentIds.map((id) => (
|
||||
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
||||
{id.slice(0, 8)}…
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Correlation debug — admin only */}
|
||||
{isAdmin && call.corr_path && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
|
||||
<span>corr:</span>
|
||||
<span className="text-gray-400">{call.corr_path}</span>
|
||||
{call.corr_incident_idle_min != null && (
|
||||
<span>idle {call.corr_incident_idle_min}min</span>
|
||||
)}
|
||||
{call.corr_score != null && (
|
||||
<span>sim={call.corr_score.toFixed(3)}</span>
|
||||
)}
|
||||
{call.corr_distance_km != null && (
|
||||
<span>dist={call.corr_distance_km}km</span>
|
||||
)}
|
||||
{call.corr_shared_units != null && (
|
||||
<span>{call.corr_shared_units} shared units</span>
|
||||
)}
|
||||
{call.corr_candidates != null && (
|
||||
<span>{call.corr_candidates} candidates</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transcript */}
|
||||
|
||||
+592
-103
@@ -1,144 +1,633 @@
|
||||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
FeatureGroup,
|
||||
LayersControl,
|
||||
MapContainer,
|
||||
Marker,
|
||||
Popup,
|
||||
TileLayer,
|
||||
useMap,
|
||||
} from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
|
||||
import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types";
|
||||
|
||||
// Fix Leaflet default icon paths broken by webpack
|
||||
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
|
||||
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
const nodeIcon = (status: string) =>
|
||||
L.divIcon({
|
||||
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||
const INCIDENT_COLORS: Record<string, string> = {
|
||||
fire: "#ef4444",
|
||||
police: "#3b82f6",
|
||||
ems: "#eab308",
|
||||
accident: "#f97316",
|
||||
other: "#6b7280",
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === "online") return "#4ade80";
|
||||
if (status === "recording") return "#fb923c";
|
||||
if (status === "unconfigured") return "#818cf8";
|
||||
return "#6b7280";
|
||||
}
|
||||
|
||||
// ── Single-node icon (with optional pulsing ring for recording) ───────────────
|
||||
function nodeIcon(status: string): L.DivIcon {
|
||||
const isRec = status === "recording";
|
||||
const color = statusColor(status);
|
||||
const ring = isRec
|
||||
? `<div class="node-pulse-ring" style="position:absolute;width:28px;height:28px;border-radius:50%;border:2px solid #fb923c;top:-7px;left:-7px;pointer-events:none;"></div>`
|
||||
: "";
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="
|
||||
width:14px;height:14px;border-radius:50%;
|
||||
background:${status === "online" || status === "recording" ? "#4ade80" : status === "unconfigured" ? "#818cf8" : "#6b7280"};
|
||||
border:2px solid #111827;
|
||||
box-shadow:0 0 6px ${status === "recording" ? "#fb923c" : "transparent"};
|
||||
"></div>`,
|
||||
html: `<div style="position:relative;width:14px;height:14px">${ring}<div style="width:14px;height:14px;border-radius:50%;background:${color};border:2px solid #111827;box-shadow:0 0 6px ${isRec ? "#fb923c" : "transparent"};"></div></div>`,
|
||||
iconSize: [14, 14],
|
||||
iconAnchor: [7, 7],
|
||||
});
|
||||
}
|
||||
|
||||
const INCIDENT_COLORS: Record<string, string> = {
|
||||
fire: "#ef4444",
|
||||
police: "#3b82f6",
|
||||
ems: "#eab308",
|
||||
accident: "#f97316",
|
||||
other: "#6b7280",
|
||||
};
|
||||
|
||||
const incidentIcon = (type: string | null) => {
|
||||
function incidentIcon(type: string | null): L.DivIcon {
|
||||
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>`,
|
||||
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:#fff;font-weight:bold;line-height:1;">!</div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ── Fan / hand-of-cards icons for clustered markers ───────────────────────────
|
||||
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||||
const n = members.length;
|
||||
const CARD = 13;
|
||||
const STEP = 5;
|
||||
const totalW = CARD + (n - 1) * STEP;
|
||||
const maxRot = Math.min(28, n * 7);
|
||||
const cards = members
|
||||
.map((m, i) => {
|
||||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||||
const rot = ratio * maxRot;
|
||||
const left = i * STEP;
|
||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${statusColor(m.status)};border:1.5px solid #111827;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.7);"></div>`;
|
||||
})
|
||||
.join("");
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
|
||||
iconSize: [totalW, CARD + 6],
|
||||
iconAnchor: [totalW / 2, CARD + 6],
|
||||
});
|
||||
}
|
||||
|
||||
function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||||
const n = members.length;
|
||||
const CARD = 14;
|
||||
const STEP = 8;
|
||||
const totalW = CARD + (n - 1) * STEP;
|
||||
const maxRot = Math.min(28, n * 7);
|
||||
const cards = members
|
||||
.map((m, i) => {
|
||||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||||
const rot = ratio * maxRot;
|
||||
const left = i * STEP;
|
||||
const color = INCIDENT_COLORS[m.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:2px;background:${color};border:1.5px solid #111827;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;font-size:8px;color:#fff;font-weight:bold;">!</div>`;
|
||||
})
|
||||
.join("");
|
||||
return L.divIcon({
|
||||
className: "",
|
||||
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
|
||||
iconSize: [totalW, CARD + 6],
|
||||
iconAnchor: [totalW / 2, CARD + 6],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fan cluster grouping ──────────────────────────────────────────────────────
|
||||
const CLUSTER_PX = 32;
|
||||
|
||||
function computeGroups<T extends { id: string; lat: number; lng: number }>(
|
||||
items: T[],
|
||||
map: L.Map
|
||||
): Map<string, T[]> {
|
||||
if (!items.length) return new Map();
|
||||
const withPx = items.map((item) => ({
|
||||
item,
|
||||
px: map.latLngToContainerPoint([item.lat, item.lng]),
|
||||
}));
|
||||
const parent: number[] = items.map((_, i) => i);
|
||||
function find(x: number): number {
|
||||
if (parent[x] !== x) parent[x] = find(parent[x]);
|
||||
return parent[x];
|
||||
}
|
||||
for (let i = 0; i < withPx.length; i++) {
|
||||
for (let j = i + 1; j < withPx.length; j++) {
|
||||
const dx = withPx[i].px.x - withPx[j].px.x;
|
||||
const dy = withPx[i].px.y - withPx[j].px.y;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < CLUSTER_PX) {
|
||||
const ri = find(i), rj = find(j);
|
||||
if (ri !== rj) parent[ri] = rj;
|
||||
}
|
||||
}
|
||||
}
|
||||
const groups = new Map<number, T[]>();
|
||||
withPx.forEach(({ item }, i) => {
|
||||
const root = find(i);
|
||||
if (!groups.has(root)) groups.set(root, []);
|
||||
groups.get(root)!.push(item);
|
||||
});
|
||||
const result = new Map<string, T[]>();
|
||||
Array.from(groups.values()).forEach((members) => result.set(members[0].id, members));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ── MapRefCapture — exposes L.Map instance to parent ─────────────────────────
|
||||
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
onReady(map);
|
||||
}, [map, onReady]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── FanNodeLayer ──────────────────────────────────────────────────────────────
|
||||
function FanNodeLayer({
|
||||
nodes,
|
||||
activeCalls,
|
||||
}: {
|
||||
nodes: NodeRecord[];
|
||||
activeCalls: CallRecord[];
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const h = () => setTick((t: number) => t + 1);
|
||||
map.on("zoomend moveend", h);
|
||||
return () => { map.off("zoomend moveend", h); };
|
||||
}, [map]);
|
||||
|
||||
const activeByNode = useMemo(
|
||||
() => Object.fromEntries(activeCalls.map((c) => [c.node_id, c])),
|
||||
[activeCalls]
|
||||
);
|
||||
const nodeById = useMemo(() => new Map(nodes.map((n) => [n.node_id, n])), [nodes]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const items = nodes.map((n) => ({ id: n.node_id, lat: n.lat, lng: n.lon }));
|
||||
return computeGroups(items, map);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes, map, tick]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => {
|
||||
const members = raw.map((r) => nodeById.get(r.id)!).filter(Boolean);
|
||||
const rep = nodeById.get(repId);
|
||||
if (!rep) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={repId}
|
||||
position={[rep.lat, rep.lon]}
|
||||
icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)}
|
||||
>
|
||||
<Popup className="font-mono" minWidth={160}>
|
||||
<div className="text-gray-900 space-y-2">
|
||||
{members.map((node, idx) => (
|
||||
<div
|
||||
key={node.node_id}
|
||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||
>
|
||||
<p className="font-bold text-sm">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs capitalize">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-0.5">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
{activeByNode[node.node_id].talkgroup_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FanIncidentLayer ──────────────────────────────────────────────────────────
|
||||
function FanIncidentLayer({
|
||||
incidents,
|
||||
onSelect,
|
||||
}: {
|
||||
incidents: IncidentRecord[];
|
||||
onSelect: (inc: IncidentRecord) => void;
|
||||
}) {
|
||||
const map = useMap();
|
||||
const [tick, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const h = () => setTick((t: number) => t + 1);
|
||||
map.on("zoomend moveend", h);
|
||||
return () => { map.off("zoomend moveend", h); };
|
||||
}, [map]);
|
||||
|
||||
const plotted = useMemo(
|
||||
() =>
|
||||
incidents
|
||||
.filter((i) => i.location_coords)
|
||||
.map((i) => ({
|
||||
id: i.incident_id,
|
||||
lat: i.location_coords!.lat,
|
||||
lng: i.location_coords!.lng,
|
||||
inc: i,
|
||||
})),
|
||||
[incidents]
|
||||
);
|
||||
|
||||
const incById = useMemo(
|
||||
() => new Map(plotted.map((p: { id: string; lat: number; lng: number; inc: IncidentRecord }) => [p.id, p.inc])),
|
||||
[plotted]
|
||||
);
|
||||
|
||||
const groups = useMemo(
|
||||
() => computeGroups(plotted, map),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[plotted, map, tick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => {
|
||||
const members = raw.map((r) => incById.get(r.id)!).filter(Boolean);
|
||||
const repPlot = plotted.find((p: { id: string }) => p.id === repId);
|
||||
if (!repPlot) return null;
|
||||
return (
|
||||
<Marker
|
||||
key={repId}
|
||||
position={[repPlot.lat, repPlot.lng]}
|
||||
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type)}
|
||||
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
||||
>
|
||||
<Popup className="font-mono" minWidth={180}>
|
||||
<div className="text-gray-900 space-y-2">
|
||||
{members.map((inc, idx) => (
|
||||
<div
|
||||
key={inc.incident_id}
|
||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||
>
|
||||
<p className="font-bold text-sm">{inc.title ?? "Incident"}</p>
|
||||
<p
|
||||
className="text-xs capitalize"
|
||||
style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}
|
||||
>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||||
<a
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
||||
>
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function timeAgo(date: Date): string {
|
||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
return `${Math.floor(s / 3600)}h ago`;
|
||||
}
|
||||
|
||||
// ── Main MapView ──────────────────────────────────────────────────────────────
|
||||
interface Props {
|
||||
nodes: NodeRecord[];
|
||||
activeCalls: CallRecord[];
|
||||
incidents?: IncidentRecord[];
|
||||
lastUpdated?: Date | null;
|
||||
}
|
||||
|
||||
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
||||
const activeByNode = Object.fromEntries(
|
||||
activeCalls.map((c) => [c.node_id, c])
|
||||
export default function MapView({ nodes, activeCalls, incidents = [], lastUpdated }: Props) {
|
||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agoClock, setAgoClock] = useState(0);
|
||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
||||
const [clockStr, setClockStr] = useState(() =>
|
||||
new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })
|
||||
);
|
||||
|
||||
// Only show incidents that have been geocoded (location_coords set by the server).
|
||||
const plottedIncidents = incidents.flatMap((inc) =>
|
||||
inc.location_coords
|
||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
||||
: []
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Radar tiles are static once loaded — force remount every 5 min to refresh
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
|
||||
// Live clock for TOC situational awareness
|
||||
useEffect(() => {
|
||||
const id = setInterval(() =>
|
||||
setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })),
|
||||
1000
|
||||
);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
const allPositions = useMemo(
|
||||
() => [
|
||||
...nodes.map((n) => [n.lat, n.lon] as [number, number]),
|
||||
...incidents
|
||||
.filter((i) => i.location_coords)
|
||||
.map((i) => [i.location_coords!.lat, i.location_coords!.lng] as [number, number]),
|
||||
],
|
||||
[nodes, incidents]
|
||||
);
|
||||
|
||||
const center: [number, number] =
|
||||
nodes.length > 0
|
||||
? [nodes[0].lat, nodes[0].lon]
|
||||
: plottedIncidents.length > 0
|
||||
? plottedIncidents[0].pos
|
||||
: [39.5, -98.35];
|
||||
: allPositions.length > 0
|
||||
? allPositions[0]
|
||||
: [39.5, -98.35];
|
||||
|
||||
const zoom =
|
||||
nodes.length > 0
|
||||
? 10
|
||||
: plottedIncidents.length > 0
|
||||
? 14
|
||||
: 4;
|
||||
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
|
||||
|
||||
const handleFitAll = useCallback(() => {
|
||||
if (!mapInstance || allPositions.length === 0) return;
|
||||
if (allPositions.length === 1) {
|
||||
mapInstance.setView(allPositions[0], 14);
|
||||
} else {
|
||||
mapInstance.fitBounds(L.latLngBounds(allPositions), { padding: [40, 40] });
|
||||
}
|
||||
}, [mapInstance, allPositions]);
|
||||
|
||||
const handleIncidentSelect = useCallback(
|
||||
(inc: IncidentRecord) => {
|
||||
if (!mapInstance || !inc.location_coords) return;
|
||||
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
|
||||
},
|
||||
[mapInstance]
|
||||
);
|
||||
|
||||
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
<div className="relative w-full h-full">
|
||||
{/* ── Map container ───────────────────────────────────────────────────── */}
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
className="w-full h-full rounded-lg"
|
||||
style={{ background: "#111827" }}
|
||||
>
|
||||
<MapRefCapture onReady={onMapReady} />
|
||||
|
||||
{/* Node markers */}
|
||||
{nodes.map((node) => (
|
||||
<Marker
|
||||
key={node.node_id}
|
||||
position={[node.lat, node.lon]}
|
||||
icon={nodeIcon(node.status)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{node.name}</p>
|
||||
<p className="text-xs text-gray-500">{node.node_id}</p>
|
||||
<p className="text-xs mt-1 capitalize">{node.status}</p>
|
||||
{activeByNode[node.node_id] && (
|
||||
<p className="text-xs text-orange-600 mt-1">
|
||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||
{activeByNode[node.node_id].talkgroup_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
<LayersControl position="topright">
|
||||
{/* Base layers */}
|
||||
<LayersControl.BaseLayer checked name="Dark">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Light">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Streets">
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
|
||||
{/* Incident markers — positioned at the node covering the incident's system */}
|
||||
{plottedIncidents.map(({ inc, pos }) => (
|
||||
<Marker
|
||||
key={inc.incident_id}
|
||||
position={pos}
|
||||
icon={incidentIcon(inc.type)}
|
||||
>
|
||||
<Popup className="font-mono">
|
||||
<div className="text-gray-900">
|
||||
<p className="font-bold">{inc.title ?? "Incident"}</p>
|
||||
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}>
|
||||
{inc.type ?? "other"}
|
||||
</p>
|
||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
||||
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</p>}
|
||||
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
|
||||
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
|
||||
View incident →
|
||||
</a>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
{/* Overlay: Nodes */}
|
||||
<LayersControl.Overlay checked name="Nodes">
|
||||
<FeatureGroup>
|
||||
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Active Incidents */}
|
||||
<LayersControl.Overlay checked name="Active Incidents">
|
||||
<FeatureGroup>
|
||||
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||||
<LayersControl.Overlay name="Weather Radar">
|
||||
<TileLayer
|
||||
key={radarEpoch}
|
||||
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
|
||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||||
opacity={0.65}
|
||||
/>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: News / RSS alerts — placeholder for future integration */}
|
||||
<LayersControl.Overlay name="News Alerts">
|
||||
<FeatureGroup />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: ADS-B — placeholder for future integration */}
|
||||
<LayersControl.Overlay name="ADS-B">
|
||||
<FeatureGroup />
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Meshtastic — placeholder for future integration */}
|
||||
<LayersControl.Overlay name="Meshtastic">
|
||||
<FeatureGroup />
|
||||
</LayersControl.Overlay>
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
|
||||
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
||||
{ago && (
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||
<span className="bg-gray-950/90 border border-gray-700 rounded-full px-3 py-1 text-xs font-mono text-green-400 whitespace-nowrap">
|
||||
● Live · {ago}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
|
||||
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
|
||||
{mapInstance && allPositions.length > 0 && (
|
||||
<button
|
||||
onClick={handleFitAll}
|
||||
title="Fit all markers in view"
|
||||
className="w-8 h-8 bg-gray-950/90 border border-gray-700 rounded text-white text-base leading-none hover:bg-gray-800 transition-colors flex items-center justify-center select-none"
|
||||
>
|
||||
⤢
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
||||
<div className="absolute bottom-8 left-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 pointer-events-none">
|
||||
<span className="text-white text-sm font-mono tabular-nums">{clockStr}</span>
|
||||
</div>
|
||||
|
||||
{/* ── Legend — bottom-right to avoid incident panel on left ────────────── */}
|
||||
<div className="absolute bottom-8 right-3 z-[1001] bg-gray-950/90 border border-gray-800 rounded-lg px-3 py-2 text-xs font-mono pointer-events-none space-y-1">
|
||||
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
||||
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
||||
<div className="border-t border-gray-800 my-0.5" />
|
||||
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
||||
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
||||
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
||||
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||
</div>
|
||||
|
||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||
{incidents.length > 0 && (
|
||||
<>
|
||||
{/* Desktop: left sidebar — starts below zoom controls + fit-all button */}
|
||||
<div className="absolute top-[8rem] left-3 bottom-[4.5rem] z-[1001] hidden md:flex flex-col w-56 gap-1.5 overflow-y-auto">
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||||
const unitCount = inc.units?.length ?? 0;
|
||||
const baseClass = "w-full text-left bg-gray-950/85 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs font-mono hover:brightness-110 transition-all";
|
||||
const cardBody = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-sm flex-shrink-0"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
<span
|
||||
className="uppercase tracking-wide font-semibold text-[10px]"
|
||||
style={{ color }}
|
||||
>
|
||||
{inc.type ?? "other"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white font-semibold leading-snug truncate">
|
||||
{inc.title ?? "Incident"}
|
||||
</p>
|
||||
{inc.location && (
|
||||
<p className="text-gray-500 truncate mt-0.5">{inc.location}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{age && <span className="text-gray-600">{age}</span>}
|
||||
{unitCount > 0 && (
|
||||
<span className="text-gray-600">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</div>
|
||||
{!inc.location_coords && (
|
||||
<p className="text-[10px] text-blue-700 mt-1">View details →</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (inc.location_coords) {
|
||||
return (
|
||||
<button
|
||||
key={inc.incident_id}
|
||||
onClick={() => handleIncidentSelect(inc)}
|
||||
className={baseClass}
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{cardBody}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={inc.incident_id}
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
className={`block ${baseClass}`}
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{cardBody}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mobile: bottom drawer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
||||
<button
|
||||
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
||||
className="w-full bg-gray-950/95 border-t border-gray-800 px-4 py-2 text-xs font-mono text-gray-300 flex items-center justify-between"
|
||||
>
|
||||
<span>Incidents ({incidents.length})</span>
|
||||
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||||
</button>
|
||||
{drawerOpen && (
|
||||
<div className="bg-gray-950/95 border-t border-gray-800 max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||
{incidents.map((inc) => {
|
||||
const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||
const label = (
|
||||
<>
|
||||
<span className="font-semibold" style={{ color }}>
|
||||
{inc.type ?? "other"}
|
||||
</span>
|
||||
{" — "}
|
||||
<span className="text-white">{inc.title ?? "Incident"}</span>
|
||||
</>
|
||||
);
|
||||
if (inc.location_coords) {
|
||||
return (
|
||||
<button
|
||||
key={inc.incident_id}
|
||||
onClick={() => {
|
||||
setDrawerOpen(false);
|
||||
handleIncidentSelect(inc);
|
||||
}}
|
||||
className="w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={inc.incident_id}
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
className="block w-full text-left border rounded px-2 py-1.5 text-xs font-mono"
|
||||
style={{ borderColor: color + "55" }}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+163
-43
@@ -1,67 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
|
||||
const links = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/nodes", label: "Nodes" },
|
||||
{ href: "/systems", label: "Systems" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ href: "/alerts", label: "Alerts" },
|
||||
// Links visible to all authenticated roles (viewer+)
|
||||
const viewerLinks = [
|
||||
{ href: "/dashboard", label: "Dashboard" },
|
||||
{ href: "/calls", label: "Calls" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/map", label: "Map" },
|
||||
{ href: "/alerts", label: "Alerts" },
|
||||
{ href: "/trips", label: "Trips" },
|
||||
];
|
||||
|
||||
// Additional links for operators and admins
|
||||
const operatorLinks = [
|
||||
{ href: "/nodes", label: "Nodes" },
|
||||
{ href: "/systems", label: "Systems" },
|
||||
{ href: "/tokens", label: "Tokens" },
|
||||
];
|
||||
|
||||
// Admin-only links
|
||||
const adminLinks = [
|
||||
{ href: "/tokens", label: "Tokens" },
|
||||
{ href: "/admin", label: "Admin" },
|
||||
];
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Nav() {
|
||||
const { user, isAdmin, signOut } = useAuth();
|
||||
const { user, isAdmin, isOperator } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { nodes: pending } = useUnconfiguredNodes();
|
||||
const unackedAlerts = useUnacknowledgedAlerts();
|
||||
const { theme, toggle } = useTheme();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const allLinks = [
|
||||
...viewerLinks,
|
||||
...(isAdmin || isOperator ? operatorLinks : []),
|
||||
...(isAdmin ? adminLinks : []),
|
||||
];
|
||||
|
||||
function navLinkClass(href: string) {
|
||||
return `text-sm font-mono transition-colors shrink-0 ${
|
||||
pathname.startsWith(href) ? "text-white" : "text-gray-500 hover:text-gray-300"
|
||||
}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
|
||||
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
|
||||
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-mono transition-colors shrink-0 ${
|
||||
pathname.startsWith(href)
|
||||
? "text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="ml-auto shrink-0">
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||
{/* Main bar */}
|
||||
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
|
||||
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
||||
{allLinks.map(({ href, label }) => (
|
||||
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
{/* Profile avatar (desktop) */}
|
||||
<button
|
||||
onClick={() => router.push("/profile")}
|
||||
className={`hidden md:flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold transition-colors ${
|
||||
pathname.startsWith("/profile")
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-gray-800 text-gray-300 hover:bg-gray-700"
|
||||
}`}
|
||||
title="Profile"
|
||||
>
|
||||
{(user?.displayName || user?.email || "?")[0].toUpperCase()}
|
||||
</button>
|
||||
|
||||
{/* Hamburger (mobile) */}
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||
{allLinks.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||
pathname.startsWith(href) ? "text-white" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{label === "Nodes" && pending.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
|
||||
{pending.length}
|
||||
</span>
|
||||
)}
|
||||
{label === "Alerts" && unackedAlerts.length > 0 && (
|
||||
<span className="inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
|
||||
{unackedAlerts.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-1">
|
||||
<Link
|
||||
href="/profile"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors flex items-center gap-2 ${
|
||||
pathname.startsWith("/profile") ? "text-white" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,29 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ value: "rtl-sdr-v3", label: "RTL-SDR v3", hint: "TCXO — LNA:34, tracking off" },
|
||||
{ value: "nesdr-smart-v4", label: "NESDR Smart v4", hint: "TCXO — LNA:32, tracking off" },
|
||||
{ value: "other", label: "Other", hint: "LNA:32, tracking on" },
|
||||
];
|
||||
|
||||
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
const [systemId, setSystemId] = useState("");
|
||||
const [preset, setPreset] = useState("rtl-sdr-v3");
|
||||
const [ppm, setPpm] = useState("0");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const ppmVal = parseFloat(ppm);
|
||||
const ppmOverride = !isNaN(ppmVal) && ppmVal !== 0 ? ppmVal : undefined;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!systemId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await c2api.assignSystem(node.node_id, systemId);
|
||||
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to assign system.");
|
||||
@@ -30,6 +41,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
|
||||
@@ -57,6 +70,36 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">SDR Hardware</label>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(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"
|
||||
>
|
||||
{PRESETS.map((p) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPreset && (
|
||||
<p className="text-xs text-gray-600 mt-1">{selectedPreset.hint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">
|
||||
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ppm}
|
||||
onChange={(e) => setPpm(e.target.value)}
|
||||
step="0.1"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
|
||||
theme: "dark",
|
||||
toggle: () => {},
|
||||
});
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("drb-theme") as Theme | null;
|
||||
if (saved === "light") setTheme("light");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
localStorage.setItem("drb-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggle: () => setTheme((t) => (t === "dark" ? "light" : "dark")) }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
+110
-3
@@ -25,8 +25,11 @@ export const c2api = {
|
||||
getNode: (id: string) => request<unknown>(`/nodes/${id}`),
|
||||
sendCommand: (nodeId: string, payload: object) =>
|
||||
request(`/nodes/${nodeId}/command`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
assignSystem: (nodeId: string, systemId: string) =>
|
||||
request(`/nodes/${nodeId}/config/${systemId}`, { method: "POST" }),
|
||||
assignSystem: (nodeId: string, systemId: string, hardwarePreset: string, ppmOverride?: number) => {
|
||||
const params = new URLSearchParams({ hardware_preset: hardwarePreset });
|
||||
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
||||
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
||||
},
|
||||
|
||||
// Systems
|
||||
getSystems: () => request<unknown[]>("/systems"),
|
||||
@@ -49,14 +52,19 @@ export const c2api = {
|
||||
request(`/nodes/${id}/approve`, { method: "POST" }),
|
||||
rejectNode: (id: string) =>
|
||||
request(`/nodes/${id}/reject`, { method: "POST" }),
|
||||
deleteNode: (id: string) =>
|
||||
request(`/nodes/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Calls
|
||||
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
|
||||
getCalls: (params?: Record<string, string>) => {
|
||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return request<unknown[]>(`/calls${qs}`);
|
||||
},
|
||||
patchTranscript: (callId: string, transcript: string) =>
|
||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
||||
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
|
||||
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
|
||||
|
||||
// Incidents
|
||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||
@@ -94,9 +102,15 @@ export const c2api = {
|
||||
reissueNodeKey: (nodeId: string) =>
|
||||
request(`/nodes/${nodeId}/reissue-key`, { method: "POST" }),
|
||||
|
||||
// Ten-codes
|
||||
getTenCodes: (systemId: string) =>
|
||||
request<{ ten_codes: Record<string, string> }>(`/systems/${systemId}/ten-codes`),
|
||||
updateTenCodes: (systemId: string, ten_codes: Record<string, string>) =>
|
||||
request(`/systems/${systemId}/ten-codes`, { method: "PUT", body: JSON.stringify({ ten_codes }) }),
|
||||
|
||||
// Vocabulary
|
||||
getVocabulary: (systemId: string) =>
|
||||
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: string; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
|
||||
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: "induction" | "correction"; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
|
||||
`/systems/${systemId}/vocabulary`
|
||||
),
|
||||
bootstrapVocabulary: (systemId: string) =>
|
||||
@@ -109,4 +123,97 @@ export const c2api = {
|
||||
request(`/systems/${systemId}/vocabulary/pending/approve`, { method: "POST", body: JSON.stringify({ term }) }),
|
||||
dismissPendingTerm: (systemId: string, term: string) =>
|
||||
request(`/systems/${systemId}/vocabulary/pending/dismiss`, { method: "POST", body: JSON.stringify({ term }) }),
|
||||
|
||||
// Feature flags (admin)
|
||||
getFeatureFlags: () =>
|
||||
request<Record<string, boolean>>("/admin/features"),
|
||||
setFeatureFlags: (flags: Record<string, boolean>) =>
|
||||
request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }),
|
||||
getCorrelationDebug: (limit: number, orphanHours: number) =>
|
||||
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
|
||||
|
||||
// Preferred bot token per system
|
||||
setPreferredToken: (tokenId: string, systemId: string) =>
|
||||
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
|
||||
|
||||
// Trips
|
||||
getTrips: () => request<import("@/lib/types").TripRecord[]>("/trips"),
|
||||
getTrip: (id: string) =>
|
||||
request<import("@/lib/types").TripRecord & { events: import("@/lib/types").TripEvent[] }>(`/trips/${id}`),
|
||||
createTrip: (body: object) =>
|
||||
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteTrip: (id: string) =>
|
||||
request(`/trips/${id}`, { method: "DELETE" }),
|
||||
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
|
||||
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
|
||||
setTripVisibility: (id: string, visibility: "public" | "private") =>
|
||||
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
|
||||
inviteToTrip: (id: string, discord_user_id: string) =>
|
||||
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
|
||||
revokeInvite: (id: string, discord_user_id: string) =>
|
||||
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
|
||||
generateLinkCode: () =>
|
||||
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
|
||||
getLinkStatus: () =>
|
||||
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
|
||||
unlinkDiscord: () =>
|
||||
request("/auth/link", { method: "DELETE" }),
|
||||
createTripEvent: (tripId: string, body: object) =>
|
||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
|
||||
updateTripEvent: (tripId: string, eventId: string, body: object) =>
|
||||
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
deleteTripEvent: (tripId: string, eventId: string) =>
|
||||
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
|
||||
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
|
||||
request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>(
|
||||
`/trips/${tripId}/chat`,
|
||||
{ method: "POST", body: JSON.stringify({ message, history }) }
|
||||
),
|
||||
|
||||
// Places
|
||||
searchPlaces: (query: string, near: string) =>
|
||||
request<import("@/lib/types").PlaceResult[]>(
|
||||
`/places/search?${new URLSearchParams({ query, near }).toString()}`
|
||||
),
|
||||
getDirections: (origin: string, destination: string) =>
|
||||
request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>(
|
||||
`/places/directions?${new URLSearchParams({ origin, destination }).toString()}`
|
||||
),
|
||||
|
||||
// Per-system AI flag overrides
|
||||
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
||||
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(flags),
|
||||
}),
|
||||
|
||||
// User management (admin only)
|
||||
listUsers: () =>
|
||||
request<import("@/lib/types").UserRecord[]>("/admin/users"),
|
||||
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
|
||||
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
getUser: (uid: string) =>
|
||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
disableUser: (uid: string) =>
|
||||
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
|
||||
enableUser: (uid: string) =>
|
||||
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
|
||||
deleteUser: (uid: string) =>
|
||||
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
|
||||
|
||||
// Audit log (admin only)
|
||||
getAuditLog: (limit = 50, offset = 0) =>
|
||||
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
|
||||
|
||||
// Session recording — called on each explicit sign-in
|
||||
recordSession: () =>
|
||||
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||
export type UserRole = "admin" | "operator" | "viewer";
|
||||
|
||||
export interface UserRecord {
|
||||
uid: string;
|
||||
email: string | null;
|
||||
display_name: string | null;
|
||||
role: UserRole;
|
||||
owned_node_ids: string[];
|
||||
disabled: boolean;
|
||||
creation_time: string | null;
|
||||
last_sign_in: string | null;
|
||||
discord_linked: boolean;
|
||||
discord_username: string | null;
|
||||
discord_user_id: string | null;
|
||||
// only present on GET /admin/users/{uid}
|
||||
sessions?: UserSession[];
|
||||
// only present on POST /admin/users response
|
||||
invite_link?: string | null;
|
||||
}
|
||||
|
||||
export interface UserSession {
|
||||
session_id: string;
|
||||
uid: string;
|
||||
email: string;
|
||||
timestamp: string;
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
log_id: string;
|
||||
action: string;
|
||||
actor_uid: string;
|
||||
actor_email: string;
|
||||
target_uid: string | null;
|
||||
target_email: string | null;
|
||||
details: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface NodeRecord {
|
||||
node_id: string;
|
||||
@@ -11,12 +50,15 @@ export interface NodeRecord {
|
||||
last_seen: string | null;
|
||||
assigned_system_id: string | null;
|
||||
approval_status: ApprovalStatus | null;
|
||||
hardware_preset?: string;
|
||||
ppm_override?: number | null;
|
||||
}
|
||||
|
||||
export interface VocabularyPendingTerm {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
added_at: string;
|
||||
source_call_ids?: string[];
|
||||
}
|
||||
|
||||
export interface SystemRecord {
|
||||
@@ -27,6 +69,8 @@ export interface SystemRecord {
|
||||
vocabulary?: string[];
|
||||
vocabulary_pending?: VocabularyPendingTerm[];
|
||||
vocabulary_bootstrapped?: boolean;
|
||||
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
||||
preferred_token_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptSegment {
|
||||
@@ -48,10 +92,20 @@ export interface CallRecord {
|
||||
transcript: string | null;
|
||||
transcript_corrected: string | null;
|
||||
segments: TranscriptSegment[] | null;
|
||||
incident_id: string | null;
|
||||
/** New: one entry per scene detected in the recording. */
|
||||
incident_ids: string[];
|
||||
/** Legacy field — present on calls recorded before the multi-scene migration. */
|
||||
incident_id?: string | null;
|
||||
location: string | null;
|
||||
tags: string[];
|
||||
status: "active" | "ended";
|
||||
// Correlation debug — written by the correlator, present after a call is linked
|
||||
corr_path?: string | null;
|
||||
corr_score?: number | null;
|
||||
corr_distance_km?: number | null;
|
||||
corr_incident_idle_min?: number | null;
|
||||
corr_shared_units?: number | null;
|
||||
corr_candidates?: number | null;
|
||||
}
|
||||
|
||||
export interface IncidentRecord {
|
||||
@@ -83,6 +137,49 @@ export interface AlertRule {
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface TripEvent {
|
||||
event_id: string;
|
||||
trip_id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
start_time: string | null;
|
||||
end_time: string | null;
|
||||
location: string;
|
||||
location_inherited: boolean;
|
||||
maps_link: string | null;
|
||||
place_id: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
attendees: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlaceResult {
|
||||
name: string;
|
||||
address: string;
|
||||
place_id: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
maps_link: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
export interface TripRecord {
|
||||
trip_id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
maps_link: string | null;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
attendees: Record<string, string>;
|
||||
available_tags: string[];
|
||||
overlap_tags: string[];
|
||||
visibility: "public" | "private";
|
||||
invited_discord_ids: string[];
|
||||
created_at: string;
|
||||
events?: TripEvent[];
|
||||
}
|
||||
|
||||
export interface AlertEvent {
|
||||
alert_id: string;
|
||||
rule_id: string;
|
||||
|
||||
@@ -6,11 +6,15 @@ import { onAuthStateChanged } from "firebase/auth";
|
||||
import { db, auth } from "@/lib/firebase";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
export function useCalls(limitCount = 50) {
|
||||
export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stable ms values so the effect dependency doesn't fire on every render
|
||||
const dateFromMs = dateFrom?.getTime();
|
||||
const dateToMs = dateTo?.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
@@ -23,11 +27,16 @@ export function useCalls(limitCount = 50) {
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query(
|
||||
collection(db, "calls"),
|
||||
const from = dateFromMs != null ? new Date(dateFromMs) : undefined;
|
||||
const to = dateToMs != null ? new Date(dateToMs) : undefined;
|
||||
|
||||
const constraints = [
|
||||
...(from ? [where("started_at", ">=", from)] : []),
|
||||
...(to ? [where("started_at", "<=", to)] : []),
|
||||
orderBy("started_at", "desc"),
|
||||
limit(limitCount)
|
||||
);
|
||||
limit(limitCount),
|
||||
];
|
||||
const q = query(collection(db, "calls"), ...constraints);
|
||||
const toISO = (v: any): string | null =>
|
||||
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
||||
unsubFirestore = onSnapshot(q, (snap) => {
|
||||
@@ -43,7 +52,7 @@ export function useCalls(limitCount = 50) {
|
||||
unsubAuth();
|
||||
if (unsubFirestore) unsubFirestore();
|
||||
};
|
||||
}, [limitCount]);
|
||||
}, [limitCount, dateFromMs, dateToMs]);
|
||||
|
||||
return { calls, loading, error };
|
||||
}
|
||||
@@ -63,7 +72,7 @@ export function useCallsByIncident(incidentId: string | null) {
|
||||
const toISO = (v: any): string | null =>
|
||||
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
||||
|
||||
const q = query(collection(db, "calls"), where("incident_id", "==", incidentId));
|
||||
const q = query(collection(db, "calls"), where("incident_ids", "array-contains", incidentId));
|
||||
unsubFirestore = onSnapshot(q, (snap) => {
|
||||
const docs = snap.docs.map((d) => {
|
||||
const data = d.data();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { collection, onSnapshot, query, orderBy } from "firebase/firestore";
|
||||
import { onAuthStateChanged } from "firebase/auth";
|
||||
import { db, auth } from "@/lib/firebase";
|
||||
import type { TripRecord } from "@/lib/types";
|
||||
|
||||
export function useTrips() {
|
||||
const [trips, setTrips] = useState<TripRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
if (!user) { setTrips([]); setLoading(false); return; }
|
||||
|
||||
const q = query(collection(db, "trips"), orderBy("start_date", "asc"));
|
||||
unsubFirestore = onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
setTrips(snap.docs.map((d) => d.data() as TripRecord));
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
console.error("useTrips:", err);
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
|
||||
}, []);
|
||||
|
||||
return { trips, loading };
|
||||
}
|
||||
@@ -14,7 +14,8 @@
|
||||
"react-dom": "^18.3.0",
|
||||
"firebase": "^10.12.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^4.2.1"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-markdown": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ const config: Config = {
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
],
|
||||
darkMode: ["class"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
FROM python:3.14-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN pip install uv && uv pip install --system --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ ./app/
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
|
||||
|
||||
async def setup_hook(self):
|
||||
await self.load_extension("app.commands.radio")
|
||||
await self.load_extension("app.commands.trips")
|
||||
|
||||
if settings.dev_guild_id:
|
||||
guild = discord.Object(id=settings.dev_guild_id)
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import Optional
|
||||
from app.internal.c2_client import c2
|
||||
from app.internal.logger import logger
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date / time helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_date(s: str) -> Optional[date]:
|
||||
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%m-%d-%Y"):
|
||||
try:
|
||||
return datetime.strptime(s.strip(), fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_time(s: str) -> Optional[str]:
|
||||
"""Normalize to HH:MM (24h). Returns None if unparseable."""
|
||||
for fmt in ("%H:%M", "%I:%M %p", "%I:%M%p", "%I %p"):
|
||||
try:
|
||||
return datetime.strptime(s.strip().upper(), fmt).strftime("%H:%M")
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _fmt_date(iso: str) -> str:
|
||||
try:
|
||||
return datetime.strptime(iso, "%Y-%m-%d").strftime("%b %-d, %Y")
|
||||
except Exception:
|
||||
return iso
|
||||
|
||||
|
||||
def _fmt_time(t: Optional[str]) -> str:
|
||||
if not t:
|
||||
return ""
|
||||
try:
|
||||
return datetime.strptime(t, "%H:%M").strftime("%-I:%M %p")
|
||||
except Exception:
|
||||
return t
|
||||
|
||||
|
||||
def _date_range(start_iso: str, end_iso: str):
|
||||
"""Yield ISO date strings from start to end inclusive."""
|
||||
try:
|
||||
current = datetime.strptime(start_iso, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(end_iso, "%Y-%m-%d").date()
|
||||
while current <= end:
|
||||
yield current.strftime("%Y-%m-%d")
|
||||
current += timedelta(days=1)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cog
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _user_can_see_trip(trip: dict, discord_user_id: str) -> bool:
|
||||
if trip.get("visibility", "public") == "public":
|
||||
return True
|
||||
if discord_user_id in trip.get("attendees", {}):
|
||||
return True
|
||||
if discord_user_id in trip.get("invited_discord_ids", []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TripCommands(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
trip_group = app_commands.Group(name="trip", description="Manage trips and itineraries.")
|
||||
event_group = app_commands.Group(
|
||||
name="event", description="Manage events within a trip.", parent=trip_group
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Autocomplete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def trip_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
trips = await c2.get_trips()
|
||||
user_id = str(interaction.user.id)
|
||||
return [
|
||||
app_commands.Choice(name=t["name"], value=t["trip_id"])
|
||||
for t in trips
|
||||
if current.lower() in t["name"].lower() and _user_can_see_trip(t, user_id)
|
||||
][:25]
|
||||
|
||||
async def event_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
trip_id = interaction.namespace.trip
|
||||
if not trip_id:
|
||||
return []
|
||||
trip = await c2.get_trip(trip_id)
|
||||
if not trip:
|
||||
return []
|
||||
return [
|
||||
app_commands.Choice(name=e["title"], value=e["event_id"])
|
||||
for e in trip.get("events", [])
|
||||
if current.lower() in e["title"].lower()
|
||||
][:25]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip create
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="create", description="Create a new trip.")
|
||||
@app_commands.describe(
|
||||
name="Trip name",
|
||||
location="Primary destination or location",
|
||||
start_date="Start date (YYYY-MM-DD or MM/DD/YYYY)",
|
||||
end_date="End date (YYYY-MM-DD or MM/DD/YYYY)",
|
||||
maps_link="Optional Google Maps link for the destination",
|
||||
)
|
||||
async def trip_create(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
name: str,
|
||||
location: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
maps_link: Optional[str] = None,
|
||||
):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
start = _parse_date(start_date)
|
||||
end = _parse_date(end_date)
|
||||
if not start or not end:
|
||||
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
|
||||
return
|
||||
if end < start:
|
||||
await interaction.followup.send("End date must be on or after start date.")
|
||||
return
|
||||
|
||||
trip = await c2.create_trip({
|
||||
"name": name,
|
||||
"location": location,
|
||||
"maps_link": maps_link,
|
||||
"start_date": start.strftime("%Y-%m-%d"),
|
||||
"end_date": end.strftime("%Y-%m-%d"),
|
||||
})
|
||||
|
||||
if not trip:
|
||||
await interaction.followup.send("Failed to create trip.")
|
||||
return
|
||||
|
||||
embed = discord.Embed(title=f"Trip Created: {name}", color=0x5865f2)
|
||||
embed.add_field(name="Location", value=location, inline=True)
|
||||
embed.add_field(
|
||||
name="Dates",
|
||||
value=f"{_fmt_date(start.strftime('%Y-%m-%d'))} — {_fmt_date(end.strftime('%Y-%m-%d'))}",
|
||||
inline=True,
|
||||
)
|
||||
if maps_link:
|
||||
embed.add_field(name="Maps", value=f"[Open]({maps_link})", inline=True)
|
||||
embed.set_footer(text="Use /trip join to RSVP • /trip event add to build the itinerary")
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip list
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="list", description="List all trips.")
|
||||
async def trip_list(self, interaction: discord.Interaction):
|
||||
await interaction.response.defer()
|
||||
|
||||
trips = await c2.get_trips()
|
||||
if not trips:
|
||||
await interaction.followup.send("No trips found.")
|
||||
return
|
||||
|
||||
today = date.today().strftime("%Y-%m-%d")
|
||||
trips.sort(key=lambda t: t.get("start_date", ""))
|
||||
|
||||
user_id = str(interaction.user.id)
|
||||
trips = [t for t in trips if _user_can_see_trip(t, user_id)]
|
||||
|
||||
embed = discord.Embed(title="Trips", color=0x2b2d31)
|
||||
for t in trips[:10]:
|
||||
upcoming = t.get("start_date", "") >= today
|
||||
status = "Upcoming" if upcoming else "Past"
|
||||
dates = (
|
||||
f"{_fmt_date(t.get('start_date', ''))} — "
|
||||
f"{_fmt_date(t.get('end_date', ''))}"
|
||||
)
|
||||
attendee_count = len(t.get("attendees", {}))
|
||||
field_name = f"{t['name']} [{status}]"[:256]
|
||||
embed.add_field(
|
||||
name=field_name,
|
||||
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip view
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="view", description="View the full itinerary for a trip.")
|
||||
@app_commands.describe(trip="The trip to view.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def trip_view(self, interaction: discord.Interaction, trip: str):
|
||||
await interaction.response.defer()
|
||||
|
||||
data = await c2.get_trip(trip)
|
||||
if not data:
|
||||
await interaction.followup.send("Trip not found.")
|
||||
return
|
||||
if not _user_can_see_trip(data, str(interaction.user.id)):
|
||||
await interaction.followup.send("This trip is private.", ephemeral=True)
|
||||
return
|
||||
|
||||
attendee_names = list(data.get("attendees", {}).values())
|
||||
desc_lines = [
|
||||
f"{_fmt_date(data['start_date'])} — {_fmt_date(data['end_date'])} • {data['location']}",
|
||||
]
|
||||
if data.get("maps_link"):
|
||||
desc_lines.append(f"[View on Maps]({data['maps_link']})")
|
||||
desc_lines.append(
|
||||
f"Going: {', '.join(attendee_names)}" if attendee_names else "No attendees yet"
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=data["name"][:256],
|
||||
description="\n".join(desc_lines)[:4096],
|
||||
color=0x5865f2,
|
||||
)
|
||||
|
||||
# Group events by date
|
||||
events_by_date: dict[str, list] = {}
|
||||
for e in data.get("events", []):
|
||||
events_by_date.setdefault(e["date"], []).append(e)
|
||||
|
||||
# Track total embed chars (Discord limit: 6000)
|
||||
embed_chars = len(embed.title or "") + len(embed.description or "")
|
||||
field_count = 0
|
||||
for day_iso in _date_range(data["start_date"], data["end_date"]):
|
||||
day_events = events_by_date.get(day_iso)
|
||||
if not day_events:
|
||||
continue
|
||||
if field_count >= 24 or embed_chars >= 5800:
|
||||
embed.add_field(name="...", value="More events not shown.", inline=False)
|
||||
break
|
||||
|
||||
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
|
||||
lines = []
|
||||
for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
|
||||
time_str = _fmt_time(e.get("start_time"))
|
||||
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
|
||||
|
||||
loc = e.get("location")
|
||||
if loc and not e.get("location_inherited"):
|
||||
line += f"\n\u3000\u3000{loc}"
|
||||
if e.get("maps_link"):
|
||||
line += f" ([Maps]({e['maps_link']}))"
|
||||
if e.get("notes"):
|
||||
line += f"\n\u3000\u3000_{e['notes']}_"
|
||||
|
||||
event_tags = e.get("tags") or []
|
||||
if event_tags:
|
||||
line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`"
|
||||
|
||||
event_att = list(e.get("attendees", {}).values())
|
||||
if event_att:
|
||||
line += f"\n\u3000\u3000{', '.join(event_att)}"
|
||||
|
||||
lines.append(line)
|
||||
|
||||
field_name = f"— {day_label} —"
|
||||
field_value = "\n".join(lines)
|
||||
if len(field_value) > 1024:
|
||||
field_value = field_value[:1021] + "…"
|
||||
embed.add_field(name=field_name, value=field_value, inline=False)
|
||||
embed_chars += len(field_name) + len(field_value)
|
||||
field_count += 1
|
||||
|
||||
if not events_by_date:
|
||||
embed.add_field(
|
||||
name="No events yet",
|
||||
value="Use `/trip event add` to build the itinerary.",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip delete
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="delete", description="Delete a trip and all its events.")
|
||||
@app_commands.describe(trip="The trip to delete.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def trip_delete(self, interaction: discord.Interaction, trip: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.delete_trip(trip)
|
||||
if ok:
|
||||
await interaction.followup.send("Trip deleted.")
|
||||
else:
|
||||
await interaction.followup.send("Trip not found or failed to delete.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip join / /trip leave
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="join", description="RSVP to a trip.")
|
||||
@app_commands.describe(trip="The trip to join.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def trip_join(self, interaction: discord.Interaction, trip: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
result = await c2.join_trip(trip, str(interaction.user.id), interaction.user.display_name)
|
||||
if result is True:
|
||||
await interaction.followup.send("You're on the trip!")
|
||||
elif result == "private":
|
||||
await interaction.followup.send("This trip is private — you need an invite to join.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to join trip.")
|
||||
|
||||
@trip_group.command(name="leave", description="Remove yourself from a trip.")
|
||||
@app_commands.describe(trip="The trip to leave.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def trip_leave(self, interaction: discord.Interaction, trip: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.leave_trip(trip, str(interaction.user.id))
|
||||
if ok:
|
||||
await interaction.followup.send("You've been removed from the trip.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to leave trip.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip event add
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@event_group.command(name="add", description="Add an event to a trip's itinerary.")
|
||||
@app_commands.describe(
|
||||
trip="The trip to add this event to.",
|
||||
title="Event title",
|
||||
date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)",
|
||||
start_time="Start time (e.g. 14:00 or 2:00 PM) — optional",
|
||||
end_time="End time (e.g. 16:00 or 4:00 PM) — optional",
|
||||
location="Location override (optional, inherits trip location if omitted)",
|
||||
maps_link="Google Maps link for this event (optional)",
|
||||
notes="Any additional notes (optional)",
|
||||
)
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def event_add(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
trip: str,
|
||||
title: str,
|
||||
date: str,
|
||||
start_time: Optional[str] = None,
|
||||
end_time: Optional[str] = None,
|
||||
location: Optional[str] = None,
|
||||
maps_link: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
parsed_date = _parse_date(date)
|
||||
if not parsed_date:
|
||||
await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.")
|
||||
return
|
||||
|
||||
parsed_start = _parse_time(start_time) if start_time else None
|
||||
parsed_end = _parse_time(end_time) if end_time else None
|
||||
|
||||
if start_time and parsed_start is None:
|
||||
await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.")
|
||||
return
|
||||
if end_time and parsed_end is None:
|
||||
await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.")
|
||||
return
|
||||
|
||||
event = await c2.create_trip_event(trip, {
|
||||
"title": title,
|
||||
"date": parsed_date.strftime("%Y-%m-%d"),
|
||||
"start_time": parsed_start,
|
||||
"end_time": parsed_end,
|
||||
"location": location,
|
||||
"maps_link": maps_link,
|
||||
"notes": notes,
|
||||
})
|
||||
|
||||
if not event:
|
||||
await interaction.followup.send(
|
||||
"Failed to create event. Make sure the date falls within the trip range."
|
||||
)
|
||||
return
|
||||
|
||||
time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else ""
|
||||
await interaction.followup.send(
|
||||
f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip event remove
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@event_group.command(name="remove", description="Remove an event from a trip.")
|
||||
@app_commands.describe(trip="The trip.", event="The event to remove.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
|
||||
async def event_remove(self, interaction: discord.Interaction, trip: str, event: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.delete_trip_event(trip, event)
|
||||
if ok:
|
||||
await interaction.followup.send("Event removed.")
|
||||
else:
|
||||
await interaction.followup.send("Event not found or failed to remove.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip event join / /trip event leave
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@event_group.command(name="join", description="Join an event (you must be on the trip first).")
|
||||
@app_commands.describe(trip="The trip.", event="The event to join.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
|
||||
async def event_join(self, interaction: discord.Interaction, trip: str, event: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
result = await c2.join_trip_event(
|
||||
trip, event, str(interaction.user.id), interaction.user.display_name
|
||||
)
|
||||
if result is True:
|
||||
await interaction.followup.send("You're in for this event!")
|
||||
elif result == "not_on_trip":
|
||||
await interaction.followup.send(
|
||||
"You need to join the trip first — use `/trip join`."
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send("Failed to join event.")
|
||||
|
||||
@event_group.command(name="leave", description="Leave an event.")
|
||||
@app_commands.describe(trip="The trip.", event="The event to leave.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete, event=event_autocomplete)
|
||||
async def event_leave(self, interaction: discord.Interaction, trip: str, event: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.leave_trip_event(trip, event, str(interaction.user.id))
|
||||
if ok:
|
||||
await interaction.followup.send("You've been removed from the event.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to leave event.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip invite
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="invite", description="Invite a Discord user to a private trip.")
|
||||
@app_commands.describe(trip="The trip.", user="The user to invite.")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
async def trip_invite(self, interaction: discord.Interaction, trip: str, user: discord.Member):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.invite_to_trip(trip, str(user.id))
|
||||
if ok:
|
||||
await interaction.followup.send(f"Invited {user.display_name} to the trip.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to send invite.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /trip privacy
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@trip_group.command(name="privacy", description="Set a trip to public or private.")
|
||||
@app_commands.describe(trip="The trip.", visibility="public or private")
|
||||
@app_commands.autocomplete(trip=trip_autocomplete)
|
||||
@app_commands.choices(visibility=[
|
||||
app_commands.Choice(name="Public — anyone can see and join", value="public"),
|
||||
app_commands.Choice(name="Private — invite only", value="private"),
|
||||
])
|
||||
async def trip_privacy(self, interaction: discord.Interaction, trip: str, visibility: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
ok = await c2.set_trip_visibility(trip, visibility)
|
||||
if ok:
|
||||
await interaction.followup.send(f"Trip is now **{visibility}**.")
|
||||
else:
|
||||
await interaction.followup.send("Failed to update trip privacy.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /link
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="link", description="Link your Discord account to your DRB web account.")
|
||||
@app_commands.describe(code="The 6-character code from the web app (Settings → Link Discord).")
|
||||
async def link_account(self, interaction: discord.Interaction, code: str):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
result = await c2.link_discord_account(
|
||||
code.upper().strip(),
|
||||
str(interaction.user.id),
|
||||
interaction.user.display_name,
|
||||
)
|
||||
if "error" in result:
|
||||
msgs = {
|
||||
"invalid_code": "Invalid code. Generate a new one from the web app.",
|
||||
"expired": "Code has expired. Generate a new one from the web app.",
|
||||
"already_linked": "This Discord account is already linked to a different web account.",
|
||||
"failed": "Something went wrong. Try again.",
|
||||
}
|
||||
await interaction.followup.send(msgs.get(result["error"], "Failed to link account."))
|
||||
else:
|
||||
await interaction.followup.send("Your Discord account is now linked to your DRB web account.")
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(TripCommands(bot))
|
||||
@@ -68,5 +68,187 @@ class C2Client:
|
||||
return node
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trips
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def get_trips(self) -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/trips", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_trips failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_trip(self, trip_id: str) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get(f"{self.base}/trips/{trip_id}", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 get_trip failed: {e}")
|
||||
return None
|
||||
|
||||
async def create_trip(self, payload: dict) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(f"{self.base}/trips", json=payload, headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 create_trip failed: {e}")
|
||||
return None
|
||||
|
||||
async def delete_trip(self, trip_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.delete(f"{self.base}/trips/{trip_id}", headers=self._headers())
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 delete_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def invite_to_trip(self, trip_id: str, discord_user_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/invite/{discord_user_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 invite_to_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def set_trip_visibility(self, trip_id: str, visibility: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.put(
|
||||
f"{self.base}/trips/{trip_id}/visibility",
|
||||
json={"visibility": visibility},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 set_trip_visibility failed: {e}")
|
||||
return False
|
||||
|
||||
async def link_discord_account(self, code: str, discord_user_id: str, discord_username: str) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/auth/link",
|
||||
json={"code": code, "discord_user_id": discord_user_id, "discord_username": discord_username},
|
||||
headers=self._headers(),
|
||||
)
|
||||
if r.status_code == 404:
|
||||
return {"error": "invalid_code"}
|
||||
if r.status_code == 410:
|
||||
return {"error": "expired"}
|
||||
if r.status_code == 409:
|
||||
return {"error": "already_linked"}
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 link_discord_account failed: {e}")
|
||||
return {"error": "failed"}
|
||||
|
||||
async def join_trip(self, trip_id: str, user_id: str, username: str) -> bool | str:
|
||||
"""Returns True on success, 'private' on 403, False on other errors."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/join",
|
||||
json={"discord_user_id": user_id, "discord_username": username},
|
||||
headers=self._headers(),
|
||||
)
|
||||
if r.status_code == 403:
|
||||
return "private"
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 join_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def leave_trip(self, trip_id: str, user_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/leave",
|
||||
json={"discord_user_id": user_id},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 leave_trip failed: {e}")
|
||||
return False
|
||||
|
||||
async def create_trip_event(self, trip_id: str, payload: dict) -> Optional[dict]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/events",
|
||||
json=payload,
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error(f"C2 create_trip_event failed: {e}")
|
||||
return None
|
||||
|
||||
async def delete_trip_event(self, trip_id: str, event_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.delete(
|
||||
f"{self.base}/trips/{trip_id}/events/{event_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 delete_trip_event failed: {e}")
|
||||
return False
|
||||
|
||||
async def join_trip_event(
|
||||
self, trip_id: str, event_id: str, user_id: str, username: str
|
||||
) -> bool | str:
|
||||
"""Returns True on success, 'not_on_trip' on 403, False on other errors."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/events/{event_id}/join",
|
||||
json={"discord_user_id": user_id, "discord_username": username},
|
||||
headers=self._headers(),
|
||||
)
|
||||
if r.status_code == 403:
|
||||
return "not_on_trip"
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 join_trip_event failed: {e}")
|
||||
return False
|
||||
|
||||
async def leave_trip_event(self, trip_id: str, event_id: str, user_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
f"{self.base}/trips/{trip_id}/events/{event_id}/leave",
|
||||
json={"discord_user_id": user_id},
|
||||
headers=self._headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"C2 leave_trip_event failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
c2 = C2Client()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Managed by CI — deployed to /etc/caddy/Caddyfile on the server.
|
||||
# Caddy handles TLS automatically via Let's Encrypt.
|
||||
|
||||
api.{$DRB_DOMAIN} {
|
||||
reverse_proxy localhost:8888 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
app.{$DRB_DOMAIN} {
|
||||
reverse_proxy localhost:3000 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.PHONY: tf-init tf-plan tf-apply tf-destroy deploy setup-ansible
|
||||
|
||||
ANSIBLE_DIR = ansible
|
||||
INVENTORY = $(ANSIBLE_DIR)/inventory.ini
|
||||
|
||||
# ── Terraform ─────────────────────────────────────────────────────────────────
|
||||
|
||||
tf-init:
|
||||
terraform init
|
||||
|
||||
tf-plan:
|
||||
terraform plan
|
||||
|
||||
tf-apply:
|
||||
terraform apply
|
||||
@echo ""
|
||||
@echo "Server IP: $$(terraform output -raw server_ip)"
|
||||
@echo "Update $(INVENTORY) with this IP, then run: make deploy"
|
||||
|
||||
tf-destroy:
|
||||
@echo "WARNING: This will destroy the VM and all data on it."
|
||||
@read -p "Type 'yes' to confirm: " confirm && [ "$$confirm" = "yes" ] && terraform destroy
|
||||
|
||||
# ── Ansible ───────────────────────────────────────────────────────────────────
|
||||
|
||||
# First-time setup: waits for Docker, clones repo, starts stack.
|
||||
setup:
|
||||
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/site.yml --ask-vault-pass
|
||||
|
||||
# Update deploy: sync code + restart changed containers. Run this after every push.
|
||||
deploy:
|
||||
ansible-playbook -i $(INVENTORY) $(ANSIBLE_DIR)/deploy.yml --ask-vault-pass
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
ip:
|
||||
@terraform output -raw server_ip
|
||||
|
||||
ssh:
|
||||
ssh drb@$$(terraform output -raw server_ip)
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
# Lightweight update deploy — runs in ~60s.
|
||||
# Use this for every code push after the initial site.yml run.
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook -i inventory.ini deploy.yml --ask-vault-pass
|
||||
|
||||
- name: Deploy DRB update
|
||||
hosts: drb
|
||||
become: true
|
||||
vars_files:
|
||||
- vault.yml
|
||||
|
||||
roles:
|
||||
- deploy
|
||||
@@ -0,0 +1,9 @@
|
||||
# Copy to group_vars/all.yml — safe to commit (no secrets here).
|
||||
|
||||
domain: example.com # must match Terraform var.domain
|
||||
app_dir: /opt/drb
|
||||
ssh_user: drb
|
||||
|
||||
# Path to the local repo root on your machine (used for rsync).
|
||||
# Trailing slash is intentional — rsync copies contents, not the folder itself.
|
||||
local_repo_path: "/path/to/Version 5C/Server/"
|
||||
@@ -0,0 +1,8 @@
|
||||
# Copy to inventory.ini and replace SERVER_IP with the Terraform output.
|
||||
# Get it with: cd ../terraform && terraform output server_ip
|
||||
|
||||
[drb]
|
||||
SERVER_IP ansible_user=drb ansible_ssh_private_key_file=~/.ssh/id_ed25519
|
||||
|
||||
[drb:vars]
|
||||
ansible_python_interpreter=/usr/bin/python3
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
# First-time setup: clone repo, write secrets, pull pre-built images and start stack.
|
||||
# Images are built and pushed by Gitea CI — this role never builds on the VM.
|
||||
|
||||
- name: Clone repo (skipped if already present)
|
||||
git:
|
||||
repo: "{{ repo_url }}"
|
||||
dest: "{{ app_dir }}"
|
||||
version: main
|
||||
update: false
|
||||
become: false
|
||||
|
||||
- name: Set ownership of app directory
|
||||
file:
|
||||
path: "{{ app_dir }}"
|
||||
state: directory
|
||||
owner: "{{ ssh_user }}"
|
||||
group: "{{ ssh_user }}"
|
||||
recurse: true
|
||||
|
||||
- name: Template top-level .env (docker-compose MQTT creds + registry)
|
||||
template:
|
||||
src: root.env.j2
|
||||
dest: "{{ app_dir }}/.env"
|
||||
owner: "{{ ssh_user }}"
|
||||
group: "{{ ssh_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Template c2-core .env
|
||||
template:
|
||||
src: c2-core.env.j2
|
||||
dest: "{{ app_dir }}/drb-c2-core/.env"
|
||||
owner: "{{ ssh_user }}"
|
||||
group: "{{ ssh_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Template discord-bot .env
|
||||
template:
|
||||
src: discord-bot.env.j2
|
||||
dest: "{{ app_dir }}/drb-server-discord-bot/.env"
|
||||
owner: "{{ ssh_user }}"
|
||||
group: "{{ ssh_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Template frontend .env
|
||||
template:
|
||||
src: frontend.env.j2
|
||||
dest: "{{ app_dir }}/drb-frontend/.env"
|
||||
owner: "{{ ssh_user }}"
|
||||
group: "{{ ssh_user }}"
|
||||
mode: "0600"
|
||||
|
||||
- name: Deploy Caddyfile
|
||||
template:
|
||||
src: Caddyfile.j2
|
||||
dest: /etc/caddy/Caddyfile
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload Caddy
|
||||
|
||||
- name: Log in to container registry
|
||||
command: >
|
||||
docker login {{ vault_registry_host }}
|
||||
-u {{ vault_registry_user }}
|
||||
-p {{ vault_registry_token }}
|
||||
no_log: true
|
||||
|
||||
- name: Pull pre-built images and start stack
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ app_dir }}"
|
||||
files:
|
||||
- docker-compose.yml
|
||||
- docker-compose.prod.yml
|
||||
pull: always
|
||||
build: never
|
||||
state: present
|
||||
@@ -0,0 +1,13 @@
|
||||
# Managed by Ansible — do not edit manually on the server.
|
||||
|
||||
api.{{ domain }} {
|
||||
reverse_proxy localhost:8888 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
app.{{ domain }} {
|
||||
reverse_proxy localhost:3000 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# drb-c2-core environment — Managed by Ansible. Do not edit manually.
|
||||
|
||||
MQTT_BROKER=mosquitto
|
||||
MQTT_PORT=1883
|
||||
MQTT_USER={{ vault_mqtt_c2_user }}
|
||||
MQTT_PASS={{ vault_mqtt_c2_pass }}
|
||||
|
||||
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
||||
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
||||
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||
GCS_BUCKET={{ vault_gcs_bucket }}
|
||||
|
||||
OPENAI_API_KEY={{ vault_openai_api_key }}
|
||||
GOOGLE_MAPS_API_KEY={{ vault_google_maps_api_key }}
|
||||
GEMINI_API_KEY={{ vault_gemini_api_key }}
|
||||
|
||||
SERVICE_KEY={{ vault_service_key }}
|
||||
NODE_API_KEY={{ vault_node_api_key }}
|
||||
|
||||
CORS_ORIGINS=["https://app.{{ domain }}"]
|
||||
@@ -0,0 +1,5 @@
|
||||
# drb-server-discord-bot environment — Managed by Ansible. Do not edit manually.
|
||||
|
||||
DISCORD_TOKEN={{ vault_discord_token }}
|
||||
C2_URL=http://c2-core:8000
|
||||
C2_SERVICE_KEY={{ vault_service_key }}
|
||||
@@ -0,0 +1,11 @@
|
||||
# drb-frontend environment — Managed by Ansible. Do not edit manually.
|
||||
|
||||
NEXT_PUBLIC_C2_URL=https://api.{{ domain }}
|
||||
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY={{ vault_firebase_api_key }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN={{ vault_firebase_auth_domain }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID={{ vault_firebase_project_id }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET={{ vault_firebase_storage_bucket }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID={{ vault_firebase_messaging_sender_id }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID={{ vault_firebase_app_id }}
|
||||
NEXT_PUBLIC_FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Top-level docker-compose environment — MQTT credentials and registry prefix.
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
|
||||
MQTT_C2_USER={{ vault_mqtt_c2_user }}
|
||||
MQTT_C2_PASS={{ vault_mqtt_c2_pass }}
|
||||
MQTT_NODE_USER={{ vault_mqtt_node_user }}
|
||||
MQTT_NODE_PASS={{ vault_mqtt_node_pass }}
|
||||
|
||||
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
|
||||
REGISTRY={{ vault_registry }}
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
# Full first-time setup: waits for the VM's startup.sh to finish installing
|
||||
# Docker, then deploys the stack. Safe to re-run — all tasks are idempotent.
|
||||
#
|
||||
# Usage:
|
||||
# ansible-playbook -i inventory.ini site.yml --ask-vault-pass
|
||||
|
||||
- name: Bootstrap + deploy DRB server
|
||||
hosts: drb
|
||||
become: true
|
||||
vars_files:
|
||||
- vault.yml
|
||||
|
||||
pre_tasks:
|
||||
- name: Install rsync
|
||||
apt:
|
||||
name: rsync
|
||||
state: present
|
||||
update_cache: false
|
||||
|
||||
- name: Wait for Docker (startup.sh runs async on first boot)
|
||||
command: docker info
|
||||
register: _docker
|
||||
until: _docker.rc == 0
|
||||
retries: 30
|
||||
delay: 10
|
||||
changed_when: false
|
||||
|
||||
- name: Create 2 GB swap file
|
||||
command: fallocate -l 2G /swapfile
|
||||
args:
|
||||
creates: /swapfile
|
||||
|
||||
- name: Set swap file permissions
|
||||
file:
|
||||
path: /swapfile
|
||||
mode: "0600"
|
||||
|
||||
- name: Format swap file
|
||||
command: mkswap /swapfile
|
||||
register: _mkswap
|
||||
changed_when: _mkswap.rc == 0
|
||||
|
||||
- name: Enable swap
|
||||
command: swapon /swapfile
|
||||
register: _swapon
|
||||
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
|
||||
changed_when: _swapon.rc == 0
|
||||
|
||||
- name: Persist swap in fstab
|
||||
lineinfile:
|
||||
path: /etc/fstab
|
||||
line: "/swapfile none swap sw 0 0"
|
||||
state: present
|
||||
|
||||
- name: Set swappiness to 10 (use swap only under pressure)
|
||||
sysctl:
|
||||
name: vm.swappiness
|
||||
value: "10"
|
||||
sysctl_set: true
|
||||
state: present
|
||||
reload: true
|
||||
|
||||
- name: Add deploy user to docker group
|
||||
user:
|
||||
name: "{{ ssh_user }}"
|
||||
groups: docker
|
||||
append: true
|
||||
|
||||
- name: Create app directory
|
||||
file:
|
||||
path: "{{ app_dir }}"
|
||||
state: directory
|
||||
owner: "{{ ssh_user }}"
|
||||
group: "{{ ssh_user }}"
|
||||
mode: "0755"
|
||||
|
||||
roles:
|
||||
- deploy
|
||||
@@ -0,0 +1,40 @@
|
||||
# Template for your Ansible Vault secrets file.
|
||||
# Copy to vault.yml, fill in values, then encrypt:
|
||||
# ansible-vault encrypt vault.yml
|
||||
# Edit later with:
|
||||
# ansible-vault edit vault.yml
|
||||
|
||||
# ── MQTT ─────────────────────────────────────────────────────────────────────
|
||||
vault_mqtt_c2_user: drb-c2-core
|
||||
vault_mqtt_c2_pass: "CHANGE_ME"
|
||||
vault_mqtt_node_user: drb-node
|
||||
vault_mqtt_node_pass: "CHANGE_ME"
|
||||
|
||||
# ── C2 Core ───────────────────────────────────────────────────────────────────
|
||||
vault_service_key: "" # openssl rand -hex 32
|
||||
vault_node_api_key: "" # openssl rand -hex 32
|
||||
vault_openai_api_key: ""
|
||||
vault_google_maps_api_key: ""
|
||||
vault_gemini_api_key: ""
|
||||
vault_gcs_bucket: "your-gcs-bucket-name"
|
||||
vault_firestore_database: "c2-server"
|
||||
|
||||
# ── Gitea Container Registry ──────────────────────────────────────────────────
|
||||
vault_registry_host: "git.vpn.cusano.net"
|
||||
vault_registry_user: "logan"
|
||||
vault_registry_token: "" # Gitea access token with package:write scope
|
||||
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
|
||||
|
||||
# ── Discord Bot ───────────────────────────────────────────────────────────────
|
||||
vault_discord_token: ""
|
||||
|
||||
# ── Frontend (Firebase) ───────────────────────────────────────────────────────
|
||||
vault_firebase_api_key: ""
|
||||
vault_firebase_auth_domain: ""
|
||||
vault_firebase_project_id: ""
|
||||
vault_firebase_storage_bucket: ""
|
||||
vault_firebase_messaging_sender_id: ""
|
||||
vault_firebase_app_id: ""
|
||||
|
||||
# No GCP key needed — the VM uses Application Default Credentials via the
|
||||
# GCE metadata server. Terraform grants the required IAM roles at apply time.
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
required_providers {
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
|
||||
# Store state in GCS — create the bucket manually once before first apply
|
||||
# Uncomment once GCS bucket permissions are confirmed working.
|
||||
# backend "gcs" {
|
||||
# bucket = "drb-tf-state"
|
||||
# prefix = "drb/state"
|
||||
# }
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# Pull live project metadata (number, name) without hardcoding them.
|
||||
data "google_project" "current" {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static external IP
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_address" "drb" {
|
||||
name = "drb-server-ip"
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firewall rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_firewall" "allow_web" {
|
||||
name = "drb-allow-web"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["80", "443"]
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
target_tags = ["drb-server"]
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "allow_ssh" {
|
||||
name = "drb-allow-ssh"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["22"]
|
||||
}
|
||||
|
||||
# Restrict SSH to your IP(s) and Gitea runner IP
|
||||
source_ranges = var.allowed_ssh_cidrs
|
||||
target_tags = ["drb-server"]
|
||||
}
|
||||
|
||||
# MQTT is NOT exposed externally — edge nodes connect via WireGuard (see below)
|
||||
# If you need to temporarily allow direct MQTT access for testing, uncomment and
|
||||
# restrict source_ranges to your node IPs.
|
||||
#
|
||||
# resource "google_compute_firewall" "allow_mqtt" {
|
||||
# name = "drb-allow-mqtt"
|
||||
# network = "default"
|
||||
# allow {
|
||||
# protocol = "tcp"
|
||||
# ports = ["8883"] # TLS MQTT, not 1883
|
||||
# }
|
||||
# source_ranges = ["YOUR_NODE_CIDR"]
|
||||
# target_tags = ["drb-server"]
|
||||
# }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compute Engine VM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "google_compute_instance" "drb_server" {
|
||||
name = "drb-server"
|
||||
machine_type = var.machine_type
|
||||
zone = var.zone
|
||||
tags = ["drb-server"]
|
||||
|
||||
boot_disk {
|
||||
initialize_params {
|
||||
image = "debian-cloud/debian-12"
|
||||
size = 30 # GB — free tier covers 30GB pd-standard on e2-micro
|
||||
type = "pd-standard"
|
||||
}
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
access_config {
|
||||
nat_ip = google_compute_address.drb.address
|
||||
}
|
||||
}
|
||||
|
||||
metadata = {
|
||||
ssh-keys = "${var.ssh_user}:${var.ssh_public_key}"
|
||||
}
|
||||
|
||||
# Startup script runs once on first boot to install Docker + Caddy
|
||||
metadata_startup_script = file("${path.module}/startup.sh")
|
||||
|
||||
# The default compute service account with cloud-platform scope gives the VM
|
||||
# full access to GCS and Firestore in the same project — no key file needed.
|
||||
service_account {
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
# Prevent Terraform from destroying + recreating on metadata changes
|
||||
ignore_changes = [metadata_startup_script]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IAM — grant the VM's default compute SA access to Firestore and GCS.
|
||||
# Since Firebase/GCS already live in the same project, no key file is needed —
|
||||
# the VM authenticates via the metadata server (ADC).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
locals {
|
||||
compute_sa = "serviceAccount:${data.google_project.current.number}-compute@developer.gserviceaccount.com"
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "drb_firestore" {
|
||||
project = var.project_id
|
||||
role = "roles/datastore.user"
|
||||
member = local.compute_sa
|
||||
}
|
||||
|
||||
resource "google_project_iam_member" "drb_gcs" {
|
||||
project = var.project_id
|
||||
role = "roles/storage.objectAdmin"
|
||||
member = local.compute_sa
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Firestore database — import existing, manages schema/settings going forward
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
id = "projects/${var.project_id}/databases/${var.firestore_database}"
|
||||
to = google_firestore_database.c2
|
||||
}
|
||||
|
||||
resource "google_firestore_database" "c2" {
|
||||
project = var.project_id
|
||||
name = var.firestore_database
|
||||
location_id = var.firestore_location
|
||||
type = "FIRESTORE_NATIVE"
|
||||
|
||||
# Prevent accidental deletion of the live database
|
||||
deletion_policy = "DELETE"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GCS bucket — audio recordings. Import existing bucket.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
id = var.audio_bucket_name
|
||||
to = google_storage_bucket.audio
|
||||
}
|
||||
|
||||
resource "google_storage_bucket" "audio" {
|
||||
project = var.project_id
|
||||
name = var.audio_bucket_name
|
||||
location = var.audio_bucket_location
|
||||
uniform_bucket_level_access = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DNS — managed in AWS Route 53 (cusano.net is there).
|
||||
# After terraform apply, add these A records in Route 53:
|
||||
# app.drb.cusano.net → server_ip output
|
||||
# api.drb.cusano.net → server_ip output
|
||||
# Or use a single wildcard: *.drb.cusano.net → server_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,22 @@
|
||||
output "server_ip" {
|
||||
value = google_compute_address.drb.address
|
||||
description = "Static external IP of the DRB server VM"
|
||||
}
|
||||
|
||||
output "app_url" {
|
||||
value = "https://app.${var.domain}"
|
||||
}
|
||||
|
||||
output "api_url" {
|
||||
value = "https://api.${var.domain}"
|
||||
}
|
||||
|
||||
output "project_number" {
|
||||
value = data.google_project.current.number
|
||||
description = "GCP project number (useful for service account references)"
|
||||
}
|
||||
|
||||
output "ssh_command" {
|
||||
value = "ssh ${var.ssh_user}@${google_compute_address.drb.address}"
|
||||
description = "SSH command to reach the server (should rarely be needed)"
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
# Runs once on first VM boot. Installs Docker, Docker Compose, and Caddy.
|
||||
set -euxo pipefail
|
||||
|
||||
# ── Docker ────────────────────────────────────────────────────────────────────
|
||||
apt-get update -y
|
||||
apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
|
||||
https://download.docker.com/linux/debian $(lsb_release -cs) stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update -y
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
# Allow drb user to run docker
|
||||
usermod -aG docker drb 2>/dev/null || true
|
||||
|
||||
# ── Caddy (reverse proxy + auto TLS) ─────────────────────────────────────────
|
||||
apt-get install -y debian-keyring debian-archive-keyring apt-transport-https
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||
> /etc/apt/sources.list.d/caddy-stable.list
|
||||
apt-get update -y
|
||||
apt-get install -y caddy
|
||||
|
||||
# ── App directory — clone repo so CI can git pull + docker compose up ─────────
|
||||
apt-get install -y git
|
||||
mkdir -p /opt/drb
|
||||
# Repo is cloned here by initial setup; CI just git pulls and rebuilds.
|
||||
# Set safe directory for the drb user
|
||||
git config --global --add safe.directory /opt/drb
|
||||
chown -R drb:drb /opt/drb 2>/dev/null || true
|
||||
|
||||
# ── Caddyfile placeholder (CI will write the real one on first deploy) ────────
|
||||
cat > /etc/caddy/Caddyfile <<'CADDY'
|
||||
# This file is managed by CI. Do not edit manually.
|
||||
# It will be replaced on the first deployment.
|
||||
:80 {
|
||||
respond "DRB server — waiting for deployment" 200
|
||||
}
|
||||
CADDY
|
||||
|
||||
systemctl enable caddy
|
||||
systemctl reload caddy
|
||||
|
||||
echo "Startup complete."
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copy to terraform.tfvars and fill in values.
|
||||
# terraform.tfvars is gitignored — never commit it.
|
||||
|
||||
project_id = "your-gcp-project-id" # gcloud config get-value project
|
||||
region = "us-central1"
|
||||
zone = "us-central1-a"
|
||||
|
||||
domain = "drb.cusano.net" # DNS is on AWS Route 53 — add A records manually after apply
|
||||
|
||||
machine_type = "e2-standard-2" # 2 vCPU / 8 GB — adjust if needed
|
||||
|
||||
ssh_user = "drb"
|
||||
ssh_public_key = "ssh-ed25519 AAAA... user@host" # cat ~/.ssh/id_ed25519.pub
|
||||
|
||||
# Your IP + any CI runner IPs that need SSH access
|
||||
allowed_ssh_cidrs = ["YOUR_IP/32"]
|
||||
|
||||
# Existing GCS bucket for audio recordings (bucket must already exist — imported into state)
|
||||
audio_bucket_name = "your-audio-bucket-name"
|
||||
audio_bucket_location = "US-CENTRAL1" # must match existing bucket location exactly — check GCP console
|
||||
|
||||
# Existing Firestore database ID and location (imported into state)
|
||||
firestore_database = "c2-server"
|
||||
firestore_location = "nam5" # nam5 = us-central, eur3 = europe, us-east1 = us-east
|
||||
@@ -0,0 +1,66 @@
|
||||
variable "project_id" {
|
||||
description = "GCP project ID"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
description = "GCP region"
|
||||
type = string
|
||||
default = "us-central1"
|
||||
}
|
||||
|
||||
variable "zone" {
|
||||
description = "GCP zone"
|
||||
type = string
|
||||
default = "us-central1-a"
|
||||
}
|
||||
|
||||
variable "domain" {
|
||||
description = "Base domain (e.g. example.com)"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "machine_type" {
|
||||
description = "Compute Engine machine type"
|
||||
type = string
|
||||
default = "e2-small"
|
||||
}
|
||||
|
||||
variable "ssh_user" {
|
||||
description = "SSH username for the VM"
|
||||
type = string
|
||||
default = "drb"
|
||||
}
|
||||
|
||||
variable "ssh_public_key" {
|
||||
description = "SSH public key to authorize on the VM"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "allowed_ssh_cidrs" {
|
||||
description = "CIDR ranges allowed to SSH to the VM (your IP + Gitea runner)"
|
||||
type = list(string)
|
||||
}
|
||||
|
||||
variable "audio_bucket_name" {
|
||||
description = "Existing GCS bucket name for call audio recordings"
|
||||
type = string
|
||||
}
|
||||
|
||||
variable "audio_bucket_location" {
|
||||
description = "GCS bucket location — must match the existing bucket's location exactly"
|
||||
type = string
|
||||
default = "US-CENTRAL1"
|
||||
}
|
||||
|
||||
variable "firestore_database" {
|
||||
description = "Firestore database ID (e.g. c2-server)"
|
||||
type = string
|
||||
default = "c2-server"
|
||||
}
|
||||
|
||||
variable "firestore_location" {
|
||||
description = "Firestore multi-region location (nam5 = us-central, eur3 = europe)"
|
||||
type = string
|
||||
default = "nam5"
|
||||
}
|
||||
Reference in New Issue
Block a user