Compare commits
79 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 |
@@ -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-frontend/.env
|
||||||
drb-c2-core/gcp-key.json
|
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
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
# DRB Server
|
# 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
|
## 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)
|
Edge Node (client machine)
|
||||||
@@ -28,9 +39,10 @@ Browser (admin)
|
|||||||
| Service | Container | Description | Port |
|
| Service | Container | Description | Port |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `mosquitto` | `eclipse-mosquitto` | MQTT broker — receives telemetry from edge nodes, dispatches commands | 1883 |
|
| `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` | — |
|
| `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
|
## Directory Structure
|
||||||
|
|
||||||
@@ -49,19 +61,19 @@ Server/
|
|||||||
│ │ │ ├── systems.py # Radio system CRUD (P25/DMR/NBFM configs)
|
│ │ │ ├── systems.py # Radio system CRUD (P25/DMR/NBFM configs)
|
||||||
│ │ │ ├── calls.py # Call log retrieval (read-only — calls created by MQTT handler)
|
│ │ │ ├── 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
|
│ │ │ ├── tokens.py # Discord bot token pool — add/delete/list; assign_token/release_token helpers
|
||||||
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes)
|
│ │ │ ├── upload.py # Audio file upload endpoint (called by edge nodes); triggers intelligence pipeline
|
||||||
│ │ │ ├── incidents.py # [PLANNED] Incident CRUD, manual link/unlink calls, resolve
|
│ │ │ ├── incidents.py # Incident CRUD, manual link/unlink calls, resolve
|
||||||
│ │ │ └── alerts.py # [PLANNED] Alert rule CRUD
|
│ │ │ └── alerts.py # Alert rule CRUD
|
||||||
│ │ └── internal/
|
│ │ └── internal/
|
||||||
│ │ ├── auth.py # Auth — Firebase ID token OR service key (Bearer)
|
│ │ ├── auth.py # Auth — Firebase ID token OR service key (Bearer)
|
||||||
│ │ ├── firestore.py # Async Firestore wrappers (doc_get, doc_set, doc_update, collection_list)
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── node_sweeper.py # Background task — marks nodes offline after 90s without heartbeat
|
||||||
│ │ ├── storage.py # GCS audio file uploads
|
│ │ ├── storage.py # GCS audio file uploads
|
||||||
│ │ ├── transcription.py # [PLANNED] STT pipeline — Whisper or Google Speech API
|
│ │ ├── transcription.py # STT pipeline — OpenAI Whisper / GPT-4o transcribe
|
||||||
│ │ ├── intelligence.py # [PLANNED] Keyword/entity extraction, severity scoring
|
│ │ ├── intelligence.py # GPT scene extraction: tags, incident type, location, units, vehicles; geocoding
|
||||||
│ │ ├── incident_correlator.py # [PLANNED] Match calls to incidents or create new ones
|
│ │ ├── incident_correlator.py # Hybrid correlator — matches calls to incidents or creates new ones
|
||||||
│ │ └── alerter.py # [PLANNED] Alert dispatch (Discord webhook, push, etc.)
|
│ │ └── alerter.py # Alert dispatch — evaluates rules, sends notifications
|
||||||
│ ├── scripts/
|
│ ├── scripts/
|
||||||
│ │ └── set_admin.py # CLI to grant/revoke Firebase admin custom claim
|
│ │ └── set_admin.py # CLI to grant/revoke Firebase admin custom claim
|
||||||
│ ├── gcp-key.json # GCP service account key — place here, NOT committed to git
|
│ ├── 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
|
└── drb-frontend/ # Next.js 14 admin dashboard
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── dashboard/ # Overview: active node grid + live call feed
|
│ ├── dashboard/ # Overview: active node grid + live call feed
|
||||||
│ ├── map/ # Leaflet map — node locations + [PLANNED] incident pins
|
│ ├── map/ # Leaflet map — node locations, active incident pins
|
||||||
│ ├── calls/ # Call history — talkgroup, duration, audio, transcript (when populated)
|
│ ├── calls/ # Call history — talkgroup, duration, audio playback, transcript
|
||||||
│ ├── nodes/ # Node list + per-node detail (approve, reject, assign system)
|
│ ├── nodes/ # Node list + per-node detail (approve, reject, assign system)
|
||||||
│ ├── systems/ # Radio system CRUD (P25 form with talkgroup editor, DMR/NBFM JSON)
|
│ ├── systems/ # Radio system CRUD (P25 form with talkgroup editor, DMR/NBFM JSON)
|
||||||
│ ├── tokens/ # Discord bot token pool management
|
│ ├── tokens/ # Discord bot token pool management
|
||||||
│ ├── incidents/ # [PLANNED] Incident list/detail — linked calls, location, resolve
|
│ ├── incidents/ # Incident list/detail — linked calls, location, summary, resolve
|
||||||
│ ├── alerts/ # [PLANNED] Alert rule configuration
|
│ ├── alerts/ # Alert rule configuration
|
||||||
│ └── login/ # Firebase Auth login page
|
│ └── login/ # Firebase Auth login page
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── MapView.tsx # react-leaflet map with status-colored markers
|
│ ├── 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
|
## 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.
|
Every radio call flows through a four-stage pipeline triggered when the edge node uploads its recording:
|
||||||
|
|
||||||
### Call lifecycle (current — fully working)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Edge node ──► MQTT call_start ──► c2-core creates CallRecord (status: active)
|
Edge node ──► MQTT call_start ──► CallRecord created (active)
|
||||||
│ talkgroup_id, talkgroup_name, freq,
|
│
|
||||||
│ node_id, system_id, started_at
|
Edge node ──► audio upload ──► GCS storage
|
||||||
▼
|
|
||||||
Firestore "calls" collection
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
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
|
## Frontend Pages
|
||||||
|
|
||||||
| Page | URL | Status | Description |
|
| Page | URL | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|
|
||||||
| Dashboard | `/dashboard` | Working | Live node grid + active call stream |
|
| Dashboard | `/dashboard` | Live node grid + active call stream |
|
||||||
| Map | `/map` | Working | Leaflet map — nodes color-coded by status, active call popups |
|
| Map | `/map` | Leaflet map — nodes color-coded by status, active call popups |
|
||||||
| Calls | `/calls` | Working | Full call history — talkgroup, duration, audio playback, transcript (when populated) |
|
| Calls | `/calls` | Full call history — talkgroup, duration, audio playback, transcript |
|
||||||
| Nodes | `/nodes` | Working | Node list; per-node detail for approve/reject/assign system |
|
| Nodes | `/nodes` | Node list; per-node detail for approve/reject/assign system |
|
||||||
| Systems | `/systems` | Working | Create and manage P25/DMR/NBFM radio system configurations |
|
| Systems | `/systems` | Create and manage P25/DMR/NBFM radio system configurations |
|
||||||
| Tokens | `/tokens` | Working | Discord bot token pool management |
|
| Tokens | `/tokens` | Discord bot token pool management |
|
||||||
| Incidents | `/incidents` | **Not built** | Incident list/detail — linked calls, location, summary, tags, resolve |
|
| Incidents | `/incidents` | Incident list/detail — linked calls, location, summary, tags, resolve |
|
||||||
| Alerts | `/alerts` | **Not built** | Alert rule configuration — keywords, talkgroups, notification channels |
|
| Alerts | `/alerts` | **Not built** | Alert rule configuration — keywords, talkgroups, notification channels |
|
||||||
|
|
||||||
## Makefile Targets
|
## 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
|
- mosquitto_data:/mosquitto/data
|
||||||
|
|
||||||
c2-core:
|
c2-core:
|
||||||
|
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||||
build: ./drb-c2-core
|
build: ./drb-c2-core
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8888:8000"
|
- "8888:8000"
|
||||||
env_file: ./drb-c2-core/.env
|
env_file: ./drb-c2-core/.env
|
||||||
volumes:
|
|
||||||
- ./drb-c2-core/gcp-key.json:/app/gcp-key.json:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- mosquitto
|
- mosquitto
|
||||||
|
|
||||||
discord-bot:
|
discord-bot:
|
||||||
|
image: ${REGISTRY}/discord-bot:${TAG:-latest}
|
||||||
build: ./drb-server-discord-bot
|
build: ./drb-server-discord-bot
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: ./drb-server-discord-bot/.env
|
env_file: ./drb-server-discord-bot/.env
|
||||||
@@ -35,6 +35,7 @@ services:
|
|||||||
- c2-core
|
- c2-core
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
image: ${REGISTRY}/frontend:${TAG:-latest}
|
||||||
build: ./drb-frontend
|
build: ./drb-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ GCS_BUCKET=your-bucket-name
|
|||||||
# How long (seconds) before a node is marked offline if no checkin received
|
# How long (seconds) before a node is marked offline if no checkin received
|
||||||
NODE_OFFLINE_THRESHOLD=90
|
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 — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
SUMMARY_INTERVAL_MINUTES=15
|
SUMMARY_INTERVAL_MINUTES=15
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -17,19 +17,32 @@ class Settings(BaseSettings):
|
|||||||
# Node health
|
# Node health
|
||||||
node_offline_threshold: int = 90 # seconds without checkin before marking offline
|
node_offline_threshold: int = 90 # seconds without checkin before marking offline
|
||||||
|
|
||||||
# OpenAI (Whisper STT)
|
# OpenAI (STT + intelligence)
|
||||||
openai_api_key: Optional[str] = None
|
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 (intelligence extraction, embeddings, incident summaries)
|
||||||
gemini_api_key: Optional[str] = None
|
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
|
summary_interval_minutes: int = 2 # how often the summary loop runs
|
||||||
correlation_window_hours: int = 2 # slow/location path: max hours since last call
|
correlation_window_hours: int = 2 # slow/location path: max hours since last call
|
||||||
embedding_similarity_threshold: float = 0.93 # slow-path: requires location corroboration
|
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_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
|
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
|
incident_auto_resolve_minutes: int = 90 # auto-resolve after N minutes with no new calls
|
||||||
|
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
|
recorrelation_scan_minutes: int = 60 # re-examine orphaned calls ended within this window
|
||||||
tg_fast_path_idle_minutes: int = 30 # fast path: max minutes since incident last updated
|
tg_fast_path_idle_minutes: int = 90 # fast path: max minutes since incident last updated
|
||||||
|
tg_dispatch_thin_idle_minutes: int = 10 # dispatch channels only: thin calls only attach to incidents idle < this many minutes
|
||||||
|
|
||||||
# Vocabulary learning
|
# Vocabulary learning
|
||||||
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
vocabulary_induction_interval_hours: int = 24 # how often the induction loop runs
|
||||||
@@ -38,7 +51,11 @@ class Settings(BaseSettings):
|
|||||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||||
service_key: Optional[str] = None
|
service_key: Optional[str] = None
|
||||||
|
|
||||||
# CORS — comma-separated list of allowed origins, or "*" for all
|
# 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] = ["*"]
|
cors_origins: list[str] = ["*"]
|
||||||
|
|
||||||
class Config:
|
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 typing import Optional
|
||||||
from fastapi import HTTPException, Security
|
from fastapi import HTTPException, Security
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
@@ -26,7 +29,7 @@ async def require_service_or_firebase_token(
|
|||||||
if not credentials:
|
if not credentials:
|
||||||
raise HTTPException(status_code=401, detail="Missing authorization token")
|
raise HTTPException(status_code=401, detail="Missing authorization token")
|
||||||
token = credentials.credentials
|
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}
|
return {"service": True}
|
||||||
try:
|
try:
|
||||||
return firebase_auth.verify_id_token(token)
|
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")
|
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(
|
async def require_admin_token(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
) -> dict:
|
) -> 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)
|
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")
|
raise HTTPException(status_code=403, detail="Admin access required")
|
||||||
return decoded
|
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)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import time as _time
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
import firebase_admin
|
import firebase_admin
|
||||||
from firebase_admin import credentials, firestore as fs
|
from firebase_admin import credentials, firestore as fs
|
||||||
@@ -6,6 +7,12 @@ from google.cloud.firestore_v1.base_query import FieldFilter
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
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():
|
def _init_firebase():
|
||||||
if firebase_admin._apps:
|
if firebase_admin._apps:
|
||||||
@@ -79,3 +86,19 @@ async def collection_where(
|
|||||||
async def doc_delete(collection: str, doc_id: str) -> None:
|
async def doc_delete(collection: str, doc_id: str) -> None:
|
||||||
ref = db.collection(collection).document(doc_id)
|
ref = db.collection(collection).document(doc_id)
|
||||||
await asyncio.to_thread(ref.delete)
|
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
@@ -10,6 +10,7 @@ Falls back gracefully if the API is unavailable or returns malformed output.
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
@@ -22,6 +23,17 @@ A busy dispatch channel sometimes captures back-to-back conversations about mult
|
|||||||
|
|
||||||
Always respond with the scenes array, even for a single scene.
|
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:
|
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)
|
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"
|
incident_type: one of "fire" | "ems" | "police" | "accident" | "other" | "unknown"
|
||||||
@@ -29,29 +41,47 @@ Response format — a JSON object with a "scenes" array. Each scene:
|
|||||||
location: most specific location string found, or empty string
|
location: most specific location string found, or empty string
|
||||||
vehicles: list of vehicle descriptions mentioned
|
vehicles: list of vehicle descriptions mentioned
|
||||||
units: list of unit IDs or officer numbers explicitly 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"
|
severity: one of "minor" | "moderate" | "major" | "unknown"
|
||||||
resolved: true if this scene explicitly signals incident closure, false otherwise
|
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
|
transcript_corrected: corrected text for this scene's transmissions only, or null
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- location: prefer intersections > addresses > mile markers > route+town > route alone > town alone. Empty string if none.
|
- 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: specific, lowercase, hyphenated. Do not repeat incident_type as a tag.
|
- 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 explicitly mentioned, not inferred.
|
- 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.
|
- Do not invent details not present in the transcript.
|
||||||
- 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".
|
- 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.
|
- 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".
|
- 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.
|
- 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}
|
System: {system_id}
|
||||||
Talkgroup: {talkgroup_name}
|
Talkgroup: {talkgroup_name}
|
||||||
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
||||||
|
|
||||||
# Nominatim viewbox half-width in degrees (~11 km at mid-latitudes)
|
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
||||||
_GEO_DELTA = 0.1
|
_GEO_DELTA = 0.5
|
||||||
|
|
||||||
# node_id → state abbreviation/name from one-time reverse geocode
|
# Cache node state (e.g. "New York") and county (e.g. "Westchester County") per node
|
||||||
_node_state_cache: dict[str, str] = {}
|
_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
|
# Strip P25 service suffixes to extract the municipality name from a talkgroup
|
||||||
_TG_SUFFIX_RE = re.compile(
|
_TG_SUFFIX_RE = re.compile(
|
||||||
@@ -63,6 +93,37 @@ _TG_SUFFIX_RE = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _build_ten_codes_block(ten_codes: dict[str, str]) -> str:
|
||||||
if not ten_codes:
|
if not ten_codes:
|
||||||
return ""
|
return ""
|
||||||
@@ -97,13 +158,36 @@ async def extract_scenes(
|
|||||||
vocabulary: list[str] = []
|
vocabulary: list[str] = []
|
||||||
ten_codes: dict[str, str] = {}
|
ten_codes: dict[str, str] = {}
|
||||||
if system_id:
|
if system_id:
|
||||||
from app.internal.vocabulary_learner import get_vocabulary
|
# Single cached read — vocabulary and ten_codes live on the same document.
|
||||||
vocab_data = await get_vocabulary(system_id)
|
system_doc = await fstore.doc_get_cached("systems", system_id)
|
||||||
vocabulary = vocab_data.get("vocabulary") or []
|
|
||||||
system_doc = await fstore.doc_get("systems", system_id)
|
|
||||||
if system_doc:
|
if system_doc:
|
||||||
|
vocabulary = system_doc.get("vocabulary") or []
|
||||||
ten_codes = system_doc.get("ten_codes") or {}
|
ten_codes = system_doc.get("ten_codes") or {}
|
||||||
|
|
||||||
|
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(
|
raw_scenes: list[dict] = await asyncio.to_thread(
|
||||||
_sync_extract,
|
_sync_extract,
|
||||||
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
transcript, talkgroup_name, talkgroup_id, system_id, segments, vocabulary, ten_codes,
|
||||||
@@ -116,7 +200,7 @@ async def extract_scenes(
|
|||||||
node_lat: Optional[float] = None
|
node_lat: Optional[float] = None
|
||||||
node_lon: Optional[float] = None
|
node_lon: Optional[float] = None
|
||||||
if node_id:
|
if node_id:
|
||||||
node_doc = await fstore.doc_get("nodes", node_id)
|
node_doc = await fstore.doc_get_cached("nodes", node_id)
|
||||||
if node_doc:
|
if node_doc:
|
||||||
node_lat = node_doc.get("lat")
|
node_lat = node_doc.get("lat")
|
||||||
node_lon = node_doc.get("lon")
|
node_lon = node_doc.get("lon")
|
||||||
@@ -128,21 +212,33 @@ async def extract_scenes(
|
|||||||
location: Optional[str] = scene.get("location") or None
|
location: Optional[str] = scene.get("location") or None
|
||||||
vehicles: list[str] = scene.get("vehicles") or []
|
vehicles: list[str] = scene.get("vehicles") or []
|
||||||
units: list[str] = scene.get("units") or []
|
units: list[str] = scene.get("units") or []
|
||||||
|
cleared_units: list[str] = scene.get("cleared_units") or []
|
||||||
severity: str = scene.get("severity") or "unknown"
|
severity: str = scene.get("severity") or "unknown"
|
||||||
resolved: bool = bool(scene.get("resolved", False))
|
resolved: bool = bool(scene.get("resolved", False))
|
||||||
|
reassignment: bool = bool(scene.get("reassignment", False))
|
||||||
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
||||||
segment_indices: Optional[list] = scene.get("segment_indices")
|
segment_indices: Optional[list] = scene.get("segment_indices")
|
||||||
|
|
||||||
if incident_type in ("unknown", "other", ""):
|
if incident_type in ("unknown", "other", ""):
|
||||||
incident_type = None
|
incident_type = None
|
||||||
|
|
||||||
# Geocode this scene's location
|
# 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
|
location_coords: Optional[dict] = None
|
||||||
if location and node_lat is not None and node_lon is not None:
|
if location and 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)
|
muni = _municipality_from_tg(talkgroup_name)
|
||||||
hint_parts = [p for p in [muni, state] if p]
|
state = await _get_node_state(node_id or "", node_lat, node_lon) if node_id else ""
|
||||||
query = f"{location}, {', '.join(hint_parts)}" if hint_parts else location
|
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)
|
location_coords = await _geocode_location(query, node_lat, node_lon)
|
||||||
|
|
||||||
# Embed this scene's content
|
# Embed this scene's content
|
||||||
@@ -158,8 +254,10 @@ async def extract_scenes(
|
|||||||
"location_coords": location_coords,
|
"location_coords": location_coords,
|
||||||
"vehicles": vehicles,
|
"vehicles": vehicles,
|
||||||
"units": units,
|
"units": units,
|
||||||
|
"cleared_units": cleared_units,
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"resolved": resolved,
|
"resolved": resolved,
|
||||||
|
"reassignment": reassignment,
|
||||||
"transcript_corrected": transcript_corrected,
|
"transcript_corrected": transcript_corrected,
|
||||||
"segment_indices": segment_indices,
|
"segment_indices": segment_indices,
|
||||||
"embedding": embedding,
|
"embedding": embedding,
|
||||||
@@ -172,6 +270,7 @@ async def extract_scenes(
|
|||||||
all_tags = list(dict.fromkeys(t for s in processed for t in s["tags"]))
|
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_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_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"]}
|
updates: dict = {"tags": all_tags, "severity": primary["severity"]}
|
||||||
if primary["location"]:
|
if primary["location"]:
|
||||||
@@ -180,6 +279,8 @@ async def extract_scenes(
|
|||||||
updates["location_coords"] = primary["location_coords"]
|
updates["location_coords"] = primary["location_coords"]
|
||||||
if all_units:
|
if all_units:
|
||||||
updates["units"] = all_units
|
updates["units"] = all_units
|
||||||
|
if all_cleared:
|
||||||
|
updates["cleared_units"] = all_cleared
|
||||||
if all_vehicles:
|
if all_vehicles:
|
||||||
updates["vehicles"] = all_vehicles
|
updates["vehicles"] = all_vehicles
|
||||||
if primary["embedding"]:
|
if primary["embedding"]:
|
||||||
@@ -203,71 +304,125 @@ async def extract_scenes(
|
|||||||
return processed
|
return processed
|
||||||
|
|
||||||
|
|
||||||
async def _geocode_location(
|
def _geo_dist_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
location_str: str, node_lat: float, node_lon: float
|
"""Haversine distance in km between two lat/lon points."""
|
||||||
) -> Optional[dict]:
|
R = 6371.0
|
||||||
"""
|
dlat = math.radians(lat2 - lat1)
|
||||||
Geocode a location string using Nominatim, biased toward the node's area.
|
dlon = math.radians(lon2 - lon1)
|
||||||
Returns {"lat": float, "lng": float} or None if geocoding fails.
|
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))
|
||||||
import httpx
|
|
||||||
|
|
||||||
viewbox = (
|
|
||||||
f"{node_lon - _GEO_DELTA},{node_lat - _GEO_DELTA},"
|
|
||||||
f"{node_lon + _GEO_DELTA},{node_lat + _GEO_DELTA}"
|
|
||||||
)
|
|
||||||
params = {
|
|
||||||
"q": location_str,
|
|
||||||
"format": "json",
|
|
||||||
"limit": 1,
|
|
||||||
"viewbox": viewbox,
|
|
||||||
"bounded": 1,
|
|
||||||
}
|
|
||||||
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",
|
|
||||||
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]:
|
async def _get_node_state(node_id: str, lat: float, lon: float) -> str:
|
||||||
"""
|
"""
|
||||||
Reverse geocode the node's position once to extract its state.
|
Return the US state name (e.g. "New York") for a node's position.
|
||||||
Result is cached for the process lifetime — nodes don't move.
|
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:
|
if node_id in _node_state_cache:
|
||||||
return _node_state_cache[node_id]
|
return _node_state_cache[node_id]
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
headers = {"User-Agent": "DRB-Dispatch/1.0 (public-safety radio monitor)"}
|
from app.config import settings
|
||||||
|
|
||||||
|
if not settings.google_maps_api_key:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
state = ""
|
||||||
|
county = ""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
"https://nominatim.openstreetmap.org/reverse",
|
"https://maps.googleapis.com/maps/api/geocode/json",
|
||||||
params={"lat": lat, "lon": lon, "format": "json", "zoom": 5},
|
params={
|
||||||
headers=headers,
|
"latlng": f"{lat},{lon}",
|
||||||
|
"result_type": "administrative_area_level_1|administrative_area_level_2",
|
||||||
|
"key": settings.google_maps_api_key,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json()
|
data = r.json()
|
||||||
state = data.get("address", {}).get("state", "")
|
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:
|
if state:
|
||||||
_node_state_cache[node_id] = state
|
_node_state_cache[node_id] = state
|
||||||
logger.info(f"Node {node_id} reverse-geocoded to state: {state!r}")
|
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
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def _geocode_location(
|
||||||
|
location_str: str, node_lat: float, node_lon: float
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
"address": location_str,
|
||||||
|
"bounds": bounds,
|
||||||
|
"region": "us",
|
||||||
|
"key": settings.google_maps_api_key,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
r = await client.get(
|
||||||
|
"https://maps.googleapis.com/maps/api/geocode/json",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Node state reverse geocode failed: {e}")
|
logger.warning(f"Geocoding failed for '{location_str}': {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
"lat": payload.get("lat", existing.get("lat", 0.0)),
|
||||||
"lon": payload.get("lon", existing.get("lon", 0.0)),
|
"lon": payload.get("lon", existing.get("lon", 0.0)),
|
||||||
}
|
}
|
||||||
# Only promote to online if already configured (don't overwrite explicit status)
|
# Update status on checkin (don't clobber an active recording)
|
||||||
if existing.get("configured") and existing.get("status") not in ("recording",):
|
if existing.get("status") not in ("recording",):
|
||||||
|
if existing.get("configured"):
|
||||||
updates["status"] = "online"
|
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)
|
await fstore.doc_update("nodes", node_id, updates)
|
||||||
|
|
||||||
# Release any orphaned Discord token when the node explicitly reports disconnected
|
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||||
if payload.get("discord_connected") is False:
|
# token here. The bot watchdog reconnects on transient Discord drops, so a single
|
||||||
from app.routers.tokens import release_token
|
# checkin with discord_connected=False during a brief reconnect window would
|
||||||
await release_token(node_id)
|
# 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
|
# Status update
|
||||||
@@ -122,10 +127,16 @@ class MQTTHandler:
|
|||||||
status = payload.get("status")
|
status = payload.get("status")
|
||||||
if not status:
|
if not status:
|
||||||
return
|
return
|
||||||
|
try:
|
||||||
await fstore.doc_update("nodes", node_id, {
|
await fstore.doc_update("nodes", node_id, {
|
||||||
"status": status,
|
"status": status,
|
||||||
"last_seen": datetime.now(timezone.utc).isoformat(),
|
"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
|
# Metadata — call_start / call_end events
|
||||||
@@ -143,8 +154,8 @@ class MQTTHandler:
|
|||||||
if not call_id:
|
if not call_id:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Look up assigned system for this node
|
# Look up assigned system for this node (cached — assignment rarely changes)
|
||||||
node = await fstore.doc_get("nodes", node_id)
|
node = await fstore.doc_get_cached("nodes", node_id)
|
||||||
system_id = node.get("assigned_system_id") if node else None
|
system_id = node.get("assigned_system_id") if node else None
|
||||||
|
|
||||||
started_at_raw = payload.get("started_at")
|
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
|
# Prefer the name from OP25 metadata; fall back to the system config
|
||||||
tgid_name = payload.get("tgid_name") or ""
|
tgid_name = payload.get("tgid_name") or ""
|
||||||
if not tgid_name and system_id and payload.get("tgid"):
|
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:
|
if system_doc:
|
||||||
tgid_int = int(payload["tgid"])
|
tgid_int = int(payload["tgid"])
|
||||||
for tg in system_doc.get("config", {}).get("talkgroups", []):
|
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.logger import logger
|
||||||
from app.internal import firestore as fstore
|
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():
|
async def sweeper_loop():
|
||||||
|
|||||||
@@ -46,9 +46,15 @@ async def _run_sweep_pass() -> None:
|
|||||||
("status", "==", "ended"),
|
("status", "==", "ended"),
|
||||||
("ended_at", ">=", cutoff),
|
("ended_at", ">=", cutoff),
|
||||||
])
|
])
|
||||||
|
# corr_path="unlinked" is written after MAX_SWEEP_ATTEMPTS failures.
|
||||||
|
# Allows a few retries so a welfare-check call can link to an escalation
|
||||||
|
# incident that is created a few minutes later, without sweeping 30× forever.
|
||||||
|
MAX_SWEEP_ATTEMPTS = 3
|
||||||
orphans = [
|
orphans = [
|
||||||
c for c in recent_ended
|
c for c in recent_ended
|
||||||
if not c.get("incident_ids") and not c.get("incident_id")
|
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:
|
if not orphans:
|
||||||
@@ -87,6 +93,7 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
|||||||
incident_type = call.get("incident_type"),
|
incident_type = call.get("incident_type"),
|
||||||
location = call.get("location"),
|
location = call.get("location"),
|
||||||
location_coords= call.get("location_coords"),
|
location_coords= call.get("location_coords"),
|
||||||
|
cleared_units = call.get("cleared_units") or [],
|
||||||
reference_time = started_at, # anchor window to when the call happened
|
reference_time = started_at, # anchor window to when the call happened
|
||||||
create_if_new = False, # never create — link-only
|
create_if_new = False, # never create — link-only
|
||||||
)
|
)
|
||||||
@@ -97,6 +104,15 @@ async def _recorrelate_orphan(call: dict) -> bool:
|
|||||||
f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}"
|
f"Re-correlation: linked orphaned call {call_id} → incident {incident_id}"
|
||||||
)
|
)
|
||||||
return True
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ from app.config import settings
|
|||||||
from app.internal.logger import logger
|
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."""
|
"""Upload audio bytes to GCS and return a signed URL, or None if disabled."""
|
||||||
if not settings.gcs_bucket:
|
if not settings.gcs_bucket:
|
||||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
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()
|
client = storage.Client()
|
||||||
signing_creds = None
|
signing_creds = None
|
||||||
bucket = client.bucket(settings.gcs_bucket)
|
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")
|
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||||
if signing_creds:
|
if signing_creds:
|
||||||
return blob.generate_signed_url(
|
return blob.generate_signed_url(
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ async def _resolve_stale_incidents() -> None:
|
|||||||
idle_minutes = (now - updated_dt).total_seconds() / 60
|
idle_minutes = (now - updated_dt).total_seconds() / 60
|
||||||
if idle_minutes > settings.incident_auto_resolve_minutes:
|
if idle_minutes > settings.incident_auto_resolve_minutes:
|
||||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
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(
|
logger.info(
|
||||||
f"Auto-resolved stale incident {incident_id} "
|
f"Auto-resolved stale incident {incident_id} "
|
||||||
f"(idle {idle_minutes:.0f}m)"
|
f"(idle {idle_minutes:.0f}m)"
|
||||||
|
|||||||
@@ -40,16 +40,9 @@ async def transcribe_call(
|
|||||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||||
return None, []
|
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:
|
try:
|
||||||
transcript, segments = await asyncio.to_thread(
|
transcript, segments = await asyncio.to_thread(
|
||||||
_sync_transcribe, gcs_uri, talkgroup_name, vocabulary
|
_sync_transcribe, gcs_uri, talkgroup_name
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
logger.warning(f"Transcription failed for call {call_id}: {e}")
|
||||||
@@ -74,7 +67,6 @@ async def transcribe_call(
|
|||||||
def _sync_transcribe(
|
def _sync_transcribe(
|
||||||
gcs_uri: str,
|
gcs_uri: str,
|
||||||
talkgroup_name: Optional[str] = None,
|
talkgroup_name: Optional[str] = None,
|
||||||
vocabulary: Optional[list[str]] = None,
|
|
||||||
) -> tuple[Optional[str], list[dict]]:
|
) -> tuple[Optional[str], list[dict]]:
|
||||||
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
"""Download audio from GCS and transcribe with OpenAI Whisper."""
|
||||||
from google.cloud import storage as gcs
|
from google.cloud import storage as gcs
|
||||||
@@ -108,23 +100,30 @@ def _sync_transcribe(
|
|||||||
try:
|
try:
|
||||||
blob.download_to_filename(tmp_path)
|
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 ""
|
tg_prefix = f"Talkgroup: {talkgroup_name}. " if talkgroup_name else ""
|
||||||
prompt = tg_prefix + vocab_prefix + _WHISPER_PROMPT
|
# 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)
|
openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||||
with open(tmp_path, "rb") as f:
|
with open(tmp_path, "rb") as f:
|
||||||
response = openai_client.audio.transcriptions.create(
|
response = openai_client.audio.transcriptions.create(
|
||||||
model="whisper-1",
|
model=settings.stt_model,
|
||||||
file=f,
|
file=f,
|
||||||
language="en",
|
language="en",
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
response_format="verbose_json",
|
response_format="verbose_json" if use_verbose else "json",
|
||||||
temperature=0,
|
temperature=0,
|
||||||
)
|
)
|
||||||
text = response.text.strip() or None
|
|
||||||
|
|
||||||
|
if use_verbose:
|
||||||
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
# Filter hallucinated segments. Two sources of hallucination in P25 recordings:
|
||||||
#
|
#
|
||||||
# 1. Trailing silence / static — Whisper fills silence past real content with
|
# 1. Trailing silence / static — Whisper fills silence past real content with
|
||||||
@@ -142,7 +141,16 @@ def _sync_transcribe(
|
|||||||
and s.start < audio_duration
|
and s.start < audio_duration
|
||||||
and getattr(s, "no_speech_prob", 0.0) < 0.8
|
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
|
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:
|
finally:
|
||||||
try:
|
try:
|
||||||
os.unlink(tmp_path)
|
os.unlink(tmp_path)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import asyncio
|
|||||||
import difflib
|
import difflib
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timezone
|
import re
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal import firestore as fstore
|
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:
|
async def get_vocabulary(system_id: str) -> dict:
|
||||||
"""Return vocabulary and pending terms for a system."""
|
"""Return vocabulary and pending terms for a system (TTL-cached, 5 min)."""
|
||||||
doc = await fstore.doc_get("systems", system_id)
|
doc = await fstore.doc_get_cached("systems", system_id)
|
||||||
if not doc:
|
if not doc:
|
||||||
return {"vocabulary": [], "vocabulary_pending": [], "vocabulary_bootstrapped": False}
|
return {"vocabulary": [], "vocabulary_pending": [], "vocabulary_bootstrapped": False}
|
||||||
return {
|
return {
|
||||||
@@ -250,8 +251,8 @@ async def vocabulary_induction_loop() -> None:
|
|||||||
f"interval: {settings.vocabulary_induction_interval_hours}h, "
|
f"interval: {settings.vocabulary_induction_interval_hours}h, "
|
||||||
f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
|
f"sample budget: {settings.vocabulary_induction_sample_tokens} tokens"
|
||||||
)
|
)
|
||||||
|
await asyncio.sleep(30) # short startup grace period before first pass
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(interval)
|
|
||||||
try:
|
try:
|
||||||
flags = await get_flags()
|
flags = await get_flags()
|
||||||
if flags["vocabulary_learning_enabled"]:
|
if flags["vocabulary_learning_enabled"]:
|
||||||
@@ -260,6 +261,7 @@ async def vocabulary_induction_loop() -> None:
|
|||||||
logger.info("Vocabulary learning disabled — skipping induction pass")
|
logger.info("Vocabulary learning disabled — skipping induction pass")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Vocabulary induction pass failed: {e}")
|
logger.error(f"Vocabulary induction pass failed: {e}")
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
|
|
||||||
async def _run_induction_pass() -> None:
|
async def _run_induction_pass() -> None:
|
||||||
@@ -281,8 +283,14 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
system_name = system_doc.get("name", "Unknown")
|
system_name = system_doc.get("name", "Unknown")
|
||||||
existing_vocab: list[str] = system_doc.get("vocabulary") or []
|
existing_vocab: list[str] = system_doc.get("vocabulary") or []
|
||||||
|
|
||||||
# Fetch recent ended calls for this system
|
# Fetch calls from the last 7 days only — avoids scanning the entire history.
|
||||||
all_calls = await fstore.collection_list("calls", system_id=system_id, status="ended")
|
# 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:
|
if not all_calls:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -290,6 +298,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
random.shuffle(all_calls)
|
random.shuffle(all_calls)
|
||||||
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
char_budget = settings.vocabulary_induction_sample_tokens * 4
|
||||||
transcript_block = ""
|
transcript_block = ""
|
||||||
|
sampled_call_docs: list[dict] = []
|
||||||
sampled = 0
|
sampled = 0
|
||||||
for call in all_calls:
|
for call in all_calls:
|
||||||
text = call.get("transcript_corrected") or call.get("transcript") or ""
|
text = call.get("transcript_corrected") or call.get("transcript") or ""
|
||||||
@@ -299,6 +308,7 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
break
|
break
|
||||||
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
tg = call.get("talkgroup_name") or f"TGID {call.get('talkgroup_id', '?')}"
|
||||||
transcript_block += f"[{tg}] {text}\n"
|
transcript_block += f"[{tg}] {text}\n"
|
||||||
|
sampled_call_docs.append(call)
|
||||||
sampled += 1
|
sampled += 1
|
||||||
|
|
||||||
if sampled < 3:
|
if sampled < 3:
|
||||||
@@ -315,11 +325,16 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
pending_lower = {p["term"].lower() for p in existing_pending}
|
pending_lower = {p["term"].lower() for p in existing_pending}
|
||||||
vocab_lower = {t.lower() for t in existing_vocab}
|
vocab_lower = {t.lower() for t in existing_vocab}
|
||||||
|
|
||||||
to_queue = [
|
to_queue = []
|
||||||
{"term": t, "source": "induction", "added_at": now}
|
for t in new_terms:
|
||||||
for t in new_terms
|
if t.lower() in vocab_lower or t.lower() in pending_lower:
|
||||||
if t.lower() not in vocab_lower and t.lower() not 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:
|
if not to_queue:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -336,6 +351,30 @@ async def _induct_system(system_id: str, system_doc: dict) -> None:
|
|||||||
# Internal sync helpers
|
# 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 = {
|
_STOP_WORDS = {
|
||||||
"the", "and", "for", "are", "was", "were", "this", "that", "with",
|
"the", "and", "for", "are", "was", "were", "this", "that", "with",
|
||||||
"have", "has", "had", "but", "not", "from", "they", "will", "what",
|
"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.internal.recorrelation_sweep import recorrelation_loop
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
|
||||||
@@ -68,8 +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(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(incidents.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(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(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -134,3 +134,48 @@ class AlertEvent(BaseModel):
|
|||||||
transcript_snippet: Optional[str] = None
|
transcript_snippet: Optional[str] = None
|
||||||
triggered_at: Optional[datetime] = None
|
triggered_at: Optional[datetime] = None
|
||||||
acknowledged: bool = False
|
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
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
from fastapi import APIRouter, Depends
|
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.auth import require_admin_token, require_firebase_token
|
||||||
from app.internal.feature_flags import get_flags, set_flags
|
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 = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
@@ -15,3 +33,144 @@ async def get_feature_flags(_=Depends(require_firebase_token)):
|
|||||||
async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
|
async def update_feature_flags(body: dict, _=Depends(require_admin_token)):
|
||||||
"""Update one or more AI feature flags. Admin only."""
|
"""Update one or more AI feature flags. Admin only."""
|
||||||
return await set_flags(body)
|
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 fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
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}
|
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")
|
@router.patch("/{call_id}/transcript")
|
||||||
async def patch_transcript(
|
async def patch_transcript(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Optional
|
|||||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Depends
|
||||||
from app.models import IncidentCreate, IncidentUpdate
|
from app.models import IncidentCreate, IncidentUpdate
|
||||||
from app.internal import firestore as fstore
|
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"])
|
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")
|
@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)."""
|
"""Immediately run the summarizer pass on all stale incidents (don't wait for the next interval)."""
|
||||||
from app.internal.summarizer import _run_summary_pass
|
from app.internal.summarizer import _run_summary_pass
|
||||||
background_tasks.add_task(_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")
|
@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."""
|
"""Immediately run the summarizer for a specific incident."""
|
||||||
from app.internal.summarizer import _summarize_incident
|
from app.internal.summarizer import _summarize_incident
|
||||||
inc = await fstore.doc_get("incidents", incident_id)
|
inc = await fstore.doc_get("incidents", incident_id)
|
||||||
if not inc:
|
if not inc:
|
||||||
raise HTTPException(404, f"Incident '{incident_id}' not found.")
|
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)
|
background_tasks.add_task(_summarize_incident, inc)
|
||||||
return {"ok": True, "incident_id": incident_id}
|
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
|
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.models import CommandPayload
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.mqtt_handler import mqtt_handler
|
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
|
from app.routers.tokens import assign_token, release_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/nodes", tags=["nodes"])
|
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}
|
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")
|
@router.post("/{node_id}/reject")
|
||||||
async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
async def reject_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||||
node = await fstore.doc_get("nodes", node_id)
|
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")
|
@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)
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
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)
|
payload = cmd.model_dump(exclude_none=True)
|
||||||
|
|
||||||
if cmd.action == "discord_join":
|
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)
|
token = await assign_token(node_id, preferred_token_id=preferred)
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(503, "No Discord bot tokens available in the pool.")
|
raise HTTPException(503, "No Discord bot tokens available in the pool.")
|
||||||
payload["token"] = token
|
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":
|
elif cmd.action == "discord_leave":
|
||||||
await release_token(node_id)
|
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}")
|
@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
|
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.
|
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:
|
if not system:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
|
||||||
# Push config to the node via MQTT
|
# Include hardware preset in the push so the edge node applies it when
|
||||||
mqtt_handler.push_config(node_id, system)
|
# 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
|
# Update Firestore
|
||||||
await fstore.doc_update("nodes", node_id, {
|
node_updates = {
|
||||||
"assigned_system_id": system_id,
|
"assigned_system_id": system_id,
|
||||||
"configured": True,
|
"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}
|
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
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
from app.models import SystemCreate, SystemRecord
|
from app.models import SystemCreate, SystemRecord
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token, bootstrap_limiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/systems", tags=["systems"])
|
router = APIRouter(prefix="/systems", tags=["systems"])
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ async def get_system(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@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())
|
system_id = str(uuid.uuid4())
|
||||||
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
doc = SystemRecord(system_id=system_id, **body.model_dump())
|
||||||
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
await fstore.doc_set("systems", system_id, doc.model_dump(), merge=False)
|
||||||
@@ -43,7 +44,7 @@ async def create_system(body: SystemCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{system_id}")
|
@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)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
@@ -52,7 +53,7 @@ async def update_system(system_id: str, body: SystemCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{system_id}", status_code=204)
|
@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)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
@@ -62,7 +63,11 @@ async def delete_system(system_id: str):
|
|||||||
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
# ── Per-system AI flag overrides ──────────────────────────────────────────────
|
||||||
|
|
||||||
@router.put("/{system_id}/ai-flags")
|
@router.put("/{system_id}/ai-flags")
|
||||||
async def update_system_ai_flags(system_id: str, body: AiFlagsBody):
|
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
|
Set per-system AI flag overrides. Only fields included in the body are
|
||||||
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
written; omitted fields remain unchanged (or absent, meaning inherit global).
|
||||||
@@ -95,7 +100,11 @@ async def get_ten_codes(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.put("/{system_id}/ten-codes")
|
@router.put("/{system_id}/ten-codes")
|
||||||
async def update_ten_codes(system_id: str, body: TenCodesBody):
|
async def update_ten_codes(
|
||||||
|
system_id: str,
|
||||||
|
body: TenCodesBody,
|
||||||
|
_: dict = Depends(require_admin_token),
|
||||||
|
):
|
||||||
"""Replace the ten-code dictionary for a system."""
|
"""Replace the ten-code dictionary for a system."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -117,18 +126,26 @@ async def get_vocabulary(system_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/bootstrap", status_code=202)
|
@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."""
|
"""Trigger a one-shot GPT-4o bootstrap to seed the vocabulary from local knowledge."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||||
|
bootstrap_limiter.check(system_id)
|
||||||
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
from app.internal.vocabulary_learner import bootstrap_system_vocabulary
|
||||||
terms = await bootstrap_system_vocabulary(system_id)
|
terms = await bootstrap_system_vocabulary(system_id)
|
||||||
return {"added": len(terms), "terms": terms}
|
return {"added": len(terms), "terms": terms}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/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."""
|
"""Manually add a term to the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -139,7 +156,11 @@ async def add_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{system_id}/vocabulary/terms")
|
@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."""
|
"""Remove a term from the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -150,7 +171,11 @@ async def remove_vocabulary_term(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/pending/approve")
|
@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."""
|
"""Move a pending induction suggestion into the approved vocabulary."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
@@ -161,7 +186,11 @@ async def approve_pending(system_id: str, body: VocabularyTermBody):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{system_id}/vocabulary/pending/dismiss")
|
@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."""
|
"""Dismiss a pending induction suggestion without adding it."""
|
||||||
existing = await fstore.doc_get("systems", system_id)
|
existing = await fstore.doc_get("systems", system_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
|
||||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
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."""
|
"""List all tokens. The actual token string is masked for safety."""
|
||||||
tokens = await fstore.collection_list("bot_tokens")
|
tokens = await fstore.collection_list("bot_tokens")
|
||||||
return [
|
return [
|
||||||
{**t, "token": t["token"][:10] + "…" + t["token"][-4:]}
|
{**t, "token": "•••" + t["token"][-4:]}
|
||||||
for t in tokens
|
for t in tokens
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@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())
|
token_id = str(uuid.uuid4())
|
||||||
doc = {
|
doc = {
|
||||||
"token_id": token_id,
|
"token_id": token_id,
|
||||||
@@ -43,7 +44,7 @@ async def add_token(body: TokenCreate):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/flush", status_code=200)
|
@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)."""
|
"""Force-release all in-use tokens (admin utility — use when tokens get orphaned)."""
|
||||||
def _find():
|
def _find():
|
||||||
from app.internal.firestore import db
|
from app.internal.firestore import db
|
||||||
@@ -60,8 +61,40 @@ async def flush_tokens():
|
|||||||
return {"released": len(results)}
|
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)
|
@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)
|
existing = await fstore.doc_get("bot_tokens", token_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(404, "Token not found.")
|
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.storage import upload_audio
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
router = APIRouter(tags=["upload"])
|
router = APIRouter(tags=["upload"])
|
||||||
|
|
||||||
@@ -43,9 +44,10 @@ async def upload_call_audio(
|
|||||||
data = await file.read()
|
data = await file.read()
|
||||||
if not data:
|
if not data:
|
||||||
raise HTTPException(400, "Empty file.")
|
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, file.filename or "", call_id=call_id)
|
||||||
audio_url = await upload_audio(data, filename)
|
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
try:
|
try:
|
||||||
@@ -83,6 +85,65 @@ def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
|||||||
return None
|
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(
|
async def _run_extraction_pipeline(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
@@ -110,7 +171,11 @@ async def _run_extraction_pipeline(
|
|||||||
all_tags: list[str] = []
|
all_tags: list[str] = []
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
all_tags.extend(scene["tags"])
|
all_tags.extend(scene["tags"])
|
||||||
incident_id = await incident_correlator.correlate_call(
|
# 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,
|
call_id=call_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
@@ -120,11 +185,16 @@ async def _run_extraction_pipeline(
|
|||||||
incident_type=scene["incident_type"],
|
incident_type=scene["incident_type"],
|
||||||
location=scene["location"],
|
location=scene["location"],
|
||||||
location_coords=scene["location_coords"],
|
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:
|
if incident_id and incident_id not in incident_ids:
|
||||||
incident_ids.append(incident_id)
|
incident_ids.append(incident_id)
|
||||||
if scene["resolved"] and incident_id:
|
if scene["resolved"] and incident_id:
|
||||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
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)")
|
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||||
|
|
||||||
if incident_ids:
|
if incident_ids:
|
||||||
@@ -165,7 +235,7 @@ async def _run_intelligence_pipeline(
|
|||||||
# but global flag=False beats everything (master switch).
|
# but global flag=False beats everything (master switch).
|
||||||
system_ai_flags: dict = {}
|
system_ai_flags: dict = {}
|
||||||
if system_id:
|
if system_id:
|
||||||
sys_doc = await fstore.doc_get("systems", system_id)
|
sys_doc = await fstore.doc_get_cached("systems", system_id)
|
||||||
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
|
system_ai_flags = (sys_doc or {}).get("ai_flags") or {}
|
||||||
|
|
||||||
def _flag(name: str) -> bool:
|
def _flag(name: str) -> bool:
|
||||||
@@ -206,7 +276,9 @@ async def _run_intelligence_pipeline(
|
|||||||
if flags["correlation_enabled"]:
|
if flags["correlation_enabled"]:
|
||||||
for scene in scenes:
|
for scene in scenes:
|
||||||
all_tags.extend(scene["tags"])
|
all_tags.extend(scene["tags"])
|
||||||
incident_id = await incident_correlator.correlate_call(
|
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,
|
call_id=call_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
@@ -216,17 +288,26 @@ async def _run_intelligence_pipeline(
|
|||||||
incident_type=scene["incident_type"],
|
incident_type=scene["incident_type"],
|
||||||
location=scene["location"],
|
location=scene["location"],
|
||||||
location_coords=scene["location_coords"],
|
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:
|
if incident_id and incident_id not in incident_ids:
|
||||||
incident_ids.append(incident_id)
|
incident_ids.append(incident_id)
|
||||||
if scene["resolved"] and incident_id:
|
if scene["resolved"] and incident_id:
|
||||||
await fstore.doc_set("incidents", incident_id, {"status": "resolved"})
|
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)")
|
logger.info(f"Auto-resolved incident {incident_id} (LLM closure detection)")
|
||||||
|
|
||||||
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
# Correlator also runs for calls with no scenes (unclassified) to attempt
|
||||||
# talkgroup-based linking even when no transcript could be produced.
|
# 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:
|
if not scenes:
|
||||||
incident_id = await incident_correlator.correlate_call(
|
_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,
|
call_id=call_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
system_id=system_id,
|
system_id=system_id,
|
||||||
|
|||||||
@@ -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}
|
||||||
+985
-17
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);
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
<h1 className="text-white text-xl font-bold font-mono">Alerts</h1>
|
||||||
{unacked.length > 0 && (
|
{unacked.length > 0 && (
|
||||||
|
|||||||
@@ -1,34 +1,196 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useCalls } from "@/lib/useCalls";
|
import { useCalls } from "@/lib/useCalls";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { CallRow } from "@/components/CallRow";
|
import { CallRow } from "@/components/CallRow";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
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() {
|
export default function CallsPage() {
|
||||||
const [limitCount, setLimitCount] = useState(100);
|
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 { systems } = useSystems();
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
||||||
|
|
||||||
|
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 active = calls.filter((c) => c.status === "active");
|
||||||
const ended = calls.filter((c) => c.status === "ended");
|
const ended = calls.filter((c) => c.status === "ended");
|
||||||
|
const filtered = useMemo(() => filterCalls(ended, filters), [ended, filters]);
|
||||||
|
|
||||||
|
const activeFilters = isActive(filters);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Calls</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-gray-500 font-mono">{calls.length} loaded</span>
|
<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>
|
</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 && (
|
{active.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
<h2 className="text-sm font-semibold text-orange-400 uppercase tracking-wider mb-3">
|
||||||
Live ({active.length})
|
Live ({active.length})
|
||||||
</h2>
|
</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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
@@ -51,17 +213,20 @@ export default function CallsPage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
<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>
|
</h2>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
||||||
) : ended.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
@@ -74,7 +239,7 @@ export default function CallsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{ended.map((c) => (
|
{filtered.map((c) => (
|
||||||
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} isAdmin={isAdmin} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function DashboardPage() {
|
|||||||
{calls.length === 0 ? (
|
{calls.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||||
|
|||||||
@@ -4,6 +4,114 @@
|
|||||||
|
|
||||||
@import 'leaflet/dist/leaflet.css';
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||||
html, body {
|
html, body {
|
||||||
@apply bg-gray-950 text-gray-100 font-mono;
|
@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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ export default function IncidentsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Nav } from "@/components/Nav";
|
import { Nav } from "@/components/Nav";
|
||||||
import { AuthProvider } from "@/components/AuthProvider";
|
import { AuthProvider } from "@/components/AuthProvider";
|
||||||
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -10,12 +11,18 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
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">
|
<body className="min-h-screen bg-gray-950">
|
||||||
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Nav />
|
<Nav />
|
||||||
<main className="p-6">{children}</main>
|
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -18,6 +19,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await signInWithEmailAndPassword(auth, email, password);
|
await signInWithEmailAndPassword(auth, email, password);
|
||||||
|
c2api.recordSession().catch(() => {});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Invalid email or password.");
|
setError("Invalid email or password.");
|
||||||
@@ -31,6 +33,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await signInWithPopup(auth, new GoogleAuthProvider());
|
await signInWithPopup(auth, new GoogleAuthProvider());
|
||||||
|
c2api.recordSession().catch(() => {});
|
||||||
router.push("/dashboard");
|
router.push("/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
setError("Google sign-in failed. Try again.");
|
setError("Google sign-in failed. Try again.");
|
||||||
|
|||||||
@@ -1,92 +1,80 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
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() {
|
export default function MapPage() {
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const incidents = useActiveIncidents();
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Map</h1>
|
<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">
|
<button
|
||||||
<span><span className="text-green-400">●</span> Online</span>
|
onClick={() => setKiosk(true)}
|
||||||
<span><span className="text-orange-400 animate-pulse">●</span> Recording</span>
|
title="Fullscreen / kiosk mode"
|
||||||
<span><span className="text-indigo-400">●</span> Unconfigured</span>
|
className="text-xs font-mono text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1.5"
|
||||||
<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>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<span><span className="text-blue-500">■</span> Police</span>
|
<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"/>
|
||||||
<span><span className="text-yellow-500">■</span> EMS</span>
|
</svg>
|
||||||
<span><span className="text-orange-500">■</span> Accident</span>
|
Fullscreen
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{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…
|
Loading map…
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: "calc(100vh - 280px)", minHeight: "400px" }}>
|
<div className="w-full h-[calc(100vh-10rem)] border border-gray-800 rounded-lg overflow-hidden">
|
||||||
<MapView nodes={nodes} activeCalls={activeCalls} incidents={incidents} />
|
<MapView
|
||||||
|
nodes={nodes}
|
||||||
|
activeCalls={activeCalls}
|
||||||
|
incidents={incidents}
|
||||||
|
lastUpdated={lastUpdated}
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { doc, onSnapshot } from "firebase/firestore";
|
import { doc, onSnapshot } from "firebase/firestore";
|
||||||
import { db } from "@/lib/firebase";
|
import { db } from "@/lib/firebase";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
@@ -111,11 +111,13 @@ function DiscordJoinModal({
|
|||||||
|
|
||||||
export default function NodeDetailPage() {
|
export default function NodeDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const router = useRouter();
|
||||||
const [node, setNode] = useState<NodeRecord | null>(null);
|
const [node, setNode] = useState<NodeRecord | null>(null);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [showDiscordJoin, setShowDiscordJoin] = useState(false);
|
const [showDiscordJoin, setShowDiscordJoin] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [approving, setApproving] = useState(false);
|
const [approving, setApproving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const { systems } = useSystems();
|
const { systems } = useSystems();
|
||||||
const { calls } = useCalls(20);
|
const { calls } = useCalls(20);
|
||||||
const { isAdmin } = useAuth();
|
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() {
|
async function handleReject() {
|
||||||
if (!confirm("Reject this node? It will not be able to upload recordings.")) return;
|
if (!confirm("Reject this node? It will not be able to upload recordings.")) return;
|
||||||
setApproving(true);
|
setApproving(true);
|
||||||
@@ -257,6 +271,15 @@ export default function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
Leave Discord
|
Leave Discord
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Recent calls */}
|
{/* Recent calls */}
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { NodeCard } from "@/components/NodeCard";
|
import { NodeCard } from "@/components/NodeCard";
|
||||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
export default function NodesPage() {
|
export default function NodesPage() {
|
||||||
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const { nodes, loading } = useNodes();
|
const { nodes, loading } = useNodes();
|
||||||
const { systems } = useSystems();
|
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 [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||||
|
|
||||||
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useRef, useState, Fragment } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useSystems } from "@/lib/useSystems";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
import type { SystemRecord, VocabularyPendingTerm } from "@/lib/types";
|
||||||
|
|
||||||
// ── P25 structured config types ──────────────────────────────────────────────
|
// ── P25 structured config types ───────────────────────────────────────────────
|
||||||
|
|
||||||
interface TalkgroupEntry {
|
interface TalkgroupEntry {
|
||||||
id: string;
|
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 ────────────────────────────────────────────────────
|
// ── Talkgroup table editor ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function TalkgroupEditor({
|
function TalkgroupEditor({
|
||||||
@@ -83,6 +327,9 @@ function TalkgroupEditor({
|
|||||||
}) {
|
}) {
|
||||||
const [showPaste, setShowPaste] = useState(false);
|
const [showPaste, setShowPaste] = useState(false);
|
||||||
const [pasteText, setPasteText] = useState("");
|
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() {
|
function addRow() {
|
||||||
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
onChange([...talkgroups, { id: "", name: "", tag: "other" }]);
|
||||||
@@ -115,13 +362,61 @@ function TalkgroupEditor({
|
|||||||
setShowPaste(false);
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{rrSystem && (
|
||||||
|
<RRImportModal
|
||||||
|
system={rrSystem}
|
||||||
|
onImport={handleRRImport}
|
||||||
|
onCancel={() => setRrSystem(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-gray-400">
|
<label className="text-xs text-gray-400">
|
||||||
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPaste(!showPaste)}
|
onClick={() => setShowPaste(!showPaste)}
|
||||||
@@ -139,10 +434,17 @@ function TalkgroupEditor({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{showPaste && (
|
||||||
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
||||||
<p className="text-xs text-gray-500">
|
<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
|
<br />Tags: fire · police · ems · transit · public works · other
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -219,7 +521,9 @@ function TalkgroupEditor({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -293,10 +597,12 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
|||||||
|
|
||||||
function SystemForm({
|
function SystemForm({
|
||||||
initial,
|
initial,
|
||||||
|
createOnly,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
initial?: SystemRecord;
|
initial?: SystemRecord;
|
||||||
|
createOnly?: boolean;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -345,7 +651,7 @@ function SystemForm({
|
|||||||
} else {
|
} else {
|
||||||
config = JSON.parse(rawJson);
|
config = JSON.parse(rawJson);
|
||||||
}
|
}
|
||||||
if (initial) {
|
if (initial && !createOnly) {
|
||||||
await c2api.updateSystem(initial.system_id, { name, type, config });
|
await c2api.updateSystem(initial.system_id, { name, type, config });
|
||||||
} else {
|
} else {
|
||||||
await c2api.createSystem({ name, type, config });
|
await c2api.createSystem({ name, type, config });
|
||||||
@@ -358,9 +664,11 @@ function SystemForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
<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 className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -433,6 +741,123 @@ 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 ─────────────────────────────────────────────────
|
// ── Per-system AI flags panel ─────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SystemAiFlags {
|
interface SystemAiFlags {
|
||||||
@@ -523,6 +948,53 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ──────────────────────────────────────────────────────────
|
// ── Vocabulary panel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -537,7 +1009,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (vocab !== null) return; // already loaded
|
if (vocab !== null) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await c2api.getVocabulary(systemId);
|
const data = await c2api.getVocabulary(systemId);
|
||||||
@@ -607,7 +1079,11 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Vocabulary
|
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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -617,7 +1093,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{!loading && vocab !== null && (
|
{!loading && vocab !== null && (
|
||||||
<>
|
<>
|
||||||
{/* Bootstrap button */}
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleBootstrap}
|
onClick={handleBootstrap}
|
||||||
@@ -629,16 +1104,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Approved vocabulary chips */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
||||||
{vocab.length > 0 ? (
|
{vocab.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{vocab.map((term) => (
|
{vocab.map((term) => (
|
||||||
<span
|
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
||||||
key={term}
|
|
||||||
className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full"
|
|
||||||
>
|
|
||||||
{term}
|
{term}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(term)}
|
onClick={() => handleRemove(term)}
|
||||||
@@ -654,7 +1125,6 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add term */}
|
|
||||||
<form onSubmit={handleAdd} className="flex gap-2">
|
<form onSubmit={handleAdd} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={newTerm}
|
value={newTerm}
|
||||||
@@ -671,29 +1141,29 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Pending induction suggestions */}
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
||||||
Induction suggestions ({pending.length})
|
Induction suggestions ({pending.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
{pending.map((p) => (
|
{pending.map((p) => (
|
||||||
<div key={p.term} className="flex items-center gap-2">
|
<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-300 flex-1">{p.term}</span>
|
||||||
<span className="text-gray-600">{p.source}</span>
|
<span className="text-gray-600">{p.source}</span>
|
||||||
<button
|
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
||||||
onClick={() => handleApprove(p.term)}
|
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
||||||
className="text-green-500 hover:text-green-400 transition-colors px-1"
|
</div>
|
||||||
>
|
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||||||
✓
|
<div className="pl-1 space-y-1">
|
||||||
</button>
|
{p.source_call_ids.map((id: string) => (
|
||||||
<button
|
<Fragment key={id}>
|
||||||
onClick={() => handleDismiss(p.term)}
|
<SourceCallPlayer callId={id} />
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors px-1"
|
</Fragment>
|
||||||
>
|
))}
|
||||||
✕
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -710,20 +1180,44 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
// ── Systems list page ─────────────────────────────────────────────────────────
|
// ── Systems list page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SystemsPage() {
|
export default function SystemsPage() {
|
||||||
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const { systems, loading } = useSystems();
|
const { systems, loading } = useSystems();
|
||||||
|
|
||||||
|
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 [editing, setEditing] = useState<SystemRecord | null | "new">(null);
|
||||||
|
const [editIsDuplicate, setEditIsDuplicate] = useState(false);
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Delete this system?")) return;
|
if (!confirm("Delete this system?")) return;
|
||||||
await c2api.deleteSystem(id);
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||||
<button
|
<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"
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
+ New System
|
+ New System
|
||||||
@@ -733,8 +1227,9 @@ export default function SystemsPage() {
|
|||||||
{editing && (
|
{editing && (
|
||||||
<SystemForm
|
<SystemForm
|
||||||
initial={editing === "new" ? undefined : editing}
|
initial={editing === "new" ? undefined : editing}
|
||||||
onSave={() => setEditing(null)}
|
createOnly={editIsDuplicate}
|
||||||
onCancel={() => setEditing(null)}
|
onSave={closeEdit}
|
||||||
|
onCancel={closeEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -771,11 +1266,17 @@ export default function SystemsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-3">
|
<div className="mt-3 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditing(s)}
|
onClick={() => openEdit(s)}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openDuplicate(s)}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(s.system_id)}
|
onClick={() => handleDelete(s.system_id)}
|
||||||
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
className="text-xs text-red-500 hover:text-red-400 transition-colors"
|
||||||
@@ -783,6 +1284,7 @@ export default function SystemsPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 ?? {}} />
|
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||||
<VocabularyPanel systemId={s.system_id} />
|
<VocabularyPanel systemId={s.system_id} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface TokenRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TokensPage() {
|
export default function TokensPage() {
|
||||||
const { isAdmin, loading: authLoading } = useAuth();
|
const { isAdmin, isOperator, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
const [tokens, setTokens] = useState<TokenRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -26,8 +26,8 @@ export default function TokensPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !isAdmin) router.replace("/dashboard");
|
if (!authLoading && !isAdmin && !isOperator) router.replace("/dashboard");
|
||||||
}, [authLoading, isAdmin, router]);
|
}, [authLoading, isAdmin, isOperator, router]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -67,10 +67,10 @@ export default function TokensPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading || !isAdmin) return null;
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Bot Token Pool</h1>
|
<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 { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
role: UserRole | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isOperator: boolean;
|
||||||
|
ownedNodeIds: string[];
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
user: null,
|
user: null,
|
||||||
loading: true,
|
loading: true,
|
||||||
|
role: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
isOperator: false,
|
||||||
|
ownedNodeIds: [],
|
||||||
signOut: async () => {},
|
signOut: async () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
const [role, setRole] = useState<UserRole | null>(null);
|
||||||
|
const [ownedNodeIds, setOwnedNodeIds] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return onAuthStateChanged(auth, async (u) => {
|
return onAuthStateChanged(auth, async (u) => {
|
||||||
@@ -30,12 +38,25 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
if (u) {
|
if (u) {
|
||||||
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
document.cookie = "drb_session=1; path=/; SameSite=Strict";
|
||||||
// Read custom claims to determine admin status
|
|
||||||
const result = await u.getIdTokenResult(true);
|
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 {
|
} else {
|
||||||
document.cookie = "drb_session=; path=/; max-age=0";
|
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";
|
document.cookie = "drb_session=; path=/; max-age=0";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
const isOperator = role === "operator";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
|
<AuthContext.Provider value={{ user, loading, role, isAdmin, isOperator, ownedNodeIds, signOut }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,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"}`}
|
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)}
|
onClick={() => hasDetails && setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 text-gray-400 text-xs">
|
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
||||||
{new Date(call.started_at).toLocaleTimeString()}
|
<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>
|
||||||
<td className="px-4 py-2 text-gray-300">
|
<td className="px-4 py-2 text-gray-300">
|
||||||
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||||
@@ -138,6 +139,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
</div>
|
</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 */}
|
{/* Transcript */}
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
"use client";
|
"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 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;
|
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
@@ -12,19 +21,7 @@ L.Icon.Default.mergeOptions({
|
|||||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||||
});
|
});
|
||||||
|
|
||||||
const nodeIcon = (status: string) =>
|
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
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>`,
|
|
||||||
iconSize: [14, 14],
|
|
||||||
iconAnchor: [7, 7],
|
|
||||||
});
|
|
||||||
|
|
||||||
const INCIDENT_COLORS: Record<string, string> = {
|
const INCIDENT_COLORS: Record<string, string> = {
|
||||||
fire: "#ef4444",
|
fire: "#ef4444",
|
||||||
police: "#3b82f6",
|
police: "#3b82f6",
|
||||||
@@ -33,112 +30,604 @@ const INCIDENT_COLORS: Record<string, string> = {
|
|||||||
other: "#6b7280",
|
other: "#6b7280",
|
||||||
};
|
};
|
||||||
|
|
||||||
const incidentIcon = (type: string | null) => {
|
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="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],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function incidentIcon(type: string | null): L.DivIcon {
|
||||||
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="
|
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>`,
|
||||||
width:16px;height:16px;border-radius:3px;
|
|
||||||
background:${color};border:2px solid #111827;
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
font-size:9px;color:#111827;font-weight:bold;line-height:1;
|
|
||||||
">!</div>`,
|
|
||||||
iconSize: [16, 16],
|
iconSize: [16, 16],
|
||||||
iconAnchor: [8, 8],
|
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 {
|
interface Props {
|
||||||
nodes: NodeRecord[];
|
nodes: NodeRecord[];
|
||||||
activeCalls: CallRecord[];
|
activeCalls: CallRecord[];
|
||||||
incidents?: IncidentRecord[];
|
incidents?: IncidentRecord[];
|
||||||
|
lastUpdated?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
|
export default function MapView({ nodes, activeCalls, incidents = [], lastUpdated }: Props) {
|
||||||
const activeByNode = Object.fromEntries(
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
activeCalls.map((c) => [c.node_id, c])
|
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).
|
useEffect(() => {
|
||||||
const plottedIncidents = incidents.flatMap((inc) =>
|
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||||||
inc.location_coords
|
return () => clearInterval(id);
|
||||||
? [{ inc, pos: [inc.location_coords.lat, inc.location_coords.lng] as [number, number] }]
|
}, []);
|
||||||
: []
|
|
||||||
|
// 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] =
|
const center: [number, number] =
|
||||||
nodes.length > 0
|
nodes.length > 0
|
||||||
? [nodes[0].lat, nodes[0].lon]
|
? [nodes[0].lat, nodes[0].lon]
|
||||||
: plottedIncidents.length > 0
|
: allPositions.length > 0
|
||||||
? plottedIncidents[0].pos
|
? allPositions[0]
|
||||||
: [39.5, -98.35];
|
: [39.5, -98.35];
|
||||||
|
|
||||||
const zoom =
|
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
|
||||||
nodes.length > 0
|
|
||||||
? 10
|
const handleFitAll = useCallback(() => {
|
||||||
: plottedIncidents.length > 0
|
if (!mapInstance || allPositions.length === 0) return;
|
||||||
? 14
|
if (allPositions.length === 1) {
|
||||||
: 4;
|
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 (
|
return (
|
||||||
|
<div className="relative w-full h-full">
|
||||||
|
{/* ── Map container ───────────────────────────────────────────────────── */}
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={center}
|
center={center}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
className="w-full h-full rounded-lg"
|
className="w-full h-full rounded-lg"
|
||||||
style={{ background: "#111827" }}
|
style={{ background: "#111827" }}
|
||||||
>
|
>
|
||||||
|
<MapRefCapture onReady={onMapReady} />
|
||||||
|
|
||||||
|
<LayersControl position="topright">
|
||||||
|
{/* Base layers */}
|
||||||
|
<LayersControl.BaseLayer checked name="Dark">
|
||||||
<TileLayer
|
<TileLayer
|
||||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
/>
|
/>
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
<LayersControl.BaseLayer name="Light">
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
|
/>
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
<LayersControl.BaseLayer name="Streets">
|
||||||
|
<TileLayer
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
/>
|
||||||
|
</LayersControl.BaseLayer>
|
||||||
|
|
||||||
{/* Node markers */}
|
{/* Overlay: Nodes */}
|
||||||
{nodes.map((node) => (
|
<LayersControl.Overlay checked name="Nodes">
|
||||||
<Marker
|
<FeatureGroup>
|
||||||
key={node.node_id}
|
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
||||||
position={[node.lat, node.lon]}
|
</FeatureGroup>
|
||||||
icon={nodeIcon(node.status)}
|
</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"
|
||||||
>
|
>
|
||||||
<Popup className="font-mono">
|
⤢
|
||||||
<div className="text-gray-900">
|
</button>
|
||||||
<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>
|
</div>
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Incident markers — positioned at the node covering the incident's system */}
|
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
|
||||||
{plottedIncidents.map(({ inc, pos }) => (
|
<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">
|
||||||
<Marker
|
<span className="text-white text-sm font-mono tabular-nums">{clockStr}</span>
|
||||||
key={inc.incident_id}
|
</div>
|
||||||
position={pos}
|
|
||||||
icon={incidentIcon(inc.type)}
|
{/* ── 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">
|
||||||
<Popup className="font-mono">
|
<div className="flex items-center gap-2"><span className="text-green-400">●</span> Online</div>
|
||||||
<div className="text-gray-900">
|
<div className="flex items-center gap-2"><span className="text-orange-400">●</span> Recording</div>
|
||||||
<p className="font-bold">{inc.title ?? "Incident"}</p>
|
<div className="flex items-center gap-2"><span className="text-indigo-400">●</span> Unconfigured</div>
|
||||||
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other }}>
|
<div className="flex items-center gap-2"><span className="text-gray-500">●</span> Offline</div>
|
||||||
{inc.type ?? "other"}
|
<div className="border-t border-gray-800 my-0.5" />
|
||||||
</p>
|
<div className="flex items-center gap-2"><span className="text-red-500">■</span> Fire</div>
|
||||||
<p className="text-xs mt-1 capitalize">{inc.status}</p>
|
<div className="flex items-center gap-2"><span className="text-blue-500">■</span> Police</div>
|
||||||
{inc.location && <p className="text-xs text-gray-600 mt-1">{inc.location}</p>}
|
<div className="flex items-center gap-2"><span className="text-yellow-500">■</span> EMS</div>
|
||||||
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
|
<div className="flex items-center gap-2"><span className="text-orange-500">■</span> Accident</div>
|
||||||
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
|
</div>
|
||||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline mt-1 block">
|
|
||||||
View incident →
|
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||||
</a>
|
{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>
|
</div>
|
||||||
</Popup>
|
|
||||||
</Marker>
|
|
||||||
))}
|
|
||||||
</MapContainer>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-22
@@ -1,47 +1,92 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
import { useUnconfiguredNodes } from "@/lib/useNodes";
|
||||||
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
|
|
||||||
const links = [
|
// Links visible to all authenticated roles (viewer+)
|
||||||
|
const viewerLinks = [
|
||||||
{ href: "/dashboard", label: "Dashboard" },
|
{ href: "/dashboard", label: "Dashboard" },
|
||||||
{ href: "/nodes", label: "Nodes" },
|
|
||||||
{ href: "/systems", label: "Systems" },
|
|
||||||
{ href: "/calls", label: "Calls" },
|
{ href: "/calls", label: "Calls" },
|
||||||
{ href: "/incidents", label: "Incidents" },
|
{ href: "/incidents", label: "Incidents" },
|
||||||
{ href: "/map", label: "Map" },
|
{ href: "/map", label: "Map" },
|
||||||
{ href: "/alerts", label: "Alerts" },
|
{ href: "/alerts", label: "Alerts" },
|
||||||
|
{ href: "/trips", label: "Trips" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
// Additional links for operators and admins
|
||||||
|
const operatorLinks = [
|
||||||
|
{ href: "/nodes", label: "Nodes" },
|
||||||
|
{ href: "/systems", label: "Systems" },
|
||||||
{ href: "/tokens", label: "Tokens" },
|
{ href: "/tokens", label: "Tokens" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Admin-only links
|
||||||
|
const adminLinks = [
|
||||||
{ href: "/admin", label: "Admin" },
|
{ 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() {
|
export function Nav() {
|
||||||
const { user, isAdmin, signOut } = useAuth();
|
const { user, isAdmin, isOperator } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
const { nodes: pending } = useUnconfiguredNodes();
|
const { nodes: pending } = useUnconfiguredNodes();
|
||||||
const unackedAlerts = useUnacknowledgedAlerts();
|
const unackedAlerts = useUnacknowledgedAlerts();
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
if (!user) return null;
|
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 (
|
return (
|
||||||
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
|
<nav className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||||
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
|
{/* Main bar */}
|
||||||
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
|
<div className="px-4 md:px-6 py-3 flex items-center gap-4 md:gap-6">
|
||||||
<Link
|
<span className="font-mono font-bold text-white tracking-tight shrink-0">DRB</span>
|
||||||
key={href}
|
|
||||||
href={href}
|
{/* Desktop links */}
|
||||||
className={`text-sm font-mono transition-colors shrink-0 ${
|
<div className="hidden md:flex items-center gap-6 overflow-x-auto">
|
||||||
pathname.startsWith(href)
|
{allLinks.map(({ href, label }) => (
|
||||||
? "text-white"
|
<Link key={href} href={href} className={navLinkClass(href)}>
|
||||||
: "text-gray-500 hover:text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
{label === "Nodes" && pending.length > 0 && (
|
{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">
|
<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">
|
||||||
@@ -55,14 +100,88 @@ export function Nav() {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="ml-auto shrink-0">
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3 shrink-0">
|
||||||
|
{/* Theme toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={toggle}
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
>
|
>
|
||||||
Sign out
|
{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>
|
</button>
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,18 +10,29 @@ interface Props {
|
|||||||
onClose: () => void;
|
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) {
|
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||||
const [systemId, setSystemId] = useState("");
|
const [systemId, setSystemId] = useState("");
|
||||||
|
const [preset, setPreset] = useState("rtl-sdr-v3");
|
||||||
|
const [ppm, setPpm] = useState("0");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!systemId) return;
|
if (!systemId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await c2api.assignSystem(node.node_id, systemId);
|
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to assign system.");
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
<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">
|
<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>
|
</select>
|
||||||
</div>
|
</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>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-1">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,8 +25,11 @@ export const c2api = {
|
|||||||
getNode: (id: string) => request<unknown>(`/nodes/${id}`),
|
getNode: (id: string) => request<unknown>(`/nodes/${id}`),
|
||||||
sendCommand: (nodeId: string, payload: object) =>
|
sendCommand: (nodeId: string, payload: object) =>
|
||||||
request(`/nodes/${nodeId}/command`, { method: "POST", body: JSON.stringify(payload) }),
|
request(`/nodes/${nodeId}/command`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
assignSystem: (nodeId: string, systemId: string) =>
|
assignSystem: (nodeId: string, systemId: string, hardwarePreset: string, ppmOverride?: number) => {
|
||||||
request(`/nodes/${nodeId}/config/${systemId}`, { method: "POST" }),
|
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
|
// Systems
|
||||||
getSystems: () => request<unknown[]>("/systems"),
|
getSystems: () => request<unknown[]>("/systems"),
|
||||||
@@ -49,14 +52,19 @@ export const c2api = {
|
|||||||
request(`/nodes/${id}/approve`, { method: "POST" }),
|
request(`/nodes/${id}/approve`, { method: "POST" }),
|
||||||
rejectNode: (id: string) =>
|
rejectNode: (id: string) =>
|
||||||
request(`/nodes/${id}/reject`, { method: "POST" }),
|
request(`/nodes/${id}/reject`, { method: "POST" }),
|
||||||
|
deleteNode: (id: string) =>
|
||||||
|
request(`/nodes/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
// Calls
|
// Calls
|
||||||
|
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
|
||||||
getCalls: (params?: Record<string, string>) => {
|
getCalls: (params?: Record<string, string>) => {
|
||||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||||
return request<unknown[]>(`/calls${qs}`);
|
return request<unknown[]>(`/calls${qs}`);
|
||||||
},
|
},
|
||||||
patchTranscript: (callId: string, transcript: string) =>
|
patchTranscript: (callId: string, transcript: string) =>
|
||||||
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
|
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
|
// Incidents
|
||||||
getIncidents: (params?: { status?: string; type?: string }) => {
|
getIncidents: (params?: { status?: string; type?: string }) => {
|
||||||
@@ -121,6 +129,56 @@ export const c2api = {
|
|||||||
request<Record<string, boolean>>("/admin/features"),
|
request<Record<string, boolean>>("/admin/features"),
|
||||||
setFeatureFlags: (flags: Record<string, boolean>) =>
|
setFeatureFlags: (flags: Record<string, boolean>) =>
|
||||||
request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }),
|
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
|
// Per-system AI flag overrides
|
||||||
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
|
||||||
@@ -128,4 +186,34 @@ export const c2api = {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(flags),
|
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 NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
||||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
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 {
|
export interface NodeRecord {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
@@ -11,12 +50,15 @@ export interface NodeRecord {
|
|||||||
last_seen: string | null;
|
last_seen: string | null;
|
||||||
assigned_system_id: string | null;
|
assigned_system_id: string | null;
|
||||||
approval_status: ApprovalStatus | null;
|
approval_status: ApprovalStatus | null;
|
||||||
|
hardware_preset?: string;
|
||||||
|
ppm_override?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VocabularyPendingTerm {
|
export interface VocabularyPendingTerm {
|
||||||
term: string;
|
term: string;
|
||||||
source: "induction" | "correction";
|
source: "induction" | "correction";
|
||||||
added_at: string;
|
added_at: string;
|
||||||
|
source_call_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemRecord {
|
export interface SystemRecord {
|
||||||
@@ -28,6 +70,7 @@ export interface SystemRecord {
|
|||||||
vocabulary_pending?: VocabularyPendingTerm[];
|
vocabulary_pending?: VocabularyPendingTerm[];
|
||||||
vocabulary_bootstrapped?: boolean;
|
vocabulary_bootstrapped?: boolean;
|
||||||
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
ten_codes?: Record<string, string>; // {"10-10": "Commercial Alarm", ...}
|
||||||
|
preferred_token_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptSegment {
|
export interface TranscriptSegment {
|
||||||
@@ -56,6 +99,13 @@ export interface CallRecord {
|
|||||||
location: string | null;
|
location: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
status: "active" | "ended";
|
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 {
|
export interface IncidentRecord {
|
||||||
@@ -87,6 +137,49 @@ export interface AlertRule {
|
|||||||
created_at?: string;
|
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 {
|
export interface AlertEvent {
|
||||||
alert_id: string;
|
alert_id: string;
|
||||||
rule_id: string;
|
rule_id: string;
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { onAuthStateChanged } from "firebase/auth";
|
|||||||
import { db, auth } from "@/lib/firebase";
|
import { db, auth } from "@/lib/firebase";
|
||||||
import type { CallRecord } from "@/lib/types";
|
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 [calls, setCalls] = useState<CallRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let unsubFirestore: (() => void) | undefined;
|
let unsubFirestore: (() => void) | undefined;
|
||||||
|
|
||||||
@@ -23,11 +27,16 @@ export function useCalls(limitCount = 50) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = query(
|
const from = dateFromMs != null ? new Date(dateFromMs) : undefined;
|
||||||
collection(db, "calls"),
|
const to = dateToMs != null ? new Date(dateToMs) : undefined;
|
||||||
|
|
||||||
|
const constraints = [
|
||||||
|
...(from ? [where("started_at", ">=", from)] : []),
|
||||||
|
...(to ? [where("started_at", "<=", to)] : []),
|
||||||
orderBy("started_at", "desc"),
|
orderBy("started_at", "desc"),
|
||||||
limit(limitCount)
|
limit(limitCount),
|
||||||
);
|
];
|
||||||
|
const q = query(collection(db, "calls"), ...constraints);
|
||||||
const toISO = (v: any): string | null =>
|
const toISO = (v: any): string | null =>
|
||||||
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
v?.toDate?.()?.toISOString?.() ?? (typeof v === "string" ? v : null);
|
||||||
unsubFirestore = onSnapshot(q, (snap) => {
|
unsubFirestore = onSnapshot(q, (snap) => {
|
||||||
@@ -43,7 +52,7 @@ export function useCalls(limitCount = 50) {
|
|||||||
unsubAuth();
|
unsubAuth();
|
||||||
if (unsubFirestore) unsubFirestore();
|
if (unsubFirestore) unsubFirestore();
|
||||||
};
|
};
|
||||||
}, [limitCount]);
|
}, [limitCount, dateFromMs, dateToMs]);
|
||||||
|
|
||||||
return { calls, loading, error };
|
return { calls, loading, error };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
"react-dom": "^18.3.0",
|
||||||
"firebase": "^10.12.0",
|
"firebase": "^10.12.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1"
|
"react-leaflet": "^4.2.1",
|
||||||
|
"react-markdown": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.4.0",
|
"typescript": "^5.4.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const config: Config = {
|
|||||||
"./app/**/*.{ts,tsx}",
|
"./app/**/*.{ts,tsx}",
|
||||||
"./components/**/*.{ts,tsx}",
|
"./components/**/*.{ts,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: ["class"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class DRBBot(commands.Bot):
|
|||||||
|
|
||||||
async def setup_hook(self):
|
async def setup_hook(self):
|
||||||
await self.load_extension("app.commands.radio")
|
await self.load_extension("app.commands.radio")
|
||||||
|
await self.load_extension("app.commands.trips")
|
||||||
|
|
||||||
if settings.dev_guild_id:
|
if settings.dev_guild_id:
|
||||||
guild = discord.Object(id=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 node
|
||||||
return None
|
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()
|
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