Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd92269d2 | ||
|
|
96625fabd0 | ||
|
|
53965e1a19 | ||
|
|
6d5eb4c5f2 | ||
|
|
97013e1505 | ||
|
|
a2cd2c57ca | ||
|
|
a195563da6 | ||
|
|
55cd1110df | ||
|
|
a0a414ad21 | ||
|
|
518ac46929 | ||
|
|
052dda0b1f | ||
|
|
ee633cbe46 | ||
|
|
1f5f1fede8 | ||
|
|
12c9ad73bb | ||
|
|
971ab74d44 | ||
|
|
6140dd7b9c | ||
|
|
2e3fde2448 | ||
|
|
c42bd1902c | ||
|
|
c6684ea61b | ||
|
|
fa5f91c0fa |
+14
-5
@@ -7,11 +7,20 @@
|
||||
# password file. Use different values in production — do NOT reuse defaults.
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# C2-core service account (full broker access)
|
||||
# C2-core service account (full broker access via the "c2core" dynsec role)
|
||||
MQTT_C2_USER=drb-c2-core
|
||||
MQTT_C2_PASS=change-me-c2
|
||||
|
||||
# Shared credential for all edge nodes (ACL scopes each node to its own
|
||||
# nodes/<NODE_ID>/# namespace via the MQTT client ID)
|
||||
MQTT_NODE_USER=drb-node
|
||||
MQTT_NODE_PASS=change-me-node
|
||||
# Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
|
||||
# bootstrap client on first boot (read directly by mosquitto, no entrypoint
|
||||
# scripting involved). Must be >=12 chars. c2-core needs this SAME value as
|
||||
# MQTT_DYNSEC_ADMIN_PASS in drb-c2-core/.env to log in as "admin" and
|
||||
# administer node credentials — see app/internal/dynsec.py.
|
||||
MOSQUITTO_DYNSEC_PASSWORD=change-me-dynsec-admin-min-12-chars
|
||||
|
||||
# There is no shared node credential anymore. Each node authenticates as
|
||||
# username=<node_id>, password=<its node_keys.api_key> — checked by
|
||||
# mosquitto's dynamic-security plugin (not an HTTP backend — that was an
|
||||
# earlier, since-rejected design using the now-archived mosquitto-go-auth).
|
||||
# Nodes obtain that key via the enrollment flow — see ENROLLMENT_TOKEN in
|
||||
# drb-c2-core/.env.example.
|
||||
|
||||
@@ -52,6 +52,15 @@ jobs:
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/frontend:latest
|
||||
${{ env.REGISTRY }}/frontend:${{ gitea.sha }}
|
||||
build-args: |
|
||||
NEXT_PUBLIC_C2_URL=https://api.${{ secrets.DRB_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${{ secrets.FIREBASE_AUTH_DOMAIN }}
|
||||
NEXT_PUBLIC_FIREBASE_PROJECT_ID=${{ secrets.FIREBASE_PROJECT_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${{ secrets.FIREBASE_STORAGE_BUCKET }}
|
||||
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
|
||||
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
|
||||
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
|
||||
|
||||
deploy:
|
||||
name: Deploy to VM
|
||||
@@ -59,15 +68,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check runner outbound IP
|
||||
run: curl -s ifconfig.me
|
||||
|
||||
- name: Write SSH key
|
||||
run: |
|
||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > /tmp/deploy_key
|
||||
chmod 600 /tmp/deploy_key
|
||||
ssh-keygen -l -f /tmp/deploy_key
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no \
|
||||
-o HostKeyAlgorithms=ssh-ed25519,rsa-sha2-256,rsa-sha2-512 \
|
||||
-o ConnectTimeout=15 \
|
||||
-v \
|
||||
-i /tmp/deploy_key \
|
||||
drb@${{ secrets.SERVER_IP }} << 'ENDSSH'
|
||||
set -e
|
||||
|
||||
+4
-1
@@ -15,7 +15,10 @@ infra/terraform.tfvars
|
||||
infra/tf.log
|
||||
infra/ansible/inventory.ini
|
||||
infra/ansible/group_vars/all.yml
|
||||
infra/ansible/vault.yml
|
||||
# Glob, not the bare filename: ansible-vault edit and manual backups leave
|
||||
# siblings like vault.yml.locked.bak, which the exact-name rule left untracked
|
||||
# but committable.
|
||||
infra/ansible/vault.yml*
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
+26
-3
@@ -8,13 +8,36 @@
|
||||
# - restart: always (instead of unless-stopped) for hard reboots.
|
||||
|
||||
services:
|
||||
# ports AND volumes both need !override here, not !reset/a plain list —
|
||||
# compose merges list-type fields by APPENDING across -f files. A plain
|
||||
# list (or !reset on volumes) would leave dev's mosquitto_certs named
|
||||
# volume mounted at /mosquitto/certs alongside this bind mount, and two
|
||||
# mounts targeting the same path is exactly the "address already in use"-
|
||||
# style footgun the c2-core override below already hit once with ports.
|
||||
# mosquitto-data is now a host bind mount too (not just certs) — it holds
|
||||
# dynamic-security.json, the broker's only record of node credentials
|
||||
# (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH"). A named Docker
|
||||
# volume already survives normal redeploys (git pull && compose pull &&
|
||||
# up -d never passes -v), but the bind mount makes it inspectable/
|
||||
# backupable the same way the cert directory already is. NOT read-only —
|
||||
# mosquitto writes dynamic-security.json here.
|
||||
mosquitto:
|
||||
restart: always
|
||||
ports: !reset [] # Remove the dev 1883:1883 mapping — internal only
|
||||
ports: !override
|
||||
- "8883:8883" # TLS only, published. 1883 stays internal (docker bridge, c2-core's own login).
|
||||
volumes: !override
|
||||
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- /opt/drb/mosquitto-data:/mosquitto/data
|
||||
- /opt/drb/mosquitto-certs:/mosquitto/certs:ro # fed by the cert-sync systemd unit, see infra/ansible
|
||||
|
||||
# !override, not a plain list: compose MERGES `ports` by appending, so a plain
|
||||
# list leaves the base file's "8888:8000" in place alongside this one. The
|
||||
# container then tries to bind 8888 twice — 0.0.0.0 and 127.0.0.1 — and the
|
||||
# second bind fails with "address already in use". It also silently defeated
|
||||
# the whole point of this override, publishing the port on every interface.
|
||||
c2-core:
|
||||
restart: always
|
||||
ports:
|
||||
ports: !override
|
||||
- "127.0.0.1:8888:8000" # Caddy proxies, not exposed publicly
|
||||
|
||||
discord-bot:
|
||||
@@ -22,5 +45,5 @@ services:
|
||||
|
||||
frontend:
|
||||
restart: always
|
||||
ports:
|
||||
ports: !override
|
||||
- "127.0.0.1:3000:3000" # Caddy proxies, not exposed publicly
|
||||
|
||||
+18
-8
@@ -1,20 +1,23 @@
|
||||
services:
|
||||
# Auth is mosquitto's own built-in dynamic-security plugin (see
|
||||
# mosquitto.conf + app/internal/dynsec.py) — NOT mosquitto-go-auth, that
|
||||
# project is archived upstream (no CVE patches), rejected for a
|
||||
# public-internet broker. Stock official image, pinned to an exact patch
|
||||
# (not the floating `:2` tag). MOSQUITTO_DYNSEC_PASSWORD seeds the
|
||||
# plugin's own one-time "admin" bootstrap client on first boot — read
|
||||
# directly by the plugin's C code, no entrypoint scripting needed for it.
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2
|
||||
image: eclipse-mosquitto:2.1.2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1883:1883"
|
||||
entrypoint: ["/bin/sh", "/mosquitto/config/entrypoint.sh"]
|
||||
- "8883:8883"
|
||||
environment:
|
||||
- MQTT_C2_USER=${MQTT_C2_USER}
|
||||
- MQTT_C2_PASS=${MQTT_C2_PASS}
|
||||
- MQTT_NODE_USER=${MQTT_NODE_USER}
|
||||
- MQTT_NODE_PASS=${MQTT_NODE_PASS}
|
||||
- MOSQUITTO_DYNSEC_PASSWORD=${MOSQUITTO_DYNSEC_PASSWORD}
|
||||
volumes:
|
||||
- ./drb-c2-core/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
|
||||
- ./drb-c2-core/mosquitto/acl.conf:/mosquitto/config/acl.conf:ro
|
||||
- ./drb-c2-core/mosquitto/entrypoint.sh:/mosquitto/config/entrypoint.sh:ro
|
||||
- mosquitto_data:/mosquitto/data
|
||||
- mosquitto_certs:/mosquitto/certs
|
||||
|
||||
c2-core:
|
||||
image: ${REGISTRY}/c2-core:${TAG:-latest}
|
||||
@@ -45,4 +48,11 @@ services:
|
||||
- c2-core
|
||||
|
||||
volumes:
|
||||
# Dev only for both. Prod overrides these to host bind mounts
|
||||
# (/opt/drb/mosquitto-data, /opt/drb/mosquitto-certs — the latter fed by
|
||||
# the Caddy cert-sync systemd unit) — see docker-compose.prod.yml and
|
||||
# infra/ansible/roles/deploy/templates/. mosquitto_data holds
|
||||
# dynamic-security.json (node MQTT credentials, see app/internal/dynsec.py)
|
||||
# as well as the usual broker persistence state.
|
||||
mosquitto_data:
|
||||
mosquitto_certs:
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
MQTT_BROKER=mosquitto
|
||||
MQTT_PORT=1883
|
||||
# Use the c2-core credential — must match MQTT_C2_USER/MQTT_C2_PASS in the
|
||||
# top-level .env (which is passed to the mosquitto entrypoint)
|
||||
# top-level .env
|
||||
MQTT_USER=drb-c2-core
|
||||
MQTT_PASS=change-me-c2
|
||||
|
||||
# Same value as the top-level .env's MOSQUITTO_DYNSEC_PASSWORD — lets
|
||||
# c2-core log in as mosquitto's built-in dynsec "admin" client to
|
||||
# administer node MQTT credentials. See app/internal/dynsec.py.
|
||||
MQTT_DYNSEC_ADMIN_PASS=change-me-dynsec-admin-min-12-chars
|
||||
|
||||
# GCP — path to service account JSON inside the container
|
||||
GCP_CREDENTIALS_PATH=/app/gcp-key.json
|
||||
|
||||
@@ -28,6 +33,7 @@ SUMMARY_INTERVAL_MINUTES=15
|
||||
CORRELATION_WINDOW_HOURS=4
|
||||
EMBEDDING_SIMILARITY_THRESHOLD=0.82
|
||||
|
||||
# Auth — static key that edge nodes send as Bearer token on /upload
|
||||
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
|
||||
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
|
||||
# Generate with: openssl rand -hex 32
|
||||
NODE_API_KEY=
|
||||
ENROLLMENT_TOKEN=
|
||||
|
||||
@@ -9,6 +9,15 @@ class Settings(BaseSettings):
|
||||
mqtt_user: Optional[str] = None
|
||||
mqtt_pass: Optional[str] = None
|
||||
|
||||
# mosquitto's built-in dynamic-security plugin (see app/internal/dynsec.py).
|
||||
# "admin" is hardcoded by the plugin itself on first boot — not actually
|
||||
# configurable — kept as a named setting rather than a literal for
|
||||
# readability. mqtt_dynsec_admin_pass must equal the mosquitto
|
||||
# container's own MOSQUITTO_DYNSEC_PASSWORD env var (root .env /
|
||||
# root.env.j2) or c2-core can't administer node credentials at all.
|
||||
mqtt_dynsec_admin_user: str = "admin"
|
||||
mqtt_dynsec_admin_pass: Optional[str] = None
|
||||
|
||||
# GCP
|
||||
gcp_credentials_path: Optional[str] = None # None → uses ADC
|
||||
gcs_bucket: Optional[str] = None # None → audio upload disabled
|
||||
@@ -51,9 +60,30 @@ class Settings(BaseSettings):
|
||||
# Internal service key — allows server-side services (discord bot) to call C2 without Firebase
|
||||
service_key: Optional[str] = None
|
||||
|
||||
# Fleet-wide token edge nodes present to POST /nodes/enroll on first boot.
|
||||
# Not a per-node secret — see routers/enrollment.py for why a leaked copy
|
||||
# of this alone can't steal an already-approved node's key.
|
||||
enrollment_token: Optional[str] = None
|
||||
|
||||
# Upload size limit — reject audio files larger than this (bytes). Default 100 MB.
|
||||
upload_max_bytes: int = 100 * 1024 * 1024
|
||||
|
||||
# Public origin this API is reachable on, e.g. "https://api.drb.example.com".
|
||||
# Only used to build absolute call-audio playback links: an <audio src> is
|
||||
# fetched by the browser directly, so a relative path would resolve against
|
||||
# the frontend origin, not this one.
|
||||
public_api_url: Optional[str] = None
|
||||
|
||||
# How long a minted call-audio playback link stays valid. Long enough for a
|
||||
# browsing session, short enough that a copied link isn't durable access.
|
||||
audio_link_ttl_seconds: int = 6 * 60 * 60
|
||||
|
||||
# Two nodes hearing the same transmission start recording within about a
|
||||
# second of each other (measured across node-002/node-PI-2 on TG 9048).
|
||||
# 10s is generous against clock skew while staying well under the gap
|
||||
# between genuinely separate transmissions on a busy dispatch channel.
|
||||
duplicate_window_seconds: int = 10
|
||||
|
||||
# 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] = ["*"]
|
||||
|
||||
@@ -37,6 +37,41 @@ async def require_service_or_firebase_token(
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
|
||||
async def require_node_service_or_firebase_token(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
) -> dict:
|
||||
"""Accept a node's own API key in addition to a service key / Firebase token.
|
||||
|
||||
Edge nodes need to read ``/systems`` to build their OP25 config, but they
|
||||
hold neither a Firebase token nor the shared service key — only the
|
||||
per-node api_key that ``/upload`` already trusts. Without this they got a
|
||||
flat 401 and silently fell back to their stale offline cache, so a system
|
||||
edited in the UI never reached the node.
|
||||
|
||||
Unlike ``/upload``, the node sends no node_id alongside the bearer token,
|
||||
so the key is matched by querying ``node_keys`` for the value rather than
|
||||
fetching a known document. Mutating routes are unaffected: they carry
|
||||
their own ``require_admin_token`` dependency, so widening the router-level
|
||||
gate grants nodes read access only.
|
||||
"""
|
||||
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:
|
||||
return firebase_auth.verify_id_token(token)
|
||||
except Exception:
|
||||
pass
|
||||
# Deferred import: app.internal.firestore initialises firebase-admin at
|
||||
# import time, and auth.py is imported from module scope in the routers.
|
||||
from app.internal import firestore as fstore
|
||||
matches = await fstore.collection_list("node_keys", api_key=token)
|
||||
if matches:
|
||||
return {"node": True, "node_id": matches[0].get("node_id")}
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
Cross-node duplicate detection for call recordings.
|
||||
|
||||
Two edge nodes within range of the same trunked system both decode and upload
|
||||
the same transmission. That is the normal case for a distributed network, not
|
||||
an error — but without this, one transmission is transcribed twice, billed
|
||||
twice, and correlated twice, and the resulting incident shows two "units"
|
||||
where there was one.
|
||||
|
||||
CANONICAL SELECTION IS DELIBERATELY NOT "FIRST UPLOAD WINS". Upload order
|
||||
depends on encode time and network latency, so it varies run to run; picking
|
||||
by it would make which recording is authoritative non-deterministic. The call
|
||||
document is created from the MQTT call_start event *before* the upload
|
||||
arrives, so by upload time every node's document for the transmission already
|
||||
exists and can be ranked. Canonical is the earliest ``started_at``, breaking
|
||||
ties on ``call_id`` so both nodes independently reach the same verdict.
|
||||
|
||||
The loser keeps its audio — it is ~60 KB and may be the cleaner capture if the
|
||||
winner's node had a weak signal — but is excluded from the AI pipeline.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Awaitable, Callable, Optional
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
# Firestore is reached through an injected callable rather than a module-level
|
||||
# import. app.internal.firestore initialises firebase-admin at import time,
|
||||
# which needs credentials and the SDK present — so importing it here would make
|
||||
# this module unimportable in a unit test. Same reasoning as the deferred
|
||||
# import in app/internal/auth.py.
|
||||
QueryFn = Callable[[str, list], Awaitable[list[dict]]]
|
||||
|
||||
|
||||
def _parse_dt(value) -> Optional[datetime]:
|
||||
"""Firestore hands back Timestamp, datetime, or ISO string depending on writer."""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _is_canonical(call: dict, others: list[dict]) -> bool:
|
||||
"""True if `call` is the one recording of this transmission that should be processed."""
|
||||
started = _parse_dt(call.get("started_at"))
|
||||
call_id = call.get("call_id") or ""
|
||||
for other in others:
|
||||
other_started = _parse_dt(other.get("started_at"))
|
||||
if not other_started or not started:
|
||||
continue
|
||||
if other_started < started:
|
||||
return False
|
||||
if other_started == started and (other.get("call_id") or "") < call_id:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def find_duplicate_of(call: dict, query: Optional[QueryFn] = None) -> Optional[str]:
|
||||
"""Return the canonical call_id if `call` duplicates another node's recording.
|
||||
|
||||
Returns None when this call is the canonical one, or when there is nothing
|
||||
to compare against (single node in range, or the call lacks the talkgroup
|
||||
and system identifiers the match is keyed on).
|
||||
"""
|
||||
system_id = call.get("system_id")
|
||||
talkgroup_id = call.get("talkgroup_id")
|
||||
call_id = call.get("call_id")
|
||||
started = _parse_dt(call.get("started_at"))
|
||||
if not (system_id and talkgroup_id is not None and call_id and started):
|
||||
return None
|
||||
|
||||
if query is None:
|
||||
from app.internal import firestore as fstore
|
||||
query = fstore.collection_where
|
||||
|
||||
window = timedelta(seconds=settings.duplicate_window_seconds)
|
||||
try:
|
||||
# Range-scan on started_at, then filter the rest in Python — Firestore
|
||||
# allows a range on only one field per query.
|
||||
nearby = await query("calls", [
|
||||
("system_id", "==", system_id),
|
||||
("started_at", ">=", started - window),
|
||||
("started_at", "<=", started + window),
|
||||
])
|
||||
except Exception as e:
|
||||
# Never block an upload on dedup — worst case is the pre-existing
|
||||
# behaviour of processing both copies.
|
||||
logger.warning(f"Duplicate check failed for call {call_id}: {e}")
|
||||
return None
|
||||
|
||||
matches = [
|
||||
c for c in nearby
|
||||
if c.get("call_id") != call_id
|
||||
and c.get("talkgroup_id") == talkgroup_id
|
||||
and c.get("node_id") != call.get("node_id") # same node twice is a real repeat
|
||||
and not c.get("duplicate_of") # never point at another duplicate
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
if _is_canonical(call, matches):
|
||||
return None
|
||||
|
||||
canonical = min(
|
||||
matches,
|
||||
key=lambda c: (_parse_dt(c.get("started_at")) or started, c.get("call_id") or ""),
|
||||
)
|
||||
return canonical.get("call_id")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Client for mosquitto's built-in dynamic-security plugin.
|
||||
|
||||
WHY THIS EXISTS: MQTT-PUBLIC-AUTH-PLAN.md originally specced the
|
||||
mosquitto-go-auth plugin (HTTP backend). That project was archived by its
|
||||
maintainer 2025-08-06 ("no more changes") — unacceptable for a broker that's
|
||||
about to be reachable from the public internet, no way to get a CVE fix.
|
||||
Replaced with mosquitto 2.x's own `dynamic-security` plugin, which ships in
|
||||
and is maintained alongside the official eclipse-mosquitto image itself.
|
||||
|
||||
HOW DYNSEC WORKS (verified against plugin source on
|
||||
github.com/eclipse-mosquitto/mosquitto, 2026-08-16 — see citations inline;
|
||||
NOT verified by running anything, per instruction not to execute/deploy
|
||||
anything from this machine):
|
||||
|
||||
- The broker persists clients/roles/ACLs in a JSON file at
|
||||
`plugin_opt_config_file` (we point this at /mosquitto/data/, the same
|
||||
volume `persistence_location` already uses — one durable volume for all
|
||||
broker state, see docker-compose.yml).
|
||||
- Admin commands are plain MQTT publishes: JSON `{"commands": [...]}` to
|
||||
`$CONTROL/dynamic-security/v1` (source: plugin.c,
|
||||
`mosquitto_callback_register(plg_id, MOSQ_EVT_CONTROL,
|
||||
dynsec_control_callback, "$CONTROL/dynamic-security/v1", ...)`).
|
||||
Replies come back on `$CONTROL/dynamic-security/v1/response`
|
||||
(source: control.c, `#define RESPONSE_TOPIC
|
||||
"$CONTROL/dynamic-security/v1/response"`).
|
||||
- Per-command JSON fields (verified against clients.c / roles.c handlers
|
||||
and the plugin README):
|
||||
createClient: username, password, clientid, textname, textdescription,
|
||||
roles: [{rolename, priority}], groups: [...]
|
||||
modifyClient: same fields, username identifies the existing client
|
||||
deleteClient: username
|
||||
createRole: rolename, textname, textdescription,
|
||||
acls: [{acltype, topic, priority, allow}]
|
||||
acltype values: publishClientSend, publishClientReceive,
|
||||
subscribeLiteral, subscribePattern, unsubscribeLiteral,
|
||||
unsubscribePattern. %u (username) and %c (clientid) are valid
|
||||
substitutions in `topic` for every type except the two *Literal ones.
|
||||
- On first boot, if `plugin_opt_config_file` doesn't exist, the plugin
|
||||
bootstraps itself (config_init.c): reads env var
|
||||
`MOSQUITTO_DYNSEC_PASSWORD` (or `plugin_opt_password_init_file`) and
|
||||
creates a client literally named "admin" (hardcoded string, NOT
|
||||
configurable — verified in config_init.c's `client_add_admin()`) with
|
||||
three roles: `super-admin` (full pub/sub on `$CONTROL/#` — i.e. this is
|
||||
what makes a client capable of issuing further dynsec commands, and
|
||||
it's an ordinary role, nothing hardcoded beyond the initial grant),
|
||||
`sys-observe` ($SYS/# read-only), `topic-observe` (# read-only, NOT
|
||||
read-write). If MOSQUITTO_DYNSEC_PASSWORD is set (we always set it),
|
||||
no `democlient` demo account gets created — that only happens in the
|
||||
"no password provided, generate one randomly" path.
|
||||
- This is a genuine backend swap, not just config: `allow_anonymous
|
||||
false` plus the *absence* of `password_file`/`acl_file` directives
|
||||
means dynsec is the only auth backend registered — nothing else is
|
||||
there to conflict with it. (Inferred from plugin architecture — every
|
||||
mosquitto auth backend, built-in or plugin, registers the same
|
||||
basic-auth/ACL callback hooks; there's no "layering" mechanism, so
|
||||
without password_file/acl_file directives there is nothing else to
|
||||
check credentials or topics.)
|
||||
|
||||
WHAT COULD NOT BE VERIFIED (see also the plan doc + final report):
|
||||
- The exact JSON envelope of a *response* message (only individual
|
||||
command outcomes were confirmed: `mosquitto_control_command_reply(cmd,
|
||||
NULL)` for success, `mosquitto_control_command_reply(cmd, "error
|
||||
string")` for failure — the wrapping object shape, e.g. whether it's
|
||||
`{"responses": [{"command": ..., "error": ...}]}`, was not directly
|
||||
read from source). This client parses defensively: it treats ANY
|
||||
dict containing a non-null "error"/"Error" key anywhere in the
|
||||
top-level response payload as failure, presence of "already exists" in
|
||||
that string as an idempotent success, and a response with no such key
|
||||
within the timeout as success. A response timeout is always a hard
|
||||
failure (never assumed to mean success).
|
||||
- "Client already exists" was confirmed verbatim as createClient's
|
||||
error string; "already exists" for createRole is assumed analogous,
|
||||
not directly confirmed.
|
||||
|
||||
TWO-SOURCES-OF-TRUTH: Firestore's `node_keys` collection is the source of
|
||||
truth for node credentials (nothing changes there); dynamic-security.json
|
||||
is a derived cache the broker uses to authenticate. `reconcile_all()`
|
||||
rebuilds every approved node's dynsec client from Firestore and is called
|
||||
on every c2-core startup — so a lost/corrupted dynamic-security.json (e.g.
|
||||
volume wiped) self-heals on the next restart instead of silently locking
|
||||
out every node. `upsert_node_client()`/`delete_node_client()` are also
|
||||
called synchronously from routers/nodes.py's approve/reissue/delete
|
||||
handlers and raise on failure — those endpoints now fail loudly (502)
|
||||
instead of updating Firestore while dynsec silently didn't get the memo.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
import paho.mqtt.client as mqtt
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
CONTROL_TOPIC = "$CONTROL/dynamic-security/v1"
|
||||
RESPONSE_TOPIC = "$CONTROL/dynamic-security/v1/response"
|
||||
_RESPONSE_TIMEOUT_SECONDS = 10
|
||||
|
||||
# Role every approved node's dynsec client is attached to. %u = the
|
||||
# authenticated username (the node_id) — this is the fixed version of the
|
||||
# old `pattern readwrite nodes/%c/#`, where %c was the client-supplied,
|
||||
# spoofable client ID.
|
||||
NODE_ROLE = "node"
|
||||
# Role c2-core's own login gets, in addition to being handed the plugin's
|
||||
# built-in `super-admin` role (see grant_c2core_admin()). Mirrors the old
|
||||
# `topic readwrite #` superuser line.
|
||||
C2CORE_ROLE = "c2core"
|
||||
|
||||
|
||||
class DynsecError(Exception):
|
||||
"""A dynsec command was rejected, or no response arrived in time."""
|
||||
|
||||
|
||||
def _run_commands_sync(commands: list[dict], username: str, password: str) -> list[dict]:
|
||||
"""
|
||||
Blocking: open a short-lived MQTT connection, publish one or more dynsec
|
||||
commands, wait for the matching replies, disconnect. Always called via
|
||||
asyncio.to_thread — see the async wrappers below. A fresh connection per
|
||||
call (rather than reusing mqtt_handler's long-lived client) keeps this
|
||||
request/response exchange simple and isolated from that client's
|
||||
async-callback-driven subscribe state.
|
||||
"""
|
||||
responses: list[dict] = []
|
||||
done = {"got": False, "error": None}
|
||||
|
||||
def _on_connect(client, userdata, flags, reason_code, properties):
|
||||
if reason_code != 0:
|
||||
done["error"] = f"connect refused: {reason_code}"
|
||||
done["got"] = True
|
||||
return
|
||||
client.subscribe(RESPONSE_TOPIC, qos=1)
|
||||
client.publish(CONTROL_TOPIC, json.dumps({"commands": commands}), qos=1)
|
||||
|
||||
def _on_message(client, userdata, msg):
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode())
|
||||
except Exception:
|
||||
return
|
||||
responses.append(payload)
|
||||
done["got"] = True
|
||||
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=f"drb-c2-core-dynsec-{uuid.uuid4().hex[:8]}",
|
||||
)
|
||||
client.username_pw_set(username, password)
|
||||
client.on_connect = _on_connect
|
||||
client.on_message = _on_message
|
||||
|
||||
try:
|
||||
client.connect(settings.mqtt_broker, settings.mqtt_port, keepalive=30)
|
||||
except Exception as e:
|
||||
raise DynsecError(f"could not connect to mosquitto for dynsec command: {e}")
|
||||
|
||||
client.loop_start()
|
||||
deadline = time.monotonic() + _RESPONSE_TIMEOUT_SECONDS
|
||||
try:
|
||||
while not done["got"] and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
|
||||
if done["error"]:
|
||||
raise DynsecError(str(done["error"]))
|
||||
if not responses:
|
||||
raise DynsecError(
|
||||
f"no response on {RESPONSE_TOPIC} within {_RESPONSE_TIMEOUT_SECONDS}s for commands: "
|
||||
f"{[c.get('command') for c in commands]}"
|
||||
)
|
||||
return responses
|
||||
|
||||
|
||||
def _check_responses_ok(responses: list[dict], tolerate_already_exists: bool = False) -> None:
|
||||
"""Raise DynsecError unless every response payload is error-free (or,
|
||||
when tolerate_already_exists, only contains an 'already exists'-style
|
||||
error — see the module docstring's "could not verify" note on why this
|
||||
is a substring match rather than a structured error code check)."""
|
||||
for payload in responses:
|
||||
# Defensive: walk the payload looking for any *-cased "error" key
|
||||
# with a non-empty value, since the exact envelope shape wasn't
|
||||
# confirmed from source. Covers both a flat {"error": "..."} and a
|
||||
# {"responses": [{"error": "..."}]}-style wrapper.
|
||||
errors = _find_error_strings(payload)
|
||||
for err in errors:
|
||||
if tolerate_already_exists and "already exist" in err.lower():
|
||||
continue
|
||||
raise DynsecError(f"dynsec command failed: {err}")
|
||||
|
||||
|
||||
def _find_error_strings(obj) -> list[str]:
|
||||
found = []
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k.lower() == "error" and v:
|
||||
found.append(str(v))
|
||||
else:
|
||||
found.extend(_find_error_strings(v))
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
found.extend(_find_error_strings(item))
|
||||
return found
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async wrappers (all real work happens in the thread pool)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _admin_publish(commands: list[dict], tolerate_already_exists: bool = False) -> list[dict]:
|
||||
if not settings.mqtt_dynsec_admin_pass:
|
||||
raise DynsecError("MQTT_DYNSEC_ADMIN_PASS / mqtt_dynsec_admin_pass is not configured")
|
||||
responses = await asyncio.to_thread(
|
||||
_run_commands_sync, commands, settings.mqtt_dynsec_admin_user, settings.mqtt_dynsec_admin_pass
|
||||
)
|
||||
_check_responses_ok(responses, tolerate_already_exists=tolerate_already_exists)
|
||||
return responses
|
||||
|
||||
|
||||
async def ensure_roles_and_c2core_grant() -> None:
|
||||
"""
|
||||
Idempotent, safe to run on every startup:
|
||||
1. createRole "node" — nodes/%u/# publish+subscribe (both directions)
|
||||
2. createRole "c2core" — full "#" publish+subscribe, same reach the
|
||||
old `topic readwrite #` superuser line gave c2-core
|
||||
3. createClient/modifyClient drb-c2-core (settings.mqtt_user) with
|
||||
BOTH roles above AND the plugin's built-in "super-admin" role —
|
||||
i.e. c2-core's existing login is handed the actual dynsec admin
|
||||
role, not a separate identity, per the design decision.
|
||||
Runs over the dedicated "admin" bootstrap login (step 3 assigns
|
||||
super-admin to c2-core's own login for the record / future use, but
|
||||
THIS module still authenticates its own ongoing calls as "admin" — see
|
||||
the module docstring for why: it's the one identity guaranteed by
|
||||
mosquitto's own source to hold super-admin, so control-plane calls
|
||||
don't depend on step 3's grant having actually landed).
|
||||
"""
|
||||
node_acl_types = ["publishClientSend", "publishClientReceive", "subscribePattern", "unsubscribePattern"]
|
||||
await _admin_publish([{
|
||||
"command": "createRole",
|
||||
"rolename": NODE_ROLE,
|
||||
"textname": "DRB edge node — own namespace only",
|
||||
"acls": [{"acltype": t, "topic": "nodes/%u/#", "priority": 0, "allow": True} for t in node_acl_types],
|
||||
}], tolerate_already_exists=True)
|
||||
|
||||
c2core_acl_types = ["publishClientSend", "publishClientReceive", "subscribePattern", "unsubscribePattern"]
|
||||
await _admin_publish([{
|
||||
"command": "createRole",
|
||||
"rolename": C2CORE_ROLE,
|
||||
"textname": "DRB c2-core — full broker access",
|
||||
"acls": [{"acltype": t, "topic": "#", "priority": 0, "allow": True} for t in c2core_acl_types],
|
||||
}], tolerate_already_exists=True)
|
||||
|
||||
if not settings.mqtt_user or not settings.mqtt_pass:
|
||||
logger.warning("dynsec: MQTT_USER/MQTT_PASS not configured — skipping c2-core client grant")
|
||||
return
|
||||
|
||||
roles = [{"rolename": C2CORE_ROLE, "priority": 1}, {"rolename": "super-admin", "priority": 2}]
|
||||
try:
|
||||
await _admin_publish([{
|
||||
"command": "createClient",
|
||||
"username": settings.mqtt_user,
|
||||
"password": settings.mqtt_pass,
|
||||
"roles": roles,
|
||||
}])
|
||||
logger.info(f"dynsec: created client {settings.mqtt_user!r} with roles {C2CORE_ROLE}, super-admin")
|
||||
except DynsecError as e:
|
||||
if "already exist" in str(e).lower():
|
||||
await _admin_publish([{
|
||||
"command": "modifyClient",
|
||||
"username": settings.mqtt_user,
|
||||
"password": settings.mqtt_pass,
|
||||
"roles": roles,
|
||||
}])
|
||||
logger.info(f"dynsec: updated existing client {settings.mqtt_user!r} with roles {C2CORE_ROLE}, super-admin")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
async def upsert_node_client(node_id: str, api_key: str) -> None:
|
||||
"""Create or update a node's dynsec client — called from
|
||||
routers/nodes.py approve_node()/reissue_node_key(), and from
|
||||
reconcile_all() on startup. Raises DynsecError on failure; callers
|
||||
must not write Firestore as if this succeeded when it didn't."""
|
||||
try:
|
||||
await _admin_publish([{
|
||||
"command": "createClient",
|
||||
"username": node_id,
|
||||
"password": api_key,
|
||||
"roles": [{"rolename": NODE_ROLE, "priority": 1}],
|
||||
}])
|
||||
except DynsecError as e:
|
||||
if "already exist" not in str(e).lower():
|
||||
raise
|
||||
await _admin_publish([{
|
||||
"command": "modifyClient",
|
||||
"username": node_id,
|
||||
"password": api_key,
|
||||
"roles": [{"rolename": NODE_ROLE, "priority": 1}],
|
||||
}])
|
||||
|
||||
|
||||
async def delete_node_client(node_id: str) -> None:
|
||||
"""Best-effort: a node that was never enrolled in dynsec (or already
|
||||
removed) is treated as already-deleted, not an error."""
|
||||
try:
|
||||
await _admin_publish([{"command": "deleteClient", "username": node_id}])
|
||||
except DynsecError as e:
|
||||
if "not found" not in str(e).lower():
|
||||
raise
|
||||
|
||||
|
||||
async def reconcile_all() -> None:
|
||||
"""
|
||||
Rebuild dynsec state for every approved node from Firestore
|
||||
(node_keys is the source of truth). Called once at c2-core startup,
|
||||
after ensure_roles_and_c2core_grant(). Self-heals a lost/corrupted
|
||||
dynamic-security.json (e.g. volume wiped, or a prior approve/reissue's
|
||||
dynsec publish silently failed to persist for some other reason) —
|
||||
without this, a broker restart with an intact Firestore but an empty
|
||||
dynsec store would lock out every previously-approved node until
|
||||
someone noticed and manually re-approved each one.
|
||||
"""
|
||||
nodes = await fstore.collection_list("nodes", approval_status="approved")
|
||||
if not nodes:
|
||||
return
|
||||
ok, failed = 0, 0
|
||||
for node in nodes:
|
||||
node_id = node.get("node_id")
|
||||
if not node_id:
|
||||
continue
|
||||
key_doc = await fstore.doc_get("node_keys", node_id)
|
||||
if not key_doc or not key_doc.get("api_key"):
|
||||
logger.warning(f"dynsec reconcile: node {node_id!r} is approved but has no node_keys entry — skipping")
|
||||
continue
|
||||
try:
|
||||
await upsert_node_client(node_id, key_doc["api_key"])
|
||||
ok += 1
|
||||
except DynsecError as e:
|
||||
failed += 1
|
||||
logger.error(f"dynsec reconcile: failed to sync node {node_id!r}: {e}")
|
||||
logger.info(f"dynsec reconcile: {ok} node(s) synced, {failed} failed")
|
||||
@@ -287,7 +287,7 @@ async def _build_context(
|
||||
call_units = units if units is not None else (call_doc.get("units") or [])
|
||||
call_vehicles = vehicles if vehicles is not None else (call_doc.get("vehicles") or [])
|
||||
call_cleared = cleared_units if cleared_units is not None else (call_doc.get("cleared_units") or [])
|
||||
call_severity = call_doc.get("severity") or "unknown"
|
||||
call_severity = call_doc.get("severity") or "routine"
|
||||
coords = location_coords or call_doc.get("location_coords")
|
||||
is_thin_call = not call_units and not call_vehicles and not coords
|
||||
|
||||
@@ -326,6 +326,7 @@ def _run_decision(ctx: dict) -> dict:
|
||||
call_embedding = ctx["call_embedding"]
|
||||
call_units = ctx["call_units"]
|
||||
call_vehicles = ctx["call_vehicles"]
|
||||
call_severity = ctx["call_severity"]
|
||||
coords = ctx["coords"]
|
||||
is_thin_call = ctx["is_thin_call"]
|
||||
now = ctx["now"]
|
||||
@@ -718,6 +719,35 @@ def _run_decision(ctx: dict) -> dict:
|
||||
f"Correlator: inferred incident_type={resolved_type!r} from tags {tags} for call {call_id}"
|
||||
)
|
||||
|
||||
# Severity, not type, decides whether a call is incident-worthy.
|
||||
#
|
||||
# Requiring a concrete incident_type here meant a channel whose traffic never
|
||||
# classifies — transit/rail administration, records lookups, prisoner
|
||||
# transports — could never open a SECOND incident. Every later call on that
|
||||
# talkgroup then funnelled into whichever incident happened to be created
|
||||
# first, producing hour-long incidents made of unrelated transmissions
|
||||
# (2026-08-16: TG 9048, 28 calls / 49 min) alongside 30+ permanent orphans.
|
||||
#
|
||||
# Anything the extractor judged a real event, or that carries any concrete
|
||||
# content, now opens an incident under the neutral "other" type. Only
|
||||
# content-free routine traffic is still left for the thin path to attach.
|
||||
#
|
||||
# `call_units` and `location` are NOT substance. Radio protocol puts a unit
|
||||
# ID in essentially every transmission and a place name in most of them, so
|
||||
# including them made has_substance true almost always and the severity check
|
||||
# dead code — the first version of this gate turned "11-Victor, 72 at Holland
|
||||
# Station" into its own incident and left 37 of 50 incidents open, one call
|
||||
# each. A vehicle, a geocode, or a tag means the extractor found something
|
||||
# beyond who was speaking and where they stood.
|
||||
if not resolved_type:
|
||||
has_substance = bool(call_vehicles or coords or tags)
|
||||
if call_severity in ("minor", "moderate", "major") or has_substance:
|
||||
resolved_type = "other"
|
||||
logger.info(
|
||||
f"Correlator: call {call_id} has no incident_type — opening an 'other' "
|
||||
f"incident (severity={call_severity}, substance={has_substance})"
|
||||
)
|
||||
|
||||
if not resolved_type:
|
||||
return {"action": "orphan", "matched_incident": None, "incident_type": None, "corr_debug": corr_debug}
|
||||
|
||||
@@ -772,11 +802,13 @@ async def _apply_decision(decision: dict, ctx: dict) -> Optional[str]:
|
||||
|
||||
if action == "link":
|
||||
matched_incident = decision["matched_incident"]
|
||||
# A thin call attaches for context but does not count as incident activity.
|
||||
thin_link = (decision.get("corr_debug") or {}).get("corr_path") == "fast/thin"
|
||||
await _update_incident(
|
||||
matched_incident, call_id, talkgroup_id, system_id, tags,
|
||||
location, location_coords, call_units, call_vehicles, call_embedding, now,
|
||||
talkgroup_name=talkgroup_name, incident_type=incident_type,
|
||||
cleared_units=call_cleared,
|
||||
cleared_units=call_cleared, refresh_activity=not thin_link,
|
||||
)
|
||||
return matched_incident["incident_id"]
|
||||
|
||||
@@ -1149,6 +1181,7 @@ async def _update_incident(
|
||||
talkgroup_name: Optional[str] = None,
|
||||
incident_type: Optional[str] = None,
|
||||
cleared_units: Optional[list[str]] = None,
|
||||
refresh_activity: bool = True,
|
||||
) -> None:
|
||||
incident_id = inc["incident_id"]
|
||||
|
||||
@@ -1200,10 +1233,20 @@ async def _update_incident(
|
||||
"units_active": units_active,
|
||||
"units_cleared": units_cleared,
|
||||
"location_mentions": location_mentions,
|
||||
"updated_at": now.isoformat(),
|
||||
"summary_stale": True,
|
||||
**embedding_updates,
|
||||
}
|
||||
|
||||
# `updated_at` drives every recency gate in the fast path, so a content-free
|
||||
# status call must NOT refresh it. When it did, each "10-4" reset the idle
|
||||
# clock on the incident it attached to, which kept that incident permanently
|
||||
# "recent" and made it absorb the entire channel for as long as anyone kept
|
||||
# acknowledging. The incident now ages from its last SUBSTANTIVE call, and
|
||||
# thin traffic rides along without extending its life.
|
||||
if refresh_activity:
|
||||
updates["updated_at"] = now.isoformat()
|
||||
else:
|
||||
updates["last_thin_at"] = now.isoformat()
|
||||
if best_location:
|
||||
updates["location"] = best_location
|
||||
if best_coords:
|
||||
|
||||
@@ -42,7 +42,7 @@ Response format — a JSON object with a "scenes" array. Each scene:
|
||||
vehicles: list of vehicle descriptions mentioned
|
||||
units: list of unit IDs or officer numbers explicitly mentioned
|
||||
cleared_units: list of unit IDs that explicitly signal back-in-service or available in this recording
|
||||
severity: one of "minor" | "moderate" | "major" | "unknown"
|
||||
severity: one of "routine" | "minor" | "moderate" | "major"
|
||||
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
|
||||
@@ -52,7 +52,12 @@ Rules:
|
||||
- tags: describe WHAT happened, not WHERE. Specific, lowercase, hyphenated. Do not use location names, road names, talkgroup names, or place names as tags (wrong: "lower-macy's", "canvas-route-6", "route-202"; right: "suspect-search", "shoplifting", "vehicle-pursuit"). Do not repeat incident_type as a tag.
|
||||
- units: ONLY identifiers that appear verbatim in the transcript. Use speaker role inference to distinguish units being dispatched from units acknowledging — both should be included. Never infer or guess unit IDs not present in the text.
|
||||
- Do not invent details not present in the transcript.
|
||||
- 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 the channel is a police channel and nothing in the transcript contradicts it, return "police" — do NOT fall back to "other" merely because the transmission is administrative. Reserve "other" for traffic that genuinely belongs to no emergency service (rail operations, public works, utility coordination). Reserve "unknown" for transcripts too garbled to place at all.
|
||||
- severity: ALWAYS return one of the four values. Judge the underlying event, not how dramatic the words sound.
|
||||
"routine" — administrative/status traffic with no incident behind it: mileage and transport logging, radio checks, acknowledgements, shift changes, track block/power requests, records lookups.
|
||||
"minor" — a real but low-stakes call: lift assist, parking complaint, past-tense larceny report, noise complaint, welfare check.
|
||||
"moderate" — an active call needing a response now: MVA, alarm activation, disturbance in progress, medical call, suspicious person, road closure.
|
||||
"major" — life safety or major property loss: structure fire, vehicle pursuit, shots fired, entrapment, cardiac arrest, officer needing assistance.
|
||||
- ten_codes: interpret radio codes using the department reference provided below. Do not guess codes not listed.
|
||||
- resolved: true only when the scene explicitly signals "Code 4", "all clear", "10-42", "in custody", "patient transported", "fire out", "GOA", "negative contact", "scene clear".
|
||||
- cleared_units: only include units that explicitly stated their own back-in-service status in this recording (e.g. "Unit 7, 10-8", "Baker-1 available", "E-14 back in service", or the department ten-code for available/back-in-service listed above). Silence or absence of a unit is NOT clearance. A scene-wide Code 4 belongs in resolved=true, not here — cleared_units is for individual unit availability signals only.
|
||||
@@ -63,6 +68,13 @@ System: {system_id}
|
||||
Talkgroup: {talkgroup_name}
|
||||
{ten_codes_block}{vocabulary_block}{transcript_block}"""
|
||||
|
||||
# The incident_type enum offered to the model in EXTRACTION_PROMPT. Kept here
|
||||
# rather than only in the prompt so a model that invents a value cannot write it
|
||||
# into incident.type. "unknown" is deliberately absent — it is a real answer
|
||||
# from the model but not a usable type, and is normalised to None alongside
|
||||
# anything unrecognised.
|
||||
_VALID_INCIDENT_TYPES = frozenset({"fire", "ems", "police", "accident", "other"})
|
||||
|
||||
# Geographic bias radius for geocoding — half-width in degrees (~55 km)
|
||||
_GEO_DELTA = 0.5
|
||||
|
||||
@@ -183,7 +195,13 @@ async def extract_scenes(
|
||||
f"({len(transcript.split())} words), skipping"
|
||||
)
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {"skip_reason": "transcript_too_short"})
|
||||
# Severity is still recorded: a five-word acknowledgement is genuinely
|
||||
# routine traffic, and downstream code treats a missing severity as
|
||||
# "not yet processed" rather than "nothing happened".
|
||||
await fstore.doc_set("calls", call_id, {
|
||||
"skip_reason": "transcript_too_short",
|
||||
"severity": "routine",
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
@@ -213,13 +231,35 @@ async def extract_scenes(
|
||||
vehicles: list[str] = scene.get("vehicles") or []
|
||||
units: list[str] = scene.get("units") or []
|
||||
cleared_units: list[str] = scene.get("cleared_units") or []
|
||||
severity: str = scene.get("severity") or "unknown"
|
||||
# Every call carries a severity — it is the signal the correlator uses to
|
||||
# decide whether a call is incident-worthy at all, so it must never be
|
||||
# absent. "unknown" is a legacy value from before the prompt guaranteed
|
||||
# one of the four levels; normalise it to the bottom rung.
|
||||
severity: str = scene.get("severity") or "routine"
|
||||
if severity == "unknown":
|
||||
severity = "routine"
|
||||
resolved: bool = bool(scene.get("resolved", False))
|
||||
reassignment: bool = bool(scene.get("reassignment", False))
|
||||
transcript_corrected: Optional[str]= scene.get("transcript_corrected") or None
|
||||
segment_indices: Optional[list] = scene.get("segment_indices")
|
||||
|
||||
if incident_type in ("unknown", "other", ""):
|
||||
# "other" is a real classification (rail ops, public works, utility work)
|
||||
# and is kept. Collapsing it to None used to make the call untypeable,
|
||||
# and an untypeable call could never open an incident — see the creation
|
||||
# gate in incident_correlator._run_decision().
|
||||
#
|
||||
# Anything outside the enum is a model error, not a new category. The
|
||||
# value is written straight through to incident.type and rendered as the
|
||||
# incident title, so on 2026-08-16 a model that answered the severity
|
||||
# question in the type field produced an incident literally titled
|
||||
# "Routine — TGID 9563". Unrecognised values become None and fall to the
|
||||
# tag/severity path, which is the same treatment "unknown" already got.
|
||||
if incident_type not in _VALID_INCIDENT_TYPES:
|
||||
if incident_type and incident_type != "unknown":
|
||||
logger.warning(
|
||||
f"Intelligence: discarding invalid incident_type {incident_type!r} "
|
||||
f"(not in {sorted(_VALID_INCIDENT_TYPES)})"
|
||||
)
|
||||
incident_type = None
|
||||
|
||||
# Geocode this scene's location.
|
||||
@@ -399,11 +439,17 @@ async def _geocode_location(
|
||||
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"):
|
||||
# Reject only APPROXIMATE — a region/city boundary centroid, which is
|
||||
# what an ungeocodable string degrades to and is genuinely useless.
|
||||
#
|
||||
# ROOFTOP-only was too strict and emptied the map: dispatch names
|
||||
# places the way people speak, and Google returns GEOMETRIC_CENTER for
|
||||
# exactly those forms — intersections ("Lake Street and Veterans
|
||||
# Memorial Drive") and named POIs ("Brewster Station"). Both are
|
||||
# precise enough to plot and to proximity-match; requiring a street
|
||||
# address threw away nearly every real dispatch location, leaving only
|
||||
# numbered addresses geocoded.
|
||||
if location_type not in ("ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"):
|
||||
logger.info(
|
||||
f"Geocoding rejected '{location_str}' — imprecise result "
|
||||
f"(location_type={location_type!r}), returning None"
|
||||
|
||||
@@ -7,4 +7,12 @@ logging.basicConfig(
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
|
||||
# httpx logs every request at INFO as a full URL *including the query string*,
|
||||
# which puts API keys in plaintext in container logs — the Google Maps key was
|
||||
# leaking on every geocode call (`?address=...&key=AIza...`). Nothing here needs
|
||||
# per-request client logging, so drop httpx to WARNING; failures still surface
|
||||
# because the callers log their own errors.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
logger = logging.getLogger("drb-c2-core")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
import paho.mqtt.client as mqtt
|
||||
from app.config import settings
|
||||
@@ -33,6 +33,10 @@ class MQTTHandler:
|
||||
client.subscribe("nodes/+/checkin", qos=1)
|
||||
client.subscribe("nodes/+/status", qos=1)
|
||||
client.subscribe("nodes/+/metadata", qos=1)
|
||||
# TODO(mqtt-cutover): drop this subscribe once the enrollment/HTTP
|
||||
# credentials flow (routers/enrollment.py) is stable in prod and
|
||||
# node-26 (the one live node) has been migrated. See
|
||||
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6.
|
||||
client.subscribe("nodes/+/key_request", qos=1)
|
||||
logger.info("MQTT connected — subscribed to node topics.")
|
||||
else:
|
||||
@@ -94,6 +98,11 @@ class MQTTHandler:
|
||||
"last_seen": now.isoformat(),
|
||||
"assigned_system_id": None,
|
||||
"approval_status": "pending",
|
||||
"node_type": payload.get("node_type", "fixed"),
|
||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
"override_timeout_at": None,
|
||||
}
|
||||
await fstore.doc_set("nodes", node_id, doc, merge=False)
|
||||
logger.info(f"New node registered: {node_id} — pending admin approval.")
|
||||
@@ -111,6 +120,34 @@ class MQTTHandler:
|
||||
elif existing.get("approval_status") == "approved":
|
||||
# Approved but not yet configured — restore reachable status after reboot
|
||||
updates["status"] = "unconfigured"
|
||||
|
||||
node_type = payload.get("node_type", existing.get("node_type", "fixed"))
|
||||
enforce_timeout = payload.get("enforce_override_timeout", existing.get("enforce_override_timeout", True))
|
||||
is_overridden = payload.get("is_overridden", False)
|
||||
override_system_id = payload.get("override_system_id")
|
||||
|
||||
updates["node_type"] = node_type
|
||||
updates["enforce_override_timeout"] = enforce_timeout
|
||||
|
||||
if node_type == "portable":
|
||||
updates["is_overridden"] = False
|
||||
updates["override_system_id"] = None
|
||||
updates["override_timeout_at"] = None
|
||||
else:
|
||||
updates["is_overridden"] = is_overridden
|
||||
updates["override_system_id"] = override_system_id
|
||||
|
||||
if is_overridden:
|
||||
existing_timeout = existing.get("override_timeout_at")
|
||||
existing_override_id = existing.get("override_system_id")
|
||||
if enforce_timeout:
|
||||
if not existing_timeout or existing_override_id != override_system_id:
|
||||
updates["override_timeout_at"] = (now + timedelta(hours=24)).isoformat()
|
||||
else:
|
||||
updates["override_timeout_at"] = None
|
||||
else:
|
||||
updates["override_timeout_at"] = None
|
||||
|
||||
await fstore.doc_update("nodes", node_id, updates)
|
||||
|
||||
# NOTE: discord_connected in checkins is informational only — do NOT release the
|
||||
@@ -221,6 +258,11 @@ class MQTTHandler:
|
||||
# ------------------------------------------------------------------
|
||||
# Key request — re-deliver an existing approved key to a node that
|
||||
# lost its credentials (e.g. after a directory move / fresh volume)
|
||||
# TODO(mqtt-cutover): remove this handler + publish_node_key() below,
|
||||
# and the key_request subscribe above, in the separate post-cutover
|
||||
# pass called out in MQTT-PUBLIC-AUTH-PLAN.md. Left in place for now so
|
||||
# node-26 (currently live, using the shared-password MQTT path) keeps
|
||||
# working until the enrollment flow has replaced it in prod.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _handle_key_request(self, node_id: str):
|
||||
@@ -253,7 +295,10 @@ class MQTTHandler:
|
||||
logger.warning(f"MQTT not connected — could not push config to {node_id}")
|
||||
|
||||
def publish_node_key(self, node_id: str, api_key: str):
|
||||
"""Publish the provisioned API key to the node (retained so it survives reconnects)."""
|
||||
"""Publish the provisioned API key to the node (retained so it survives reconnects).
|
||||
TODO(mqtt-cutover): dead once nodes.py's callers switch to the HTTP
|
||||
credentials poll (routers/enrollment.py) exclusively. See note above
|
||||
_handle_key_request."""
|
||||
topic = f"nodes/{node_id}/api_key"
|
||||
if self._client and self._connected:
|
||||
self._client.publish(topic, json.dumps({"api_key": api_key}), qos=2, retain=True)
|
||||
|
||||
@@ -55,3 +55,35 @@ async def _sweep():
|
||||
logger.info(f"Node {node_id} marked offline (last seen: {last_seen.isoformat()})")
|
||||
from app.routers.tokens import release_token
|
||||
await release_token(node_id)
|
||||
continue
|
||||
|
||||
# Check for expired system overrides (only for fixed nodes with timeout enforced)
|
||||
override_timeout_raw = node.get("override_timeout_at")
|
||||
enforce_timeout = node.get("enforce_override_timeout", True)
|
||||
node_type = node.get("node_type", "fixed")
|
||||
|
||||
if override_timeout_raw and enforce_timeout and node_type != "portable":
|
||||
if isinstance(override_timeout_raw, str):
|
||||
override_timeout = datetime.fromisoformat(override_timeout_raw)
|
||||
else:
|
||||
override_timeout = override_timeout_raw
|
||||
if override_timeout.tzinfo is None:
|
||||
override_timeout = override_timeout.replace(tzinfo=timezone.utc)
|
||||
|
||||
if datetime.now(timezone.utc) > override_timeout:
|
||||
node_id = node.get("node_id")
|
||||
assigned_system_id = node.get("assigned_system_id")
|
||||
logger.info(f"Node {node_id} override has expired. Reverting to system {assigned_system_id}.")
|
||||
|
||||
# Push the original assigned config if it exists
|
||||
if assigned_system_id:
|
||||
system_doc = await fstore.doc_get("systems", assigned_system_id)
|
||||
if system_doc:
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
mqtt_handler.push_config(node_id, system_doc)
|
||||
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
"override_timeout_at": None,
|
||||
})
|
||||
|
||||
@@ -54,6 +54,13 @@ async def _run_sweep_pass() -> None:
|
||||
c for c in recent_ended
|
||||
if not c.get("incident_ids") and not c.get("incident_id")
|
||||
and not c.get("corr_path") # skip calls already exhausted
|
||||
and not c.get("duplicate_of") # another node's copy — never processed by design
|
||||
# /upload deliberately skips correlation for garbage and too-short
|
||||
# transcripts (routers/upload.py) because they carry no signal. The sweep
|
||||
# was not applying the same guard, so those fragments came back in through
|
||||
# the thin path minutes later and attached to whatever was most recent —
|
||||
# a second route into the over-merge the thin fix above addresses.
|
||||
and not c.get("skip_reason")
|
||||
and c.get("corr_sweep_count", 0) < MAX_SWEEP_ATTEMPTS
|
||||
]
|
||||
|
||||
|
||||
@@ -1,9 +1,40 @@
|
||||
"""
|
||||
Call-audio storage and playback links.
|
||||
|
||||
TWO THINGS THIS MODULE DELIBERATELY DOES NOT DO ANY MORE:
|
||||
|
||||
1. It does not return a GCS *signed* URL from the upload path. Signing needs a
|
||||
service-account private key, and the deployed VM runs on Application Default
|
||||
Credentials with no key file (see ansible c2-core.env.j2). The old code
|
||||
silently fell back to returning a bare ``gs://`` URI, which broke two things
|
||||
at once: browsers can't fetch a gs:// URI, so no recording was ever
|
||||
playable, and ``_public_url_to_gcs_uri`` in upload.py returned None for it,
|
||||
so the transcription step was skipped without logging anything at all.
|
||||
|
||||
2. It does not store a long-lived URL on the call document. What gets persisted
|
||||
is the canonical ``gs://`` object location; a short-lived playback link is
|
||||
minted on read instead. Nothing durable and nothing loggable is a credential.
|
||||
|
||||
Playback goes through c2-core's own /media route rather than GCS directly,
|
||||
because an <audio src> cannot carry an Authorization header — so the link
|
||||
itself has to be the credential. It is a plain HMAC over (call_id, expiry)
|
||||
keyed by SERVICE_KEY, which costs no network round-trip, keeps the bucket
|
||||
fully private, and needs no IAM change on the VM's service account.
|
||||
"""
|
||||
import asyncio
|
||||
import datetime
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
# Domain separation: the audio-link key is derived from SERVICE_KEY rather than
|
||||
# being SERVICE_KEY itself, so a leaked playback link can never be replayed as
|
||||
# a service-key bearer token against the rest of the API.
|
||||
_KEY_CONTEXT = b"drb-audio-link-v1"
|
||||
|
||||
|
||||
def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
"""Return a safe GCS object name derived from the call_id.
|
||||
@@ -12,7 +43,6 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
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"
|
||||
@@ -20,38 +50,140 @@ def _safe_audio_filename(filename: str, call_id: str) -> str:
|
||||
|
||||
|
||||
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 the canonical gs:// URI, or None if disabled."""
|
||||
if not settings.gcs_bucket:
|
||||
logger.info("GCS_BUCKET not configured — skipping audio upload.")
|
||||
return None
|
||||
|
||||
def _upload() -> str:
|
||||
safe_name = _safe_audio_filename(filename, call_id)
|
||||
blob_path = f"calls/{safe_name}"
|
||||
|
||||
def _upload() -> None:
|
||||
from google.cloud import storage
|
||||
from google.oauth2 import service_account as sa
|
||||
if settings.gcp_credentials_path:
|
||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
signing_creds = sa.Credentials.from_service_account_file(settings.gcp_credentials_path)
|
||||
else:
|
||||
client = storage.Client()
|
||||
signing_creds = None
|
||||
bucket = client.bucket(settings.gcs_bucket)
|
||||
safe_name = _safe_audio_filename(filename, call_id)
|
||||
blob = bucket.blob(f"calls/{safe_name}")
|
||||
blob = client.bucket(settings.gcs_bucket).blob(blob_path)
|
||||
blob.upload_from_string(data, content_type="audio/mpeg")
|
||||
if signing_creds:
|
||||
return blob.generate_signed_url(
|
||||
version="v2",
|
||||
expiration=datetime.timedelta(days=365),
|
||||
method="GET",
|
||||
credentials=signing_creds,
|
||||
)
|
||||
# Fallback: return the gs:// URI (no public access)
|
||||
return f"gs://{settings.gcs_bucket}/calls/{filename}"
|
||||
|
||||
try:
|
||||
url = await asyncio.to_thread(_upload)
|
||||
logger.info(f"Audio uploaded: {url}")
|
||||
return url
|
||||
await asyncio.to_thread(_upload)
|
||||
except Exception as e:
|
||||
logger.error(f"GCS upload failed: {e}")
|
||||
return None
|
||||
|
||||
gcs_uri = f"gs://{settings.gcs_bucket}/{blob_path}"
|
||||
logger.info(f"Audio uploaded: {gcs_uri}")
|
||||
return gcs_uri
|
||||
|
||||
|
||||
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||
if not bucket_name:
|
||||
return None
|
||||
|
||||
def _download() -> bytes:
|
||||
from google.cloud import storage
|
||||
if settings.gcp_credentials_path:
|
||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
else:
|
||||
client = storage.Client()
|
||||
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_download)
|
||||
except Exception as e:
|
||||
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def split_gcs_uri(gcs_uri: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""``gs://bucket/path/to.mp3`` → ``("bucket", "path/to.mp3")``."""
|
||||
if not gcs_uri or not gcs_uri.startswith("gs://"):
|
||||
return None, None
|
||||
without_scheme = gcs_uri[len("gs://"):]
|
||||
if "/" not in without_scheme:
|
||||
return None, None
|
||||
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||
return bucket_name, blob_path
|
||||
|
||||
|
||||
def gcs_uri_for_call(call: dict) -> Optional[str]:
|
||||
"""Resolve the audio object for a call document.
|
||||
|
||||
Prefers the canonical ``audio_gcs_uri`` written by /upload. Falls back to
|
||||
reconstructing the object name from the call_id for documents written
|
||||
before this module was fixed: those stored a gs:// URI built from the
|
||||
*client-supplied* filename, which never matched the object actually
|
||||
written (always ``calls/{call_id}.mp3``). Reconstructing rather than
|
||||
trusting the stored value is what makes every pre-existing recording
|
||||
playable again without a data migration.
|
||||
"""
|
||||
uri = call.get("audio_gcs_uri")
|
||||
if uri:
|
||||
return uri
|
||||
call_id = call.get("call_id")
|
||||
if call.get("audio_url") and call_id and settings.gcs_bucket:
|
||||
return f"gs://{settings.gcs_bucket}/calls/{call_id}.mp3"
|
||||
return None
|
||||
|
||||
|
||||
def _link_key() -> Optional[bytes]:
|
||||
if not settings.service_key:
|
||||
return None
|
||||
return hmac.new(settings.service_key.encode("utf-8"), _KEY_CONTEXT, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def sign_audio_link(call_id: str, expires_at: int) -> Optional[str]:
|
||||
key = _link_key()
|
||||
if not key:
|
||||
return None
|
||||
msg = f"{call_id}:{expires_at}".encode("utf-8")
|
||||
return hmac.new(key, msg, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_audio_link(call_id: str, expires_at: int, signature: str) -> bool:
|
||||
if expires_at < int(time.time()):
|
||||
return False
|
||||
expected = sign_audio_link(call_id, expires_at)
|
||||
if not expected:
|
||||
return False
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
_warned_no_service_key = False
|
||||
_warned_no_public_url = False
|
||||
|
||||
|
||||
def playback_url(call: dict) -> Optional[str]:
|
||||
"""Mint a short-lived playback URL for a call, or None if it has no audio."""
|
||||
global _warned_no_service_key, _warned_no_public_url
|
||||
|
||||
call_id = call.get("call_id")
|
||||
if not call_id or not gcs_uri_for_call(call):
|
||||
return None
|
||||
|
||||
expires_at = int(time.time()) + settings.audio_link_ttl_seconds
|
||||
signature = sign_audio_link(call_id, expires_at)
|
||||
if not signature:
|
||||
if not _warned_no_service_key:
|
||||
logger.error("SERVICE_KEY not set — call audio cannot be served.")
|
||||
_warned_no_service_key = True
|
||||
return None
|
||||
|
||||
# Loud rather than silent: a relative link here would 404 against the
|
||||
# frontend origin, which is the exact failure mode this module exists to
|
||||
# stop repeating. Deploy via ansible so c2-core.env.j2 sets PUBLIC_API_URL.
|
||||
if not settings.public_api_url and not _warned_no_public_url:
|
||||
logger.error("PUBLIC_API_URL not set — call audio links will be relative and will not resolve.")
|
||||
_warned_no_public_url = True
|
||||
|
||||
base = (settings.public_api_url or "").rstrip("/")
|
||||
return f"{base}/media/calls/{call_id}/audio?exp={expires_at}&sig={signature}"
|
||||
|
||||
|
||||
def with_playback_url(call: dict) -> dict:
|
||||
"""Return the call dict with a freshly minted ``audio_url``."""
|
||||
return {**call, "audio_url": playback_url(call)}
|
||||
|
||||
@@ -5,6 +5,7 @@ Audio is downloaded from GCS then sent to the Whisper API. Falls back to
|
||||
returning None on any failure so the intelligence pipeline can still run.
|
||||
"""
|
||||
import asyncio
|
||||
import re
|
||||
import tempfile
|
||||
import os
|
||||
from typing import Optional
|
||||
@@ -14,15 +15,83 @@ from app.internal import firestore as fstore
|
||||
# Whisper treats `prompt` as preceding transcript text, not instructions.
|
||||
# Writing it as actual radio speech primes the vocabulary toward P25 codes
|
||||
# and phrasing before the model hears the audio.
|
||||
#
|
||||
# DO NOT put an enumerated run of ten-codes in here. The original version of
|
||||
# this prompt opened with "10-4. 10-23. 10-20. 10-97. 10-8. ..." and Whisper,
|
||||
# treating that as text it should continue, filled noisy or silent audio with
|
||||
# sequences like "10-4. 10-5. 10-6. ... 10-99." Those hallucinations sailed
|
||||
# straight past the no_speech_prob filter below, because the model is highly
|
||||
# confident the continuation it invented is speech. Codes appear here only
|
||||
# singly and inside a sentence, where there is no series to extend.
|
||||
_WHISPER_PROMPT = (
|
||||
"10-4. 10-23. 10-20. 10-97. 10-8. 10-7. 10-34. 10-50. 10-52. "
|
||||
"Post 4, I'm out. Post 3. En route. On scene. In route. "
|
||||
"Copy. Negative. Stand by. Be advised. Go ahead. "
|
||||
"Units responding. Dispatch. Talkgroup. "
|
||||
"Engine. Ladder. Medic. Rescue. Car. Unit. "
|
||||
"MVA. MVC. Structure fire. Working fire."
|
||||
"Dispatch, go ahead. Copy that, en route. Show me on scene. "
|
||||
"Be advised, units responding. Negative, stand by. "
|
||||
"Post 4, I'm out. Received, thank you. "
|
||||
"Engine and ladder responding to a structure fire. "
|
||||
"Medic on scene with one patient. "
|
||||
"Vehicle accident with injuries, MVA. "
|
||||
"Show me 10-8 and clear."
|
||||
)
|
||||
|
||||
# Degenerate-output detection (see _is_degenerate). Tuned to catch Whisper's
|
||||
# repetition failure mode without discarding terse but real radio traffic.
|
||||
_MIN_CODES_FOR_RUN = 6 # ten-codes needed before a run is even considered
|
||||
_RUN_RATIO = 0.7 # share of consecutive pairs that must step by +1
|
||||
_MIN_SEGMENTS_FOR_REPEAT = 6 # segments needed before repetition is considered
|
||||
_UNIQUE_RATIO = 0.25 # unique/total segment texts at or below this is degenerate
|
||||
_MAX_PHRASE_REPEATS = 8 # identical consecutive phrase repeats allowed in one blob
|
||||
|
||||
|
||||
def _ten_code_run(text: str) -> bool:
|
||||
"""True if the text is mostly a counting run of ten-codes.
|
||||
|
||||
Real traffic uses ten-codes constantly, but never in ascending order — a
|
||||
dispatcher does not say "10-4, 10-5, 10-6". An arithmetic series is the
|
||||
signature of Whisper continuing a pattern rather than hearing one.
|
||||
"""
|
||||
numbers = [int(n) for n in re.findall(r"\b10-(\d{1,2})\b", text)]
|
||||
if len(numbers) < _MIN_CODES_FOR_RUN:
|
||||
return False
|
||||
steps = [b - a for a, b in zip(numbers, numbers[1:])]
|
||||
ascending = sum(1 for s in steps if s == 1)
|
||||
return steps and (ascending / len(steps)) >= _RUN_RATIO
|
||||
|
||||
|
||||
def _phrase_loop(text: str) -> bool:
|
||||
"""True if one short phrase repeats far more than speech plausibly would.
|
||||
|
||||
Catches the other repetition mode, e.g. "Dispatch, do you copy?" emitted
|
||||
a dozen times over static.
|
||||
"""
|
||||
parts = [p.strip().lower() for p in re.split(r"[.!?]", text) if p.strip()]
|
||||
if len(parts) <= _MAX_PHRASE_REPEATS:
|
||||
return False
|
||||
repeats = 1
|
||||
for prev, cur in zip(parts, parts[1:]):
|
||||
repeats = repeats + 1 if cur == prev else 1
|
||||
if repeats > _MAX_PHRASE_REPEATS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_degenerate(text: str, segments: list[dict]) -> bool:
|
||||
"""True if a transcript looks like Whisper output rather than radio traffic.
|
||||
|
||||
Applied AFTER the per-segment no_speech_prob filter, which does not catch
|
||||
these: the model reports high confidence in text it invented by continuing
|
||||
a pattern, so the only tell is the shape of the output itself.
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
if _ten_code_run(text) or _phrase_loop(text):
|
||||
return True
|
||||
# Near-identical segments repeated across the whole recording.
|
||||
if len(segments) >= _MIN_SEGMENTS_FOR_REPEAT:
|
||||
normalised = {s["text"].strip().lower() for s in segments}
|
||||
if len(normalised) / len(segments) <= _UNIQUE_RATIO:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def transcribe_call(
|
||||
call_id: str,
|
||||
@@ -76,7 +145,10 @@ def _sync_transcribe(
|
||||
|
||||
if not settings.openai_api_key:
|
||||
logger.warning("OPENAI_API_KEY not set — transcription disabled.")
|
||||
return None
|
||||
# Tuple, not a bare None: the caller unpacks two values, so returning
|
||||
# None here raised a TypeError that surfaced as a misleading
|
||||
# "Transcription failed" instead of the real missing-key warning.
|
||||
return None, []
|
||||
|
||||
without_scheme = gcs_uri[len("gs://"):]
|
||||
bucket_name, blob_path = without_scheme.split("/", 1)
|
||||
@@ -145,11 +217,17 @@ def _sync_transcribe(
|
||||
# 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
|
||||
if _is_degenerate(text or "", segments):
|
||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
||||
return None, []
|
||||
return text, segments
|
||||
else:
|
||||
# json format returns just {"text": "..."} — no segments or timestamps.
|
||||
# Intelligence extraction falls back to treating the whole transcript as one block.
|
||||
text = (response.text or "").strip() or None
|
||||
if _is_degenerate(text or "", []):
|
||||
logger.info(f"Discarded hallucinated transcript for {gcs_uri}: {(text or '')[:80]!r}")
|
||||
return None, []
|
||||
return text, []
|
||||
finally:
|
||||
try:
|
||||
|
||||
+41
-2
@@ -9,8 +9,14 @@ from app.internal.summarizer import summarizer_loop
|
||||
from app.internal.vocabulary_learner import vocabulary_induction_loop
|
||||
from app.internal.recorrelation_sweep import recorrelation_loop
|
||||
from app.config import settings
|
||||
from app.internal.auth import require_firebase_token, require_service_or_firebase_token
|
||||
from app.internal.auth import (
|
||||
require_firebase_token,
|
||||
require_service_or_firebase_token,
|
||||
require_node_service_or_firebase_token,
|
||||
)
|
||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||
from app.routers import enrollment, media
|
||||
from app.internal import dynsec
|
||||
from app.internal import firestore as fstore
|
||||
|
||||
|
||||
@@ -36,6 +42,22 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("DRB C2 Core starting.")
|
||||
await _release_orphaned_tokens()
|
||||
|
||||
# dynsec bootstrap + reconcile — must happen before mqtt_handler.connect()
|
||||
# so that by the time the app is serving requests, c2-core's own dynsec
|
||||
# client/roles exist and every already-approved node's dynsec client
|
||||
# matches Firestore (see app/internal/dynsec.py "TWO-SOURCES-OF-TRUTH").
|
||||
# Non-fatal by design: if the broker or MQTT_DYNSEC_ADMIN_PASS isn't
|
||||
# reachable/configured yet (e.g. first-ever deploy, mosquitto still
|
||||
# starting), log loudly and keep booting rather than crash-looping
|
||||
# c2-core itself — mqtt_handler.connect() below has its own retry loop
|
||||
# and node approval/reissue endpoints fail loudly on their own if dynsec
|
||||
# calls fail later, so nothing here is silently swallowed forever.
|
||||
try:
|
||||
await dynsec.ensure_roles_and_c2core_grant()
|
||||
await dynsec.reconcile_all()
|
||||
except dynsec.DynsecError as e:
|
||||
logger.error(f"dynsec bootstrap/reconcile failed — node approval/reissue will fail until this is resolved: {e}")
|
||||
|
||||
await mqtt_handler.connect()
|
||||
sweeper_task = asyncio.create_task(sweeper_loop())
|
||||
summarizer_task = asyncio.create_task(summarizer_loop())
|
||||
@@ -63,7 +85,11 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(systems.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
# systems is the one router edge nodes read directly (system_cacher.py builds
|
||||
# the OP25 config from it), so its gate also accepts a per-node api_key. The
|
||||
# write routes inside carry their own require_admin_token, so nodes get read
|
||||
# access only.
|
||||
app.include_router(systems.router, dependencies=[Depends(require_node_service_or_firebase_token)])
|
||||
app.include_router(calls.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(tokens.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||
@@ -74,6 +100,19 @@ app.include_router(upload.router) # auth is per-node, handled inline
|
||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||
app.include_router(users.router) # auth: admin only
|
||||
app.include_router(links.router) # auth is per-endpoint (generate: firebase, resolve: service key)
|
||||
app.include_router(enrollment.router) # public; auth is the enrollment/pickup-secret tokens, checked inline
|
||||
# public by necessity — an <audio src> can't send a bearer token, so the
|
||||
# short-lived HMAC in the URL is the credential. Checked inline in media.py.
|
||||
app.include_router(media.router)
|
||||
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
||||
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||
# broker — see MQTT-PUBLIC-AUTH-PLAN.md. MQTT auth is now mosquitto's own
|
||||
# built-in dynamic-security plugin (app/internal/dynsec.py talks to it over
|
||||
# MQTT control topics, not HTTP), so there is nothing at /internal/mqtt/*
|
||||
# anymore. Caddy's Caddyfile.j2 still 404s /internal/* on api.<domain> as
|
||||
# defence in depth even though nothing calls it today — cheap insurance
|
||||
# against a future /internal/* route being added and forgotten there.
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -16,6 +16,11 @@ class NodeRecord(BaseModel):
|
||||
configured: bool = False
|
||||
last_seen: Optional[datetime] = None
|
||||
assigned_system_id: Optional[str] = None
|
||||
node_type: str = "fixed" # fixed or portable
|
||||
enforce_override_timeout: bool = True
|
||||
is_overridden: bool = False
|
||||
override_system_id: Optional[str] = None
|
||||
override_timeout_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CommandPayload(BaseModel):
|
||||
@@ -57,7 +62,9 @@ class CallRecord(BaseModel):
|
||||
srcaddr: Optional[str] = None
|
||||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
audio_url: Optional[str] = None
|
||||
audio_gcs_uri: Optional[str] = None # canonical gs:// object location
|
||||
audio_url: Optional[str] = None # NOT stored — minted per read, see internal/storage.py
|
||||
duplicate_of: Optional[str] = None # another node recorded this same transmission first
|
||||
transcript: Optional[str] = None # populated later by STT
|
||||
incident_ids: List[str] = [] # one per scene detected in the recording
|
||||
location: Optional[Dict[str, float]] = None # {lat, lng}
|
||||
|
||||
@@ -132,6 +132,7 @@ async def debug_correlation(
|
||||
_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 not c.get("duplicate_of") # another node's copy — never meant to correlate
|
||||
and c.get("system_id") in ai_systems
|
||||
]
|
||||
orphans.sort(key=lambda c: c.get("started_at", ""), reverse=True)
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.auth import require_admin_token
|
||||
from app.internal.storage import gcs_uri_for_call, with_playback_url
|
||||
|
||||
|
||||
class TranscriptUpdate(BaseModel):
|
||||
@@ -25,7 +26,9 @@ async def list_calls(
|
||||
filters["status"] = status
|
||||
if system_id:
|
||||
filters["system_id"] = system_id
|
||||
return await fstore.collection_list("calls", **filters)
|
||||
calls = await fstore.collection_list("calls", **filters)
|
||||
# audio_url is not stored — it's a short-lived signed link minted per read.
|
||||
return [with_playback_url(c) for c in calls]
|
||||
|
||||
|
||||
@router.get("/{call_id}")
|
||||
@@ -33,7 +36,7 @@ async def get_call(call_id: str):
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
return call
|
||||
return with_playback_url(call)
|
||||
|
||||
|
||||
@router.post("/{call_id}/reprocess")
|
||||
@@ -43,10 +46,9 @@ async def reprocess_call(call_id: str, background_tasks: BackgroundTasks):
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
|
||||
from app.routers.upload import _run_intelligence_pipeline, _public_url_to_gcs_uri
|
||||
from app.routers.upload import _run_intelligence_pipeline
|
||||
|
||||
audio_url = call.get("audio_url")
|
||||
gcs_uri = _public_url_to_gcs_uri(audio_url) if audio_url else None
|
||||
gcs_uri = gcs_uri_for_call(call)
|
||||
|
||||
background_tasks.add_task(
|
||||
_run_intelligence_pipeline,
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
Node self-enrollment — the public replacement for the old shared-MQTT-
|
||||
password flow (see MQTT-PUBLIC-AUTH-PLAN.md "Enrollment flow").
|
||||
|
||||
1. POST /nodes/enroll (X-Enrollment-Token: <fleet-wide token>)
|
||||
First-boot node upserts itself as `approval_status: pending` and gets
|
||||
back a one-time pickup_secret. Only its hash is persisted.
|
||||
2. GET /nodes/{id}/credentials (X-Pickup-Secret: <secret from step 1>)
|
||||
Node polls this with backoff until an admin approves it in the
|
||||
frontend (existing nodes.py approve_node() flow — unchanged, still
|
||||
writes node_keys/{id}.api_key) and then reads its api_key back.
|
||||
|
||||
These two endpoints are meant to be public (unlike the dynsec control-plane
|
||||
traffic in app/internal/dynsec.py, which never leaves the docker-internal
|
||||
MQTT bridge) — that's the whole point of moving off WireGuard-per-node.
|
||||
Auth is the token headers checked inline below, not the app-wide
|
||||
Firebase/service-key dependency the rest of routers/nodes.py uses.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Header, Request
|
||||
from pydantic import BaseModel
|
||||
from app.config import settings
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/nodes", tags=["enrollment"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-source-IP token bucket for /nodes/enroll.
|
||||
#
|
||||
# c2-core has no rate-limiting dependency anywhere today (see
|
||||
# MQTT-PUBLIC-AUTH-PLAN.md); this is a deliberately small (~30 line)
|
||||
# in-memory limiter rather than a new library. Known limitations:
|
||||
# - per-process: with more than one c2-core instance, each has its own
|
||||
# bucket, so real throughput is (limit x instance count). Fine today —
|
||||
# there is exactly one instance.
|
||||
# - resets on every restart/redeploy — not persisted anywhere.
|
||||
# Good enough to blunt casual guessing of node_ids against the fleet token;
|
||||
# not a substitute for a real edge/WAF rate limiter if this endpoint is
|
||||
# ever seriously targeted.
|
||||
# ---------------------------------------------------------------------------
|
||||
class _TokenBucket:
|
||||
def __init__(self, capacity: int, refill_per_sec: float):
|
||||
self.capacity = capacity
|
||||
self.refill_per_sec = refill_per_sec
|
||||
self._buckets: dict[str, tuple[float, float]] = {} # key -> (tokens, last_refill_ts)
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
tokens, last_ts = self._buckets.get(key, (float(self.capacity), now))
|
||||
tokens = min(self.capacity, tokens + (now - last_ts) * self.refill_per_sec)
|
||||
if tokens < 1:
|
||||
self._buckets[key] = (tokens, now)
|
||||
return False
|
||||
self._buckets[key] = (tokens - 1, now)
|
||||
return True
|
||||
|
||||
|
||||
# Burst of 5, refilling 1/minute — enrollment is a first-boot, once-per-node
|
||||
# event, so a legitimate node never needs more than a handful of attempts.
|
||||
_enroll_limiter = _TokenBucket(capacity=5, refill_per_sec=1 / 60)
|
||||
|
||||
|
||||
def _hash_secret(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode()).hexdigest()
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
node_id: str
|
||||
name: Optional[str] = None
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
|
||||
|
||||
class EnrollResponse(BaseModel):
|
||||
node_id: str
|
||||
pickup_secret: str
|
||||
approval_status: str
|
||||
|
||||
|
||||
@router.post("/enroll", response_model=EnrollResponse)
|
||||
async def enroll_node(
|
||||
body: EnrollRequest,
|
||||
request: Request,
|
||||
x_enrollment_token: Optional[str] = Header(None),
|
||||
):
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if not _enroll_limiter.allow(client_ip):
|
||||
raise HTTPException(429, "Too many enrollment attempts. Try again later.")
|
||||
|
||||
if not settings.enrollment_token:
|
||||
raise HTTPException(503, "Enrollment is not configured on this server.")
|
||||
if not x_enrollment_token or not secrets.compare_digest(x_enrollment_token, settings.enrollment_token):
|
||||
logger.warning(f"Enroll 401: bad/missing enrollment token from {client_ip} for node_id={body.node_id!r}")
|
||||
raise HTTPException(401, "Invalid or missing X-Enrollment-Token")
|
||||
|
||||
node_id = body.node_id.strip()
|
||||
if not node_id:
|
||||
raise HTTPException(400, "node_id is required")
|
||||
|
||||
existing = await fstore.doc_get("nodes", node_id)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# CRITICAL GUARD — do not remove or weaken this check.
|
||||
#
|
||||
# An already-approved node_id must NEVER get a fresh pickup_secret off
|
||||
# the fleet-wide enrollment token alone. The fleet token is shared by
|
||||
# every node (it ships in every node's .env / setup.sh prompt), so it
|
||||
# is the credential most likely to leak. Without this guard, a leaked
|
||||
# fleet token plus a guessable node_id (node-001, node-002, ...) would
|
||||
# let an attacker "re-enroll" a live, already-approved node and race
|
||||
# the real node to GET /nodes/{id}/credentials — stealing its actual
|
||||
# api_key before the legitimate device ever asks.
|
||||
#
|
||||
# Recovery for an approved node goes through the existing admin-only
|
||||
# POST /nodes/{id}/reissue-key instead (routers/nodes.py), which
|
||||
# requires a Firebase admin token, not the fleet token.
|
||||
# -------------------------------------------------------------------
|
||||
if existing and existing.get("approval_status") == "approved":
|
||||
logger.warning(
|
||||
f"Enroll refused: node_id={node_id!r} is already approved — "
|
||||
f"refusing to issue a new pickup_secret from the fleet token alone "
|
||||
f"(source_ip={client_ip})"
|
||||
)
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Node is already approved. This endpoint cannot re-issue credentials "
|
||||
"for an approved node from the enrollment token alone — use admin "
|
||||
"key reissue.",
|
||||
)
|
||||
|
||||
pickup_secret = secrets.token_hex(24)
|
||||
doc = {
|
||||
"node_id": node_id,
|
||||
"name": body.name or (existing or {}).get("name") or node_id,
|
||||
"lat": body.lat or (existing or {}).get("lat", 0.0),
|
||||
"lon": body.lon or (existing or {}).get("lon", 0.0),
|
||||
"approval_status": (existing or {}).get("approval_status", "pending"),
|
||||
"pickup_secret_hash": _hash_secret(pickup_secret),
|
||||
}
|
||||
# approval_status stays "pending" for a brand-new node; if it's an
|
||||
# existing "pending" or "rejected" node re-enrolling (e.g. lost its
|
||||
# pickup_secret before an admin ever approved it), leave whatever
|
||||
# status it already has rather than silently flipping "rejected" back
|
||||
# to "pending" — that decision belongs to an admin, not this endpoint.
|
||||
await fstore.doc_set("nodes", node_id, doc, merge=True)
|
||||
logger.info(f"Node enrolled: {node_id} (status={doc['approval_status']}, source_ip={client_ip})")
|
||||
|
||||
return EnrollResponse(node_id=node_id, pickup_secret=pickup_secret, approval_status=doc["approval_status"])
|
||||
|
||||
|
||||
class CredentialsResponse(BaseModel):
|
||||
approval_status: str
|
||||
api_key: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/{node_id}/credentials", response_model=CredentialsResponse)
|
||||
async def get_node_credentials(node_id: str, x_pickup_secret: Optional[str] = Header(None)):
|
||||
if not x_pickup_secret:
|
||||
raise HTTPException(401, "Missing X-Pickup-Secret header")
|
||||
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node or not node.get("pickup_secret_hash"):
|
||||
raise HTTPException(404, "Unknown node, or node was never enrolled via POST /nodes/enroll")
|
||||
|
||||
if not secrets.compare_digest(_hash_secret(x_pickup_secret), node["pickup_secret_hash"]):
|
||||
raise HTTPException(401, "Invalid pickup secret")
|
||||
|
||||
approval_status = node.get("approval_status", "pending")
|
||||
if approval_status != "approved":
|
||||
return CredentialsResponse(approval_status=approval_status)
|
||||
|
||||
key_doc = await fstore.doc_get("node_keys", node_id)
|
||||
if not key_doc or not key_doc.get("api_key"):
|
||||
# Approved but no key yet — shouldn't normally happen, approve_node()
|
||||
# always writes node_keys in the same call that sets approved. Treat
|
||||
# it as "keep polling" rather than erroring the node's retry loop.
|
||||
return CredentialsResponse(approval_status=approval_status)
|
||||
|
||||
return CredentialsResponse(approval_status=approval_status, api_key=key_doc["api_key"])
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Call-audio playback.
|
||||
|
||||
Public router by necessity: a browser's <audio src="..."> cannot attach an
|
||||
Authorization header, so the link itself carries the credential — a short-lived
|
||||
HMAC over (call_id, expiry) minted by app/internal/storage.py. That is why this
|
||||
router is included in main.py WITHOUT a router-level auth dependency; the check
|
||||
happens inline below, in the same spirit as routers/enrollment.py.
|
||||
|
||||
The bucket stays fully private and c2-core reads the object server-side with
|
||||
Application Default Credentials, so no GCS signed URL — and therefore no
|
||||
service-account private key on the VM — is involved anywhere in this path.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio
|
||||
|
||||
router = APIRouter(prefix="/media", tags=["media"])
|
||||
|
||||
|
||||
@router.get("/calls/{call_id}/audio")
|
||||
async def get_call_audio(
|
||||
call_id: str,
|
||||
exp: int = Query(..., description="Link expiry, unix seconds."),
|
||||
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
||||
):
|
||||
# Verify before touching Firestore so an invalid link costs nothing.
|
||||
if not verify_audio_link(call_id, exp, sig):
|
||||
raise HTTPException(403, "Invalid or expired audio link")
|
||||
|
||||
call = await fstore.doc_get("calls", call_id)
|
||||
if not call:
|
||||
raise HTTPException(404, f"Call '{call_id}' not found.")
|
||||
|
||||
gcs_uri = gcs_uri_for_call(call)
|
||||
if not gcs_uri:
|
||||
raise HTTPException(404, "No audio for this call.")
|
||||
|
||||
data = await download_audio(gcs_uri)
|
||||
if not data:
|
||||
raise HTTPException(404, "Audio object missing from storage.")
|
||||
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="audio/mpeg",
|
||||
headers={
|
||||
"Content-Length": str(len(data)),
|
||||
# Recordings are small (16 kbps mono — a 30s call is ~60 KB), so the
|
||||
# whole body is sent at once and the browser seeks within its own
|
||||
# buffer. Range support would only matter for long files.
|
||||
"Accept-Ranges": "none",
|
||||
# Immutable content, but the URL expires — cache privately only.
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
)
|
||||
@@ -1,9 +1,12 @@
|
||||
import secrets
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from app.models import CommandPayload
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.mqtt_handler import mqtt_handler
|
||||
from app.internal import dynsec
|
||||
from app.internal.logger import logger
|
||||
from app.internal.auth import require_admin_token, require_service_key_or_admin
|
||||
from app.routers.tokens import assign_token, release_token
|
||||
|
||||
@@ -30,8 +33,23 @@ async def approve_node(node_id: str, _: dict = Depends(require_admin_token)):
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
|
||||
api_key = secrets.token_hex(32)
|
||||
|
||||
# dynsec FIRST, Firestore second: if the broker rejects/never confirms
|
||||
# the new client, we must not tell Firestore (and the admin UI) the
|
||||
# node is approved with a key mosquitto doesn't actually recognise —
|
||||
# that's exactly the silent-drift the two-sources-of-truth problem
|
||||
# warns about. See app/internal/dynsec.py.
|
||||
try:
|
||||
await dynsec.upsert_node_client(node_id, api_key)
|
||||
except dynsec.DynsecError as e:
|
||||
logger.error(f"Approve {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
|
||||
raise HTTPException(502, f"Could not provision MQTT credentials for node: {e}")
|
||||
|
||||
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
||||
await fstore.doc_update("nodes", node_id, {"approval_status": "approved"})
|
||||
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
|
||||
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
|
||||
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
|
||||
mqtt_handler.publish_node_key(node_id, api_key)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -41,6 +59,11 @@ 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.")
|
||||
try:
|
||||
await dynsec.delete_node_client(node_id)
|
||||
except dynsec.DynsecError as e:
|
||||
logger.error(f"Delete {node_id!r}: dynsec deleteClient failed, NOT deleting Firestore docs: {e}")
|
||||
raise HTTPException(502, f"Could not revoke MQTT credentials for node: {e}")
|
||||
await fstore.doc_delete("node_keys", node_id)
|
||||
await fstore.doc_delete("nodes", node_id)
|
||||
|
||||
@@ -101,7 +124,15 @@ async def reissue_node_key(node_id: str, _: dict = Depends(require_admin_token))
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
api_key = secrets.token_hex(32)
|
||||
try:
|
||||
await dynsec.upsert_node_client(node_id, api_key)
|
||||
except dynsec.DynsecError as e:
|
||||
logger.error(f"Reissue {node_id!r}: dynsec upsert failed, NOT writing Firestore: {e}")
|
||||
raise HTTPException(502, f"Could not update MQTT credentials for node: {e}")
|
||||
await fstore.doc_set("node_keys", node_id, {"node_id": node_id, "api_key": api_key}, merge=False)
|
||||
# TODO(mqtt-cutover): drop this MQTT push once nodes pull their key via
|
||||
# GET /nodes/{id}/credentials (routers/enrollment.py) exclusively — see
|
||||
# MQTT-PUBLIC-AUTH-PLAN.md "Rollout order" step 6. Kept for node-26.
|
||||
mqtt_handler.publish_node_key(node_id, api_key)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -126,10 +157,13 @@ async def assign_system(
|
||||
if not system:
|
||||
raise HTTPException(404, f"System '{system_id}' not found.")
|
||||
|
||||
# Include hardware preset in the push so the edge node applies it when
|
||||
# generating the OP25 config. Strip it from the system doc first so it
|
||||
# doesn't collide with SystemConfig field validation on the node side.
|
||||
push_payload = {**system, "hardware_preset": hardware_preset}
|
||||
# Include hardware preset, node type, and enforce timeout in the push
|
||||
push_payload = {
|
||||
**system,
|
||||
"hardware_preset": hardware_preset,
|
||||
"node_type": node.get("node_type", "fixed"),
|
||||
"enforce_override_timeout": node.get("enforce_override_timeout", True),
|
||||
}
|
||||
if ppm_override is not None:
|
||||
push_payload["ppm_override"] = ppm_override
|
||||
mqtt_handler.push_config(node_id, push_payload)
|
||||
@@ -145,3 +179,89 @@ async def assign_system(
|
||||
await fstore.doc_update("nodes", node_id, node_updates)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class NodeUpdateBody(BaseModel):
|
||||
node_type: Optional[str] = None
|
||||
enforce_override_timeout: Optional[bool] = None
|
||||
|
||||
|
||||
@router.patch("/{node_id}")
|
||||
async def update_node(
|
||||
node_id: str,
|
||||
body: NodeUpdateBody,
|
||||
_: 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.")
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
|
||||
await fstore.doc_update("nodes", node_id, updates)
|
||||
|
||||
# Re-push config to apply new node settings locally
|
||||
updated_node = await fstore.doc_get("nodes", node_id)
|
||||
assigned_system_id = updated_node.get("assigned_system_id")
|
||||
if assigned_system_id:
|
||||
system = await fstore.doc_get("systems", assigned_system_id)
|
||||
if system:
|
||||
push_payload = {
|
||||
**system,
|
||||
"hardware_preset": updated_node.get("hardware_preset", "rtl-sdr-v3"),
|
||||
"node_type": updated_node.get("node_type", "fixed"),
|
||||
"enforce_override_timeout": updated_node.get("enforce_override_timeout", True),
|
||||
}
|
||||
if updated_node.get("ppm_override") is not None:
|
||||
push_payload["ppm_override"] = updated_node["ppm_override"]
|
||||
mqtt_handler.push_config(node_id, push_payload)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class AckOverrideBody(BaseModel):
|
||||
timeout_minutes: int = 1440
|
||||
|
||||
|
||||
@router.post("/{node_id}/override/ack")
|
||||
async def ack_override(
|
||||
node_id: str,
|
||||
body: AckOverrideBody,
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
new_timeout = datetime.now(timezone.utc) + timedelta(minutes=body.timeout_minutes)
|
||||
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"override_timeout_at": new_timeout.isoformat()
|
||||
})
|
||||
return {"ok": True, "override_timeout_at": new_timeout.isoformat()}
|
||||
|
||||
|
||||
@router.post("/{node_id}/override/reset")
|
||||
async def reset_override(
|
||||
node_id: str,
|
||||
_: dict = Depends(require_service_key_or_admin),
|
||||
):
|
||||
node = await fstore.doc_get("nodes", node_id)
|
||||
if not node:
|
||||
raise HTTPException(404, f"Node '{node_id}' not found.")
|
||||
|
||||
assigned_system_id = node.get("assigned_system_id")
|
||||
if assigned_system_id:
|
||||
system = await fstore.doc_get("systems", assigned_system_id)
|
||||
if system:
|
||||
mqtt_handler.push_config(node_id, system)
|
||||
|
||||
await fstore.doc_update("nodes", node_id, {
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
"override_timeout_at": None,
|
||||
})
|
||||
return {"ok": True}
|
||||
|
||||
@@ -2,6 +2,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.internal.storage import upload_audio
|
||||
from app.internal import dedup
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal.logger import logger
|
||||
from app.config import settings
|
||||
@@ -47,16 +48,28 @@ async def upload_call_audio(
|
||||
if len(data) > settings.upload_max_bytes:
|
||||
raise HTTPException(413, f"File too large (max {settings.upload_max_bytes // (1024*1024)} MB).")
|
||||
|
||||
audio_url = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
gcs_uri = await upload_audio(data, file.filename or "", call_id=call_id)
|
||||
|
||||
if audio_url:
|
||||
if gcs_uri:
|
||||
try:
|
||||
await fstore.doc_set("calls", call_id, {"audio_url": audio_url})
|
||||
# Canonical object location only. The playback link is minted per
|
||||
# read in storage.playback_url() — nothing durable is stored here.
|
||||
await fstore.doc_set("calls", call_id, {"audio_gcs_uri": gcs_uri})
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update call {call_id} with audio_url: {e}")
|
||||
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||
|
||||
# Convert public GCS URL to gs:// URI for Speech-to-Text
|
||||
gcs_uri = _public_url_to_gcs_uri(audio_url)
|
||||
# Another node in range recorded the same transmission. Keep the audio
|
||||
# (it may be the cleaner capture) but don't transcribe or correlate it
|
||||
# a second time — see app/internal/dedup.py.
|
||||
call_doc = await fstore.doc_get("calls", call_id)
|
||||
duplicate_of = await dedup.find_duplicate_of(call_doc) if call_doc else None
|
||||
if duplicate_of:
|
||||
await fstore.doc_set("calls", call_id, {"duplicate_of": duplicate_of})
|
||||
logger.info(
|
||||
f"Call {call_id} from {node_id} duplicates {duplicate_of} "
|
||||
f"— audio kept, AI pipeline skipped."
|
||||
)
|
||||
return {"url": gcs_uri, "duplicate_of": duplicate_of}
|
||||
|
||||
background_tasks.add_task(
|
||||
_run_intelligence_pipeline,
|
||||
@@ -68,21 +81,7 @@ async def upload_call_audio(
|
||||
gcs_uri=gcs_uri,
|
||||
)
|
||||
|
||||
return {"url": audio_url}
|
||||
|
||||
|
||||
def _public_url_to_gcs_uri(url: str) -> Optional[str]:
|
||||
"""
|
||||
Convert a public GCS URL (possibly signed) like
|
||||
https://storage.googleapis.com/bucket/calls/file.mp3?Expires=...
|
||||
to a gs:// URI usable by Speech-to-Text.
|
||||
Returns None if the URL doesn't look like a GCS URL.
|
||||
"""
|
||||
prefix = "https://storage.googleapis.com/"
|
||||
if url and url.startswith(prefix):
|
||||
path = url[len(prefix):].split("?")[0] # strip signed-URL query params
|
||||
return "gs://" + path
|
||||
return None
|
||||
return {"url": gcs_uri}
|
||||
|
||||
|
||||
async def _correlate_with_consensus(
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# -----------------------------------------------------------------------
|
||||
# Mosquitto ACL — DRB C2 Server
|
||||
# -----------------------------------------------------------------------
|
||||
# Two principals:
|
||||
# drb-c2-core — the backend service; needs full broker access
|
||||
# drb-node — shared credential for all edge nodes; scoped to their
|
||||
# own namespace via MQTT client ID (%c = NODE_ID)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
# C2-core service — full read/write on every topic
|
||||
user drb-c2-core
|
||||
topic readwrite #
|
||||
|
||||
# Edge nodes — each node may only read/write topics under nodes/<its-own-ID>/
|
||||
# Mosquitto substitutes %c with the connecting client's MQTT client ID at
|
||||
# runtime. Edge nodes set client_id = NODE_ID in mqtt_manager.py, so this
|
||||
# cryptographically prevents node-A from publishing to nodes/node-B/api_key
|
||||
# or any other node's namespace.
|
||||
pattern readwrite nodes/%c/#
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Mosquitto entrypoint — generates /mosquitto/config/passwd from env vars
|
||||
# before handing off to the broker process.
|
||||
#
|
||||
# Required environment variables (set in docker-compose.yml):
|
||||
# MQTT_C2_USER — username for the drb-c2-core service
|
||||
# MQTT_C2_PASS — password for the drb-c2-core service
|
||||
# MQTT_NODE_USER — shared username for all edge nodes
|
||||
# MQTT_NODE_PASS — shared password for all edge nodes
|
||||
set -e
|
||||
|
||||
PASSWD_FILE=/tmp/passwd
|
||||
|
||||
# Remove any stale file so we start clean on every container start
|
||||
rm -f "$PASSWD_FILE"
|
||||
|
||||
if [ -z "$MQTT_C2_USER" ] || [ -z "$MQTT_C2_PASS" ]; then
|
||||
echo "ERROR: MQTT_C2_USER and MQTT_C2_PASS must be set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$MQTT_NODE_USER" ] || [ -z "$MQTT_NODE_PASS" ]; then
|
||||
echo "ERROR: MQTT_NODE_USER and MQTT_NODE_PASS must be set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -c creates/overwrites the file; subsequent calls append without -c
|
||||
mosquitto_passwd -c -b "$PASSWD_FILE" "$MQTT_C2_USER" "$MQTT_C2_PASS"
|
||||
mosquitto_passwd -b "$PASSWD_FILE" "$MQTT_NODE_USER" "$MQTT_NODE_PASS"
|
||||
|
||||
# mosquitto_passwd creates the file 0600 (root-only); mosquitto drops to
|
||||
# the mosquitto user before reading it, so make it world-readable.
|
||||
chmod 644 "$PASSWD_FILE"
|
||||
|
||||
echo "Mosquitto: password file written for users: $MQTT_C2_USER, $MQTT_NODE_USER"
|
||||
|
||||
exec /usr/sbin/mosquitto -c /mosquitto/config/mosquitto.conf
|
||||
@@ -1,11 +1,43 @@
|
||||
listener 1883
|
||||
# Auth: mosquitto's own built-in dynamic-security plugin — NOT
|
||||
# mosquitto-go-auth (that project is archived upstream, no CVE patches;
|
||||
# rejected for an internet-facing broker). This plugin ships in and is
|
||||
# maintained alongside the official eclipse-mosquitto image itself.
|
||||
# See MQTT-PUBLIC-AUTH-PLAN.md and app/internal/dynsec.py for the full
|
||||
# design (bootstrap, roles, the two-sources-of-truth reconcile).
|
||||
#
|
||||
# Plugin path is DERIVED FROM SOURCE (docker/2.1-alpine/Dockerfile in
|
||||
# eclipse-mosquitto/mosquitto), not observed by running the image —
|
||||
# nothing in this project executes/pulls images from this machine. Verify
|
||||
# it on first real deploy: `docker compose logs mosquitto` will say
|
||||
# "Error: Unable to load plugin" at the exact path below if it's wrong for
|
||||
# whatever patch tag ends up pinned.
|
||||
plugin /usr/lib/mosquitto_dynamic_security.so
|
||||
# Lives on the same persistent volume as `persistence_location` below —
|
||||
# one durable volume for all broker state, survives redeploys.
|
||||
plugin_opt_config_file /mosquitto/data/dynamic-security.json
|
||||
|
||||
allow_anonymous false
|
||||
# No password_file/acl_file directive anywhere in this file — the plugin
|
||||
# above is the only registered auth backend. There is no "coexist" mode:
|
||||
# nothing else is registered to conflict with it.
|
||||
|
||||
# Credentials and ACLs are generated/mounted at container startup
|
||||
password_file /tmp/passwd
|
||||
acl_file /mosquitto/config/acl.conf
|
||||
# Internal, plaintext — c2-core's own connection only (its dynsec-admin
|
||||
# control-plane calls AND its regular data-plane pub/sub both use this).
|
||||
# Never published to the host in prod (docker-compose.prod.yml removes the
|
||||
# port mapping); external nodes use the TLS listener below instead.
|
||||
listener 1883
|
||||
|
||||
# Public, TLS — edge nodes connect here as username=node_id, password=api_key
|
||||
# (the same credential /upload already trusts via node_keys), authorized by
|
||||
# the "node" dynsec role (nodes/%u/# — %u is the dynsec-authenticated
|
||||
# username, fixing the old %c-based ACL's client-ID-spoofing hole). Cert/key
|
||||
# come from infra/ansible's Caddy cert-sync unit; see
|
||||
# MQTT-PUBLIC-AUTH-PLAN.md "Infra" and the "Rollout order" cert-verification
|
||||
# step for what happens before that cert exists.
|
||||
listener 8883
|
||||
certfile /mosquitto/certs/mqtt.crt
|
||||
keyfile /mosquitto/certs/mqtt.key
|
||||
|
||||
# Persist retained messages (e.g. api_key, node status) across broker restarts
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
|
||||
|
||||
@@ -1,2 +1,61 @@
|
||||
# All C2 core settings have defaults — no env setup needed.
|
||||
# Add any shared fixtures here if required in the future.
|
||||
#
|
||||
# firebase-admin and google-cloud-firestore are runtime-only dependencies: they
|
||||
# are installed in the container but not in the local dev venv, and
|
||||
# app/internal/firestore.py calls _init_firebase() at import time. Without the
|
||||
# stubs below, importing ANY module that reaches Firestore fails at collection
|
||||
# time, which is why test_mqtt_handler and test_node_sweeper could not be run
|
||||
# outside the container.
|
||||
#
|
||||
# The stubs are installed only when the real packages are absent, so the
|
||||
# container's real SDK is never shadowed.
|
||||
import sys
|
||||
from types import ModuleType
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
try: # pragma: no cover - exercised only by which packages are installed
|
||||
import firebase_admin # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
_firebase = ModuleType("firebase_admin")
|
||||
# Falsy so _init_firebase() takes the initialize_app() branch rather than the
|
||||
# already-initialised branch, which is itself broken (see DEFERRED.md).
|
||||
_firebase._apps = {}
|
||||
_firebase.initialize_app = MagicMock()
|
||||
_firebase.credentials = MagicMock()
|
||||
_firebase.firestore = MagicMock()
|
||||
|
||||
_credentials = ModuleType("firebase_admin.credentials")
|
||||
_credentials.Certificate = MagicMock()
|
||||
_credentials.ApplicationDefault = MagicMock()
|
||||
|
||||
_fs = ModuleType("firebase_admin.firestore")
|
||||
_fs.client = MagicMock()
|
||||
# A distinct sentinel rather than a MagicMock: production code writes this
|
||||
# into dicts that tests compare against, and a MagicMock compares unequal
|
||||
# to itself across attribute accesses.
|
||||
_fs.SERVER_TIMESTAMP = "__SERVER_TIMESTAMP__"
|
||||
|
||||
_auth = ModuleType("firebase_admin.auth")
|
||||
_auth.verify_id_token = MagicMock()
|
||||
_auth.set_custom_user_claims = MagicMock()
|
||||
_auth.get_user_by_email = MagicMock()
|
||||
_auth.get_user = MagicMock()
|
||||
|
||||
_firebase.auth = _auth
|
||||
_firebase.credentials = _credentials
|
||||
_firebase.firestore = _fs
|
||||
|
||||
sys.modules["firebase_admin"] = _firebase
|
||||
sys.modules["firebase_admin.credentials"] = _credentials
|
||||
sys.modules["firebase_admin.firestore"] = _fs
|
||||
sys.modules["firebase_admin.auth"] = _auth
|
||||
|
||||
try: # pragma: no cover
|
||||
from google.cloud.firestore_v1.base_query import FieldFilter # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
for _name in (
|
||||
"google", "google.cloud", "google.cloud.firestore_v1",
|
||||
"google.cloud.firestore_v1.base_query",
|
||||
):
|
||||
sys.modules.setdefault(_name, ModuleType(_name))
|
||||
sys.modules["google.cloud.firestore_v1.base_query"].FieldFilter = MagicMock()
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
Unit tests for the incident-creation gate and the thin-call activity rule.
|
||||
|
||||
Both behaviours come from the 2026-08-16 correlation dump, where TG 9048
|
||||
produced one 28-call / 49-minute incident alongside 32 permanent orphans:
|
||||
|
||||
* Requiring a concrete incident_type to create an incident meant a channel
|
||||
whose traffic never classifies could never open a second incident, so every
|
||||
later call funnelled into whichever incident existed first.
|
||||
* Thin ("10-4") calls refreshed updated_at, which kept that incident
|
||||
permanently inside the fast-path recency gate.
|
||||
|
||||
_run_decision is pure — it reads only the context dict — so these cases need no
|
||||
Firestore. _update_incident writes, so its test patches fstore.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.internal.incident_correlator import _run_decision, _update_incident
|
||||
|
||||
NOW = datetime(2026, 8, 16, 21, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _ctx(**overrides) -> dict:
|
||||
"""Context with no active incidents, so the decision reaches the creation gate."""
|
||||
base = {
|
||||
"call_id": "call-1",
|
||||
"all_active": [],
|
||||
"recent": [],
|
||||
"call_doc": {},
|
||||
"call_embedding": None,
|
||||
"call_units": [],
|
||||
"call_vehicles": [],
|
||||
"call_cleared": [],
|
||||
"call_severity": "routine",
|
||||
"coords": None,
|
||||
"is_thin_call": True,
|
||||
"now": NOW,
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": 9048,
|
||||
"talkgroup_name": "MTA PD Districts 6/7/11 - Police Dispatch",
|
||||
"tags": [],
|
||||
"incident_type": None,
|
||||
"location": None,
|
||||
"location_coords": None,
|
||||
"reassignment": False,
|
||||
"create_if_new": True,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Creation gate — severity decides incident-worthiness, not incident_type
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_routine_status_traffic_stays_orphaned():
|
||||
"""A content-free acknowledgement must not open an incident of its own."""
|
||||
assert _run_decision(_ctx())["action"] == "orphan"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("severity", ["minor", "moderate", "major"])
|
||||
def test_any_real_severity_opens_an_untyped_incident(severity):
|
||||
decision = _run_decision(_ctx(call_severity=severity))
|
||||
assert decision["action"] == "new"
|
||||
assert decision["incident_type"] == "other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("call_vehicles", ["RMP 22146"]),
|
||||
("coords", {"lat": 41.0, "lng": -73.8}),
|
||||
("tags", ["prisoner-transport"]),
|
||||
])
|
||||
def test_concrete_content_opens_an_untyped_incident(field, value):
|
||||
"""Routine severity is overridden by anything the extractor actually found."""
|
||||
decision = _run_decision(_ctx(**{field: value}))
|
||||
assert decision["action"] == "new"
|
||||
assert decision["incident_type"] == "other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field,value", [
|
||||
("call_units", ["11-Victor"]),
|
||||
("location", "Holland Station"),
|
||||
])
|
||||
def test_ambient_radio_fields_are_not_substance(field, value):
|
||||
"""
|
||||
A unit ID and a place name appear in nearly every transmission, so treating
|
||||
them as substance made the severity check dead code: "11-Victor, 72 at
|
||||
Holland Station" opened its own incident, and 37 of 50 incidents were single
|
||||
routine calls left permanently active.
|
||||
"""
|
||||
assert _run_decision(_ctx(**{field: value}))["action"] == "orphan"
|
||||
|
||||
|
||||
def test_units_and_location_together_still_orphan():
|
||||
decision = _run_decision(_ctx(call_units=["11-Victor"], location="Holland Station"))
|
||||
assert decision["action"] == "orphan"
|
||||
|
||||
|
||||
def test_units_with_real_severity_still_open_an_incident():
|
||||
"""Severity is the gate — ambient fields don't block it, they just can't open it alone."""
|
||||
decision = _run_decision(_ctx(call_units=["11-Victor"], call_severity="moderate"))
|
||||
assert decision["action"] == "new"
|
||||
assert decision["incident_type"] == "other"
|
||||
|
||||
|
||||
def test_explicit_type_is_never_downgraded_to_other():
|
||||
decision = _run_decision(_ctx(incident_type="police", call_severity="moderate"))
|
||||
assert decision["action"] == "new"
|
||||
assert decision["incident_type"] == "police"
|
||||
|
||||
|
||||
def test_other_survives_extraction_and_creates_an_incident():
|
||||
""""other" is a real classification now, not a synonym for unclassifiable."""
|
||||
decision = _run_decision(_ctx(incident_type="other"))
|
||||
assert decision["action"] == "new"
|
||||
assert decision["incident_type"] == "other"
|
||||
|
||||
|
||||
def test_sweep_never_creates_incidents():
|
||||
"""The re-correlation sweep passes create_if_new=False — it may only link."""
|
||||
decision = _run_decision(_ctx(call_severity="major", create_if_new=False))
|
||||
assert decision["action"] == "orphan"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thin calls attach for context but do not count as incident activity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _incident(idle_minutes: float) -> dict:
|
||||
updated = NOW - timedelta(minutes=idle_minutes)
|
||||
return {
|
||||
"incident_id": "inc-1",
|
||||
"system_ids": ["sys-1"],
|
||||
"talkgroup_ids": ["9048"],
|
||||
"updated_at": updated.isoformat(),
|
||||
"started_at": updated.isoformat(),
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
|
||||
def test_thin_call_links_to_the_active_incident_on_its_talkgroup():
|
||||
inc = _incident(0.2)
|
||||
decision = _run_decision(_ctx(all_active=[inc], recent=[inc]))
|
||||
assert decision["action"] == "link"
|
||||
assert decision["corr_debug"]["corr_path"] == "fast/thin"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_thin_link_does_not_refresh_updated_at():
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
await _update_incident(
|
||||
_incident(5), "call-1", 9048, "sys-1", [], None, None, [], [], None, NOW,
|
||||
refresh_activity=False,
|
||||
)
|
||||
updates = mock_fstore.doc_set.await_args.args[2]
|
||||
assert "updated_at" not in updates, "a '10-4' must not reset the incident idle clock"
|
||||
assert updates["last_thin_at"] == NOW.isoformat()
|
||||
assert updates["summary_stale"] is True, "the call still belongs in the summary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_substantive_link_does_refresh_updated_at():
|
||||
with patch("app.internal.incident_correlator.fstore") as mock_fstore:
|
||||
mock_fstore.doc_set = AsyncMock()
|
||||
await _update_incident(
|
||||
_incident(5), "call-1", 9048, "sys-1", [], None, None, ["6 Adam"], [], None, NOW,
|
||||
)
|
||||
updates = mock_fstore.doc_set.await_args.args[2]
|
||||
assert updates["updated_at"] == NOW.isoformat()
|
||||
assert "last_thin_at" not in updates
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Unit tests for cross-node duplicate detection.
|
||||
|
||||
Fixture timings come from real production data: node-002 and node-PI-2 both
|
||||
recorded TG 9048 on 2026-08-16, starting ~1.1s apart.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.internal.dedup import _parse_dt, _is_canonical, find_duplicate_of
|
||||
|
||||
BASE = datetime(2026, 8, 16, 19, 31, 46, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _query_returning(*calls):
|
||||
"""Stand-in for fstore.collection_where."""
|
||||
async def _q(_collection, _conditions):
|
||||
return list(calls)
|
||||
return _q
|
||||
|
||||
|
||||
def _query_raising(exc):
|
||||
async def _q(_collection, _conditions):
|
||||
raise exc
|
||||
return _q
|
||||
|
||||
|
||||
def _call(call_id, node_id, offset_seconds=0.0, talkgroup_id=9048, **extra):
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"node_id": node_id,
|
||||
"system_id": "sys-1",
|
||||
"talkgroup_id": talkgroup_id,
|
||||
"started_at": BASE + timedelta(seconds=offset_seconds),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timestamp parsing — Firestore returns three different shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_parse_dt_accepts_aware_datetime():
|
||||
assert _parse_dt(BASE) == BASE
|
||||
|
||||
|
||||
def test_parse_dt_assumes_utc_for_naive_datetime():
|
||||
naive = datetime(2026, 8, 16, 19, 31, 46)
|
||||
assert _parse_dt(naive) == BASE
|
||||
|
||||
|
||||
def test_parse_dt_accepts_iso_string_with_z():
|
||||
assert _parse_dt("2026-08-16T19:31:46Z") == BASE
|
||||
|
||||
|
||||
def test_parse_dt_returns_none_for_junk():
|
||||
assert _parse_dt("not a date") is None
|
||||
assert _parse_dt(None) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical selection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_earlier_start_wins():
|
||||
early = _call("a", "node-002", 0.0)
|
||||
late = _call("b", "node-PI-2", 1.1)
|
||||
assert _is_canonical(early, [late]) is True
|
||||
assert _is_canonical(late, [early]) is False
|
||||
|
||||
|
||||
def test_identical_starts_break_tie_on_call_id():
|
||||
first = _call("aaa", "node-002", 0.0)
|
||||
second = _call("bbb", "node-PI-2", 0.0)
|
||||
assert _is_canonical(first, [second]) is True
|
||||
assert _is_canonical(second, [first]) is False
|
||||
|
||||
|
||||
def test_both_nodes_reach_the_same_verdict():
|
||||
"""The whole point: the decision must not depend on upload order."""
|
||||
a = _call("a", "node-002", 0.0)
|
||||
b = _call("b", "node-PI-2", 1.1)
|
||||
verdicts = [_is_canonical(a, [b]), _is_canonical(b, [a])]
|
||||
assert verdicts.count(True) == 1, "exactly one recording must be canonical"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_duplicate_of
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_canonical_id_for_later_recording():
|
||||
canonical = _call("canon", "node-002", 0.0)
|
||||
later = _call("later", "node-PI-2", 1.1)
|
||||
q = _query_returning(canonical, later)
|
||||
assert await find_duplicate_of(later, query=q) == "canon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_none_for_the_canonical_recording():
|
||||
canonical = _call("canon", "node-002", 0.0)
|
||||
later = _call("later", "node-PI-2", 1.1)
|
||||
q = _query_returning(canonical, later)
|
||||
assert await find_duplicate_of(canonical, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_node_is_never_a_duplicate():
|
||||
"""Back-to-back transmissions from one node are real, separate calls."""
|
||||
first = _call("a", "node-002", 0.0)
|
||||
second = _call("b", "node-002", 2.0)
|
||||
q = _query_returning(first, second)
|
||||
assert await find_duplicate_of(second, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_talkgroup_is_not_a_duplicate():
|
||||
other_tg = _call("a", "node-002", 0.0, talkgroup_id=9600)
|
||||
mine = _call("b", "node-PI-2", 1.0, talkgroup_id=9048)
|
||||
q = _query_returning(other_tg, mine)
|
||||
assert await find_duplicate_of(mine, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_never_chains_onto_another_duplicate():
|
||||
"""A third node must point at the original, not at a duplicate of it."""
|
||||
canonical = _call("canon", "node-002", 0.0)
|
||||
already_dupe = _call("dupe", "node-PI-2", 0.5, duplicate_of="canon")
|
||||
third = _call("third", "node-003", 1.0)
|
||||
q = _query_returning(canonical, already_dupe, third)
|
||||
assert await find_duplicate_of(third, query=q) == "canon"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_returns_none():
|
||||
lonely = _call("only", "node-002", 0.0)
|
||||
q = _query_returning(lonely)
|
||||
assert await find_duplicate_of(lonely, query=q) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_identifiers_skip_the_check():
|
||||
called = False
|
||||
|
||||
async def _q(_collection, _conditions):
|
||||
nonlocal called
|
||||
called = True
|
||||
return []
|
||||
|
||||
incomplete = {"call_id": "x", "node_id": "node-002", "started_at": BASE}
|
||||
assert await find_duplicate_of(incomplete, query=_q) is None
|
||||
assert called is False, "must bail out before querying"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_failure_never_blocks_the_upload():
|
||||
call = _call("a", "node-002", 0.0)
|
||||
q = _query_raising(RuntimeError("firestore down"))
|
||||
assert await find_duplicate_of(call, query=q) is None
|
||||
@@ -4,6 +4,17 @@ WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
# Build-time public vars — baked into the Next.js bundle by the CI workflow
|
||||
ARG NEXT_PUBLIC_C2_URL
|
||||
ARG NEXT_PUBLIC_FIREBASE_API_KEY
|
||||
ARG NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN
|
||||
ARG NEXT_PUBLIC_FIREBASE_PROJECT_ID
|
||||
ARG NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET
|
||||
ARG NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID
|
||||
ARG NEXT_PUBLIC_FIREBASE_APP_ID
|
||||
ARG NEXT_PUBLIC_FIRESTORE_DATABASE
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-slim AS runner
|
||||
|
||||
@@ -1,21 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
|
||||
import { useCalls, useActiveCalls } from "@/lib/useCalls";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
||||
import { NodeCard } from "@/components/NodeCard";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
import { severityBadge, severityRank } from "@/lib/severity";
|
||||
import { useState } from "react";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
import type { NodeRecord, IncidentRecord } from "@/lib/types";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
|
||||
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
||||
<Card>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
function IncidentSummaryCard({ incident }: { incident: IncidentRecord }) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card hover className="cursor-pointer" onClick={() => router.push(`/incidents/${incident.incident_id}`)}>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
{severityBadge(incident.severity)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug line-clamp-2">{incident.title ?? "Untitled incident"}</p>
|
||||
<p className="text-gray-500 text-xs font-mono mt-2">
|
||||
{fmtTime(incident.started_at)} · {incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +56,7 @@ export default function DashboardPage() {
|
||||
const { calls, error: callsError } = useCalls(20);
|
||||
const activeCalls = useActiveCalls();
|
||||
const { systems, error: systemsError } = useSystems();
|
||||
const activeIncidents = useActiveIncidents();
|
||||
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
||||
|
||||
const { isAdmin } = useAuth();
|
||||
@@ -33,44 +65,66 @@ export default function DashboardPage() {
|
||||
|
||||
const fsError = nodesError ?? callsError ?? systemsError;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1>
|
||||
// Worst-first: the incident that most needs a human's attention leads the panel.
|
||||
const sortedIncidents = [...activeIncidents].sort(
|
||||
(a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at)
|
||||
);
|
||||
const notableIncidentCount = activeIncidents.filter((i) => severityRank(i.severity) >= 2).length;
|
||||
|
||||
{fsError && (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
badge={notableIncidentCount > 0 && <Badge tone="danger">{notableIncidentCount} moderate+ active</Badge>}
|
||||
/>
|
||||
|
||||
{fsError && <ErrorBanner message={`Firestore error: ${fsError}`} />}
|
||||
|
||||
{/* Pending config banner */}
|
||||
{pending.length > 0 && (
|
||||
<div className="bg-indigo-950 border border-indigo-800 rounded-lg p-4 flex items-center justify-between">
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-lg p-4 flex items-center justify-between gap-3 flex-wrap">
|
||||
<p className="text-indigo-300 text-sm font-mono">
|
||||
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConfigNode(pending[0])}
|
||||
className="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg transition-colors"
|
||||
>
|
||||
Configure now
|
||||
</button>
|
||||
<Button size="sm" onClick={() => setConfigNode(pending[0])}>Configure now</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Active Incidents" value={activeIncidents.length} accent={activeIncidents.length > 0 ? "text-orange-400" : undefined} />
|
||||
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
|
||||
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
|
||||
<StatCard label="Total Nodes" value={nodes.length} />
|
||||
<StatCard label="Systems" value={systems.length} />
|
||||
</div>
|
||||
|
||||
{/* Active incidents — the primary "what's happening" view */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">Active Incidents</h2>
|
||||
<Link href="/incidents" className="text-xs text-indigo-400 hover:text-indigo-300 font-mono transition-colors">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
{sortedIncidents.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No active incidents"
|
||||
description="Incidents appear here automatically as calls correlate into events."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedIncidents.slice(0, 6).map((inc) => (
|
||||
<IncidentSummaryCard key={inc.incident_id} incident={inc} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Nodes */}
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No nodes registered yet.</p>
|
||||
<EmptyState title="No nodes registered yet" description="Deploy a field SDR node and it will show up here automatically." />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{nodes.map((n) => (
|
||||
@@ -84,9 +138,9 @@ export default function DashboardPage() {
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||
{calls.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
||||
<EmptyState title="No calls recorded yet" />
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<Card padding="none" className="overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
||||
@@ -104,16 +158,12 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{configNode && (
|
||||
<NodeConfigModal
|
||||
node={configNode}
|
||||
systems={systems}
|
||||
onClose={() => setConfigNode(null)}
|
||||
/>
|
||||
<NodeConfigModal node={configNode} systems={systems} onClose={() => setConfigNode(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
|
||||
const FAQS: { q: string; a: string }[] = [
|
||||
{
|
||||
q: "What hardware do I need to run a node?",
|
||||
a: "A node is a small field SDR device running our edge-node software — it needs an SDR dongle capable of receiving your local P25 or analog trunked system, and a network connection to reach your DRB account. Full setup instructions are provided once you add a node.",
|
||||
},
|
||||
{
|
||||
q: "What's the difference between a 'call' and an 'incident'?",
|
||||
a: "A call is a single radio transmission. An incident is the thing you actually care about — a pursuit, a fire, an accident — built by correlating related calls together, sometimes across multiple talkgroups or nodes. Incidents are the primary view; calls are the evidence behind them.",
|
||||
},
|
||||
{
|
||||
q: "Does DRB do the transcription and AI work itself, or is that a separate cost?",
|
||||
a: "Transcription and incident correlation are included in every paid plan and run automatically on every recorded call. The Community plan includes AI features on a limited call volume; Pro and Enterprise scale with your node count.",
|
||||
},
|
||||
{
|
||||
q: "Can I listen to live radio traffic without opening the dashboard?",
|
||||
a: "Yes — the Discord bot can join a voice channel and relay live audio from any of your nodes, so your team can listen without a separate scanner app.",
|
||||
},
|
||||
{
|
||||
q: "How does node ownership and team access work?",
|
||||
a: "Admins have full access. Operators are scoped to a specific list of nodes they own — they see and manage only those. Viewers get read-only access to everything the org exposes. You manage all of this from Settings → Members.",
|
||||
},
|
||||
{
|
||||
q: "What happens if I go over my plan's node or seat limit?",
|
||||
a: "You'll see a plan-limit notice in Settings → Billing before anything is blocked. In this demo build there's no live enforcement wired up yet — see the Billing settings page for what's stubbed vs. real.",
|
||||
},
|
||||
{
|
||||
q: "How long is call and incident history kept?",
|
||||
a: "Retention depends on plan — 7 days on Community, 90 days on Pro, and a year or more on Enterprise (negotiable). Historical calls remain searchable and linked to their incidents for the full retention window.",
|
||||
},
|
||||
{
|
||||
q: "Is DMR supported?",
|
||||
a: "Not yet — DMR is on the roadmap but the current release only decodes P25 and analog trunked systems.",
|
||||
},
|
||||
];
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FaqPage() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-md mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="text-center">
|
||||
<Badge tone="brand">FAQ</Badge>
|
||||
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
||||
<p className="text-gray-400 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800">
|
||||
{FAQS.map((item, i) => {
|
||||
const open = openIndex === i;
|
||||
return (
|
||||
<div key={item.q}>
|
||||
<button
|
||||
onClick={() => setOpenIndex(open ? null : i)}
|
||||
className="w-full flex items-center justify-between gap-4 py-5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 rounded-lg"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="text-white font-semibold text-sm md:text-base">{item.q}</span>
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
{open && (
|
||||
<p className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-16">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
|
||||
const SECTIONS = [
|
||||
{
|
||||
eyebrow: "Correlation",
|
||||
title: "Calls become incidents",
|
||||
body:
|
||||
"The correlation engine groups related transmissions — across talkgroups and even across nodes — into a single incident. A pursuit renders as a path through every checkin point heard while it moved; a structure fire or accident renders as a pin at the location dispatch gave.",
|
||||
points: [
|
||||
"Hybrid rule + LLM correlation with a cheap/smart consensus tiebreak",
|
||||
"Distance, timing, shared units, and talkgroup signals all feed the match",
|
||||
"Every call keeps its correlation debug trail for admins to audit",
|
||||
],
|
||||
},
|
||||
{
|
||||
eyebrow: "AI pipeline",
|
||||
title: "Transcription and entity extraction",
|
||||
body:
|
||||
"Every recorded call is transcribed and scanned for the details that matter — units on scene, vehicles, and locations — so an incident reads like a dispatch briefing instead of a stack of raw audio.",
|
||||
points: [
|
||||
"Automatic speech-to-text on every call",
|
||||
"Scene & entity extraction feeds the correlator and the incident summary",
|
||||
"AI-generated incident summaries, regenerable on demand",
|
||||
],
|
||||
},
|
||||
{
|
||||
eyebrow: "Situational awareness",
|
||||
title: "Live map, full history",
|
||||
body:
|
||||
"Glance at the map to see what's active right now, or scrub back through history to review how a specific incident unfolded — every linked call, in order, with playback.",
|
||||
points: [
|
||||
"Real-time node and incident map",
|
||||
"Per-incident call timeline with audio playback",
|
||||
"Configurable alert rules that post to Discord on keyword or talkgroup match",
|
||||
],
|
||||
},
|
||||
{
|
||||
eyebrow: "Field hardware",
|
||||
title: "Field SDR nodes",
|
||||
body:
|
||||
"Lightweight edge nodes run OP25/GNU Radio against a P25 or analog trunked system and stream decoded audio to your account. Deploy one node to cover a town, or a whole network across a region.",
|
||||
points: [
|
||||
"P25 and analog trunked systems supported",
|
||||
"Per-node hardware tuning (gain, PPM, antenna) persists independently of system assignment",
|
||||
"Node health, call activity, and configuration all visible from the dashboard",
|
||||
],
|
||||
},
|
||||
{
|
||||
eyebrow: "Team",
|
||||
title: "Discord voice relay & role-scoped access",
|
||||
body:
|
||||
"The Discord bot relays live radio audio into a voice channel so your team can listen along without a separate app, and doubles as a lightweight utility bot for team coordination.",
|
||||
points: [
|
||||
"Live audio relay per node, on demand",
|
||||
"Admin / operator / viewer roles, with operators scoped to the nodes they own",
|
||||
"Discord account linking for in-Discord commands",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function FeaturesPage() {
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<Badge tone="brand">Features</Badge>
|
||||
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
|
||||
<p className="text-gray-400 mt-4 leading-relaxed">
|
||||
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
|
||||
correlation, mapping, and a live relay — end to end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{SECTIONS.map((s) => (
|
||||
<div key={s.title} className="grid grid-cols-1 lg:grid-cols-5 gap-8 items-start">
|
||||
<div className="lg:col-span-2">
|
||||
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
|
||||
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
|
||||
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p>
|
||||
</div>
|
||||
<Card padding="lg" className="lg:col-span-3">
|
||||
<ul className="space-y-3">
|
||||
{s.points.map((p) => (
|
||||
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
|
||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
|
||||
{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-20 pt-16 border-t border-gray-800">
|
||||
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
|
||||
<div className="mt-6">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,3 +115,36 @@ html:not(.dark) input::placeholder,
|
||||
html:not(.dark) textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
||||
* Same pattern as above: components use hardcoded dark-palette Tailwind
|
||||
* classes, remapped here for light mode instead of dark: prefixes.
|
||||
* Only new classes introduced by the marketing pages / settings shell live
|
||||
* below — everything else reuses the palette already mapped above.
|
||||
*/
|
||||
|
||||
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
||||
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
|
||||
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
|
||||
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
|
||||
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
|
||||
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
||||
html:not(.dark) .bg-yellow-600\/10 { background-color: rgba(202,138,4,0.08) !important; }
|
||||
html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !important; }
|
||||
|
||||
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
|
||||
.marketing-hero-bg {
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
|
||||
}
|
||||
html:not(.dark) .marketing-hero-bg {
|
||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
|
||||
}
|
||||
|
||||
/* Skeleton loading shimmer */
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.skeleton {
|
||||
animation: skeleton-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@@ -10,26 +10,11 @@ import { useAuth } from "@/components/AuthProvider";
|
||||
import { CallRow } from "@/components/CallRow";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
import { severityBadge } from "@/lib/severity";
|
||||
|
||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
fire: "bg-red-900 text-red-300",
|
||||
police: "bg-blue-900 text-blue-300",
|
||||
ems: "bg-yellow-900 text-yellow-300",
|
||||
accident: "bg-orange-900 text-orange-300",
|
||||
other: "bg-gray-800 text-gray-300",
|
||||
};
|
||||
|
||||
function TypeBadge({ type }: { type: string | null }) {
|
||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: IncidentRecord["status"] }) {
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-mono ${
|
||||
@@ -93,6 +78,7 @@ export default function IncidentDetailPage() {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<TypeBadge type={incident.type} />
|
||||
<StatusBadge status={incident.status} />
|
||||
{severityBadge(incident.severity)}
|
||||
</div>
|
||||
<h1 className="text-lg sm:text-xl font-bold text-white font-mono leading-snug">
|
||||
{incident.title ?? "Incident"}
|
||||
|
||||
+179
-165
@@ -1,49 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useIncidents } from "@/lib/useIncidents";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { useState } from "react";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
import { severityBadge, severityRank } from "@/lib/severity";
|
||||
import { TypeBadge } from "@/components/IncidentBadges";
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
fire: "bg-red-900 text-red-300",
|
||||
police: "bg-blue-900 text-blue-300",
|
||||
ems: "bg-yellow-900 text-yellow-300",
|
||||
accident: "bg-orange-900 text-orange-300",
|
||||
other: "bg-gray-800 text-gray-300",
|
||||
};
|
||||
// Severity badge/ordering now lives in lib/severity.ts (shared with CallRow).
|
||||
// `severityBadge()` already returns null for the legacy "unknown" value.
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
major: "bg-red-950 text-red-400",
|
||||
moderate: "bg-orange-950 text-orange-400",
|
||||
minor: "bg-gray-800 text-gray-400",
|
||||
};
|
||||
type SeverityFilter = "all" | "minor" | "moderate" | "major";
|
||||
const SEVERITY_FILTERS: { key: SeverityFilter; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "minor", label: "Minor+" },
|
||||
{ key: "moderate", label: "Moderate+" },
|
||||
{ key: "major", label: "Major only" },
|
||||
];
|
||||
const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, moderate: 2, major: 3 };
|
||||
|
||||
function severityBadge(severity: string | null | undefined) {
|
||||
if (!severity || severity === "unknown") return null;
|
||||
const cls = SEVERITY_COLORS[severity] ?? "bg-gray-800 text-gray-400";
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{severity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function typeBadge(type: string | null) {
|
||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
type SortMode = "recent" | "severity";
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleString(); } catch { return iso; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rows / cards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
incident: IncidentRecord;
|
||||
isAdmin: boolean;
|
||||
@@ -53,19 +46,13 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-gray-800 hover:bg-gray-900 cursor-pointer"
|
||||
className="border-b border-gray-800 last:border-0 hover:bg-gray-900/60 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/incidents/${incident.incident_id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">{typeBadge(incident.type)}</td>
|
||||
<td className="px-4 py-3"><TypeBadge type={incident.type} /></td>
|
||||
<td className="px-4 py-3 text-white text-sm">{incident.title ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
incident.status === "active"
|
||||
? "bg-green-900 text-green-300"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}>
|
||||
{incident.status}
|
||||
</span>
|
||||
<Badge tone={incident.status === "active" ? "success" : "neutral"}>{incident.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">{severityBadge(incident.severity)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{incident.call_ids.length}</td>
|
||||
@@ -73,22 +60,94 @@ function IncidentRow({ incident, isAdmin, onResolve }: {
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(incident.updated_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isAdmin && incident.status === "active" && (
|
||||
<button
|
||||
<Button
|
||||
size="sm" variant="secondary"
|
||||
onClick={(e) => { e.stopPropagation(); onResolve(incident.incident_id); }}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateModal({ onClose, onCreate }: {
|
||||
onClose: () => void;
|
||||
onCreate: (body: object) => Promise<void>;
|
||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((inc) => (
|
||||
<Card
|
||||
key={inc.incident_id}
|
||||
padding="sm"
|
||||
hover
|
||||
className="cursor-pointer active:bg-gray-800"
|
||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<TypeBadge type={inc.type} />
|
||||
<Badge tone={inc.status === "active" ? "success" : "neutral"}>{inc.status}</Badge>
|
||||
</div>
|
||||
{isAdmin && inc.status === "active" && (
|
||||
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="sm:hidden">
|
||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
</div>
|
||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Severity</th>
|
||||
<th className="px-4 py-3">Calls</th>
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">Updated</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((inc) => (
|
||||
<IncidentRow key={inc.incident_id} incident={inc} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateModal({ onClose, onCreate }: { onClose: () => void; onCreate: (body: object) => Promise<void> }) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [type, setType] = useState("other");
|
||||
const [summary, setSummary] = useState("");
|
||||
@@ -106,11 +165,8 @@ function CreateModal({ onClose, onCreate }: {
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-bold">Create Incident</h2>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
||||
@@ -138,114 +194,37 @@ function CreateModal({ onClose, onCreate }: {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit" disabled={saving}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
|
||||
>
|
||||
{saving ? "Creating…" : "Create"}
|
||||
</button>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={saving}>{saving ? "Creating…" : "Create"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentCards({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{incidents.map((inc) => (
|
||||
<div
|
||||
key={inc.incident_id}
|
||||
className="bg-gray-900 border border-gray-800 rounded-xl p-4 cursor-pointer active:bg-gray-800"
|
||||
onClick={() => router.push(`/incidents/${inc.incident_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeBadge(inc.type)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
inc.status === "active" ? "bg-green-900 text-green-300" : "bg-gray-800 text-gray-400"
|
||||
}`}>{inc.status}</span>
|
||||
</div>
|
||||
{isAdmin && inc.status === "active" && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onResolve(inc.incident_id); }}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-white text-sm font-semibold leading-snug">{inc.title ?? "—"}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{severityBadge(inc.severity)}
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{fmtTime(inc.started_at)} · {inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentTable({ incidents, isAdmin, onResolve }: {
|
||||
incidents: IncidentRecord[];
|
||||
isAdmin: boolean;
|
||||
onResolve: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile card view */}
|
||||
<div className="sm:hidden">
|
||||
<IncidentCards incidents={incidents} isAdmin={isAdmin} onResolve={onResolve} />
|
||||
</div>
|
||||
|
||||
{/* Desktop table view */}
|
||||
<div className="hidden sm:block bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Severity</th>
|
||||
<th className="px-4 py-3">Calls</th>
|
||||
<th className="px-4 py-3">Started</th>
|
||||
<th className="px-4 py-3">Updated</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((inc) => (
|
||||
<IncidentRow
|
||||
key={inc.incident_id}
|
||||
incident={inc}
|
||||
isAdmin={isAdmin}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function IncidentsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { incidents, loading } = useIncidents();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||
|
||||
const active = incidents.filter((i) => i.status === "active");
|
||||
const resolved = incidents.filter((i) => i.status === "resolved");
|
||||
const filtered = useMemo(() => {
|
||||
const threshold = FILTER_THRESHOLD[severityFilter];
|
||||
const list = incidents.filter((i) => severityRank(i.severity) >= threshold);
|
||||
if (sortMode === "severity") {
|
||||
return [...list].sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
||||
}
|
||||
return list; // useIncidents() already orders by started_at desc
|
||||
}, [incidents, severityFilter, sortMode]);
|
||||
|
||||
const active = filtered.filter((i) => i.status === "active");
|
||||
const resolved = filtered.filter((i) => i.status === "resolved");
|
||||
const hiddenCount = incidents.length - filtered.length;
|
||||
|
||||
async function handleResolve(id: string) {
|
||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||
@@ -253,30 +232,53 @@ export default function IncidentsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-white text-xl font-bold font-mono">Incidents</h1>
|
||||
{active.length > 0 && (
|
||||
<span className="text-xs bg-red-900 text-red-300 px-2 py-0.5 rounded-full font-mono">
|
||||
{active.length} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Incidents"
|
||||
badge={active.length > 0 && <Badge tone="danger">{active.length} active</Badge>}
|
||||
action={isAdmin && <Button onClick={() => setShowCreate(true)}>+ Create Incident</Button>}
|
||||
/>
|
||||
|
||||
{/* Severity filter + sort — severity is a filter dimension, not decoration */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
|
||||
key={key}
|
||||
onClick={() => setSeverityFilter(key)}
|
||||
className={`text-sm font-mono px-3.5 py-1.5 rounded-md transition-colors ${
|
||||
severityFilter === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
+ Create Incident
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs font-mono text-gray-500">
|
||||
Sort
|
||||
<select
|
||||
value={sortMode}
|
||||
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg px-2 py-1.5 text-gray-200 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="recent">Most recent</option>
|
||||
<option value="severity">Highest severity</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{hiddenCount > 0 && (
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
{hiddenCount} incident{hiddenCount !== 1 ? "s" : ""} hidden by the severity filter.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Active</h2>
|
||||
@@ -291,8 +293,20 @@ export default function IncidentsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{incidents.length === 0 && (
|
||||
<p className="text-gray-600 text-sm font-mono">No incidents recorded yet.</p>
|
||||
{filtered.length === 0 && (
|
||||
<EmptyState
|
||||
title={incidents.length === 0 ? "No incidents recorded yet" : "No incidents match this filter"}
|
||||
description={
|
||||
incidents.length === 0
|
||||
? "Incidents appear automatically once calls start correlating."
|
||||
: "Try a lower severity threshold."
|
||||
}
|
||||
action={
|
||||
incidents.length > 0 && severityFilter !== "all" ? (
|
||||
<Button variant="secondary" size="sm" onClick={() => setSeverityFilter("all")}>Clear filter</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { AuthProvider } from "@/components/AuthProvider";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import { ChromeSwitcher } from "@/components/ChromeSwitcher";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "DRB Portal",
|
||||
description: "Distributed Radio Bot — Control & Monitoring",
|
||||
title: "DRB — Public-Safety Radio Intelligence",
|
||||
description: "Live incident awareness from field SDR nodes — transcribed, correlated, and mapped in real time.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -19,8 +19,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
<body className="min-h-screen bg-gray-950">
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Nav />
|
||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||
<ChromeSwitcher>{children}</ChromeSwitcher>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { signInWithEmailAndPassword, GoogleAuthProvider, signInWithPopup } from "firebase/auth";
|
||||
import { auth } from "@/lib/firebase";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
@@ -44,8 +45,12 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto pt-16">
|
||||
<Link href="/" className="flex items-center justify-center gap-2 mb-6 font-mono font-bold text-white">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||
DRB
|
||||
</Link>
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
||||
<h1 className="text-white text-lg font-bold">DRB Portal</h1>
|
||||
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -192,6 +192,54 @@ export default function NodeDetailPage() {
|
||||
<StatusBadge status={node.status} />
|
||||
</div>
|
||||
|
||||
{/* Override Warning */}
|
||||
{node.is_overridden && (
|
||||
<div className="bg-yellow-950/40 border border-yellow-800/60 rounded-lg p-4 font-mono text-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-yellow-400 font-semibold">
|
||||
<span className="text-base">⚠</span>
|
||||
<span>Local System Override Active</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-xs leading-relaxed">
|
||||
This node is operating on a local system override.
|
||||
{node.override_timeout_at ? (
|
||||
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
||||
) : (
|
||||
<> No timeout is currently enforced (permanent override).</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{node.override_timeout_at && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await c2api.ackOverride(id, 1440);
|
||||
} catch (e) {
|
||||
alert("Failed to extend timer.");
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-yellow-800 hover:bg-yellow-700 text-white rounded text-xs transition-colors"
|
||||
>
|
||||
Ack (Reset 24h Timer)
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm("Force this node to revert back to its assigned system config?")) {
|
||||
try {
|
||||
await c2api.resetOverride(id);
|
||||
} catch (e) {
|
||||
alert("Failed to reset override.");
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 bg-red-900 hover:bg-red-800 text-red-200 rounded text-xs transition-colors"
|
||||
>
|
||||
Force Revert Config
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
||||
{[
|
||||
@@ -199,6 +247,8 @@ export default function NodeDetailPage() {
|
||||
["Location", `${node.lat}, ${node.lon}`],
|
||||
["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"],
|
||||
["Configured", node.configured ? "Yes" : "No"],
|
||||
["Node Type", node.node_type ?? "fixed"],
|
||||
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
|
||||
+139
-3
@@ -1,5 +1,141 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PLANS } from "@/lib/billing";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
const CAPABILITIES = [
|
||||
{
|
||||
title: "Incidents, not raw calls",
|
||||
body: "Individual transmissions are correlated into a single incident — a pursuit becomes a path through every checkin point, a structure fire becomes a pin at the dispatched address.",
|
||||
},
|
||||
{
|
||||
title: "AI transcription & extraction",
|
||||
body: "Every call is transcribed and scanned for units, vehicles, and locations, so an incident page reads like a briefing instead of a call log.",
|
||||
},
|
||||
{
|
||||
title: "Live map, full history",
|
||||
body: "Watch what's happening right now, or scrub back through history to see how an incident unfolded call by call.",
|
||||
},
|
||||
{
|
||||
title: "Field SDR nodes",
|
||||
body: "Lightweight edge nodes decode P25 and analog police/fire traffic and stream it to your account — deploy one node or a whole regional network.",
|
||||
},
|
||||
{
|
||||
title: "Discord voice relay",
|
||||
body: "Pipe live radio audio into a Discord channel so your team can listen along in real time, no separate scanner app required.",
|
||||
},
|
||||
{
|
||||
title: "Role-scoped access",
|
||||
body: "Admins, operators scoped to the nodes they own, and read-only viewers — invite your team with the access level that fits.",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{ n: "01", title: "Deploy a node", body: "Point a field SDR node at your local P25 or analog system. It streams decoded audio to your DRB account over the network." },
|
||||
{ n: "02", title: "We transcribe & correlate", body: "Calls are transcribed, entities are extracted, and related calls are correlated into incidents automatically." },
|
||||
{ n: "03", title: "Your team watches", body: "Incidents show up on the live map and dashboard with an AI summary, units on scene, and every related recording." },
|
||||
];
|
||||
|
||||
export default function MarketingHomePage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="marketing-hero-bg">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 pt-20 pb-24 md:pt-28 md:pb-32">
|
||||
<div className="max-w-3xl">
|
||||
<Badge tone="brand">Public-safety radio intelligence</Badge>
|
||||
<h1 className="text-display-sm md:text-display mt-5 text-white">
|
||||
See what's happening on the radio, as an incident — not a wall of calls.
|
||||
</h1>
|
||||
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
||||
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
|
||||
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
|
||||
bot to relay the audio live to your team.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-8">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
<LinkButton href="/pricing" variant="secondary" size="lg">View pricing</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Capabilities */}
|
||||
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="max-w-2xl mb-10">
|
||||
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
||||
<p className="text-gray-400 mt-3">
|
||||
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
|
||||
about — and let you click into any one of them for the full picture.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{CAPABILITIES.map((c) => (
|
||||
<Card key={c.title} padding="lg" hover>
|
||||
<h3 className="text-white font-semibold">{c.title}</h3>
|
||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="border-t border-gray-800">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<h2 className="text-display-sm text-white mb-10">How it works</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{STEPS.map((s) => (
|
||||
<div key={s.n}>
|
||||
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
||||
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
|
||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pricing teaser */}
|
||||
<section className="border-t border-gray-800">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4 mb-10">
|
||||
<div>
|
||||
<h2 className="text-display-sm text-white">Plans for one node or a whole region</h2>
|
||||
<p className="text-gray-400 mt-2">Start free. Upgrade when you add nodes or need longer retention.</p>
|
||||
</div>
|
||||
<Link href="/pricing" className="text-indigo-400 hover:text-indigo-300 text-sm font-mono transition-colors shrink-0">
|
||||
See full plan comparison →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5">
|
||||
{PLANS.map((plan) => (
|
||||
<Card key={plan.id} padding="lg" highlighted={plan.highlighted}>
|
||||
{plan.highlighted && <Badge tone="brand" className="mb-3">Most popular</Badge>}
|
||||
<h3 className="text-white font-semibold">{plan.name}</h3>
|
||||
<p className="text-gray-500 text-xs mt-1">{plan.tagline}</p>
|
||||
<p className="text-white text-2xl font-bold font-mono mt-4">
|
||||
{plan.priceMonthlyUsd === null ? "Custom" : plan.priceMonthlyUsd === 0 ? "Free" : `$${plan.priceMonthlyUsd}`}
|
||||
{plan.priceMonthlyUsd !== null && plan.priceMonthlyUsd > 0 && <span className="text-gray-500 text-sm font-normal">/mo</span>}
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="border-t border-gray-800">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
||||
<h2 className="text-display-sm text-white">Bring your first node online</h2>
|
||||
<p className="text-gray-400 mt-3 max-w-xl mx-auto">
|
||||
Sign in to create an account, add a node, and start seeing incidents within minutes of your first call.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { PLANS, type BillingInterval } from "@/lib/billing";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="text-green-400 shrink-0 mt-0.5">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PricingPage() {
|
||||
const [interval, setInterval] = useState<BillingInterval>("monthly");
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="text-center max-w-2xl mx-auto">
|
||||
<h1 className="text-display-sm md:text-display text-white">Simple, node-based pricing</h1>
|
||||
<p className="text-gray-400 mt-4">
|
||||
Every plan includes the full incident pipeline — transcription, correlation, mapping, and the Discord relay.
|
||||
Plans differ in how many nodes and seats you get, and how far back your history goes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Interval toggle */}
|
||||
<div className="flex items-center justify-center gap-1 mt-10 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit mx-auto">
|
||||
{(["monthly", "annual"] as BillingInterval[]).map((i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setInterval(i)}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||
interval === i ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
{i === "annual" && <span className="ml-1.5 text-green-400 text-xs">save ~17%</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Plan cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-10 items-stretch">
|
||||
{PLANS.map((plan) => {
|
||||
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||
const priceLabel =
|
||||
price === null ? "Custom" : price === 0 ? "Free" : `$${interval === "annual" ? Math.round(price / 12) : price}`;
|
||||
|
||||
return (
|
||||
<Card key={plan.id} padding="lg" highlighted={plan.highlighted} className="flex flex-col">
|
||||
{plan.highlighted && <Badge tone="brand" className="mb-3 w-fit">Most popular</Badge>}
|
||||
<h2 className="text-white text-lg font-bold">{plan.name}</h2>
|
||||
<p className="text-gray-500 text-sm mt-1.5 leading-relaxed">{plan.tagline}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<span className="text-white text-3xl font-bold font-mono">{priceLabel}</span>
|
||||
{price !== null && price > 0 && <span className="text-gray-500 text-sm">/mo</span>}
|
||||
{interval === "annual" && price !== null && price > 0 && (
|
||||
<p className="text-gray-600 text-xs mt-1">billed ${plan.priceAnnualUsd}/year</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LinkButton href="/login" variant={plan.highlighted ? "primary" : "secondary"} fullWidth>
|
||||
{plan.priceMonthlyUsd === null ? "Contact sales" : "Get started"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-2.5 flex-1">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm text-gray-300">
|
||||
<CheckIcon />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-600 text-xs font-mono mt-8">
|
||||
Prices shown are sample figures for this demo build — nothing here is connected to a live payment processor.
|
||||
</p>
|
||||
|
||||
<div className="text-center mt-16">
|
||||
<p className="text-gray-400">
|
||||
Questions about a plan?{" "}
|
||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||
{" "}or{" "}
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to talk to us</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listApiKeys, createApiKey, revokeApiKey, type ApiKeyRecord } from "@/lib/apiKeys";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated: (r: ApiKeyRecord) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rawKey, setRawKey] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const { record, rawKey } = await createApiKey(name);
|
||||
onCreated(record);
|
||||
setRawKey(rawKey);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copy() {
|
||||
if (!rawKey) return;
|
||||
navigator.clipboard?.writeText(rawKey).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
||||
}
|
||||
|
||||
if (rawKey) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||
<h2 className="text-white font-semibold">Key created</h2>
|
||||
<p className="text-xs text-gray-400">
|
||||
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
||||
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
||||
</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={copy} fullWidth>{copied ? "Copied!" : "Copy key"}</Button>
|
||||
<Button onClick={onClose} fullWidth>Done</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md">
|
||||
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
||||
<input
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Ops dashboard integration"
|
||||
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="flex gap-3">
|
||||
<Button type="submit" disabled={saving} fullWidth>{saving ? "Creating…" : "Create key"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ApiKeysSettingsPage() {
|
||||
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
useEffect(() => { listApiKeys().then(setKeys).finally(() => setLoading(false)); }, []);
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
await revokeApiKey(id);
|
||||
setKeys((prev) => prev.map((k) => (k.key_id === id ? { ...k, revoked: true } : k)));
|
||||
}
|
||||
|
||||
const active = keys.filter((k) => !k.revoked);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
||||
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
||||
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
||||
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
||||
end. See that file for the exact backend routes a real integration needs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<CreateKeyModal onClose={() => setShowCreate(false)} onCreated={(r) => setKeys((prev) => [...prev, r])} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
||||
</div>
|
||||
|
||||
{!loading && keys.length === 0 ? (
|
||||
<EmptyState title="No API keys yet" description="Create one to authenticate external integrations against the DRB API." />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Label</th>
|
||||
<th className="px-4 py-3 text-left">Key</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Last used</th>
|
||||
<th className="px-4 py-3 text-left">Status</th>
|
||||
<th className="px-4 py-3 w-20"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3 text-white">{k.name}</td>
|
||||
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!k.revoked && (
|
||||
<button onClick={() => handleRevoke(k.key_id)} className="text-xs text-red-500 hover:text-red-400 transition-colors">
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
PLANS, getPlan, getCurrentSubscription, getUsageSummary, getInvoices,
|
||||
createCheckoutSession, createBillingPortalSession,
|
||||
type Subscription, type UsageSummary, type Invoice, type PlanId,
|
||||
} from "@/lib/billing";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SkeletonCard } from "@/components/ui/Skeleton";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function StatusBanner({ sub }: { sub: Subscription }) {
|
||||
if (sub.status === "trialing" && sub.trialEndsAt) {
|
||||
return (
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-indigo-300">
|
||||
Trial active — ends {fmtDate(sub.trialEndsAt)}. Add a payment method to keep your plan after that.
|
||||
</p>
|
||||
<Badge tone="brand">Trial</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.status === "past_due") {
|
||||
return (
|
||||
<div className="bg-red-600/10 border border-red-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-red-400">
|
||||
Payment failed on your last invoice. Update your payment method to avoid losing access.
|
||||
</p>
|
||||
<Badge tone="danger">Past due</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (sub.status === "canceled") {
|
||||
return (
|
||||
<div className="bg-yellow-600/10 border border-yellow-600/40 rounded-xl p-4 flex items-center justify-between gap-4 flex-wrap">
|
||||
<p className="text-sm text-yellow-400">Your subscription is canceled. Reactivate to restore full access.</p>
|
||||
<Badge tone="warning">Canceled</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function UsageBar({ label, used, limit }: { label: string; used: number; limit: number | "unlimited" }) {
|
||||
const pct = limit === "unlimited" ? 0 : Math.min(100, Math.round((used / Math.max(limit, 1)) * 100));
|
||||
const nearLimit = limit !== "unlimited" && used / limit >= 0.9;
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-400 font-mono">{label}</span>
|
||||
<span className="text-xs font-mono text-gray-300">
|
||||
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INVOICE_TONE: Record<Invoice["status"], "success" | "warning" | "neutral" | "danger"> = {
|
||||
paid: "success",
|
||||
open: "warning",
|
||||
void: "neutral",
|
||||
uncollectible: "danger",
|
||||
};
|
||||
|
||||
export default function BillingSettingsPage() {
|
||||
const [sub, setSub] = useState<Subscription | null>(null);
|
||||
const [usage, setUsage] = useState<UsageSummary | null>(null);
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busyPlan, setBusyPlan] = useState<PlanId | null>(null);
|
||||
const [portalBusy, setPortalBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getCurrentSubscription(), getUsageSummary(), getInvoices()])
|
||||
.then(([s, u, i]) => { setSub(s); setUsage(u); setInvoices(i); })
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleChoosePlan(planId: PlanId) {
|
||||
setBusyPlan(planId);
|
||||
setActionError(null);
|
||||
try {
|
||||
const { url } = await createCheckoutSession(planId, sub?.interval ?? "monthly");
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusyPlan(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManageBilling() {
|
||||
setPortalBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const { url } = await createBillingPortalSession();
|
||||
window.location.href = url;
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setPortalBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || !sub || !usage) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SkeletonCard /><SkeletonCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const plan = getPlan(sub.planId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
||||
</p>
|
||||
|
||||
<StatusBanner sub={sub} />
|
||||
|
||||
{actionError && (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm">{actionError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Current plan"
|
||||
subtitle={sub.cancelAtPeriodEnd ? `Cancels ${sub.currentPeriodEnd ? fmtDate(sub.currentPeriodEnd) : "at period end"}` : sub.currentPeriodEnd ? `Renews ${fmtDate(sub.currentPeriodEnd)}` : undefined}
|
||||
action={<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name}</Badge>}
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800">
|
||||
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Change plan" subtitle="Upgrading takes effect immediately; downgrading takes effect at the end of the current period." />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{PLANS.map((p) => {
|
||||
const isCurrent = p.id === sub.planId;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
|
||||
>
|
||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
||||
<p className="text-white text-lg font-bold font-mono mt-3">
|
||||
{p.priceMonthlyUsd === null ? "Custom" : p.priceMonthlyUsd === 0 ? "Free" : `$${p.priceMonthlyUsd}/mo`}
|
||||
</p>
|
||||
<Button
|
||||
className="mt-3"
|
||||
size="sm"
|
||||
variant={isCurrent ? "secondary" : "primary"}
|
||||
disabled={isCurrent || busyPlan === p.id}
|
||||
onClick={() => handleChoosePlan(p.id)}
|
||||
fullWidth
|
||||
>
|
||||
{isCurrent ? "Current plan" : busyPlan === p.id ? "Redirecting…" : p.priceMonthlyUsd === null ? "Contact sales" : "Switch"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Invoice history" />
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm">No invoices yet.</p>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{invoices.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gray-200 text-sm">{inv.description}</p>
|
||||
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/settings/organization", label: "Organization" },
|
||||
{ href: "/settings/members", label: "Members" },
|
||||
{ href: "/settings/nodes", label: "Node Ownership" },
|
||||
{ href: "/settings/api-keys", label: "API Keys" },
|
||||
{ href: "/settings/billing", label: "Billing" },
|
||||
];
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const { isAdmin, loading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !isAdmin) router.replace("/dashboard");
|
||||
}, [loading, isAdmin, router]);
|
||||
|
||||
if (loading || !isAdmin) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Organization profile, team access, node ownership, API keys, and billing."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||
{TABS.map((t) => (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import type { UserRecord, UserRole } from "@/lib/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState, ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonRow } from "@/components/ui/Skeleton";
|
||||
|
||||
const ROLE_TONE: Record<UserRole, "brand" | "success" | "neutral"> = {
|
||||
admin: "brand",
|
||||
operator: "success",
|
||||
viewer: "neutral",
|
||||
};
|
||||
|
||||
const ROLE_LABEL: Record<UserRole, string> = { admin: "Admin", operator: "Operator", viewer: "Viewer" };
|
||||
|
||||
function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (u: UserRecord) => void }) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<UserRole>("viewer");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await c2api.createUser({ email, role });
|
||||
onCreated(created);
|
||||
if (created.invite_link) setInviteLink(created.invite_link);
|
||||
else onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (inviteLink) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-semibold">Member invited</h2>
|
||||
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||
</div>
|
||||
<Button onClick={onClose} fullWidth>Done</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<Card padding="lg" className="w-full max-w-md">
|
||||
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
placeholder="teammate@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
||||
<select
|
||||
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
||||
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"
|
||||
>
|
||||
<option value="admin">Admin — full access</option>
|
||||
<option value="operator">Operator — owns nodes</option>
|
||||
<option value="viewer">Viewer — read-only</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
<div className="flex gap-3 pt-1">
|
||||
<Button type="submit" disabled={saving} fullWidth>{saving ? "Sending…" : "Send invite"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={onClose} fullWidth>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MembersSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [savingUid, setSavingUid] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setUsers(await c2api.listUsers());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
async function handleRoleChange(u: UserRecord, role: UserRole) {
|
||||
setSavingUid(u.uid);
|
||||
try {
|
||||
const updated = await c2api.updateUser(u.uid, { role, owned_node_ids: role === "operator" ? u.owned_node_ids : [] });
|
||||
setUsers((prev) => prev.map((x) => (x.uid === u.uid ? { ...x, ...updated } : x)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingUid(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showInvite && (
|
||||
<InviteModal onClose={() => setShowInvite(false)} onCreated={(u) => setUsers((prev) => [...prev, u])} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{!loading && users.length === 0 ? (
|
||||
<EmptyState title="No members yet" description="Invite your team to give them dashboard access." />
|
||||
) : (
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Member</th>
|
||||
<th className="px-4 py-3 text-left">Role</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
|
||||
<th className="px-4 py-3 text-left hidden md:table-cell">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading
|
||||
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||
: users.map((u) => (
|
||||
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-white">{u.display_name || u.email}</p>
|
||||
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.uid === user?.uid ? (
|
||||
<Badge tone={ROLE_TONE[u.role]}>{ROLE_LABEL[u.role]}</Badge>
|
||||
) : (
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={savingUid === u.uid}
|
||||
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
|
||||
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
{u.disabled ? <Badge tone="danger">Disabled</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-600 font-mono">
|
||||
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { UserRecord } from "@/lib/types";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import { SkeletonRow } from "@/components/ui/Skeleton";
|
||||
|
||||
const UNASSIGNED = "__unassigned__";
|
||||
|
||||
export default function NodeOwnershipSettingsPage() {
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingNodeId, setSavingNodeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
c2api.listUsers()
|
||||
.then(setUsers)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => setLoadingUsers(false));
|
||||
}, []);
|
||||
|
||||
// Ownership (owned_node_ids) is only meaningful for operators elsewhere in the
|
||||
// app (see Admin → Users); admins already have full access regardless.
|
||||
const assignable = useMemo(() => users.filter((u) => u.role === "operator"), [users]);
|
||||
|
||||
const ownerByNode = useMemo(() => {
|
||||
const map = new Map<string, UserRecord>();
|
||||
for (const u of users) {
|
||||
if (u.role !== "operator") continue;
|
||||
for (const nodeId of u.owned_node_ids) map.set(nodeId, u);
|
||||
}
|
||||
return map;
|
||||
}, [users]);
|
||||
|
||||
const reassign = useCallback(async (nodeId: string, newUid: string) => {
|
||||
setSavingNodeId(nodeId);
|
||||
setError(null);
|
||||
try {
|
||||
const prevOwner = ownerByNode.get(nodeId);
|
||||
// Remove from previous owner, if any and different from the new one.
|
||||
if (prevOwner && prevOwner.uid !== newUid) {
|
||||
const next = prevOwner.owned_node_ids.filter((id) => id !== nodeId);
|
||||
await c2api.updateUser(prevOwner.uid, { owned_node_ids: next });
|
||||
setUsers((all) => all.map((u) => (u.uid === prevOwner.uid ? { ...u, owned_node_ids: next } : u)));
|
||||
}
|
||||
// Add to new owner, if one was selected.
|
||||
if (newUid !== UNASSIGNED) {
|
||||
const newOwner = users.find((u) => u.uid === newUid);
|
||||
if (newOwner && !newOwner.owned_node_ids.includes(nodeId)) {
|
||||
const next = [...newOwner.owned_node_ids, nodeId];
|
||||
await c2api.updateUser(newOwner.uid, { owned_node_ids: next });
|
||||
setUsers((all) => all.map((u) => (u.uid === newOwner.uid ? { ...u, owned_node_ids: next } : u)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingNodeId(null);
|
||||
}
|
||||
}, [ownerByNode, users]);
|
||||
|
||||
const loading = nodesLoading || loadingUsers;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
||||
</p>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<th className="px-4 py-3 text-left">Node</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
|
||||
<th className="px-4 py-3 text-left">Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||
) : nodes.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
|
||||
) : (
|
||||
nodes.map((n) => {
|
||||
const owner = ownerByNode.get(n.node_id);
|
||||
return (
|
||||
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||
{n.name}
|
||||
</Link>
|
||||
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={owner?.uid ?? UNASSIGNED}
|
||||
disabled={savingNodeId === n.node_id}
|
||||
onChange={(e) => reassign(n.node_id, e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
|
||||
>
|
||||
<option value={UNASSIGNED}>Unassigned</option>
|
||||
{assignable.map((u) => (
|
||||
<option key={u.uid} value={u.uid}>{u.display_name || u.email}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { getCurrentSubscription, getPlan, type Subscription } from "@/lib/billing";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
|
||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
||||
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrganizationSettingsPage() {
|
||||
const { nodes } = useNodes();
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [sub, setSub] = useState<Subscription | null>(null);
|
||||
const [orgName, setOrgName] = useState("My Organization");
|
||||
|
||||
useEffect(() => {
|
||||
c2api.listUsers().then((u) => setMemberCount(u.length)).catch(() => setMemberCount(null));
|
||||
getCurrentSubscription().then(setSub);
|
||||
}, []);
|
||||
|
||||
const plan = sub ? getPlan(sub.planId) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Organization profile"
|
||||
subtitle="Basic identity for this DRB account."
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
||||
<input
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(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 className="flex items-center gap-3">
|
||||
<Button size="sm" disabled title="Organization profile isn't persisted server-side yet">
|
||||
Save changes
|
||||
</Button>
|
||||
<span className="text-xs text-gray-600 font-mono">
|
||||
Preview only — no backend endpoint stores this yet.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Overview"
|
||||
action={
|
||||
plan ? (
|
||||
<Badge tone={plan.id === "free" ? "neutral" : "brand"}>{plan.name} plan</Badge>
|
||||
) : (
|
||||
<Skeleton className="h-5 w-16" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
|
||||
<StatTile label="Nodes" value={nodes.length} />
|
||||
<StatTile label="Members" value={memberCount ?? "—"} />
|
||||
<StatTile
|
||||
label="Status"
|
||||
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
|
||||
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Manage plan & billing →
|
||||
</Link>
|
||||
<Link href="/settings/members" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||
Manage members →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
||||
Delete organization
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" disabled title="Not available in this build — contact support">
|
||||
Transfer ownership
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function SettingsIndexPage() {
|
||||
redirect("/settings/organization");
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { severityBadge } from "@/lib/severity";
|
||||
|
||||
interface Props {
|
||||
call: CallRecord;
|
||||
@@ -37,11 +38,31 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
: call.incident_id ? [call.incident_id] : [];
|
||||
|
||||
const isActive = call.status === "active";
|
||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || call.audio_url;
|
||||
// Rows come straight from Firestore (lib/useCalls.ts), and the doc only holds
|
||||
// the private gs:// object location — never a playable URL. Presence of audio
|
||||
// is known from the doc; the actual link is minted by the API on expand.
|
||||
// audio_url is the legacy field: older docs stored an (unplayable) gs:// URI
|
||||
// there, so it still signals "this call has a recording".
|
||||
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
||||
const hasDetails = call.transcript || call.transcript_corrected || (call.tags && call.tags.length > 0) || incidentIds.length > 0 || hasAudio;
|
||||
const displayTranscript = (!showOriginal && call.transcript_corrected) ? call.transcript_corrected : call.transcript;
|
||||
const hasBoth = !!(call.transcript && call.transcript_corrected);
|
||||
const hasSegments = call.segments && call.segments.length > 1;
|
||||
|
||||
// Fetched lazily on expand: playback links are short-lived, so minting one
|
||||
// for every row up front would waste most of them and expire the rest.
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [audioError, setAudioError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || !hasAudio || audioUrl || audioError) return;
|
||||
let cancelled = false;
|
||||
c2api.getCall(call.call_id)
|
||||
.then((full) => { if (!cancelled) setAudioUrl(full.audio_url ?? null); })
|
||||
.catch(() => { if (!cancelled) setAudioError(true); });
|
||||
return () => { cancelled = true; };
|
||||
}, [expanded, hasAudio, audioUrl, audioError, call.call_id]);
|
||||
|
||||
function startEdit() {
|
||||
setEditText(call.transcript_corrected ?? call.transcript ?? "");
|
||||
setEditing(true);
|
||||
@@ -78,6 +99,10 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{call.tags[0]}
|
||||
</span>
|
||||
)}
|
||||
{/* Routine/minor are the majority of traffic and stay unbadged; moderate+ is the triage signal worth a badge in a dense list. */}
|
||||
{(call.severity === "moderate" || call.severity === "major") && (
|
||||
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
||||
@@ -89,7 +114,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{call.audio_url ? (
|
||||
{hasAudio ? (
|
||||
<span className="text-blue-400">▶</span>
|
||||
) : (
|
||||
<span className="text-gray-700">—</span>
|
||||
@@ -104,13 +129,19 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||
{/* Audio player */}
|
||||
{call.audio_url && (
|
||||
{hasAudio && (
|
||||
audioError ? (
|
||||
<p className="text-xs text-red-400 font-mono">Could not load audio.</p>
|
||||
) : audioUrl ? (
|
||||
<audio
|
||||
controls
|
||||
src={call.audio_url}
|
||||
src={audioUrl}
|
||||
className="w-full max-w-sm h-8"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Nav } from "@/components/Nav";
|
||||
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
|
||||
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
|
||||
|
||||
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
|
||||
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
|
||||
const MARKETING_PATHS = new Set(["/", "/features", "/pricing", "/faq"]);
|
||||
|
||||
/**
|
||||
* Picks page chrome by route: the public marketing pages get a full-bleed
|
||||
* layout with their own header/footer, everything else (the authenticated
|
||||
* app, including /login and /settings) keeps the existing app Nav + padded
|
||||
* main container.
|
||||
*/
|
||||
export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (MARKETING_PATHS.has(pathname)) {
|
||||
return (
|
||||
<>
|
||||
<MarketingHeader />
|
||||
{children}
|
||||
<MarketingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="max-w-screen-2xl mx-auto px-4 md:px-6 py-6">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Shared incident "type" badge — used on the incidents list, incident detail,
|
||||
// and the dashboard's active-incidents panel. `other` covers anything outside
|
||||
// the four radio-traffic archetypes (rail ops, public works, utility
|
||||
// coordination, …) and must always resolve to a styled badge, never fall
|
||||
// through unstyled.
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
fire: "bg-red-900 text-red-300",
|
||||
police: "bg-blue-900 text-blue-300",
|
||||
ems: "bg-yellow-900 text-yellow-300",
|
||||
accident: "bg-orange-900 text-orange-300",
|
||||
other: "bg-gray-800 text-gray-300",
|
||||
};
|
||||
|
||||
export function TypeBadge({ type }: { type: string | null }) {
|
||||
const cls = TYPE_COLORS[type ?? "other"] ?? TYPE_COLORS.other;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full capitalize ${cls}`}>
|
||||
{type ?? "other"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const operatorLinks = [
|
||||
// Admin-only links
|
||||
const adminLinks = [
|
||||
{ href: "/admin", label: "Admin" },
|
||||
{ href: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
function SunIcon() {
|
||||
|
||||
@@ -45,6 +45,18 @@ export function NodeCard({ node, system }: Props) {
|
||||
⚠ Needs configuration
|
||||
</div>
|
||||
)}
|
||||
{node.is_overridden && (
|
||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
||||
<span>⚠ Local Override</span>
|
||||
{node.override_timeout_at ? (
|
||||
<span className="text-gray-500">
|
||||
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Permanent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,11 @@ const PRESETS = [
|
||||
];
|
||||
|
||||
export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
const [systemId, setSystemId] = useState("");
|
||||
const [preset, setPreset] = useState("rtl-sdr-v3");
|
||||
const [ppm, setPpm] = useState("0");
|
||||
const [systemId, setSystemId] = useState(node.assigned_system_id ?? "");
|
||||
const [preset, setPreset] = useState(node.hardware_preset ?? "rtl-sdr-v3");
|
||||
const [ppm, setPpm] = useState(node.ppm_override ? String(node.ppm_override) : "0");
|
||||
const [nodeType, setNodeType] = useState(node.node_type ?? "");
|
||||
const [enforceTimeout, setEnforceTimeout] = useState(node.enforce_override_timeout ?? true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -32,6 +34,10 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await c2api.updateNode(node.node_id, {
|
||||
node_type: nodeType,
|
||||
enforce_override_timeout: enforceTimeout,
|
||||
});
|
||||
await c2api.assignSystem(node.node_id, systemId, preset, ppmOverride);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
@@ -100,12 +106,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
|
||||
<select
|
||||
value={nodeType}
|
||||
onChange={(e) => setNodeType(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
required
|
||||
>
|
||||
<option value="">Select node type...</option>
|
||||
<option value="fixed">Fixed Node (Standard)</option>
|
||||
<option value="portable">Portable Node (Handheld)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{nodeType === "fixed" && (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enforceTimeout"
|
||||
checked={enforceTimeout}
|
||||
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
/>
|
||||
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
|
||||
Enforce Local Override Timeout (24 hours)
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !systemId}
|
||||
disabled={saving || !systemId || !nodeType}
|
||||
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"
|
||||
>
|
||||
{saving ? "Saving…" : "Assign & Configure"}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function MarketingFooter() {
|
||||
return (
|
||||
<footer className="border-t border-gray-800 mt-24">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-10 flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2 font-mono font-bold text-white">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-lg bg-indigo-600 text-white text-xs">D</span>
|
||||
DRB
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm font-mono text-gray-500">
|
||||
<Link href="/features" className="hover:text-gray-300 transition-colors">Features</Link>
|
||||
<Link href="/pricing" className="hover:text-gray-300 transition-colors">Pricing</Link>
|
||||
<Link href="/faq" className="hover:text-gray-300 transition-colors">FAQ</Link>
|
||||
<Link href="/login" className="hover:text-gray-300 transition-colors">Sign in</Link>
|
||||
</nav>
|
||||
|
||||
<p className="text-xs font-mono text-gray-600">© {new Date().getFullYear()} DRB. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { LinkButton } from "@/components/ui/Button";
|
||||
|
||||
const LINKS = [
|
||||
{ href: "/features", label: "Features" },
|
||||
{ href: "/pricing", label: "Pricing" },
|
||||
{ href: "/faq", label: "FAQ" },
|
||||
];
|
||||
|
||||
export function MarketingHeader() {
|
||||
const pathname = usePathname();
|
||||
const { user, loading } = useAuth();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-gray-800 bg-gray-950/95 backdrop-blur">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-4 flex items-center gap-6">
|
||||
<Link href="/" className="flex items-center gap-2 shrink-0 font-mono font-bold text-white tracking-tight">
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-indigo-600 text-white text-sm">D</span>
|
||||
DRB
|
||||
</Link>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-6 ml-4">
|
||||
{LINKS.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`text-sm font-mono transition-colors ${
|
||||
pathname === href ? "text-white" : "text-gray-400 hover:text-gray-200"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto hidden md:flex items-center gap-3">
|
||||
{!loading && user ? (
|
||||
<LinkButton href="/dashboard" size="md">Go to dashboard</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton href="/login" variant="ghost" size="md">Sign in</LinkButton>
|
||||
<LinkButton href="/login" size="md">Get started</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden ml-auto text-gray-400 hover:text-gray-200 transition-colors p-1"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? (
|
||||
<svg width="22" height="22" 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="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden border-t border-gray-800 bg-gray-950 px-4 py-3 flex flex-col gap-1">
|
||||
{LINKS.map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`py-2 text-sm font-mono transition-colors ${pathname === href ? "text-white" : "text-gray-400"}`}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="border-t border-gray-800 pt-3 mt-2 flex flex-col gap-2">
|
||||
{!loading && user ? (
|
||||
<LinkButton href="/dashboard" size="md" fullWidth>Go to dashboard</LinkButton>
|
||||
) : (
|
||||
<>
|
||||
<LinkButton href="/login" variant="secondary" size="md" fullWidth>Sign in</LinkButton>
|
||||
<LinkButton href="/login" size="md" fullWidth>Get started</LinkButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Tone = "neutral" | "brand" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const TONE_CLASSES: Record<Tone, string> = {
|
||||
neutral: "bg-gray-800 text-gray-300",
|
||||
brand: "bg-indigo-900 text-indigo-300",
|
||||
success: "bg-green-900 text-green-300",
|
||||
warning: "bg-yellow-900 text-yellow-300",
|
||||
danger: "bg-red-900 text-red-300",
|
||||
info: "bg-blue-900 text-blue-300",
|
||||
};
|
||||
|
||||
export function Badge({ children, tone = "neutral", className }: { children: ReactNode; tone?: Tone; className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center gap-1 text-xs font-mono px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
TONE_CLASSES[tone],
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from "next/link";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type Size = "sm" | "md" | "lg";
|
||||
|
||||
const VARIANT_CLASSES: Record<Variant, string> = {
|
||||
primary:
|
||||
"bg-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 text-white shadow-card disabled:hover:bg-indigo-600",
|
||||
secondary:
|
||||
"bg-gray-800 hover:bg-gray-700 active:bg-gray-700 text-gray-100 border border-gray-700 disabled:hover:bg-gray-800",
|
||||
ghost:
|
||||
"bg-transparent hover:bg-gray-800 active:bg-gray-800 text-gray-300 hover:text-white disabled:hover:bg-transparent",
|
||||
danger:
|
||||
"bg-red-700 hover:bg-red-600 active:bg-red-700 text-white disabled:hover:bg-red-700",
|
||||
};
|
||||
|
||||
const SIZE_CLASSES: Record<Size, string> = {
|
||||
sm: "text-xs px-3 py-1.5 rounded-lg gap-1.5",
|
||||
md: "text-sm px-4 py-2 rounded-lg gap-2",
|
||||
lg: "text-sm px-5 py-2.5 rounded-xl gap-2",
|
||||
};
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center font-semibold font-mono transition-colors " +
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed " +
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-950";
|
||||
|
||||
interface CommonProps {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
type ButtonProps = CommonProps &
|
||||
ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
href?: undefined;
|
||||
};
|
||||
|
||||
interface LinkButtonProps extends CommonProps {
|
||||
href: string;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
function classes(variant: Variant, size: Size, fullWidth: boolean | undefined, extra?: string) {
|
||||
return [BASE, VARIANT_CLASSES[variant], SIZE_CLASSES[size], fullWidth ? "w-full" : "", extra ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/** Button — use for in-page actions. Pass `href` instead to render a Link (see LinkButton export). */
|
||||
export function Button({ variant = "primary", size = "md", children, className, fullWidth, ...rest }: ButtonProps) {
|
||||
return (
|
||||
<button className={classes(variant, size, fullWidth, className)} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Same visual language as Button, but renders a Next.js Link — for navigation, not actions. */
|
||||
export function LinkButton({ variant = "primary", size = "md", children, className, fullWidth, href, external }: LinkButtonProps) {
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={classes(variant, size, fullWidth, className)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className={classes(variant, size, fullWidth, className)}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
hover?: boolean;
|
||||
padding?: "none" | "sm" | "md" | "lg";
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
const PADDING: Record<NonNullable<CardProps["padding"]>, string> = {
|
||||
none: "",
|
||||
sm: "p-4",
|
||||
md: "p-5",
|
||||
lg: "p-8",
|
||||
};
|
||||
|
||||
/** Standard surface card — the base container used across app + settings + marketing. */
|
||||
export function Card({ children, hover, padding = "md", highlighted, className, ...rest }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"bg-gray-900 border rounded-xl",
|
||||
highlighted ? "border-indigo-600/40 shadow-glow" : "border-gray-800",
|
||||
hover ? "transition-colors hover:border-gray-600" : "",
|
||||
PADDING[padding],
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ title, subtitle, action }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm">{title}</h3>
|
||||
{subtitle && <p className="text-gray-500 text-xs mt-0.5 leading-snug">{subtitle}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
|
||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-gray-800 rounded-xl">
|
||||
{icon && <div className="text-gray-700 mb-3">{icon}</div>}
|
||||
<p className="text-gray-300 text-sm font-semibold font-mono">{title}</p>
|
||||
{description && <p className="text-gray-600 text-xs font-mono mt-1 max-w-sm">{description}</p>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline error banner — for API/Firestore errors surfaced within a page section. */
|
||||
export function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm font-mono">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
/** Standard page title row — title + optional badge on the left, primary action on the right. */
|
||||
export function PageHeader({ title, description, badge, action }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-white font-mono">{title}</h1>
|
||||
{badge}
|
||||
</div>
|
||||
{description && <p className="text-gray-500 text-sm mt-1 max-w-2xl">{description}</p>}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Loading placeholder block. Use instead of a bare "Loading…" string wherever the eventual
|
||||
* content has a predictable shape (cards, table rows, stat tiles). */
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={`skeleton bg-gray-800 rounded-md ${className ?? "h-4 w-full"}`} />;
|
||||
}
|
||||
|
||||
export function SkeletonCard() {
|
||||
return (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonRow({ cols = 5 }: { cols?: number }) {
|
||||
return (
|
||||
<tr className="border-b border-gray-800">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<td key={i} className="px-4 py-3">
|
||||
<Skeleton className="h-3 w-full max-w-[8rem]" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Organization API keys — STUB MODULE, no backend endpoint exists yet.
|
||||
*
|
||||
* This is a distinct concept from the two API-key-shaped things that already
|
||||
* exist server-side:
|
||||
* - `node_keys` (Firestore collection) — per-node upload credentials, issued
|
||||
* via /nodes/{id}/reissue-key. Not this.
|
||||
* - The Discord bot token pool (app/tokens) — Discord bot tokens, not this.
|
||||
*
|
||||
* This module models organization-level API keys for third-party
|
||||
* integrations (a standard SaaS feature) that DRB does not yet expose.
|
||||
* Everything below is in-memory demo state so the settings UI has something
|
||||
* real to render; nothing here is persisted or capable of authenticating
|
||||
* against the real API.
|
||||
*
|
||||
* TODO(api-keys): to make this real, add to drb-c2-core:
|
||||
* - `org_api_keys` Firestore collection: {key_id, org_id, name, key_hash,
|
||||
* key_prefix, created_at, last_used_at, created_by_uid, revoked}
|
||||
* - POST /org/api-keys → generate, return the raw key ONCE
|
||||
* - GET /org/api-keys → list (prefix + metadata only, never the raw key)
|
||||
* - DELETE /org/api-keys/{id} → revoke
|
||||
* - A new auth path in internal/auth.py that checks `Authorization: Bearer drb_live_…`
|
||||
* against `key_hash` (constant-time compare), scoped like a viewer/operator token.
|
||||
* Then replace the functions below with c2api calls hitting those routes.
|
||||
*/
|
||||
|
||||
export interface ApiKeyRecord {
|
||||
key_id: string;
|
||||
name: string;
|
||||
/** Only the prefix is ever shown after creation — mirrors how real key systems (Stripe, GitHub) do it. */
|
||||
key_prefix: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
revoked: boolean;
|
||||
}
|
||||
|
||||
// Sample/demo fixture — obviously not real keys, never sent anywhere.
|
||||
let DEMO_KEYS: ApiKeyRecord[] = [
|
||||
{
|
||||
key_id: "demo_key_1",
|
||||
name: "Ops dashboard integration",
|
||||
key_prefix: "drb_live_sample_4f2a",
|
||||
created_at: "2026-07-02T14:00:00.000Z",
|
||||
last_used_at: "2026-08-15T09:12:00.000Z",
|
||||
revoked: false,
|
||||
},
|
||||
];
|
||||
|
||||
export async function listApiKeys(): Promise<ApiKeyRecord[]> {
|
||||
return DEMO_KEYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full (fake) key exactly once, same UX contract a real key
|
||||
* issuance flow would have — the raw secret is shown once and never again.
|
||||
*/
|
||||
export async function createApiKey(name: string): Promise<{ record: ApiKeyRecord; rawKey: string }> {
|
||||
const suffix = Math.random().toString(36).slice(2, 10);
|
||||
const record: ApiKeyRecord = {
|
||||
key_id: `demo_key_${DEMO_KEYS.length + 1}`,
|
||||
name,
|
||||
key_prefix: `drb_live_sample_${suffix.slice(0, 4)}`,
|
||||
created_at: new Date().toISOString(),
|
||||
last_used_at: null,
|
||||
revoked: false,
|
||||
};
|
||||
DEMO_KEYS = [...DEMO_KEYS, record];
|
||||
return { record, rawKey: `drb_live_sample_${suffix}_DEMO_NOT_A_REAL_KEY` };
|
||||
}
|
||||
|
||||
export async function revokeApiKey(keyId: string): Promise<void> {
|
||||
DEMO_KEYS = DEMO_KEYS.map((k) => (k.key_id === keyId ? { ...k, revoked: true } : k));
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Billing/licensing boundary — STUB MODULE.
|
||||
*
|
||||
* This file defines the typed shape the settings UI (app/settings/billing) talks to.
|
||||
* Nothing here calls a real payment processor. Every exported function returns
|
||||
* hardcoded sample data or throws, and is marked with a TODO describing exactly
|
||||
* what a real integration would do.
|
||||
*
|
||||
* Do NOT wire a real Stripe (or other) publishable/secret key into this file.
|
||||
* When a processor is chosen:
|
||||
* 1. Add a c2-core router (e.g. `routers/billing.py`) that owns all server-side
|
||||
* calls to the processor's API using a secret key from server env — never
|
||||
* exposed to the frontend.
|
||||
* 2. Add a Stripe (or similar) webhook endpoint on c2-core that keeps an
|
||||
* `organizations/{orgId}` Firestore doc in sync with subscription state
|
||||
* (plan, status, current_period_end, seats, node_limit).
|
||||
* 3. Replace the bodies below with `c2api`-style `fetch` calls into that router.
|
||||
* Checkout/portal functions should return a redirect URL from a real
|
||||
* Checkout/Billing Portal session — the frontend's only job is
|
||||
* `window.location.href = url`, it should never touch card data directly.
|
||||
*/
|
||||
|
||||
export type PlanId = "free" | "pro" | "enterprise";
|
||||
export type SubscriptionStatus = "trialing" | "active" | "past_due" | "canceled" | "none";
|
||||
export type BillingInterval = "monthly" | "annual";
|
||||
|
||||
export interface PlanLimits {
|
||||
seats: number | "unlimited";
|
||||
nodes: number | "unlimited";
|
||||
retentionDays: number;
|
||||
}
|
||||
|
||||
export interface PlanDefinition {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
tagline: string;
|
||||
priceMonthlyUsd: number | null; // null = "contact us"
|
||||
priceAnnualUsd: number | null;
|
||||
limits: PlanLimits;
|
||||
features: string[];
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
planId: PlanId;
|
||||
status: SubscriptionStatus;
|
||||
interval: BillingInterval;
|
||||
currentPeriodEnd: string | null; // ISO date
|
||||
cancelAtPeriodEnd: boolean;
|
||||
trialEndsAt: string | null; // ISO date
|
||||
seatsUsed: number;
|
||||
nodesUsed: number;
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
id: string;
|
||||
date: string; // ISO date
|
||||
amountUsd: number;
|
||||
status: "paid" | "open" | "void" | "uncollectible";
|
||||
description: string;
|
||||
/** In a real integration, a short-lived link to the processor-hosted PDF/receipt. */
|
||||
hostedUrl: string | null;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
seatsUsed: number;
|
||||
seatsLimit: number | "unlimited";
|
||||
nodesUsed: number;
|
||||
nodesLimit: number | "unlimited";
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plan catalog — this is real UI copy (safe to ship), just not wired to a
|
||||
// live pricing table. In a real integration this would likely be fetched
|
||||
// from the processor (Stripe Prices API) instead of hardcoded here so price
|
||||
// changes don't require a frontend deploy.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PLANS: PlanDefinition[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Community",
|
||||
tagline: "For a single node and a small crew keeping an eye on local traffic.",
|
||||
priceMonthlyUsd: 0,
|
||||
priceAnnualUsd: 0,
|
||||
limits: { seats: 3, nodes: 1, retentionDays: 7 },
|
||||
features: [
|
||||
"1 field node",
|
||||
"3 team seats",
|
||||
"Live incident map",
|
||||
"7-day call & incident history",
|
||||
"Discord voice relay",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
tagline: "For agencies and serious hobbyist networks running multiple nodes.",
|
||||
priceMonthlyUsd: 79,
|
||||
priceAnnualUsd: 790,
|
||||
limits: { seats: 15, nodes: 10, retentionDays: 90 },
|
||||
features: [
|
||||
"Up to 10 field nodes",
|
||||
"15 team seats",
|
||||
"AI incident correlation & summaries",
|
||||
"90-day call & incident history",
|
||||
"Alert rules with Discord webhooks",
|
||||
"API key access",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
tagline: "For regional networks with custom retention, SSO, and support needs.",
|
||||
priceMonthlyUsd: null,
|
||||
priceAnnualUsd: null,
|
||||
limits: { seats: "unlimited", nodes: "unlimited", retentionDays: 365 },
|
||||
features: [
|
||||
"Unlimited field nodes",
|
||||
"Unlimited team seats",
|
||||
"1-year+ retention (custom)",
|
||||
"SSO / SAML",
|
||||
"Dedicated support & uptime SLA",
|
||||
"Custom data residency",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getPlan(id: PlanId): PlanDefinition {
|
||||
return PLANS.find((p) => p.id === id) ?? PLANS[0];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sample account state — clearly a demo fixture, not a real customer record.
|
||||
// TODO(billing): replace with `c2api.getSubscription()` once c2-core exposes
|
||||
// GET /org/subscription backed by the processor + Firestore org doc.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_SUBSCRIPTION: Subscription = {
|
||||
planId: "pro",
|
||||
status: "trialing",
|
||||
interval: "monthly",
|
||||
currentPeriodEnd: new Date(Date.now() + 1000 * 60 * 60 * 24 * 21).toISOString(),
|
||||
cancelAtPeriodEnd: false,
|
||||
trialEndsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7).toISOString(),
|
||||
seatsUsed: 4,
|
||||
nodesUsed: 2,
|
||||
};
|
||||
|
||||
const SAMPLE_INVOICES: Invoice[] = [
|
||||
{ id: "sample_inv_1003", date: "2026-07-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||
{ id: "sample_inv_1002", date: "2026-06-16", amountUsd: 79, status: "paid", description: "Pro plan — monthly", hostedUrl: null },
|
||||
{ id: "sample_inv_1001", date: "2026-05-16", amountUsd: 0, status: "paid", description: "Community plan", hostedUrl: null },
|
||||
];
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getSubscription()` → GET /org/subscription.
|
||||
* Returns sample data so the settings UI has something real to render today.
|
||||
*/
|
||||
export async function getCurrentSubscription(): Promise<Subscription> {
|
||||
return SAMPLE_SUBSCRIPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getUsageSummary()` → GET /org/usage,
|
||||
* computed server-side from `nodes` count + org member count.
|
||||
*/
|
||||
export async function getUsageSummary(): Promise<UsageSummary> {
|
||||
const sub = await getCurrentSubscription();
|
||||
const plan = getPlan(sub.planId);
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
return {
|
||||
seatsUsed: sub.seatsUsed,
|
||||
seatsLimit: plan.limits.seats,
|
||||
nodesUsed: sub.nodesUsed,
|
||||
nodesLimit: plan.limits.nodes,
|
||||
periodStart: periodStart.toISOString(),
|
||||
periodEnd: periodEnd.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.getInvoices()` → GET /org/invoices,
|
||||
* which on the backend would list Stripe Invoices for the org's customer id
|
||||
* and map them to this shape (hostedUrl = Stripe's `hosted_invoice_url`).
|
||||
*/
|
||||
export async function getInvoices(): Promise<Invoice[]> {
|
||||
return SAMPLE_INVOICES;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.createCheckoutSession(planId, interval)`
|
||||
* → POST /org/billing/checkout-session, which creates a Stripe Checkout
|
||||
* Session server-side (secret key never leaves the server) and returns
|
||||
* `{ url }`. Frontend then does `window.location.href = url`.
|
||||
*
|
||||
* Throws here — there is no live checkout to redirect to.
|
||||
*/
|
||||
export async function createCheckoutSession(_planId: PlanId, _interval: BillingInterval): Promise<{ url: string }> {
|
||||
throw new Error(
|
||||
"Checkout is not wired to a payment processor yet. This is a demo build — " +
|
||||
"no card will be charged. See lib/billing.ts for the integration TODO."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.createBillingPortalSession()` →
|
||||
* POST /org/billing/portal-session, which creates a Stripe Billing Portal
|
||||
* session server-side and returns `{ url }` for redirect. The portal is
|
||||
* where a real integration would let customers update payment methods,
|
||||
* cancel, or download invoices — avoids building that UI ourselves.
|
||||
*/
|
||||
export async function createBillingPortalSession(): Promise<{ url: string }> {
|
||||
throw new Error(
|
||||
"Billing portal is not wired to a payment processor yet. See lib/billing.ts for the integration TODO."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(billing): replace with `c2api.previewPlanChange(planId, interval)` →
|
||||
* GET /org/billing/preview?plan=…, which on the backend would call the
|
||||
* processor's upcoming-invoice/proration preview endpoint.
|
||||
* Returns a rough client-side estimate so the upgrade/downgrade UI has
|
||||
* something to show; not a real proration calculation.
|
||||
*/
|
||||
export async function previewPlanChange(planId: PlanId, interval: BillingInterval): Promise<{ dueTodayUsd: number; nextAmountUsd: number }> {
|
||||
const plan = getPlan(planId);
|
||||
const price = interval === "annual" ? plan.priceAnnualUsd : plan.priceMonthlyUsd;
|
||||
return { dueTodayUsd: price ?? 0, nextAmountUsd: price ?? 0 };
|
||||
}
|
||||
@@ -30,6 +30,12 @@ export const c2api = {
|
||||
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
|
||||
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
|
||||
},
|
||||
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
|
||||
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
|
||||
resetOverride: (nodeId: string) =>
|
||||
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
|
||||
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
|
||||
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
|
||||
// Systems
|
||||
getSystems: () => request<unknown[]>("/systems"),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared severity ladder for calls and incidents: routine < minor < moderate < major.
|
||||
* Every call/incident gets one of these four. `"unknown"` (and any other
|
||||
* unrecognized value) is a legacy value still present on historical docs —
|
||||
* treat it as "no severity", not as a fifth level.
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export type Severity = "routine" | "minor" | "moderate" | "major";
|
||||
|
||||
export const SEVERITY_ORDER: Record<Severity, number> = { routine: 0, minor: 1, moderate: 2, major: 3 };
|
||||
export const SEVERITY_LABEL: Record<Severity, string> = { routine: "Routine", minor: "Minor", moderate: "Moderate", major: "Major" };
|
||||
export const SEVERITY_COLORS: Record<Severity, string> = {
|
||||
routine: "bg-gray-800/40 text-gray-600",
|
||||
minor: "bg-gray-800 text-gray-400",
|
||||
moderate: "bg-orange-950 text-orange-400",
|
||||
major: "bg-red-950 text-red-400",
|
||||
};
|
||||
|
||||
export function isKnownSeverity(s: string | null | undefined): s is Severity {
|
||||
return s === "routine" || s === "minor" || s === "moderate" || s === "major";
|
||||
}
|
||||
|
||||
/** Legacy/unset severities rank below `routine` so a recency-sorted list never confuses them with a real (low) severity. */
|
||||
export function severityRank(s: string | null | undefined): number {
|
||||
return isKnownSeverity(s) ? SEVERITY_ORDER[s] : -1;
|
||||
}
|
||||
|
||||
export function severityBadge(severity: string | null | undefined): ReactElement | null {
|
||||
if (!isKnownSeverity(severity)) return null;
|
||||
return (
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${SEVERITY_COLORS[severity]}`}>
|
||||
{SEVERITY_LABEL[severity]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -52,6 +52,11 @@ export interface NodeRecord {
|
||||
approval_status: ApprovalStatus | null;
|
||||
hardware_preset?: string;
|
||||
ppm_override?: number | null;
|
||||
node_type?: string;
|
||||
enforce_override_timeout?: boolean;
|
||||
is_overridden?: boolean;
|
||||
override_system_id?: string | null;
|
||||
override_timeout_at?: string | null;
|
||||
}
|
||||
|
||||
export interface VocabularyPendingTerm {
|
||||
@@ -88,6 +93,13 @@ export interface CallRecord {
|
||||
freq: number | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
/** Private gs:// object location. Present on the Firestore doc; not playable. */
|
||||
audio_gcs_uri?: string | null;
|
||||
/**
|
||||
* Short-lived playback link. Minted per read by the API — only populated on
|
||||
* calls fetched via c2api, never on docs read straight from Firestore.
|
||||
* On pre-fix docs this holds a legacy (unplayable) gs:// URI instead.
|
||||
*/
|
||||
audio_url: string | null;
|
||||
transcript: string | null;
|
||||
transcript_corrected: string | null;
|
||||
@@ -99,6 +111,8 @@ export interface CallRecord {
|
||||
location: string | null;
|
||||
tags: string[];
|
||||
status: "active" | "ended";
|
||||
/** Four-level ladder: routine | minor | moderate | major. Legacy docs may still carry "unknown". */
|
||||
severity?: string | null;
|
||||
// Correlation debug — written by the correlator, present after a call is linked
|
||||
corr_path?: string | null;
|
||||
corr_score?: number | null;
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Public marketing pages — no session required. Keep this in sync with
|
||||
// MARKETING_PATHS in components/ChromeSwitcher.tsx (that one picks page
|
||||
// chrome; this one decides whether to redirect at all).
|
||||
const PUBLIC_PATHS = new Set(["/", "/features", "/pricing", "/faq"]);
|
||||
|
||||
// NOTE: this is a UX redirect only, not a security boundary — it just checks
|
||||
// a client-set cookie's presence. Real enforcement is server-side, in
|
||||
// drb-c2-core/app/internal/auth.py. See CLAUDE.md.
|
||||
export function middleware(request: NextRequest) {
|
||||
const session = request.cookies.get("drb_session");
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (PUBLIC_PATHS.has(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname === "/login") {
|
||||
if (session) return NextResponse.redirect(new URL("/dashboard", request.url));
|
||||
return NextResponse.next();
|
||||
|
||||
@@ -10,6 +10,28 @@ const config: Config = {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: ["ui-monospace", "Cascadia Code", "Source Code Pro", "monospace"],
|
||||
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
|
||||
},
|
||||
// Marketing/product type scale — used by the (marketing) surface and
|
||||
// settings shell so headings read as a deliberate hierarchy rather than
|
||||
// ad-hoc text-xl/text-2xl bumps.
|
||||
fontSize: {
|
||||
"display-lg": ["3.5rem", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "700" }],
|
||||
"display": ["2.75rem", { lineHeight: "1.1", letterSpacing: "-0.02em", fontWeight: "700" }],
|
||||
"display-sm": ["2.125rem", { lineHeight: "1.15", letterSpacing: "-0.01em", fontWeight: "700" }],
|
||||
},
|
||||
boxShadow: {
|
||||
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
|
||||
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
|
||||
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in 0.4s ease-out",
|
||||
"slide-up": "slide-up 0.4s ease-out",
|
||||
},
|
||||
keyframes: {
|
||||
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
|
||||
"slide-up": { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# 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,19 @@
|
||||
---
|
||||
# The "Deploy Caddyfile" task notifies this. Without this file the play aborts
|
||||
# with "The requested handler 'Reload Caddy' was not found" — notify does not
|
||||
# tolerate a missing handler.
|
||||
#
|
||||
# reloaded, not restarted: caddy reload swaps config with zero downtime and
|
||||
# keeps existing TLS certs/connections; a restart drops every in-flight request.
|
||||
|
||||
- name: Reload Caddy
|
||||
ansible.builtin.systemd_service:
|
||||
name: caddy
|
||||
state: reloaded
|
||||
enabled: true
|
||||
|
||||
# For the mqtt-cert-sync.service/.path unit files — systemd won't pick up a
|
||||
# new/changed unit file until the manager config is reloaded.
|
||||
- name: Reload systemd daemon
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
@@ -2,12 +2,19 @@
|
||||
# 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)
|
||||
# update: true (was false) — with update disabled, every re-run of this playbook
|
||||
# redeployed the code that happened to be on the VM at first clone, so any fix
|
||||
# pushed to main was invisible here and the only way to ship one was CI or a
|
||||
# manual pull. force: true discards local edits made on the VM; the templated
|
||||
# .env files and Caddyfile live outside git tracking, so nothing generated by
|
||||
# this role is at risk.
|
||||
- name: Clone or update repo
|
||||
git:
|
||||
repo: "{{ repo_url }}"
|
||||
dest: "{{ app_dir }}"
|
||||
version: main
|
||||
update: false
|
||||
update: true
|
||||
force: true
|
||||
become: false
|
||||
|
||||
- name: Set ownership of app directory
|
||||
@@ -59,6 +66,93 @@
|
||||
mode: "0644"
|
||||
notify: Reload Caddy
|
||||
|
||||
# --- MQTT TLS cert sync (Caddy -> mosquitto) --------------------------------
|
||||
# See MQTT-PUBLIC-AUTH-PLAN.md "Infra". mosquitto reads its cert from this
|
||||
# directory (docker-compose.prod.yml bind-mounts it in); nothing but root can
|
||||
# read Caddy's own cert storage, so a systemd path unit + oneshot service
|
||||
# copies a readable copy out and SIGHUPs the broker on every change.
|
||||
|
||||
# root:1883 0750, not root:root 0700. The stock eclipse-mosquitto entrypoint
|
||||
# drops privileges to the in-image `mosquitto` user (uid/gid 1883), so a
|
||||
# root-only directory makes the broker fail to read its own cert and
|
||||
# crash-loop: "Unable to load server certificate ... Permission denied".
|
||||
# The host has no `mosquitto` user, hence the numeric gid.
|
||||
- name: Create mosquitto certs directory
|
||||
file:
|
||||
path: /opt/drb/mosquitto-certs
|
||||
state: directory
|
||||
owner: root
|
||||
group: "1883"
|
||||
mode: "0750"
|
||||
|
||||
# dynamic-security.json (node credentials — see app/internal/dynsec.py)
|
||||
# lives here, and mosquitto WRITES it, so this must be owned by the uid the
|
||||
# broker actually runs as (1883), not root. The earlier assumption that the
|
||||
# container runs as root was wrong — the image's entrypoint drops privileges
|
||||
# to the `mosquitto` user, which a real deploy proved by failing to read a
|
||||
# root-owned cert. Same numeric-gid reasoning as the certs directory above.
|
||||
- name: Create mosquitto data directory
|
||||
file:
|
||||
path: /opt/drb/mosquitto-data
|
||||
state: directory
|
||||
owner: "1883"
|
||||
group: "1883"
|
||||
mode: "0700"
|
||||
# recurse so an existing root-owned dynamic-security.json / mosquitto.db
|
||||
# left behind by the earlier root-owned deploy gets fixed too — chowning
|
||||
# only the directory would leave the broker unable to rewrite them.
|
||||
recurse: true
|
||||
|
||||
- name: Deploy MQTT cert-sync script
|
||||
template:
|
||||
src: sync-mqtt-cert.sh.j2
|
||||
dest: /opt/drb/sync-mqtt-cert.sh
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Deploy MQTT cert-sync systemd service unit
|
||||
template:
|
||||
src: mqtt-cert-sync.service.j2
|
||||
dest: /etc/systemd/system/mqtt-cert-sync.service
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload systemd daemon
|
||||
|
||||
- name: Deploy MQTT cert-sync systemd path unit
|
||||
template:
|
||||
src: mqtt-cert-sync.path.j2
|
||||
dest: /etc/systemd/system/mqtt-cert-sync.path
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
notify: Reload systemd daemon
|
||||
|
||||
# Flush the daemon-reload handler now (rather than at end-of-play) so the
|
||||
# path unit is registered and actively watching BEFORE the "Reload Caddy"
|
||||
# handler below fires and Caddy goes to obtain the mqtt.{{ domain }} cert —
|
||||
# otherwise the unit could miss the very first PathChanged event.
|
||||
- name: Apply pending handlers (systemd daemon-reload)
|
||||
meta: flush_handlers
|
||||
|
||||
- name: Enable and start MQTT cert-sync path unit
|
||||
ansible.builtin.systemd_service:
|
||||
name: mqtt-cert-sync.path
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
# Best-effort initial sync in case Caddy already has a cert from a previous
|
||||
# run (e.g. re-running this playbook after the first successful deploy) —
|
||||
# the path unit only fires on a CHANGE, so it won't pick up a cert that was
|
||||
# already sitting there unchanged before it started watching. Non-fatal if
|
||||
# nothing exists yet (first-ever run, before Caddy has issued anything).
|
||||
- name: Best-effort initial MQTT cert sync
|
||||
command: /opt/drb/sync-mqtt-cert.sh
|
||||
register: _initial_sync
|
||||
changed_when: "'copied cert' in _initial_sync.stdout"
|
||||
failed_when: false
|
||||
|
||||
- name: Log in to container registry
|
||||
command: >
|
||||
docker login {{ vault_registry_host }}
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
# Managed by Ansible — do not edit manually on the server.
|
||||
# Managed by Ansible — do not edit manually.
|
||||
|
||||
api.{{ domain }} {
|
||||
# MQTT auth is no longer an HTTP backend c2-core exposes (it moved to
|
||||
# mosquitto's own built-in dynamic-security plugin, administered over MQTT
|
||||
# control topics — see app/internal/dynsec.py) — there is currently no
|
||||
# /internal/* route in c2-core at all. This block stays anyway as defence
|
||||
# in depth: c2-core's app-wide reverse_proxy below forwards every path by
|
||||
# default, so this guarantees any FUTURE /internal/* route (or a
|
||||
# regression that reintroduces one) is still unreachable from the public
|
||||
# internet unless someone also deliberately deletes this block. `route`
|
||||
# forces top-to-bottom evaluation instead of Caddy's automatic directive
|
||||
# sorting, so this is guaranteed to run before reverse_proxy.
|
||||
route {
|
||||
respond /internal/* 404
|
||||
reverse_proxy localhost:8888 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.{{ domain }} {
|
||||
# mqtt.{{ domain }} has no application behind it — mosquitto's TLS listener
|
||||
# (8883) is a raw MQTT socket, not HTTP, so Caddy can't reverse_proxy to it.
|
||||
# This block's only job is to make Caddy request+manage a Let's Encrypt cert
|
||||
# for the name via ACME HTTP-01, which infra/ansible's cert-sync unit then
|
||||
# copies out to mosquitto. The DNS A record for mqtt.{{ domain }} must exist
|
||||
# before this runs, or ACME issuance fails (see MQTT-PUBLIC-AUTH-PLAN.md).
|
||||
mqtt.{{ domain }} {
|
||||
respond 404
|
||||
}
|
||||
|
||||
# Frontend is served on the bare domain, not app.{{ domain }}: only drb and api
|
||||
# have public DNS records. A vhost for a name with no A record still starts,
|
||||
# but Caddy retries ACME against it forever and logs a failure each time.
|
||||
# To move it to app.{{ domain }}, create the A record first, then change this
|
||||
# line — the reverse_proxy target stays the same either way.
|
||||
{{ domain }} {
|
||||
reverse_proxy localhost:3000 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,34 @@ MQTT_PORT=1883
|
||||
MQTT_USER={{ vault_mqtt_c2_user }}
|
||||
MQTT_PASS={{ vault_mqtt_c2_pass }}
|
||||
|
||||
# Same value as mosquitto's MOSQUITTO_DYNSEC_PASSWORD (root.env.j2) — lets
|
||||
# c2-core log in as the dynsec plugin's built-in "admin" client to
|
||||
# administer node credentials. See app/internal/dynsec.py.
|
||||
MQTT_DYNSEC_ADMIN_PASS={{ vault_mqtt_dynsec_admin_pass }}
|
||||
|
||||
# No GCP_CREDENTIALS_PATH — the VM uses Application Default Credentials
|
||||
# via the GCE metadata server. The Terraform IAM bindings grant the required roles.
|
||||
# NOTE: because there is no service-account key file here, c2-core cannot mint
|
||||
# GCS *signed* URLs. Call audio is therefore served through c2-core's own
|
||||
# /media route (app/routers/media.py) rather than direct-from-bucket links.
|
||||
FIRESTORE_DATABASE={{ vault_firestore_database }}
|
||||
GCS_BUCKET={{ vault_gcs_bucket }}
|
||||
|
||||
# Absolute origin for call-audio playback links. The browser fetches <audio src>
|
||||
# directly, so a relative path would resolve against the frontend origin
|
||||
# (https://{{ domain }}) instead of the API's.
|
||||
PUBLIC_API_URL=https://api.{{ domain }}
|
||||
|
||||
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 }}
|
||||
ENROLLMENT_TOKEN={{ vault_enrollment_token }}
|
||||
|
||||
CORS_ORIGINS=["https://app.{{ domain }}"]
|
||||
# Bare domain, not app.<domain>: the frontend is served on {{ domain }} itself
|
||||
# (see Caddyfile.j2 — only api. and the bare name have DNS records). This said
|
||||
# app.{{ domain }} while the browser origin was https://{{ domain }}, so every
|
||||
# frontend call to the API would have failed CORS. If the frontend ever moves
|
||||
# to app.{{ domain }}, change this at the same time.
|
||||
CORS_ORIGINS=["https://{{ domain }}"]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Managed by Ansible — do not edit manually.
|
||||
#
|
||||
# Watches Caddy's on-disk cert for mqtt.{{ domain }} and fires
|
||||
# mqtt-cert-sync.service on every change (initial issuance + every renewal).
|
||||
# See sync-mqtt-cert.sh.j2 for the "unverified path" caveat — if Caddy's
|
||||
# storage layout doesn't match, this unit simply never fires and mosquitto
|
||||
# keeps using its self-signed placeholder cert (see mosquitto/entrypoint.sh)
|
||||
# rather than failing loudly, so check `systemctl status mqtt-cert-sync.path`
|
||||
# after the first deploy.
|
||||
[Unit]
|
||||
Description=Watch for a renewed MQTT TLS cert from Caddy (mqtt.{{ domain }})
|
||||
|
||||
[Path]
|
||||
PathChanged=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mqtt.{{ domain }}/mqtt.{{ domain }}.crt
|
||||
Unit=mqtt-cert-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,11 @@
|
||||
# Managed by Ansible — do not edit manually.
|
||||
#
|
||||
# Runs as root: it needs read access to Caddy's 0700 cert storage AND the
|
||||
# ability to run `docker compose kill -s HUP` regardless of docker group
|
||||
# membership. Triggered by mqtt-cert-sync.path, not run standalone.
|
||||
[Unit]
|
||||
Description=Sync Caddy-issued MQTT TLS cert to mosquitto and reload
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/drb/sync-mqtt-cert.sh
|
||||
@@ -1,10 +1,26 @@
|
||||
# Top-level docker-compose environment — MQTT credentials and registry prefix.
|
||||
# Managed by Ansible. Do not edit manually.
|
||||
#
|
||||
# The passwords are $-escaped ($ -> $$). Compose INTERPOLATES this file, so a
|
||||
# raw "$fP" in a password is read as the variable $fP, warned about, and
|
||||
# replaced with an empty string. The env_file templates (c2-core.env.j2 etc.)
|
||||
# are NOT interpolated, so they keep the literal value — which means an
|
||||
# unescaped $ here silently gives mosquitto and c2-core two different
|
||||
# passwords and MQTT auth fails. Compose collapses $$ back to a single $, so
|
||||
# both sides end up with the real password.
|
||||
# Do not add the same escaping to the env_file templates; it would be literal.
|
||||
|
||||
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 }}
|
||||
MQTT_C2_PASS={{ vault_mqtt_c2_pass | replace('$', '$$') }}
|
||||
|
||||
# Seeds mosquitto's built-in dynamic-security plugin's one-time "admin"
|
||||
# bootstrap client on first boot (read directly by the plugin's C code via
|
||||
# getenv — see app/internal/dynsec.py). Must be >=12 chars (plugin-enforced
|
||||
# minimum). c2-core needs this SAME value as MQTT_DYNSEC_ADMIN_PASS in its
|
||||
# own env (c2-core.env.j2) to log in as "admin" and administer node
|
||||
# credentials — kept as one vault var (vault_mqtt_dynsec_admin_pass) so the
|
||||
# two can't drift.
|
||||
MOSQUITTO_DYNSEC_PASSWORD={{ vault_mqtt_dynsec_admin_pass | replace('$', '$$') }}
|
||||
|
||||
# Container registry prefix — docker compose uses this for image: ${REGISTRY}/name:latest
|
||||
REGISTRY={{ vault_registry }}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Managed by Ansible — do not edit manually.
|
||||
#
|
||||
# Copies Caddy's managed TLS cert for mqtt.{{ domain }} out of Caddy's
|
||||
# storage (root:caddy, 0700 — nothing else can read it) into a location the
|
||||
# mosquitto container can read, then SIGHUPs the broker so it picks up the
|
||||
# new cert without a full restart.
|
||||
#
|
||||
# Triggered by mqtt-cert-sync.path.j2 (a systemd path unit) watching the
|
||||
# source cert file for changes — a path unit rather than cron so this fires
|
||||
# on the actual write instead of racing a polling interval.
|
||||
#
|
||||
# CONFIRMED 2026-08-16 against a real issuance on drb-server: this path is
|
||||
# correct, and the copied cert came out as CN=mqtt.drb.cusano.net issued by
|
||||
# Let's Encrypt. Was previously flagged unverified.
|
||||
#
|
||||
# UNVERIFIED: mosquitto 2.x reloading TLS certs on SIGHUP without dropping
|
||||
# connections is documented upstream but untested here. If listener 8883
|
||||
# doesn't pick up the new cert (check `docker compose logs mosquitto` after
|
||||
# a sync), replace the `kill -s HUP` line below with a full
|
||||
# `docker compose ... restart mosquitto` instead.
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="mqtt.{{ domain }}"
|
||||
CADDY_CERT_DIR="/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/${DOMAIN}"
|
||||
DEST_DIR="/opt/drb/mosquitto-certs"
|
||||
APP_DIR="{{ app_dir }}"
|
||||
|
||||
SRC_CERT="${CADDY_CERT_DIR}/${DOMAIN}.crt"
|
||||
SRC_KEY="${CADDY_CERT_DIR}/${DOMAIN}.key"
|
||||
|
||||
if [ ! -f "$SRC_CERT" ] || [ ! -f "$SRC_KEY" ]; then
|
||||
echo "sync-mqtt-cert: source cert/key not found yet at $CADDY_CERT_DIR — Caddy may not have issued it yet." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
# Copy, don't symlink — nothing outside the caddy user can read the
|
||||
# originals (0700-owned), so mosquitto (running as a different container/
|
||||
# user) needs its own readable copy, not a pointer to an unreadable file.
|
||||
cp "$SRC_CERT" "$DEST_DIR/mqtt.crt"
|
||||
cp "$SRC_KEY" "$DEST_DIR/mqtt.key"
|
||||
# Ownership matters: the stock eclipse-mosquitto entrypoint drops privileges
|
||||
# to the in-image `mosquitto` user (uid/gid 1883) — the broker does NOT run
|
||||
# as root, despite what an earlier note in DEFERRED.md claimed. Proof from a
|
||||
# real deploy: "running mosquitto as user: mosquitto", immediately followed
|
||||
# by "Unable to load server certificate ... Permission denied" on a
|
||||
# 600 root:root cert. The host has no such user, so use the numeric gid.
|
||||
# The cert is public material (0644); the key is group-read only (0640).
|
||||
chown root:1883 "$DEST_DIR/mqtt.crt" "$DEST_DIR/mqtt.key"
|
||||
chmod 644 "$DEST_DIR/mqtt.crt"
|
||||
chmod 640 "$DEST_DIR/mqtt.key"
|
||||
|
||||
cd "$APP_DIR"
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml kill -s HUP mosquitto
|
||||
echo "sync-mqtt-cert: copied cert for ${DOMAIN} and sent SIGHUP to mosquitto."
|
||||
+19
-2
@@ -36,16 +36,33 @@
|
||||
path: /swapfile
|
||||
mode: "0600"
|
||||
|
||||
# mkswap refuses to touch a file that is already active as swap, so a
|
||||
# re-run would fail here without this guard. The swap file survives
|
||||
# reboots via the fstab entry below, so on any second run it IS active.
|
||||
- name: Check whether the swap file is already active
|
||||
command: swapon --show=NAME --noheadings
|
||||
register: _active_swaps
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Format swap file
|
||||
command: mkswap /swapfile
|
||||
when: "'/swapfile' not in _active_swaps.stdout"
|
||||
register: _mkswap
|
||||
changed_when: _mkswap.rc == 0
|
||||
|
||||
# Guarded by the same check as mkswap above. The stderr test alone was not
|
||||
# enough: an already-active swap file reports "Device or resource busy",
|
||||
# not "already", so the original failed_when never matched it.
|
||||
- name: Enable swap
|
||||
command: swapon /swapfile
|
||||
when: "'/swapfile' not in _active_swaps.stdout"
|
||||
register: _swapon
|
||||
failed_when: _swapon.rc != 0 and 'already' not in _swapon.stderr
|
||||
changed_when: _swapon.rc == 0
|
||||
failed_when: >
|
||||
_swapon.rc is defined and _swapon.rc != 0
|
||||
and 'already' not in _swapon.stderr
|
||||
and 'busy' not in _swapon.stderr
|
||||
changed_when: _swapon.rc is defined and _swapon.rc == 0
|
||||
|
||||
- name: Persist swap in fstab
|
||||
lineinfile:
|
||||
|
||||
@@ -4,15 +4,26 @@
|
||||
# Edit later with:
|
||||
# ansible-vault edit vault.yml
|
||||
|
||||
# DO NOT put a literal "$" in any value here. Docker compose interpolates the
|
||||
# top-level .env, and depending on version it also interpolates env_file, so a
|
||||
# password like "aB$fPx" is read as the variable $fPx and silently replaced
|
||||
# with an empty string — on one side of the connection but not the other.
|
||||
# That produced "MQTT connect refused: Not authorized" with no obvious cause.
|
||||
# Generate with: openssl rand -hex 32 (hex output has no shell metacharacters)
|
||||
|
||||
# ── MQTT ─────────────────────────────────────────────────────────────────────
|
||||
# No more shared node credential (vault_mqtt_node_user/pass) — nodes now
|
||||
# authenticate as username=<node_id>, password=<their node_keys.api_key>,
|
||||
# checked by mosquitto's built-in dynamic-security plugin (c2-core
|
||||
# administers it — see app/internal/dynsec.py). See vault_enrollment_token
|
||||
# below for how a node gets that key in the first place.
|
||||
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"
|
||||
vault_mqtt_dynsec_admin_pass: "CHANGE_ME" # openssl rand -hex 32 — must be >=12 chars, plugin-enforced minimum
|
||||
|
||||
# ── C2 Core ───────────────────────────────────────────────────────────────────
|
||||
vault_service_key: "" # openssl rand -hex 32
|
||||
vault_node_api_key: "" # openssl rand -hex 32
|
||||
vault_enrollment_token: "" # openssl rand -hex 32 — fleet-wide, shared by every node's POST /nodes/enroll
|
||||
vault_openai_api_key: ""
|
||||
vault_google_maps_api_key: ""
|
||||
vault_gemini_api_key: ""
|
||||
@@ -22,7 +33,14 @@ 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_token: "" # Gitea access token, READ-ONLY package scope.
|
||||
# The VM only pulls (roles/deploy/tasks/main.yml:62-72);
|
||||
# nothing here pushes. Pushing is CI's job and uses a
|
||||
# separate write-scoped token (BUILD_TOKEN in Gitea
|
||||
# repo secrets). Keep them separate: this token sits on
|
||||
# an internet-facing VM, and a write-scoped one there
|
||||
# would let an attacker publish a poisoned image that
|
||||
# every future deploy and edge node would install.
|
||||
vault_registry: "git.vpn.cusano.net/logan" # full image prefix
|
||||
|
||||
# ── Discord Bot ───────────────────────────────────────────────────────────────
|
||||
|
||||
+23
-14
@@ -64,20 +64,25 @@ resource "google_compute_firewall" "allow_ssh" {
|
||||
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"]
|
||||
# }
|
||||
# MQTT is now publicly exposed on 8883 (TLS) — nodes get deployed to
|
||||
# arbitrary locations by arbitrary people, so there is no fixed CIDR to
|
||||
# restrict this to (WireGuard-per-node was evaluated and rejected; see
|
||||
# MQTT-PUBLIC-AUTH-PLAN.md). Security is enforced by mosquitto-go-auth
|
||||
# (per-node api_key over TLS), not by network ACL. 1883 (plaintext) is
|
||||
# intentionally NOT opened here — it stays on the docker bridge for
|
||||
# c2-core's own connection only.
|
||||
resource "google_compute_firewall" "allow_mqtt" {
|
||||
name = "drb-allow-mqtt"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["8883"] # TLS MQTT only, not 1883
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
target_tags = ["drb-server"]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compute Engine VM
|
||||
@@ -185,5 +190,9 @@ resource "google_storage_bucket" "audio" {
|
||||
# After terraform apply, add these A records in Route 53:
|
||||
# app.drb.cusano.net → server_ip output
|
||||
# api.drb.cusano.net → server_ip output
|
||||
# mqtt.drb.cusano.net → server_ip output — MUST exist before the ansible
|
||||
# deploy that adds the Caddy
|
||||
# mqtt.<domain> block, or ACME
|
||||
# issuance for it fails.
|
||||
# Or use a single wildcard: *.drb.cusano.net → server_ip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+11
-1
@@ -3,12 +3,22 @@ output "server_ip" {
|
||||
description = "Static external IP of the DRB server VM"
|
||||
}
|
||||
|
||||
# Bare domain, not app.<domain> — the frontend is served on the domain itself
|
||||
# (see infra/ansible/roles/deploy/templates/Caddyfile.j2). Only the bare name,
|
||||
# api. and mqtt. have DNS records; app. has never resolved.
|
||||
output "app_url" {
|
||||
value = "https://app.${var.domain}"
|
||||
value = "https://${var.domain}"
|
||||
description = "Frontend / portal URL"
|
||||
}
|
||||
|
||||
output "api_url" {
|
||||
value = "https://api.${var.domain}"
|
||||
description = "c2-core REST API URL"
|
||||
}
|
||||
|
||||
output "mqtt_host" {
|
||||
value = "mqtt.${var.domain}"
|
||||
description = "Broker hostname edge nodes connect to on TCP 8883 (TLS)"
|
||||
}
|
||||
|
||||
output "project_number" {
|
||||
|
||||
Reference in New Issue
Block a user