Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fdfb2758 | ||
|
|
7cd289874a | ||
|
|
f4d78aba77 | ||
|
|
268470b6c0 | ||
|
|
4a93390967 | ||
|
|
94898d1af1 | ||
|
|
33056e2269 | ||
|
|
e8084357c3 | ||
|
|
9702b90d40 | ||
|
|
606b0a103c | ||
|
|
f16f0a9f93 | ||
|
|
6d667e81c9 | ||
|
|
bf206334e3 | ||
|
|
3968b49b80 | ||
|
|
8099871c68 | ||
|
|
49166789f4 | ||
|
|
9698d463ad | ||
|
|
3c5496acb1 | ||
|
|
cde86a8ff9 | ||
|
|
292c4fffd9 | ||
|
|
da55114eef | ||
|
|
1a84bf46d7 | ||
|
|
eb0faf2aea | ||
|
|
1d6e79ba25 | ||
|
|
0d613019e0 | ||
|
|
e1bd3feac5 | ||
|
|
7a0e86decf | ||
|
|
3ef8d586ea | ||
|
|
6bf072973c | ||
|
|
a267fdf982 | ||
|
|
9c124d4c8b | ||
|
|
6e462720a1 | ||
|
|
fdcedf175c | ||
|
|
e7070a0afa | ||
|
|
2aaf474828 | ||
|
|
d10c2f732a | ||
|
|
f49dc86111 | ||
|
|
f479578942 | ||
|
|
32d223e556 | ||
|
|
f3b21cd432 | ||
|
|
13a3525e4c | ||
|
|
6d0d3f79ce | ||
|
|
b6055019fc | ||
|
|
2c254c1f57 | ||
|
|
c30b2ca33e | ||
|
|
bdbde359a1 | ||
|
|
afd2bc463d | ||
|
|
fb1b6956c2 | ||
|
|
a976464e72 | ||
|
|
db4e326332 | ||
|
|
7bee87b147 | ||
|
|
18c2aeeff2 | ||
|
|
b615cce30c | ||
|
|
0efcd676c3 | ||
|
|
ebeabe342e | ||
|
|
824b0f6f5a | ||
|
|
c836986b89 | ||
|
|
f252e3eb61 |
@@ -31,6 +31,10 @@ NY511_API_KEY=
|
|||||||
NY511_API_BASE=
|
NY511_API_BASE=
|
||||||
# Ontario 511 developer key (camera provider "ontario511"; providers default OFF)
|
# Ontario 511 developer key (camera provider "ontario511"; providers default OFF)
|
||||||
ONTARIO511_API_KEY=
|
ONTARIO511_API_KEY=
|
||||||
|
# NWS weather alerts / METAR wind (server-26#237). NWS requires app name + contact in the User-Agent
|
||||||
|
NWS_USER_AGENT=DRB (you@example.com)
|
||||||
|
# Comma list of NWS state/marine area codes served
|
||||||
|
NWS_AREA=NY
|
||||||
|
|
||||||
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
# OpenAI — for transcription (Whisper), intelligence extraction, embeddings, and summaries
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ class Settings(BaseSettings):
|
|||||||
# Camera providers (app/internal/cameras). Keys only; every provider is still
|
# Camera providers (app/internal/cameras). Keys only; every provider is still
|
||||||
# OFF until an admin enables it, and licence-gated ones need an acknowledgement.
|
# OFF until an admin enables it, and licence-gated ones need an acknowledgement.
|
||||||
ontario511_api_key: Optional[str] = None
|
ontario511_api_key: Optional[str] = None
|
||||||
|
# Weather context (server-26#237). api.weather.gov requires a User-Agent that
|
||||||
|
# names the app AND a contact (e.g. "DRB (ops@example.com)"); the placeholder
|
||||||
|
# default must be overridden in every deploy -- NWS may block anonymous agents.
|
||||||
|
nws_user_agent: str = "DRB scanner map (contact: set NWS_USER_AGENT)"
|
||||||
|
# Comma list of NWS state/marine area codes for /alerts/active (the areas served).
|
||||||
|
nws_area: str = "NY"
|
||||||
|
# NWS alerts cache TTL; the issue asks 60-120 s (floored at 30 s in code).
|
||||||
|
weather_alerts_ttl_s: int = 90
|
||||||
|
|
||||||
# Gemini (intelligence extraction, embeddings, incident summaries)
|
# Gemini (intelligence extraction, embeddings, incident summaries)
|
||||||
gemini_api_key: Optional[str] = None
|
gemini_api_key: Optional[str] = None
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ Firestore layout:
|
|||||||
unlinked_at|None, unlinked_by|None
|
unlinked_at|None, unlinked_by|None
|
||||||
|
|
||||||
Person names are never stored: the redeemer is kept as a Discord user id.
|
Person names are never stored: the redeemer is kept as a Discord user id.
|
||||||
|
|
||||||
|
Every way a server gets linked — ``/drb link code:`` (``redeem_and_settle``),
|
||||||
|
the platform admin's ``PUT /discord/guilds/{id}/link`` and the per-bot OAuth
|
||||||
|
invite (server-26#225 S6) (both ``link_and_settle``) — writes the guild doc
|
||||||
|
through ONE transaction body (``_apply_link``) and then runs ONE settle step
|
||||||
|
(``_settle``): a server that was acting for a different org (in practice the
|
||||||
|
``DISCORD_UNLINKED_GUILD_ORG_ID`` fallback) has its live radio channels
|
||||||
|
stopped, because they were placed on that org's nodes. The org it acted for
|
||||||
|
before is read inside the same transaction as the write, so the stop decision
|
||||||
|
can't race a concurrent link.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -35,6 +45,7 @@ import secrets
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
CODES = "discord_link_codes"
|
CODES = "discord_link_codes"
|
||||||
@@ -91,6 +102,63 @@ async def mint_code(org_id: str, uid: str) -> dict:
|
|||||||
"expires_minutes": CODE_TTL_MINUTES}
|
"expires_minutes": CODE_TTL_MINUTES}
|
||||||
|
|
||||||
|
|
||||||
|
# Stop reasons the settle step stamps on the streams it stops.
|
||||||
|
RELINK_STOP_REASON = "relinked"
|
||||||
|
ADMIN_RELINK_STOP_REASON = "server linked to another organization"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_link(txn, guild_ref, guild: Optional[dict], *, org_id: str, guild_id: str,
|
||||||
|
guild_name: Optional[str], linked_by: str, via: str, now: datetime,
|
||||||
|
allow_move: bool = False, extra: Optional[dict] = None):
|
||||||
|
"""The guild-side half of every link, inside a transaction whose reads are
|
||||||
|
all done. Returns a LinkError (nothing written) or the result dict:
|
||||||
|
``{org_id, already_linked, previous_org_id}`` — ``previous_org_id`` is the
|
||||||
|
org the server acted for before (its link, else the fallback org).
|
||||||
|
|
||||||
|
``allow_move`` is the platform admin's support override; every self-serve
|
||||||
|
path refuses to move a server another org has linked."""
|
||||||
|
current = (guild or {}).get("org_id")
|
||||||
|
if current and current != org_id and not allow_move:
|
||||||
|
return LinkError(409, "linked_elsewhere",
|
||||||
|
"This server is already linked to a different DRB organization. "
|
||||||
|
"That organization has to unlink it first.")
|
||||||
|
iso = now.isoformat()
|
||||||
|
name = (guild_name or "").strip()[:100]
|
||||||
|
txn.set(guild_ref, {
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"org_id": org_id,
|
||||||
|
"guild_name": name or (guild or {}).get("guild_name"),
|
||||||
|
"linked_by": linked_by,
|
||||||
|
"linked_via": via,
|
||||||
|
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
|
||||||
|
"unlinked_at": None,
|
||||||
|
"unlinked_by": None,
|
||||||
|
# A server an admin removed (streams.remove_guild) comes back on link.
|
||||||
|
"removed_at": None,
|
||||||
|
"removed_by": None,
|
||||||
|
**(extra or {}),
|
||||||
|
}, merge=True)
|
||||||
|
return {"org_id": org_id, "already_linked": current == org_id,
|
||||||
|
"previous_org_id": streams.acting_org_of(guild)}
|
||||||
|
|
||||||
|
|
||||||
|
def link_guild_txn(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
|
||||||
|
allow_move: bool = False, extra: Optional[dict] = None, now: Optional[datetime] = None):
|
||||||
|
"""Transaction body that links ``guild_id`` to ``org_id`` directly (no code)."""
|
||||||
|
db = fstore.db
|
||||||
|
when = now or _now()
|
||||||
|
|
||||||
|
def _txn(txn):
|
||||||
|
guild_ref = db.collection(GUILDS).document(guild_id)
|
||||||
|
snap = guild_ref.get(transaction=txn)
|
||||||
|
guild = snap.to_dict() if snap.exists else None
|
||||||
|
out = _apply_link(txn, guild_ref, guild, org_id=org_id, guild_id=guild_id, guild_name=guild_name,
|
||||||
|
linked_by=linked_by, via=via, now=when, allow_move=allow_move, extra=extra)
|
||||||
|
return {"error": out} if isinstance(out, LinkError) else out
|
||||||
|
|
||||||
|
return _txn
|
||||||
|
|
||||||
|
|
||||||
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
|
def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str, now: datetime):
|
||||||
"""Build the transaction body. Every read happens before any write."""
|
"""Build the transaction body. Every read happens before any write."""
|
||||||
db = fstore.db
|
db = fstore.db
|
||||||
@@ -113,37 +181,20 @@ def _redeem_txn(code: str, guild_id: str, guild_name: Optional[str], discord_use
|
|||||||
return {"error": LinkError(410, "code_expired",
|
return {"error": LinkError(410, "code_expired",
|
||||||
"That link code has expired. Generate a new one in DRB → "
|
"That link code has expired. Generate a new one in DRB → "
|
||||||
"Settings → Organization.")}
|
"Settings → Organization.")}
|
||||||
org_id = doc["org_id"]
|
out = _apply_link(txn, guild_ref, guild, org_id=doc["org_id"], guild_id=guild_id,
|
||||||
current = (guild or {}).get("org_id")
|
guild_name=guild_name, linked_by=f"discord:{discord_user_id}", via="code", now=now)
|
||||||
if current and current != org_id:
|
if isinstance(out, LinkError):
|
||||||
return {"error": LinkError(409, "linked_elsewhere",
|
return {"error": out} # the code is NOT consumed: its org can use it elsewhere
|
||||||
"This server is already linked to a different DRB organization. "
|
txn.update(code_ref, {"redeemed_at": now.isoformat(), "redeemed_guild_id": guild_id,
|
||||||
"That organization has to unlink it first.")}
|
|
||||||
|
|
||||||
iso = now.isoformat()
|
|
||||||
txn.update(code_ref, {"redeemed_at": iso, "redeemed_guild_id": guild_id,
|
|
||||||
"redeemed_by_discord_id": discord_user_id})
|
"redeemed_by_discord_id": discord_user_id})
|
||||||
txn.set(guild_ref, {
|
return {**out, "created_by_uid": doc.get("created_by_uid")}
|
||||||
"guild_id": guild_id,
|
|
||||||
"org_id": org_id,
|
|
||||||
"guild_name": (guild_name or "")[:100] or (guild or {}).get("guild_name"),
|
|
||||||
"linked_by": f"discord:{discord_user_id}",
|
|
||||||
"linked_via": "code",
|
|
||||||
"linked_at": ((guild or {}).get("linked_at") if current == org_id else None) or iso,
|
|
||||||
"unlinked_at": None,
|
|
||||||
"unlinked_by": None,
|
|
||||||
# A server an admin removed (streams.remove_guild) comes back on link.
|
|
||||||
"removed_at": None,
|
|
||||||
"removed_by": None,
|
|
||||||
}, merge=True)
|
|
||||||
return {"org_id": org_id, "already_linked": current == org_id,
|
|
||||||
"created_by_uid": doc.get("created_by_uid")}
|
|
||||||
|
|
||||||
return _txn
|
return _txn
|
||||||
|
|
||||||
|
|
||||||
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
|
async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], discord_user_id: str) -> dict:
|
||||||
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal."""
|
"""Link ``guild_id`` to the code's org. Raises LinkError on refusal.
|
||||||
|
Does NOT stop anything — routes call ``redeem_and_settle``."""
|
||||||
code = normalize(raw_code)
|
code = normalize(raw_code)
|
||||||
if len(code) != _CODE_LEN:
|
if len(code) != _CODE_LEN:
|
||||||
raise LinkError(404, "invalid_code",
|
raise LinkError(404, "invalid_code",
|
||||||
@@ -155,6 +206,39 @@ async def redeem_code(raw_code: str, guild_id: str, guild_name: Optional[str], d
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _settle(guild_id: str, result: dict, stop_reason: str) -> dict:
|
||||||
|
"""After a link committed: a server that was acting for another org (the
|
||||||
|
fallback org, or — admin override only — another linked org) has its live
|
||||||
|
radio channels stopped; they were placed on that org's nodes, and left
|
||||||
|
running the reconciler would keep re-placing them there
|
||||||
|
(server-26#225 plan finding 3: this lived in two routers before)."""
|
||||||
|
before = result.get("previous_org_id")
|
||||||
|
stopped: list = []
|
||||||
|
if before and before != result["org_id"]:
|
||||||
|
stopped = await streams.stop_guild(str(guild_id), stop_reason)
|
||||||
|
return {**result, "streams_stopped": stopped}
|
||||||
|
|
||||||
|
|
||||||
|
async def link_and_settle(org_id: str, guild_id: str, guild_name: Optional[str], linked_by: str, via: str, *,
|
||||||
|
allow_move: bool = False, extra: Optional[dict] = None,
|
||||||
|
stop_reason: str = RELINK_STOP_REASON) -> dict:
|
||||||
|
"""Link a server to an org directly (admin, OAuth) and settle. Raises
|
||||||
|
LinkError (``linked_elsewhere``) unless ``allow_move``. Returns
|
||||||
|
``{org_id, already_linked, previous_org_id, streams_stopped}``."""
|
||||||
|
result = await fstore.run_transaction(
|
||||||
|
link_guild_txn(org_id, str(guild_id), guild_name, linked_by, via, allow_move=allow_move, extra=extra))
|
||||||
|
if "error" in result:
|
||||||
|
raise result["error"]
|
||||||
|
return await _settle(guild_id, result, stop_reason)
|
||||||
|
|
||||||
|
|
||||||
|
async def redeem_and_settle(raw_code: str, guild_id: str, guild_name: Optional[str],
|
||||||
|
discord_user_id: str) -> dict:
|
||||||
|
"""``/drb link code:`` — redeem, then settle. Raises LinkError."""
|
||||||
|
result = await redeem_code(raw_code, guild_id, guild_name, discord_user_id)
|
||||||
|
return await _settle(guild_id, result, RELINK_STOP_REASON)
|
||||||
|
|
||||||
|
|
||||||
async def list_org_guilds(org_id: str) -> list[dict]:
|
async def list_org_guilds(org_id: str) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
|
{"guild_id": g.get("guild_id"), "guild_name": g.get("guild_name"),
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""
|
||||||
|
Per-bot OAuth "Connect a Discord server" (server-26#225 S6, owner decision Q1).
|
||||||
|
|
||||||
|
An org owner links a Discord server by inviting ONE radio bot through
|
||||||
|
Discord's OAuth2 bot invite. The invite's ``state`` carries a single-use,
|
||||||
|
short-lived nonce bound to the org and the user who started it; Discord
|
||||||
|
redirects back with ``code`` (and ``guild_id``), C2 exchanges the code with
|
||||||
|
THAT bot application's client secret, and the exchange's ``guild`` object —
|
||||||
|
never the query string — says which server to link.
|
||||||
|
|
||||||
|
Secrets:
|
||||||
|
|
||||||
|
bot_oauth/{token_id} server-only (Firestore rules deny all
|
||||||
|
client_secret, enabled, client reads; c2-core's admin SDK only)
|
||||||
|
set_by, set_at, checked_at
|
||||||
|
|
||||||
|
The secret is never on ``bot_tokens`` (GET /tokens returns an allowlist of
|
||||||
|
those fields), never returned by any route, never logged, never put in an
|
||||||
|
error string. Firestore encrypts it at rest like every other document; the
|
||||||
|
codebase has no application-level encryption pattern (bot tokens are
|
||||||
|
stored the same way), so none is invented here.
|
||||||
|
|
||||||
|
States:
|
||||||
|
|
||||||
|
discord_oauth_states/{sha256(nonce)} the nonce itself is never stored
|
||||||
|
org_id, uid, token_id, node_id, return_to, created_at, expires_at,
|
||||||
|
used_at|None, used_by|None
|
||||||
|
|
||||||
|
OAuth is off while ``settings.discord_oauth_redirect_uri`` is empty: every
|
||||||
|
route 404s and the UI hides the button.
|
||||||
|
|
||||||
|
HTTP goes through discord_rest's shared client (User-Agent, transport for
|
||||||
|
tests) and counts 401/403/429 answers against its process-wide breaker, so a
|
||||||
|
wrong secret retried in a loop can't contribute to an IP ban either.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
|
||||||
|
OAUTH = "bot_oauth"
|
||||||
|
STATES = "discord_oauth_states"
|
||||||
|
AUTHORIZE_URL = "https://discord.com/oauth2/authorize"
|
||||||
|
# Discord client secrets are 32 url-safe characters today; accept a margin.
|
||||||
|
SECRET_MIN, SECRET_MAX = 16, 128
|
||||||
|
RETURN_TO_MAX = 200
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool((settings.discord_oauth_redirect_uri or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def state_key(nonce: str) -> str:
|
||||||
|
return hashlib.sha256((nonce or "").encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def safe_return_to(value: Optional[str]) -> Optional[str]:
|
||||||
|
"""A same-site path only (``/nodes/abc``), never an absolute or
|
||||||
|
protocol-relative URL — the callback page redirects to it."""
|
||||||
|
v = (value or "").strip()
|
||||||
|
if not v or len(v) > RETURN_TO_MAX or not v.startswith("/") or v.startswith("//") or "\\" in v:
|
||||||
|
return None
|
||||||
|
if any(ord(ch) < 0x20 for ch in v):
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secrets
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def enabled_token_ids() -> set[str]:
|
||||||
|
"""Bots whose OAuth secret is set and enabled."""
|
||||||
|
return {d.get("token_id") for d in await fstore.collection_list(OAUTH)
|
||||||
|
if d.get("token_id") and d.get("enabled") and d.get("client_secret")}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_secret(token_id: str) -> Optional[str]:
|
||||||
|
doc = await fstore.doc_get(OAUTH, token_id)
|
||||||
|
if not doc or not doc.get("enabled") or not doc.get("client_secret"):
|
||||||
|
return None
|
||||||
|
return doc["client_secret"]
|
||||||
|
|
||||||
|
|
||||||
|
async def store_secret(token_id: str, client_secret: str, uid: str) -> None:
|
||||||
|
now = _now().isoformat()
|
||||||
|
await fstore.doc_set(OAUTH, token_id, {
|
||||||
|
"token_id": token_id, "client_secret": client_secret, "enabled": True,
|
||||||
|
"set_by": uid, "set_at": now, "checked_at": now,
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_secret(token_id: str) -> bool:
|
||||||
|
if not await fstore.doc_get(OAUTH, token_id):
|
||||||
|
return False
|
||||||
|
await fstore.doc_delete(OAUTH, token_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Discord OAuth2 HTTP (client_id + client_secret, basic auth)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _post_form(path: str, data: dict, client_id: str, client_secret: str) -> discord_rest.RestResult:
|
||||||
|
"""POST an application-authenticated form. Never raises for a Discord or
|
||||||
|
network failure; the error string never holds the secret, the code or a
|
||||||
|
token (only Discord's short ``error`` field)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
if discord_rest._breaker.tripped(now):
|
||||||
|
return discord_rest.RestResult(None, error="Discord REST paused (invalid-request budget reached)")
|
||||||
|
try:
|
||||||
|
r = await discord_rest._client().post(path, data=data, auth=(client_id, client_secret))
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return discord_rest.RestResult(None, error=type(e).__name__)
|
||||||
|
status = r.status_code
|
||||||
|
if status in (401, 403) or (status == 429 and r.headers.get("x-ratelimit-scope") != "shared"):
|
||||||
|
discord_rest._breaker.record(time.monotonic())
|
||||||
|
body: Any = None
|
||||||
|
try:
|
||||||
|
body = r.json() if r.content else None
|
||||||
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
if 200 <= status < 300:
|
||||||
|
return discord_rest.RestResult(status, body)
|
||||||
|
err = body.get("error") if isinstance(body, dict) and isinstance(body.get("error"), str) else None
|
||||||
|
return discord_rest.RestResult(status, None, error=f"Discord answered {status}" + (f" ({err[:40]})" if err else ""))
|
||||||
|
|
||||||
|
|
||||||
|
async def exchange_code(client_id: str, client_secret: str, code: str) -> discord_rest.RestResult:
|
||||||
|
return await _post_form("/oauth2/token", {
|
||||||
|
"grant_type": "authorization_code", "code": code,
|
||||||
|
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
|
||||||
|
}, client_id, client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke(client_id: str, client_secret: str, access_token: Optional[str]) -> None:
|
||||||
|
"""Best effort: C2 never uses the user's access token, so it is revoked
|
||||||
|
as soon as the guild has been read from the exchange."""
|
||||||
|
if access_token:
|
||||||
|
await _post_form("/oauth2/token/revoke", {"token": access_token, "token_type_hint": "access_token"},
|
||||||
|
client_id, client_secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_secret(client_id: str, client_secret: str) -> discord_rest.RestResult:
|
||||||
|
"""Prove ``client_secret`` belongs to ``client_id`` with a client-
|
||||||
|
credentials grant (scope ``identify`` — the bot owner's own token), then
|
||||||
|
revoke the token it returns. Nothing is stored from the answer."""
|
||||||
|
r = await _post_form("/oauth2/token", {"grant_type": "client_credentials", "scope": "identify"},
|
||||||
|
client_id, client_secret)
|
||||||
|
if r.ok and isinstance(r.data, dict):
|
||||||
|
await revoke(client_id, client_secret, r.data.get("access_token"))
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State + authorize URL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def authorize_url(application_id: str, nonce: str) -> str:
|
||||||
|
return AUTHORIZE_URL + "?" + urlencode({
|
||||||
|
"client_id": application_id,
|
||||||
|
"scope": "bot",
|
||||||
|
"permissions": str(leases.RADIO_BOT_PERMISSIONS),
|
||||||
|
"response_type": "code",
|
||||||
|
"redirect_uri": settings.discord_oauth_redirect_uri.strip(),
|
||||||
|
"state": nonce,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def create_state(*, org_id: str, uid: str, token_id: str, node_id: Optional[str],
|
||||||
|
return_to: Optional[str]) -> tuple[str, datetime]:
|
||||||
|
nonce = secrets.token_urlsafe(32)
|
||||||
|
now = _now()
|
||||||
|
expires_at = now + timedelta(seconds=max(60, settings.discord_oauth_state_ttl_seconds))
|
||||||
|
await fstore.doc_set(STATES, state_key(nonce), {
|
||||||
|
"org_id": org_id, "uid": uid, "token_id": token_id, "node_id": node_id,
|
||||||
|
"return_to": return_to, "created_at": now.isoformat(), "expires_at": expires_at.isoformat(),
|
||||||
|
"used_at": None, "used_by": None,
|
||||||
|
}, merge=False)
|
||||||
|
return nonce, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
class StateError(Exception):
|
||||||
|
def __init__(self, status: int, code: str, message: str):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status, self.code, self.message = status, code, message
|
||||||
|
|
||||||
|
|
||||||
|
def consume_state_txn(nonce: str, uid: str, now: datetime):
|
||||||
|
"""Transaction body: the state must exist, be unexpired and unused, and
|
||||||
|
belong to ``uid`` (login-CSRF: someone else's state never links into the
|
||||||
|
caller's org, and the caller's own code can't be replayed by another
|
||||||
|
account). Any presentation of a live state burns it — a failure later in
|
||||||
|
the flow means starting again, never retrying the same state."""
|
||||||
|
db = fstore.db
|
||||||
|
key = state_key(nonce)
|
||||||
|
|
||||||
|
def _txn(txn):
|
||||||
|
ref = db.collection(STATES).document(key)
|
||||||
|
snap = ref.get(transaction=txn)
|
||||||
|
doc = snap.to_dict() if snap.exists else None
|
||||||
|
if not doc:
|
||||||
|
return {"error": StateError(400, "invalid_state",
|
||||||
|
"That Discord link is not valid. Start again from DRB.")}
|
||||||
|
if doc.get("used_at"):
|
||||||
|
return {"error": StateError(409, "state_used",
|
||||||
|
"That Discord link was already used. Start again from DRB.")}
|
||||||
|
txn.update(ref, {"used_at": now.isoformat(), "used_by": uid})
|
||||||
|
try:
|
||||||
|
expired = now > datetime.fromisoformat(doc.get("expires_at") or "")
|
||||||
|
except ValueError:
|
||||||
|
expired = True
|
||||||
|
if expired:
|
||||||
|
return {"error": StateError(410, "state_expired",
|
||||||
|
"That Discord link expired. Start again from DRB.")}
|
||||||
|
if doc.get("uid") != uid:
|
||||||
|
return {"error": StateError(403, "wrong_user",
|
||||||
|
"That Discord link was started by a different DRB account. "
|
||||||
|
"Sign in as that account, or start again.")}
|
||||||
|
return {"state": doc}
|
||||||
|
|
||||||
|
return _txn
|
||||||
|
|
||||||
|
|
||||||
|
async def consume_state(nonce: str, uid: str) -> dict:
|
||||||
|
if not nonce or len(nonce) > 200:
|
||||||
|
raise StateError(400, "invalid_state", "That Discord link is not valid. Start again from DRB.")
|
||||||
|
out = await fstore.run_transaction(consume_state_txn(nonce, uid, _now()))
|
||||||
|
if "error" in out:
|
||||||
|
raise out["error"]
|
||||||
|
return out["state"]
|
||||||
@@ -655,3 +655,100 @@ async def guild_snapshot(token_doc: dict, guild_id: str, *, refresh: bool = Fals
|
|||||||
error=f"check failed ({type(e).__name__})")
|
error=f"check failed ({type(e).__name__})")
|
||||||
|
|
||||||
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
|
return await access_cache.get_or_load(key, _load, lambda s: ttl_for(s.status), refresh=refresh)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Voice-channel directory from a radio bot's view (server-26#225 S5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DIRECTORY_MAX_CHANNELS = 200 # same cap as PUT /discord/guilds/{id}/directory
|
||||||
|
_DIRECTORY_NAME_MAX = 100
|
||||||
|
VOICE_CHANNEL = 2
|
||||||
|
CATEGORY_CHANNEL = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or(value: Any, default: int = 0) -> int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def directory_from_snapshot(snap: Optional[GuildSnapshot]) -> Optional[dict]:
|
||||||
|
"""A server's name and voice channels as one radio bot's snapshot sees
|
||||||
|
them, in the shape the DRB server bot pushes (``PUT
|
||||||
|
/discord/guilds/{id}/directory``): ``{name, voice_channels: [{channel_id,
|
||||||
|
name, category, position}]}``, in Discord's sidebar order (channels with
|
||||||
|
no category first, then each category by position; channels by position,
|
||||||
|
then id). None unless the snapshot is OK.
|
||||||
|
|
||||||
|
Every voice channel in the list is kept, including any this bot can't
|
||||||
|
View, exactly like the server bot's gateway list: whether a bot may join
|
||||||
|
is the permission check's answer (discord_streams.evaluate_access), and
|
||||||
|
the picker shows it per channel. Stage channels are not voice channels
|
||||||
|
here either."""
|
||||||
|
if snap is None or snap.status != OK or not isinstance(snap.channels, list):
|
||||||
|
return None
|
||||||
|
chans = [c for c in snap.channels if isinstance(c, dict) and c.get("id") is not None]
|
||||||
|
cats = {str(c["id"]): c for c in chans if c.get("type") == CATEGORY_CHANNEL}
|
||||||
|
|
||||||
|
def parent(c: dict) -> Optional[dict]:
|
||||||
|
return cats.get(str(c.get("parent_id") or ""))
|
||||||
|
|
||||||
|
def order(c: dict) -> tuple:
|
||||||
|
p = parent(c)
|
||||||
|
top = (0, 0, 0) if p is None else (1, _int_or(p.get("position")), _int_or(p.get("id")))
|
||||||
|
return (*top, _int_or(c.get("position")), _int_or(c.get("id")))
|
||||||
|
|
||||||
|
out: list[dict] = []
|
||||||
|
for c in sorted((c for c in chans if c.get("type") == VOICE_CHANNEL), key=order)[:DIRECTORY_MAX_CHANNELS]:
|
||||||
|
p = parent(c)
|
||||||
|
category = str(p.get("name") or "")[:_DIRECTORY_NAME_MAX] if p else ""
|
||||||
|
out.append({
|
||||||
|
"channel_id": str(c["id"]),
|
||||||
|
"name": str(c.get("name") or "")[:_DIRECTORY_NAME_MAX],
|
||||||
|
"category": category or None,
|
||||||
|
"position": len(out),
|
||||||
|
})
|
||||||
|
name = snap.guild.get("name") if isinstance(snap.guild, dict) else None
|
||||||
|
return {"name": str(name)[:_DIRECTORY_NAME_MAX] if name else None, "voice_channels": out}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DirectoryResult:
|
||||||
|
"""``guild_directory``'s answer. ``status``: OK (``name`` and
|
||||||
|
``voice_channels`` from ``token_id``'s snapshot), NOT_MEMBER (every bot
|
||||||
|
asked turned out not to be in the server, or its token is invalid) or
|
||||||
|
UNKNOWN (no usable answer — keep what is stored)."""
|
||||||
|
guild_id: str
|
||||||
|
status: str
|
||||||
|
name: Optional[str] = None
|
||||||
|
voice_channels: Optional[list] = None
|
||||||
|
token_id: Optional[str] = None
|
||||||
|
fetched_at: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def guild_directory(token_docs: list[dict], guild_id: str, *, refresh: bool = False,
|
||||||
|
max_bots: int = 3) -> DirectoryResult:
|
||||||
|
"""The voice-channel list of one server, read from the first of
|
||||||
|
``token_docs`` (radio bots believed to be in it, best first) whose cached
|
||||||
|
snapshot is OK. It is the same snapshot the permission check reads, so
|
||||||
|
listing channels costs no extra Discord request. At most ``max_bots``
|
||||||
|
bots are asked, one after another. Never raises for a Discord failure.
|
||||||
|
|
||||||
|
The caller decides which server and which bots: this never looks a
|
||||||
|
server up by itself, so it can't discover one (a server DRB doesn't
|
||||||
|
already know is never listed from a radio bot's membership)."""
|
||||||
|
guild_id = str(guild_id)
|
||||||
|
statuses: list[str] = []
|
||||||
|
for doc in token_docs[:max(1, max_bots)]:
|
||||||
|
snap = await guild_snapshot(doc, guild_id, refresh=refresh)
|
||||||
|
statuses.append(snap.status)
|
||||||
|
d = directory_from_snapshot(snap)
|
||||||
|
if d is not None:
|
||||||
|
return DirectoryResult(guild_id=guild_id, status=OK, name=d["name"],
|
||||||
|
voice_channels=d["voice_channels"], token_id=snap.token_id,
|
||||||
|
fetched_at=snap.fetched_at)
|
||||||
|
if statuses and all(s in (NOT_MEMBER, INVALID) for s in statuses):
|
||||||
|
return DirectoryResult(guild_id=guild_id, status=NOT_MEMBER)
|
||||||
|
return DirectoryResult(guild_id=guild_id, status=UNKNOWN)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ A retained MQTT message therefore never stores a bot credential on the broker.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone, timedelta
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -93,6 +94,17 @@ def _parse_ts(value) -> Optional[datetime]:
|
|||||||
# Guild -> org
|
# Guild -> org
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def acting_org_of(link: Optional[dict]) -> Optional[str]:
|
||||||
|
"""``resolve_guild_org`` for an already-read ``discord_guilds`` doc (None =
|
||||||
|
no doc). Pure, so a link transaction can decide from the snapshot it read
|
||||||
|
(discord_guild_link.link_guild_txn, server-26#225 S6)."""
|
||||||
|
if link and link.get("org_id"):
|
||||||
|
return link["org_id"]
|
||||||
|
if link and link.get("unlinked_at"):
|
||||||
|
return None
|
||||||
|
return settings.discord_unlinked_guild_org_id or None
|
||||||
|
|
||||||
|
|
||||||
async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
||||||
"""The org a Discord server acts for: its ``discord_guilds`` link, else
|
"""The org a Discord server acts for: its ``discord_guilds`` link, else
|
||||||
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
|
``settings.discord_unlinked_guild_org_id`` (None when that is empty).
|
||||||
@@ -100,12 +112,7 @@ async def resolve_guild_org(guild_id: str) -> Optional[str]:
|
|||||||
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
|
A server an org explicitly unlinked (``unlinked_at`` set, server-26#213)
|
||||||
gets no fallback: otherwise unlinking org B's server would quietly hand it
|
gets no fallback: otherwise unlinking org B's server would quietly hand it
|
||||||
to the founding org."""
|
to the founding org."""
|
||||||
link = await fstore.doc_get(GUILDS, str(guild_id))
|
return acting_org_of(await fstore.doc_get(GUILDS, str(guild_id)))
|
||||||
if link and link.get("org_id"):
|
|
||||||
return link["org_id"]
|
|
||||||
if link and link.get("unlinked_at"):
|
|
||||||
return None
|
|
||||||
return settings.discord_unlinked_guild_org_id or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
|
async def _node_stream_counts(exclude_stream_id: Optional[str] = None) -> dict[str, int]:
|
||||||
@@ -352,14 +359,39 @@ async def check_bots(tokens: dict[str, dict], token_ids: list[str], guild_id: st
|
|||||||
for t in token_ids}
|
for t in token_ids}
|
||||||
|
|
||||||
|
|
||||||
|
# server-26#251: an interactive start (a person pressed Start / ran /join)
|
||||||
|
# re-reads a bot's snapshot that says "denied" once it is this old, so a
|
||||||
|
# permission fixed in Discord a moment ago is seen without waiting out the
|
||||||
|
# cache. The reconciler never does (plan finding 4: no REST loop).
|
||||||
|
RECHECK_DENIED_AFTER_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
|
async def _perm_check(*, org_id: str, guild_id: str, channel_id: str, stream_id: str,
|
||||||
preferred_token_id: Optional[str], node_ids: set) -> Optional[dict]:
|
preferred_token_id: Optional[str], node_ids: set,
|
||||||
|
recheck_denied: bool = False) -> Optional[dict]:
|
||||||
if not settings.discord_perm_check_enabled:
|
if not settings.discord_perm_check_enabled:
|
||||||
return None
|
return None
|
||||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||||
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
|
ids = tokens_to_check(tokens, guild_id=guild_id, org_id=org_id, node_ids=node_ids,
|
||||||
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
|
exclude_stream_id=stream_id, preferred_token_id=preferred_token_id)
|
||||||
return await check_bots(tokens, ids, guild_id, channel_id)
|
snaps = await gather_snapshots(tokens, ids, guild_id)
|
||||||
|
|
||||||
|
def verdict(t: str) -> dict:
|
||||||
|
return evaluate_access(snaps[t], channel_id, bot=tokens[t].get("name") or t) if snaps.get(t) \
|
||||||
|
else _unanswered()
|
||||||
|
|
||||||
|
out = {t: verdict(t) for t in ids}
|
||||||
|
if recheck_denied:
|
||||||
|
now = time.time()
|
||||||
|
stale = [t for t in ids if out[t]["status"] == leases.PERM_DENIED
|
||||||
|
and now - (getattr(snaps.get(t), "fetched_at", None) or now) >= RECHECK_DENIED_AFTER_SECONDS]
|
||||||
|
if stale:
|
||||||
|
fresh = await gather_snapshots(tokens, stale, guild_id, refresh=True)
|
||||||
|
for t, snap in fresh.items():
|
||||||
|
if snap is not None: # not answered in time: keep the cached verdict
|
||||||
|
snaps[t] = snap
|
||||||
|
out[t] = verdict(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
|
def _channel_ref(perms: Optional[dict], channel_name: Optional[str]) -> str:
|
||||||
@@ -409,7 +441,9 @@ ERROR_TEXT = {
|
|||||||
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
|
"pool_exhausted": ("Every radio bot that can join this server is already busy — a bot can be in only one voice "
|
||||||
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
|
"channel per server, and a bot carrying radio on another node can't be shared. Add another "
|
||||||
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
|
"radio bot to DRB and invite it to this server, or stop a radio channel here."),
|
||||||
"limit": "This server already has its maximum number of radio channels. Stop one with /leave first.",
|
# Reaches the web picker and /join alike: name both ways to stop one.
|
||||||
|
"limit": "This server already has its maximum number of radio channels. Stop one first (Stop on a node's "
|
||||||
|
"Discord radio panel, or /leave in Discord).",
|
||||||
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
|
"preferred_unavailable": "That radio bot can't be used here (it was removed, failed validation, or belongs to "
|
||||||
"another organization). Pick another bot, or let DRB choose any free one.",
|
"another organization). Pick another bot, or let DRB choose any free one.",
|
||||||
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
|
"pool_full": ("No radio bot is in this server, and every DRB radio bot that could be invited is already in its "
|
||||||
@@ -484,6 +518,7 @@ async def start_stream(
|
|||||||
preferred_token_id: Optional[str] = None,
|
preferred_token_id: Optional[str] = None,
|
||||||
max_streams: Optional[int] = None,
|
max_streams: Optional[int] = None,
|
||||||
stream_id: Optional[str] = None,
|
stream_id: Optional[str] = None,
|
||||||
|
interactive: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Put ``system_id`` on a voice channel.
|
"""Put ``system_id`` on a voice channel.
|
||||||
|
|
||||||
@@ -504,6 +539,12 @@ async def start_stream(
|
|||||||
channel first (``_perm_check``; server-26#225). Every entry point — the
|
channel first (``_perm_check``; server-26#225). Every entry point — the
|
||||||
bot's /join, the web picker, the node command and the reconciler —
|
bot's /join, the web picker, the node command and the reconciler —
|
||||||
comes through here, so none can hand a node a bot Discord will refuse.
|
comes through here, so none can hand a node a bot Discord will refuse.
|
||||||
|
|
||||||
|
``interactive``: a person asked for this start (the routes pass True,
|
||||||
|
the reconciler never does). A bot whose cached check says "denied" is
|
||||||
|
then asked again if that answer is more than
|
||||||
|
``RECHECK_DENIED_AFTER_SECONDS`` old (server-26#251), so "fix it in
|
||||||
|
Discord, then try again" works on the next try.
|
||||||
"""
|
"""
|
||||||
if mode not in ("persistent", "adhoc"):
|
if mode not in ("persistent", "adhoc"):
|
||||||
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
|
raise StreamError("bad_mode", "mode must be persistent or adhoc", status=400)
|
||||||
@@ -527,14 +568,14 @@ async def start_stream(
|
|||||||
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
|
org_id=org_id, guild_id=guild_id, channel_id=channel_id, system_id=system_id, mode=mode,
|
||||||
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
|
channel_name=channel_name, guild_name=guild_name, created_by=created_by, node_id=node_id,
|
||||||
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
|
preferred_token_id=preferred_token_id, max_streams=max_streams, stream_id=stream_id,
|
||||||
existing=existing,
|
existing=existing, interactive=interactive,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
|
async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_id: str, mode: str,
|
||||||
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
|
channel_name: Optional[str], guild_name: Optional[str], created_by: Optional[str],
|
||||||
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
|
node_id: Optional[str], preferred_token_id: Optional[str], max_streams: Optional[int],
|
||||||
stream_id: Optional[str], existing: dict) -> dict:
|
stream_id: Optional[str], existing: dict, interactive: bool = False) -> dict:
|
||||||
already_on = False
|
already_on = False
|
||||||
if stream_id:
|
if stream_id:
|
||||||
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
|
existing = await fstore.doc_get(STREAMS, stream_id) or existing # re-read under the lock
|
||||||
@@ -612,7 +653,8 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
|
|||||||
|
|
||||||
node_ids = {n["node_id"] for n in candidates}
|
node_ids = {n["node_id"] for n in candidates}
|
||||||
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
|
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id, stream_id=stream_id,
|
||||||
preferred_token_id=preferred_token_id, node_ids=node_ids)
|
preferred_token_id=preferred_token_id, node_ids=node_ids,
|
||||||
|
recheck_denied=interactive)
|
||||||
if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
|
if perms and any(p.get("status") == "bad_channel" for p in perms.values()):
|
||||||
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
|
raise StreamError("bad_channel", f"{_channel_ref(perms, channel_name)} is not a voice channel. "
|
||||||
"Radio can only play in a voice channel — pick one from the list.",
|
"Radio can only play in a voice channel — pick one from the list.",
|
||||||
@@ -638,7 +680,7 @@ async def _start_locked(*, org_id: str, guild_id: str, channel_id: str, system_i
|
|||||||
# Bots that just turned out to be members get checked too.
|
# Bots that just turned out to be members get checked too.
|
||||||
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
|
perms = await _perm_check(org_id=org_id, guild_id=guild_id, channel_id=channel_id,
|
||||||
stream_id=stream_id, preferred_token_id=preferred_token_id,
|
stream_id=stream_id, preferred_token_id=preferred_token_id,
|
||||||
node_ids=node_ids)
|
node_ids=node_ids, recheck_denied=interactive)
|
||||||
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
|
res = await leases.lease_for_stream(stream_id, node_id=node["node_id"], org_id=org_id,
|
||||||
preferred_token_id=preferred_token_id, stream_fields=fields,
|
preferred_token_id=preferred_token_id, stream_fields=fields,
|
||||||
soft_preferred_token_id=soft_pref, perms=perms)
|
soft_preferred_token_id=soft_pref, perms=perms)
|
||||||
|
|||||||
@@ -690,8 +690,15 @@ async def _geocode_location(
|
|||||||
"""
|
"""
|
||||||
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
||||||
|
|
||||||
Returns {"lat": float, "lng": float}, or None if geocoding fails or the
|
Returns {"lat": float, "lng": float, "location_type": str}, or None if
|
||||||
result lands outside the area this channel covers.
|
geocoding fails or the result lands outside the area this channel covers.
|
||||||
|
|
||||||
|
server-26#236: `location_type` (ROOFTOP / RANGE_INTERPOLATED /
|
||||||
|
GEOMETRIC_CENTER) rides along with the coordinates so the map can show how
|
||||||
|
precise a pin is — a route centroid must not draw the same teardrop as a
|
||||||
|
rooftop. It is display-only: every consumer of these coords reads only
|
||||||
|
"lat"/"lng", and the dict is stored as-is on the call doc and, through
|
||||||
|
incident_correlator._resolve_location_pair, on the incident pin.
|
||||||
|
|
||||||
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
|
THE REFERENCE POINT IS THE TALKGROUP, NOT THE NODE (server-26#6 / #37). This
|
||||||
used to reject anything more than geocode_max_km (40km) from the receiving
|
used to reject anything more than geocode_max_km (40km) from the receiving
|
||||||
@@ -801,7 +808,7 @@ async def _geocode_location(
|
|||||||
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
|
f"{dist_km:.1f}km from the receiving node, past {max_km:.1f}km — "
|
||||||
f"accepted anyway: the query names its own region, not the node's"
|
f"accepted anyway: the query names its own region, not the node's"
|
||||||
)
|
)
|
||||||
coords = {"lat": lat, "lng": lng}
|
coords = {"lat": lat, "lng": lng, "location_type": location_type}
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Geocoded '{location_str}' → {coords} "
|
f"Geocoded '{location_str}' → {coords} "
|
||||||
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ class MQTTHandler:
|
|||||||
"sdr_devices": payload.get("sdr_devices"),
|
"sdr_devices": payload.get("sdr_devices"),
|
||||||
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
||||||
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
||||||
|
"disk_free_bytes": payload.get("disk_free_bytes"),
|
||||||
|
"disk_total_bytes": payload.get("disk_total_bytes"),
|
||||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||||
"is_overridden": False,
|
"is_overridden": False,
|
||||||
"override_system_id": None,
|
"override_system_id": None,
|
||||||
@@ -161,8 +163,11 @@ class MQTTHandler:
|
|||||||
# radio_channels (node-26#24): a conventional node's channels and
|
# radio_channels (node-26#24): a conventional node's channels and
|
||||||
# whether each is encrypted — the one decode failure the owner has
|
# whether each is encrypted — the one decode failure the owner has
|
||||||
# to hear about from C2, since nothing on the node can fix it.
|
# to hear about from C2, since nothing on the node can fix it.
|
||||||
|
# disk_* (node-26#41): free/total bytes of the host disk the node
|
||||||
|
# records to — a full disk silently stopped node-002 for 17 days.
|
||||||
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
||||||
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels"):
|
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
|
||||||
|
"disk_free_bytes", "disk_total_bytes"):
|
||||||
if key in payload:
|
if key in payload:
|
||||||
updates[key] = payload[key]
|
updates[key] = payload[key]
|
||||||
if "sdr_count" in payload:
|
if "sdr_count" in payload:
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Pluggable power-outage providers (map layer, server-26#184).
|
||||||
|
|
||||||
|
Same shape as ``app.internal.cameras``: every source is one adapter carrying
|
||||||
|
its catalogued licence status, every provider defaults OFF, and a licence-gated
|
||||||
|
one serves only after an admin acknowledgement recorded in audit_log
|
||||||
|
(``registry``). Scraped utility storm-center JSON is never shipped -- those
|
||||||
|
sources are stubs until a data-sharing agreement exists.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Outage provider interface, normalised record and the per-provider feed cache.
|
||||||
|
|
||||||
|
The licence model (``Licence``, ``LicenceInfo``, the GATED set) is the camera
|
||||||
|
layer's, imported -- one catalogue vocabulary for every licence-gated source.
|
||||||
|
Only the acknowledgement wording differs (outage data, not imagery), so
|
||||||
|
``OutageLicenceInfo`` overrides ``warning()`` and nothing else.
|
||||||
|
|
||||||
|
Outage feeds are public and identical for every org, so -- like the camera
|
||||||
|
catalogs -- they are cached in memory, fetched lazily on request, and never
|
||||||
|
written to Firestore. A failed refresh keeps serving the last good feed and
|
||||||
|
reports the error and its age: a dead feed must show as an error, never as an
|
||||||
|
empty layer (the AI-silent-failures lesson).
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.internal.cameras.base import ( # noqa: F401 (re-exported for adapters)
|
||||||
|
BBox, Licence, LicenceInfo, bbox_intersects, finite, get_json, require_parsed, safe_error,
|
||||||
|
)
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
RETRY_AFTER_FAILURE_S = 60
|
||||||
|
DEFAULT_FEED_TTL_S = 5 * 60 # ODIN asks utilities for updates every 5-15 min
|
||||||
|
USER_AGENT = "DRB-c2-core/1.0 (+public power-outage data; contact via odin.ornl.gov)"
|
||||||
|
|
||||||
|
OutageArea = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OutageLicenceInfo(LicenceInfo):
|
||||||
|
"""``LicenceInfo`` with the acknowledgement worded for outage data."""
|
||||||
|
|
||||||
|
def warning(self) -> Optional[str]:
|
||||||
|
if self.status == Licence.PROHIBITED:
|
||||||
|
return f"{self.label}. This source's terms forbid this use; it cannot be enabled."
|
||||||
|
if not self.gated:
|
||||||
|
return None
|
||||||
|
return (f"{self.label}. {self.terms} Enabling it means DRB shows this source's outage data "
|
||||||
|
f"to every signed-in user. Only confirm if DRB actually holds the right to do so.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OutageProviderMeta:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
region: str
|
||||||
|
coverage: BBox # rough bbox; a query outside it never triggers a fetch
|
||||||
|
licence: LicenceInfo
|
||||||
|
granularity: str # "county", "point/polygon", ...
|
||||||
|
implemented: bool
|
||||||
|
notes: str # what is verified vs unverified about this adapter
|
||||||
|
homepage: Optional[str] = None
|
||||||
|
ttl_s: int = DEFAULT_FEED_TTL_S
|
||||||
|
|
||||||
|
|
||||||
|
class OutageProvider:
|
||||||
|
"""Base adapter. Subclasses set ``meta`` and implement ``fetch``."""
|
||||||
|
meta: OutageProviderMeta
|
||||||
|
|
||||||
|
async def fetch(self) -> List[OutageArea]:
|
||||||
|
raise NotImplementedError(f"{self.meta.id}: adapter is a stub ({self.meta.notes})")
|
||||||
|
|
||||||
|
def coverage_note(self) -> Optional[str]:
|
||||||
|
"""What this source can and cannot see, shown next to its data."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubProvider(OutageProvider):
|
||||||
|
"""A catalogued source with no adapter -- listed so Admin shows its licence
|
||||||
|
status, but it can never be enabled."""
|
||||||
|
|
||||||
|
def __init__(self, meta: OutageProviderMeta):
|
||||||
|
assert not meta.implemented
|
||||||
|
self.meta = meta
|
||||||
|
|
||||||
|
|
||||||
|
class Feed:
|
||||||
|
"""In-memory cache of one provider's normalised outage areas."""
|
||||||
|
|
||||||
|
def __init__(self, provider: OutageProvider):
|
||||||
|
self.provider = provider
|
||||||
|
self.items: List[OutageArea] = []
|
||||||
|
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
|
||||||
|
self.last_attempt_at: Optional[float] = None
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self._next_attempt = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self) -> "Feed":
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
async with self._lock:
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
ttl = self.provider.meta.ttl_s
|
||||||
|
self.last_attempt_at = time.time()
|
||||||
|
try:
|
||||||
|
items = await self.provider.fetch()
|
||||||
|
self.items = items
|
||||||
|
self.fetched_at = time.time()
|
||||||
|
self.error = None
|
||||||
|
self._next_attempt = time.time() + ttl
|
||||||
|
except Exception as e:
|
||||||
|
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
|
||||||
|
self.error = safe_error(e)
|
||||||
|
logger.warning(f"outages/{self.provider.meta.id}: refresh failed, serving {len(self.items)} cached: {self.error}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
return {"fetched_at": self.fetched_at, "last_attempt_at": self.last_attempt_at,
|
||||||
|
"error": self.error, "count": len(self.items)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers shared by adapters ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def make_area(provider_id: str, fips: str, *, county: Optional[str], state: Optional[str], customers_out: int,
|
||||||
|
customers_served: Optional[int] = None, etr: Optional[str] = None, since: Optional[str] = None,
|
||||||
|
updated_at: Optional[str] = None, lat: Optional[float] = None, lon: Optional[float] = None,
|
||||||
|
bbox: Optional[BBox] = None, utilities: Optional[List[dict]] = None,
|
||||||
|
stale_customers_out: int = 0) -> OutageArea:
|
||||||
|
"""One normalised county outage record. ``id`` is provider-prefixed.
|
||||||
|
|
||||||
|
``customers_out`` counts only live events. ``stale_customers_out`` counts
|
||||||
|
events the source still lists whose own restoration estimate passed long
|
||||||
|
ago (ODIN carries April rows in October) -- kept visible, never summed in.
|
||||||
|
``stale`` is True when every event in the county is stale."""
|
||||||
|
return {
|
||||||
|
"id": f"{provider_id}:{fips}",
|
||||||
|
"provider": provider_id,
|
||||||
|
"fips": fips, "county": county, "state": state,
|
||||||
|
"customers_out": customers_out,
|
||||||
|
"stale_customers_out": stale_customers_out,
|
||||||
|
"stale": customers_out == 0 and stale_customers_out > 0,
|
||||||
|
"customers_served": customers_served,
|
||||||
|
"etr": etr, # latest estimated restoration across the county's outages, if any
|
||||||
|
"since": since, # earliest reported start across the county's outages
|
||||||
|
"updated_at": updated_at, # newest time a reporting utility in this county last sent data
|
||||||
|
# Approximate: the centre of the county's bounding box, for placing a label.
|
||||||
|
"lat": lat, "lon": lon,
|
||||||
|
"bbox": list(bbox) if bbox else None, # south, west, north, east
|
||||||
|
"utilities": utilities or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def area_in_bbox(area: OutageArea, bbox: BBox) -> bool:
|
||||||
|
"""County bbox intersects the query; falls back to the label point."""
|
||||||
|
b = area.get("bbox")
|
||||||
|
if b and len(b) == 4:
|
||||||
|
return bbox_intersects((b[0], b[1], b[2], b[3]), bbox)
|
||||||
|
lat, lon = area.get("lat"), area.get("lon")
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return False
|
||||||
|
s, w, n, e = bbox
|
||||||
|
return s <= lat <= n and w <= lon <= e
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso(v: Any) -> Optional[datetime]:
|
||||||
|
"""ISO-8601 (with Z or offset) -> aware UTC datetime; None when unparseable."""
|
||||||
|
if not isinstance(v, str) or not v:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
d = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return (d if d.tzinfo else d.replace(tzinfo=timezone.utc)).astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def iso(d: Optional[datetime]) -> Optional[str]:
|
||||||
|
return d.isoformat().replace("+00:00", "Z") if d else None
|
||||||
|
|
||||||
|
|
||||||
|
def geometry_bbox(geom: Any) -> Optional[BBox]:
|
||||||
|
"""Bounding box (south, west, north, east) of a GeoJSON Polygon/MultiPolygon."""
|
||||||
|
if not isinstance(geom, dict):
|
||||||
|
return None
|
||||||
|
lats: List[float] = []
|
||||||
|
lons: List[float] = []
|
||||||
|
|
||||||
|
def walk(c: Any) -> None:
|
||||||
|
if isinstance(c, list) and len(c) >= 2 and not isinstance(c[0], list):
|
||||||
|
lon, lat = finite(c[0]), finite(c[1])
|
||||||
|
if lon is not None and lat is not None and -180 <= lon <= 180 and -90 <= lat <= 90:
|
||||||
|
lons.append(lon)
|
||||||
|
lats.append(lat)
|
||||||
|
elif isinstance(c, list):
|
||||||
|
for x in c:
|
||||||
|
walk(x)
|
||||||
|
|
||||||
|
walk(geom.get("coordinates"))
|
||||||
|
if not lats:
|
||||||
|
return None
|
||||||
|
return (min(lats), min(lons), max(lats), max(lons))
|
||||||
|
|
||||||
|
|
||||||
|
def bbox_centre(b: Optional[BBox]) -> Tuple[Optional[float], Optional[float]]:
|
||||||
|
if not b:
|
||||||
|
return None, None
|
||||||
|
return (round((b[0] + b[2]) / 2, 5), round((b[1] + b[3]) / 2, 5))
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
"""Outage sources -- server-26#184 (source-terms table in its 2026-10-05 comment).
|
||||||
|
|
||||||
|
Only ODIN has an adapter. Every other source is listed so Admin shows its
|
||||||
|
licence status, and is a stub because DRB holds no right to use it: the utility
|
||||||
|
storm-center maps are undocumented JSON with no reuse licence (the panel ruling
|
||||||
|
stands -- do not ship scraped utility data commercially), PowerOutage.us
|
||||||
|
licenses its API for personal/non-commercial use only, and EAGLE-I's live feed
|
||||||
|
is a DOE partner system. Do not "fix" a stub by scraping.
|
||||||
|
|
||||||
|
ODIN, verified first-hand 2026-10-05 against https://odin.ornl.gov/v3/api-docs
|
||||||
|
(the OpenAPI document the ODIN developer guide points at) and live responses:
|
||||||
|
|
||||||
|
GET /odi?format=JSON public, no token. ``{"outage": [CIM Outage, ...]}``,
|
||||||
|
one row per outage event, every row aggregated to a
|
||||||
|
county: ``communityDescriptor`` = 5-digit county FIPS,
|
||||||
|
``metersAffected``, ``reportedStartTime``,
|
||||||
|
``estimatedRestorationTime.ert``, ``names`` carrying
|
||||||
|
``UtilityName`` / ``UtilityId`` (EIA id).
|
||||||
|
GET /odi/map?scale=COUNTY public. One row per county with active outages:
|
||||||
|
``id`` (FIPS), ``countyName``, ``stateName``,
|
||||||
|
``metersOut`` (= sum of /odi metersAffected, checked),
|
||||||
|
``feature`` (county Polygon/MultiPolygon).
|
||||||
|
GET /odi/status public. One row per reporting utility: ``eiaId``,
|
||||||
|
``name``, ``receivedDate``, ``hasError``.
|
||||||
|
|
||||||
|
``metersServed`` on /odi/map is the utility's system-wide total (National Grid
|
||||||
|
NY carries 1,686,884 on every county), so it is NOT a county denominator and
|
||||||
|
``customers_served`` stays None.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.internal.outages.base import (
|
||||||
|
Licence, OutageArea, OutageLicenceInfo, OutageProvider, OutageProviderMeta, StubProvider, USER_AGENT,
|
||||||
|
bbox_centre, finite, geometry_bbox, get_json, iso, make_area, parse_iso, require_parsed,
|
||||||
|
)
|
||||||
|
|
||||||
|
US_BBOX = (17.5, -180.0, 72.0, -64.0) # CONUS + AK + HI + PR, rough
|
||||||
|
NY_BBOX = (40.4, -80.0, 45.1, -71.7)
|
||||||
|
|
||||||
|
ODIN_BASE = "https://odin.ornl.gov"
|
||||||
|
ODIN_OPENAPI = "https://odin.ornl.gov/v3/api-docs"
|
||||||
|
_FIPS = re.compile(r"^\d{5}$")
|
||||||
|
# An event whose own estimated restoration passed this long ago is stale: ODIN
|
||||||
|
# still listed Avangrid rows from April 2026 (ETR April/May) on 2026-10-05.
|
||||||
|
# Those are not live outages and must not be summed into one or set "since".
|
||||||
|
STALE_AFTER_ETR = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
class OdinProvider(OutageProvider):
|
||||||
|
meta = OutageProviderMeta(
|
||||||
|
id="odin",
|
||||||
|
name="ODIN (Oak Ridge National Laboratory / DOE)",
|
||||||
|
region="United States -- participating utilities only (NY: National Grid, Central Hudson, Avangrid reporting 2026-10-05)",
|
||||||
|
coverage=US_BBOX,
|
||||||
|
licence=OutageLicenceInfo(
|
||||||
|
status=Licence.UNVERIFIED,
|
||||||
|
label="Licence unverified -- public county-level API, but no terms of use or reuse licence found",
|
||||||
|
terms=("ODIN documents GET /odi, /odi/map and /odi/status as public, non-authenticated endpoints "
|
||||||
|
"returning current outages aggregated to county FIPS (OpenAPI read first-hand 2026-10-05). "
|
||||||
|
"That document carries no licence or terms-of-service field, and no terms-of-use page was "
|
||||||
|
"found on odin.ornl.gov. ORNL is operated for DOE by a contractor and the data is "
|
||||||
|
"utility-reported, so federal public-domain status cannot be assumed. Confirm reuse terms "
|
||||||
|
"with ODIN (odin.ornl.gov/pages/contact.html) before relying on it commercially."),
|
||||||
|
terms_url=ODIN_OPENAPI,
|
||||||
|
attribution="Outage data: ODIN, Oak Ridge National Laboratory (utility-reported)",
|
||||||
|
attribution_url="https://odin.ornl.gov",
|
||||||
|
disclaimer=("County-level counts of meters reported out by utilities that participate in ODIN. "
|
||||||
|
"A utility that does not report to ODIN shows no outages here, even during one. "
|
||||||
|
"Restoration times are the utility's estimate, when given."),
|
||||||
|
issue="server-26#184",
|
||||||
|
),
|
||||||
|
granularity="county (FIPS)",
|
||||||
|
implemented=True,
|
||||||
|
notes=("Public API, verified first-hand 2026-10-05: /odi?format=JSON (counts, start, ETR, utility EIA id), "
|
||||||
|
"/odi/map?scale=COUNTY (county name, state, county polygon -> bbox and label point) and /odi/status "
|
||||||
|
"(utility last-received time). Counts are meters, used as customers. Con Edison / O&R are on ODIN's "
|
||||||
|
"participant list but were NOT reporting on 2026-10-05, so NYC/Westchester show nothing from ODIN "
|
||||||
|
"until they do. Schema canary: rows upstream but none parseable is an error, never an empty layer."),
|
||||||
|
homepage="https://odin.ornl.gov",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.reporting_utilities: Optional[int] = None
|
||||||
|
self.utilities_with_error: Optional[int] = None
|
||||||
|
|
||||||
|
async def fetch(self) -> List[OutageArea]:
|
||||||
|
h = {"User-Agent": USER_AGENT}
|
||||||
|
odi, county_map, status = await asyncio.gather(
|
||||||
|
get_json(f"{ODIN_BASE}/odi", params={"format": "JSON"}, headers=h),
|
||||||
|
get_json(f"{ODIN_BASE}/odi/map", params={"scale": "COUNTY"}, headers=h),
|
||||||
|
get_json(f"{ODIN_BASE}/odi/status", headers=h),
|
||||||
|
)
|
||||||
|
areas, info = parse_odin(odi, county_map, status)
|
||||||
|
self.reporting_utilities = info["reporting_utilities"]
|
||||||
|
self.utilities_with_error = info["utilities_with_error"]
|
||||||
|
return areas
|
||||||
|
|
||||||
|
def coverage_note(self) -> Optional[str]:
|
||||||
|
n = self.reporting_utilities
|
||||||
|
base = "Only utilities that report to ODIN appear; one outside ODIN shows no outages here even during one."
|
||||||
|
if n is None:
|
||||||
|
return base
|
||||||
|
err = f", {self.utilities_with_error} with a feed error" if self.utilities_with_error else ""
|
||||||
|
return f"{base} {n} utilities reporting nationwide{err}."
|
||||||
|
|
||||||
|
|
||||||
|
def _utility(names: object) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
name = eia = None
|
||||||
|
for n in names if isinstance(names, list) else []:
|
||||||
|
if not isinstance(n, dict):
|
||||||
|
continue
|
||||||
|
if n.get("nameType") == "UtilityName" and isinstance(n.get("name"), str):
|
||||||
|
name = n["name"]
|
||||||
|
elif n.get("nameType") == "UtilityId" and n.get("name") is not None:
|
||||||
|
eia = str(n["name"])
|
||||||
|
return name, eia
|
||||||
|
|
||||||
|
|
||||||
|
def parse_odin(odi: object, county_map: object, status: object,
|
||||||
|
now: Optional[datetime] = None) -> Tuple[List[OutageArea], dict]:
|
||||||
|
"""The three public ODIN responses -> one normalised record per county.
|
||||||
|
|
||||||
|
Events whose estimated restoration is more than ``STALE_AFTER_ETR`` before
|
||||||
|
``now`` are counted separately (``stale_customers_out``), not as live.
|
||||||
|
|
||||||
|
Raises ValueError when a payload has the wrong top-level shape or when rows
|
||||||
|
came back but none could be read -- an upstream schema change must surface
|
||||||
|
as a feed error, not as an empty map."""
|
||||||
|
if not isinstance(odi, dict) or not isinstance(odi.get("outage"), list):
|
||||||
|
raise ValueError("ODIN /odi: expected {'outage': [...]} -- upstream schema change?")
|
||||||
|
if not isinstance(county_map, list):
|
||||||
|
raise ValueError("ODIN /odi/map: expected a list -- upstream schema change?")
|
||||||
|
if not isinstance(status, list):
|
||||||
|
raise ValueError("ODIN /odi/status: expected a list -- upstream schema change?")
|
||||||
|
|
||||||
|
# Utility freshness, by EIA id.
|
||||||
|
received: Dict[str, str] = {}
|
||||||
|
errors = 0
|
||||||
|
for u in status:
|
||||||
|
if not isinstance(u, dict):
|
||||||
|
continue
|
||||||
|
errors += 1 if u.get("hasError") is True else 0
|
||||||
|
eia, d = u.get("eiaId"), parse_iso(u.get("receivedDate"))
|
||||||
|
if eia is not None and d is not None:
|
||||||
|
received[str(eia)] = iso(d)
|
||||||
|
require_parsed(len(status), list(received), "ODIN /odi/status")
|
||||||
|
|
||||||
|
# County geography.
|
||||||
|
geo: Dict[str, dict] = {}
|
||||||
|
for row in county_map:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
fips = str(row.get("id") or "")
|
||||||
|
if not _FIPS.match(fips):
|
||||||
|
continue
|
||||||
|
b = geometry_bbox(row.get("feature"))
|
||||||
|
geo[fips] = {"county": row.get("countyName") if isinstance(row.get("countyName"), str) else None,
|
||||||
|
"state": row.get("stateName") if isinstance(row.get("stateName"), str) else None,
|
||||||
|
"bbox": b}
|
||||||
|
require_parsed(len(county_map), list(geo), "ODIN /odi/map county")
|
||||||
|
|
||||||
|
# Outage events, aggregated per county.
|
||||||
|
stale_before = (now or datetime.now(timezone.utc)) - STALE_AFTER_ETR
|
||||||
|
agg: Dict[str, dict] = {}
|
||||||
|
parsed = stale_events = 0
|
||||||
|
for o in odi["outage"]:
|
||||||
|
if not isinstance(o, dict):
|
||||||
|
continue
|
||||||
|
fips = str(o.get("communityDescriptor") or "")
|
||||||
|
meters = finite(o.get("metersAffected"))
|
||||||
|
if not _FIPS.match(fips) or meters is None or meters < 0:
|
||||||
|
continue
|
||||||
|
parsed += 1
|
||||||
|
area = o.get("outageArea") if isinstance(o.get("outageArea"), dict) else {}
|
||||||
|
start = parse_iso(o.get("reportedStartTime")) or parse_iso(area.get("earliestReportedTime"))
|
||||||
|
ert_obj = o.get("estimatedRestorationTime") if isinstance(o.get("estimatedRestorationTime"), dict) else {}
|
||||||
|
ert = parse_iso(ert_obj.get("ert"))
|
||||||
|
if ert is not None and ert.year < 2000: # Puget Sound Energy sends 0001-01-01T00:00:00Z for "no ETR"
|
||||||
|
ert = None
|
||||||
|
stale = ert is not None and ert < stale_before
|
||||||
|
stale_events += stale
|
||||||
|
a = agg.setdefault(fips, {"out": 0, "stale_out": 0, "utilities": {},
|
||||||
|
"live": {"since": None, "etr": None}, "stale": {"since": None, "etr": None}})
|
||||||
|
a["stale_out" if stale else "out"] += int(meters)
|
||||||
|
t = a["stale" if stale else "live"] # since/etr come from live events whenever there are any
|
||||||
|
if start and (t["since"] is None or start < t["since"]):
|
||||||
|
t["since"] = start
|
||||||
|
if ert and (t["etr"] is None or ert > t["etr"]):
|
||||||
|
t["etr"] = ert
|
||||||
|
name, eia = _utility(o.get("names"))
|
||||||
|
key = eia or name or "unknown"
|
||||||
|
u = a["utilities"].setdefault(key, {"name": name, "eia_id": eia, "customers_out": 0,
|
||||||
|
"stale_customers_out": 0})
|
||||||
|
u["stale_customers_out" if stale else "customers_out"] += int(meters)
|
||||||
|
require_parsed(len(odi["outage"]), [None] * parsed, "ODIN /odi outage")
|
||||||
|
|
||||||
|
areas: List[OutageArea] = []
|
||||||
|
for fips, a in agg.items():
|
||||||
|
g = geo.get(fips, {})
|
||||||
|
utilities = sorted(a["utilities"].values(), key=lambda u: (-u["customers_out"], -u["stale_customers_out"]))
|
||||||
|
stamps = [received[u["eia_id"]] for u in utilities if u["eia_id"] in received]
|
||||||
|
lat, lon = bbox_centre(g.get("bbox"))
|
||||||
|
t = a["live"] if a["out"] else a["stale"]
|
||||||
|
areas.append(make_area(
|
||||||
|
"odin", fips, county=g.get("county"), state=g.get("state"), customers_out=a["out"],
|
||||||
|
stale_customers_out=a["stale_out"], customers_served=None, etr=iso(t["etr"]), since=iso(t["since"]),
|
||||||
|
updated_at=max(stamps) if stamps else None, lat=lat, lon=lon, bbox=g.get("bbox"),
|
||||||
|
utilities=utilities,
|
||||||
|
))
|
||||||
|
areas.sort(key=lambda r: (-r["customers_out"], -r["stale_customers_out"]))
|
||||||
|
return areas, {"reporting_utilities": len(status), "utilities_with_error": errors, "stale_events": stale_events}
|
||||||
|
|
||||||
|
|
||||||
|
# ── stubs: catalogued, never enable-able ─────────────────────────────────────
|
||||||
|
|
||||||
|
_STORM_CENTER_TERMS = ("The utility's public outage map is backed by undocumented JSON (vendor storm-center "
|
||||||
|
"endpoints) with no published licence for automated reuse; the endpoints can change "
|
||||||
|
"without notice. Panel ruling (server-26#184): do not ship scraped utility data "
|
||||||
|
"commercially. A direct data-sharing agreement is the licensed path (also a sales lead).")
|
||||||
|
|
||||||
|
|
||||||
|
def _stub(id: str, name: str, region: str, coverage, status: Licence, label: str, terms: str,
|
||||||
|
terms_url: Optional[str], granularity: str, notes: str, homepage: Optional[str]) -> StubProvider:
|
||||||
|
return StubProvider(OutageProviderMeta(
|
||||||
|
id=id, name=name, region=region, coverage=coverage,
|
||||||
|
licence=OutageLicenceInfo(status=status, label=label, terms=terms, terms_url=terms_url,
|
||||||
|
issue="server-26#184"),
|
||||||
|
granularity=granularity, implemented=False, notes=notes, homepage=homepage,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDERS: List[OutageProvider] = [
|
||||||
|
OdinProvider(),
|
||||||
|
_stub("poweroutage_us", "PowerOutage.us (FE Bluefire LLC)", "United States", US_BBOX, Licence.PAID,
|
||||||
|
"Paid API -- licence is personal/non-commercial (news media excepted); commercial use needs an agreement DRB does not hold",
|
||||||
|
("API & Content Terms: the licence is for \"personal and noncommercial use (except for News Media "
|
||||||
|
"Organizations)\"; commercial use needs a separate agreement; pricing by contact. Quoted from a "
|
||||||
|
"search summary -- the terms page returned 403 to a direct fetch, so re-read it first-hand before "
|
||||||
|
"any acknowledgement."),
|
||||||
|
"https://poweroutage.us/legal/apitermsofuse", "county / utility",
|
||||||
|
"No adapter: needs a commercial contract and its API documentation.", "https://poweroutage.us"),
|
||||||
|
_stub("coned_oru_stormcenter", "Con Edison / Orange & Rockland outage map", "NYC, Westchester, Orange & Rockland",
|
||||||
|
NY_BBOX, Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
|
||||||
|
_STORM_CENTER_TERMS, "https://www.coned.com/en/services-and-outages/outage-map-faq", "point / polygon + ETR",
|
||||||
|
("No adapter by design. Con Edison and O&R are on ODIN's participant list but were not reporting to "
|
||||||
|
"ODIN on 2026-10-05; if they start, the ODIN adapter covers them at county level."),
|
||||||
|
"https://outagemap.coned.com"),
|
||||||
|
_stub("nyseg_rge_stormcenter", "NYSEG / RG&E outage map (Avangrid)", "Upstate NY", NY_BBOX,
|
||||||
|
Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
|
||||||
|
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
|
||||||
|
("No adapter by design. Avangrid (NYSEG/RG&E) reports to ODIN, so county-level data arrives through "
|
||||||
|
"the ODIN adapter."),
|
||||||
|
"https://www.nyseg.com"),
|
||||||
|
_stub("central_hudson_stormcenter", "Central Hudson outage map", "Hudson Valley, NY", NY_BBOX,
|
||||||
|
Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Requires a data-sharing agreement -- undocumented storm-center JSON, no reuse licence",
|
||||||
|
_STORM_CENTER_TERMS, None, "point / polygon + ETR",
|
||||||
|
("No adapter by design. Central Hudson reports to ODIN, so county-level data arrives through the "
|
||||||
|
"ODIN adapter."),
|
||||||
|
"https://www.cenhud.com"),
|
||||||
|
_stub("eagle_i_live", "EAGLE-I live feed (ORNL / DOE)", "United States", US_BBOX, Licence.AGREEMENT_REQUIRED,
|
||||||
|
"Live feed is a DOE / government-partner system -- DRB has no access agreement",
|
||||||
|
("EAGLE-I's live 15-minute county outage feed is restricted to DOE and government partners. The public "
|
||||||
|
"ORNL Constellation dataset is historic only (no terms stated on the dataset) and is no use for a "
|
||||||
|
"live map."),
|
||||||
|
"https://doi.ccs.ornl.gov/dataset/d26451af-7ea4-577c-8285-78b72cd8a2dc", "county",
|
||||||
|
"No adapter: needs a DOE partner agreement.", "https://eagle-i.doe.gov"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Outage provider registry: settings, licence gate, and bbox queries.
|
||||||
|
|
||||||
|
Mirrors ``app.internal.cameras.registry`` rule for rule. Settings live in
|
||||||
|
Firestore ``config/outage_providers``::
|
||||||
|
|
||||||
|
{"providers": {"<id>": {"enabled": bool, "updated_at": iso, "updated_by": {...},
|
||||||
|
"acknowledgement": {"by_uid", "by_email", "at", "licence_status", "warning"} | None}}}
|
||||||
|
|
||||||
|
Rules (owner decision #247 on server-26#184):
|
||||||
|
* every provider defaults OFF -- a missing doc/entry means off, and an
|
||||||
|
unreadable settings doc means everything is off (fail closed);
|
||||||
|
* a stub (no adapter) or a PROHIBITED source can never be enabled;
|
||||||
|
* a GATED licence (agreement required / non-commercial / unverified / paid)
|
||||||
|
can only be enabled with an explicit acknowledgement, recorded with who and
|
||||||
|
when in the settings doc AND audit_log. The audit write is a precondition
|
||||||
|
for a gated enable: no record, no enable;
|
||||||
|
* an acknowledgement is bound to the licence status it was given for. If a
|
||||||
|
provider's catalogued status changes in code, the old acknowledgement no
|
||||||
|
longer counts and the provider stops serving until re-acknowledged.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.internal.outages import providers as sources
|
||||||
|
from app.internal.outages.base import BBox, Feed, Licence, OutageArea, OutageProvider, area_in_bbox, bbox_intersects
|
||||||
|
|
||||||
|
COLLECTION, DOC_ID = "config", "outage_providers"
|
||||||
|
_TTL_S = 30.0
|
||||||
|
MAX_AREAS = 3000 # per response; ODIN's whole country is ~100-300 counties on a normal day
|
||||||
|
|
||||||
|
PROVIDERS: Dict[str, OutageProvider] = {p.meta.id: p for p in sources.PROVIDERS}
|
||||||
|
FEEDS: Dict[str, Feed] = {pid: Feed(p) for pid, p in PROVIDERS.items() if p.meta.implemented}
|
||||||
|
|
||||||
|
NO_SOURCE_MESSAGE = "No licensed outage source covers this area."
|
||||||
|
|
||||||
|
_cache: Dict[str, dict] = {}
|
||||||
|
_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(Exception):
|
||||||
|
"""A refused settings change. ``code`` is the HTTP status the router returns."""
|
||||||
|
|
||||||
|
def __init__(self, code: int, message: str, **extra):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code, self.message, self.extra = code, message, extra
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_cache() -> None:
|
||||||
|
global _cache_ts
|
||||||
|
_cache_ts = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_settings() -> Dict[str, dict]:
|
||||||
|
"""Per-provider settings. Unreadable Firestore means everything is OFF (fail closed)."""
|
||||||
|
global _cache, _cache_ts
|
||||||
|
if _cache_ts and time.monotonic() - _cache_ts < _TTL_S:
|
||||||
|
return _cache
|
||||||
|
try:
|
||||||
|
doc = await fstore.doc_get(COLLECTION, DOC_ID) or {}
|
||||||
|
provs = doc.get("providers") if isinstance(doc.get("providers"), dict) else {}
|
||||||
|
_cache = {k: v for k, v in provs.items() if isinstance(v, dict)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"outage providers: settings unreadable ({e}); treating every provider as OFF")
|
||||||
|
_cache = {}
|
||||||
|
_cache_ts = time.monotonic()
|
||||||
|
return _cache
|
||||||
|
|
||||||
|
|
||||||
|
def effective(provider: OutageProvider, entry: Optional[dict]) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""(serving?, reason-if-not) for a provider given its settings entry."""
|
||||||
|
meta = provider.meta
|
||||||
|
if not meta.implemented:
|
||||||
|
return False, "adapter is a stub"
|
||||||
|
if meta.licence.status == Licence.PROHIBITED:
|
||||||
|
return False, "source terms forbid this use"
|
||||||
|
if not entry or not entry.get("enabled"):
|
||||||
|
return False, "disabled (default)"
|
||||||
|
if meta.licence.gated:
|
||||||
|
ack = entry.get("acknowledgement") or {}
|
||||||
|
if ack.get("licence_status") != meta.licence.status.value:
|
||||||
|
return False, "licence acknowledgement missing or out of date"
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
async def is_serving(provider_id: str) -> bool:
|
||||||
|
p = PROVIDERS.get(provider_id)
|
||||||
|
if p is None:
|
||||||
|
return False
|
||||||
|
return effective(p, (await get_settings()).get(provider_id))[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_enabled(provider_id: str, enabled: bool, actor: Tuple[str, str],
|
||||||
|
acknowledge_licence: bool = False) -> dict:
|
||||||
|
p = PROVIDERS.get(provider_id)
|
||||||
|
if p is None:
|
||||||
|
raise ProviderError(404, f"unknown outage provider '{provider_id}'")
|
||||||
|
meta, lic = p.meta, p.meta.licence
|
||||||
|
uid, email = actor
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
entry: dict = {"enabled": bool(enabled), "updated_at": now, "updated_by": {"uid": uid, "email": email}}
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
if not meta.implemented:
|
||||||
|
raise ProviderError(409, f"{meta.name}: no working adapter -- {meta.notes}")
|
||||||
|
if lic.status == Licence.PROHIBITED:
|
||||||
|
raise ProviderError(409, lic.warning() or "prohibited")
|
||||||
|
if lic.gated:
|
||||||
|
if not acknowledge_licence:
|
||||||
|
raise ProviderError(409, f"{meta.name}: licence acknowledgement required",
|
||||||
|
requires_acknowledgement=True, warning=lic.warning(),
|
||||||
|
licence=lic.as_dict())
|
||||||
|
entry["acknowledgement"] = {"by_uid": uid, "by_email": email, "at": now,
|
||||||
|
"licence_status": lic.status.value, "warning": lic.warning()}
|
||||||
|
# Precondition, not a side effect: a gated enable with no audit
|
||||||
|
# record must not happen. A failure here aborts before the write.
|
||||||
|
try:
|
||||||
|
await _audit(actor, "outage_provider.enable_acknowledged", provider_id,
|
||||||
|
{"licence_status": lic.status.value, "licence_label": lic.label,
|
||||||
|
"warning": lic.warning(), "acknowledged_at": now})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"outage providers: audit write failed, refusing gated enable of {provider_id} ({e})")
|
||||||
|
raise ProviderError(503, "audit log unavailable -- a licence-gated provider cannot be enabled "
|
||||||
|
"without an audit record; try again")
|
||||||
|
else:
|
||||||
|
entry["acknowledgement"] = None # re-enabling a gated source needs a fresh acknowledgement
|
||||||
|
|
||||||
|
before = (await get_settings()).get(provider_id) or {}
|
||||||
|
await fstore.doc_set(COLLECTION, DOC_ID, {"providers": {provider_id: entry}}, merge=True)
|
||||||
|
invalidate_cache()
|
||||||
|
if not (enabled and lic.gated):
|
||||||
|
try:
|
||||||
|
await _audit(actor, "outage_provider.enable" if enabled else "outage_provider.disable", provider_id,
|
||||||
|
{"from": bool(before.get("enabled")), "to": bool(enabled),
|
||||||
|
"licence_status": lic.status.value})
|
||||||
|
except Exception as e: # the toggle already happened; never 500 a completed write
|
||||||
|
logger.error(f"outage providers: audit write failed for {provider_id} ({e})")
|
||||||
|
logger.info(f"outage provider {provider_id} -> enabled={enabled} by {email or uid}")
|
||||||
|
return await describe(provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _audit(actor: Tuple[str, str], action: str, provider_id: str, details: dict) -> None:
|
||||||
|
from app.internal import audit
|
||||||
|
await audit.write_audit(actor_uid=actor[0], actor_email=actor[1], action=action,
|
||||||
|
details={"provider_id": provider_id, **details})
|
||||||
|
|
||||||
|
|
||||||
|
async def describe(provider_id: str) -> dict:
|
||||||
|
p = PROVIDERS[provider_id]
|
||||||
|
entry = (await get_settings()).get(provider_id) or {}
|
||||||
|
serving, reason = effective(p, entry)
|
||||||
|
m = p.meta
|
||||||
|
feed = FEEDS.get(provider_id)
|
||||||
|
return {
|
||||||
|
"id": m.id, "name": m.name, "region": m.region, "granularity": m.granularity,
|
||||||
|
"implemented": m.implemented, "notes": m.notes, "homepage": m.homepage,
|
||||||
|
"licence": m.licence.as_dict(),
|
||||||
|
"enabled": bool(entry.get("enabled")),
|
||||||
|
"serving": serving, "not_serving_reason": reason,
|
||||||
|
"acknowledgement": entry.get("acknowledgement"),
|
||||||
|
"updated_at": entry.get("updated_at"), "updated_by": entry.get("updated_by"),
|
||||||
|
"coverage_note": p.coverage_note(),
|
||||||
|
"health": feed.status() if feed else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_providers() -> List[dict]:
|
||||||
|
await get_settings()
|
||||||
|
return [await describe(pid) for pid in PROVIDERS]
|
||||||
|
|
||||||
|
|
||||||
|
def credit(provider_id: str) -> dict:
|
||||||
|
lic = PROVIDERS[provider_id].meta.licence
|
||||||
|
return {"attribution": lic.attribution, "attribution_url": lic.attribution_url, "disclaimer": lic.disclaimer}
|
||||||
|
|
||||||
|
|
||||||
|
async def outages_in_bbox(bbox: BBox) -> dict:
|
||||||
|
"""Outage areas from every SERVING provider whose coverage meets ``bbox``.
|
||||||
|
Providers that are off are never fetched.
|
||||||
|
|
||||||
|
``status`` says why the list is what it is, so an empty layer is never
|
||||||
|
ambiguous: ``none_enabled`` (no provider is on), ``no_source`` (none of the
|
||||||
|
serving providers covers this area), ``error`` (every covering provider
|
||||||
|
failed and has nothing cached), else ``ok`` -- which may legitimately
|
||||||
|
carry zero areas when nothing is out."""
|
||||||
|
conf = await get_settings()
|
||||||
|
serving = [pid for pid in FEEDS if effective(PROVIDERS[pid], conf.get(pid))[0]]
|
||||||
|
live = [pid for pid in serving if bbox_intersects(PROVIDERS[pid].meta.coverage, bbox)]
|
||||||
|
await asyncio.gather(*(FEEDS[pid].get() for pid in live))
|
||||||
|
areas: List[OutageArea] = []
|
||||||
|
providers = []
|
||||||
|
for pid in serving:
|
||||||
|
feed = FEEDS[pid]
|
||||||
|
hits = [a for a in feed.items if area_in_bbox(a, bbox)] if pid in live else []
|
||||||
|
room = max(0, MAX_AREAS - len(areas))
|
||||||
|
areas.extend(hits[:room])
|
||||||
|
providers.append({"id": pid, "name": PROVIDERS[pid].meta.name, **credit(pid),
|
||||||
|
"granularity": PROVIDERS[pid].meta.granularity,
|
||||||
|
"coverage_note": PROVIDERS[pid].coverage_note(),
|
||||||
|
"covers_bbox": pid in live,
|
||||||
|
"fetched_at": feed.fetched_at, "error": feed.error,
|
||||||
|
"total_in_bbox": len(hits), "truncated": len(hits) > room})
|
||||||
|
|
||||||
|
if not serving:
|
||||||
|
status, message = "none_enabled", "No outage source is enabled. An admin can enable one under Admin -> Outage providers."
|
||||||
|
elif not live:
|
||||||
|
status, message = "no_source", NO_SOURCE_MESSAGE
|
||||||
|
elif all(FEEDS[pid].error and FEEDS[pid].fetched_at is None for pid in live):
|
||||||
|
status, message = "error", "Every outage source covering this area failed to load; see each provider's error."
|
||||||
|
else:
|
||||||
|
status, message = "ok", None
|
||||||
|
return {"areas": areas, "providers": providers, "serving_count": len(serving),
|
||||||
|
"status": status, "message": message}
|
||||||
@@ -100,24 +100,65 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
|||||||
return gcs_uri
|
return gcs_uri
|
||||||
|
|
||||||
|
|
||||||
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
||||||
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
# A 600 s FLAC call is ~13 MB. /media used to pull the whole object into
|
||||||
|
# memory (the old download_audio(), removed once nothing called it — #225 S8),
|
||||||
|
# so the browser waited for all of it before the first sample played. These
|
||||||
|
# two read only what a request asks for: the
|
||||||
|
# object size (one metadata GET) and then an inclusive byte span, which the
|
||||||
|
# router streams out in chunks. One cached client, because the router issues
|
||||||
|
# several span reads per playback and a fresh Client() per read re-resolves
|
||||||
|
# credentials every time.
|
||||||
|
_gcs_client_cache = None
|
||||||
|
|
||||||
|
|
||||||
|
def _gcs_client():
|
||||||
|
global _gcs_client_cache
|
||||||
|
if _gcs_client_cache is None:
|
||||||
|
from google.cloud import storage
|
||||||
|
if settings.gcp_credentials_path:
|
||||||
|
_gcs_client_cache = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||||
|
else:
|
||||||
|
_gcs_client_cache = storage.Client()
|
||||||
|
return _gcs_client_cache
|
||||||
|
|
||||||
|
|
||||||
|
async def audio_object_size(gcs_uri: str) -> Optional[int]:
|
||||||
|
"""Size in bytes of a stored audio object, or None if it is missing/unreadable."""
|
||||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||||
if not bucket_name:
|
if not bucket_name:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _download() -> bytes:
|
def _size() -> Optional[int]:
|
||||||
from google.cloud import storage
|
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
|
||||||
if settings.gcp_credentials_path:
|
blob.reload()
|
||||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
return blob.size
|
||||||
else:
|
|
||||||
client = storage.Client()
|
|
||||||
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await asyncio.to_thread(_download)
|
size = await asyncio.to_thread(_size)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
logger.warning(f"GCS metadata read failed for {gcs_uri}: {e}")
|
||||||
|
return None
|
||||||
|
return int(size) if size is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def download_audio_range(gcs_uri: str, start: int, end: int) -> Optional[bytes]:
|
||||||
|
"""Bytes ``start``..``end`` INCLUSIVE of a stored object (GCS's own convention).
|
||||||
|
|
||||||
|
Reads only that span from GCS — never the whole object.
|
||||||
|
"""
|
||||||
|
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||||
|
if not bucket_name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _read() -> bytes:
|
||||||
|
blob = _gcs_client().bucket(bucket_name).blob(blob_path)
|
||||||
|
return blob.download_as_bytes(start=start, end=end)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.to_thread(_read)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GCS ranged read failed for {gcs_uri} [{start}-{end}]: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""Weather context for the map: NWS active alerts and nearest-METAR wind (server-26#237).
|
||||||
|
|
||||||
|
Sources -- both US Government works, public domain, no licence gate:
|
||||||
|
* NWS alerts: https://api.weather.gov/alerts/active?area=<codes>
|
||||||
|
(docs: https://www.weather.gov/documentation/services-web-api). NWS requires
|
||||||
|
a User-Agent naming the app and a contact, and rate-limits, so the feed is
|
||||||
|
fetched server-side, cached (``settings.weather_alerts_ttl_s``) and shared by
|
||||||
|
every viewer. Credit: "National Weather Service".
|
||||||
|
* METAR wind: https://aviationweather.gov/api/data/metar?bbox=minLat,minLon,maxLat,maxLon&format=json
|
||||||
|
(docs: https://aviationweather.gov/data/api/). Verified 2026-10-05: JSON list,
|
||||||
|
one latest report per station, HTTP 204 with an empty body when the bbox holds
|
||||||
|
no station, ``wdir`` is an int or the string "VRB".
|
||||||
|
|
||||||
|
Shape and failure rules are the 511NY ones (app/internal/ny511.py): a failed
|
||||||
|
refresh keeps serving the last good data and reports the error and its age; a
|
||||||
|
payload whose records ALL fail to parse is a schema change and an error, never
|
||||||
|
an empty layer.
|
||||||
|
|
||||||
|
Many NWS alerts carry ``geometry: null`` -- they are zone-based (forecast/county
|
||||||
|
zones listed in ``affectedZones``). Each zone's polygon is fetched once and kept
|
||||||
|
24 h. An alert whose zones cannot be resolved is returned in ``zone_only`` with
|
||||||
|
its event and expiry, so nothing is silently dropped.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal.cameras.base import safe_error
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
NWS_ALERTS_URL = "https://api.weather.gov/alerts/active"
|
||||||
|
NWS_ZONE_PREFIX = "https://api.weather.gov/zones/" # only zone URLs under this are ever fetched
|
||||||
|
METAR_URL = "https://aviationweather.gov/api/data/metar"
|
||||||
|
CREDIT = "National Weather Service"
|
||||||
|
WIND_CREDIT = "NOAA Aviation Weather Center (METAR)"
|
||||||
|
DEFAULT_USER_AGENT = "DRB scanner map (contact: set NWS_USER_AGENT)"
|
||||||
|
|
||||||
|
RETRY_AFTER_FAILURE_S = 30
|
||||||
|
ZONE_TTL_S = 24 * 3600
|
||||||
|
ZONE_FAILURE_RETRY_S = 15 * 60
|
||||||
|
ZONE_CONCURRENCY = 6
|
||||||
|
# A state-wide winter event can list 100+ zones. Fetches past this cap wait for
|
||||||
|
# the next refresh (the zone cache fills across refreshes); meanwhile the alert
|
||||||
|
# shows in zone_only or with the zones resolved so far.
|
||||||
|
MAX_ZONE_FETCHES_PER_REFRESH = 80
|
||||||
|
# Wall-clock cap on one refresh's zone fetches. The refresh runs under the feed
|
||||||
|
# lock, so without it a slow zone endpoint (20 s timeout x 80 fetches / 6 wide)
|
||||||
|
# could hold every /weather/alerts request for minutes. Unfinished zones are
|
||||||
|
# simply not cached and are retried next refresh.
|
||||||
|
ZONE_BUDGET_S = 15.0
|
||||||
|
|
||||||
|
WIND_TTL_S = 5 * 60
|
||||||
|
WIND_CELL_DEG = 0.5 # wind cache cell; one upstream call per cell per TTL
|
||||||
|
WIND_MAX_KM = 75.0 # a station farther than this says nothing about the incident's wind
|
||||||
|
WIND_STALE_S = 2 * 3600 # older reports are returned but flagged stale
|
||||||
|
_WIND_CACHE_MAX = 500
|
||||||
|
|
||||||
|
_warned_ua = False
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(accept: str) -> Dict[str, str]:
|
||||||
|
global _warned_ua
|
||||||
|
ua = settings.nws_user_agent or DEFAULT_USER_AGENT
|
||||||
|
if (ua == DEFAULT_USER_AGENT or "example.com" in ua) and not _warned_ua:
|
||||||
|
_warned_ua = True
|
||||||
|
logger.warning("NWS_USER_AGENT is the placeholder -- set it to 'app (contact email)'; NWS may block us")
|
||||||
|
return {"User-Agent": ua, "Accept": accept}
|
||||||
|
|
||||||
|
|
||||||
|
# ── geometry helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _polygons(geom: Any) -> List[Any]:
|
||||||
|
"""Polygon coordinate arrays out of a Polygon / MultiPolygon / GeometryCollection."""
|
||||||
|
if not isinstance(geom, dict):
|
||||||
|
return []
|
||||||
|
t = geom.get("type")
|
||||||
|
if t == "Polygon" and isinstance(geom.get("coordinates"), list):
|
||||||
|
return [geom["coordinates"]]
|
||||||
|
if t == "MultiPolygon" and isinstance(geom.get("coordinates"), list):
|
||||||
|
return list(geom["coordinates"])
|
||||||
|
if t == "GeometryCollection":
|
||||||
|
return [p for g in geom.get("geometries") or [] for p in _polygons(g)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _merge(polys: List[Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
if not polys:
|
||||||
|
return None
|
||||||
|
if len(polys) == 1:
|
||||||
|
return {"type": "Polygon", "coordinates": polys[0]}
|
||||||
|
return {"type": "MultiPolygon", "coordinates": polys}
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox(geom: Optional[Dict[str, Any]]) -> Optional[List[float]]:
|
||||||
|
"""[south, west, north, east] of a (Multi)Polygon, or None."""
|
||||||
|
lats: List[float] = []
|
||||||
|
lons: List[float] = []
|
||||||
|
for poly in _polygons(geom):
|
||||||
|
for ring in poly:
|
||||||
|
for pt in ring:
|
||||||
|
try:
|
||||||
|
lons.append(float(pt[0]))
|
||||||
|
lats.append(float(pt[1]))
|
||||||
|
except (TypeError, ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
if not lats:
|
||||||
|
return None
|
||||||
|
return [min(lats), min(lons), max(lats), max(lons)]
|
||||||
|
|
||||||
|
|
||||||
|
def intersects(b: Optional[List[float]], south: float, west: float, north: float, east: float) -> bool:
|
||||||
|
return b is not None and b[0] <= north and b[2] >= south and b[1] <= east and b[3] >= west
|
||||||
|
|
||||||
|
|
||||||
|
# ── NWS alerts ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def normalize_alert(feature: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""One GeoJSON feature -> our record (geometry may still be None). None if unparseable."""
|
||||||
|
if not isinstance(feature, dict):
|
||||||
|
return None
|
||||||
|
p = feature.get("properties")
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
return None
|
||||||
|
aid = p.get("id") or feature.get("id")
|
||||||
|
event = p.get("event")
|
||||||
|
if not aid or not event:
|
||||||
|
return None
|
||||||
|
geometry = _merge(_polygons(feature.get("geometry")))
|
||||||
|
zones = [z for z in (p.get("affectedZones") or []) if isinstance(z, str)]
|
||||||
|
return {
|
||||||
|
"id": aid,
|
||||||
|
"event": event,
|
||||||
|
"severity": p.get("severity"),
|
||||||
|
"urgency": p.get("urgency"),
|
||||||
|
"headline": p.get("headline"),
|
||||||
|
"onset": p.get("onset"),
|
||||||
|
"expires": p.get("expires"),
|
||||||
|
"ends": p.get("ends"),
|
||||||
|
"sender_name": p.get("senderName"), # an NWS office ("NWS Upton NY"), not a person
|
||||||
|
"area_desc": p.get("areaDesc"),
|
||||||
|
"geometry": geometry,
|
||||||
|
"geometry_source": "alert" if geometry else None,
|
||||||
|
"zones": zones,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _ZoneCache:
|
||||||
|
def __init__(self):
|
||||||
|
self._entries: Dict[str, Tuple[float, Optional[List[Any]]]] = {} # url -> (expires_at, polygons|None)
|
||||||
|
|
||||||
|
def get(self, url: str) -> Tuple[bool, Optional[List[Any]]]:
|
||||||
|
e = self._entries.get(url)
|
||||||
|
if e is None or e[0] < time.time():
|
||||||
|
return False, None
|
||||||
|
return True, e[1]
|
||||||
|
|
||||||
|
def put(self, url: str, polys: Optional[List[Any]]):
|
||||||
|
ttl = ZONE_TTL_S if polys else ZONE_FAILURE_RETRY_S
|
||||||
|
self._entries[url] = (time.time() + ttl, polys)
|
||||||
|
|
||||||
|
def purge(self):
|
||||||
|
now = time.time()
|
||||||
|
for k in [k for k, e in self._entries.items() if e[0] < now]:
|
||||||
|
del self._entries[k]
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self._entries.clear()
|
||||||
|
|
||||||
|
|
||||||
|
zones = _ZoneCache()
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_zone(client: "httpx.AsyncClient", url: str, sem: asyncio.Semaphore) -> None:
|
||||||
|
async with sem:
|
||||||
|
try:
|
||||||
|
r = await client.get(url, headers=_headers("application/geo+json"))
|
||||||
|
r.raise_for_status()
|
||||||
|
polys = _polygons(r.json().get("geometry"))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NWS zone {url} unresolved: {safe_error(e)}")
|
||||||
|
polys = []
|
||||||
|
zones.put(url, polys or None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_zones(client: "httpx.AsyncClient", alerts: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Fill geometry for zone-based alerts in place, from the 24 h zone cache."""
|
||||||
|
zones.purge()
|
||||||
|
wanted: List[str] = []
|
||||||
|
for a in alerts:
|
||||||
|
if a["geometry"] is None:
|
||||||
|
for z in a["zones"]:
|
||||||
|
if z.startswith(NWS_ZONE_PREFIX) and not zones.get(z)[0] and z not in wanted:
|
||||||
|
wanted.append(z)
|
||||||
|
if wanted:
|
||||||
|
sem = asyncio.Semaphore(ZONE_CONCURRENCY)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
asyncio.gather(*(_fetch_zone(client, z, sem) for z in wanted[:MAX_ZONE_FETCHES_PER_REFRESH])),
|
||||||
|
timeout=ZONE_BUDGET_S)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"NWS zone fetches over {ZONE_BUDGET_S:.0f}s budget; the rest resolve next refresh")
|
||||||
|
for a in alerts:
|
||||||
|
if a["geometry"] is not None:
|
||||||
|
continue
|
||||||
|
polys: List[Any] = []
|
||||||
|
missing = 0
|
||||||
|
for z in a["zones"]:
|
||||||
|
hit, p = zones.get(z)
|
||||||
|
if hit and p:
|
||||||
|
polys.extend(p)
|
||||||
|
else:
|
||||||
|
missing += 1
|
||||||
|
a["geometry"] = _merge(polys)
|
||||||
|
if a["geometry"] is not None:
|
||||||
|
a["geometry_source"] = "zones"
|
||||||
|
a["zones_unresolved"] = missing # >0: drawn area is smaller than the real one
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_alerts() -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||||
|
area = ",".join(s.strip().upper() for s in (settings.nws_area or "").split(",") if s.strip())
|
||||||
|
if not area:
|
||||||
|
raise ValueError("NWS_AREA is empty -- no state/marine area to ask NWS for")
|
||||||
|
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||||
|
r = await client.get(NWS_ALERTS_URL, params={"area": area}, headers=_headers("application/geo+json"))
|
||||||
|
r.raise_for_status()
|
||||||
|
raw = r.json()
|
||||||
|
features = raw.get("features") if isinstance(raw, dict) else None
|
||||||
|
if not isinstance(features, list):
|
||||||
|
raise ValueError("NWS alerts payload has no 'features' list -- schema change?")
|
||||||
|
parsed = [a for a in (normalize_alert(f) for f in features) if a is not None]
|
||||||
|
if features and not parsed:
|
||||||
|
raise ValueError(f"0 of {len(features)} NWS alerts parsed -- schema change?")
|
||||||
|
await _resolve_zones(client, parsed)
|
||||||
|
mapped, zone_only = [], []
|
||||||
|
for a in parsed:
|
||||||
|
a["bbox"] = _bbox(a["geometry"])
|
||||||
|
if a["geometry"] is None:
|
||||||
|
zone_only.append({k: v for k, v in a.items() if k not in ("geometry", "geometry_source", "bbox")})
|
||||||
|
else:
|
||||||
|
mapped.append(a)
|
||||||
|
return mapped, zone_only
|
||||||
|
|
||||||
|
|
||||||
|
class _AlertsFeed:
|
||||||
|
def __init__(self):
|
||||||
|
self.alerts: List[Dict[str, Any]] = []
|
||||||
|
self.zone_only: List[Dict[str, Any]] = []
|
||||||
|
self.fetched_at: Optional[float] = None # epoch s of last SUCCESSFUL fetch
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self._next_attempt = 0.0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get(self) -> "_AlertsFeed":
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
async with self._lock: # single flight: waiters see the refreshed state
|
||||||
|
if time.time() < self._next_attempt:
|
||||||
|
return self
|
||||||
|
ttl = max(30, int(settings.weather_alerts_ttl_s))
|
||||||
|
try:
|
||||||
|
self.alerts, self.zone_only = await _fetch_alerts()
|
||||||
|
self.fetched_at = time.time()
|
||||||
|
self.error = None
|
||||||
|
self._next_attempt = time.time() + ttl
|
||||||
|
except Exception as e:
|
||||||
|
self._next_attempt = time.time() + min(ttl, RETRY_AFTER_FAILURE_S)
|
||||||
|
self.error = safe_error(e)
|
||||||
|
logger.warning(f"NWS alerts refresh failed, serving {len(self.alerts)} cached: {self.error}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
alerts = _AlertsFeed()
|
||||||
|
|
||||||
|
|
||||||
|
# ── METAR wind ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||||
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||||
|
dp, dl = p2 - p1, math.radians(lon2 - lon1)
|
||||||
|
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||||
|
return 6371.0 * 2 * math.asin(min(1.0, math.sqrt(a)))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_metar(x: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
if not isinstance(x, dict) or not x.get("icaoId"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
lat, lon = float(x["lat"]), float(x["lon"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
wspd = x.get("wspd")
|
||||||
|
if not isinstance(wspd, (int, float)):
|
||||||
|
return None # no wind group in this report
|
||||||
|
wdir = x.get("wdir")
|
||||||
|
variable = isinstance(wdir, str) and wdir.upper() == "VRB"
|
||||||
|
obs = x.get("obsTime")
|
||||||
|
obs_iso = (datetime.fromtimestamp(obs, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
if isinstance(obs, (int, float)) else x.get("reportTime"))
|
||||||
|
gust = x.get("wgst")
|
||||||
|
return {
|
||||||
|
"station": x["icaoId"],
|
||||||
|
"station_name": x.get("name") or "",
|
||||||
|
"lat": lat, "lon": lon,
|
||||||
|
"obs_epoch": obs if isinstance(obs, (int, float)) else None,
|
||||||
|
"obs_time": obs_iso,
|
||||||
|
"wind_dir_deg": wdir if isinstance(wdir, (int, float)) else None,
|
||||||
|
"variable": variable,
|
||||||
|
"wind_kt": wspd,
|
||||||
|
"gust_kt": gust if isinstance(gust, (int, float)) else None,
|
||||||
|
"raw": x.get("rawOb"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _WindCell:
|
||||||
|
def __init__(self):
|
||||||
|
self.stations: List[Dict[str, Any]] = []
|
||||||
|
self.fetched_at: Optional[float] = None
|
||||||
|
self.error: Optional[str] = None
|
||||||
|
self.next_attempt = 0.0
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
_wind_cells: Dict[Tuple[int, int], _WindCell] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_key(lat: float, lon: float) -> Tuple[int, int]:
|
||||||
|
return (math.floor(lat / WIND_CELL_DEG), math.floor(lon / WIND_CELL_DEG))
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_metars(key: Tuple[int, int]) -> List[Dict[str, Any]]:
|
||||||
|
# Search box = the cell padded by WIND_MAX_KM, so every point in the cell sees
|
||||||
|
# every station within WIND_MAX_KM of it.
|
||||||
|
s, w = key[0] * WIND_CELL_DEG, key[1] * WIND_CELL_DEG
|
||||||
|
n, e = s + WIND_CELL_DEG, w + WIND_CELL_DEG
|
||||||
|
pad_lat = WIND_MAX_KM / 111.0
|
||||||
|
pad_lon = WIND_MAX_KM / (111.0 * max(0.2, math.cos(math.radians(max(abs(s), abs(n))))))
|
||||||
|
bbox = f"{s - pad_lat:.3f},{w - pad_lon:.3f},{n + pad_lat:.3f},{e + pad_lon:.3f}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
r = await client.get(METAR_URL, params={"bbox": bbox, "format": "json"}, headers=_headers("application/json"))
|
||||||
|
r.raise_for_status()
|
||||||
|
if r.status_code == 204 or not r.content.strip():
|
||||||
|
return [] # no station in the box
|
||||||
|
raw = r.json()
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise ValueError(f"METAR API: expected a JSON list, got {type(raw).__name__}")
|
||||||
|
latest: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for m in (normalize_metar(x) for x in raw):
|
||||||
|
if m is None:
|
||||||
|
continue
|
||||||
|
cur = latest.get(m["station"])
|
||||||
|
if cur is None or (m["obs_epoch"] or 0) > (cur["obs_epoch"] or 0):
|
||||||
|
latest[m["station"]] = m
|
||||||
|
# Schema canary: reports without a wind group are normal, but if NO record even
|
||||||
|
# has a station id and position, the field names changed under us.
|
||||||
|
if raw and not any(isinstance(x, dict) and x.get("icaoId") and "lat" in x for x in raw):
|
||||||
|
raise ValueError(f"0 of {len(raw)} METAR records parsed -- schema change?")
|
||||||
|
return list(latest.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def _cell(lat: float, lon: float) -> _WindCell:
|
||||||
|
key = _cell_key(lat, lon)
|
||||||
|
cell = _wind_cells.get(key)
|
||||||
|
if cell is None:
|
||||||
|
if len(_wind_cells) >= _WIND_CACHE_MAX: # bound memory: drop expired cells, then the oldest
|
||||||
|
for k in [k for k, c in _wind_cells.items() if c.next_attempt < time.time() and not c.lock.locked()]:
|
||||||
|
del _wind_cells[k]
|
||||||
|
if len(_wind_cells) >= _WIND_CACHE_MAX:
|
||||||
|
del _wind_cells[min(_wind_cells, key=lambda k: _wind_cells[k].next_attempt)]
|
||||||
|
cell = _wind_cells[key] = _WindCell()
|
||||||
|
if time.time() < cell.next_attempt:
|
||||||
|
return cell
|
||||||
|
async with cell.lock:
|
||||||
|
if time.time() < cell.next_attempt:
|
||||||
|
return cell
|
||||||
|
try:
|
||||||
|
cell.stations = await _fetch_metars(key)
|
||||||
|
cell.fetched_at = time.time()
|
||||||
|
cell.error = None
|
||||||
|
cell.next_attempt = time.time() + WIND_TTL_S
|
||||||
|
except Exception as e:
|
||||||
|
cell.next_attempt = time.time() + RETRY_AFTER_FAILURE_S
|
||||||
|
cell.error = safe_error(e)
|
||||||
|
logger.warning(f"METAR refresh for cell {key} failed, serving {len(cell.stations)} cached: {cell.error}")
|
||||||
|
return cell
|
||||||
|
|
||||||
|
|
||||||
|
async def nearest_wind(lat: float, lon: float) -> Dict[str, Any]:
|
||||||
|
"""Wind from the nearest METAR station within WIND_MAX_KM, or a clear reason why not."""
|
||||||
|
cell = await _cell(lat, lon)
|
||||||
|
base = {"fetched_at": cell.fetched_at, "error": cell.error, "credit": WIND_CREDIT}
|
||||||
|
ranked = sorted(((haversine_km(lat, lon, s["lat"], s["lon"]), s) for s in cell.stations), key=lambda t: t[0])
|
||||||
|
if not ranked or ranked[0][0] > WIND_MAX_KM:
|
||||||
|
if cell.fetched_at is None:
|
||||||
|
reason = f"METAR feed unavailable: {cell.error}" if cell.error else "METAR feed not fetched yet"
|
||||||
|
else:
|
||||||
|
reason = f"no METAR station reporting wind within {WIND_MAX_KM:.0f} km"
|
||||||
|
return {**base, "available": False, "reason": reason, "wind": None}
|
||||||
|
dist, s = ranked[0]
|
||||||
|
stale = s["obs_epoch"] is not None and time.time() - s["obs_epoch"] > WIND_STALE_S
|
||||||
|
wind = {k: v for k, v in s.items() if k not in ("obs_epoch",)}
|
||||||
|
wind["distance_km"] = round(dist, 1)
|
||||||
|
wind["stale"] = stale
|
||||||
|
return {**base, "available": True, "reason": None, "wind": wind}
|
||||||
@@ -19,12 +19,14 @@ from app.internal.auth import (
|
|||||||
)
|
)
|
||||||
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places, links, users
|
||||||
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
from app.routers import enrollment, media, org, waitlist, telemetry, replay, traffic, cameras
|
||||||
|
from app.routers import outages, weather
|
||||||
from app.routers import discord as discord_router
|
from app.routers import discord as discord_router
|
||||||
from app.routers import discord_link as discord_link_router
|
from app.routers import discord_link as discord_link_router
|
||||||
from app.internal import dynsec
|
from app.internal import dynsec
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.routers import discord_web
|
from app.routers import discord_web
|
||||||
from app.routers import discord_checks
|
from app.routers import discord_checks
|
||||||
|
from app.routers import discord_oauth
|
||||||
|
|
||||||
|
|
||||||
async def _clear_legacy_token_flags():
|
async def _clear_legacy_token_flags():
|
||||||
@@ -147,6 +149,9 @@ app.include_router(places.router, dependencies=[Depends(require_service_or_fi
|
|||||||
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(traffic.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
|
app.include_router(cameras.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
app.include_router(cameras.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
||||||
|
app.include_router(outages.router, dependencies=[Depends(require_service_or_firebase_token)])
|
||||||
|
app.include_router(outages.admin_router) # auth: admin only, per endpoint (needs the principal for audit)
|
||||||
|
app.include_router(weather.router, dependencies=[Depends(require_service_or_firebase_token)]) # auth: any signed-in user/service, like traffic
|
||||||
app.include_router(upload.router) # auth is per-node, handled inline
|
app.include_router(upload.router) # auth is per-node, handled inline
|
||||||
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin)
|
||||||
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
app.include_router(replay.router) # auth: admin only (every route spends or reads a replay run)
|
||||||
@@ -166,6 +171,7 @@ app.include_router(discord_web.router)
|
|||||||
app.include_router(discord_router.router)
|
app.include_router(discord_router.router)
|
||||||
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
|
app.include_router(discord_link_router.router) # auth per-endpoint (org owner / bot service key) — server-26#213
|
||||||
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
|
app.include_router(discord_checks.router) # auth per-endpoint (org member / service key or admin) — server-26#225
|
||||||
|
app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signed-in starter); 404 while OAuth is off — #225 S6
|
||||||
# NOTE: there used to be an app.routers.mqtt_auth router here (an HTTP
|
# 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
|
# backend for the mosquitto-go-auth plugin). That plugin's upstream project
|
||||||
# is archived (no CVE patches) and was rejected for an internet-facing
|
# is archived (no CVE patches) and was rejected for an internet-facing
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ Auth:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Path, Security
|
from fastapi import APIRouter, Depends, HTTPException, Path, Security
|
||||||
@@ -26,6 +25,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.internal import discord_guild_link as glink
|
||||||
from app.internal import discord_leases as leases
|
from app.internal import discord_leases as leases
|
||||||
from app.internal import discord_rest
|
from app.internal import discord_rest
|
||||||
from app.internal import discord_streams as streams
|
from app.internal import discord_streams as streams
|
||||||
@@ -138,6 +138,7 @@ async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_
|
|||||||
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
|
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
|
||||||
created_by=body.created_by, preferred_token_id=body.preferred_token_id,
|
created_by=body.created_by, preferred_token_id=body.preferred_token_id,
|
||||||
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
|
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
|
||||||
|
interactive=True, # /join, the admin page: re-read a stale "denied" (server-26#251)
|
||||||
)
|
)
|
||||||
except streams.StreamError as e:
|
except streams.StreamError as e:
|
||||||
return _error(e)
|
return _error(e)
|
||||||
@@ -181,23 +182,18 @@ async def link_guild(guild_id: str, body: GuildLink, decoded: dict = Depends(req
|
|||||||
org = await fstore.doc_get("organizations", body.org_id)
|
org = await fstore.doc_get("organizations", body.org_id)
|
||||||
if not org:
|
if not org:
|
||||||
raise HTTPException(404, "Organization not found.")
|
raise HTTPException(404, "Organization not found.")
|
||||||
previous_org = await streams.resolve_guild_org(guild_id)
|
# Support override: may move a server between orgs. Re-linking a server
|
||||||
doc = {
|
# that was unlinked or removed brings it back. Live channels placed on the
|
||||||
"guild_id": guild_id, "org_id": body.org_id,
|
# previous org's nodes are stopped (glink._settle) — left running, the
|
||||||
"linked_by": decoded.get("uid"), "linked_at": datetime.now(timezone.utc).isoformat(),
|
# reconciler would keep re-placing them there.
|
||||||
# Re-linking a server that was unlinked or removed brings it back.
|
out = await glink.link_and_settle(body.org_id, guild_id, body.guild_name, decoded.get("uid") or "admin",
|
||||||
"unlinked_at": None, "unlinked_by": None, "removed_at": None, "removed_by": None,
|
"admin", allow_move=True, stop_reason=glink.ADMIN_RELINK_STOP_REASON)
|
||||||
}
|
uid, email = describe_actor(decoded)
|
||||||
if body.guild_name and body.guild_name.strip():
|
await write_audit(uid, email, "discord.guild.link", details={
|
||||||
doc["guild_name"] = body.guild_name.strip()[:100]
|
"guild_id": guild_id, "org_id": body.org_id, "previous_org_id": out["previous_org_id"],
|
||||||
await fstore.doc_set(streams.GUILDS, guild_id, doc)
|
"already_linked": out["already_linked"], "streams_stopped": len(out["streams_stopped"]), "via": "admin",
|
||||||
# Live channels in this server were placed on the previous org's nodes;
|
})
|
||||||
# left running, the reconciler would keep re-placing them there, so one
|
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": out["streams_stopped"]}
|
||||||
# org's equipment would keep serving a server that now belongs to another.
|
|
||||||
stopped: list[str] = []
|
|
||||||
if previous_org and previous_org != body.org_id:
|
|
||||||
stopped = await streams.stop_guild(guild_id, "server linked to another organization")
|
|
||||||
return {"ok": True, "guild_id": guild_id, "org_id": body.org_id, "stopped": stopped}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/streams/{stream_id}")
|
@router.delete("/streams/{stream_id}")
|
||||||
@@ -273,6 +269,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
Radio bots come from ``GET /tokens``; no token string is returned here.
|
Radio bots come from ``GET /tokens``; no token string is returned here.
|
||||||
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
Discord user ids (``created_by_discord_id``) and the linking admin's uid
|
||||||
are deliberately left out.
|
are deliberately left out.
|
||||||
|
|
||||||
|
Per server, ``main_bot_present`` (the DRB server bot — optional since
|
||||||
|
server-26#225: True/False as it last reported, None = never heard from it
|
||||||
|
there) and ``radio_bots`` (names of radio bots Discord last said are in
|
||||||
|
it). A server with radio bots and no server bot is a normal setup.
|
||||||
"""
|
"""
|
||||||
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
links = {str(g.get("guild_id")): g for g in await fstore.collection_list(streams.GUILDS)
|
||||||
if g.get("guild_id")}
|
if g.get("guild_id")}
|
||||||
@@ -288,6 +289,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
|
continue # removed from DRB (DELETE /discord/guilds/{id}); re-listed if it gets a channel again
|
||||||
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
guilds[gid] = {"guild_id": gid, "guild_name": link.get("guild_name"), "linked": bool(link.get("org_id")),
|
||||||
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
|
"org_id": link.get("org_id"), "linked_at": link.get("linked_at"),
|
||||||
|
"linked_via": link.get("linked_via"),
|
||||||
|
# The DRB server bot is optional (server-26#225 S8): True/False
|
||||||
|
# = it said it is / is not in the server, None = never heard.
|
||||||
|
"main_bot_present": link.get("bot_present"),
|
||||||
|
"directory_source": link.get("directory_source"),
|
||||||
# Explicitly unlinked servers get no fallback org (mirrors
|
# Explicitly unlinked servers get no fallback org (mirrors
|
||||||
# streams.resolve_guild_org once server-26#218 lands).
|
# streams.resolve_guild_org once server-26#218 lands).
|
||||||
"_no_fallback": bool(link.get("unlinked_at"))}
|
"_no_fallback": bool(link.get("unlinked_at"))}
|
||||||
@@ -295,7 +301,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
for s in all_streams:
|
for s in all_streams:
|
||||||
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
gid = str(s.get("guild_id") or leases.guild_of(s.get("stream_id") or ""))
|
||||||
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
g = guilds.setdefault(gid, {"guild_id": gid, "guild_name": None, "linked": False,
|
||||||
"org_id": None, "linked_at": None})
|
"org_id": None, "linked_at": None, "linked_via": None,
|
||||||
|
"main_bot_present": None, "directory_source": None})
|
||||||
if s.get("guild_name") and not g.get("guild_name"):
|
if s.get("guild_name") and not g.get("guild_name"):
|
||||||
g["guild_name"] = s["guild_name"]
|
g["guild_name"] = s["guild_name"]
|
||||||
node = nodes.get(s.get("node_id")) or {}
|
node = nodes.get(s.get("node_id")) or {}
|
||||||
@@ -309,6 +316,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
|||||||
g["acting_org_id"] = acting
|
g["acting_org_id"] = acting
|
||||||
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
|
||||||
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
|
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
|
||||||
|
# Radio bots Discord last said are in the server (names only). Any one
|
||||||
|
# of them is enough to list channels, check permissions and stream.
|
||||||
|
g["radio_bots"] = sorted(str(t.get("name") or tid) for tid, t in tokens.items()
|
||||||
|
if t.get("valid") is not False and isinstance(t.get("guild_ids"), list)
|
||||||
|
and g["guild_id"] in t["guild_ids"])
|
||||||
|
|
||||||
# Streams sharing a voice channel sit together, oldest first.
|
# Streams sharing a voice channel sit together, oldest first.
|
||||||
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ def _channel_state(rows: list[dict], enabled: bool) -> str:
|
|||||||
return "needs_fix" # a start is refused with missing_permissions
|
return "needs_fix" # a start is refused with missing_permissions
|
||||||
if any(r["ok"] for r in rows):
|
if any(r["ok"] for r in rows):
|
||||||
return "busy"
|
return "busy"
|
||||||
|
if any(r["status"] == discord_rest.INVALID for r in rows):
|
||||||
|
return "invalid" # the bot IS here, but Discord rejected its token (#257)
|
||||||
return "no_bot"
|
return "no_bot"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -101,23 +101,21 @@ async def redeem_link_code(guild_id: str, body: RedeemBody, principal: dict = De
|
|||||||
redeem_guild_limiter.check(f"guild:{guild_id}")
|
redeem_guild_limiter.check(f"guild:{guild_id}")
|
||||||
redeem_user_limiter.check(f"user:{body.discord_user_id}")
|
redeem_user_limiter.check(f"user:{body.discord_user_id}")
|
||||||
actor = f"discord:{body.discord_user_id}"
|
actor = f"discord:{body.discord_user_id}"
|
||||||
before = await streams.resolve_guild_org(guild_id)
|
|
||||||
try:
|
try:
|
||||||
result = await glink.redeem_code(body.code, guild_id, body.guild_name, body.discord_user_id)
|
# Links, then stops anything the server had playing for the org it
|
||||||
|
# acted for before (the DISCORD_UNLINKED_GUILD_ORG_ID fallback).
|
||||||
|
result = await glink.redeem_and_settle(body.code, guild_id, body.guild_name, body.discord_user_id)
|
||||||
except glink.LinkError as e:
|
except glink.LinkError as e:
|
||||||
await write_audit(actor, "", "discord.guild.link_refused",
|
await write_audit(actor, "", "discord.guild.link_refused",
|
||||||
details={"guild_id": guild_id, "reason": e.code})
|
details={"guild_id": guild_id, "reason": e.code})
|
||||||
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
|
return JSONResponse(status_code=e.status, content={"detail": e.message, "code": e.code})
|
||||||
|
|
||||||
org_id = result["org_id"]
|
org_id = result["org_id"]
|
||||||
stopped: list = []
|
stopped = result["streams_stopped"]
|
||||||
if before and before != org_id:
|
|
||||||
# The server was acting for DISCORD_UNLINKED_GUILD_ORG_ID; anything it
|
|
||||||
# had playing belongs to that org, not the one it just linked to.
|
|
||||||
stopped = await streams.stop_guild(guild_id, "relinked")
|
|
||||||
await write_audit(actor, "", "discord.guild.link", details={
|
await write_audit(actor, "", "discord.guild.link", details={
|
||||||
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||||
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
|
"code_created_by_uid": result.get("created_by_uid"), "streams_stopped": len(stopped),
|
||||||
|
"via": "code",
|
||||||
})
|
})
|
||||||
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
|
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} by {actor}")
|
||||||
org = await fstore.doc_get("organizations", org_id)
|
org = await fstore.doc_get("organizations", org_id)
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""
|
||||||
|
"Connect a Discord server" through a radio bot's own OAuth2 invite
|
||||||
|
(server-26#225 S6, owner decision Q1 = per-bot OAuth).
|
||||||
|
|
||||||
|
GET /discord/oauth/options org owner / platform admin — which bot Connect
|
||||||
|
would invite (no state minted). 404 = off.
|
||||||
|
POST /discord/oauth/start org owner / platform admin — mint a state,
|
||||||
|
return Discord's authorize URL
|
||||||
|
POST /discord/oauth/complete signed-in user who started it — exchange the
|
||||||
|
code, link the server, check permissions
|
||||||
|
|
||||||
|
Platform admins may pass ?org_id= to act for another org (resolve_org_scope).
|
||||||
|
Every route is a 404 while ``discord_oauth_redirect_uri`` is empty or no
|
||||||
|
eligible bot has an OAuth secret (PUT /tokens/{id}/oauth).
|
||||||
|
|
||||||
|
Which bot (``_pick_bot``): an explicit ``token_id`` the org may use; else the
|
||||||
|
radio bot preferred for ``system_id`` (the org's own system or a shared one;
|
||||||
|
strict: a preferred bot that can't connect is a 404, not a quiet swap); else
|
||||||
|
the radio bot preferred for the node's system (``node_id``); else the org's own
|
||||||
|
bring-your-own bot; else the pool bot with the fewest servers that is under
|
||||||
|
``discord_pool_bot_guild_cap`` (owner decision Q3). Only bots with an OAuth
|
||||||
|
secret set count. A bot the caller's org can't use is a 404, never a 403.
|
||||||
|
|
||||||
|
The org a server links to is the one the STATE names, re-checked against a
|
||||||
|
fresh read of the caller's membership; the server is the one Discord's code
|
||||||
|
exchange names. The redirect's ``guild_id`` query parameter is ignored.
|
||||||
|
|
||||||
|
This reverses ``discord_web``'s old "C2 never calls Discord on the browser's
|
||||||
|
behalf" on purpose (plan finding 5): the call is made with the bot
|
||||||
|
application's credentials, after the org-scope checks and rate limits here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_guild_link as glink
|
||||||
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_oauth as oauth
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.audit import write_audit
|
||||||
|
from app.internal.auth import (
|
||||||
|
_RateLimiter, describe_actor, get_role, require_firebase_token, require_org_owner_token, resolve_org_scope,
|
||||||
|
)
|
||||||
|
from app.internal.logger import logger
|
||||||
|
from app.routers.discord_checks import voice_check
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/discord/oauth", tags=["discord"])
|
||||||
|
|
||||||
|
start_limiter = _RateLimiter(max_calls=5, window_seconds=600)
|
||||||
|
complete_limiter = _RateLimiter(max_calls=10, window_seconds=600)
|
||||||
|
_ID = r"^[A-Za-z0-9_-]{1,64}$"
|
||||||
|
_OFF = "Connecting a Discord server through DRB isn't available."
|
||||||
|
|
||||||
|
|
||||||
|
def _require_enabled() -> None:
|
||||||
|
if not oauth.enabled():
|
||||||
|
raise HTTPException(404, _OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(status: int, code: str, message: str, **extra) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=status, content={"detail": message, "code": code, **extra})
|
||||||
|
|
||||||
|
|
||||||
|
def _server_count(tok: dict) -> Optional[int]:
|
||||||
|
members = tok.get("guild_ids")
|
||||||
|
return len(members) if isinstance(members, list) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _usable(tok: dict, org_id: str, oauth_ids: set) -> bool:
|
||||||
|
return (tok.get("token_id") in oauth_ids
|
||||||
|
and tok.get("valid") is not False
|
||||||
|
and bool(tok.get("application_id"))
|
||||||
|
and tok.get("org_id") in (None, org_id)
|
||||||
|
and leases.invitable(tok))
|
||||||
|
|
||||||
|
|
||||||
|
async def _pick_bot(org_id: str, *, node_id: Optional[str] = None,
|
||||||
|
token_id: Optional[str] = None, system_id: Optional[str] = None) -> tuple[dict, str]:
|
||||||
|
"""(token doc, why) — why is ``chosen`` | ``preferred`` | ``org_bot`` |
|
||||||
|
``pool``. Raises HTTPException 404 when nothing fits."""
|
||||||
|
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||||
|
oauth_ids = await oauth.enabled_token_ids()
|
||||||
|
|
||||||
|
if token_id:
|
||||||
|
tok = tokens.get(token_id)
|
||||||
|
if not tok or tok.get("org_id") not in (None, org_id):
|
||||||
|
raise HTTPException(404, "Radio bot not found.")
|
||||||
|
if not _usable(tok, org_id, oauth_ids):
|
||||||
|
raise HTTPException(404, f"{tok.get('name') or 'That radio bot'} can't be used to connect a server "
|
||||||
|
"right now.")
|
||||||
|
return tok, "chosen"
|
||||||
|
|
||||||
|
if system_id:
|
||||||
|
# Same visibility as a node's system: the org's own or a shared one
|
||||||
|
# (no org_id). Another org's system is a 404, never a 403.
|
||||||
|
system = await fstore.doc_get("systems", system_id)
|
||||||
|
if not system or system.get("org_id") not in (None, org_id):
|
||||||
|
raise HTTPException(404, "System not found.")
|
||||||
|
pref_id = system.get("preferred_token_id") or ""
|
||||||
|
if pref_id:
|
||||||
|
# The caller asked for THIS system's bot by name: inviting a
|
||||||
|
# different one instead would be the silent swap this exists to stop.
|
||||||
|
pref = tokens.get(pref_id)
|
||||||
|
if not pref or not _usable(pref, org_id, oauth_ids):
|
||||||
|
raise HTTPException(404, "That system's radio bot can't be used to connect a server right now.")
|
||||||
|
return pref, "preferred"
|
||||||
|
|
||||||
|
if node_id:
|
||||||
|
node = await fstore.doc_get("nodes", node_id)
|
||||||
|
if not node or node.get("org_id") != org_id:
|
||||||
|
raise HTTPException(404, "Node not found.")
|
||||||
|
system_id = streams._effective_system(node)
|
||||||
|
system = await fstore.doc_get("systems", system_id) if system_id else None
|
||||||
|
pref = tokens.get((system or {}).get("preferred_token_id") or "")
|
||||||
|
if pref and _usable(pref, org_id, oauth_ids):
|
||||||
|
return pref, "preferred"
|
||||||
|
|
||||||
|
own = sorted((t for t in tokens.values() if t.get("org_id") == org_id and _usable(t, org_id, oauth_ids)),
|
||||||
|
key=lambda t: (str(t.get("name") or "").lower(), t["token_id"]))
|
||||||
|
if own:
|
||||||
|
return own[0], "org_bot"
|
||||||
|
|
||||||
|
cap = settings.discord_pool_bot_guild_cap
|
||||||
|
pool = sorted((t for t in tokens.values() if t.get("org_id") is None and _usable(t, org_id, oauth_ids)),
|
||||||
|
key=lambda t: (_server_count(t) if _server_count(t) is not None else cap,
|
||||||
|
str(t.get("name") or "").lower(), t["token_id"]))
|
||||||
|
if pool:
|
||||||
|
return pool[0], "pool"
|
||||||
|
raise HTTPException(404, _OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _bot_summary(tok: dict, why: str) -> dict:
|
||||||
|
return {"name": tok.get("name") or "DRB radio bot", "reason": why,
|
||||||
|
"own_bot": tok.get("org_id") is not None}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/options")
|
||||||
|
async def oauth_options(node_id: Optional[str] = Query(None, pattern=_ID),
|
||||||
|
token_id: Optional[str] = Query(None, pattern=_ID),
|
||||||
|
org_id: Optional[str] = None,
|
||||||
|
decoded: dict = Depends(require_org_owner_token),
|
||||||
|
system_id: Annotated[Optional[str], Query(pattern=_ID)] = None):
|
||||||
|
"""Whether Connect is offered here, and which bot it would invite."""
|
||||||
|
_require_enabled()
|
||||||
|
scope = resolve_org_scope(decoded, org_id)
|
||||||
|
tok, why = await _pick_bot(scope, node_id=node_id, token_id=token_id, system_id=system_id)
|
||||||
|
return {"available": True, "bot": _bot_summary(tok, why)}
|
||||||
|
|
||||||
|
|
||||||
|
class StartBody(BaseModel):
|
||||||
|
node_id: Optional[str] = Field(None, pattern=_ID)
|
||||||
|
token_id: Optional[str] = Field(None, pattern=_ID)
|
||||||
|
system_id: Optional[str] = Field(None, pattern=_ID)
|
||||||
|
return_to: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start")
|
||||||
|
async def oauth_start(body: StartBody, org_id: Optional[str] = None,
|
||||||
|
decoded: dict = Depends(require_org_owner_token)):
|
||||||
|
_require_enabled()
|
||||||
|
scope = resolve_org_scope(decoded, org_id)
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
start_limiter.check(f"{uid}:discord_oauth_start")
|
||||||
|
tok, why = await _pick_bot(scope, node_id=body.node_id, token_id=body.token_id,
|
||||||
|
system_id=body.system_id)
|
||||||
|
return_to = oauth.safe_return_to(body.return_to)
|
||||||
|
nonce, expires_at = await oauth.create_state(org_id=scope, uid=uid, token_id=tok["token_id"],
|
||||||
|
node_id=body.node_id, return_to=return_to)
|
||||||
|
await write_audit(uid, email, "discord.oauth.start", details={
|
||||||
|
"org_id": scope, "token_id": tok["token_id"], "reason": why, "node_id": body.node_id,
|
||||||
|
"system_id": body.system_id})
|
||||||
|
return {"authorize_url": oauth.authorize_url(str(tok["application_id"]), nonce),
|
||||||
|
"expires_at": expires_at.isoformat(), "bot": _bot_summary(tok, why)}
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteBody(BaseModel):
|
||||||
|
# Lengths checked in the handler: a pydantic 422 would echo the code back.
|
||||||
|
code: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _still_owner(decoded: dict, uid: str, org_id: str) -> bool:
|
||||||
|
"""Fresh read: the claims in an ID token can be up to an hour old."""
|
||||||
|
if get_role(decoded) == "admin":
|
||||||
|
return True
|
||||||
|
member = await fstore.doc_get("org_members", uid)
|
||||||
|
return bool(member and member.get("org_id") == org_id and member.get("org_role") == "owner")
|
||||||
|
|
||||||
|
|
||||||
|
async def _channel_summary(guild_id: str, org_id: str, node_id: Optional[str]) -> Optional[dict]:
|
||||||
|
"""Permissions right after the link, within discord_oauth_link_check_seconds.
|
||||||
|
None = not answered in time (the picker's badges will say)."""
|
||||||
|
link = await fstore.doc_get(streams.GUILDS, guild_id) or {}
|
||||||
|
try:
|
||||||
|
out = await asyncio.wait_for(
|
||||||
|
voice_check(guild_id=guild_id, org_id=org_id, node_id=node_id,
|
||||||
|
channels=link.get("voice_channels") or [], channel_id=None, refresh=True,
|
||||||
|
with_token_ids=False),
|
||||||
|
timeout=settings.discord_oauth_link_check_seconds)
|
||||||
|
except Exception as e: # timeout or anything else: the link already stands
|
||||||
|
logger.info(f"Discord OAuth link {guild_id}: permission check skipped ({type(e).__name__})")
|
||||||
|
return None
|
||||||
|
channels = [{"channel_id": c["channel_id"], "name": c.get("name"), "state": c["state"],
|
||||||
|
"fix": next((b.get("fix") for b in c.get("bots") or [] if b.get("fix")), None)}
|
||||||
|
for c in out.get("channels") or []]
|
||||||
|
return {
|
||||||
|
"enabled": out.get("enabled"),
|
||||||
|
"channels_ready": sum(1 for c in channels if c["state"] == "ready"),
|
||||||
|
"channels_blocked": sum(1 for c in channels if c["state"] == "needs_fix"),
|
||||||
|
"channels": channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete")
|
||||||
|
async def oauth_complete(body: CompleteBody, decoded: dict = Depends(require_firebase_token)):
|
||||||
|
_require_enabled()
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
complete_limiter.check(f"{uid}:discord_oauth_complete")
|
||||||
|
if not (0 < len(body.code) <= 200):
|
||||||
|
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
|
||||||
|
|
||||||
|
# 1. Consume the state (single use; must be this caller's).
|
||||||
|
try:
|
||||||
|
state = await oauth.consume_state(body.state, uid)
|
||||||
|
except oauth.StateError as e:
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={"reason": e.code})
|
||||||
|
return _err(e.status, e.code, e.message)
|
||||||
|
org_id, token_id = state["org_id"], state["token_id"]
|
||||||
|
return_to = state.get("return_to")
|
||||||
|
|
||||||
|
# 2. Still the owner of that org (fresh read, not the token's claims).
|
||||||
|
if not await _still_owner(decoded, uid, org_id):
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={"org_id": org_id, "reason": "not_owner"})
|
||||||
|
return _err(403, "not_owner", "Only the organization owner can connect a Discord server.")
|
||||||
|
|
||||||
|
# 3. Exchange the code with that bot application's own secret.
|
||||||
|
tok = await fstore.doc_get(leases.TOKENS, token_id)
|
||||||
|
secret = await oauth.load_secret(token_id)
|
||||||
|
if (not tok or not tok.get("application_id") or not secret
|
||||||
|
or tok.get("org_id") not in (None, org_id) or tok.get("valid") is False):
|
||||||
|
return _err(409, "bot_unavailable", "That radio bot can no longer connect servers. Start again from DRB.")
|
||||||
|
client_id = str(tok["application_id"])
|
||||||
|
r = await oauth.exchange_code(client_id, secret, body.code)
|
||||||
|
if not r.ok or not isinstance(r.data, dict):
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={
|
||||||
|
"org_id": org_id, "token_id": token_id, "reason": "exchange_failed", "status": r.status})
|
||||||
|
if r.status is not None and 400 <= r.status < 500 and r.status != 429:
|
||||||
|
return _err(400, "code_rejected", "Discord didn't accept that sign-in. Start again from DRB.")
|
||||||
|
return _err(502, "discord_unavailable", "Couldn't reach Discord to finish connecting. Start again from DRB.")
|
||||||
|
await oauth.revoke(client_id, secret, r.data.get("access_token"))
|
||||||
|
|
||||||
|
# 4. The server is the one Discord's exchange names — never the query string.
|
||||||
|
guild = r.data.get("guild") if isinstance(r.data.get("guild"), dict) else None
|
||||||
|
guild_id = str((guild or {}).get("id") or "")
|
||||||
|
if not guild_id.isdigit():
|
||||||
|
await write_audit(uid, email, "discord.oauth.refused", details={
|
||||||
|
"org_id": org_id, "token_id": token_id, "reason": "no_guild"})
|
||||||
|
return _err(400, "no_guild", "Discord didn't say which server the bot was added to. Start again from DRB.")
|
||||||
|
guild_name = guild.get("name") if isinstance(guild.get("name"), str) else None
|
||||||
|
|
||||||
|
# 5. Link + settle (a server another org linked is never moved: 409).
|
||||||
|
try:
|
||||||
|
result = await glink.link_and_settle(org_id, guild_id, guild_name, uid, "oauth",
|
||||||
|
extra={"linked_with_token_id": token_id})
|
||||||
|
except glink.LinkError as e:
|
||||||
|
await write_audit(uid, email, "discord.guild.link_refused", details={
|
||||||
|
"guild_id": guild_id, "org_id": org_id, "reason": e.code, "via": "oauth", "token_id": token_id})
|
||||||
|
return _err(e.status, e.code, e.message, guild_name=guild_name, bot_name=tok.get("name"))
|
||||||
|
|
||||||
|
# 6. The bot is in the server now: record it, drop stale cached answers, audit.
|
||||||
|
members = tok.get("guild_ids")
|
||||||
|
if isinstance(members, list) and guild_id not in members:
|
||||||
|
await fstore.doc_update(leases.TOKENS, token_id, {"guild_ids": [*members, guild_id]})
|
||||||
|
discord_rest.invalidate(guild_id=guild_id)
|
||||||
|
await write_audit(uid, email, "discord.guild.link", details={
|
||||||
|
"guild_id": guild_id, "org_id": org_id, "already_linked": result["already_linked"],
|
||||||
|
"previous_org_id": result["previous_org_id"], "streams_stopped": len(result["streams_stopped"]),
|
||||||
|
"via": "oauth", "token_id": token_id,
|
||||||
|
})
|
||||||
|
logger.info(f"Discord guild {guild_id} linked to org={org_id!r} via OAuth (bot {token_id}) by uid={uid}")
|
||||||
|
|
||||||
|
# 7. Which voice channels are ready, in the same request (capped).
|
||||||
|
check = await _channel_summary(guild_id, org_id, state.get("node_id"))
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"guild_id": guild_id,
|
||||||
|
"guild_name": guild_name,
|
||||||
|
"org_id": org_id,
|
||||||
|
"bot_name": tok.get("name"),
|
||||||
|
"already_linked": result["already_linked"],
|
||||||
|
"streams_stopped": len(result["streams_stopped"]),
|
||||||
|
"node_id": state.get("node_id"),
|
||||||
|
"return_to": return_to,
|
||||||
|
"checked": check is not None,
|
||||||
|
"channels_ready": check["channels_ready"] if check else None,
|
||||||
|
"channels_blocked": check["channels_blocked"] if check else None,
|
||||||
|
"channels": check["channels"] if check else [],
|
||||||
|
}
|
||||||
@@ -6,15 +6,49 @@ The node page used to take raw guild/channel IDs. It now picks from:
|
|||||||
* the Discord servers the caller's org owns — ``discord_guilds`` docs that
|
* the Discord servers the caller's org owns — ``discord_guilds`` docs that
|
||||||
resolve to the node's org (an explicit link, or the unlinked-server
|
resolve to the node's org (an explicit link, or the unlinked-server
|
||||||
fallback org, same rule the bot uses: streams.resolve_guild_org), and
|
fallback org, same rule the bot uses: streams.resolve_guild_org), and
|
||||||
* their voice channels, from a directory the DRB server bot pushes
|
* their voice channels, from a directory kept on ``discord_guilds/{id}``.
|
||||||
(``PUT /discord/guilds/{id}/directory``). The bot already holds its
|
|
||||||
gateway cache of every server it is in; C2 never calls Discord with a
|
Where the directory comes from (server-26#225 S5 — the main bot is optional):
|
||||||
token on the browser's behalf and no token string ever reaches the browser.
|
|
||||||
|
* the DRB server bot, when it is in the server: it pushes its gateway view
|
||||||
|
(``PUT /discord/guilds/{id}/directory``) on start, on changes and every
|
||||||
|
15 minutes (``directory_source: "main_bot"``);
|
||||||
|
* otherwise a radio bot that is in the server: C2 reads the channel list
|
||||||
|
over Discord REST with that bot's token (discord_rest.guild_directory —
|
||||||
|
the same cached snapshot the permission check reads, so no extra
|
||||||
|
request) and stores it (``directory_source: "radio_bot"``,
|
||||||
|
``radio_bot_present``). That happens when the picker is opened and the
|
||||||
|
stored list is missing (inline, inside the permission-check time budget)
|
||||||
|
or older than ``discord_directory_refresh_seconds`` (in the background).
|
||||||
|
|
||||||
|
This reverses the old rule "C2 never calls Discord on the browser's behalf"
|
||||||
|
on purpose (server-26#225 plan finding 5): C2 does call Discord when a
|
||||||
|
signed-in user opens the picker, presses Start or Re-check — with a radio
|
||||||
|
bot's token, only for a server already linked to the caller's org, through
|
||||||
|
discord_rest's rate-limited, cached, breaker-guarded client (refresh=1 at
|
||||||
|
most once per 10 s per user and server). No token string ever reaches the
|
||||||
|
browser, and REST answers never create or re-link a ``discord_guilds`` doc.
|
||||||
|
|
||||||
|
The DRB server bot is optional end to end (server-26#225 S8). One radio bot
|
||||||
|
in the server is enough to link it (per-bot OAuth, routers/discord_oauth.py),
|
||||||
|
list its voice channels (here), check permissions (routers/discord_checks.py)
|
||||||
|
and stream (streams.start_stream). The server bot adds its slash commands
|
||||||
|
(/join, /leave, /status, /drb link) and a gateway-fed channel list; every
|
||||||
|
path it uses keeps working.
|
||||||
|
|
||||||
|
Which servers an org sees (``_org_guilds``):
|
||||||
|
|
||||||
|
* linked to the org: shown unless the server bot said it left AND no
|
||||||
|
radio bot the org may use is in it (``bot_tokens.guild_ids``);
|
||||||
|
* acting for the org only through the unlinked-server fallback: shown only
|
||||||
|
while the server bot is there, exactly as before. A radio bot's
|
||||||
|
membership never discovers a server, and REST never creates a
|
||||||
|
``discord_guilds`` doc.
|
||||||
|
|
||||||
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
|
A pick becomes a ``discord_streams`` desired-state doc **pinned to this node**
|
||||||
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
|
(streams.start_stream(node_id=...)), exactly what the old raw-ID path did, with
|
||||||
the checks that path skipped: the server must belong to the node's org, the
|
the checks that path skipped: the server must belong to the node's org, the
|
||||||
channel must be a voice channel the bot reported in that server, and the
|
channel must be a voice channel in that server's directory, and the
|
||||||
per-server and per-node stream caps apply.
|
per-server and per-node stream caps apply.
|
||||||
|
|
||||||
Auth:
|
Auth:
|
||||||
@@ -29,6 +63,7 @@ Auth:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -38,6 +73,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal import discord_leases as leases
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_rest
|
||||||
from app.internal import discord_streams as streams
|
from app.internal import discord_streams as streams
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.audit import write_audit
|
from app.internal.audit import write_audit
|
||||||
@@ -54,6 +90,14 @@ router = APIRouter(prefix="/discord", tags=["discord"])
|
|||||||
|
|
||||||
MAX_CHANNELS = 200
|
MAX_CHANNELS = 200
|
||||||
_NAME_MAX = 100
|
_NAME_MAX = 100
|
||||||
|
MAIN_BOT = "main_bot"
|
||||||
|
RADIO_BOT = "radio_bot"
|
||||||
|
# The server bot re-pushes every 15 min. A list it pushed more than three
|
||||||
|
# pushes ago means it is down (it never said it left): a radio bot's view
|
||||||
|
# takes over until it is back.
|
||||||
|
MAIN_BOT_STALE_SECONDS = 45 * 60
|
||||||
|
# How many member radio bots are asked, one after another, for the list.
|
||||||
|
DIRECTORY_BOTS = 3
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@@ -80,16 +124,20 @@ class GuildDirectory(BaseModel):
|
|||||||
@router.put("/guilds/{guild_id}/directory")
|
@router.put("/guilds/{guild_id}/directory")
|
||||||
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
|
async def put_guild_directory(guild_id: str, body: GuildDirectory, _: dict = Depends(require_service_key)):
|
||||||
"""The server bot's view of one Discord server: its name and voice channels.
|
"""The server bot's view of one Discord server: its name and voice channels.
|
||||||
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link)."""
|
Merged onto ``discord_guilds/{id}`` — never touches ``org_id`` (the link).
|
||||||
|
Cached Discord snapshots of the server are dropped (a channel was added,
|
||||||
|
removed or renamed), so the next permission check reads it fresh."""
|
||||||
if not guild_id.isdigit():
|
if not guild_id.isdigit():
|
||||||
raise HTTPException(400, "guild_id must be a Discord snowflake.")
|
raise HTTPException(400, "guild_id must be a Discord snowflake.")
|
||||||
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
|
data: dict = {"guild_id": guild_id, "bot_present": body.present, "directory_at": _now_iso()}
|
||||||
if body.present:
|
if body.present:
|
||||||
data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
|
data["voice_channels"] = [c.model_dump() for c in body.voice_channels]
|
||||||
|
data["directory_source"] = MAIN_BOT
|
||||||
if body.name:
|
if body.name:
|
||||||
# Same field the /drb link flow writes (feat/213-guild-link, PR #218).
|
# Same field the /drb link flow writes (feat/213-guild-link, PR #218).
|
||||||
data["guild_name"] = body.name
|
data["guild_name"] = body.name
|
||||||
await fstore.doc_set(streams.GUILDS, guild_id, data)
|
await fstore.doc_set(streams.GUILDS, guild_id, data)
|
||||||
|
discord_rest.invalidate(guild_id=guild_id)
|
||||||
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
|
return {"ok": True, "guild_id": guild_id, "voice_channels": len(body.voice_channels)}
|
||||||
|
|
||||||
|
|
||||||
@@ -130,24 +178,156 @@ def _require_control(can: bool) -> None:
|
|||||||
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
|
raise HTTPException(403, "Only the organization owner, an admin, or this node's operator can do that.")
|
||||||
|
|
||||||
|
|
||||||
async def _org_guilds(org_id: str) -> list[dict]:
|
async def _load_tokens() -> dict[str, dict]:
|
||||||
"""``discord_guilds`` docs this org may stream into, bot still present.
|
return {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
||||||
|
|
||||||
|
|
||||||
|
def _member_radio_bots(tokens: dict[str, dict], guild_id: str, org_id: Optional[str]) -> list[str]:
|
||||||
|
"""Radio bots this org may use (pool, or the org's own) that Discord last
|
||||||
|
said are in the server, best first. Unknown membership (``guild_ids``
|
||||||
|
None) does not count: presence has to be positive."""
|
||||||
|
out = [tid for tid, t in tokens.items()
|
||||||
|
if t.get("token") and t.get("valid") is not False and t.get("org_id") in (None, org_id)
|
||||||
|
and isinstance(t.get("guild_ids"), list) and guild_id in t["guild_ids"]]
|
||||||
|
out.sort(key=lambda tid: leases._candidate_order(tid, tokens[tid], "", org_id, None))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _org_guilds(org_id: str, tokens: Optional[dict] = None) -> list[dict]:
|
||||||
|
"""``discord_guilds`` docs this org may stream into, with a bot there.
|
||||||
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
|
Mirrors streams.resolve_guild_org: explicit link, else the unlinked-server
|
||||||
fallback org — except a server an org explicitly unlinked (``unlinked_at``,
|
fallback org — except a server an org explicitly unlinked (``unlinked_at``,
|
||||||
PR #218), which gets no fallback."""
|
PR #218), which gets no fallback.
|
||||||
|
|
||||||
|
A linked server is shown while the server bot is there or a radio bot the
|
||||||
|
org may use is (server-26#225 S5: the main bot is optional). A server
|
||||||
|
that acts for the org only through the fallback still needs the server
|
||||||
|
bot: a radio bot's membership never discovers one."""
|
||||||
fallback = settings.discord_unlinked_guild_org_id or None
|
fallback = settings.discord_unlinked_guild_org_id or None
|
||||||
|
if tokens is None:
|
||||||
|
tokens = await _load_tokens()
|
||||||
out = []
|
out = []
|
||||||
for g in await fstore.collection_list(streams.GUILDS):
|
for g in await fstore.collection_list(streams.GUILDS):
|
||||||
gid = str(g.get("guild_id") or "")
|
gid = str(g.get("guild_id") or "")
|
||||||
if not gid or g.get("bot_present") is False:
|
if not gid:
|
||||||
|
continue
|
||||||
|
if g.get("org_id"):
|
||||||
|
if g["org_id"] != org_id:
|
||||||
|
continue
|
||||||
|
if g.get("bot_present") is False and not _member_radio_bots(tokens, gid, org_id):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if g.get("unlinked_at") or not fallback or fallback != org_id or g.get("bot_present") is False:
|
||||||
continue
|
continue
|
||||||
owner = g.get("org_id") or (None if g.get("unlinked_at") else fallback)
|
|
||||||
if owner and owner == org_id:
|
|
||||||
out.append(g)
|
out.append(g)
|
||||||
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
|
out.sort(key=lambda g: ((_guild_name(g) or "").lower(), g["guild_id"]))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Directory from a radio bot (server-26#225 S5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _age_seconds(ts) -> Optional[float]:
|
||||||
|
when = streams._parse_ts(ts)
|
||||||
|
return (datetime.now(timezone.utc) - when).total_seconds() if when else None
|
||||||
|
|
||||||
|
|
||||||
|
def _directory_due(g: dict) -> Optional[str]:
|
||||||
|
"""None when the stored list is fine; "missing" when there is none;
|
||||||
|
"stale" when it is old. Only for a server linked to an org — a fallback
|
||||||
|
server's list is the server bot's alone."""
|
||||||
|
if not g.get("org_id"):
|
||||||
|
return None
|
||||||
|
if g.get("voice_channels") is None:
|
||||||
|
return "missing"
|
||||||
|
from_main = g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
|
||||||
|
limit = MAIN_BOT_STALE_SECONDS if from_main else settings.discord_directory_refresh_seconds
|
||||||
|
age = _age_seconds(g.get("directory_at"))
|
||||||
|
return "stale" if age is None or age > limit else None
|
||||||
|
|
||||||
|
|
||||||
|
def _main_bot_list_fresh(g: dict) -> bool:
|
||||||
|
"""The server bot is there and its own list is current: it wins."""
|
||||||
|
return (g.get("bot_present") is True and (g.get("directory_source") or MAIN_BOT) == MAIN_BOT
|
||||||
|
and _directory_due(g) is None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_directory(g: dict, org_id: str, tokens: dict[str, dict]) -> Optional[dict]:
|
||||||
|
"""Read one linked server's voice channels from a member radio bot and
|
||||||
|
store them. Returns the fields written (also merged into ``g``), or None
|
||||||
|
when nothing was learned. Writes only onto the existing doc, and only
|
||||||
|
while it is still linked to ``org_id``: never creates a doc, never
|
||||||
|
touches ``org_id`` or the server bot's ``bot_present``."""
|
||||||
|
gid = g["guild_id"]
|
||||||
|
members = _member_radio_bots(tokens, gid, org_id)
|
||||||
|
if not members:
|
||||||
|
return None
|
||||||
|
docs = [{**tokens[t], "token_id": t} for t in members]
|
||||||
|
res = await discord_rest.guild_directory(docs, gid, max_bots=DIRECTORY_BOTS)
|
||||||
|
now = _now_iso()
|
||||||
|
if res.status == discord_rest.OK:
|
||||||
|
updates: dict = {"voice_channels": res.voice_channels, "directory_at": now,
|
||||||
|
"directory_source": RADIO_BOT, "radio_bot_present": True, "radio_bot_checked_at": now}
|
||||||
|
if res.name:
|
||||||
|
updates["guild_name"] = res.name
|
||||||
|
elif res.status == discord_rest.NOT_MEMBER:
|
||||||
|
updates = {"radio_bot_present": False, "radio_bot_checked_at": now}
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
cur = await fstore.doc_get(streams.GUILDS, gid)
|
||||||
|
if not cur or cur.get("org_id") != org_id:
|
||||||
|
return None # unlinked / moved / removed meanwhile
|
||||||
|
if _main_bot_list_fresh(cur):
|
||||||
|
return None # the server bot pushed a fresh list meanwhile; it wins
|
||||||
|
await fstore.doc_update(streams.GUILDS, gid, updates)
|
||||||
|
g.update(updates)
|
||||||
|
logger.info(f"Discord directory for {gid} from radio bot {res.token_id or '-'}: "
|
||||||
|
f"{res.status}, {len(res.voice_channels or [])} voice channel(s)")
|
||||||
|
return updates
|
||||||
|
|
||||||
|
|
||||||
|
_directory_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_quietly(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
|
||||||
|
try:
|
||||||
|
await _refresh_directory(g, org_id, tokens)
|
||||||
|
except Exception as e: # a background refresh must never surface anywhere
|
||||||
|
logger.warning(f"Discord directory refresh for {g.get('guild_id')} failed: {type(e).__name__}")
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_in_background(g: dict, org_id: str, tokens: dict[str, dict]) -> None:
|
||||||
|
gid = g["guild_id"]
|
||||||
|
running = _directory_tasks.get(gid)
|
||||||
|
if running is not None and not running.done():
|
||||||
|
return
|
||||||
|
task = asyncio.create_task(_refresh_quietly(dict(g), org_id, tokens))
|
||||||
|
_directory_tasks[gid] = task
|
||||||
|
task.add_done_callback(lambda t, gid=gid: _directory_tasks.pop(gid, None)
|
||||||
|
if _directory_tasks.get(gid) is t else None)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fill_directories(guilds: list[dict], org_id: str, tokens: dict[str, dict]) -> None:
|
||||||
|
"""Missing lists are read now (all servers at once, inside the
|
||||||
|
permission-check budget — an answer that comes later is cached and
|
||||||
|
stored by the next look); stale ones are refreshed in the background and
|
||||||
|
the stored list is shown meanwhile."""
|
||||||
|
now: list[dict] = []
|
||||||
|
for g in guilds:
|
||||||
|
due = _directory_due(g)
|
||||||
|
if not due or not _member_radio_bots(tokens, g["guild_id"], org_id):
|
||||||
|
continue
|
||||||
|
if due == "missing":
|
||||||
|
now.append(g)
|
||||||
|
else:
|
||||||
|
_refresh_in_background(g, org_id, tokens)
|
||||||
|
if not now:
|
||||||
|
return
|
||||||
|
tasks = [asyncio.create_task(_refresh_quietly(g, org_id, tokens)) for g in now]
|
||||||
|
await asyncio.wait(tasks, timeout=settings.discord_perm_check_budget_seconds)
|
||||||
|
|
||||||
|
|
||||||
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
|
def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> dict:
|
||||||
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
|
res = leases.pick_token(tokens, node_id=node_id, guild_id=guild_id, org_id=org_id)
|
||||||
if res.ok:
|
if res.ok:
|
||||||
@@ -181,7 +361,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
|
|||||||
system_id = streams._effective_system(node)
|
system_id = streams._effective_system(node)
|
||||||
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
|
system = (await fstore.doc_get_cached("systems", system_id)) if system_id else None
|
||||||
|
|
||||||
guilds = await _org_guilds(org_id) if org_id else []
|
tokens = await _load_tokens()
|
||||||
|
guilds = await _org_guilds(org_id, tokens) if org_id else []
|
||||||
|
if org_id:
|
||||||
|
await _fill_directories(guilds, org_id, tokens)
|
||||||
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
|
live = [s for s in await fstore.collection_list(streams.STREAMS, desired="on")
|
||||||
if s.get("org_id") == org_id]
|
if s.get("org_id") == org_id]
|
||||||
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
|
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
|
||||||
@@ -190,7 +373,6 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
|
|||||||
by_channel: dict[tuple, list] = {}
|
by_channel: dict[tuple, list] = {}
|
||||||
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
|
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
|
||||||
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
|
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
|
||||||
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
|
|
||||||
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
|
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
|
||||||
|
|
||||||
out_guilds = []
|
out_guilds = []
|
||||||
@@ -216,6 +398,10 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
|
|||||||
"linked": bool(g.get("org_id")),
|
"linked": bool(g.get("org_id")),
|
||||||
"synced": g.get("voice_channels") is not None,
|
"synced": g.get("voice_channels") is not None,
|
||||||
"synced_at": g.get("directory_at"),
|
"synced_at": g.get("directory_at"),
|
||||||
|
# Who listed the channels: "main_bot" (DRB server bot) or "radio_bot" (S5).
|
||||||
|
"directory_source": (g.get("directory_source") or (MAIN_BOT if g.get("voice_channels") is not None
|
||||||
|
else None)),
|
||||||
|
"main_bot_present": g.get("bot_present") is True,
|
||||||
"voice_channels": channels,
|
"voice_channels": channels,
|
||||||
"streams_on": sum(1 for s in live if s.get("guild_id") == gid),
|
"streams_on": sum(1 for s in live if s.get("guild_id") == gid),
|
||||||
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
|
"bot": _bot_status(tokens, node_id=node_id, guild_id=gid, org_id=org_id),
|
||||||
@@ -261,10 +447,24 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
|
|||||||
raise HTTPException(409, "This node is not listening to a system yet.")
|
raise HTTPException(409, "This node is not listening to a system yet.")
|
||||||
|
|
||||||
guild_id, channel_id = str(body.guild_id), str(body.channel_id)
|
guild_id, channel_id = str(body.guild_id), str(body.channel_id)
|
||||||
guild = next((g for g in await _org_guilds(org_id) if g["guild_id"] == guild_id), None)
|
tokens = await _load_tokens()
|
||||||
|
guild = next((g for g in await _org_guilds(org_id, tokens) if g["guild_id"] == guild_id), None)
|
||||||
if not guild:
|
if not guild:
|
||||||
raise HTTPException(404, "That Discord server is not linked to this organization.")
|
raise HTTPException(404, "That Discord server is not linked to this organization.")
|
||||||
channel = next((c for c in guild.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
|
|
||||||
|
def _find(g: dict) -> Optional[dict]:
|
||||||
|
return next((c for c in g.get("voice_channels") or [] if str(c.get("channel_id")) == channel_id), None)
|
||||||
|
|
||||||
|
channel = _find(guild)
|
||||||
|
if not channel and guild.get("org_id") and not _main_bot_list_fresh(guild):
|
||||||
|
# No list yet, or a radio bot's list that may predate this channel:
|
||||||
|
# read it once more (cached snapshot) before saying no.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_refresh_directory(guild, org_id, tokens),
|
||||||
|
timeout=settings.discord_perm_check_budget_seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
channel = _find(guild)
|
||||||
if not channel:
|
if not channel:
|
||||||
raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
|
raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
|
||||||
"Refresh the list and pick again.")
|
"Refresh the list and pick again.")
|
||||||
@@ -287,6 +487,7 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
|
|||||||
channel_name=channel.get("name"), guild_name=_guild_name(guild),
|
channel_name=channel.get("name"), guild_name=_guild_name(guild),
|
||||||
created_by=f"web:{decoded.get('uid') or 'unknown'}",
|
created_by=f"web:{decoded.get('uid') or 'unknown'}",
|
||||||
max_streams=settings.discord_guild_max_streams,
|
max_streams=settings.discord_guild_max_streams,
|
||||||
|
interactive=True,
|
||||||
)
|
)
|
||||||
except streams.StreamError as e:
|
except streams.StreamError as e:
|
||||||
return JSONResponse(status_code=e.status,
|
return JSONResponse(status_code=e.status,
|
||||||
|
|||||||
@@ -10,19 +10,100 @@ 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
|
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
|
Application Default Credentials, so no GCS signed URL — and therefore no
|
||||||
service-account private key on the VM — is involved anywhere in this path.
|
service-account private key on the VM — is involved anywhere in this path.
|
||||||
|
|
||||||
|
HTTP Range (server-26#233): the body is streamed from GCS in fixed-size span
|
||||||
|
reads instead of downloaded whole first. A 600 s FLAC call is ~13 MB, and the
|
||||||
|
old full-body response (``Accept-Ranges: none``) made the browser wait for all
|
||||||
|
of it before playback started and made seeking re-download it. Now playback
|
||||||
|
starts after the first span and a seek is a ``206`` for just the bytes needed.
|
||||||
|
Only a single range is honoured; a multi-range request gets the full body,
|
||||||
|
which RFC 9110 §14.2 permits.
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
import re
|
||||||
|
from typing import AsyncIterator, Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException, Query, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal.storage import verify_audio_link, gcs_uri_for_call, download_audio, content_type_for
|
from app.internal.logger import logger
|
||||||
|
from app.internal.storage import (
|
||||||
|
audio_object_size,
|
||||||
|
content_type_for,
|
||||||
|
download_audio_range,
|
||||||
|
gcs_uri_for_call,
|
||||||
|
verify_audio_link,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/media", tags=["media"])
|
router = APIRouter(prefix="/media", tags=["media"])
|
||||||
|
|
||||||
|
# Bytes per GCS span read. ~25 s of FLAC at ~1.3 MB/min, so the first span —
|
||||||
|
# the only one the browser waits on — is one small GCS round-trip.
|
||||||
|
STREAM_CHUNK_BYTES = 512 * 1024
|
||||||
|
|
||||||
|
# Immutable content, but the URL expires — cache privately only.
|
||||||
|
_CACHE_CONTROL = "private, max-age=3600"
|
||||||
|
|
||||||
|
_RANGE_RE = re.compile(r"^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
class _Unsatisfiable(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_range(header: Optional[str], size: int) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Resolve a ``Range`` header against an object of ``size`` bytes.
|
||||||
|
|
||||||
|
Returns an inclusive ``(start, end)``, or None when the header is absent or
|
||||||
|
should be ignored (not ``bytes``, multi-range, malformed — RFC 9110 lets a
|
||||||
|
server answer those with the full 200). Raises _Unsatisfiable for a
|
||||||
|
well-formed range that selects nothing (-> 416).
|
||||||
|
"""
|
||||||
|
if not header:
|
||||||
|
return None
|
||||||
|
m = _RANGE_RE.match(header)
|
||||||
|
if not m:
|
||||||
|
return None # other unit, multi-range ("a-b,c-d") or garbage
|
||||||
|
first, last = m.group(1), m.group(2)
|
||||||
|
if first == "" and last == "":
|
||||||
|
return None
|
||||||
|
if first == "":
|
||||||
|
# Suffix range: the final N bytes.
|
||||||
|
n = int(last)
|
||||||
|
if n == 0 or size == 0:
|
||||||
|
raise _Unsatisfiable()
|
||||||
|
return max(0, size - n), size - 1
|
||||||
|
start = int(first)
|
||||||
|
if last != "" and int(last) < start:
|
||||||
|
return None # invalid spec -> ignore the header
|
||||||
|
if start >= size:
|
||||||
|
raise _Unsatisfiable()
|
||||||
|
end = size - 1 if last == "" else min(int(last), size - 1)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
async def _stream(gcs_uri: str, first_chunk: bytes, start: int, end: int) -> AsyncIterator[bytes]:
|
||||||
|
yield first_chunk
|
||||||
|
pos = start + len(first_chunk)
|
||||||
|
while pos <= end:
|
||||||
|
chunk_end = min(pos + STREAM_CHUNK_BYTES - 1, end)
|
||||||
|
data = await download_audio_range(gcs_uri, pos, chunk_end)
|
||||||
|
if not data:
|
||||||
|
# Headers are already sent, so the status can't change; ending the
|
||||||
|
# body short makes the client see a truncated response rather than
|
||||||
|
# silently wrong audio.
|
||||||
|
logger.warning(f"/media stream cut short at byte {pos} of {gcs_uri}")
|
||||||
|
return
|
||||||
|
yield data
|
||||||
|
pos += len(data)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/calls/{call_id}/audio")
|
@router.get("/calls/{call_id}/audio")
|
||||||
async def get_call_audio(
|
async def get_call_audio(
|
||||||
call_id: str,
|
call_id: str,
|
||||||
exp: int = Query(..., description="Link expiry, unix seconds."),
|
exp: int = Query(..., description="Link expiry, unix seconds."),
|
||||||
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
sig: str = Query(..., description="HMAC over call_id and expiry."),
|
||||||
|
range_header: Optional[str] = Header(None, alias="Range"),
|
||||||
):
|
):
|
||||||
# Verify before touching Firestore so an invalid link costs nothing.
|
# Verify before touching Firestore so an invalid link costs nothing.
|
||||||
if not verify_audio_link(call_id, exp, sig):
|
if not verify_audio_link(call_id, exp, sig):
|
||||||
@@ -36,26 +117,48 @@ async def get_call_audio(
|
|||||||
if not gcs_uri:
|
if not gcs_uri:
|
||||||
raise HTTPException(404, "No audio for this call.")
|
raise HTTPException(404, "No audio for this call.")
|
||||||
|
|
||||||
data = await download_audio(gcs_uri)
|
size = await audio_object_size(gcs_uri)
|
||||||
if not data:
|
if size is None:
|
||||||
raise HTTPException(404, "Audio object missing from storage.")
|
raise HTTPException(404, "Audio object missing from storage.")
|
||||||
|
|
||||||
return Response(
|
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser will
|
||||||
content=data,
|
# not play a FLAC body labelled audio/mpeg. Derived from the stored
|
||||||
# Was hardcoded audio/mpeg. The node now uploads FLAC, and a browser
|
|
||||||
# will not play a FLAC body labelled audio/mpeg. Derived from the stored
|
|
||||||
# object's extension so old .mp3 recordings keep working unchanged.
|
# object's extension so old .mp3 recordings keep working unchanged.
|
||||||
media_type=content_type_for(gcs_uri),
|
media_type = content_type_for(gcs_uri)
|
||||||
|
|
||||||
|
try:
|
||||||
|
span = parse_range(range_header, size)
|
||||||
|
except _Unsatisfiable:
|
||||||
|
return Response(
|
||||||
|
status_code=416,
|
||||||
|
headers={"Content-Range": f"bytes */{size}", "Accept-Ranges": "bytes"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if size == 0:
|
||||||
|
return Response(
|
||||||
|
content=b"",
|
||||||
|
media_type=media_type,
|
||||||
|
headers={"Accept-Ranges": "bytes", "Cache-Control": _CACHE_CONTROL},
|
||||||
|
)
|
||||||
|
|
||||||
|
start, end = span if span else (0, size - 1)
|
||||||
|
# Fetched before the response starts so a read failure is still a clean
|
||||||
|
# 404 rather than a stream that dies after a 200/206 has gone out.
|
||||||
|
first = await download_audio_range(gcs_uri, start, min(start + STREAM_CHUNK_BYTES - 1, end))
|
||||||
|
if not first:
|
||||||
|
raise HTTPException(404, "Audio object missing from storage.")
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Length": str(len(data)),
|
"Content-Length": str(end - start + 1),
|
||||||
# Whole body at once, no Range support. This was comfortable at
|
"Accept-Ranges": "bytes",
|
||||||
# 16 kbps mono (~60 KB for a 30 s call); FLAC is ~1.3 MB/min, so a
|
"Cache-Control": _CACHE_CONTROL,
|
||||||
# long call is now tens of MB and the browser must download all of
|
}
|
||||||
# it before playback starts. Acceptable for typical few-second
|
if span:
|
||||||
# transmissions, but this is the change that makes Range support
|
headers["Content-Range"] = f"bytes {start}-{end}/{size}"
|
||||||
# actually matter — see DEFERRED.md.
|
|
||||||
"Accept-Ranges": "none",
|
return StreamingResponse(
|
||||||
# Immutable content, but the URL expires — cache privately only.
|
_stream(gcs_uri, first, start, end),
|
||||||
"Cache-Control": "private, max-age=3600",
|
status_code=206 if span else 200,
|
||||||
},
|
media_type=media_type,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ async def send_command(
|
|||||||
doc = await discord_streams.start_stream(
|
doc = await discord_streams.start_stream(
|
||||||
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
|
org_id=node.get("org_id"), guild_id=cmd.guild_id, channel_id=cmd.channel_id,
|
||||||
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
|
system_id=system_id, node_id=node_id, preferred_token_id=cmd.preferred_token_id,
|
||||||
mode="adhoc",
|
mode="adhoc", interactive=True,
|
||||||
)
|
)
|
||||||
except discord_streams.StreamError as e:
|
except discord_streams.StreamError as e:
|
||||||
raise HTTPException(e.status, e.message)
|
raise HTTPException(e.status, e.message)
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Power-outage endpoints (map layer, server-26#184).
|
||||||
|
|
||||||
|
``GET /outages`` -- any signed-in user (auth applied at include time in main.py):
|
||||||
|
outage areas inside a bbox from every provider an admin has enabled, each with
|
||||||
|
the provider's attribution, disclaimer, coverage note and feed freshness, plus
|
||||||
|
a ``status`` that says why an empty list is empty.
|
||||||
|
|
||||||
|
``/admin/outage-providers`` -- platform admin only: list every catalogued
|
||||||
|
provider with its licence status and health, and toggle it. Gated licences
|
||||||
|
need ``acknowledge_licence: true``; that acknowledgement is stored with who and
|
||||||
|
when and written to audit_log (see app/internal/outages/registry.py).
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.internal.auth import describe_actor, require_admin_token
|
||||||
|
from app.internal.outages import registry
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/outages", tags=["outages"])
|
||||||
|
admin_router = APIRouter(prefix="/admin/outage-providers", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_outages(
|
||||||
|
south: float = Query(..., ge=-90, le=90),
|
||||||
|
west: float = Query(..., ge=-180, le=180),
|
||||||
|
north: float = Query(..., ge=-90, le=90),
|
||||||
|
east: float = Query(..., ge=-180, le=180),
|
||||||
|
):
|
||||||
|
if south > north or west > east:
|
||||||
|
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
|
||||||
|
return await registry.outages_in_bbox((south, west, north, east))
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderToggle(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
acknowledge_licence: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.get("")
|
||||||
|
async def list_outage_providers(_=Depends(require_admin_token)):
|
||||||
|
return {"providers": await registry.list_providers()}
|
||||||
|
|
||||||
|
|
||||||
|
@admin_router.put("/{provider_id}")
|
||||||
|
async def set_outage_provider(provider_id: str, body: ProviderToggle, principal: dict = Depends(require_admin_token)):
|
||||||
|
try:
|
||||||
|
return await registry.set_enabled(provider_id, body.enabled, describe_actor(principal),
|
||||||
|
acknowledge_licence=body.acknowledge_licence)
|
||||||
|
except registry.ProviderError as e:
|
||||||
|
return JSONResponse(status_code=e.code, content={"detail": e.message, **e.extra})
|
||||||
@@ -2,8 +2,10 @@ import uuid
|
|||||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from app.config import settings
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal import discord_leases as leases
|
from app.internal import discord_leases as leases
|
||||||
|
from app.internal import discord_oauth
|
||||||
from app.internal import discord_rest
|
from app.internal import discord_rest
|
||||||
from app.internal.audit import write_audit
|
from app.internal.audit import write_audit
|
||||||
from app.internal.auth import (
|
from app.internal.auth import (
|
||||||
@@ -27,9 +29,27 @@ class TokenCreate(BaseModel):
|
|||||||
org_id: Optional[str] = None
|
org_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
def _masked(t: dict) -> dict:
|
# What GET /tokens may return of a bot_tokens doc (server-26#225 S6: an
|
||||||
out = {**t, "token": "•••" + (t.get("token") or "")[-4:]}
|
# allowlist, so a field added to the doc later — a secret, say — never
|
||||||
|
# leaks by default). The token string itself is masked; OAuth client secrets
|
||||||
|
# live in bot_oauth/{id}, never on this doc, and are never returned.
|
||||||
|
_PUBLIC_TOKEN_FIELDS = (
|
||||||
|
"token_id", "name", "org_id", "valid", "guild_ids", "check_error", "validated_at",
|
||||||
|
"application_id", "bot_user_id", "bot_name", "lease", "in_use", "assigned_node_id", "assigned_at",
|
||||||
|
"preferred_for_system_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _masked(t: dict, oauth_ids: Optional[set] = None) -> dict:
|
||||||
|
out = {k: t[k] for k in _PUBLIC_TOKEN_FIELDS if k in t}
|
||||||
|
out["token"] = "•••" + (t.get("token") or "")[-4:]
|
||||||
out["invite_url"] = leases.invite_url(t.get("application_id"))
|
out["invite_url"] = leases.invite_url(t.get("application_id"))
|
||||||
|
members = t.get("guild_ids")
|
||||||
|
out["server_count"] = len(members) if isinstance(members, list) else None
|
||||||
|
# Pool bots stop being offered at the cap (owner decision Q3); BYO bots have none.
|
||||||
|
out["server_cap"] = settings.discord_pool_bot_guild_cap if t.get("org_id") is None else None
|
||||||
|
if oauth_ids is not None:
|
||||||
|
out["oauth_enabled"] = t.get("token_id") in oauth_ids
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +67,8 @@ async def _republish_after_release(lease: Optional[dict]) -> None:
|
|||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_tokens(_: dict = Depends(require_admin_token)):
|
async def list_tokens(_: dict = Depends(require_admin_token)):
|
||||||
"""List all tokens. The actual token string is masked for safety."""
|
"""List all tokens. The actual token string is masked for safety."""
|
||||||
return [_masked(t) for t in await fstore.collection_list("bot_tokens")]
|
oauth_ids = await discord_oauth.enabled_token_ids()
|
||||||
|
return [_masked(t, oauth_ids) for t in await fstore.collection_list("bot_tokens")]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", status_code=201)
|
@router.post("", status_code=201)
|
||||||
@@ -83,7 +104,7 @@ async def refresh_token(token_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
if not await fstore.doc_get("bot_tokens", token_id):
|
if not await fstore.doc_get("bot_tokens", token_id):
|
||||||
raise HTTPException(404, "Token not found.")
|
raise HTTPException(404, "Token not found.")
|
||||||
await discord_rest.refresh_membership([token_id])
|
await discord_rest.refresh_membership([token_id])
|
||||||
return _masked(await fstore.doc_get("bot_tokens", token_id) or {})
|
return _masked(await fstore.doc_get("bot_tokens", token_id) or {}, await discord_oauth.enabled_token_ids())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/flush", status_code=200)
|
@router.post("/flush", status_code=200)
|
||||||
@@ -265,3 +286,57 @@ async def delete_token(token_id: str, _: dict = Depends(require_admin_token)):
|
|||||||
if existing.get("lease"):
|
if existing.get("lease"):
|
||||||
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
|
raise HTTPException(409, "Token is currently leased to a node. Stop its radio channels (or flush) first.")
|
||||||
await fstore.doc_delete("bot_tokens", token_id)
|
await fstore.doc_delete("bot_tokens", token_id)
|
||||||
|
await discord_oauth.clear_secret(token_id)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthSecret(BaseModel):
|
||||||
|
# Length is checked in the handler, not here: a pydantic 422 echoes the
|
||||||
|
# rejected input back in its body.
|
||||||
|
client_secret: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{token_id}/oauth", status_code=200)
|
||||||
|
async def set_oauth_secret(token_id: str, body: OAuthSecret, decoded: dict = Depends(require_admin_token)):
|
||||||
|
"""Set (or replace) this radio bot application's OAuth2 client secret, so
|
||||||
|
"Connect a Discord server" can invite it (server-26#225 S6). Checked with
|
||||||
|
Discord first (client-credentials grant) — a wrong secret is refused and
|
||||||
|
nothing is stored. The secret goes to bot_oauth/{id}, server-side only:
|
||||||
|
it is never echoed, never returned by any route and never logged."""
|
||||||
|
if "/" in token_id:
|
||||||
|
raise HTTPException(400, "Bad id.")
|
||||||
|
tok = await fstore.doc_get("bot_tokens", token_id)
|
||||||
|
if not tok:
|
||||||
|
raise HTTPException(404, "Token not found.")
|
||||||
|
if not tok.get("application_id"):
|
||||||
|
raise HTTPException(409, "DRB doesn't know this bot's application id yet. Re-check the bot, then try again.")
|
||||||
|
secret = (body.client_secret or "").strip()
|
||||||
|
if not (discord_oauth.SECRET_MIN <= len(secret) <= discord_oauth.SECRET_MAX):
|
||||||
|
raise HTTPException(400, "That doesn't look like a Discord client secret. Copy it from the Discord "
|
||||||
|
"Developer Portal → OAuth2.")
|
||||||
|
r = await discord_oauth.check_secret(str(tok["application_id"]), secret)
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
if not r.ok:
|
||||||
|
await write_audit(uid, email, "discord.token.oauth_refused", details={
|
||||||
|
"token_id": token_id, "status": r.status})
|
||||||
|
if r.status in (400, 401):
|
||||||
|
raise HTTPException(400, "Discord didn't accept that client secret for this bot's application. "
|
||||||
|
"Copy it again from the Discord Developer Portal → OAuth2.")
|
||||||
|
raise HTTPException(503, "Couldn't check the secret with Discord right now. Nothing was saved; "
|
||||||
|
"try again in a minute.")
|
||||||
|
await discord_oauth.store_secret(token_id, secret, uid)
|
||||||
|
await write_audit(uid, email, "discord.token.oauth_set", details={"token_id": token_id})
|
||||||
|
logger.info(f"Radio bot {token_id}: OAuth secret set by uid={uid}")
|
||||||
|
return {"ok": True, "token_id": token_id, "oauth_enabled": True,
|
||||||
|
"redirect_uri_configured": discord_oauth.enabled()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{token_id}/oauth", status_code=200)
|
||||||
|
async def clear_oauth_secret(token_id: str, decoded: dict = Depends(require_admin_token)):
|
||||||
|
"""Forget this bot's OAuth secret: Connect stops offering it."""
|
||||||
|
if "/" in token_id:
|
||||||
|
raise HTTPException(400, "Bad id.")
|
||||||
|
removed = await discord_oauth.clear_secret(token_id)
|
||||||
|
uid, email = describe_actor(decoded)
|
||||||
|
if removed:
|
||||||
|
await write_audit(uid, email, "discord.token.oauth_clear", details={"token_id": token_id})
|
||||||
|
return {"ok": True, "token_id": token_id, "oauth_enabled": False}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Weather context for the map (server-26#237): NWS alerts + nearest-METAR wind.
|
||||||
|
|
||||||
|
Public-domain US Government data, cached server-side in app/internal/weather.py;
|
||||||
|
no licence gate (unlike 511NY / outage providers).
|
||||||
|
"""
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app.internal import weather
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/weather", tags=["weather"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/alerts")
|
||||||
|
async def get_alerts(
|
||||||
|
south: Optional[float] = Query(None, ge=-90, le=90),
|
||||||
|
west: Optional[float] = Query(None, ge=-180, le=180),
|
||||||
|
north: Optional[float] = Query(None, ge=-90, le=90),
|
||||||
|
east: Optional[float] = Query(None, ge=-180, le=180),
|
||||||
|
):
|
||||||
|
"""Active NWS alerts with polygons, optionally filtered to those intersecting a bbox.
|
||||||
|
|
||||||
|
``zone_only`` holds alerts whose zones could not be resolved to a polygon; it
|
||||||
|
is never bbox-filtered (there is no shape to test) and never dropped.
|
||||||
|
"""
|
||||||
|
box = (south, west, north, east)
|
||||||
|
if any(v is not None for v in box) and any(v is None for v in box):
|
||||||
|
raise HTTPException(400, "give all of south, west, north, east or none")
|
||||||
|
if south is not None and (south > north or west > east):
|
||||||
|
raise HTTPException(400, "bbox must satisfy south<=north and west<=east")
|
||||||
|
feed = await weather.alerts.get()
|
||||||
|
items = feed.alerts
|
||||||
|
if south is not None:
|
||||||
|
items = [a for a in items if weather.intersects(a.get("bbox"), south, west, north, east)]
|
||||||
|
return {
|
||||||
|
"alerts": items,
|
||||||
|
"zone_only": feed.zone_only,
|
||||||
|
"fetched_at": feed.fetched_at,
|
||||||
|
"error": feed.error,
|
||||||
|
"credit": weather.CREDIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wind")
|
||||||
|
async def get_wind(
|
||||||
|
lat: float = Query(..., ge=-90, le=90),
|
||||||
|
lon: float = Query(..., ge=-180, le=180),
|
||||||
|
):
|
||||||
|
"""Wind from the nearest METAR station, or ``available: false`` with a reason."""
|
||||||
|
return await weather.nearest_wind(lat, lon)
|
||||||
+3384
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
Channel list and presence without the main bot (server-26#225 S5), plus the
|
||||||
|
interactive re-check of a cached "denied" answer (server-26#251).
|
||||||
|
|
||||||
|
* discord_rest.directory_from_snapshot / guild_directory: a radio bot's REST
|
||||||
|
snapshot becomes the same directory the DRB server bot pushes.
|
||||||
|
* _org_guilds: a linked server stays visible with only a radio bot in it; a
|
||||||
|
fallback-org server still needs the server bot; REST never discovers a
|
||||||
|
server or creates a ``discord_guilds`` doc.
|
||||||
|
* join_options / start_node_stream: a missing list is read inline from a
|
||||||
|
member radio bot, a stale one in the background; the server bot's fresh
|
||||||
|
list is never overwritten; nothing is written onto a server that was
|
||||||
|
unlinked meanwhile.
|
||||||
|
* start_stream(interactive=True) re-reads a "denied" snapshot older than
|
||||||
|
RECHECK_DENIED_AFTER_SECONDS once; the reconciler never does.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.routers import discord as discord_router
|
||||||
|
from app.models import CommandPayload
|
||||||
|
from app.routers import discord_web as web
|
||||||
|
from app.routers import nodes as nodes_router
|
||||||
|
from tests.test_discord_perm_check import ( # noqa: F401 (fs = fixture)
|
||||||
|
ADMIN, GUILD, ORG, OTHER, OWNER, SYS, _guild, _snap, _token, fs, run,
|
||||||
|
)
|
||||||
|
|
||||||
|
LINKED = "300" # linked to ORG, never seen by the server bot
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(seconds: float) -> str:
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(seconds=seconds)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_channels():
|
||||||
|
return [
|
||||||
|
{"id": "9", "name": "Radio", "type": 4, "position": 1},
|
||||||
|
{"id": "8", "name": "Admin", "type": 4, "position": 0},
|
||||||
|
{"id": "3001", "name": "dispatch", "type": 2, "position": 1, "parent_id": "9", "permission_overwrites": []},
|
||||||
|
{"id": "3002", "name": "fireground", "type": 2, "position": 0, "parent_id": "9", "permission_overwrites": []},
|
||||||
|
{"id": "3003", "name": "staff", "type": 2, "position": 0, "parent_id": "8", "permission_overwrites": []},
|
||||||
|
{"id": "3004", "name": "lobby", "type": 2, "position": 5, "permission_overwrites": []},
|
||||||
|
{"id": "3005", "name": "general", "type": 0, "position": 0, "permission_overwrites": []},
|
||||||
|
{"id": "3006", "name": "town hall", "type": 13, "position": 0, "permission_overwrites": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _rest_snap(tok_id, guild_id=LINKED, status=discord_rest.OK):
|
||||||
|
if status != discord_rest.OK:
|
||||||
|
return discord_rest.GuildSnapshot(token_id=tok_id, guild_id=guild_id, status=status, error="x")
|
||||||
|
return discord_rest.GuildSnapshot(
|
||||||
|
token_id=tok_id, guild_id=guild_id, status=discord_rest.OK, bot_user_id=f"u-{tok_id}",
|
||||||
|
guild={**_guild(), "id": guild_id, "name": "Station 9"}, member={"roles": []},
|
||||||
|
channels=_rest_channels())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dfs(fs, monkeypatch):
|
||||||
|
"""The perm-check fixture plus a linked server only radio bots know."""
|
||||||
|
fs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": ORG, "linked_at": _ago(60)})
|
||||||
|
fs.dir_mode = {} # token_id -> "ok" | "not_member" | "unknown"
|
||||||
|
|
||||||
|
async def _snapshot(token_doc, guild_id, *, refresh=False):
|
||||||
|
tid = token_doc["token_id"]
|
||||||
|
fs.snap_calls.append((tid, guild_id, refresh))
|
||||||
|
if guild_id != LINKED:
|
||||||
|
mode = fs.snap_mode.get(tid, "ok")
|
||||||
|
return _snap(tid, muted=(mode == "muted"))
|
||||||
|
mode = fs.dir_mode.get(tid, "ok")
|
||||||
|
status = {"ok": discord_rest.OK, "not_member": discord_rest.NOT_MEMBER}.get(mode, discord_rest.UNKNOWN)
|
||||||
|
return _rest_snap(tid, status=status)
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
|
||||||
|
web._directory_tasks.clear()
|
||||||
|
return fs
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain():
|
||||||
|
while web._directory_tasks:
|
||||||
|
await asyncio.gather(*list(web._directory_tasks.values()), return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ids(opts):
|
||||||
|
return [g["guild_id"] for g in opts["guilds"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# discord_rest — directory from a snapshot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_directory_from_snapshot_matches_the_server_bots_shape_and_order():
|
||||||
|
d = discord_rest.directory_from_snapshot(_rest_snap("t1"))
|
||||||
|
assert d["name"] == "Station 9"
|
||||||
|
assert [(c["channel_id"], c["category"], c["position"]) for c in d["voice_channels"]] == [
|
||||||
|
("3004", None, 0), # no category first
|
||||||
|
("3003", "Admin", 1), # categories by position
|
||||||
|
("3002", "Radio", 2), # channels by position inside one
|
||||||
|
("3001", "Radio", 3),
|
||||||
|
] # text and stage channels are not listed
|
||||||
|
assert set(d["voice_channels"][0]) == {"channel_id", "name", "category", "position"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_from_snapshot_is_none_unless_ok():
|
||||||
|
assert discord_rest.directory_from_snapshot(None) is None
|
||||||
|
for status in (discord_rest.NOT_MEMBER, discord_rest.UNKNOWN, discord_rest.INVALID):
|
||||||
|
assert discord_rest.directory_from_snapshot(_rest_snap("t1", status=status)) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_caps_channels_and_name_length():
|
||||||
|
snap = _rest_snap("t1")
|
||||||
|
snap.channels = [{"id": str(5000 + i), "name": "x" * 300, "type": 2, "position": i} for i in range(250)]
|
||||||
|
d = discord_rest.directory_from_snapshot(snap)
|
||||||
|
assert len(d["voice_channels"]) == discord_rest.DIRECTORY_MAX_CHANNELS
|
||||||
|
assert len(d["voice_channels"][0]["name"]) == 100
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guild_directory_tries_the_next_bot_and_reports_not_member(dfs):
|
||||||
|
_token(dfs, "a", guilds=(LINKED,))
|
||||||
|
_token(dfs, "b", guilds=(LINKED,))
|
||||||
|
docs = [{**dfs.get("bot_tokens", t)} for t in ("a", "b")]
|
||||||
|
dfs.dir_mode["a"] = "not_member"
|
||||||
|
got = await discord_rest.guild_directory(docs, LINKED)
|
||||||
|
assert got.status == discord_rest.OK and got.token_id == "b" and len(got.voice_channels) == 4
|
||||||
|
dfs.dir_mode["b"] = "not_member"
|
||||||
|
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.NOT_MEMBER
|
||||||
|
dfs.dir_mode["b"] = "unknown"
|
||||||
|
assert (await discord_rest.guild_directory(docs, LINKED)).status == discord_rest.UNKNOWN
|
||||||
|
assert (await discord_rest.guild_directory([], LINKED)).status == discord_rest.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Which servers an org sees
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_linked_server_with_only_a_radio_bot_is_listed_with_its_channels(dfs):
|
||||||
|
"""Owner requirement 1: invite only the radio bot, no main bot."""
|
||||||
|
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert g["synced"] is True and g["directory_source"] == "radio_bot" and g["main_bot_present"] is False
|
||||||
|
assert [c["name"] for c in g["voice_channels"]] == ["lobby", "staff", "fireground", "dispatch"]
|
||||||
|
assert g["name"] == "Station 9" and g["linked"] is True and g["bot"]["ok"] is True
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["org_id"] == ORG and "bot_present" not in doc # never the server bot's field
|
||||||
|
assert doc["radio_bot_present"] is True and doc["directory_source"] == "radio_bot"
|
||||||
|
assert doc["guild_name"] == "Station 9" and len(doc["voice_channels"]) == 4
|
||||||
|
assert "secret" not in repr(opts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_linked_server_without_any_radio_bot_keeps_todays_behaviour(dfs):
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert g["synced"] is False and g["bot"]["ok"] is False # invite links, as before
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("tok", [
|
||||||
|
{"org": OTHER}, # another org's branded bot
|
||||||
|
{"guilds": None}, # membership never checked
|
||||||
|
{"valid": False},
|
||||||
|
])
|
||||||
|
def test_only_a_usable_member_bot_keeps_a_server_without_the_main_bot(dfs, tok):
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": False})
|
||||||
|
kw = {"guilds": (LINKED,), **tok}
|
||||||
|
_token(dfs, "x", **kw)
|
||||||
|
assert LINKED not in _ids(run(web.join_options("n1", OWNER)))
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_bot_leaving_keeps_server_while_a_radio_bot_is_in_it(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(GUILD,))
|
||||||
|
run(web.put_guild_directory(GUILD, web.GuildDirectory(present=False), {"service": True}))
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
assert GUILD in _ids(opts)
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == GUILD)
|
||||||
|
assert g["main_bot_present"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_server_is_never_discovered_by_a_radio_bot(dfs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
|
||||||
|
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": False, "voice_channels": []})
|
||||||
|
_token(dfs, "t1", guilds=("400", "401", LINKED)) # also in a server DRB has no doc for
|
||||||
|
ids = _ids(run(web.join_options("n1", OWNER)))
|
||||||
|
assert "400" not in ids and "401" not in ids
|
||||||
|
assert dfs.get("discord_guilds", "401") is None # REST never creates a doc
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] in ("400", "401")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_server_list_is_never_read_over_rest(dfs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", ORG)
|
||||||
|
dfs.put("discord_guilds", "400", {"guild_id": "400", "bot_present": True, "voice_channels": None,
|
||||||
|
"directory_at": _ago(99999)})
|
||||||
|
_token(dfs, "t1", guilds=("400",))
|
||||||
|
assert "400" in _ids(run(web.join_options("n1", OWNER)))
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == "400"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# When the list is read, and who wins
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_fresh_main_bot_list_is_never_read_over_rest(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
|
||||||
|
"directory_source": "main_bot", "directory_at": _ago(600),
|
||||||
|
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
|
||||||
|
opts = run(web.join_options("n1", OWNER))
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert g["directory_source"] == "main_bot" and len(g["voice_channels"]) == 1
|
||||||
|
assert not [c for c in dfs.snap_calls if c[1] == LINKED]
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_list_is_refreshed_in_the_background(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
old = [{"channel_id": "3001", "name": "old name"}]
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": old,
|
||||||
|
"directory_source": "radio_bot",
|
||||||
|
"directory_at": _ago(settings.discord_directory_refresh_seconds + 5)})
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
await _drain()
|
||||||
|
return opts
|
||||||
|
opts = run(go())
|
||||||
|
g = next(g for g in opts["guilds"] if g["guild_id"] == LINKED)
|
||||||
|
assert [c["name"] for c in g["voice_channels"]] == ["old name"] # stored list shown meanwhile
|
||||||
|
assert len(dfs.get("discord_guilds", LINKED)["voice_channels"]) == 4 # and replaced right after
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_main_bot_list_is_taken_over_by_a_radio_bot(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "bot_present": True,
|
||||||
|
"directory_at": _ago(web.MAIN_BOT_STALE_SECONDS + 60),
|
||||||
|
"voice_channels": [{"channel_id": "3001", "name": "dispatch"}]})
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await web.join_options("n1", OWNER)
|
||||||
|
await _drain()
|
||||||
|
run(go())
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["directory_source"] == "radio_bot" and len(doc["voice_channels"]) == 4
|
||||||
|
assert doc["bot_present"] is True # not ours to change
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_member_marks_presence_and_keeps_the_list(dfs):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
keep = [{"channel_id": "3001", "name": "dispatch"}]
|
||||||
|
dfs.put("discord_guilds", LINKED, {**dfs.get("discord_guilds", LINKED), "voice_channels": keep,
|
||||||
|
"directory_source": "radio_bot", "directory_at": _ago(99999)})
|
||||||
|
dfs.dir_mode["t1"] = "not_member"
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
await web.join_options("n1", OWNER)
|
||||||
|
await _drain()
|
||||||
|
run(go())
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["radio_bot_present"] is False and doc["voice_channels"] == keep
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_never_writes_onto_a_server_unlinked_meanwhile(dfs, monkeypatch):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
real = discord_rest.guild_directory
|
||||||
|
|
||||||
|
async def _unlink_then_answer(docs, guild_id, **kw):
|
||||||
|
dfs.put("discord_guilds", LINKED, {"guild_id": LINKED, "org_id": None, "unlinked_at": _ago(0)})
|
||||||
|
return await real(docs, guild_id, **kw)
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_directory", _unlink_then_answer)
|
||||||
|
g = dict(dfs.get("discord_guilds", LINKED), org_id=ORG)
|
||||||
|
assert run(web._refresh_directory(g, ORG, {"t1": dfs.get("bot_tokens", "t1")})) is None
|
||||||
|
assert "voice_channels" not in dfs.get("discord_guilds", LINKED)
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_discord_never_holds_the_picker_past_the_budget(dfs, monkeypatch):
|
||||||
|
_token(dfs, "t1", guilds=(LINKED,))
|
||||||
|
monkeypatch.setattr(settings, "discord_perm_check_budget_seconds", 0.05)
|
||||||
|
|
||||||
|
async def _slow(docs, guild_id, **kw):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_directory", _slow)
|
||||||
|
|
||||||
|
async def go():
|
||||||
|
t0 = time.monotonic()
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
return opts, time.monotonic() - t0
|
||||||
|
opts, took = run(go())
|
||||||
|
assert took < 1.0
|
||||||
|
assert next(g for g in opts["guilds"] if g["guild_id"] == LINKED)["synced"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_push_marks_main_bot_and_drops_cached_snapshots(dfs, monkeypatch):
|
||||||
|
dropped = []
|
||||||
|
monkeypatch.setattr(discord_rest, "invalidate", lambda token_id=None, guild_id=None: dropped.append(
|
||||||
|
(token_id, guild_id)) or 0)
|
||||||
|
run(web.put_guild_directory(LINKED, web.GuildDirectory(name="S9", voice_channels=[
|
||||||
|
web.VoiceChannelIn(channel_id="3001", name="dispatch")]), {"service": True}))
|
||||||
|
doc = dfs.get("discord_guilds", LINKED)
|
||||||
|
assert doc["directory_source"] == "main_bot" and doc["bot_present"] is True and doc["org_id"] == ORG
|
||||||
|
assert dropped == [(None, LINKED)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Starting from the picker without the main bot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_start_checks_the_channel_against_the_radio_bots_list(dfs):
|
||||||
|
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
|
||||||
|
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id="3001"), OWNER))
|
||||||
|
assert doc["ok"] is True and doc["token_id"] == "byo"
|
||||||
|
s = dfs.get("discord_streams", doc["stream_id"])
|
||||||
|
assert s["channel_name"] == "dispatch" and s["guild_name"] == "Station 9" and s["node_id"] == "n1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_refuses_a_channel_the_radio_bot_does_not_list(dfs):
|
||||||
|
_token(dfs, "byo", org=ORG, guilds=(LINKED,))
|
||||||
|
for bad in ("3005", "3999"): # a text channel; no such channel
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=LINKED, channel_id=bad), OWNER))
|
||||||
|
assert e.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# server-26#251 — an interactive start re-reads a stale "denied"
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def aging(fs, monkeypatch):
|
||||||
|
"""Snapshots that are ``fs.age`` seconds old unless refreshed; ``muted``
|
||||||
|
until ``fs.fixed`` (the owner fixed the permission in Discord)."""
|
||||||
|
fs.age, fs.fixed = 60.0, False
|
||||||
|
|
||||||
|
async def _snapshot(token_doc, guild_id, *, refresh=False):
|
||||||
|
tid = token_doc["token_id"]
|
||||||
|
fs.snap_calls.append((tid, guild_id, refresh))
|
||||||
|
s = _snap(tid, muted=not (refresh and fs.fixed))
|
||||||
|
s.fetched_at = time.time() - (0 if refresh else fs.age)
|
||||||
|
return s
|
||||||
|
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
|
||||||
|
_token(fs, "t1", name="DRB Radio 2")
|
||||||
|
return fs
|
||||||
|
|
||||||
|
|
||||||
|
def _join():
|
||||||
|
return discord_router.start_stream(discord_router.StreamStart(guild_id=GUILD, channel_id="1001",
|
||||||
|
system_id=SYS), {"service": True})
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_after_fixing_the_permission_succeeds_at_once(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
r = run(_join())
|
||||||
|
assert r["token_id"] == "t1"
|
||||||
|
assert ("t1", GUILD, True) in aging.snap_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_still_denied_after_the_recheck_is_refused(aging):
|
||||||
|
r = run(_join())
|
||||||
|
assert r.status_code == 409 and b"can't Speak in #dispatch" in r.body
|
||||||
|
assert [c for c in aging.snap_calls if c[2]] == [("t1", GUILD, True)] # once
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fresh_denied_answer_is_not_asked_again(aging):
|
||||||
|
aging.age = 1.0
|
||||||
|
aging.fixed = True
|
||||||
|
r = run(_join())
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert not [c for c in aging.snap_calls if c[2]]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reconciler_never_rechecks(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
with pytest.raises(streams.StreamError) as e:
|
||||||
|
await streams.start_stream(org_id=ORG, guild_id=GUILD, channel_id="1001", system_id=SYS)
|
||||||
|
assert e.value.code == "missing_permissions"
|
||||||
|
assert not [c for c in aging.snap_calls if c[2]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_web_picker_start_is_interactive(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
doc = run(web.start_node_stream("n1", web.NodeStreamStart(guild_id=GUILD, channel_id="1001"), OWNER))
|
||||||
|
assert doc["ok"] is True and doc["token_id"] == "t1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_discord_join_command_is_interactive(aging):
|
||||||
|
aging.fixed = True
|
||||||
|
out = run(nodes_router.send_command("n1", CommandPayload(action="discord_join", guild_id=GUILD,
|
||||||
|
channel_id="1001"), ADMIN))
|
||||||
|
assert out["ok"] is True and out["token_id"] == "t1"
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
"""
|
||||||
|
Per-bot OAuth "Connect a Discord server" (server-26#225 S6).
|
||||||
|
|
||||||
|
Discord is an httpx.MockTransport; Firestore is tests/fake_firestore.py.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_guild_link as glink
|
||||||
|
from app.internal import discord_oauth as oauth
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.routers import discord_oauth as router
|
||||||
|
from app.routers import tokens as tokens_router
|
||||||
|
from tests.fake_firestore import FakeFirestore
|
||||||
|
|
||||||
|
REDIRECT = "https://drb.example.test/discord/connected"
|
||||||
|
SECRET_A = "s3cr3t-app-a-0123456789abcdefXYZ"
|
||||||
|
SECRET_POOL = "s3cr3t-pool-0123456789abcdefXYZ"
|
||||||
|
GUILD = "100000000000000001"
|
||||||
|
OWNER_A = {"uid": "owner-a", "email": "a@example.test", "org_id": "org-a", "org_role": "owner"}
|
||||||
|
OWNER_B = {"uid": "owner-b", "email": "b@example.test", "org_id": "org-b", "org_role": "owner"}
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
|
||||||
|
|
||||||
|
|
||||||
|
class Discord:
|
||||||
|
"""Scripted OAuth endpoints. ``token_reply`` answers POST /oauth2/token."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls: list[httpx.Request] = []
|
||||||
|
self.token_reply = httpx.Response(200, json={
|
||||||
|
"access_token": "user-access-token", "token_type": "Bearer", "scope": "bot",
|
||||||
|
"guild": {"id": GUILD, "name": "Station 7"}})
|
||||||
|
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
self.calls.append(request)
|
||||||
|
path = request.url.path.removeprefix("/api/v10")
|
||||||
|
if path == "/oauth2/token":
|
||||||
|
r = self.token_reply
|
||||||
|
return r(request) if callable(r) else r
|
||||||
|
if path == "/oauth2/token/revoke":
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
return httpx.Response(404, json={"code": 0})
|
||||||
|
|
||||||
|
def form(self, i: int) -> dict:
|
||||||
|
return {k: v[0] for k, v in parse_qs(self.calls[i].content.decode()).items()}
|
||||||
|
|
||||||
|
def basic(self, i: int) -> str:
|
||||||
|
raw = self.calls[i].headers["authorization"].split(" ", 1)[1]
|
||||||
|
return base64.b64decode(raw).decode()
|
||||||
|
|
||||||
|
def paths(self):
|
||||||
|
return [r.url.path.removeprefix("/api/v10") for r in self.calls]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def discord(monkeypatch):
|
||||||
|
d = Discord()
|
||||||
|
discord_rest.reset()
|
||||||
|
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(d))
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fs(monkeypatch, discord):
|
||||||
|
fake = FakeFirestore.install(monkeypatch)
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||||
|
monkeypatch.setattr(settings, "discord_pool_bot_guild_cap", 90)
|
||||||
|
for org in ("org-a", "org-b", "founding"):
|
||||||
|
fake.put("organizations", org, {"org_id": org, "name": org})
|
||||||
|
fake.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "owner"})
|
||||||
|
fake.put("org_members", "owner-b", {"uid": "owner-b", "org_id": "org-b", "org_role": "owner"})
|
||||||
|
# org-a's own bot, two pool bots (one full), org-b's own bot.
|
||||||
|
fake.put("bot_tokens", "own-a", {"token_id": "own-a", "name": "Riverton Radio", "token": "tok-own-a",
|
||||||
|
"org_id": "org-a", "application_id": "111", "guild_ids": []})
|
||||||
|
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "tok-pool-1",
|
||||||
|
"org_id": None, "application_id": "201", "guild_ids": ["9"] * 40})
|
||||||
|
fake.put("bot_tokens", "pool-2", {"token_id": "pool-2", "name": "DRB Radio 2", "token": "tok-pool-2",
|
||||||
|
"org_id": None, "application_id": "202", "guild_ids": ["9"] * 90})
|
||||||
|
fake.put("bot_tokens", "own-b", {"token_id": "own-b", "name": "Harbor Radio", "token": "tok-own-b",
|
||||||
|
"org_id": "org-b", "application_id": "333", "guild_ids": []})
|
||||||
|
for tid, secret in (("own-a", SECRET_A), ("pool-1", SECRET_POOL), ("pool-2", SECRET_POOL),
|
||||||
|
("own-b", SECRET_A)):
|
||||||
|
fake.put(oauth.OAUTH, tid, {"token_id": tid, "client_secret": secret, "enabled": True})
|
||||||
|
for lim in (router.start_limiter, router.complete_limiter):
|
||||||
|
lim._log.clear()
|
||||||
|
stopped: list = []
|
||||||
|
|
||||||
|
async def _stop_guild(guild_id, reason, channel_id=None, stream_id=None):
|
||||||
|
stopped.append((guild_id, reason))
|
||||||
|
return [f"{guild_id}:1"]
|
||||||
|
monkeypatch.setattr(streams, "stop_guild", _stop_guild)
|
||||||
|
fake.stopped = stopped
|
||||||
|
|
||||||
|
async def _voice_check(**kw):
|
||||||
|
return {"enabled": True, "channels": [
|
||||||
|
{"channel_id": "1", "name": "dispatch", "state": "ready", "bots": [{"fix": None}]},
|
||||||
|
{"channel_id": "2", "name": "fireground", "state": "needs_fix",
|
||||||
|
"bots": [{"fix": "In Discord: Edit Channel → Permissions → add Riverton Radio → allow Speak, "
|
||||||
|
"then try again."}]},
|
||||||
|
{"channel_id": "3", "name": "ops", "state": "unknown", "bots": []},
|
||||||
|
]}
|
||||||
|
monkeypatch.setattr(router, "voice_check", _voice_check)
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
|
def _audit(fake, action):
|
||||||
|
return [e for e in (fake.data.get("audit_log") or {}).values() if e["action"] == action]
|
||||||
|
|
||||||
|
|
||||||
|
def _status(resp):
|
||||||
|
return getattr(resp, "status_code", 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _body(resp):
|
||||||
|
return json.loads(resp.body) if hasattr(resp, "body") else resp
|
||||||
|
|
||||||
|
|
||||||
|
async def _start(decoded=OWNER_A, **kw):
|
||||||
|
out = await router.oauth_start(router.StartBody(**kw), org_id=None, decoded=decoded)
|
||||||
|
q = parse_qs(urlparse(out["authorize_url"]).query)
|
||||||
|
return out, {k: v[0] for k, v in q.items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _complete(state, decoded=OWNER_A, code="the-code"):
|
||||||
|
return await router.oauth_complete(router.CompleteBody(code=code, state=state), decoded=decoded)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Off switch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_every_route_is_404_while_redirect_uri_is_empty(fs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", "")
|
||||||
|
for call in (router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A),
|
||||||
|
router.oauth_start(router.StartBody(), org_id=None, decoded=OWNER_A),
|
||||||
|
_complete("anything")):
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await call
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_404_when_no_usable_bot_has_a_secret(fs):
|
||||||
|
for tid in ("own-a", "pool-1", "pool-2"):
|
||||||
|
fs.data[oauth.OAUTH].pop(tid)
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Which bot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_org_bot_first_then_least_full_pool_bot_under_cap(fs):
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"] == {"name": "Riverton Radio", "reason": "org_bot", "own_bot": True}
|
||||||
|
fs.data[oauth.OAUTH].pop("own-a")
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "pool"
|
||||||
|
# pool-2 is at the cap (90): never offered, even when it's the only one left
|
||||||
|
fs.data[oauth.OAUTH].pop("pool-1")
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_preferred_bot_for_the_nodes_system_wins(fs):
|
||||||
|
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s1"})
|
||||||
|
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
|
||||||
|
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1" and out["bot"]["reason"] == "preferred"
|
||||||
|
# another org's node is a 404
|
||||||
|
fs.put("nodes", "nb", {"node_id": "nb", "org_id": "org-b", "assigned_system_id": "s1"})
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id="nb", token_id=None, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_system_id_invites_that_systems_preferred_bot(fs):
|
||||||
|
"""The S7 picker's "bot for a system" choice: a platform-pinned pool bot
|
||||||
|
is invited by system_id, never quietly swapped for Automatic."""
|
||||||
|
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-1"})
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s1")
|
||||||
|
assert out["bot"] == {"name": "DRB Radio 1", "reason": "preferred", "own_bot": False}
|
||||||
|
out, q = await _start(system_id="s1", return_to="/settings")
|
||||||
|
assert q["client_id"] == "201" and out["bot"]["reason"] == "preferred"
|
||||||
|
assert _audit(fs, "discord.oauth.start")[-1]["details"]["system_id"] == "s1"
|
||||||
|
# a shared system (no org_id) serves every org, as on the node path
|
||||||
|
fs.put("systems", "shared", {"system_id": "shared", "org_id": None, "preferred_token_id": "pool-1"})
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_B,
|
||||||
|
system_id="shared")
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1"
|
||||||
|
# system_id outranks node_id
|
||||||
|
fs.put("nodes", "n1", {"node_id": "n1", "org_id": "org-a", "assigned_system_id": "s2"})
|
||||||
|
fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-a"})
|
||||||
|
out = await router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER_A, system_id="s1")
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_system_id_of_another_org_or_unknown_is_404(fs):
|
||||||
|
fs.put("systems", "sb", {"system_id": "sb", "org_id": "org-b", "preferred_token_id": "pool-1"})
|
||||||
|
for sid in ("sb", "nope"):
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid)
|
||||||
|
assert e.value.status_code == 404 and e.value.detail == "System not found."
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await _start(system_id=sid)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
assert not _audit(fs, "discord.oauth.start")
|
||||||
|
# a platform admin acting for org-b may use it
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id="org-b", decoded=ADMIN, system_id="sb")
|
||||||
|
assert out["bot"]["name"] == "DRB Radio 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_system_id_whose_bot_cannot_connect_is_404_not_a_swap(fs):
|
||||||
|
# pool-2 is at the cap; another org's bot pinned (bad data); no secret
|
||||||
|
fs.put("systems", "s1", {"system_id": "s1", "org_id": "org-a", "preferred_token_id": "pool-2"})
|
||||||
|
fs.put("systems", "s2", {"system_id": "s2", "org_id": "org-a", "preferred_token_id": "own-b"})
|
||||||
|
fs.put("systems", "s3", {"system_id": "s3", "org_id": "org-a", "preferred_token_id": "pool-1"})
|
||||||
|
fs.data[oauth.OAUTH].pop("pool-1")
|
||||||
|
for sid in ("s1", "s2", "s3"):
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id=sid)
|
||||||
|
assert e.value.status_code == 404 and "Harbor" not in str(e.value.detail)
|
||||||
|
# no preferred bot at all: the usual order (org's own bot first)
|
||||||
|
fs.put("systems", "s4", {"system_id": "s4", "org_id": "org-a"})
|
||||||
|
out = await router.oauth_options(node_id=None, token_id=None, org_id=None, decoded=OWNER_A, system_id="s4")
|
||||||
|
assert out["bot"]["reason"] == "org_bot"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_explicit_bot_must_be_usable_by_the_org(fs):
|
||||||
|
out = await router.oauth_options(node_id=None, token_id="pool-1", org_id=None, decoded=OWNER_A)
|
||||||
|
assert out["bot"]["reason"] == "chosen"
|
||||||
|
for tid in ("own-b", "pool-2", "nope"): # other org's bot, full pool bot, unknown
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await router.oauth_options(node_id=None, token_id=tid, org_id=None, decoded=OWNER_A)
|
||||||
|
assert e.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_start_returns_the_bot_invite_and_stores_only_a_hash_of_the_state(fs):
|
||||||
|
out, q = await _start(return_to="/nodes/n1")
|
||||||
|
assert urlparse(out["authorize_url"]).netloc == "discord.com"
|
||||||
|
assert q["client_id"] == "111" and q["scope"] == "bot" and q["response_type"] == "code"
|
||||||
|
assert q["permissions"] == str((1 << 10) | (1 << 20) | (1 << 21))
|
||||||
|
assert q["redirect_uri"] == REDIRECT
|
||||||
|
nonce = q["state"]
|
||||||
|
assert len(nonce) >= 40
|
||||||
|
assert fs.get(oauth.STATES, nonce) is None # the nonce itself is never stored
|
||||||
|
st = fs.get(oauth.STATES, oauth.state_key(nonce))
|
||||||
|
assert st["org_id"] == "org-a" and st["uid"] == "owner-a" and st["token_id"] == "own-a"
|
||||||
|
assert st["return_to"] == "/nodes/n1" and st["used_at"] is None
|
||||||
|
assert len(_audit(fs, "discord.oauth.start")) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", ["https://evil.test/x", "//evil.test/x", "/\\evil.test", "nodes", "/a\nb"])
|
||||||
|
async def test_return_to_is_a_same_site_path_only(fs, bad):
|
||||||
|
_, q = await _start(return_to=bad)
|
||||||
|
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["return_to"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_start_rate_limited_per_user(fs):
|
||||||
|
for _ in range(5):
|
||||||
|
await _start()
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await _start()
|
||||||
|
assert e.value.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Complete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_complete_links_the_server_from_the_exchange_and_reports_channels(fs, discord):
|
||||||
|
_, q = await _start(return_to="/settings/discord")
|
||||||
|
out = await _complete(q["state"])
|
||||||
|
assert out["ok"] and out["guild_id"] == GUILD and out["guild_name"] == "Station 7"
|
||||||
|
assert out["org_id"] == "org-a" and out["bot_name"] == "Riverton Radio"
|
||||||
|
assert (out["channels_ready"], out["channels_blocked"]) == (1, 1)
|
||||||
|
assert out["channels"][1]["fix"].startswith("In Discord")
|
||||||
|
assert out["return_to"] == "/settings/discord"
|
||||||
|
|
||||||
|
g = fs.get(streams.GUILDS, GUILD)
|
||||||
|
assert g["org_id"] == "org-a" and g["linked_via"] == "oauth" and g["linked_by"] == "owner-a"
|
||||||
|
assert g["linked_with_token_id"] == "own-a"
|
||||||
|
assert fs.get("bot_tokens", "own-a")["guild_ids"] == [GUILD]
|
||||||
|
# the exchange used that bot application's own credentials and the exact redirect URI
|
||||||
|
assert discord.paths() == ["/oauth2/token", "/oauth2/token/revoke"]
|
||||||
|
assert discord.basic(0) == f"111:{SECRET_A}"
|
||||||
|
assert discord.form(0) == {"grant_type": "authorization_code", "code": "the-code", "redirect_uri": REDIRECT}
|
||||||
|
assert discord.form(1)["token"] == "user-access-token" # revoked straight away
|
||||||
|
assert fs.get(oauth.STATES, oauth.state_key(q["state"]))["used_at"]
|
||||||
|
[entry] = _audit(fs, "discord.guild.link")
|
||||||
|
assert entry["details"]["via"] == "oauth" and entry["details"]["guild_id"] == GUILD
|
||||||
|
|
||||||
|
|
||||||
|
async def test_state_is_single_use(fs):
|
||||||
|
_, q = await _start()
|
||||||
|
assert _status(await _complete(q["state"])) == 200
|
||||||
|
again = await _complete(q["state"])
|
||||||
|
assert _status(again) == 409 and _body(again)["code"] == "state_used"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_another_users_state_is_refused_and_burned(fs):
|
||||||
|
_, q = await _start(OWNER_A)
|
||||||
|
resp = await _complete(q["state"], decoded=OWNER_B) # login-CSRF shape
|
||||||
|
assert _status(resp) == 403 and _body(resp)["code"] == "wrong_user"
|
||||||
|
assert fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
assert _status(await _complete(q["state"], decoded=OWNER_A)) == 409 # consumed
|
||||||
|
|
||||||
|
|
||||||
|
async def test_expired_and_unknown_states(fs):
|
||||||
|
_, q = await _start()
|
||||||
|
key = oauth.state_key(q["state"])
|
||||||
|
past = (datetime.now(timezone.utc) - timedelta(seconds=1)).isoformat()
|
||||||
|
fs.put(oauth.STATES, key, {**fs.get(oauth.STATES, key), "expires_at": past})
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 410 and fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
assert _status(await _complete("not-a-state")) == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_caller_who_is_no_longer_owner_is_refused(fs, discord):
|
||||||
|
_, q = await _start()
|
||||||
|
fs.put("org_members", "owner-a", {"uid": "owner-a", "org_id": "org-a", "org_role": "member"})
|
||||||
|
resp = await _complete(q["state"]) # token claims still say owner
|
||||||
|
assert _status(resp) == 403 and _body(resp)["code"] == "not_owner"
|
||||||
|
assert discord.calls == [] and fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_exchange_without_a_guild_links_nothing(fs, discord):
|
||||||
|
discord.token_reply = httpx.Response(200, json={"access_token": "x", "scope": "bot"})
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 400 and _body(resp)["code"] == "no_guild"
|
||||||
|
assert fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rejected_code_and_unreachable_discord(fs, discord):
|
||||||
|
discord.token_reply = httpx.Response(400, json={"error": "invalid_grant"})
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 400 and _body(resp)["code"] == "code_rejected"
|
||||||
|
|
||||||
|
def _down(request):
|
||||||
|
raise httpx.ConnectError("down", request=request)
|
||||||
|
discord.token_reply = _down
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 502 and fs.get(streams.GUILDS, GUILD) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_server_linked_to_another_org_is_never_moved(fs):
|
||||||
|
fs.put(streams.GUILDS, GUILD, {"guild_id": GUILD, "org_id": "org-b", "guild_name": "Harbor"})
|
||||||
|
_, q = await _start()
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 409 and _body(resp)["code"] == "linked_elsewhere"
|
||||||
|
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-b"
|
||||||
|
assert fs.stopped == []
|
||||||
|
assert [e["details"]["via"] for e in _audit(fs, "discord.guild.link_refused")] == ["oauth"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_linking_away_from_the_fallback_org_stops_its_streams(fs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "founding")
|
||||||
|
_, q = await _start()
|
||||||
|
out = await _complete(q["state"])
|
||||||
|
assert out["streams_stopped"] == 1 and fs.stopped == [(GUILD, glink.RELINK_STOP_REASON)]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_permission_check_timeout_leaves_the_link_standing(fs, monkeypatch):
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_link_check_seconds", 0.05)
|
||||||
|
|
||||||
|
async def _slow(**kw):
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
monkeypatch.setattr(router, "voice_check", _slow)
|
||||||
|
_, q = await _start()
|
||||||
|
out = await _complete(q["state"])
|
||||||
|
assert out["ok"] and out["checked"] is False and out["channels_ready"] is None
|
||||||
|
assert fs.get(streams.GUILDS, GUILD)["org_id"] == "org-a"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bot_handed_to_another_org_after_start_is_refused(fs):
|
||||||
|
_, q = await _start()
|
||||||
|
fs.put("bot_tokens", "own-a", {**fs.get("bot_tokens", "own-a"), "org_id": "org-b"})
|
||||||
|
resp = await _complete(q["state"])
|
||||||
|
assert _status(resp) == 409 and _body(resp)["code"] == "bot_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Secrets: PUT /tokens/{id}/oauth, never returned, never logged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_set_secret_checks_it_with_discord_and_never_echoes_it(fs, discord, caplog):
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
fs.data[oauth.OAUTH].clear()
|
||||||
|
discord.token_reply = httpx.Response(401, json={"error": "invalid_client"})
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert e.value.status_code == 400 and fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
|
|
||||||
|
discord.token_reply = httpx.Response(200, json={"access_token": "cc-token", "scope": "identify"})
|
||||||
|
out = await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert out["oauth_enabled"] is True
|
||||||
|
assert discord.form(1) == {"grant_type": "client_credentials", "scope": "identify"}
|
||||||
|
assert discord.basic(1) == f"201:{SECRET_POOL}"
|
||||||
|
assert discord.form(2)["token"] == "cc-token" # the check's token is revoked
|
||||||
|
assert fs.get(oauth.OAUTH, "pool-1")["client_secret"] == SECRET_POOL
|
||||||
|
assert "client_secret" not in fs.get("bot_tokens", "pool-1")
|
||||||
|
|
||||||
|
listed = await tokens_router.list_tokens(_=ADMIN)
|
||||||
|
everything = json.dumps([listed, out, fs.data.get("audit_log")]) + caplog.text
|
||||||
|
assert SECRET_POOL not in everything and "tok-pool-1" not in everything
|
||||||
|
row = next(t for t in listed if t["token_id"] == "pool-1")
|
||||||
|
assert row["oauth_enabled"] is True and row["server_count"] == 40 and row["server_cap"] == 90
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unreachable_discord_saves_nothing(fs, discord):
|
||||||
|
fs.data[oauth.OAUTH].clear()
|
||||||
|
|
||||||
|
def _down(request):
|
||||||
|
raise httpx.ConnectError("down", request=request)
|
||||||
|
discord.token_reply = _down
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret=SECRET_POOL),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert e.value.status_code == 503 and fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_list_is_an_allowlist(fs):
|
||||||
|
fs.put("bot_tokens", "pool-1", {**fs.get("bot_tokens", "pool-1"), "client_secret": "LEAK", "future": "x"})
|
||||||
|
row = next(t for t in await tokens_router.list_tokens(_=ADMIN) if t["token_id"] == "pool-1")
|
||||||
|
assert "client_secret" not in row and "future" not in row
|
||||||
|
assert row["token"] == "•••ol-1" and row["name"] == "DRB Radio 1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_clear_and_delete_remove_the_secret(fs):
|
||||||
|
await tokens_router.clear_oauth_secret("pool-1", decoded=ADMIN)
|
||||||
|
assert fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
|
await tokens_router.delete_token("own-a", _=ADMIN)
|
||||||
|
assert fs.get(oauth.OAUTH, "own-a") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_secret_is_refused_without_echo(fs, discord):
|
||||||
|
fs.data[oauth.OAUTH].clear()
|
||||||
|
with pytest.raises(HTTPException) as e:
|
||||||
|
await tokens_router.set_oauth_secret("pool-1", tokens_router.OAuthSecret(client_secret="short-secret"),
|
||||||
|
decoded=ADMIN)
|
||||||
|
assert e.value.status_code == 400 and "short-secret" not in str(e.value.detail)
|
||||||
|
assert discord.calls == [] and fs.get(oauth.OAUTH, "pool-1") is None
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
"""
|
||||||
|
The two owner setups from server-26#225, end to end (S8).
|
||||||
|
|
||||||
|
1. One-bot org: no DRB server bot anywhere. A platform admin adds the org's
|
||||||
|
own radio bot and its OAuth secret; the owner marks it preferred for the
|
||||||
|
org's system, connects the Discord server through that bot's invite,
|
||||||
|
opens the node page's picker and starts a stream.
|
||||||
|
2. Full setup: the DRB server bot is in the server (directory push, /drb
|
||||||
|
link, /join) beside several radio bots. C2 places each stream by
|
||||||
|
membership, Discord permissions on the voice channel, one voice channel
|
||||||
|
per bot per server and the system's preferred bot, lets several bots
|
||||||
|
share one voice channel, and explains when nothing fits.
|
||||||
|
|
||||||
|
Every route handler here is the real one, and so is every module under it
|
||||||
|
(discord_streams, discord_leases, discord_rest, discord_oauth,
|
||||||
|
discord_guild_link, discord_perms). Only the edges are fake: Firestore is
|
||||||
|
tests/fake_firestore.py, MQTT records what would be published, and Discord is
|
||||||
|
``DiscordWorld`` behind httpx.MockTransport — a small model of servers, roles,
|
||||||
|
channel overrides and bot applications that answers the same REST and OAuth2
|
||||||
|
calls Discord does.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.internal import discord_perms as perms
|
||||||
|
from app.internal import discord_rest
|
||||||
|
from app.internal import discord_streams as streams
|
||||||
|
from app.routers import discord as discord_router
|
||||||
|
from app.routers import discord_checks as checks
|
||||||
|
from app.routers import discord_link as link_router
|
||||||
|
from app.routers import discord_oauth as oauth_router
|
||||||
|
from app.routers import discord_web as web
|
||||||
|
from app.routers import tokens as tokens_router
|
||||||
|
from tests.fake_firestore import FakeFirestore
|
||||||
|
|
||||||
|
ORG = "org-a"
|
||||||
|
VOICE = perms.VIEW_CHANNEL | perms.CONNECT | perms.SPEAK
|
||||||
|
REDIRECT = "https://drb.example.test/discord/connected"
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.test", "role": "admin", "org_id": "founding"}
|
||||||
|
OWNER = {"uid": "owner-a", "email": "owner@example.test", "org_id": ORG, "org_role": "owner"}
|
||||||
|
SERVICE = {"service": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# A small Discord
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BotApp:
|
||||||
|
name: str
|
||||||
|
token: str
|
||||||
|
user_id: str
|
||||||
|
app_id: str
|
||||||
|
secret: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Guild:
|
||||||
|
gid: str
|
||||||
|
name: str
|
||||||
|
roles: dict = field(default_factory=dict) # role id -> permission int (@everyone id = gid)
|
||||||
|
role_names: dict = field(default_factory=dict)
|
||||||
|
channels: list = field(default_factory=list)
|
||||||
|
members: dict = field(default_factory=dict) # user id -> [role ids]
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordWorld:
|
||||||
|
"""Answers Discord REST (``Authorization: Bot …``) and OAuth2
|
||||||
|
(``Authorization: Basic client_id:secret``) for the bots and servers in
|
||||||
|
it. Adding a bot through the OAuth code exchange makes it a member, like
|
||||||
|
the real "Authorize" click does."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.bots: dict[str, BotApp] = {} # token -> app
|
||||||
|
self.guilds: dict[str, Guild] = {}
|
||||||
|
self.pending: dict[str, tuple] = {} # oauth code -> (app_id, guild_id)
|
||||||
|
self.requests: list[httpx.Request] = []
|
||||||
|
|
||||||
|
# --- world building ---------------------------------------------------
|
||||||
|
def bot(self, name: str, n: int) -> BotApp:
|
||||||
|
b = BotApp(name=name, token=f"bot-token-{n}", user_id=f"9000{n}", app_id=f"7000{n}",
|
||||||
|
secret=f"client-secret-{n}-0123456789abcdef")
|
||||||
|
self.bots[b.token] = b
|
||||||
|
return b
|
||||||
|
|
||||||
|
def guild(self, gid: str, name: str) -> Guild:
|
||||||
|
g = Guild(gid=gid, name=name, roles={gid: VOICE}, role_names={gid: "@everyone"})
|
||||||
|
self.guilds[gid] = g
|
||||||
|
return g
|
||||||
|
|
||||||
|
def authorize(self, app: BotApp, gid: str) -> str:
|
||||||
|
"""The owner clicks Authorize in Discord for ``gid``: Discord sends
|
||||||
|
the browser back with a code."""
|
||||||
|
code = f"code-{app.app_id}-{gid}"
|
||||||
|
self.pending[code] = (app.app_id, gid)
|
||||||
|
return code
|
||||||
|
|
||||||
|
def auth_kinds(self) -> list[str]:
|
||||||
|
return [r.headers.get("authorization", "").split(" ", 1)[0] for r in self.requests]
|
||||||
|
|
||||||
|
def bot_tokens_used(self) -> set[str]:
|
||||||
|
return {r.headers["authorization"].split(" ", 1)[1] for r in self.requests
|
||||||
|
if r.headers.get("authorization", "").startswith("Bot ")}
|
||||||
|
|
||||||
|
# --- transport --------------------------------------------------------
|
||||||
|
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
self.requests.append(request)
|
||||||
|
path = request.url.path.removeprefix("/api/v10")
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if path.startswith("/oauth2/token"):
|
||||||
|
return self._oauth(request, path, auth)
|
||||||
|
if not auth.startswith("Bot "):
|
||||||
|
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
|
||||||
|
app = self.bots.get(auth[4:])
|
||||||
|
if app is None:
|
||||||
|
return httpx.Response(401, json={"code": 0, "message": "401: Unauthorized"})
|
||||||
|
return self._rest(app, request, path)
|
||||||
|
|
||||||
|
def _oauth(self, request: httpx.Request, path: str, auth: str) -> httpx.Response:
|
||||||
|
if not auth.startswith("Basic "):
|
||||||
|
return httpx.Response(401, json={"error": "invalid_client"})
|
||||||
|
client_id, _, secret = base64.b64decode(auth[6:]).decode().partition(":")
|
||||||
|
app = next((a for a in self.bots.values() if a.app_id == client_id), None)
|
||||||
|
if app is None or app.secret != secret:
|
||||||
|
return httpx.Response(401, json={"error": "invalid_client"})
|
||||||
|
if path == "/oauth2/token/revoke":
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||||
|
if form.get("grant_type") == "client_credentials":
|
||||||
|
return httpx.Response(200, json={"access_token": "cc-token", "token_type": "Bearer"})
|
||||||
|
if form.get("grant_type") != "authorization_code" or form.get("redirect_uri") != REDIRECT:
|
||||||
|
return httpx.Response(400, json={"error": "invalid_grant"})
|
||||||
|
pending = self.pending.pop(form.get("code"), None)
|
||||||
|
if not pending or pending[0] != client_id:
|
||||||
|
return httpx.Response(400, json={"error": "invalid_grant"})
|
||||||
|
g = self.guilds[pending[1]]
|
||||||
|
g.members.setdefault(app.user_id, []) # the bot joins the server
|
||||||
|
return httpx.Response(200, json={"access_token": "user-access-token", "token_type": "Bearer",
|
||||||
|
"scope": "bot", "guild": {"id": g.gid, "name": g.name}})
|
||||||
|
|
||||||
|
def _rest(self, app: BotApp, request: httpx.Request, path: str) -> httpx.Response:
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
if path == "/users/@me":
|
||||||
|
return httpx.Response(200, json={"id": app.user_id, "username": app.name, "bot": True})
|
||||||
|
if path == "/oauth2/applications/@me":
|
||||||
|
return httpx.Response(200, json={"id": app.app_id, "name": app.name})
|
||||||
|
if path == "/users/@me/guilds":
|
||||||
|
after = int(request.url.params.get("after") or 0)
|
||||||
|
mine = sorted((g for g in self.guilds.values() if app.user_id in g.members), key=lambda g: int(g.gid))
|
||||||
|
return httpx.Response(200, json=[{"id": g.gid, "name": g.name} for g in mine if int(g.gid) > after])
|
||||||
|
if parts[0] == "guilds" and len(parts) >= 2:
|
||||||
|
g = self.guilds.get(parts[1])
|
||||||
|
if g is None or app.user_id not in g.members:
|
||||||
|
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_GUILD, "message": "Unknown Guild"})
|
||||||
|
if len(parts) == 2:
|
||||||
|
return httpx.Response(200, json={
|
||||||
|
"id": g.gid, "name": g.name, "owner_id": "human-owner",
|
||||||
|
"roles": [{"id": rid, "name": g.role_names.get(rid, rid), "permissions": str(p)}
|
||||||
|
for rid, p in g.roles.items()]})
|
||||||
|
if parts[2] == "members" and len(parts) == 4:
|
||||||
|
if parts[3] not in g.members:
|
||||||
|
return httpx.Response(404, json={"code": discord_rest.UNKNOWN_MEMBER})
|
||||||
|
return httpx.Response(200, json={"user": {"id": parts[3]}, "roles": g.members[parts[3]]})
|
||||||
|
if parts[2] == "channels":
|
||||||
|
return httpx.Response(200, json=g.channels)
|
||||||
|
return httpx.Response(404, json={"code": 0, "message": "404: Not Found"})
|
||||||
|
|
||||||
|
|
||||||
|
def voice(cid: str, name: str, position: int, *overwrites: dict, parent: Optional[str] = None) -> dict:
|
||||||
|
return {"id": cid, "name": name, "type": 2, "position": position, "user_limit": 0,
|
||||||
|
"parent_id": parent, "permission_overwrites": list(overwrites)}
|
||||||
|
|
||||||
|
|
||||||
|
def deny(role_id: str, bits: int) -> dict:
|
||||||
|
return {"id": role_id, "type": 0, "allow": "0", "deny": str(bits)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture: fake Firestore + MQTT + Discord, one org
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def env(monkeypatch):
|
||||||
|
fake = FakeFirestore.install(monkeypatch)
|
||||||
|
world = DiscordWorld()
|
||||||
|
discord_rest.reset()
|
||||||
|
monkeypatch.setattr(discord_rest, "_transport", httpx.MockTransport(world))
|
||||||
|
published: list = []
|
||||||
|
from app.internal.mqtt_handler import mqtt_handler
|
||||||
|
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", lambda n, p: published.append((n, p)) or True)
|
||||||
|
monkeypatch.setattr(settings, "discord_oauth_redirect_uri", REDIRECT)
|
||||||
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||||
|
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
|
||||||
|
streams._publish_locks.clear()
|
||||||
|
streams._guild_locks.clear()
|
||||||
|
checks._last_refresh.clear()
|
||||||
|
web._directory_tasks.clear()
|
||||||
|
for lim in (oauth_router.start_limiter, oauth_router.complete_limiter, link_router.mint_limiter,
|
||||||
|
link_router.redeem_guild_limiter, link_router.redeem_user_limiter):
|
||||||
|
lim._log.clear()
|
||||||
|
fake.put("organizations", ORG, {"org_id": ORG, "name": "Riverton"})
|
||||||
|
fake.put("org_members", OWNER["uid"], {"uid": OWNER["uid"], "org_id": ORG, "org_role": "owner"})
|
||||||
|
yield fake, world, published
|
||||||
|
discord_rest.reset()
|
||||||
|
|
||||||
|
|
||||||
|
def _node(fake, nid: str, system_id: str):
|
||||||
|
fake.put("nodes", nid, {"node_id": nid, "name": f"Node {nid}", "org_id": ORG, "status": "online",
|
||||||
|
"approval_status": "approved", "assigned_system_id": system_id})
|
||||||
|
|
||||||
|
|
||||||
|
def _body(resp):
|
||||||
|
return json.loads(resp.body) if hasattr(resp, "body") else resp
|
||||||
|
|
||||||
|
|
||||||
|
def _status(resp):
|
||||||
|
return getattr(resp, "status_code", 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_secrets(world: DiscordWorld, *payloads) -> None:
|
||||||
|
text = json.dumps(payloads, default=str)
|
||||||
|
for app in world.bots.values():
|
||||||
|
assert app.token not in text and app.secret not in text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. One-bot org — no DRB server bot
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_one_bot_org_links_lists_checks_and_streams_without_the_server_bot(env):
|
||||||
|
fake, world, published = env
|
||||||
|
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
|
||||||
|
_node(fake, "n1", "sys-a")
|
||||||
|
fake.put("node_keys", "n1", {"api_key": "node-key-n1"})
|
||||||
|
g = world.guild("400000000000000001", "Riverton FD")
|
||||||
|
g.channels = [
|
||||||
|
{"id": "400000000000000010", "name": "Radio", "type": 4, "position": 0, "permission_overwrites": []},
|
||||||
|
voice("400000000000000011", "dispatch", 0, parent="400000000000000010"),
|
||||||
|
{"id": "400000000000000012", "name": "general", "type": 0, "position": 1, "permission_overwrites": []},
|
||||||
|
]
|
||||||
|
riverton = world.bot("Riverton Radio", 1)
|
||||||
|
# A pool bot exists too (no OAuth secret, not in the server): never chosen here.
|
||||||
|
fake.put("bot_tokens", "pool-1", {"token_id": "pool-1", "name": "DRB Radio 1", "token": "pool-token",
|
||||||
|
"org_id": None, "application_id": "60001", "guild_ids": [], "valid": True,
|
||||||
|
"lease": None})
|
||||||
|
|
||||||
|
# Platform admin: add the org's own bot, then its OAuth client secret.
|
||||||
|
added = await tokens_router.add_token(
|
||||||
|
tokens_router.TokenCreate(name="Riverton Radio", token=riverton.token, org_id=ORG), ADMIN)
|
||||||
|
tid = added["token_id"]
|
||||||
|
assert added["valid"] is True and added["guild_ids"] == []
|
||||||
|
out = await tokens_router.set_oauth_secret(tid, tokens_router.OAuthSecret(client_secret=riverton.secret), ADMIN)
|
||||||
|
assert out["oauth_enabled"] is True
|
||||||
|
|
||||||
|
# Owner (Q2): their own bot, preferred for their own system.
|
||||||
|
await tokens_router.set_preferred_system(tid, "sys-a", OWNER)
|
||||||
|
assert fake.get("systems", "sys-a")["preferred_token_id"] == tid
|
||||||
|
|
||||||
|
# Owner: Connect a Discord server from the node — the preferred bot is invited.
|
||||||
|
opts = await oauth_router.oauth_options(node_id="n1", token_id=None, org_id=None, decoded=OWNER)
|
||||||
|
assert opts["bot"] == {"name": "Riverton Radio", "reason": "preferred", "own_bot": True}
|
||||||
|
start = await oauth_router.oauth_start(oauth_router.StartBody(node_id="n1", return_to="/nodes/n1"),
|
||||||
|
org_id=None, decoded=OWNER)
|
||||||
|
q = {k: v[0] for k, v in parse_qs(urlparse(start["authorize_url"]).query).items()}
|
||||||
|
assert q["client_id"] == riverton.app_id and q["scope"] == "bot" and q["redirect_uri"] == REDIRECT
|
||||||
|
|
||||||
|
# In Discord: pick Riverton FD, Authorize. Discord sends the browser back.
|
||||||
|
code = world.authorize(riverton, g.gid)
|
||||||
|
done = _body(await oauth_router.oauth_complete(oauth_router.CompleteBody(code=code, state=q["state"]),
|
||||||
|
decoded=OWNER))
|
||||||
|
assert done["ok"] is True and done["guild_id"] == g.gid and done["guild_name"] == "Riverton FD"
|
||||||
|
assert done["return_to"] == "/nodes/n1" and done["bot_name"] == "Riverton Radio"
|
||||||
|
# Permissions were checked in the same request, from the radio bot's view.
|
||||||
|
assert done["checked"] is True and done["channels_ready"] == 1 and done["channels_blocked"] == 0
|
||||||
|
assert [c["name"] for c in done["channels"]] == ["dispatch"]
|
||||||
|
|
||||||
|
link = fake.get("discord_guilds", g.gid)
|
||||||
|
assert link["org_id"] == ORG and link["linked_via"] == "oauth"
|
||||||
|
assert "bot_present" not in link # the server bot never said anything
|
||||||
|
assert g.gid in fake.get("bot_tokens", tid)["guild_ids"]
|
||||||
|
|
||||||
|
# Node page: the server is listed, its channels read through the radio bot.
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
[gopt] = opts["guilds"]
|
||||||
|
assert gopt["guild_id"] == g.gid and gopt["linked"] is True
|
||||||
|
assert gopt["main_bot_present"] is False and gopt["directory_source"] == "radio_bot"
|
||||||
|
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch"]
|
||||||
|
assert gopt["bot"]["ok"] is True
|
||||||
|
vc = await checks.node_voice_check("n1", g.gid, refresh=0, channel_id=None, decoded=OWNER)
|
||||||
|
[row] = vc["channels"]
|
||||||
|
assert row["state"] == "ready" and [b["name"] for b in row["bots"]] == ["Riverton Radio"]
|
||||||
|
|
||||||
|
# Start: the preferred bot carries it, on this node.
|
||||||
|
started = await web.start_node_stream(
|
||||||
|
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
|
||||||
|
assert _status(started) == 200, _body(started)
|
||||||
|
assert started["token_id"] == tid and started["delivered"] is True
|
||||||
|
stream = fake.get("discord_streams", started["stream_id"])
|
||||||
|
assert stream["node_id"] == "n1" and stream["guild_name"] == "Riverton FD"
|
||||||
|
assert stream["channel_name"] == "dispatch" and stream["mode"] == "persistent"
|
||||||
|
node_id, payload = published[-1]
|
||||||
|
assert node_id == "n1"
|
||||||
|
assert [(s["stream_id"], s["token_id"]) for s in payload["streams"]] == [(started["stream_id"], tid)]
|
||||||
|
|
||||||
|
# The node picks the token string up with its own key — the only place it leaves C2.
|
||||||
|
got = await discord_router.node_token(
|
||||||
|
"n1", tid, HTTPAuthorizationCredentials(scheme="Bearer", credentials="node-key-n1"))
|
||||||
|
assert got["token"] == riverton.token
|
||||||
|
|
||||||
|
# Nothing but this org's radio bot ever talked to Discord, and nothing a
|
||||||
|
# browser saw carries a token or a client secret.
|
||||||
|
assert world.bot_tokens_used() == {riverton.token}
|
||||||
|
assert set(world.auth_kinds()) <= {"Bot", "Basic"}
|
||||||
|
_no_secrets(world, opts, vc, done, started, start, await tokens_router.list_tokens(ADMIN))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_bot_org_reconciler_replaces_the_stream_without_the_server_bot(env):
|
||||||
|
"""Persistent stream, node lost: the reconciler re-places it on another
|
||||||
|
node of the same system with the same radio bot — no server bot involved."""
|
||||||
|
fake, world, published = env
|
||||||
|
fake.put("systems", "sys-a", {"system_id": "sys-a", "name": "Riverton P25", "org_id": ORG})
|
||||||
|
_node(fake, "n1", "sys-a")
|
||||||
|
_node(fake, "n2", "sys-a")
|
||||||
|
g = world.guild("400000000000000001", "Riverton FD")
|
||||||
|
g.channels = [voice("400000000000000011", "dispatch", 0)]
|
||||||
|
riverton = world.bot("Riverton Radio", 1)
|
||||||
|
g.members[riverton.user_id] = []
|
||||||
|
fake.put("bot_tokens", "own", {"token_id": "own", "name": "Riverton Radio", "token": riverton.token,
|
||||||
|
"org_id": ORG, "application_id": riverton.app_id, "bot_user_id": riverton.user_id,
|
||||||
|
"guild_ids": [g.gid], "valid": True, "lease": None})
|
||||||
|
fake.put("discord_guilds", g.gid, {"guild_id": g.gid, "org_id": ORG, "linked_via": "oauth"})
|
||||||
|
|
||||||
|
started = await web.start_node_stream(
|
||||||
|
"n1", web.NodeStreamStart(guild_id=g.gid, channel_id="400000000000000011", mode="persistent"), OWNER)
|
||||||
|
sid = started["stream_id"]
|
||||||
|
# n1 goes away for good (past the lease grace).
|
||||||
|
fake.put("nodes", "n1", {**fake.get("nodes", "n1"), "status": "offline",
|
||||||
|
"last_seen": "2026-01-01T00:00:00+00:00"})
|
||||||
|
await streams.reconcile_once()
|
||||||
|
s = fake.get("discord_streams", sid)
|
||||||
|
assert s["desired"] == "on" and s["node_id"] == "n2" and s["token_id"] == "own"
|
||||||
|
assert world.bot_tokens_used() == {riverton.token}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Full setup — DRB server bot + several radio bots
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
GID = "500000000000000001"
|
||||||
|
DISPATCH, FIREGROUND, LOBBY = "500000000000000011", "500000000000000012", "500000000000000013"
|
||||||
|
MUTED = "500000000000000099"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def full(env):
|
||||||
|
fake, world, published = env
|
||||||
|
for sid, name in (("sys-a", "County P25"), ("sys-b", "City Fire"), ("sys-c", "EMS")):
|
||||||
|
fake.put("systems", sid, {"system_id": sid, "name": name, "org_id": ORG})
|
||||||
|
_node(fake, "n1", "sys-a")
|
||||||
|
_node(fake, "n2", "sys-b")
|
||||||
|
_node(fake, "n3", "sys-c")
|
||||||
|
g = world.guild(GID, "Station 7")
|
||||||
|
g.roles[MUTED], g.role_names[MUTED] = 0, "Muted"
|
||||||
|
g.channels = [
|
||||||
|
voice(DISPATCH, "dispatch", 0, deny(MUTED, perms.SPEAK)), # @Muted can't Speak here
|
||||||
|
voice(FIREGROUND, "fireground", 1, deny(GID, perms.CONNECT)), # nobody can Connect here
|
||||||
|
voice(LOBBY, "lobby", 2),
|
||||||
|
]
|
||||||
|
bots = {}
|
||||||
|
for n, (tid, name, org, member, roles) in enumerate((
|
||||||
|
("own-a", "Riverton Radio", ORG, True, [MUTED]),
|
||||||
|
("pool-1", "DRB Radio 1", None, True, []),
|
||||||
|
("pool-2", "DRB Radio 2", None, True, []),
|
||||||
|
("pool-3", "DRB Radio 3", None, False, []),
|
||||||
|
), start=1):
|
||||||
|
app = world.bot(name, n)
|
||||||
|
if member:
|
||||||
|
g.members[app.user_id] = roles
|
||||||
|
fake.put("bot_tokens", tid, {"token_id": tid, "name": name, "token": app.token, "org_id": org,
|
||||||
|
"application_id": app.app_id, "bot_user_id": app.user_id,
|
||||||
|
"guild_ids": [GID] if member else [], "valid": True, "lease": None})
|
||||||
|
bots[tid] = app
|
||||||
|
# A platform admin pinned DRB Radio 2 to City Fire.
|
||||||
|
fake.put("systems", "sys-b", {**fake.get("systems", "sys-b"), "preferred_token_id": "pool-2"})
|
||||||
|
fake.bots = bots
|
||||||
|
return fake, world, published
|
||||||
|
|
||||||
|
|
||||||
|
async def _server_bot_sets_up(fake):
|
||||||
|
"""The DRB server bot is in Station 7: it pushes its channel list, and
|
||||||
|
someone with Manage Server runs /drb link with the owner's code."""
|
||||||
|
await web.put_guild_directory(GID, web.GuildDirectory(name="Station 7", voice_channels=[
|
||||||
|
web.VoiceChannelIn(channel_id=DISPATCH, name="dispatch", category="Radio", position=0),
|
||||||
|
web.VoiceChannelIn(channel_id=FIREGROUND, name="fireground", category="Radio", position=1),
|
||||||
|
web.VoiceChannelIn(channel_id=LOBBY, name="lobby", category="Radio", position=2),
|
||||||
|
]), SERVICE)
|
||||||
|
minted = await link_router.create_link_code(org_id=None, decoded=OWNER)
|
||||||
|
out = _body(await link_router.redeem_link_code(
|
||||||
|
GID, link_router.RedeemBody(code=minted["code"], discord_user_id="123456", guild_name="Station 7"),
|
||||||
|
SERVICE))
|
||||||
|
assert out["ok"] is True and out["org_id"] == ORG
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_setup_places_each_stream_and_explains_every_refusal(full):
|
||||||
|
fake, world, published = full
|
||||||
|
await _server_bot_sets_up(fake)
|
||||||
|
link = fake.get("discord_guilds", GID)
|
||||||
|
assert link["org_id"] == ORG and link["bot_present"] is True and link["directory_source"] == "main_bot"
|
||||||
|
|
||||||
|
# Before anything plays: nobody may Connect in #fireground. Every member
|
||||||
|
# bot is named with what removed the permission; the bot that isn't in
|
||||||
|
# the server yet is offered as an invite.
|
||||||
|
r = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=FIREGROUND), OWNER)
|
||||||
|
body = _body(r)
|
||||||
|
assert _status(r) == 409 and body["code"] == "missing_permissions"
|
||||||
|
assert sorted(d["name"] for d in body["denied"]) == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
|
||||||
|
assert all(d["missing"][0] == "Connect" for d in body["denied"])
|
||||||
|
assert body["detail"].startswith("No radio bot can play in #fireground:")
|
||||||
|
assert "#fireground's override for @everyone" in body["detail"]
|
||||||
|
assert [i["name"] for i in body["invite"]] == ["DRB Radio 3"]
|
||||||
|
assert fake.data.get("discord_streams") in (None, {})
|
||||||
|
|
||||||
|
# /join County P25 in #dispatch (DRB server bot): the org's own bot comes
|
||||||
|
# first but @Muted can't Speak there, so DRB Radio 1 takes it.
|
||||||
|
a = _body(await discord_router.start_stream(discord_router.StreamStart(
|
||||||
|
guild_id=GID, channel_id=DISPATCH, system_id="sys-a", channel_name="dispatch", created_by="111"), SERVICE))
|
||||||
|
assert a["token_id"] == "pool-1" and a["node_id"] == "n1"
|
||||||
|
|
||||||
|
# /join City Fire in the SAME channel: its preferred bot, beside the first.
|
||||||
|
b = _body(await discord_router.start_stream(discord_router.StreamStart(
|
||||||
|
guild_id=GID, channel_id=DISPATCH, system_id="sys-b", channel_name="dispatch", created_by="111"), SERVICE))
|
||||||
|
assert b["token_id"] == "pool-2" and b["node_id"] == "n2" and b["stream_id"] != a["stream_id"]
|
||||||
|
on_dispatch = [s for s in fake.data["discord_streams"].values()
|
||||||
|
if s["channel_id"] == DISPATCH and s["desired"] == "on"]
|
||||||
|
assert sorted(s["token_id"] for s in on_dispatch) == ["pool-1", "pool-2"]
|
||||||
|
|
||||||
|
# EMS on #dispatch from the web picker: both pool bots already hold a
|
||||||
|
# voice channel in this server, and the org's own bot is muted there.
|
||||||
|
vc = await checks.node_voice_check("n3", GID, refresh=0, channel_id=DISPATCH, decoded=OWNER)
|
||||||
|
[row] = vc["channels"]
|
||||||
|
assert row["state"] == "needs_fix"
|
||||||
|
by_name = {b["name"]: b for b in row["bots"]}
|
||||||
|
assert by_name["DRB Radio 1"]["unavailable_reason"] == "busy_here"
|
||||||
|
assert by_name["Riverton Radio"]["missing"] == ["Speak"]
|
||||||
|
assert "@Muted" in by_name["Riverton Radio"]["reason"]
|
||||||
|
r = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=DISPATCH), OWNER)
|
||||||
|
body = _body(r)
|
||||||
|
assert _status(r) == 409 and body["code"] == "missing_permissions"
|
||||||
|
assert body["detail"].startswith("Riverton Radio can't Speak in #dispatch:")
|
||||||
|
assert "Edit Channel → Permissions → add Riverton Radio → allow Speak" in body["detail"]
|
||||||
|
|
||||||
|
# EMS on #lobby: nothing overrides @Muted there, so the org's own bot plays it.
|
||||||
|
c = await web.start_node_stream("n3", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
|
||||||
|
assert _status(c) == 200 and c["token_id"] == "own-a"
|
||||||
|
|
||||||
|
# Naming a bot that already holds a channel here is refused, not swapped.
|
||||||
|
r = await discord_router.start_stream(discord_router.StreamStart(
|
||||||
|
guild_id=GID, channel_id=LOBBY, system_id="sys-a", preferred_token_id="pool-1"), SERVICE)
|
||||||
|
body = _body(r)
|
||||||
|
assert _status(r) == 409 and body["code"] == "preferred_busy"
|
||||||
|
assert "DRB Radio 1 is already playing County P25 in #dispatch" in body["detail"]
|
||||||
|
|
||||||
|
# Each node was told only about its own stream, by token id.
|
||||||
|
last = {n: p for n, p in published}
|
||||||
|
assert {n: [s["token_id"] for s in p["streams"]] for n, p in last.items()} == {
|
||||||
|
"n1": ["pool-1"], "n2": ["pool-2"], "n3": ["own-a"]}
|
||||||
|
|
||||||
|
# /status and the admin page see both kinds of bot.
|
||||||
|
ov = await discord_router.guild_overview(GID, SERVICE)
|
||||||
|
assert ov["linked"] is True and len(ov["streams"]) == 3
|
||||||
|
adm = await discord_router.admin_overview(ADMIN)
|
||||||
|
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
|
||||||
|
assert ga["main_bot_present"] is True and ga["linked_via"] == "code"
|
||||||
|
assert ga["radio_bots"] == ["DRB Radio 1", "DRB Radio 2", "Riverton Radio"]
|
||||||
|
# Only radio bots ever called Discord; the server bot works over the gateway.
|
||||||
|
assert world.bot_tokens_used() <= {app.token for app in fake.bots.values()}
|
||||||
|
_no_secrets(world, ov, adm, vc)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_setup_server_bot_leaving_keeps_radio_working(full):
|
||||||
|
"""The DRB server bot is kicked: the server stays usable through its
|
||||||
|
radio bots (picker, permission check, start) — the main bot is optional."""
|
||||||
|
fake, world, published = full
|
||||||
|
await _server_bot_sets_up(fake)
|
||||||
|
await web.put_guild_directory(GID, web.GuildDirectory(present=False), SERVICE)
|
||||||
|
assert fake.get("discord_guilds", GID)["bot_present"] is False
|
||||||
|
|
||||||
|
opts = await web.join_options("n1", OWNER)
|
||||||
|
[gopt] = opts["guilds"]
|
||||||
|
assert gopt["main_bot_present"] is False
|
||||||
|
assert [c["name"] for c in gopt["voice_channels"]] == ["dispatch", "fireground", "lobby"]
|
||||||
|
c = await web.start_node_stream("n1", web.NodeStreamStart(guild_id=GID, channel_id=LOBBY), OWNER)
|
||||||
|
assert _status(c) == 200 and c["token_id"] == "own-a"
|
||||||
|
adm = await discord_router.admin_overview(ADMIN)
|
||||||
|
[ga] = [x for x in adm["guilds"] if x["guild_id"] == GID]
|
||||||
|
assert ga["main_bot_present"] is False and len(ga["radio_bots"]) == 3
|
||||||
@@ -84,7 +84,7 @@ def fs(monkeypatch):
|
|||||||
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
monkeypatch.setattr(settings, "discord_unlinked_guild_org_id", "")
|
||||||
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
|
monkeypatch.setattr(settings, "discord_perm_check_enabled", True)
|
||||||
|
|
||||||
# Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member"
|
# Per-token snapshot behaviour: "ok" | "muted" | "unknown" | "slow" | "not_member" | "invalid"
|
||||||
fake.snap_mode = {}
|
fake.snap_mode = {}
|
||||||
fake.snap_calls = []
|
fake.snap_calls = []
|
||||||
|
|
||||||
@@ -99,6 +99,8 @@ def fs(monkeypatch):
|
|||||||
return _snap(tid, status=discord_rest.UNKNOWN)
|
return _snap(tid, status=discord_rest.UNKNOWN)
|
||||||
if mode == "not_member":
|
if mode == "not_member":
|
||||||
return _snap(tid, status=discord_rest.NOT_MEMBER)
|
return _snap(tid, status=discord_rest.NOT_MEMBER)
|
||||||
|
if mode == "invalid":
|
||||||
|
return _snap(tid, status=discord_rest.INVALID)
|
||||||
return _snap(tid, muted=(mode == "muted"))
|
return _snap(tid, muted=(mode == "muted"))
|
||||||
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
|
monkeypatch.setattr(discord_rest, "guild_snapshot", _snapshot)
|
||||||
|
|
||||||
@@ -413,6 +415,23 @@ def test_voice_check_needs_fix_when_every_free_bot_is_denied(fs):
|
|||||||
assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed
|
assert out["channels"][0]["state"] == "unknown" # can't tell -> start still allowed
|
||||||
|
|
||||||
|
|
||||||
|
def test_voice_check_invalid_token_is_not_no_bot(fs):
|
||||||
|
# server-26#257: the bot is in the server but Discord rejected its token.
|
||||||
|
# "No radio bot" (and an invite to a bot already there) would be wrong.
|
||||||
|
_token(fs, "t1")
|
||||||
|
fs.snap_mode["t1"] = "invalid"
|
||||||
|
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
|
||||||
|
assert out["channels"][0]["state"] == "invalid"
|
||||||
|
assert out["channels"][0]["bots"][0]["status"] == "invalid"
|
||||||
|
# a working bot elsewhere in the list still wins
|
||||||
|
_token(fs, "t2")
|
||||||
|
discord_rest.invalidate()
|
||||||
|
out = run(checks.node_voice_check("n1", GUILD, refresh=0, channel_id="1001", decoded=OWNER))
|
||||||
|
assert out["channels"][0]["state"] == "ready"
|
||||||
|
# no bot at all is still no_bot
|
||||||
|
assert checks._channel_state([], True) == "no_bot"
|
||||||
|
|
||||||
|
|
||||||
def test_voice_check_is_org_scoped(fs):
|
def test_voice_check_is_org_scoped(fs):
|
||||||
_token(fs, "t1")
|
_token(fs, "t1")
|
||||||
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)):
|
for node, guild, who in (("n1", "200", OWNER), ("nx", GUILD, OWNER), ("n1", GUILD, STRANGER)):
|
||||||
|
|||||||
@@ -80,7 +80,10 @@ def test_directory_push_keeps_org_link(fs):
|
|||||||
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
|
assert [c["channel_id"] for c in g["voice_channels"]] == ["1003"]
|
||||||
|
|
||||||
|
|
||||||
def test_directory_bot_removed_hides_guild(fs):
|
def test_directory_bot_removed_hides_guild_without_a_radio_bot(fs):
|
||||||
|
# server-26#225 S5: the server bot leaving hides a linked server only when
|
||||||
|
# no radio bot the org may use is in it either.
|
||||||
|
fs.put("bot_tokens", "t1", {**fs.get("bot_tokens", "t1"), "guild_ids": ["200"]})
|
||||||
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
|
run(web.put_guild_directory("100", web.GuildDirectory(present=False), {"service": True}))
|
||||||
opts = run(web.join_options("n1", OWNER))
|
opts = run(web.join_options("n1", OWNER))
|
||||||
assert opts["guilds"] == []
|
assert opts["guilds"] == []
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
server-26#236: geocode precision must travel with the coordinates.
|
||||||
|
|
||||||
|
`_geocode_location` already judged a result by Google's `location_type` but
|
||||||
|
returned only {lat, lng}, so a GEOMETRIC_CENTER route centroid and a ROOFTOP
|
||||||
|
address drew the same pin. The type is now part of the returned dict, which
|
||||||
|
is stored as-is on the call doc and carried unchanged onto the incident pin by
|
||||||
|
`incident_correlator._resolve_location_pair`.
|
||||||
|
|
||||||
|
Display-only: every correlator read of location_coords indexes ["lat"] and
|
||||||
|
["lng"] directly, so the extra key cannot change a correlation decision.
|
||||||
|
"""
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import app.internal.intelligence as intel
|
||||||
|
from app.config import settings
|
||||||
|
from tests.test_geocode_named_region import _maps_result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _api_key():
|
||||||
|
with patch.object(settings, "google_maps_api_key", "test-key"):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
ANCHOR = {"lat": 41.16, "lng": -73.86, "radius_km": 10.0}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("location_type", ["ROOFTOP", "RANGE_INTERPOLATED", "GEOMETRIC_CENTER"])
|
||||||
|
async def test_accepted_location_type_is_returned_with_the_coords(location_type):
|
||||||
|
with _maps_result(41.161, -73.861, location_type=location_type):
|
||||||
|
coords = await intel._geocode_location("1 Main Street, Ossining", anchor=ANCHOR)
|
||||||
|
assert coords == {"lat": 41.161, "lng": -73.861, "location_type": location_type}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("location_type", ["APPROXIMATE", "", "SOMETHING_NEW"])
|
||||||
|
async def test_rejected_location_type_still_returns_none(location_type):
|
||||||
|
with _maps_result(41.161, -73.861, location_type=location_type):
|
||||||
|
coords = await intel._geocode_location("Ossining", anchor=ANCHOR)
|
||||||
|
assert coords is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_location_type_survives_onto_the_incident_pin():
|
||||||
|
"""`_resolve_location_pair` stores the call's coords dict as-is, so the
|
||||||
|
precision reaches the incident without any correlator change."""
|
||||||
|
from app.internal.incident_correlator import _resolve_location_pair
|
||||||
|
|
||||||
|
pin = {"lat": 41.161, "lng": -73.861, "location_type": "GEOMETRIC_CENTER"}
|
||||||
|
fields = _resolve_location_pair({}, "Route 9 and Main Street", pin)
|
||||||
|
assert fields["location_coords"] == pin
|
||||||
|
assert fields["location_coords_source"] == "Route 9 and Main Street"
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ async def test_named_region_geocode_accepted_beyond_node_distance():
|
|||||||
node_lat=NODE_LAT, node_lon=NODE_LON,
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
trust_named_region=True,
|
trust_named_region=True,
|
||||||
)
|
)
|
||||||
assert coords == {"lat": FAR_LAT, "lng": FAR_LNG}
|
assert coords == {"lat": FAR_LAT, "lng": FAR_LNG, "location_type": "ROOFTOP"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -110,7 +110,7 @@ async def test_named_region_geocode_within_node_distance_is_unaffected():
|
|||||||
node_lat=NODE_LAT, node_lon=NODE_LON,
|
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||||
trust_named_region=True,
|
trust_named_region=True,
|
||||||
)
|
)
|
||||||
assert coords == {"lat": near_lat, "lng": near_lng}
|
assert coords == {"lat": near_lat, "lng": near_lng, "location_type": "ROOFTOP"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
HTTP Range on /media/calls/{id}/audio (server-26#233).
|
||||||
|
|
||||||
|
The GCS reads are replaced by an in-memory object: ``audio_object_size`` and
|
||||||
|
``download_audio_range`` are patched where routers/media.py looks them up, and
|
||||||
|
every span read is recorded so the tests can assert the route never asks GCS
|
||||||
|
for more than the requested range (plus at most one STREAM_CHUNK_BYTES span).
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal import storage
|
||||||
|
from app.routers import media
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(media.router)
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
CALL_ID = "call-abc"
|
||||||
|
GCS_URI = "gs://bucket/calls/call-abc.flac"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeObject:
|
||||||
|
"""A stored object plus a log of every (start, end) span read from it."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes):
|
||||||
|
self.data = data
|
||||||
|
self.reads: list[tuple[int, int]] = []
|
||||||
|
self.size_calls = 0
|
||||||
|
|
||||||
|
async def size(self, gcs_uri):
|
||||||
|
self.size_calls += 1
|
||||||
|
return len(self.data)
|
||||||
|
|
||||||
|
async def read(self, gcs_uri, start, end):
|
||||||
|
self.reads.append((start, end))
|
||||||
|
return self.data[start:end + 1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def signed(monkeypatch):
|
||||||
|
monkeypatch.setattr(storage.settings, "service_key", "test-key", raising=False)
|
||||||
|
exp = int(time.time()) + 600
|
||||||
|
return {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)}
|
||||||
|
|
||||||
|
|
||||||
|
def _run(obj: FakeObject, params, headers=None, doc=None):
|
||||||
|
doc_get = AsyncMock(return_value=doc if doc is not None else {"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})
|
||||||
|
with patch.object(media.fstore, "doc_get", doc_get), \
|
||||||
|
patch.object(media, "audio_object_size", obj.size), \
|
||||||
|
patch.object(media, "download_audio_range", obj.read):
|
||||||
|
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=params, headers=headers or {})
|
||||||
|
return resp, doc_get
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounded_range_returns_206_with_exact_bytes(signed):
|
||||||
|
obj = FakeObject(bytes(range(256)) * 40) # 10240 bytes
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=100-199"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == obj.data[100:200]
|
||||||
|
assert resp.headers["content-range"] == f"bytes 100-199/{len(obj.data)}"
|
||||||
|
assert resp.headers["content-length"] == "100"
|
||||||
|
assert resp.headers["accept-ranges"] == "bytes"
|
||||||
|
assert resp.headers["content-type"] == "audio/flac"
|
||||||
|
assert resp.headers["cache-control"] == "private, max-age=3600"
|
||||||
|
# Only the requested span was read from GCS — not the whole object.
|
||||||
|
assert obj.reads == [(100, 199)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_ended_range(signed):
|
||||||
|
obj = FakeObject(b"x" * 5000 + b"tail")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=5000-"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == b"tail"
|
||||||
|
assert resp.headers["content-range"] == "bytes 5000-5003/5004"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suffix_range(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=-3"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == b"789"
|
||||||
|
assert resp.headers["content-range"] == "bytes 7-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_suffix_longer_than_object_returns_whole_object(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=-500"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == obj.data
|
||||||
|
assert resp.headers["content-range"] == "bytes 0-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
def test_end_past_object_is_clamped(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=8-999"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.content == b"89"
|
||||||
|
assert resp.headers["content-range"] == "bytes 8-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rng", ["bytes=10-", "bytes=50-60", "bytes=-0"])
|
||||||
|
def test_unsatisfiable_range_is_416(signed, rng):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": rng})
|
||||||
|
assert resp.status_code == 416
|
||||||
|
assert resp.headers["content-range"] == "bytes */10"
|
||||||
|
assert obj.reads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_range_is_200_full_body_and_advertises_ranges(signed):
|
||||||
|
obj = FakeObject(b"abcdef" * 100)
|
||||||
|
resp, _ = _run(obj, signed)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == obj.data
|
||||||
|
assert resp.headers["accept-ranges"] == "bytes"
|
||||||
|
assert resp.headers["content-length"] == str(len(obj.data))
|
||||||
|
assert "content-range" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("rng", ["bytes=0-1,4-5", "items=0-5", "bytes=5-2", "garbage"])
|
||||||
|
def test_ignored_range_headers_fall_back_to_200(signed, rng):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, _ = _run(obj, signed, {"Range": rng})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content == obj.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_object_is_streamed_in_bounded_spans(signed):
|
||||||
|
# A ~13 MB "600 s FLAC": a seek near the end reads only from there on, in
|
||||||
|
# spans no larger than STREAM_CHUNK_BYTES, and the first span is all the
|
||||||
|
# route waits on before the response starts.
|
||||||
|
size = 13 * 1024 * 1024
|
||||||
|
obj = FakeObject(bytes(size))
|
||||||
|
start = size - 1_500_000
|
||||||
|
resp, _ = _run(obj, signed, {"Range": f"bytes={start}-"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert len(resp.content) == 1_500_000
|
||||||
|
assert obj.reads[0][0] == start
|
||||||
|
assert all(e - s + 1 <= media.STREAM_CHUNK_BYTES for s, e in obj.reads)
|
||||||
|
assert sum(e - s + 1 for s, e in obj.reads) == 1_500_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_bad_signature_is_403_before_any_lookup(signed):
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, doc_get = _run(obj, {"exp": signed["exp"], "sig": "0" * 64}, {"Range": "bytes=0-3"})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
doc_get.assert_not_called()
|
||||||
|
assert obj.size_calls == 0
|
||||||
|
assert obj.reads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_link_is_403(signed, monkeypatch):
|
||||||
|
exp = int(time.time()) - 5
|
||||||
|
obj = FakeObject(b"0123456789")
|
||||||
|
resp, doc_get = _run(obj, {"exp": exp, "sig": storage.sign_audio_link(CALL_ID, exp)})
|
||||||
|
assert resp.status_code == 403
|
||||||
|
doc_get.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_object_is_404(signed):
|
||||||
|
obj = FakeObject(b"")
|
||||||
|
|
||||||
|
async def no_size(gcs_uri):
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(media.fstore, "doc_get", AsyncMock(return_value={"call_id": CALL_ID, "audio_gcs_uri": GCS_URI})), \
|
||||||
|
patch.object(media, "audio_object_size", no_size), \
|
||||||
|
patch.object(media, "download_audio_range", obj.read):
|
||||||
|
resp = client.get(f"/media/calls/{CALL_ID}/audio", params=signed)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_mp3_keeps_its_content_type(signed):
|
||||||
|
obj = FakeObject(b"ID3" + b"\0" * 50)
|
||||||
|
resp, _ = _run(obj, signed, {"Range": "bytes=0-2"}, doc={"call_id": CALL_ID, "audio_gcs_uri": "gs://b/calls/x.mp3"})
|
||||||
|
assert resp.status_code == 206
|
||||||
|
assert resp.headers["content-type"] == "audio/mpeg"
|
||||||
|
assert resp.content == b"ID3"
|
||||||
|
|
||||||
|
|
||||||
|
# ── storage.py: the ranged reads never pull the whole object ────────────────
|
||||||
|
def _fake_client(monkeypatch, size=1234, payload=b"span"):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
client = MagicMock()
|
||||||
|
blob = client.bucket.return_value.blob.return_value
|
||||||
|
blob.size = size
|
||||||
|
blob.download_as_bytes.return_value = payload
|
||||||
|
monkeypatch.setattr(storage, "_gcs_client_cache", client)
|
||||||
|
return client, blob
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_size_reloads_metadata_only(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
client, blob = _fake_client(monkeypatch, size=13_000_000)
|
||||||
|
assert asyncio.run(storage.audio_object_size(GCS_URI)) == 13_000_000
|
||||||
|
client.bucket.assert_called_with("bucket")
|
||||||
|
client.bucket.return_value.blob.assert_called_with("calls/call-abc.flac")
|
||||||
|
blob.reload.assert_called_once()
|
||||||
|
blob.download_as_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_range_read_passes_inclusive_bounds(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
_, blob = _fake_client(monkeypatch)
|
||||||
|
assert asyncio.run(storage.download_audio_range(GCS_URI, 100, 199)) == b"span"
|
||||||
|
blob.download_as_bytes.assert_called_once_with(start=100, end=199)
|
||||||
|
|
||||||
|
|
||||||
|
def test_storage_range_read_failure_returns_none(monkeypatch):
|
||||||
|
import asyncio
|
||||||
|
_, blob = _fake_client(monkeypatch)
|
||||||
|
blob.download_as_bytes.side_effect = RuntimeError("gcs down")
|
||||||
|
assert asyncio.run(storage.download_audio_range(GCS_URI, 0, 10)) is None
|
||||||
|
blob.reload.side_effect = RuntimeError("404")
|
||||||
|
assert asyncio.run(storage.audio_object_size(GCS_URI)) is None
|
||||||
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
|
|||||||
assert "last_seen" in updates
|
assert "last_seen" in updates
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checkin_stores_disk_free_and_total(handler):
|
||||||
|
# node-26#41: a full disk stopped node-002 recording while it showed Online.
|
||||||
|
existing = {"node_id": "node-01", "status": "online", "configured": True}
|
||||||
|
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
|
||||||
|
mock_fstore.doc_get = AsyncMock(return_value=existing)
|
||||||
|
mock_fstore.doc_update = AsyncMock()
|
||||||
|
await handler._handle_checkin("node-01", {"disk_free_bytes": 5, "disk_total_bytes": 100})
|
||||||
|
updates = mock_fstore.doc_update.call_args[0][2]
|
||||||
|
assert updates["disk_free_bytes"] == 5 and updates["disk_total_bytes"] == 100
|
||||||
|
|
||||||
|
# An older node that doesn't report disk must not wipe the fields.
|
||||||
|
await handler._handle_checkin("node-01", {"name": "x"})
|
||||||
|
assert "disk_free_bytes" not in mock_fstore.doc_update.call_args[0][2]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_checkin_does_not_promote_unconfigured_to_online(handler):
|
async def test_checkin_does_not_promote_unconfigured_to_online(handler):
|
||||||
existing = {
|
existing = {
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Power-outage providers (server-26#184): registry, licence gate, admin routes, ODIN adapter.
|
||||||
|
|
||||||
|
What is held here (mirrors tests/test_camera_providers.py):
|
||||||
|
* every provider defaults OFF, and an off provider is never fetched;
|
||||||
|
* stubs can never be enabled (409);
|
||||||
|
* a gated licence needs ``acknowledge_licence``, which is stored with who/when
|
||||||
|
and audited -- and an audit failure refuses the enable (fail closed);
|
||||||
|
* an acknowledgement given for a different licence status no longer counts;
|
||||||
|
* the admin routes are admin-only;
|
||||||
|
* the ODIN parser reads the recorded public payloads, and an all-unparseable
|
||||||
|
payload is an error, not an empty layer;
|
||||||
|
* an area no serving provider covers says so instead of returning a bare [].
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal.auth import require_admin_token, require_service_or_firebase_token
|
||||||
|
from app.internal.outages import base, providers, registry
|
||||||
|
from app.internal.outages.base import Licence
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "admin@example.com", "role": "admin"}
|
||||||
|
FIXTURE = json.loads((Path(__file__).parent / "fixtures" / "odin_public_2026-10-05.json").read_text())
|
||||||
|
STUBS = ("poweroutage_us", "coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live")
|
||||||
|
NY_BBOX = (42.5, -78.5, 43.5, -75.5) # Rochester .. Syracuse
|
||||||
|
RECORDED_AT = datetime(2026, 10, 5, 6, 45, tzinfo=timezone.utc) # when the fixture was captured
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.docs = {}
|
||||||
|
self.audit = []
|
||||||
|
|
||||||
|
async def doc_get(self, col, doc):
|
||||||
|
return self.docs.get((col, doc))
|
||||||
|
|
||||||
|
async def doc_set(self, col, doc, data, merge=True):
|
||||||
|
cur = self.docs.setdefault((col, doc), {})
|
||||||
|
if col == "audit_log":
|
||||||
|
self.audit.append(data)
|
||||||
|
return
|
||||||
|
for k, v in data.items(): # one-level deep merge is all registry needs
|
||||||
|
if merge and isinstance(v, dict) and isinstance(cur.get(k), dict):
|
||||||
|
cur[k] = {**cur[k], **v}
|
||||||
|
else:
|
||||||
|
cur[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def store():
|
||||||
|
s = FakeStore()
|
||||||
|
registry.invalidate_cache()
|
||||||
|
for feed in registry.FEEDS.values():
|
||||||
|
feed.items, feed.fetched_at, feed.error, feed._next_attempt = [], None, None, 0.0
|
||||||
|
with patch.object(registry.fstore, "doc_get", s.doc_get), patch.object(registry.fstore, "doc_set", s.doc_set), \
|
||||||
|
patch("app.internal.audit.fstore.doc_set", s.doc_set):
|
||||||
|
yield s
|
||||||
|
registry.invalidate_cache()
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def run(coro):
|
||||||
|
return asyncio.run(coro)
|
||||||
|
|
||||||
|
|
||||||
|
def _odin_areas():
|
||||||
|
return providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
|
||||||
|
now=RECORDED_AT)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ── registry / gate ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_every_catalogued_source_is_registered_and_defaults_off():
|
||||||
|
listed = run(registry.list_providers())
|
||||||
|
assert {p["id"] for p in listed} == {"odin", *STUBS}
|
||||||
|
assert all(p["enabled"] is False and p["serving"] is False for p in listed)
|
||||||
|
assert all(p["licence"]["label"] for p in listed)
|
||||||
|
odin = next(p for p in listed if p["id"] == "odin")
|
||||||
|
assert odin["licence"]["status"] == "unverified" and odin["licence"]["gated"] is True
|
||||||
|
assert odin["licence"]["terms_url"] == "https://odin.ornl.gov/v3/api-docs"
|
||||||
|
assert odin["not_serving_reason"] == "disabled (default)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogued_licence_statuses_match_the_source_terms():
|
||||||
|
lic = {pid: p.meta.licence.status for pid, p in registry.PROVIDERS.items()}
|
||||||
|
assert lic["poweroutage_us"] == Licence.PAID
|
||||||
|
for pid in ("coned_oru_stormcenter", "nyseg_rge_stormcenter", "central_hudson_stormcenter", "eagle_i_live"):
|
||||||
|
assert lic[pid] == Licence.AGREEMENT_REQUIRED, pid
|
||||||
|
assert all(not registry.PROVIDERS[pid].meta.implemented for pid in STUBS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_warning_is_worded_for_outage_data():
|
||||||
|
w = registry.PROVIDERS["odin"].meta.licence.warning()
|
||||||
|
assert "outage data" in w and "imagery" not in w
|
||||||
|
|
||||||
|
|
||||||
|
def test_off_provider_is_never_fetched():
|
||||||
|
fetch = AsyncMock(return_value=[])
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
|
||||||
|
out = run(registry.outages_in_bbox(NY_BBOX))
|
||||||
|
assert out["areas"] == [] and out["providers"] == [] and fetch.await_count == 0
|
||||||
|
assert out["serving_count"] == 0 and out["status"] == "none_enabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stubs_cannot_be_enabled():
|
||||||
|
for pid in STUBS:
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled(pid, True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
assert e.value.code == 409, pid
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_licence_requires_acknowledgement(store):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled("odin", True, ("u1", "a@x")))
|
||||||
|
assert e.value.code == 409 and e.value.extra["requires_acknowledgement"] is True
|
||||||
|
assert "Licence unverified" in e.value.extra["warning"]
|
||||||
|
assert not store.audit and not run(registry.is_serving("odin"))
|
||||||
|
|
||||||
|
out = run(registry.set_enabled("odin", True, ("u1", "a@x"), acknowledge_licence=True))
|
||||||
|
assert out["serving"] is True
|
||||||
|
ack = out["acknowledgement"]
|
||||||
|
assert ack["by_uid"] == "u1" and ack["by_email"] == "a@x" and ack["at"]
|
||||||
|
assert ack["licence_status"] == Licence.UNVERIFIED.value
|
||||||
|
assert store.audit[-1]["action"] == "outage_provider.enable_acknowledged"
|
||||||
|
assert store.audit[-1]["details"]["provider_id"] == "odin"
|
||||||
|
assert store.docs[("config", "outage_providers")]["providers"]["odin"]["enabled"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_gated_enable_refused_when_audit_cannot_be_written(store):
|
||||||
|
with patch.object(registry, "_audit", AsyncMock(side_effect=RuntimeError("firestore down"))):
|
||||||
|
with pytest.raises(registry.ProviderError) as e:
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
assert e.value.code == 503
|
||||||
|
assert not run(registry.is_serving("odin"))
|
||||||
|
assert ("config", "outage_providers") not in store.docs
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_clears_acknowledgement_and_is_audited(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
out = run(registry.set_enabled("odin", False, ("u", "e")))
|
||||||
|
assert out["serving"] is False and out["acknowledgement"] is None
|
||||||
|
assert store.audit[-1]["action"] == "outage_provider.disable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_acknowledgement_stops_serving():
|
||||||
|
p = registry.PROVIDERS["odin"]
|
||||||
|
entry = {"enabled": True, "acknowledgement": {"licence_status": "paid"}}
|
||||||
|
assert registry.effective(p, entry) == (False, "licence acknowledgement missing or out of date")
|
||||||
|
entry["acknowledgement"]["licence_status"] = p.meta.licence.status.value
|
||||||
|
assert registry.effective(p, entry) == (True, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_settings_fail_closed():
|
||||||
|
with patch.object(registry.fstore, "doc_get", AsyncMock(side_effect=RuntimeError("boom"))):
|
||||||
|
registry.invalidate_cache()
|
||||||
|
assert run(registry.get_settings()) == {}
|
||||||
|
assert not run(registry.is_serving("odin"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── bbox query ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_query_returns_areas_in_bbox_with_credit(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(return_value=_odin_areas())):
|
||||||
|
out = run(registry.outages_in_bbox(NY_BBOX))
|
||||||
|
assert out["status"] == "ok" and out["serving_count"] == 1
|
||||||
|
assert {a["fips"] for a in out["areas"]} == {"36055", "36067"} # not Albany, not King County WA
|
||||||
|
prov = out["providers"][0]
|
||||||
|
assert prov["attribution"].startswith("Outage data: ODIN") and prov["disclaimer"]
|
||||||
|
assert prov["total_in_bbox"] == 2 and prov["fetched_at"] and prov["error"] is None
|
||||||
|
assert "Only utilities that report to ODIN appear" in prov["coverage_note"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_no_serving_provider_covers_says_so(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
fetch = AsyncMock(return_value=_odin_areas())
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", fetch):
|
||||||
|
out = run(registry.outages_in_bbox((51.4, -0.2, 51.6, 0.1))) # London
|
||||||
|
assert fetch.await_count == 0 # outside ODIN coverage -> never fetched
|
||||||
|
assert out["areas"] == [] and out["status"] == "no_source"
|
||||||
|
assert out["message"] == "No licensed outage source covers this area."
|
||||||
|
|
||||||
|
|
||||||
|
def test_dead_feed_is_an_error_not_an_empty_layer(store):
|
||||||
|
run(registry.set_enabled("odin", True, ("u", "e"), acknowledge_licence=True))
|
||||||
|
with patch.object(registry.PROVIDERS["odin"], "fetch", AsyncMock(side_effect=ValueError("schema change?"))):
|
||||||
|
out = run(registry.outages_in_bbox(NY_BBOX))
|
||||||
|
assert out["status"] == "error" and "schema change" in out["providers"][0]["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_refresh_keeps_last_good_feed():
|
||||||
|
feed = registry.FEEDS["odin"]
|
||||||
|
with patch.object(feed.provider, "fetch", AsyncMock(return_value=_odin_areas())):
|
||||||
|
run(feed.get())
|
||||||
|
feed._next_attempt = 0.0
|
||||||
|
with patch.object(feed.provider, "fetch", AsyncMock(side_effect=RuntimeError("down"))):
|
||||||
|
run(feed.get())
|
||||||
|
assert len(feed.items) == 4 and "down" in feed.error and feed.fetched_at
|
||||||
|
|
||||||
|
|
||||||
|
# ── admin routes ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_admin_routes_require_admin():
|
||||||
|
def deny():
|
||||||
|
raise HTTPException(403, "Admin access required")
|
||||||
|
app.dependency_overrides[require_admin_token] = deny
|
||||||
|
assert client.get("/admin/outage-providers").status_code == 403
|
||||||
|
assert client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_route_toggle_and_gate(store):
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: ADMIN
|
||||||
|
r = client.put("/admin/outage-providers/odin", json={"enabled": True})
|
||||||
|
assert r.status_code == 409 and r.json()["requires_acknowledgement"] is True
|
||||||
|
assert r.json()["licence"]["status"] == "unverified"
|
||||||
|
r = client.put("/admin/outage-providers/odin", json={"enabled": True, "acknowledge_licence": True})
|
||||||
|
assert r.status_code == 200 and r.json()["acknowledgement"]["by_email"] == "admin@example.com"
|
||||||
|
assert client.put("/admin/outage-providers/poweroutage_us",
|
||||||
|
json={"enabled": True, "acknowledge_licence": True}).status_code == 409
|
||||||
|
assert client.put("/admin/outage-providers/nope", json={"enabled": True}).status_code == 404
|
||||||
|
listed = client.get("/admin/outage-providers").json()["providers"]
|
||||||
|
assert next(p for p in listed if p["id"] == "odin")["serving"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_outages_route_rejects_inverted_bbox_and_reports_status():
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"uid": "v"}
|
||||||
|
assert client.get("/outages", params={"south": 2, "west": 0, "north": 1, "east": 1}).status_code == 400
|
||||||
|
r = client.get("/outages", params={"south": 40.9, "west": -74.0, "north": 41.4, "east": -73.4})
|
||||||
|
assert r.status_code == 200 and r.json()["areas"] == [] and r.json()["status"] == "none_enabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_outages_route_requires_sign_in():
|
||||||
|
def deny():
|
||||||
|
raise HTTPException(401, "Not signed in")
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = deny
|
||||||
|
assert client.get("/outages", params={"south": 40, "west": -74, "north": 41, "east": -73}).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── ODIN adapter (recorded public payloads, 2026-10-05) ──────────────────────
|
||||||
|
|
||||||
|
def test_odin_parser_reads_recorded_payload():
|
||||||
|
areas, info = providers.parse_odin(FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
|
||||||
|
now=RECORDED_AT)
|
||||||
|
by = {a["fips"]: a for a in areas}
|
||||||
|
assert set(by) == {"36001", "36067", "36055", "53033"}
|
||||||
|
onondaga = by["36067"]
|
||||||
|
assert onondaga["id"] == "odin:36067" and onondaga["provider"] == "odin"
|
||||||
|
assert onondaga["county"] == "Onondaga" and onondaga["state"] == "New York"
|
||||||
|
assert onondaga["customers_out"] == 11 and onondaga["customers_served"] is None
|
||||||
|
assert onondaga["etr"] == "2026-10-05T08:00:00Z" and onondaga["since"] == "2026-10-04T18:35:40Z"
|
||||||
|
assert onondaga["updated_at"].startswith("2026-10-05T06:20:47")
|
||||||
|
assert onondaga["utilities"] == [{"name": "NATIONAL GRID", "eia_id": "13573", "customers_out": 11,
|
||||||
|
"stale_customers_out": 0}]
|
||||||
|
assert onondaga["stale"] is False and onondaga["stale_customers_out"] == 0
|
||||||
|
s, w, n, e = onondaga["bbox"]
|
||||||
|
assert s < onondaga["lat"] < n and w < onondaga["lon"] < e
|
||||||
|
assert 42.7 < onondaga["lat"] < 43.3 and -76.6 < onondaga["lon"] < -75.8
|
||||||
|
# King County WA: several outage events from two utilities, summed per county
|
||||||
|
king = by["53033"]
|
||||||
|
assert king["customers_out"] == sum(r["metersOut"] for r in FIXTURE["odi_map_scale_county"] if r["id"] == "53033")
|
||||||
|
assert len(king["utilities"]) == 2
|
||||||
|
# Puget Sound Energy's "0001-01-01T00:00:00Z" means no ETR -- not a restoration time, not stale.
|
||||||
|
assert king["etr"] is None and king["stale_customers_out"] == 0
|
||||||
|
assert info["reporting_utilities"] == len(FIXTURE["odi_status"]) and info["utilities_with_error"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_odin_events_long_past_their_etr_are_stale_not_live():
|
||||||
|
# ODIN still listed Avangrid's Monroe County row from April 2026 (ETR
|
||||||
|
# 2026-04-10) on 2026-10-05. It must not read as 18 customers out right now.
|
||||||
|
by = {a["fips"]: a for a in _odin_areas()}
|
||||||
|
monroe = by["36055"]
|
||||||
|
assert monroe["customers_out"] == 0 and monroe["stale_customers_out"] == 18 and monroe["stale"] is True
|
||||||
|
assert monroe["since"].startswith("2026-04-10") # still shown, so the stale row is explainable
|
||||||
|
# Same payload, captured the day the outage was live: not stale.
|
||||||
|
live = {a["fips"]: a for a in providers.parse_odin(
|
||||||
|
FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"],
|
||||||
|
now=datetime(2026, 4, 10, 20, 0, tzinfo=timezone.utc))[0]}
|
||||||
|
assert live["36055"]["customers_out"] == 18 and live["36055"]["stale"] is False
|
||||||
|
# Live counties sort ahead of stale ones.
|
||||||
|
assert [a["stale"] for a in _odin_areas()] == sorted(a["stale"] for a in _odin_areas())
|
||||||
|
|
||||||
|
|
||||||
|
def test_odin_fetch_uses_the_three_public_endpoints():
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_get_json(url, params=None, headers=None):
|
||||||
|
calls.append((url, params))
|
||||||
|
return {"https://odin.ornl.gov/odi": FIXTURE["odi_format_json"],
|
||||||
|
"https://odin.ornl.gov/odi/map": FIXTURE["odi_map_scale_county"],
|
||||||
|
"https://odin.ornl.gov/odi/status": FIXTURE["odi_status"]}[url]
|
||||||
|
|
||||||
|
p = providers.OdinProvider()
|
||||||
|
with patch.object(providers, "get_json", fake_get_json):
|
||||||
|
areas = run(p.fetch())
|
||||||
|
assert len(areas) == 4
|
||||||
|
assert ("https://odin.ornl.gov/odi", {"format": "JSON"}) in calls
|
||||||
|
assert ("https://odin.ornl.gov/odi/map", {"scale": "COUNTY"}) in calls
|
||||||
|
assert p.reporting_utilities == len(FIXTURE["odi_status"])
|
||||||
|
assert "utilities reporting nationwide" in p.coverage_note()
|
||||||
|
|
||||||
|
|
||||||
|
def test_odin_schema_canary_trips_on_unparseable_payload():
|
||||||
|
odi, cmap, status = FIXTURE["odi_format_json"], FIXTURE["odi_map_scale_county"], FIXTURE["odi_status"]
|
||||||
|
renamed_odi = {"outage": [{"county": "36067", "count": 11}]} # fields renamed upstream
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin(renamed_odi, cmap, status)
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin(odi, [{"fips": "36067"}], status)
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin(odi, cmap, [{"utility": "x"}])
|
||||||
|
with pytest.raises(ValueError, match="schema"):
|
||||||
|
providers.parse_odin([], cmap, status) # wrapper changed
|
||||||
|
# A genuinely quiet day is NOT an error: nothing upstream, nothing parsed.
|
||||||
|
assert providers.parse_odin({"outage": []}, [], status)[0] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_area_in_bbox_uses_county_extent():
|
||||||
|
a = base.make_area("odin", "36067", county="Onondaga", state="New York", customers_out=1,
|
||||||
|
lat=43.0, lon=-76.2, bbox=(42.77, -76.5, 43.27, -75.9))
|
||||||
|
assert base.area_in_bbox(a, (43.2, -76.0, 43.5, -75.5)) # overlaps the county's corner only
|
||||||
|
assert not base.area_in_bbox(a, (44.0, -76.0, 44.5, -75.5))
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""
|
||||||
|
server-26#237 — weather backend: NWS active alerts + nearest-METAR wind.
|
||||||
|
|
||||||
|
Pins the failure shapes that would otherwise look like "no weather right now":
|
||||||
|
an upstream error keeps last-good data AND reports the error, a schema change is
|
||||||
|
an error (not an empty layer), and zone-based alerts (geometry null) are never
|
||||||
|
dropped. Also pins the NWS contract: User-Agent sent, one upstream call per TTL.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.internal import weather
|
||||||
|
from app.internal.auth import require_service_or_firebase_token
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
SQUARE = {"type": "Polygon", "coordinates": [[[-74.0, 41.0], [-73.5, 41.0], [-73.5, 41.5], [-74.0, 41.5], [-74.0, 41.0]]]}
|
||||||
|
ZONE_POLY = {"type": "Polygon", "coordinates": [[[-75.0, 42.0], [-74.5, 42.0], [-74.5, 42.5], [-75.0, 42.0]]]}
|
||||||
|
ZONE_A = "https://api.weather.gov/zones/forecast/NYZ067"
|
||||||
|
ZONE_B = "https://api.weather.gov/zones/forecast/NYZ999"
|
||||||
|
|
||||||
|
|
||||||
|
def _alert(aid, event, geometry=None, zones=()):
|
||||||
|
return {"id": f"https://api.weather.gov/alerts/{aid}", "type": "Feature", "geometry": geometry,
|
||||||
|
"properties": {"id": aid, "event": event, "severity": "Severe", "urgency": "Immediate",
|
||||||
|
"headline": f"{event} issued by NWS Upton NY", "onset": "2026-10-05T10:00:00-04:00",
|
||||||
|
"expires": "2026-10-05T12:00:00-04:00", "ends": None, "senderName": "NWS Upton NY",
|
||||||
|
"areaDesc": "Westchester", "affectedZones": list(zones)}}
|
||||||
|
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def __init__(self, body=None, status=200):
|
||||||
|
self._body, self.status_code = body, status
|
||||||
|
self.content = b"" if body is None else b"{}"
|
||||||
|
|
||||||
|
def raise_for_status(self):
|
||||||
|
if self.status_code >= 400:
|
||||||
|
raise RuntimeError(f"HTTP {self.status_code}")
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._body
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHttp:
|
||||||
|
"""Routes GETs by URL; records every call (url, params, headers)."""
|
||||||
|
def __init__(self, routes):
|
||||||
|
self.routes, self.calls = routes, []
|
||||||
|
|
||||||
|
def __call__(self, **kw):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get(self, url, params=None, headers=None):
|
||||||
|
self.calls.append((url, params, headers))
|
||||||
|
r = self.routes.get(url)
|
||||||
|
if r is None:
|
||||||
|
return Resp(status=404)
|
||||||
|
return r() if callable(r) else r
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function():
|
||||||
|
app.dependency_overrides[require_service_or_firebase_token] = lambda: {"admin": True}
|
||||||
|
weather.alerts = weather._AlertsFeed()
|
||||||
|
weather.zones.clear()
|
||||||
|
weather._wind_cells.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.pop(require_service_or_firebase_token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _nws(features, extra=None):
|
||||||
|
routes = {weather.NWS_ALERTS_URL: Resp({"type": "FeatureCollection", "features": features})}
|
||||||
|
routes.update(extra or {})
|
||||||
|
return FakeHttp(routes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_happy_path_normalises_and_sends_user_agent():
|
||||||
|
http = _nws([_alert("a1", "Severe Thunderstorm Warning", SQUARE)])
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http), \
|
||||||
|
patch.object(weather.settings, "nws_user_agent", "DRB (ops@example.com)"), \
|
||||||
|
patch.object(weather.settings, "nws_area", "ny, ct"):
|
||||||
|
feed = asyncio.run(weather.alerts.get())
|
||||||
|
url, params, headers = http.calls[0]
|
||||||
|
assert params == {"area": "NY,CT"}
|
||||||
|
assert headers["User-Agent"] == "DRB (ops@example.com)" and headers["Accept"] == "application/geo+json"
|
||||||
|
a = feed.alerts[0]
|
||||||
|
assert {k: a[k] for k in ("id", "event", "severity", "urgency", "sender_name", "expires")} == {
|
||||||
|
"id": "a1", "event": "Severe Thunderstorm Warning", "severity": "Severe", "urgency": "Immediate",
|
||||||
|
"sender_name": "NWS Upton NY", "expires": "2026-10-05T12:00:00-04:00"}
|
||||||
|
assert a["geometry"] == SQUARE and a["geometry_source"] == "alert"
|
||||||
|
assert a["bbox"] == [41.0, -74.0, 41.5, -73.5]
|
||||||
|
assert feed.error is None and feed.fetched_at is not None and feed.zone_only == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_geometry_resolves_zones_or_lands_in_zone_only():
|
||||||
|
http = _nws(
|
||||||
|
[_alert("z1", "Flood Watch", None, [ZONE_A]), # zone resolves -> polygon
|
||||||
|
_alert("z2", "Heat Advisory", None, [ZONE_B]), # zone 404s -> zone_only, not dropped
|
||||||
|
_alert("z3", "Wind Advisory", None, ["https://evil.example/zones/x"])], # never fetched
|
||||||
|
{ZONE_A: Resp({"geometry": ZONE_POLY, "properties": {}})})
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
feed = asyncio.run(weather.alerts.get())
|
||||||
|
assert [a["id"] for a in feed.alerts] == ["z1"]
|
||||||
|
assert feed.alerts[0]["geometry"] == ZONE_POLY and feed.alerts[0]["geometry_source"] == "zones"
|
||||||
|
assert feed.alerts[0]["zones_unresolved"] == 0
|
||||||
|
assert sorted(z["id"] for z in feed.zone_only) == ["z2", "z3"]
|
||||||
|
assert feed.zone_only[0]["expires"] and feed.zone_only[0]["event"]
|
||||||
|
assert not any("evil.example" in c[0] for c in http.calls)
|
||||||
|
|
||||||
|
# second refresh: zone cache (24 h) means ZONE_A is not fetched again
|
||||||
|
weather.alerts._next_attempt = 0.0
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
asyncio.run(weather.alerts.get())
|
||||||
|
assert sum(1 for c in http.calls if c[0] == ZONE_A) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_upstream_500_keeps_last_good_and_reports_error():
|
||||||
|
ok = _nws([_alert("a1", "Tornado Warning", SQUARE)])
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", ok):
|
||||||
|
asyncio.run(weather.alerts.get())
|
||||||
|
good_at = weather.alerts.fetched_at
|
||||||
|
weather.alerts._next_attempt = 0.0
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.NWS_ALERTS_URL: Resp(status=500)})):
|
||||||
|
feed = asyncio.run(weather.alerts.get())
|
||||||
|
assert [a["id"] for a in feed.alerts] == ["a1"] and feed.fetched_at == good_at
|
||||||
|
assert "500" in feed.error
|
||||||
|
assert feed._next_attempt - __import__("time").time() <= weather.RETRY_AFTER_FAILURE_S + 1
|
||||||
|
|
||||||
|
r = client.get("/weather/alerts")
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200 and body["error"] and len(body["alerts"]) == 1
|
||||||
|
assert body["fetched_at"] == good_at and body["credit"] == "National Weather Service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_canary_trips_when_no_feature_parses():
|
||||||
|
http = FakeHttp({weather.NWS_ALERTS_URL: Resp({"features": [{"props": {}}, {"properties": {"name": "x"}}]})})
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
feed = asyncio.run(weather.alerts.get())
|
||||||
|
assert feed.alerts == [] and "schema" in feed.error
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_feature_list_is_not_an_error():
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", _nws([])):
|
||||||
|
feed = asyncio.run(weather.alerts.get())
|
||||||
|
assert feed.error is None and feed.fetched_at is not None and feed.alerts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_one_upstream_call_per_ttl_even_when_concurrent():
|
||||||
|
http = _nws([_alert("a1", "Tornado Warning", SQUARE)])
|
||||||
|
|
||||||
|
async def many():
|
||||||
|
await asyncio.gather(*(weather.alerts.get() for _ in range(8)))
|
||||||
|
await weather.alerts.get()
|
||||||
|
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
asyncio.run(many())
|
||||||
|
assert sum(1 for c in http.calls if c[0] == weather.NWS_ALERTS_URL) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_endpoint_bbox_filter_and_validation():
|
||||||
|
far = {"type": "Polygon", "coordinates": [[[-79.0, 42.8], [-78.5, 42.8], [-78.5, 43.0], [-79.0, 42.8]]]} # Buffalo
|
||||||
|
http = _nws([_alert("near", "Flood Warning", SQUARE), _alert("far", "Lake Effect Snow Warning", far),
|
||||||
|
_alert("zo", "Heat Advisory", None, [ZONE_B])])
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
r = client.get("/weather/alerts", params={"south": 40.9, "west": -74.2, "north": 41.2, "east": -73.4})
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200 and [a["id"] for a in body["alerts"]] == ["near"]
|
||||||
|
assert [z["id"] for z in body["zone_only"]] == ["zo"] # never bbox-filtered away
|
||||||
|
assert len(client.get("/weather/alerts").json()["alerts"]) == 2
|
||||||
|
assert client.get("/weather/alerts", params={"south": 41.4, "west": -74, "north": 40.9, "east": -73}).status_code == 400
|
||||||
|
assert client.get("/weather/alerts", params={"south": 40.9}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def _metar(icao, lat, lon, wdir, wspd, wgst=None, obs=None):
|
||||||
|
return {"icaoId": icao, "name": f"{icao} airport", "lat": lat, "lon": lon,
|
||||||
|
"obsTime": obs or int(__import__("time").time()) - 600, "reportTime": "2026-10-05T06:48:00.000Z",
|
||||||
|
"wdir": wdir, "wspd": wspd, "wgst": wgst, "rawOb": f"METAR {icao} ..."}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wind_picks_nearest_station_and_caches_per_cell():
|
||||||
|
rows = [_metar("KLGA", 40.7794, -73.8803, 310, 6),
|
||||||
|
_metar("KHPN", 41.067, -73.7076, "VRB", 4, 18),
|
||||||
|
_metar("KHPN", 41.067, -73.7076, 200, 3, obs=1)] # older duplicate report
|
||||||
|
http = FakeHttp({weather.METAR_URL: Resp(rows)})
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
r = client.get("/weather/wind", params={"lat": 41.03, "lon": -73.76}) # White Plains
|
||||||
|
body = r.json()
|
||||||
|
assert r.status_code == 200 and body["available"] is True
|
||||||
|
w = body["wind"]
|
||||||
|
assert w["station"] == "KHPN" and w["distance_km"] < 10
|
||||||
|
assert w["wind_dir_deg"] is None and w["variable"] is True and w["wind_kt"] == 4 and w["gust_kt"] == 18
|
||||||
|
assert w["stale"] is False and body["credit"]
|
||||||
|
client.get("/weather/wind", params={"lat": 41.04, "lon": -73.77}) # same cell -> cached
|
||||||
|
assert len(http.calls) == 1
|
||||||
|
params, headers = http.calls[0][1], http.calls[0][2]
|
||||||
|
s, w_, n, e = (float(v) for v in params["bbox"].split(","))
|
||||||
|
assert s < 41.03 < n and w_ < -73.76 < e and params["format"] == "json"
|
||||||
|
assert "User-Agent" in headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_wind_unavailable_reasons():
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.METAR_URL: Resp(None, status=204)})):
|
||||||
|
body = client.get("/weather/wind", params={"lat": 30.0, "lon": -50.0}).json()
|
||||||
|
assert body["available"] is False and "no METAR station" in body["reason"] and body["error"] is None
|
||||||
|
|
||||||
|
weather._wind_cells.clear()
|
||||||
|
with patch.object(weather.httpx, "AsyncClient", FakeHttp({weather.METAR_URL: Resp(status=503)})):
|
||||||
|
body = client.get("/weather/wind", params={"lat": 41.03, "lon": -73.76}).json()
|
||||||
|
assert body["available"] is False and "unavailable" in body["reason"] and "503" in body["error"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_zone_endpoint_cannot_hold_the_feed_lock():
|
||||||
|
"""Zone fetches run under the feed lock; a hung zone endpoint must not stall
|
||||||
|
every /weather/alerts request -- the alert lands in zone_only instead."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
class SlowZones(FakeHttp):
|
||||||
|
async def get(self, url, params=None, headers=None):
|
||||||
|
if url.startswith(weather.NWS_ZONE_PREFIX):
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
return await super().get(url, params, headers)
|
||||||
|
|
||||||
|
http = SlowZones({weather.NWS_ALERTS_URL: Resp({"features": [_alert("z1", "Flood Watch", None, [ZONE_A])]}),
|
||||||
|
ZONE_A: Resp({"geometry": ZONE_POLY})})
|
||||||
|
with patch.object(weather, "ZONE_BUDGET_S", 0.2), patch.object(weather.httpx, "AsyncClient", http):
|
||||||
|
t0 = time.time()
|
||||||
|
feed = asyncio.run(weather.alerts.get())
|
||||||
|
assert time.time() - t0 < 2
|
||||||
|
assert feed.error is None and [z["id"] for z in feed.zone_only] == ["z1"]
|
||||||
|
assert weather.zones.get(ZONE_A) == (False, None) # not cached as a failure; retried next refresh
|
||||||
+124
-114
@@ -4,6 +4,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { ReplayTab } from "@/components/admin/ReplayTab";
|
import { ReplayTab } from "@/components/admin/ReplayTab";
|
||||||
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
|
import { CameraProvidersTab } from "@/components/admin/CameraProvidersTab";
|
||||||
|
import { OutageProvidersTab } from "@/components/admin/OutageProvidersTab";
|
||||||
import { useEffect, useState, useRef, useCallback } from "react";
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
import type { UserRecord, AuditEntry, UserRole, CallRecord } from "@/lib/types";
|
||||||
@@ -84,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
|
|||||||
const ROLE_COLORS: Record<UserRole, string> = {
|
const ROLE_COLORS: Record<UserRole, string> = {
|
||||||
admin: "bg-indigo-900 text-indigo-300",
|
admin: "bg-indigo-900 text-indigo-300",
|
||||||
operator: "bg-green-900 text-green-300",
|
operator: "bg-green-900 text-green-300",
|
||||||
viewer: "bg-gray-800 text-gray-400",
|
viewer: "bg-raised text-ink-2",
|
||||||
};
|
};
|
||||||
|
|
||||||
function RoleBadge({ role }: { role: UserRole }) {
|
function RoleBadge({ role }: { role: UserRole }) {
|
||||||
@@ -135,14 +136,14 @@ function FeaturesTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
{FLAG_META.map(({ key, label, description }) => (
|
{FLAG_META.map(({ key, label, description }) => (
|
||||||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-white text-sm font-semibold">{label}</p>
|
<p className="text-white text-sm font-semibold">{label}</p>
|
||||||
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
<p className="text-ink-muted text-xs mt-0.5 leading-snug">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
enabled={flags?.[key] ?? true}
|
enabled={flags?.[key] ?? true}
|
||||||
@@ -237,30 +238,30 @@ function CorrelationDebugTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
|
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
|
||||||
Copy the JSON and paste it to Claude to diagnose correlation problems.
|
Copy the JSON and paste it to Claude to diagnose correlation problems.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label>
|
<label className="text-xs text-ink-2 block mb-1">Incidents (limit)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={100}
|
min={1} max={100}
|
||||||
value={limit}
|
value={limit}
|
||||||
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||||||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-24 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label>
|
<label className="text-xs text-ink-2 block mb-1">Orphan scan window (hours)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={168}
|
min={1} max={168}
|
||||||
value={orphanHours}
|
value={orphanHours}
|
||||||
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
|
||||||
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -280,12 +281,12 @@ function CorrelationDebugTab() {
|
|||||||
|
|
||||||
{meta && (
|
{meta && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
|
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors"
|
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-line-strong bg-surface text-ink-2 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy JSON"}
|
{copied ? "Copied!" : "Copy JSON"}
|
||||||
</button>
|
</button>
|
||||||
@@ -294,22 +295,22 @@ function CorrelationDebugTab() {
|
|||||||
|
|
||||||
{meta && Object.keys(pathCounts).length > 0 && (
|
{meta && Object.keys(pathCounts).length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p>
|
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">corr_path distribution</p>
|
||||||
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
|
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
|
||||||
<div key={path} className="flex justify-between text-xs font-mono">
|
<div key={path} className="flex justify-between text-xs font-mono">
|
||||||
<span className="text-gray-300">{path}</span>
|
<span className="text-ink-2">{path}</span>
|
||||||
<span className="text-indigo-400">{n}</span>
|
<span className="text-indigo-400">{n}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p>
|
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">fit_signal distribution</p>
|
||||||
{Object.keys(signalCounts).length === 0
|
{Object.keys(signalCounts).length === 0
|
||||||
? <p className="text-xs text-gray-600 font-mono">No signal data yet — deploy correlator update first</p>
|
? <p className="text-xs text-ink-muted font-mono">No signal data yet — deploy correlator update first</p>
|
||||||
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
|
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
|
||||||
<div key={sig} className="flex justify-between text-xs font-mono">
|
<div key={sig} className="flex justify-between text-xs font-mono">
|
||||||
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span>
|
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-ink-2"}>{sig}</span>
|
||||||
<span className="text-indigo-400">{n}</span>
|
<span className="text-indigo-400">{n}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -319,13 +320,13 @@ function CorrelationDebugTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
|
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
||||||
{meta.orphans_by_talkgroup.map((tg) => (
|
{meta.orphans_by_talkgroup.map((tg) => (
|
||||||
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
|
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
|
||||||
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span>
|
<span className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
|
||||||
<span className="text-gray-400">
|
<span className="text-ink-2">
|
||||||
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
|
{tg.count} total · <span className="text-ink-muted">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -335,7 +336,7 @@ function CorrelationDebugTab() {
|
|||||||
{json && (
|
{json && (
|
||||||
<pre
|
<pre
|
||||||
ref={preRef}
|
ref={preRef}
|
||||||
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre"
|
className="bg-page border border-line rounded-xl p-4 text-xs text-ink-2 font-mono overflow-auto max-h-[60vh] whitespace-pre"
|
||||||
>
|
>
|
||||||
{json}
|
{json}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -454,13 +455,13 @@ function UserDetailPanel({
|
|||||||
const isSelf = user.uid === currentUid;
|
const isSelf = user.uid === currentUid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-5 space-y-5 font-mono">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-semibold">{detail.email}</p>
|
<p className="text-white font-semibold">{detail.email}</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
|
<p className="text-xs text-ink-muted mt-0.5">{detail.uid}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
|
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors text-xl leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -471,21 +472,21 @@ function UserDetailPanel({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Display Name</label>
|
||||||
<input
|
<input
|
||||||
value={editName}
|
value={editName}
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Full name"
|
placeholder="Full name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
<label className="text-xs text-ink-2 block mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={editRole}
|
value={editRole}
|
||||||
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="admin">Admin — full access</option>
|
<option value="admin">Admin — full access</option>
|
||||||
<option value="operator">Operator — owns nodes</option>
|
<option value="operator">Operator — owns nodes</option>
|
||||||
@@ -495,13 +496,13 @@ function UserDetailPanel({
|
|||||||
|
|
||||||
{editRole === "operator" && (
|
{editRole === "operator" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={editNodes}
|
value={editNodes}
|
||||||
onChange={(e) => setEditNodes(e.target.value)}
|
onChange={(e) => setEditNodes(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="node-abc123, node-def456"
|
placeholder="node-abc123, node-def456"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -516,10 +517,10 @@ function UserDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
<div className="border-t border-line pt-4 space-y-2 text-xs">
|
||||||
<div className="flex justify-between items-center gap-3">
|
<div className="flex justify-between items-center gap-3">
|
||||||
<span className="text-gray-500">Organization</span>
|
<span className="text-ink-muted">Organization</span>
|
||||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
|
||||||
{!detail.org_id
|
{!detail.org_id
|
||||||
? "None: sees no data"
|
? "None: sees no data"
|
||||||
: detail.org_id === myOrgId
|
: detail.org_id === myOrgId
|
||||||
@@ -537,34 +538,34 @@ function UserDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Status</span>
|
<span className="text-ink-muted">Status</span>
|
||||||
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||||||
{detail.disabled ? "Disabled" : "Active"}
|
{detail.disabled ? "Disabled" : "Active"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Discord</span>
|
<span className="text-ink-muted">Discord</span>
|
||||||
<span className="text-gray-300">
|
<span className="text-ink-2">
|
||||||
{detail.discord_linked
|
{detail.discord_linked
|
||||||
? `@${detail.discord_username ?? detail.discord_user_id}`
|
? `@${detail.discord_username ?? detail.discord_user_id}`
|
||||||
: "Not linked"}
|
: "Not linked"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Created</span>
|
<span className="text-ink-muted">Created</span>
|
||||||
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
|
<span className="text-ink-2">{fmtDate(detail.creation_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Last sign-in</span>
|
<span className="text-ink-muted">Last sign-in</span>
|
||||||
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
|
<span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(detail.sessions?.length ?? 0) > 0 && (
|
{(detail.sessions?.length ?? 0) > 0 && (
|
||||||
<div className="border-t border-gray-800 pt-4">
|
<div className="border-t border-line pt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSessions((v) => !v)}
|
onClick={() => setShowSessions((v) => !v)}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{showSessions ? "▲" : "▼"}</span>
|
<span>{showSessions ? "▲" : "▼"}</span>
|
||||||
<span>Login history ({detail.sessions?.length} recent)</span>
|
<span>Login history ({detail.sessions?.length} recent)</span>
|
||||||
@@ -572,9 +573,9 @@ function UserDetailPanel({
|
|||||||
{showSessions && (
|
{showSessions && (
|
||||||
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
{detail.sessions?.map((s) => (
|
{detail.sessions?.map((s) => (
|
||||||
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
|
<div key={s.session_id} className="text-xs text-ink-2 flex justify-between gap-4">
|
||||||
<span>{fmtDatetime(s.timestamp)}</span>
|
<span>{fmtDatetime(s.timestamp)}</span>
|
||||||
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
|
<span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -582,7 +583,7 @@ function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
|
<div className="border-t border-line pt-4 flex gap-4 flex-wrap">
|
||||||
{!isSelf ? (
|
{!isSelf ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -601,7 +602,7 @@ function UserDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
|
<p className="text-xs text-ink-muted">Cannot disable or delete your own account.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -666,19 +667,19 @@ function CreateUserModal({
|
|||||||
if (inviteLink) {
|
if (inviteLink) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
|
||||||
<h2 className="text-white font-semibold">User Created</h2>
|
<h2 className="text-white font-semibold">User Created</h2>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-ink-2">
|
||||||
Share this one-time invite link with the new user so they can set their password.
|
Share this one-time invite link with the new user so they can set their password.
|
||||||
It expires after use.
|
It expires after use.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={copyLink}
|
onClick={copyLink}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy link"}
|
{copied ? "Copied!" : "Copy link"}
|
||||||
</button>
|
</button>
|
||||||
@@ -696,37 +697,37 @@ function CreateUserModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 font-mono">
|
||||||
<h2 className="text-white font-semibold mb-4">Create User</h2>
|
<h2 className="text-white font-semibold mb-4">Create User</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Display Name <span className="text-gray-600">(optional)</span>
|
Display Name <span className="text-ink-muted">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Jane Smith"
|
placeholder="Jane Smith"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
<label className="text-xs text-ink-2 block mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => setRole(e.target.value as UserRole)}
|
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"
|
className="w-full bg-raised border border-line-strong 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="admin">Admin — full access</option>
|
||||||
<option value="operator">Operator — owns nodes</option>
|
<option value="operator">Operator — owns nodes</option>
|
||||||
@@ -735,14 +736,14 @@ function CreateUserModal({
|
|||||||
</div>
|
</div>
|
||||||
{role === "operator" && (
|
{role === "operator" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={nodeIds}
|
value={nodeIds}
|
||||||
onChange={(e) => setNodeIds(e.target.value)}
|
onChange={(e) => setNodeIds(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="node-abc123, node-def456"
|
placeholder="node-abc123, node-def456"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -759,7 +760,7 @@ function CreateUserModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -819,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
<p className="text-xs text-ink-muted font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
@@ -835,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No users found.</p>
|
<p className="text-ink-muted text-sm font-mono">No users found.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
<div className="border border-line rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-xs font-mono">
|
<table className="w-full text-xs font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
<tr className="bg-surface text-ink-muted uppercase tracking-wider">
|
||||||
<th className="px-4 py-2.5 text-left">Email</th>
|
<th className="px-4 py-2.5 text-left">Email</th>
|
||||||
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
||||||
<th className="px-4 py-2.5 text-left">Role</th>
|
<th className="px-4 py-2.5 text-left">Role</th>
|
||||||
@@ -856,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
|||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr
|
<tr
|
||||||
key={u.uid}
|
key={u.uid}
|
||||||
className={`border-t border-gray-800 transition-colors ${
|
className={`border-t border-line transition-colors ${
|
||||||
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
|
selectedUid === u.uid ? "bg-raised/60" : "hover:bg-raised/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
|
<td className="px-4 py-2.5 text-ink">{u.email ?? "—"}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
<td className="px-4 py-2.5 text-ink-2 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
||||||
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
|
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
|
||||||
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
|
<td className="px-4 py-2.5 text-ink-muted hidden sm:table-cell">
|
||||||
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
|
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
|
<td className="px-4 py-2.5 text-ink-muted hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{u.disabled
|
{u.disabled
|
||||||
? <span className="text-red-500">Disabled</span>
|
? <span className="text-red-500">Disabled</span>
|
||||||
@@ -951,15 +952,15 @@ function AuditLogTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No audit entries yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
<div className="border border-line rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-xs font-mono">
|
<table className="w-full text-xs font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
<tr className="bg-surface text-ink-muted uppercase tracking-wider">
|
||||||
<th className="px-4 py-2.5 text-left">Time</th>
|
<th className="px-4 py-2.5 text-left">Time</th>
|
||||||
<th className="px-4 py-2.5 text-left">Action</th>
|
<th className="px-4 py-2.5 text-left">Action</th>
|
||||||
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
||||||
@@ -969,12 +970,12 @@ function AuditLogTab() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
|
<tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
|
||||||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
<td className="px-4 py-2.5 text-ink-muted whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
||||||
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
|
<td className="px-4 py-2.5 text-ink-2 hidden sm:table-cell">{e.actor_email}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
<td className="px-4 py-2.5 text-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
|
<td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
|
||||||
{Object.keys(e.details).length > 0
|
{Object.keys(e.details).length > 0
|
||||||
? Object.entries(e.details)
|
? Object.entries(e.details)
|
||||||
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||||||
@@ -1028,26 +1029,26 @@ function StaleCallsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
Finds calls stuck in <span className="text-ink-2">active</span> status because a node rebooted before sending an end-call event.
|
||||||
Preview first, then close.
|
Preview first, then close.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
|
<label className="text-xs text-ink-2 block mb-1">Older than (minutes)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={1440}
|
min={1} max={1440}
|
||||||
value={minutes}
|
value={minutes}
|
||||||
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
||||||
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => run(true)}
|
onClick={() => run(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? "Working…" : "Preview"}
|
{loading ? "Working…" : "Preview"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1067,18 +1068,18 @@ function StaleCallsTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-2">
|
||||||
<p className="text-sm font-mono text-white">
|
<p className="text-sm font-mono text-white">
|
||||||
{result.dry_run ? "Preview: " : "Closed: "}
|
{result.dry_run ? "Preview: " : "Closed: "}
|
||||||
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
||||||
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>}
|
{result.count === 0 && <span className="text-ink-muted"> — nothing to clear</span>}
|
||||||
</p>
|
</p>
|
||||||
{result.call_ids.length > 0 && (
|
{result.call_ids.length > 0 && (
|
||||||
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
||||||
{result.call_ids.map((id) => (
|
{result.call_ids.map((id) => (
|
||||||
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
|
<p key={id} className="text-xs font-mono text-ink-2">{id}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1103,17 +1104,17 @@ function fmtPct(x: number | null | undefined): string {
|
|||||||
|
|
||||||
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
|
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
|
<div className="bg-surface border border-line rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-mono">Calls verified</p>
|
<p className="text-xs text-ink-muted font-mono">Calls verified</p>
|
||||||
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
|
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p>
|
<p className="text-xs text-ink-muted font-mono">Raw WER (whisper-1)</p>
|
||||||
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
|
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p>
|
<p className="text-xs text-ink-muted font-mono">Corrected WER (shipped)</p>
|
||||||
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
|
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
|
||||||
</div>
|
</div>
|
||||||
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
|
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
|
||||||
@@ -1168,8 +1169,10 @@ function SttEvalTab() {
|
|||||||
// scanning", not "done", so this fires again on its own.
|
// scanning", not "done", so this fires again on its own.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queue.length === 0 && !exhausted) loadBatch();
|
if (queue.length === 0 && !exhausted) loadBatch();
|
||||||
|
// `cursor` is a dep so an empty page that advanced the cursor scans the
|
||||||
|
// next one; without it the tab sat on "Loading…" forever.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [queue.length, exhausted]);
|
}, [queue.length, exhausted, cursor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
||||||
@@ -1196,7 +1199,7 @@ function SttEvalTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
|
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
|
||||||
This never touches the call's real transcript or re-runs anything — it only records ground truth
|
This never touches the call's real transcript or re-runs anything — it only records ground truth
|
||||||
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
|
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
|
||||||
@@ -1211,8 +1214,8 @@ function SttEvalTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{current ? (
|
{current ? (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-ink-2">
|
||||||
<span>{new Date(current.started_at).toLocaleString()}</span>
|
<span>{new Date(current.started_at).toLocaleString()}</span>
|
||||||
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1221,18 +1224,18 @@ function SttEvalTab() {
|
|||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
<audio controls src={current.audio_url} className="w-full h-9" />
|
<audio controls src={current.audio_url} className="w-full h-9" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p>
|
<p className="text-xs text-ink-muted italic">No audio on this call — skip it.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Machine transcript (pre-filled) — correct it into what was actually said
|
Machine transcript (pre-filled) — correct it into what was actually said
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1247,15 +1250,15 @@ function SttEvalTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={skip}
|
onClick={skip}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
<div className="bg-surface border border-line rounded-xl p-4">
|
||||||
<p className="text-sm font-mono text-gray-400">
|
<p className="text-sm font-mono text-ink-2">
|
||||||
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
|
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1268,11 +1271,12 @@ function SttEvalTab() {
|
|||||||
// Main admin page
|
// Main admin page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
type AdminTab = "features" | "cameras" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
type AdminTab = "features" | "cameras" | "outages" | "correlation" | "replay" | "users" | "audit" | "calls" | "eval";
|
||||||
|
|
||||||
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
const TAB_LABELS: { key: AdminTab; label: string }[] = [
|
||||||
{ key: "features", label: "AI Features" },
|
{ key: "features", label: "AI Features" },
|
||||||
{ key: "cameras", label: "Camera providers" },
|
{ key: "cameras", label: "Camera providers" },
|
||||||
|
{ key: "outages", label: "Outage providers" },
|
||||||
{ key: "correlation", label: "Correlation Debug" },
|
{ key: "correlation", label: "Correlation Debug" },
|
||||||
{ key: "replay", label: "Replay" },
|
{ key: "replay", label: "Replay" },
|
||||||
{ key: "calls", label: "Calls" },
|
{ key: "calls", label: "Calls" },
|
||||||
@@ -1285,7 +1289,7 @@ export default function AdminPage() {
|
|||||||
const { user, isAdmin, loading: authLoading } = useAuth();
|
const { user, isAdmin, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [tab, setTab] = useState<AdminTab>("features");
|
const [tab, setTab] = useState<AdminTab>("features");
|
||||||
// Deep link to a tab (/admin#cameras), e.g. from the live wall's empty state.
|
// Deep link to a tab (/admin#cameras, /admin#outages), e.g. from a map layer's empty state.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const h = window.location.hash.slice(1);
|
const h = window.location.hash.slice(1);
|
||||||
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
|
if (TAB_LABELS.some((t) => t.key === h)) setTab(h as AdminTab);
|
||||||
@@ -1304,19 +1308,22 @@ export default function AdminPage() {
|
|||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
|
|
||||||
// Users/Audit tabs benefit from full width; everything else is narrow
|
// Users/Audit tabs benefit from full width; everything else is narrow
|
||||||
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras";
|
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
// The width toggle wraps the tab body only. It used to wrap the tab bar
|
||||||
|
// too, so the bar reflowed on every switch and left the cursor over
|
||||||
|
// whatever landed under it (e.g. Calls -> "Close calls").
|
||||||
|
<div className="space-y-6">
|
||||||
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{TAB_LABELS.map(({ key, label }) => (
|
{TAB_LABELS.map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
||||||
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
tab === key ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -1324,8 +1331,10 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
{tab === "cameras" && <CameraProvidersTab />}
|
{tab === "cameras" && <CameraProvidersTab />}
|
||||||
|
{tab === "outages" && <OutageProvidersTab />}
|
||||||
{tab === "correlation" && <CorrelationDebugTab />}
|
{tab === "correlation" && <CorrelationDebugTab />}
|
||||||
{tab === "replay" && <ReplayTab />}
|
{tab === "replay" && <ReplayTab />}
|
||||||
{tab === "calls" && <StaleCallsTab />}
|
{tab === "calls" && <StaleCallsTab />}
|
||||||
@@ -1333,5 +1342,6 @@ export default function AdminPage() {
|
|||||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
{tab === "audit" && <AuditLogTab />}
|
{tab === "audit" && <AuditLogTab />}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useAlerts } from "@/lib/useAlerts";
|
import { useAlerts } from "@/lib/useAlerts";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
|
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import type { AlertRule } from "@/lib/types";
|
import type { AlertRule } from "@/lib/types";
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [webhook, setWebhook] = useState("");
|
const [webhook, setWebhook] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Load and toggle/delete failures (server-26#284) — separate from the create
|
||||||
|
// form's own `error`, and it keeps the list from claiming "no rules" when
|
||||||
|
// the fetch actually failed.
|
||||||
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const data = await c2api.getAlertRules() as AlertRule[];
|
const data = await c2api.getAlertRules() as AlertRule[];
|
||||||
setRules(data);
|
setRules(data);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
setListError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
setListError(`Couldn't load alert rules: ${errorMessage(e)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +57,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const created = await c2api.createAlertRule(body) as AlertRule;
|
const created = await c2api.createAlertRule(body) as AlertRule;
|
||||||
setRules((prev) => [created, ...prev]);
|
setRules((prev) => [created, ...prev]);
|
||||||
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
||||||
} catch {
|
} catch (e) {
|
||||||
setError("Failed to create rule.");
|
setError(`Failed to create rule: ${errorMessage(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -61,52 +68,55 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
try {
|
try {
|
||||||
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
||||||
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
|
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
|
||||||
} catch (e) { console.error(e); }
|
setListError(null);
|
||||||
|
} catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
|
if (!window.confirm("Delete this alert rule?")) return;
|
||||||
try {
|
try {
|
||||||
await c2api.deleteAlertRule(id);
|
await c2api.deleteAlertRule(id);
|
||||||
setRules((prev) => prev.filter((r) => r.rule_id !== id));
|
setRules((prev) => prev.filter((r) => r.rule_id !== id));
|
||||||
} catch (e) { console.error(e); }
|
setListError(null);
|
||||||
|
} catch (e) { setListError(`Couldn't delete the rule: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<form onSubmit={handleCreate} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
|
<form onSubmit={handleCreate} className="bg-surface border border-line rounded-xl p-5 space-y-4">
|
||||||
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
|
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
required value={name} onChange={(e) => setName(e.target.value)}
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Structure Fire"
|
placeholder="e.g. Structure Fire"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Keywords (comma-separated)</label>
|
<label className="text-xs text-ink-2 block mb-1">Keywords (comma-separated)</label>
|
||||||
<input
|
<input
|
||||||
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
||||||
placeholder="fire, smoke, structure"
|
placeholder="fire, smoke, structure"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Talkgroup IDs (comma-separated)</label>
|
<label className="text-xs text-ink-2 block mb-1">Talkgroup IDs (comma-separated)</label>
|
||||||
<input
|
<input
|
||||||
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
||||||
placeholder="9048, 9600"
|
placeholder="9048, 9600"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Discord Webhook URL (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Discord Webhook URL (optional)</label>
|
||||||
<input
|
<input
|
||||||
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
||||||
placeholder="https://discord.com/api/webhooks/…"
|
placeholder="https://discord.com/api/webhooks/…"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,13 +130,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
{listError && <ErrorBanner message={listError} />}
|
||||||
{rules.length === 0 ? (
|
|
||||||
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p>
|
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
|
{!loaded ? (
|
||||||
|
<p className="text-ink-muted text-sm font-mono p-4">{listError ? "Rules unavailable." : "Loading…"}</p>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
<tr className="border-b border-line text-xs text-ink-muted uppercase">
|
||||||
<th className="px-4 py-3">Name</th>
|
<th className="px-4 py-3">Name</th>
|
||||||
<th className="px-4 py-3">Keywords</th>
|
<th className="px-4 py-3">Keywords</th>
|
||||||
<th className="px-4 py-3">Talkgroups</th>
|
<th className="px-4 py-3">Talkgroups</th>
|
||||||
@@ -137,11 +151,11 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<tr key={rule.rule_id} className="border-b border-gray-800">
|
<tr key={rule.rule_id} className="border-b border-line">
|
||||||
<td className="px-4 py-3 text-white text-sm">{rule.name}</td>
|
<td className="px-4 py-3 text-white text-sm">{rule.name}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs">
|
<td className="px-4 py-3 text-ink-2 text-xs">
|
||||||
{rule.discord_webhook ? (
|
{rule.discord_webhook ? (
|
||||||
<span className="text-green-400">configured</span>
|
<span className="text-green-400">configured</span>
|
||||||
) : "—"}
|
) : "—"}
|
||||||
@@ -153,13 +167,13 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||||
rule.enabled
|
rule.enabled
|
||||||
? "bg-green-900 text-green-300 hover:bg-green-800"
|
? "bg-green-900 text-green-300 hover:bg-green-800"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-raised text-ink-2 hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rule.enabled ? "enabled" : "disabled"}
|
{rule.enabled ? "enabled" : "disabled"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-gray-500"}`}>
|
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{rule.enabled ? "enabled" : "disabled"}
|
{rule.enabled ? "enabled" : "disabled"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -188,11 +202,13 @@ export default function AlertsPage() {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { alerts, loading, error } = useAlerts();
|
const { alerts, loading, error } = useAlerts();
|
||||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||||
|
const [ackError, setAckError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleAcknowledge(id: string) {
|
async function handleAcknowledge(id: string) {
|
||||||
|
setAckError(null);
|
||||||
try {
|
try {
|
||||||
await c2api.acknowledgeAlert(id);
|
await c2api.acknowledgeAlert(id);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||||
@@ -209,13 +225,13 @@ export default function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
|
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t as "events" | "rules")}
|
onClick={() => setTab(t as "events" | "rules")}
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||||
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === "events" ? "Triggered Alerts" : "Rules"}
|
{t === "events" ? "Triggered Alerts" : "Rules"}
|
||||||
@@ -223,9 +239,11 @@ export default function AlertsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
|
||||||
|
|
||||||
{tab === "events" && (
|
{tab === "events" && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-red-400 text-sm font-mono">
|
<p className="text-red-400 text-sm font-mono">
|
||||||
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||||
@@ -233,7 +251,7 @@ export default function AlertsPage() {
|
|||||||
: `Couldn't load alerts: ${error}`}
|
: `Couldn't load alerts: ${error}`}
|
||||||
</p>
|
</p>
|
||||||
) : alerts.length === 0 ? (
|
) : alerts.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No alerts triggered yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
||||||
@@ -242,10 +260,10 @@ export default function AlertsPage() {
|
|||||||
variant="inline"
|
variant="inline"
|
||||||
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
||||||
/>
|
/>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
<tr className="border-b border-line text-xs text-ink-muted uppercase">
|
||||||
<th className="px-4 py-3">Rule</th>
|
<th className="px-4 py-3">Rule</th>
|
||||||
<th className="px-4 py-3">Talkgroup</th>
|
<th className="px-4 py-3">Talkgroup</th>
|
||||||
<th className="px-4 py-3">Matched</th>
|
<th className="px-4 py-3">Matched</th>
|
||||||
@@ -258,10 +276,10 @@ export default function AlertsPage() {
|
|||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<tr
|
<tr
|
||||||
key={alert.alert_id}
|
key={alert.alert_id}
|
||||||
className={`border-b border-gray-800 ${alert.acknowledged ? "opacity-50" : ""}`}
|
className={`border-b border-line ${alert.acknowledged ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
|
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">
|
||||||
{alert.talkgroup_name || alert.talkgroup_id || "—"}
|
{alert.talkgroup_name || alert.talkgroup_id || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -273,17 +291,17 @@ export default function AlertsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-xs truncate">
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono max-w-xs truncate">
|
||||||
{alert.transcript_snippet || "—"}
|
{alert.transcript_snippet || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{alert.acknowledged ? (
|
{alert.acknowledged ? (
|
||||||
<span className="text-xs text-gray-500">acked</span>
|
<span className="text-xs text-ink-muted">acked</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAcknowledge(alert.alert_id)}
|
onClick={() => handleAcknowledge(alert.alert_id)}
|
||||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
Acknowledge
|
Acknowledge
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function ChevronIcon({ open }: { open: boolean }) {
|
|||||||
<svg
|
<svg
|
||||||
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
strokeLinecap="round" strokeLinejoin="round"
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
className={`text-ink-muted shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
>
|
>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -83,10 +83,10 @@ export default function FaqPage() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Badge tone="brand">FAQ</Badge>
|
<Badge tone="brand">FAQ</Badge>
|
||||||
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
<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>
|
<p className="text-ink-2 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800">
|
<div className="mt-12 divide-y divide-line border-t border-b border-line">
|
||||||
{FAQS.map((item, i) => {
|
{FAQS.map((item, i) => {
|
||||||
const open = openIndex === i;
|
const open = openIndex === i;
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +100,7 @@ export default function FaqPage() {
|
|||||||
<ChevronIcon open={open} />
|
<ChevronIcon open={open} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
|
<div className="text-ink-2 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function FeaturesPage() {
|
|||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<Badge tone="brand">Features</Badge>
|
<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>
|
<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">
|
<p className="text-ink-2 mt-4 leading-relaxed">
|
||||||
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
|
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.
|
correlation, mapping, and a live relay — end to end.
|
||||||
</p>
|
</p>
|
||||||
@@ -88,12 +88,12 @@ export default function FeaturesPage() {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
|
<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>
|
<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>
|
<p className="text-ink-2 mt-3 leading-relaxed">{s.body}</p>
|
||||||
</div>
|
</div>
|
||||||
<Card padding="lg" className="lg:col-span-3">
|
<Card padding="lg" className="lg:col-span-3">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{s.points.map((p) => (
|
{s.points.map((p) => (
|
||||||
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
|
<li key={p} className="flex items-start gap-3 text-sm text-ink-2">
|
||||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
|
||||||
{p}
|
{p}
|
||||||
</li>
|
</li>
|
||||||
@@ -107,7 +107,7 @@ export default function FeaturesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-20 pt-16 border-t border-gray-800">
|
<div className="text-center mt-20 pt-16 border-t border-line">
|
||||||
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
|
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
@import 'leaflet/dist/leaflet.css';
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
|
||||||
|
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
|
||||||
/* ── Design tokens ────────────────────────────────────────────────────────────
|
/* ── Design tokens ────────────────────────────────────────────────────────────
|
||||||
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
||||||
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
|
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
|
||||||
@@ -16,43 +18,43 @@
|
|||||||
* Do not add a hue here for a category; add a glyph.
|
* Do not add a hue here for a category; add a glyph.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--page: #F4F6F9;
|
--page: #F7F7F8;
|
||||||
--surface: #FFFFFF;
|
--surface: #FFFFFF;
|
||||||
--raised: #F7F9FB;
|
--raised: #F2F2F5;
|
||||||
--line: rgba(11,17,27,.11);
|
--line: rgba(24,24,27,.10);
|
||||||
--line-strong: rgba(11,17,27,.22);
|
--line-strong: rgba(24,24,27,.20);
|
||||||
--ink: #101620;
|
--ink: #18181B;
|
||||||
--ink-2: #46536A;
|
--ink-2: #52525B;
|
||||||
--ink-muted: #6B788C;
|
--ink-muted: #6B6B76;
|
||||||
|
|
||||||
--accent: #2A78D6;
|
--accent: #5E6AD2;
|
||||||
--sev-moderate: #B07800;
|
--sev-moderate: #B54708;
|
||||||
--sev-major: #C0281F;
|
--sev-major: #D92D20;
|
||||||
|
|
||||||
--map-bg: #E8ECF1;
|
--map-bg: #ECECF0;
|
||||||
--map-block: #DFE4EB;
|
--map-block: #E1E1E8;
|
||||||
--map-road: #FFFFFF;
|
--map-road: #FFFFFF;
|
||||||
--map-water: #D3E2EE;
|
--map-water: #DADDF2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--page: #0B0E13;
|
--page: #08090A;
|
||||||
--surface: #141922;
|
--surface: #111214;
|
||||||
--raised: #1B2230;
|
--raised: #1A1B1F;
|
||||||
--line: rgba(255,255,255,.09);
|
--line: rgba(255,255,255,.08);
|
||||||
--line-strong: rgba(255,255,255,.17);
|
--line-strong: rgba(255,255,255,.16);
|
||||||
--ink: #E8EBF0;
|
--ink: #EDEDEF;
|
||||||
--ink-2: #A5B0C2;
|
--ink-2: #A9A9B3;
|
||||||
--ink-muted: #77839A;
|
--ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
|
||||||
|
|
||||||
--accent: #3987E5;
|
--accent: #8B93F0;
|
||||||
--sev-moderate: #C98500;
|
--sev-moderate: #FDB022;
|
||||||
--sev-major: #D03B3B;
|
--sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
|
||||||
|
|
||||||
--map-bg: #0E131B;
|
--map-bg: #0D0E11;
|
||||||
--map-block: #161C26;
|
--map-block: #16171B;
|
||||||
--map-road: #232C3A;
|
--map-road: #26272D;
|
||||||
--map-water: #12202E;
|
--map-water: #141729;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||||
@@ -69,7 +71,7 @@ html, body {
|
|||||||
|
|
||||||
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
||||||
* readable. Lift both to gray-500 (~4:1). */
|
* readable. Lift both to gray-500 (~4:1). */
|
||||||
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
.dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
|
||||||
|
|
||||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
@@ -80,34 +82,34 @@ html, body {
|
|||||||
|
|
||||||
/* Structural backgrounds */
|
/* Structural backgrounds */
|
||||||
html:not(.dark),
|
html:not(.dark),
|
||||||
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
|
||||||
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
|
||||||
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
||||||
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
|
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,0.70) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(242,242,245,0.60) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(242,242,245,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
|
||||||
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
|
||||||
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
html:not(.dark) .text-white { color: #18181b !important; }
|
||||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-100 { color: #27272a !important; }
|
||||||
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-200 { color: #27272a !important; }
|
||||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
|
||||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
html:not(.dark) .text-gray-400 { color: #52525b !important; }
|
||||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
|
||||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
|
||||||
|
|
||||||
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||||
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
||||||
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
|
|||||||
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
||||||
|
|
||||||
/* Hover states */
|
/* Hover states */
|
||||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #fafafa !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
|
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
|
||||||
|
|
||||||
/* Hover text */
|
/* Hover text */
|
||||||
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
|
||||||
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
|
||||||
|
|
||||||
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
||||||
|
|
||||||
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
|
|||||||
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
||||||
|
|
||||||
/* Unconfigured / Info */
|
/* Unconfigured / Info */
|
||||||
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
|
||||||
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
|
||||||
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
|
||||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
|
||||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
|
||||||
|
|
||||||
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
@@ -191,6 +193,28 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The legend floats above the map's stacking context, so it would cover the
|
||||||
|
* expanded layers list (both live in the right-hand column). Hide it while the
|
||||||
|
* list is open. */
|
||||||
|
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
|
||||||
|
* overlay cards sat on a bright, busy map. Inverting the raster keeps the
|
||||||
|
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
|
||||||
|
* the overlays the dark ground their palette was chosen for. */
|
||||||
|
.leaflet-tile-pane .drb-tiles-dark {
|
||||||
|
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet popups stay white in both themes (their body text is a fixed
|
||||||
|
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
|
||||||
|
* white — is pinned to a grey that holds AA on white (~6:1). */
|
||||||
|
.leaflet-popup-content .text-ink-muted {
|
||||||
|
color: #5F5F69;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
|
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
|
||||||
* padding and let the tile's own theme colours show, so the tile is no bigger
|
* padding and let the tile's own theme colours show, so the tile is no bigger
|
||||||
* than the video it holds. */
|
* than the video it holds. */
|
||||||
@@ -204,14 +228,22 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||||
|
/* Placeholders must read as hints, not values: Tailwind's default #9ca3af on
|
||||||
|
* the dark surface looked like filled-in text (e.g. /systems Local Area showed
|
||||||
|
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
|
||||||
|
.dark input::placeholder,
|
||||||
|
.dark textarea::placeholder {
|
||||||
|
color: #6E6E78;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
html:not(.dark) textarea {
|
html:not(.dark) textarea {
|
||||||
color: #0f172a;
|
color: #18181b;
|
||||||
}
|
}
|
||||||
html:not(.dark) input::placeholder,
|
html:not(.dark) input::placeholder,
|
||||||
html:not(.dark) textarea::placeholder {
|
html:not(.dark) textarea::placeholder {
|
||||||
color: #94a3b8;
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
||||||
@@ -222,8 +254,8 @@ html:not(.dark) textarea::placeholder {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
/* 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) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
|
||||||
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
|
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
|
||||||
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !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) .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) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
||||||
@@ -232,10 +264,10 @@ html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !imp
|
|||||||
|
|
||||||
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
|
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
|
||||||
.marketing-hero-bg {
|
.marketing-hero-bg {
|
||||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
|
||||||
}
|
}
|
||||||
html:not(.dark) .marketing-hero-bg {
|
html:not(.dark) .marketing-hero-bg {
|
||||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton loading shimmer */
|
/* Skeleton loading shimmer */
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import { useIncident } from "@/lib/useIncidents";
|
import { useIncident } from "@/lib/useIncidents";
|
||||||
import { useCallsByIncident } from "@/lib/useCalls";
|
import { useCallsByIncident } from "@/lib/useCalls";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
@@ -13,25 +14,17 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
|
|||||||
import { isKnownSeverity } from "@/lib/severity";
|
import { isKnownSeverity } from "@/lib/severity";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
|
import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const EARLIER_PAGE_SIZE = 8;
|
const EARLIER_PAGE_SIZE = 8;
|
||||||
|
|
||||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
|
||||||
const R = 6371;
|
|
||||||
const dLat = ((b.lat - a.lat) * Math.PI) / 180;
|
|
||||||
const dLng = ((b.lng - a.lng) * Math.PI) / 180;
|
|
||||||
const s =
|
|
||||||
Math.sin(dLat / 2) ** 2 +
|
|
||||||
Math.cos((a.lat * Math.PI) / 180) * Math.cos((b.lat * Math.PI) / 180) * Math.sin(dLng / 2) ** 2;
|
|
||||||
return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s));
|
|
||||||
}
|
|
||||||
|
|
||||||
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
|
|
||||||
const start = new Date(startedAt).getTime();
|
const start = new Date(startedAt).getTime();
|
||||||
const end = active ? Date.now() : new Date(updatedAt).getTime();
|
const end = active ? Date.now() : new Date(endedAt).getTime();
|
||||||
const mins = Math.max(0, Math.round((end - start) / 60000));
|
const mins = Math.max(0, Math.round((end - start) / 60000));
|
||||||
if (mins < 60) return `${mins}m`;
|
if (mins < 60) return `${mins}m`;
|
||||||
const hrs = Math.floor(mins / 60);
|
const hrs = Math.floor(mins / 60);
|
||||||
@@ -41,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
|
|||||||
export default function IncidentDetailPage() {
|
export default function IncidentDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { incident, loading } = useIncident(id);
|
const { incident, loading } = useIncident(id);
|
||||||
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
||||||
@@ -49,31 +41,15 @@ export default function IncidentDetailPage() {
|
|||||||
|
|
||||||
const [summarizing, setSummarizing] = useState(false);
|
const [summarizing, setSummarizing] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||||
|
|
||||||
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
|
// Stop numbers and path length come from the same liveWall.pathStops() the
|
||||||
// number shown on a spine entry matches the number on its map marker.
|
// map's IncidentPathLayer draws from (server-26#236), so the spine numbers
|
||||||
const geocodedCalls = useMemo(
|
// exactly what the map numbers: only a movement incident (police/accident
|
||||||
() =>
|
// with a pursuit tag) is numbered, and an impossible-speed stop never is.
|
||||||
calls
|
const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
|
||||||
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
|
const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map<string, number>()), [path]);
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.started_at.localeCompare(b.started_at)),
|
|
||||||
[calls]
|
|
||||||
);
|
|
||||||
const stopNumberByCallId = useMemo(() => {
|
|
||||||
const m = new Map<string, number>();
|
|
||||||
geocodedCalls.forEach((c, i) => m.set(c.call_id, i + 1));
|
|
||||||
return m;
|
|
||||||
}, [geocodedCalls]);
|
|
||||||
|
|
||||||
const pathLengthKm = useMemo(() => {
|
|
||||||
let total = 0;
|
|
||||||
for (let i = 1; i < geocodedCalls.length; i++) {
|
|
||||||
total += haversineKm(geocodedCalls[i - 1].location_coords!, geocodedCalls[i].location_coords!);
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}, [geocodedCalls]);
|
|
||||||
|
|
||||||
const newestFirst = useMemo(
|
const newestFirst = useMemo(
|
||||||
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
||||||
@@ -81,16 +57,20 @@ export default function IncidentDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handleResolve() {
|
async function handleResolve() {
|
||||||
|
// No reopen path exists (server-26#284), so resolving is final from the UI.
|
||||||
|
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
|
||||||
setResolving(true);
|
setResolving(true);
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||||
finally { setResolving(false); }
|
finally { setResolving(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSummarize() {
|
async function handleSummarize() {
|
||||||
setSummarizing(true);
|
setSummarizing(true);
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.summarizeIncident(id); }
|
try { await c2api.summarizeIncident(id); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
|
||||||
finally { setSummarizing(false); }
|
finally { setSummarizing(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,18 +82,23 @@ export default function IncidentDetailPage() {
|
|||||||
const unitsCleared = incident.units_cleared ?? [];
|
const unitsCleared = incident.units_cleared ?? [];
|
||||||
const vehicles = incident.vehicles ?? [];
|
const vehicles = incident.vehicles ?? [];
|
||||||
const active = incident.status === "active";
|
const active = incident.status === "active";
|
||||||
|
// A manual resolve doesn't set resolved_at and updated_at can predate the
|
||||||
|
// last call, which showed "0m elapsed" on an 11-call incident — so a
|
||||||
|
// resolved incident runs to the latest of those and its newest call.
|
||||||
|
const resolvedEnd = [incident.resolved_at, incident.updated_at, newestFirst[0]?.ended_at, newestFirst[0]?.started_at]
|
||||||
|
.filter((t): t is string => !!t)
|
||||||
|
.reduce((a, b) => (b > a ? b : a), incident.started_at);
|
||||||
|
|
||||||
const visible = newestFirst.slice(0, earlierShown);
|
const visible = newestFirst.slice(0, earlierShown);
|
||||||
const remaining = newestFirst.length - visible.length;
|
const remaining = newestFirst.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
{/* A real link, not router.back(): arriving from Live or a shared URL,
|
||||||
onClick={() => router.back()}
|
"back" went to the map or out of the app. */}
|
||||||
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
<Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
|
||||||
>
|
|
||||||
← Incidents
|
← Incidents
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
@@ -129,8 +114,13 @@ export default function IncidentDetailPage() {
|
|||||||
{incident.title ?? "Incident"}
|
{incident.title ?? "Incident"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-ink-muted font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
|
{elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
|
||||||
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>}
|
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
|
||||||
|
{path?.mayBeMerged && (
|
||||||
|
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
|
||||||
|
{" · "}may be merged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
{" · "}{incident.call_ids.length} call{incident.call_ids.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +138,8 @@ export default function IncidentDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actionError && <ErrorBanner message={actionError} />}
|
||||||
|
|
||||||
{displayTags.length > 0 && (
|
{displayTags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{displayTags.map((t) => (
|
{displayTags.map((t) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useIncidents } from "@/lib/useIncidents";
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
|
|||||||
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
||||||
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
|
|||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
|
|
||||||
async function handleResolve(id: string) {
|
async function handleResolve(id: string) {
|
||||||
|
// No reopen path exists (server-26#284), so resolving is final from the UI.
|
||||||
|
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
|
|||||||
chips are pipeline output, so the notice rides with the list. */}
|
chips are pipeline output, so the notice rides with the list. */}
|
||||||
<MachineOutputNotice variant="inline" />
|
<MachineOutputNotice variant="inline" />
|
||||||
|
|
||||||
|
{actionError && <ErrorBanner message={actionError} />}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||||
|
|||||||
@@ -87,30 +87,30 @@ export default function LoginPage() {
|
|||||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
DRB
|
DRB
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
<label className="text-xs text-ink-2 block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -127,7 +127,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
<span className="text-xs text-gray-500">or</span>
|
<span className="text-xs text-ink-muted">or</span>
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ export default function LoginPage() {
|
|||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-500">
|
<p className="text-center text-xs text-ink-muted">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { CallRow } from "@/components/CallRow";
|
|||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
|
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
function ApprovalBadge({ status }: { status: string | null }) {
|
function ApprovalBadge({ status }: { status: string | null }) {
|
||||||
@@ -54,10 +56,14 @@ export default function NodeDetailPage() {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
async function sendCommand(action: string) {
|
async function sendCommand(action: string, label: string) {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await c2api.sendCommand(id, { action });
|
await c2api.sendCommand(id, { action });
|
||||||
|
alert(`${label} sent to the node.`);
|
||||||
|
} catch (e) {
|
||||||
|
// Was try/finally only: an unhandled rejection and no feedback (server-26#284).
|
||||||
|
alert(`Couldn't send ${label.toLowerCase()}: ${errorMessage(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -125,7 +131,7 @@ export default function NodeDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return <p className="text-gray-500 font-mono text-sm">Loading node…</p>;
|
return <p className="text-ink-muted font-mono text-sm">Loading node…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const system = systemMap[node.assigned_system_id ?? ""];
|
const system = systemMap[node.assigned_system_id ?? ""];
|
||||||
@@ -135,7 +141,7 @@ export default function NodeDetailPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
|
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
|
||||||
<p className="text-gray-500 text-sm font-mono">{node.node_id}</p>
|
<p className="text-ink-muted text-sm font-mono">{node.node_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +153,7 @@ export default function NodeDetailPage() {
|
|||||||
<span className="text-base">⚠</span>
|
<span className="text-base">⚠</span>
|
||||||
<span>Local System Override Active</span>
|
<span>Local System Override Active</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-xs leading-relaxed">
|
<p className="text-ink-2 text-xs leading-relaxed">
|
||||||
This node is operating on a local system override.
|
This node is operating on a local system override.
|
||||||
{node.override_timeout_at ? (
|
{node.override_timeout_at ? (
|
||||||
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
||||||
@@ -155,7 +161,8 @@ export default function NodeDetailPage() {
|
|||||||
<> No timeout is currently enforced (permanent override).</>
|
<> No timeout is currently enforced (permanent override).</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2">
|
{/* Override routes are require_service_key_or_admin (server-26#222). */}
|
||||||
|
{isAdmin && <div className="flex gap-2">
|
||||||
{node.override_timeout_at && (
|
{node.override_timeout_at && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -184,12 +191,12 @@ export default function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
Force Revert Config
|
Force Revert Config
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
<div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
|
||||||
{[
|
{[
|
||||||
["System", system?.name ?? "Unassigned"],
|
["System", system?.name ?? "Unassigned"],
|
||||||
["Location", `${node.lat}, ${node.lon}`],
|
["Location", `${node.lat}, ${node.lon}`],
|
||||||
@@ -208,12 +215,23 @@ export default function NodeDetailPage() {
|
|||||||
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span className="text-ink-muted">{label}</span>
|
||||||
<span className="text-gray-200">{value}</span>
|
<span className="text-ink">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{(() => {
|
||||||
|
const disk = diskState(node);
|
||||||
|
return (
|
||||||
<div className="flex justify-between px-4 py-2.5">
|
<div className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">Approval</span>
|
<span className="text-ink-muted">Disk</span>
|
||||||
|
<span className={disk ? DISK_TEXT_CLASS[disk.level] : "text-ink-muted"}>
|
||||||
|
{disk ? `${disk.level !== "ok" ? "⚠ " : ""}${disk.label}` : "not reported (node software too old)"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<div className="flex justify-between px-4 py-2.5">
|
||||||
|
<span className="text-ink-muted">Approval</span>
|
||||||
<ApprovalBadge status={node.approval_status ?? null} />
|
<ApprovalBadge status={node.approval_status ?? null} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +250,7 @@ export default function NodeDetailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleReject}
|
onClick={handleReject}
|
||||||
disabled={approving}
|
disabled={approving}
|
||||||
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-gray-300 rounded-lg text-sm font-mono transition-colors"
|
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-ink-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
@@ -240,25 +258,34 @@ export default function NodeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{/* Configure and Restart are admin-only on the server (server-26#222);
|
||||||
|
non-admins get a note instead of a button that 403s. */}
|
||||||
|
{!isAdmin && !node.configured && (
|
||||||
|
<p className="text-sm text-ink-2">This node isn't configured yet. An admin needs to assign it a radio system.</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfig(true)}
|
onClick={() => setShowConfig(true)}
|
||||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
|
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
{node.configured ? "Reassign System" : "Configure"}
|
{node.configured ? "Reassign System" : "Configure"}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onClick={() => sendCommand("op25_restart")}
|
onClick={() => sendCommand("op25_restart", "Restart OP25")}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Restart OP25
|
Restart OP25
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onClick={handleRequestUpdate}
|
onClick={handleRequestUpdate}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Update Node Software
|
Update Node Software
|
||||||
</button>
|
</button>
|
||||||
@@ -283,16 +310,16 @@ export default function NodeDetailPage() {
|
|||||||
|
|
||||||
{/* Recent calls */}
|
{/* Recent calls */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||||
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
|
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
|
||||||
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
||||||
{nodeCalls.length === 0 ? (
|
{nodeCalls.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p>
|
<p className="text-ink-muted text-sm font-mono">No calls recorded from this node.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
|
||||||
<th className="px-4 py-2 text-left">Time</th>
|
<th className="px-4 py-2 text-left">Time</th>
|
||||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||||
<th className="px-4 py-2 text-left">System</th>
|
<th className="px-4 py-2 text-left">System</th>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function NodesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleUpdateAll}
|
onClick={handleUpdateAll}
|
||||||
disabled={updatingAll}
|
disabled={updatingAll}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{updatingAll ? "Requesting…" : "Update All Nodes"}
|
{updatingAll ? "Requesting…" : "Update All Nodes"}
|
||||||
</button>
|
</button>
|
||||||
@@ -79,22 +79,28 @@ export default function NodesPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pending.map((n) => (
|
{pending.map((n) => (
|
||||||
|
// Configuring is admin-only on the server (server-26#222); for
|
||||||
|
// everyone else the card links to the node page instead.
|
||||||
|
isAdmin ? (
|
||||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider">
|
||||||
All Nodes ({nodes.length})
|
All Nodes ({nodes.length})
|
||||||
</h2>
|
</h2>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : nodes.length === 0 ? (
|
) : nodes.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
|
<p className="text-ink-muted text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{nodes.map((n) => (
|
{nodes.map((n) => (
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm mx-auto pt-16">
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
|
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
|
||||||
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
|
<p className="text-ink-2 text-xs mt-2 leading-relaxed">
|
||||||
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
|
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
<label className="text-xs text-ink-2 block mb-1">Organization name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={orgName}
|
value={orgName}
|
||||||
@@ -73,7 +73,7 @@ export default function OnboardingPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="e.g. Riverside County Scanner"
|
placeholder="e.g. Riverside County Scanner"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function MarketingHomePage() {
|
|||||||
<h1 className="text-display-sm md:text-display mt-5 text-white">
|
<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.
|
See what's happening on the radio, as an incident — not a wall of calls.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
<p className="text-ink-2 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,
|
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
|
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.
|
bot to relay the audio live to your team.
|
||||||
@@ -68,7 +68,7 @@ function MarketingHomePage() {
|
|||||||
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<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">
|
<div className="max-w-2xl mb-10">
|
||||||
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
||||||
<p className="text-gray-400 mt-3">
|
<p className="text-ink-2 mt-3">
|
||||||
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
|
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.
|
about — and let you click into any one of them for the full picture.
|
||||||
</p>
|
</p>
|
||||||
@@ -77,14 +77,14 @@ function MarketingHomePage() {
|
|||||||
{CAPABILITIES.map((c) => (
|
{CAPABILITIES.map((c) => (
|
||||||
<Card key={c.title} padding="lg" hover>
|
<Card key={c.title} padding="lg" hover>
|
||||||
<h3 className="text-white font-semibold">{c.title}</h3>
|
<h3 className="text-white font-semibold">{c.title}</h3>
|
||||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
|
<p className="text-ink-2 text-sm mt-2 leading-relaxed">{c.body}</p>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<section className="border-t border-gray-800">
|
<section className="border-t border-line">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<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>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
@@ -92,7 +92,7 @@ function MarketingHomePage() {
|
|||||||
<div key={s.n}>
|
<div key={s.n}>
|
||||||
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
<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>
|
<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>
|
<p className="text-ink-2 text-sm mt-2 leading-relaxed">{s.body}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,12 +100,12 @@ function MarketingHomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
|
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
|
||||||
<section className="border-t border-gray-800">
|
<section className="border-t border-line">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<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">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-display-sm text-white">Pricing is in development</h2>
|
<h2 className="text-display-sm text-white">Pricing is in development</h2>
|
||||||
<p className="text-gray-400 mt-2 max-w-xl">
|
<p className="text-ink-2 mt-2 max-w-xl">
|
||||||
We would rather talk to you about what you need than post a number we would have to
|
We would rather talk to you about what you need than post a number we would have to
|
||||||
walk back. Tell us about your coverage area and we will figure it out together.
|
walk back. Tell us about your coverage area and we will figure it out together.
|
||||||
</p>
|
</p>
|
||||||
@@ -118,10 +118,10 @@ function MarketingHomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Final CTA */}
|
{/* Final CTA */}
|
||||||
<section className="border-t border-gray-800">
|
<section className="border-t border-line">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
<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>
|
<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">
|
<p className="text-ink-2 mt-3 max-w-xl mx-auto">
|
||||||
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
|
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function PricingPage() {
|
|||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<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">
|
<div className="text-center max-w-2xl mx-auto">
|
||||||
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
|
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
|
||||||
<p className="text-gray-400 mt-4">
|
<p className="text-ink-2 mt-4">
|
||||||
We are still working out what a fair price looks like for the people who actually use this.
|
We are still working out what a fair price looks like for the people who actually use this.
|
||||||
Rather than post a number we would have to walk back, we would rather talk to you about what
|
Rather than post a number we would have to walk back, we would rather talk to you about what
|
||||||
you need and what it is worth.
|
you need and what it is worth.
|
||||||
@@ -24,7 +24,7 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<Card padding="lg" className="mt-12 max-w-2xl mx-auto">
|
<Card padding="lg" className="mt-12 max-w-2xl mx-auto">
|
||||||
<h2 className="text-white text-lg font-bold">What you get today</h2>
|
<h2 className="text-white text-lg font-bold">What you get today</h2>
|
||||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">
|
<p className="text-ink-2 text-sm mt-2 leading-relaxed">
|
||||||
The full incident pipeline — transcription, correlation into incidents, mapping, and the
|
The full incident pipeline — transcription, correlation into incidents, mapping, and the
|
||||||
Discord relay. Node counts, seats, and history length are set per account while we work out
|
Discord relay. Node counts, seats, and history length are set per account while we work out
|
||||||
the plan structure.
|
the plan structure.
|
||||||
@@ -35,7 +35,7 @@ export default function PricingPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="text-center mt-16">
|
<div className="text-center mt-16">
|
||||||
<p className="text-gray-400">
|
<p className="text-ink-2">
|
||||||
Questions?{" "}
|
Questions?{" "}
|
||||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||||
{" "}or{" "}
|
{" "}or{" "}
|
||||||
|
|||||||
@@ -63,18 +63,18 @@ export default function PrivacyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-display-sm text-white">Privacy Policy</h1>
|
<h1 className="text-display-sm text-white">Privacy Policy</h1>
|
||||||
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
<p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
||||||
|
|
||||||
<div className="mt-10 space-y-8">
|
<div className="mt-10 space-y-8">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<section key={s.heading}>
|
<section key={s.heading}>
|
||||||
<h2 className="text-white font-semibold">{s.heading}</h2>
|
<h2 className="text-white font-semibold">{s.heading}</h2>
|
||||||
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
<p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-xs font-mono mt-16">
|
<p className="text-ink-muted text-xs font-mono mt-16">
|
||||||
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
Questions in the meantime? <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 reach us directly</Link>.
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -92,13 +92,13 @@ export default function ProfilePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
||||||
{user.displayName && user.email && (
|
{user.displayName && user.email && (
|
||||||
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
|
<p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
|
||||||
)}
|
)}
|
||||||
{role && (
|
{role && (
|
||||||
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||||
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
||||||
role === "operator" ? "bg-green-900 text-green-300" :
|
role === "operator" ? "bg-green-900 text-green-300" :
|
||||||
"bg-gray-800 text-gray-400"
|
"bg-raised text-ink-2"
|
||||||
}`}>
|
}`}>
|
||||||
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,9 +107,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Firebase account */}
|
{/* Firebase account */}
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<section className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-2">Account</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Row label="Email" value={user.email ?? "—"} />
|
<Row label="Email" value={user.email ?? "—"} />
|
||||||
<Row label="UID" value={user.uid} mono truncate />
|
<Row label="UID" value={user.uid} mono truncate />
|
||||||
@@ -125,12 +125,12 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Discord linking */}
|
{/* Discord linking */}
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<section className="bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-3">Discord</p>
|
||||||
|
|
||||||
{linkLoading ? (
|
{linkLoading ? (
|
||||||
<p className="text-gray-500 text-sm">Loading…</p>
|
<p className="text-ink-muted text-sm">Loading…</p>
|
||||||
) : linkStatus?.linked ? (
|
) : linkStatus?.linked ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -156,21 +156,21 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-ink-2">
|
||||||
Link your Discord account to access private trips from both the web and Discord.
|
Link your Discord account to access private trips from both the web and Discord.
|
||||||
</p>
|
</p>
|
||||||
{code ? (
|
{code ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
|
<div className="bg-raised rounded-lg px-4 py-3 flex items-center gap-3">
|
||||||
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
|
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-ink-2">
|
||||||
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={generateCode}
|
onClick={generateCode}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
|
className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Generate new code
|
Generate new code
|
||||||
</button>
|
</button>
|
||||||
@@ -190,9 +190,9 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Sign out */}
|
{/* Sign out */}
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-xl">
|
<section className="bg-surface border border-line rounded-xl">
|
||||||
<div className="px-4 py-3 flex items-center justify-between">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-400">Sign out of this device</p>
|
<p className="text-sm text-ink-2">Sign out of this device</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="text-sm text-red-500 hover:text-red-400 transition-colors"
|
className="text-sm text-red-500 hover:text-red-400 transition-colors"
|
||||||
@@ -213,8 +213,8 @@ function Row({ label, value, mono = false, truncate = false }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<span className="text-xs text-gray-500 shrink-0">{label}</span>
|
<span className="text-xs text-ink-muted shrink-0">{label}</span>
|
||||||
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
<span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<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">
|
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||||
<h2 className="text-white font-semibold">Key created</h2>
|
<h2 className="text-white font-semibold">Key created</h2>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-ink-2">
|
||||||
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
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.
|
<code className="text-ink-2">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||||
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -61,11 +61,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
<label className="text-xs text-ink-2 block mb-1">Label</label>
|
||||||
<input
|
<input
|
||||||
required value={name} onChange={(e) => setName(e.target.value)}
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Ops dashboard integration"
|
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -96,9 +96,9 @@ export default function ApiKeysSettingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-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-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||||
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
<p className="text-ink-2 text-xs mt-1 leading-relaxed">
|
||||||
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
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
|
demo module (<code className="text-ink-2">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.
|
end. See that file for the exact backend routes a real integration needs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +108,7 @@ export default function ApiKeysSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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>
|
<p className="text-sm text-ink-muted">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
||||||
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ export default function ApiKeysSettingsPage() {
|
|||||||
<Card padding="none" className="overflow-hidden">
|
<Card padding="none" className="overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
||||||
<th className="px-4 py-3 text-left">Label</th>
|
<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">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">Created</th>
|
||||||
@@ -129,11 +129,11 @@ export default function ApiKeysSettingsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{keys.map((k) => (
|
{keys.map((k) => (
|
||||||
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
|
<tr key={k.key_id} className="border-b border-line last:border-0">
|
||||||
<td className="px-4 py-3 text-white">{k.name}</td>
|
<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-ink-muted 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-ink-2 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 text-ink-2 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline justify-between mb-1.5">
|
<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 text-ink-2 font-mono">{label}</span>
|
||||||
<span className="text-xs font-mono text-gray-300">
|
<span className="text-xs font-mono text-ink-2">
|
||||||
{used} / {limit === "unlimited" ? "∞" : limit}
|
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
<div className="h-2 rounded-full bg-raised overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||||
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||||
@@ -142,7 +142,7 @@ export default function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<p className="text-xs text-gray-600 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export default function BillingSettingsPage() {
|
|||||||
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||||
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 pt-5 border-t border-gray-800">
|
<div className="mt-5 pt-5 border-t border-line">
|
||||||
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||||
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -179,10 +179,10 @@ export default function BillingSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
|
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-line"}`}
|
||||||
>
|
>
|
||||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
<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-ink-muted text-xs mt-1 flex-1">{p.tagline}</p>
|
||||||
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
|
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
||||||
@@ -212,17 +212,17 @@ export default function BillingSettingsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Invoice history" />
|
<CardHeader title="Invoice history" />
|
||||||
{invoices.length === 0 ? (
|
{invoices.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm">No invoices yet.</p>
|
<p className="text-ink-muted text-sm">No invoices yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-800">
|
<div className="divide-y divide-line">
|
||||||
{invoices.map((inv) => (
|
{invoices.map((inv) => (
|
||||||
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-gray-200 text-sm">{inv.description}</p>
|
<p className="text-ink text-sm">{inv.description}</p>
|
||||||
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
<p className="text-ink-muted text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
<span className="text-ink-2 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||||
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
|
|||||||
|
|
||||||
function HowItWorks() {
|
function HowItWorks() {
|
||||||
const steps = [
|
const steps = [
|
||||||
["1 · Desired", "/join in Discord or this page writes a discord_streams doc."],
|
["1 · Desired", "A node page's picker, this page, or /join (DRB server bot) writes a discord_streams doc."],
|
||||||
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server, in one transaction."],
|
["2 · Lease", "C2 picks an online node on that system and a radio bot already in that server that Discord lets View, Connect and Speak in the channel — the system's preferred bot first — in one transaction."],
|
||||||
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
|
["3 · Reconcile", "A retained MQTT message tells the node its full channel list (token ids, never token strings)."],
|
||||||
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
|
["4 · Voice", "The node logs in as the leased bot and plays its PulseAudio capture — never Icecast."],
|
||||||
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
|
["5 · Actual", "The node reports each channel's real state; this page and the Discord reply show it."],
|
||||||
@@ -54,6 +54,12 @@ function HowItWorks() {
|
|||||||
title="How a radio channel stays up"
|
title="How a radio channel stays up"
|
||||||
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
|
note="Node reboot, C2 sweep, Discord voice drop: the node re-reads its desired state and reconnects; persistent channels whose node went away are re-placed on another node. A channel desired-on but not connected for 2 minutes turns red on the Radio channels tab."
|
||||||
>
|
>
|
||||||
|
<p className="px-4 pt-4 text-xs text-ink-2">
|
||||||
|
<b className="text-ink">The DRB server bot is optional.</b> One radio bot in a Discord server is enough: adding it
|
||||||
|
through <i>Connect a Discord server</i> (Settings → Organization) links the server, and C2 reads its voice
|
||||||
|
channels and permissions with that bot. The server bot adds the slash commands (/join, /leave, /status,
|
||||||
|
/drb link) and pushes its own channel list; both ways keep working side by side.
|
||||||
|
</p>
|
||||||
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-2 p-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
{steps.map(([t, d]) => (
|
{steps.map(([t, d]) => (
|
||||||
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
|
<div key={t} className="bg-raised border border-line rounded-lg p-3 text-xs text-ink-muted">
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||||||
description="Organization profile, team access, node ownership, API keys, and billing."
|
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">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||||
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
|
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
|
||||||
<Link
|
<Link
|
||||||
key={t.href}
|
key={t.href}
|
||||||
href={t.href}
|
href={t.href}
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||||
pathname === t.href || pathname.startsWith(t.href + "/")
|
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||||
? "bg-gray-800 text-white"
|
? "bg-raised text-white"
|
||||||
: "text-gray-500 hover:text-gray-300"
|
: "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
|
|||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<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">
|
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||||
<h2 className="text-white font-semibold">Member invited</h2>
|
<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>
|
<p className="text-xs text-ink-2">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">
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onClose} fullWidth>Done</Button>
|
<Button onClick={onClose} fullWidth>Done</Button>
|
||||||
@@ -62,18 +62,18 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
|
|||||||
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="teammate@example.com"
|
placeholder="teammate@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
<label className="text-xs text-ink-2 block mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
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"
|
className="w-full bg-raised border border-line-strong 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="admin">Admin — full access</option>
|
||||||
<option value="operator">Operator — owns nodes</option>
|
<option value="operator">Operator — owns nodes</option>
|
||||||
@@ -130,7 +130,7 @@ export default function MembersSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-ink-muted">
|
||||||
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||||
@@ -144,7 +144,7 @@ export default function MembersSettingsPage() {
|
|||||||
<Card padding="none" className="overflow-hidden">
|
<Card padding="none" className="overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
||||||
<th className="px-4 py-3 text-left">Member</th>
|
<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">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 sm:table-cell">Owned nodes</th>
|
||||||
@@ -155,10 +155,10 @@ export default function MembersSettingsPage() {
|
|||||||
{loading
|
{loading
|
||||||
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||||
: users.map((u) => (
|
: users.map((u) => (
|
||||||
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
<tr key={u.uid} className="border-b border-line last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<p className="text-white">{u.display_name || u.email}</p>
|
<p className="text-white">{u.display_name || u.email}</p>
|
||||||
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
{u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{u.uid === user?.uid ? (
|
{u.uid === user?.uid ? (
|
||||||
@@ -168,7 +168,7 @@ export default function MembersSettingsPage() {
|
|||||||
value={u.role}
|
value={u.role}
|
||||||
disabled={savingUid === u.uid}
|
disabled={savingUid === u.uid}
|
||||||
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
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"
|
className="bg-raised border border-line-strong 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="admin">Admin</option>
|
||||||
<option value="operator">Operator</option>
|
<option value="operator">Operator</option>
|
||||||
@@ -176,7 +176,7 @@ export default function MembersSettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
|
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">
|
||||||
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 hidden md:table-cell">
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
@@ -189,7 +189,7 @@ export default function MembersSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-600 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function EnrollmentTokensPanel() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-surface rounded px-2 py-1.5">{justMinted}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
||||||
@@ -133,7 +133,7 @@ function EnrollmentTokensPanel() {
|
|||||||
: <>(check the broker host)</>}:
|
: <>(check the broker host)</>}:
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
|
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-surface rounded px-2 py-1.5">{installCmd}</pre>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
||||||
@@ -163,7 +163,7 @@ function EnrollmentTokensPanel() {
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="Node ID, e.g. node-003"
|
placeholder="Node ID, e.g. node-003"
|
||||||
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="flex-1 min-w-[12rem] bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
||||||
{minting ? "Minting…" : "New token"}
|
{minting ? "Minting…" : "New token"}
|
||||||
@@ -176,11 +176,11 @@ function EnrollmentTokensPanel() {
|
|||||||
<SkeletonRow cols={1} />
|
<SkeletonRow cols={1} />
|
||||||
</div>
|
</div>
|
||||||
) : tokens.length === 0 ? (
|
) : tokens.length === 0 ? (
|
||||||
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p>
|
<p className="text-ink-muted text-xs">No enrollment tokens yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
|
||||||
<th className="py-2 text-left">Label</th>
|
<th className="py-2 text-left">Label</th>
|
||||||
<th className="py-2 text-left hidden sm:table-cell">Created</th>
|
<th className="py-2 text-left hidden sm:table-cell">Created</th>
|
||||||
<th className="py-2 text-left">Status</th>
|
<th className="py-2 text-left">Status</th>
|
||||||
@@ -189,14 +189,14 @@ function EnrollmentTokensPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tokens.map((t) => (
|
{tokens.map((t) => (
|
||||||
<tr key={t.token_id} className="border-b border-gray-800 last:border-0">
|
<tr key={t.token_id} className="border-b border-line last:border-0">
|
||||||
<td className="py-2 text-white">{t.label}</td>
|
<td className="py-2 text-white">{t.label}</td>
|
||||||
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell">
|
<td className="py-2 text-ink-muted text-xs hidden sm:table-cell">
|
||||||
{new Date(t.created_at).toLocaleDateString()}
|
{new Date(t.created_at).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs">
|
<td className="py-2 text-xs">
|
||||||
{t.revoked ? (
|
{t.revoked ? (
|
||||||
<span className="text-gray-600">Revoked</span>
|
<span className="text-ink-muted">Revoked</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
|
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +283,7 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
<EnrollmentTokensPanel />
|
<EnrollmentTokensPanel />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-ink-muted">
|
||||||
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
<Card padding="none" className="overflow-hidden">
|
<Card padding="none" className="overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
||||||
<th className="px-4 py-3 text-left">Node</th>
|
<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 hidden sm:table-cell">Status</th>
|
||||||
<th className="px-4 py-3 text-left">Owner</th>
|
<th className="px-4 py-3 text-left">Owner</th>
|
||||||
@@ -302,17 +302,17 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||||
) : nodes.length === 0 ? (
|
) : 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>
|
<tr><td colSpan={3} className="px-4 py-8 text-center text-ink-muted text-sm">No nodes registered yet.</td></tr>
|
||||||
) : (
|
) : (
|
||||||
nodes.map((n) => {
|
nodes.map((n) => {
|
||||||
const owner = ownerByNode.get(n.node_id);
|
const owner = ownerByNode.get(n.node_id);
|
||||||
return (
|
return (
|
||||||
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
|
<tr key={n.node_id} className="border-b border-line last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||||
{n.name}
|
{n.name}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
<p className="text-ink-muted text-xs font-mono">{n.node_id}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -320,7 +320,7 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
value={owner?.uid ?? UNASSIGNED}
|
value={owner?.uid ?? UNASSIGNED}
|
||||||
disabled={savingNodeId === n.node_id}
|
disabled={savingNodeId === n.node_id}
|
||||||
onChange={(e) => reassign(n.node_id, e.target.value)}
|
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]"
|
className="bg-raised border border-line-strong 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>
|
<option value={UNASSIGNED}>Unassigned</option>
|
||||||
{assignable.map((u) => (
|
{assignable.map((u) => (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
|
|||||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono">{label}</p>
|
||||||
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -69,12 +69,12 @@ export default function OrganizationSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
<label className="text-xs text-ink-2 block mb-1">Organization name</label>
|
||||||
<input
|
<input
|
||||||
value={orgName}
|
value={orgName}
|
||||||
disabled={orgLoading || !canEdit}
|
disabled={orgLoading || !canEdit}
|
||||||
onChange={(e) => setOrgName(e.target.value)}
|
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 disabled:opacity-50"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
|
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
|
||||||
@@ -110,7 +110,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
value={sub ? sub.status.replace("_", " ") : "—"}
|
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
|
<div className="mt-5 pt-5 border-t border-line flex items-center gap-4 text-sm">
|
||||||
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||||
Manage plan & billing →
|
Manage plan & billing →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -124,7 +124,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
|
|
||||||
<PreferredBotsCard canEdit={canEdit} />
|
<PreferredBotsCard canEdit={canEdit} />
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
<div className="bg-surface border border-red-800/60 rounded-xl p-5">
|
||||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
||||||
|
|||||||
@@ -61,23 +61,23 @@ export default function SignupPage() {
|
|||||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
DRB
|
DRB
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
<h1 className="text-white text-lg font-bold">Create your account</h1>
|
<h1 className="text-white text-lg font-bold">Create your account</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
<label className="text-xs text-ink-2 block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -85,7 +85,7 @@ export default function SignupPage() {
|
|||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -102,7 +102,7 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
<span className="text-xs text-gray-500">or</span>
|
<span className="text-xs text-ink-muted">or</span>
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export default function SignupPage() {
|
|||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-500">
|
<p className="text-center text-xs text-ink-muted">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+127
-116
@@ -438,17 +438,17 @@ function RRImportModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
|
<div className="px-5 pt-5 pb-4 border-b border-line">
|
||||||
<h2 className="text-white font-semibold">{system.name}</h2>
|
<h2 className="text-white font-semibold">{system.name}</h2>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-ink-muted mt-0.5">
|
||||||
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
||||||
</p>
|
</p>
|
||||||
{system.sysIds && (
|
{system.sysIds && (
|
||||||
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
|
<p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-ink-muted mt-1">
|
||||||
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,7 +456,7 @@ function RRImportModal({
|
|||||||
{/* Category list */}
|
{/* Category list */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
|
<p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
|
||||||
<div className="flex gap-3 text-xs text-indigo-400">
|
<div className="flex gap-3 text-xs text-indigo-400">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -480,18 +480,18 @@ function RRImportModal({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(cat.name)}
|
checked={selected.has(cat.name)}
|
||||||
onChange={() => toggle(cat.name)}
|
onChange={() => toggle(cat.name)}
|
||||||
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
|
className="rounded border-line-strong bg-raised accent-indigo-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
|
<span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
|
<span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
|
<div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
@@ -503,7 +503,7 @@ function RRImportModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
|
|||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cls =
|
const cls =
|
||||||
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
|
"w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs text-gray-500 font-sans">{label}</span>
|
<span className="text-xs text-ink-muted font-sans">{label}</span>
|
||||||
{multiline ? (
|
{multiline ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={value}
|
value={value}
|
||||||
@@ -656,8 +656,8 @@ function TalkgroupEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-gray-400">
|
<label className="text-xs text-ink-2">
|
||||||
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* RadioReference file import */}
|
{/* RadioReference file import */}
|
||||||
@@ -704,10 +704,10 @@ function TalkgroupEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showPaste && (
|
{showPaste && (
|
||||||
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
<div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-ink-muted">
|
||||||
Paste rows from RadioReference — tab- or comma-separated:{" "}
|
Paste rows from RadioReference — tab- or comma-separated:{" "}
|
||||||
<span className="text-gray-400">ID, Name, Tag</span>
|
<span className="text-ink-2">ID, Name, Tag</span>
|
||||||
<br />Tags: fire · police · ems · transit · public works · other
|
<br />Tags: fire · police · ems · transit · public works · other
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -715,7 +715,7 @@ function TalkgroupEditor({
|
|||||||
onChange={(e) => setPasteText(e.target.value)}
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
|
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
|
||||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -729,10 +729,10 @@ function TalkgroupEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{talkgroups.length > 0 ? (
|
{talkgroups.length > 0 ? (
|
||||||
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
<div className="border border-line rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-xs font-mono">
|
<table className="w-full text-xs font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-800 text-gray-400">
|
<tr className="bg-raised text-ink-2">
|
||||||
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
|
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
|
||||||
<th className="px-3 py-1.5 text-left">Name</th>
|
<th className="px-3 py-1.5 text-left">Name</th>
|
||||||
<th className="px-3 py-1.5 text-left w-28">Tag</th>
|
<th className="px-3 py-1.5 text-left w-28">Tag</th>
|
||||||
@@ -742,7 +742,7 @@ function TalkgroupEditor({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{talkgroups.map((tg, i) => (
|
{talkgroups.map((tg, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
<tr className="border-t border-gray-800 hover:bg-gray-800/30">
|
<tr className="border-t border-line hover:bg-raised/30">
|
||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
<input
|
<input
|
||||||
value={tg.id}
|
value={tg.id}
|
||||||
@@ -763,7 +763,7 @@ function TalkgroupEditor({
|
|||||||
<select
|
<select
|
||||||
value={tg.tag}
|
value={tg.tag}
|
||||||
onChange={(e) => updateRow(i, "tag", e.target.value)}
|
onChange={(e) => updateRow(i, "tag", e.target.value)}
|
||||||
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1"
|
className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
|
||||||
>
|
>
|
||||||
{TG_TAGS.map((t) => (
|
{TG_TAGS.map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
@@ -776,7 +776,7 @@ function TalkgroupEditor({
|
|||||||
title="Local knowledge for the transcript corrector"
|
title="Local knowledge for the transcript corrector"
|
||||||
onClick={() => setExpanded(expanded === i ? null : i)}
|
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||||
className={`transition-colors font-bold mr-2 ${
|
className={`transition-colors font-bold mr-2 ${
|
||||||
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
|
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{expanded === i ? "▾" : "▸"}
|
{expanded === i ? "▾" : "▸"}
|
||||||
@@ -784,7 +784,7 @@ function TalkgroupEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeRow(i)}
|
onClick={() => removeRow(i)}
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors font-bold"
|
className="text-ink-muted hover:text-red-400 transition-colors font-bold"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -794,11 +794,11 @@ function TalkgroupEditor({
|
|||||||
talkgroups and most inherit the system's context rather than
|
talkgroups and most inherit the system's context rather than
|
||||||
override it. */}
|
override it. */}
|
||||||
{expanded === i && (
|
{expanded === i && (
|
||||||
<tr className="border-t border-gray-800 bg-gray-900/60">
|
<tr className="border-t border-line bg-surface/60">
|
||||||
<td colSpan={4} className="px-3 py-3">
|
<td colSpan={4} className="px-3 py-3">
|
||||||
<p className="text-xs text-gray-500 mb-2 font-sans">
|
<p className="text-xs text-ink-muted mb-2 font-sans">
|
||||||
Given to the transcript corrector for this talkgroup only, ranked
|
Given to the transcript corrector for this talkgroup only, ranked
|
||||||
<span className="text-gray-300"> above</span> the system's own list.
|
<span className="text-ink-2"> above</span> the system's own list.
|
||||||
Leave blank to inherit the system's.
|
Leave blank to inherit the system's.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
@@ -849,7 +849,7 @@ function TalkgroupEditor({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 italic py-1">
|
<p className="text-xs text-ink-muted italic py-1">
|
||||||
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
|
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label>
|
<label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
|
||||||
<input
|
<input
|
||||||
value={value.nac}
|
value={value.nac}
|
||||||
onChange={(e) => onChange({ ...value, nac: e.target.value })}
|
onChange={(e) => onChange({ ...value, nac: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="0x293"
|
placeholder="0x293"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">System ID</label>
|
<label className="text-xs text-ink-2 block mb-1">System ID</label>
|
||||||
<input
|
<input
|
||||||
value={value.system_id}
|
value={value.system_id}
|
||||||
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
|
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="50513"
|
placeholder="50513"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label>
|
<label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
|
||||||
<input
|
<input
|
||||||
value={value.wacn}
|
value={value.wacn}
|
||||||
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
|
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="0xBEE00"
|
placeholder="0xBEE00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span>
|
Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={value.control_channels}
|
value={value.control_channels}
|
||||||
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
|
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="851.0125, 851.5125, 852.0125"
|
placeholder="851.0125, 851.5125, 852.0125"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span>
|
Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={value.voice_channels}
|
value={value.voice_channels}
|
||||||
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
|
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
|||||||
|
|
||||||
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
|
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
|
||||||
|
|
||||||
const INPUT = "w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
const INPUT = "w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||||||
|
|
||||||
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
|
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
|
||||||
const chs = value.channels;
|
const chs = value.channels;
|
||||||
@@ -931,13 +931,13 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
|
|||||||
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
|
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-ink-muted">
|
||||||
Fixed voice channels with no control channel — RadioReference lists them as conventional
|
Fixed voice channels with no control channel — RadioReference lists them as conventional
|
||||||
frequencies with mode P25 (or "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
frequencies with mode P25 (or "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
||||||
one SDR, within about 1.5 MHz of each other. Each channel's calls are labelled with its name.
|
one SDR, within about 1.5 MHz of each other. Each channel's calls are labelled with its name.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400">
|
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
|
||||||
<span>Frequency (MHz)</span>
|
<span>Frequency (MHz)</span>
|
||||||
<span>Channel name</span>
|
<span>Channel name</span>
|
||||||
<span>NAC (hex, optional)</span>
|
<span>NAC (hex, optional)</span>
|
||||||
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
|
|||||||
talkgroups={value.talkgroups}
|
talkgroups={value.talkgroups}
|
||||||
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
|
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-600 -mt-2">
|
<p className="text-xs text-ink-muted -mt-2">
|
||||||
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
|
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label>
|
<label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
|
||||||
<input
|
<input
|
||||||
id="analog-freq"
|
id="analog-freq"
|
||||||
value={ch.freq}
|
value={ch.freq}
|
||||||
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label>
|
<label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
|
||||||
<input
|
<input
|
||||||
id="analog-name"
|
id="analog-name"
|
||||||
value={ch.name}
|
value={ch.name}
|
||||||
@@ -1124,27 +1124,27 @@ function SystemForm({
|
|||||||
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
<form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
|
||||||
<h3 className="text-white font-semibold">{title}</h3>
|
<h3 className="text-white font-semibold">{title}</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Westchester County P25"
|
placeholder="Westchester County P25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label>
|
<label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
|
||||||
<select
|
<select
|
||||||
id="system-kind"
|
id="system-kind"
|
||||||
value={kind}
|
value={kind}
|
||||||
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
|
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{KIND_OPTIONS.map((o) => (
|
{KIND_OPTIONS.map((o) => (
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
@@ -1165,12 +1165,12 @@ function SystemForm({
|
|||||||
<AnalogForm value={analog} onChange={setAnalog} />
|
<AnalogForm value={analog} onChange={setAnalog} />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
|
<label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={rawJson}
|
value={rawJson}
|
||||||
onChange={(e) => setRawJson(e.target.value)}
|
onChange={(e) => setRawJson(e.target.value)}
|
||||||
rows={10}
|
rows={10}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1179,7 +1179,7 @@ function SystemForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleRaw}
|
onClick={toggleRaw}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
||||||
>
|
>
|
||||||
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
|
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1198,7 +1198,7 @@ function SystemForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-2 font-mono text-xs">
|
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||||
{tokens === null ? (
|
{tokens === null ? (
|
||||||
<p className="text-gray-600 italic">Loading tokens…</p>
|
<p className="text-ink-muted italic">Loading tokens…</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-red-400">{error}</p>
|
<p className="text-red-400">{error}</p>
|
||||||
) : tokens.length === 0 ? (
|
) : tokens.length === 0 ? (
|
||||||
<p className="text-gray-600 italic">No tokens in pool.</p>
|
<p className="text-ink-muted italic">No tokens in pool.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-600">
|
<p className="text-ink-muted">
|
||||||
When a node on this system joins a voice channel, this token is tried first.
|
When a node on this system joins a voice channel, this token is tried first.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="accent-indigo-500"
|
className="accent-indigo-500"
|
||||||
/>
|
/>
|
||||||
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
|
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
|
||||||
{t.name}
|
{t.name}
|
||||||
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Clear preference (use any free token)
|
Clear preference (use any free token)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!preferredId && (
|
{!preferredId && (
|
||||||
<p className="text-gray-700">No preference — any free token will be used.</p>
|
<p className="text-ink-muted">No preference — any free token will be used.</p>
|
||||||
)}
|
)}
|
||||||
{currentToken && (
|
{currentToken && (
|
||||||
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||||||
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>AI Flags</span>
|
<span>AI Flags</span>
|
||||||
@@ -1400,23 +1400,23 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
|
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-300 flex-1">{label}</span>
|
<span className="text-ink-2 flex-1">{label}</span>
|
||||||
{isSet ? (
|
{isSet ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClear(key)}
|
onClick={() => handleClear(key)}
|
||||||
disabled={saving === key}
|
disabled={saving === key}
|
||||||
className="text-gray-600 hover:text-gray-400 transition-colors"
|
className="text-ink-muted hover:text-ink-2 transition-colors"
|
||||||
title="Clear override (inherit global)"
|
title="Clear override (inherit global)"
|
||||||
>
|
>
|
||||||
reset
|
reset
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">inherits global</span>
|
<span className="text-ink-muted">inherits global</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
|
<p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
|||||||
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
||||||
</button>
|
</button>
|
||||||
{open && call && (
|
{open && call && (
|
||||||
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
|
<div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
|
||||||
{call.audio_url ? (
|
{call.audio_url ? (
|
||||||
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 italic">No audio</p>
|
<p className="text-ink-muted italic">No audio</p>
|
||||||
)}
|
)}
|
||||||
{transcript && (
|
{transcript && (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
|
<p className="text-ink-muted italic line-clamp-2">{transcript}</p>
|
||||||
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
||||||
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
||||||
</>
|
</>
|
||||||
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Local Area
|
Local Area
|
||||||
{area !== null && (
|
{area !== null && (
|
||||||
<span className="text-gray-600 ml-1">
|
<span className="text-ink-muted ml-1">
|
||||||
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-3 text-xs">
|
<div className="mt-3 space-y-3 text-xs">
|
||||||
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
{loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
|
||||||
{area && (
|
{area && (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-500">
|
<p className="text-ink-muted">
|
||||||
Given to the transcript corrector for every talkgroup on this system.
|
Given to the transcript corrector for every talkgroup on this system.
|
||||||
Whisper mishears local names constantly — naming them here is what lets
|
Whisper mishears local names constantly — naming them here is what lets
|
||||||
them be put back.
|
them be put back.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-ink-muted">
|
||||||
Fill this in <span className="text-gray-300">only if it is true of every
|
Fill this in <span className="text-ink-2">only if it is true of every
|
||||||
talkgroup</span> on the system. One town, one department — fill it once here.
|
talkgroup</span> on the system. One town, one department — fill it once here.
|
||||||
A system spanning several counties — leave it blank and describe each
|
A system spanning several counties — leave it blank and describe each
|
||||||
talkgroup in the edit form instead.
|
talkgroup in the edit form instead.
|
||||||
@@ -1592,7 +1592,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
|||||||
{/* The anchor is the backend's answer to what was typed above, and
|
{/* The anchor is the backend's answer to what was typed above, and
|
||||||
it decides whether location checking runs at all — so it is
|
it decides whether location checking runs at all — so it is
|
||||||
worth showing rather than leaving as invisible state. */}
|
worth showing rather than leaving as invisible state. */}
|
||||||
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p>
|
<p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Proposed Terms
|
Proposed Terms
|
||||||
{rows !== null && (
|
{rows !== null && (
|
||||||
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}>
|
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
|
||||||
({total > 0 ? `${total} awaiting review` : "none"})
|
({total > 0 ? `${total} awaiting review` : "none"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-3 text-xs">
|
<div className="mt-3 space-y-3 text-xs">
|
||||||
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
{rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
|
||||||
{rows !== null && total === 0 && (
|
{rows !== null && total === 0 && (
|
||||||
<p className="text-gray-600 italic">
|
<p className="text-ink-muted italic">
|
||||||
Nothing proposed yet. Terms appear here when a corrected place name
|
Nothing proposed yet. Terms appear here when a corrected place name
|
||||||
turns out to be real nearby, or when the induction loop spots one in
|
turns out to be real nearby, or when the induction loop spots one in
|
||||||
recent traffic.
|
recent traffic.
|
||||||
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
)}
|
)}
|
||||||
{(rows ?? []).map((row) => (
|
{(rows ?? []).map((row) => (
|
||||||
<div key={row.talkgroup_id}>
|
<div key={row.talkgroup_id}>
|
||||||
<p className="text-gray-400 font-mono mb-1">
|
<p className="text-ink-2 font-mono mb-1">
|
||||||
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
|
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{row.pending.map((p) => (
|
{row.pending.map((p) => (
|
||||||
<li
|
<li
|
||||||
key={p.term}
|
key={p.term}
|
||||||
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1"
|
className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<span className="text-white font-mono">{p.term}</span>
|
<span className="text-white font-mono">{p.term}</span>
|
||||||
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
|
{p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
|
||||||
<span className="text-gray-700 ml-2">{p.source}</span>
|
<span className="text-ink-muted ml-2">{p.source}</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||||||
onClick={() => act(row.talkgroup_id, p.term, false)}
|
onClick={() => act(row.talkgroup_id, p.term, false)}
|
||||||
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold"
|
className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
|
||||||
>
|
>
|
||||||
Drop
|
Drop
|
||||||
</button>
|
</button>
|
||||||
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Vocabulary
|
Vocabulary
|
||||||
{vocab !== null && (
|
{vocab !== null && (
|
||||||
<span className="text-gray-600 ml-1">
|
<span className="text-ink-muted ml-1">
|
||||||
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
|
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-3 font-mono text-xs">
|
<div className="mt-3 space-y-3 font-mono text-xs">
|
||||||
{loading && <p className="text-gray-600 italic">Loading…</p>}
|
{loading && <p className="text-ink-muted italic">Loading…</p>}
|
||||||
|
|
||||||
{!loading && vocab !== null && (
|
{!loading && vocab !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
>
|
>
|
||||||
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
|
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
<span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
<p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
||||||
{vocab.length > 0 ? (
|
{vocab.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{vocab.map((term) => (
|
{vocab.map((term) => (
|
||||||
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
<span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
|
||||||
{term}
|
{term}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(term)}
|
onClick={() => handleRemove(term)}
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors leading-none"
|
className="text-ink-muted hover:text-red-400 transition-colors leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p>
|
<p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
value={newTerm}
|
value={newTerm}
|
||||||
onChange={(e) => setNewTerm(e.target.value)}
|
onChange={(e) => setNewTerm(e.target.value)}
|
||||||
placeholder="Add term (e.g. 5-baker, YVAC)"
|
placeholder="Add term (e.g. 5-baker, YVAC)"
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={adding || !newTerm.trim()}
|
disabled={adding || !newTerm.trim()}
|
||||||
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors"
|
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
<p className="text-ink-muted uppercase tracking-wider mb-1.5">
|
||||||
Induction suggestions ({pending.length})
|
Induction suggestions ({pending.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{pending.map((p) => (
|
{pending.map((p) => (
|
||||||
<div key={p.term} className="space-y-1">
|
<div key={p.term} className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
<span className="text-ink-2 flex-1">{p.term}</span>
|
||||||
<span className="text-gray-600">{p.source}</span>
|
<span className="text-ink-muted">{p.source}</span>
|
||||||
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
||||||
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
<button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||||||
<div className="pl-1 space-y-1">
|
<div className="pl-1 space-y-1">
|
||||||
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
|
|||||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Delete this system?")) return;
|
if (!confirm("Delete this system?")) return;
|
||||||
await c2api.deleteSystem(id);
|
try { await c2api.deleteSystem(id); }
|
||||||
|
catch (e) { alert(`Couldn't delete the system: ${e instanceof Error ? e.message : String(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(s: SystemRecord) {
|
function openEdit(s: SystemRecord) {
|
||||||
@@ -1952,12 +1953,16 @@ export default function SystemsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||||
|
{/* Every systems write route is require_admin_token (server-26#283);
|
||||||
|
operators get a read-only view. */}
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
+ New System
|
+ New System
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
@@ -1970,9 +1975,9 @@ export default function SystemsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : systems.length === 0 ? (
|
) : systems.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{systems.map((s) => {
|
{systems.map((s) => {
|
||||||
@@ -1987,13 +1992,13 @@ export default function SystemsPage() {
|
|||||||
? (s.config.channels as unknown[]).length
|
? (s.config.channels as unknown[]).length
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono">
|
<div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-semibold">{s.name}</p>
|
<p className="text-white font-semibold">{s.name}</p>
|
||||||
<p className="text-xs text-gray-500">{s.system_id}</p>
|
<p className="text-xs text-ink-muted">{s.system_id}</p>
|
||||||
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
|
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
|
||||||
<p className="text-xs text-gray-600 mt-0.5 space-x-2">
|
<p className="text-xs text-ink-muted mt-0.5 space-x-2">
|
||||||
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
|
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
|
||||||
{ccCount !== null && <span>· {ccCount} CC</span>}
|
{ccCount !== null && <span>· {ccCount} CC</span>}
|
||||||
{chCount !== null && <span>· {chCount} ch</span>}
|
{chCount !== null && <span>· {chCount} ch</span>}
|
||||||
@@ -2001,11 +2006,11 @@ export default function SystemsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded">
|
<span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
|
||||||
{kindLabel(s)}
|
{kindLabel(s)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-3">
|
{isAdmin && <div className="mt-3 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(s)}
|
onClick={() => openEdit(s)}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
@@ -2014,7 +2019,7 @@ export default function SystemsPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openDuplicate(s)}
|
onClick={() => openDuplicate(s)}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
@@ -2024,15 +2029,21 @@ export default function SystemsPage() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
|
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
|
||||||
(server-26#204 item 6), so for anyone else this panel could
|
(server-26#204 item 6), so for anyone else this panel could
|
||||||
neither list the pool nor save a choice (server-26#213 item 4). */}
|
neither list the pool nor save a choice (server-26#213 item 4). */}
|
||||||
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
|
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
|
||||||
|
{/* All admin-only routes (server-26#283), incl. area-context and
|
||||||
|
talkgroup-pending GETs and every vocabulary write. */}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||||
<AreaContextPanel systemId={s.system_id} />
|
<AreaContextPanel systemId={s.system_id} />
|
||||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
<TalkgroupPendingPanel systemId={s.system_id} />
|
||||||
<VocabularyPanel systemId={s.system_id} />
|
<VocabularyPanel systemId={s.system_id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -69,18 +69,18 @@ export default function TermsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-display-sm text-white">Terms of Service</h1>
|
<h1 className="text-display-sm text-white">Terms of Service</h1>
|
||||||
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
<p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
||||||
|
|
||||||
<div className="mt-10 space-y-8">
|
<div className="mt-10 space-y-8">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<section key={s.heading}>
|
<section key={s.heading}>
|
||||||
<h2 className="text-white font-semibold">{s.heading}</h2>
|
<h2 className="text-white font-semibold">{s.heading}</h2>
|
||||||
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
<p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-xs font-mono mt-16">
|
<p className="text-ink-muted text-xs font-mono mt-16">
|
||||||
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
Questions in the meantime? <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 reach us directly</Link>.
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,7 +142,7 @@ function PlaceSearch({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
|
||||||
placeholder="Search a place…"
|
placeholder="Search a place…"
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,7 +154,7 @@ function PlaceSearch({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700">
|
<div className="bg-raised border border-line-strong rounded-lg overflow-hidden divide-y divide-line-strong">
|
||||||
{results.map((p) => (
|
{results.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.place_id}
|
key={p.place_id}
|
||||||
@@ -162,7 +163,7 @@ function PlaceSearch({
|
|||||||
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<p className="text-white text-sm font-medium">{p.name}</p>
|
<p className="text-white text-sm font-medium">{p.name}</p>
|
||||||
<p className="text-gray-400 text-xs">{p.address}</p>
|
<p className="text-ink-2 text-xs">{p.address}</p>
|
||||||
{p.rating && (
|
{p.rating && (
|
||||||
<p className="text-yellow-400 text-xs">★ {p.rating}</p>
|
<p className="text-yellow-400 text-xs">★ {p.rating}</p>
|
||||||
)}
|
)}
|
||||||
@@ -240,60 +241,60 @@ function AddEventModal({
|
|||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
|
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
<label className="text-xs text-ink-2 block mb-1">Title</label>
|
||||||
<input
|
<input
|
||||||
required value={title} onChange={(e) => setTitle(e.target.value)}
|
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Dinner at the bar"
|
placeholder="Dinner at the bar"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
<label className="text-xs text-ink-2 block mb-1">Date</label>
|
||||||
<input
|
<input
|
||||||
required type="date"
|
required type="date"
|
||||||
min={trip.start_date} max={trip.end_date}
|
min={trip.start_date} max={trip.end_date}
|
||||||
value={date} onChange={(e) => setDate(e.target.value)}
|
value={date} onChange={(e) => setDate(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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Start</label>
|
<label className="text-xs text-ink-2 block mb-1">Start</label>
|
||||||
<input
|
<input
|
||||||
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
|
type="time" value={startTime} onChange={(e) => setStartTime(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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">End</label>
|
<label className="text-xs text-ink-2 block mb-1">End</label>
|
||||||
<input
|
<input
|
||||||
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
|
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
|
||||||
min={startTime}
|
min={startTime}
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Find a place</label>
|
<label className="text-xs text-ink-2 block mb-1">Find a place</label>
|
||||||
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
|
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Location{" "}
|
Location{" "}
|
||||||
<span className="text-gray-600">(auto-filled from search, or type manually)</span>
|
<span className="text-ink-muted">(auto-filled from search, or type manually)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={location} onChange={(e) => setLocation(e.target.value)}
|
value={location} onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder={`Inherits: ${trip.location}`}
|
placeholder={`Inherits: ${trip.location}`}
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -307,16 +308,16 @@ function AddEventModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Notes (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
|
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableTags.length > 0 && (
|
{availableTags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Tags</label>
|
<label className="text-xs text-ink-2 block mb-1">Tags</label>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{availableTags.map((tag) => {
|
{availableTags.map((tag) => {
|
||||||
const active = tags.includes(tag);
|
const active = tags.includes(tag);
|
||||||
@@ -326,7 +327,7 @@ function AddEventModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
||||||
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
||||||
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
|
active ? tagColor(tag, availableTags) : "bg-raised text-ink-muted border-line-strong hover:border-line-strong hover:text-ink-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
@@ -340,7 +341,7 @@ function AddEventModal({
|
|||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end pt-1">
|
<div className="flex gap-3 justify-end pt-1">
|
||||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
<button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -389,7 +390,7 @@ function DayTimeline({
|
|||||||
|
|
||||||
if (timed.length === 0 && untimed.length === 0) {
|
if (timed.length === 0 && untimed.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-gray-600 text-sm font-mono py-8 text-center">
|
<p className="text-ink-muted text-sm font-mono py-8 text-center">
|
||||||
No events on this day yet.
|
No events on this day yet.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -424,10 +425,10 @@ function DayTimeline({
|
|||||||
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
|
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
|
||||||
className="absolute inset-x-0 flex items-center pointer-events-none"
|
className="absolute inset-x-0 flex items-center pointer-events-none"
|
||||||
>
|
>
|
||||||
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2">
|
<span className="w-14 text-right pr-3 text-xs text-ink-muted font-mono shrink-0 -translate-y-2">
|
||||||
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
|
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t border-gray-800/70" />
|
<div className="flex-1 border-t border-line/70" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -444,7 +445,7 @@ function DayTimeline({
|
|||||||
{e.end_time && (
|
{e.end_time && (
|
||||||
<div
|
<div
|
||||||
style={{ top, height, left: 60, right: 0 }}
|
style={{ top, height, left: 60, right: 0 }}
|
||||||
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
|
className="absolute bg-raised/30 border-l-2 border-line-strong/30 z-0 pointer-events-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Dashed marker line + label */}
|
{/* Dashed marker line + label */}
|
||||||
@@ -452,15 +453,15 @@ function DayTimeline({
|
|||||||
style={{ top, left: 60, right: 0 }}
|
style={{ top, left: 60, right: 0 }}
|
||||||
className="absolute z-10 flex items-center gap-2"
|
className="absolute z-10 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex-1 border-t border-dashed border-gray-600/40" />
|
<div className="flex-1 border-t border-dashed border-line-strong/40" />
|
||||||
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
|
<span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
|
||||||
{fmtTime(e.start_time)}
|
{fmtTime(e.start_time)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
|
<span className="text-ink-muted text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
|
<button onClick={() => onEdit(e)} className="text-ink-muted hover:text-indigo-400 text-[10px]">Edit</button>
|
||||||
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
|
<button onClick={() => onDelete(e.event_id)} className="text-ink-muted hover:text-red-400 text-xs leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -483,25 +484,27 @@ function DayTimeline({
|
|||||||
style={{ top, height, left: 60, right: 0 }}
|
style={{ top, height, left: 60, right: 0 }}
|
||||||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
||||||
isConflict
|
isConflict
|
||||||
? "bg-red-950/70 border border-red-700/70"
|
// Theme tokens: the old bg-indigo-950/60 had no light-mode
|
||||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
// override, so light mode got dark text on a dark block.
|
||||||
|
? "bg-sev-major/15 border border-sev-major/50"
|
||||||
|
: "bg-accent/15 border border-accent/40 hover:border-accent/70"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2 h-full">
|
<div className="flex items-start justify-between gap-2 h-full">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
||||||
{height >= 44 && (
|
{height >= 44 && (
|
||||||
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}>
|
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-sev-major" : "text-accent"}`}>
|
||||||
{fmtTime(e.start_time)}
|
{fmtTime(e.start_time)}
|
||||||
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
||||||
{isConflict ? " ⚠ conflict" : ""}
|
{isConflict ? " ⚠ conflict" : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{height >= 60 && !e.location_inherited && e.location && (
|
{height >= 60 && !e.location_inherited && e.location && (
|
||||||
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p>
|
<p className="text-ink-muted text-xs mt-0.5 truncate">{e.location}</p>
|
||||||
)}
|
)}
|
||||||
{height >= 60 && e.notes && (
|
{height >= 60 && e.notes && (
|
||||||
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
|
<p className="text-ink-muted text-xs mt-0.5 italic truncate">{e.notes}</p>
|
||||||
)}
|
)}
|
||||||
{e.tags?.length > 0 && (
|
{e.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
@@ -525,13 +528,13 @@ function DayTimeline({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(e)}
|
onClick={() => onEdit(e)}
|
||||||
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
className="text-ink-muted hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(e.event_id)}
|
onClick={() => onDelete(e.event_id)}
|
||||||
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
className="text-ink-muted hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -543,9 +546,9 @@ function DayTimeline({
|
|||||||
{drive && (
|
{drive && (
|
||||||
<div
|
<div
|
||||||
style={{ top: top + height + 2, left: 60 }}
|
style={{ top: top + height + 2, left: 60 }}
|
||||||
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
|
className="absolute text-xs text-ink-muted font-mono flex items-center gap-1 z-20"
|
||||||
>
|
>
|
||||||
<span className="text-gray-700">↓</span> {drive} drive
|
<span className="text-ink-muted">↓</span> {drive} drive
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -557,18 +560,18 @@ function DayTimeline({
|
|||||||
{/* Untimed / all-day events */}
|
{/* Untimed / all-day events */}
|
||||||
{untimed.length > 0 && (
|
{untimed.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p>
|
<p className="text-xs font-mono text-ink-muted uppercase tracking-wider">Unscheduled</p>
|
||||||
{untimed.map((e) => (
|
{untimed.map((e) => (
|
||||||
<div
|
<div
|
||||||
key={e.event_id}
|
key={e.event_id}
|
||||||
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group"
|
className="bg-surface border border-line rounded-lg px-4 py-2.5 flex items-center justify-between group"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-white text-sm">{e.title}</p>
|
<p className="text-white text-sm">{e.title}</p>
|
||||||
{!e.location_inherited && e.location && (
|
{!e.location_inherited && e.location && (
|
||||||
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
|
<p className="text-ink-muted text-xs mt-0.5">{e.location}</p>
|
||||||
)}
|
)}
|
||||||
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
|
{e.notes && <p className="text-ink-muted text-xs italic mt-0.5">{e.notes}</p>}
|
||||||
{e.tags?.length > 0 && (
|
{e.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||||||
@@ -585,11 +588,11 @@ function DayTimeline({
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => onEdit(e)}
|
<button onClick={() => onEdit(e)}
|
||||||
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
className="text-xs text-ink-muted hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onDelete(e.event_id)}
|
<button onClick={() => onDelete(e.event_id)}
|
||||||
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
className="text-ink-muted hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -728,17 +731,17 @@ function AssistantPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2">
|
<div className="px-4 py-3 border-b border-line shrink-0 flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white text-sm font-semibold">Trip Assistant</p>
|
<p className="text-white text-sm font-semibold">Trip Assistant</p>
|
||||||
<p className="text-gray-500 text-xs mt-0.5">
|
<p className="text-ink-muted text-xs mt-0.5">
|
||||||
Tell me what you want to do — I can search places and suggest events.
|
Tell me what you want to do — I can search places and suggest events.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearChat}
|
onClick={clearChat}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5"
|
className="text-xs text-ink-muted hover:text-ink-2 transition-colors shrink-0 mt-0.5"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -758,7 +761,7 @@ function AssistantPanel({
|
|||||||
<button
|
<button
|
||||||
key={prompt}
|
key={prompt}
|
||||||
onClick={() => { setInput(prompt); }}
|
onClick={() => { setInput(prompt); }}
|
||||||
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors"
|
className="w-full text-left bg-raised/60 hover:bg-raised border border-line-strong/50 rounded-lg px-3 py-2 text-ink-2 text-xs transition-colors"
|
||||||
>
|
>
|
||||||
{prompt}
|
{prompt}
|
||||||
</button>
|
</button>
|
||||||
@@ -772,7 +775,7 @@ function AssistantPanel({
|
|||||||
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
|
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
|
||||||
msg.role === "user"
|
msg.role === "user"
|
||||||
? "bg-indigo-600 text-white"
|
? "bg-indigo-600 text-white"
|
||||||
: "bg-gray-800 text-gray-200"
|
: "bg-raised text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.role === "user" ? msg.content : (
|
{msg.role === "user" ? msg.content : (
|
||||||
@@ -804,11 +807,11 @@ function AssistantPanel({
|
|||||||
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
|
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
|
||||||
s.added
|
s.added
|
||||||
? "border-green-800/50 bg-green-950/30"
|
? "border-green-800/50 bg-green-950/30"
|
||||||
: "border-gray-700 bg-gray-900"
|
: "border-line-strong bg-surface"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-white text-sm font-semibold">{s.title}</p>
|
<p className="text-white text-sm font-semibold">{s.title}</p>
|
||||||
<div className="text-xs text-gray-400 space-y-0.5">
|
<div className="text-xs text-ink-2 space-y-0.5">
|
||||||
{s.date && (
|
{s.date && (
|
||||||
<p>
|
<p>
|
||||||
{fmtDate(s.date)}
|
{fmtDate(s.date)}
|
||||||
@@ -817,7 +820,7 @@ function AssistantPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{s.location && <p className="truncate">{s.location}</p>}
|
{s.location && <p className="truncate">{s.location}</p>}
|
||||||
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
|
{s.notes && <p className="text-ink-muted italic">{s.notes}</p>}
|
||||||
{s.tags && s.tags.length > 0 && (
|
{s.tags && s.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
||||||
@@ -840,7 +843,7 @@ function AssistantPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => dismissSuggestion(msg.id, idx)}
|
onClick={() => dismissSuggestion(msg.id, idx)}
|
||||||
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors"
|
className="px-3 text-ink-muted hover:text-ink text-xs transition-colors"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
@@ -856,7 +859,7 @@ function AssistantPanel({
|
|||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm">
|
<div className="bg-raised rounded-xl px-3 py-2 text-ink-2 text-sm">
|
||||||
<span className="animate-pulse">Thinking…</span>
|
<span className="animate-pulse">Thinking…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,7 +868,7 @@ function AssistantPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="px-3 py-3 border-t border-gray-800 shrink-0">
|
<div className="px-3 py-3 border-t border-line shrink-0">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -875,7 +878,7 @@ function AssistantPanel({
|
|||||||
placeholder="What do you want to do?"
|
placeholder="What do you want to do?"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
|
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
@@ -955,7 +958,7 @@ export default function TripDetailPage() {
|
|||||||
async function handleDeleteTrip() {
|
async function handleDeleteTrip() {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
|
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteEvent(eventId: string) {
|
async function handleDeleteEvent(eventId: string) {
|
||||||
@@ -963,7 +966,7 @@ export default function TripDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
||||||
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
|
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { alert(`Couldn't delete the event: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleUpdateEvent(body: object) {
|
async function handleUpdateEvent(body: object) {
|
||||||
@@ -1055,8 +1058,8 @@ export default function TripDetailPage() {
|
|||||||
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
if (loading) return <p className="text-ink-muted text-sm font-mono">Loading…</p>;
|
||||||
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>;
|
if (!trip) return <p className="text-ink-muted text-sm font-mono">Trip not found.</p>;
|
||||||
|
|
||||||
const days = dateRange(trip.start_date, trip.end_date);
|
const days = dateRange(trip.start_date, trip.end_date);
|
||||||
const attendees = Object.values(trip.attendees ?? {});
|
const attendees = Object.values(trip.attendees ?? {});
|
||||||
@@ -1070,7 +1073,7 @@ export default function TripDetailPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/trips")}
|
onClick={() => router.push("/trips")}
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-sm font-mono text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
← Trips
|
← Trips
|
||||||
</button>
|
</button>
|
||||||
@@ -1078,7 +1081,7 @@ export default function TripDetailPage() {
|
|||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
|
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
|
||||||
<span>{trip.location}</span>
|
<span>{trip.location}</span>
|
||||||
{trip.maps_link && (
|
{trip.maps_link && (
|
||||||
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
|
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
|
||||||
@@ -1086,11 +1089,11 @@ export default function TripDetailPage() {
|
|||||||
Maps
|
Maps
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-700">•</span>
|
<span className="text-ink-muted">•</span>
|
||||||
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
|
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
|
||||||
{attendees.length > 0 && (
|
{attendees.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-700">•</span>
|
<span className="text-ink-muted">•</span>
|
||||||
<span className="text-xs">{attendees.join(", ")}</span>
|
<span className="text-xs">{attendees.join(", ")}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1114,7 +1117,7 @@ export default function TripDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleVisibility}
|
onClick={handleToggleVisibility}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
|
className="text-xs text-ink-muted hover:text-ink transition-colors border border-line-strong rounded-lg px-3 py-2"
|
||||||
>
|
>
|
||||||
{trip.visibility === "private" ? "Make public" : "Make private"}
|
{trip.visibility === "private" ? "Make public" : "Make private"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1159,9 +1162,9 @@ export default function TripDetailPage() {
|
|||||||
onChange={(e) => setTagInput(e.target.value)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
||||||
placeholder="Add tag…"
|
placeholder="Add tag…"
|
||||||
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
|
className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-24"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
<button onClick={handleAddTag} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1170,13 +1173,13 @@ export default function TripDetailPage() {
|
|||||||
{/* Invite management — admin only, only when private */}
|
{/* Invite management — admin only, only when private */}
|
||||||
{isAdmin && trip.visibility === "private" && (
|
{isAdmin && trip.visibility === "private" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono">Invited</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(trip.invited_discord_ids ?? []).length === 0 && (
|
{(trip.invited_discord_ids ?? []).length === 0 && (
|
||||||
<span className="text-xs text-gray-600">No invites yet</span>
|
<span className="text-xs text-ink-muted">No invites yet</span>
|
||||||
)}
|
)}
|
||||||
{(trip.invited_discord_ids ?? []).map((discordId) => (
|
{(trip.invited_discord_ids ?? []).map((discordId) => (
|
||||||
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
|
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-raised border border-line-strong rounded-full px-2.5 py-0.5 text-ink-2">
|
||||||
<span className="font-mono">{discordId}</span>
|
<span className="font-mono">{discordId}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRevokeInvite(discordId)}
|
onClick={() => handleRevokeInvite(discordId)}
|
||||||
@@ -1192,9 +1195,9 @@ export default function TripDetailPage() {
|
|||||||
onChange={(e) => setInviteInput(e.target.value)}
|
onChange={(e) => setInviteInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
||||||
placeholder="Discord user ID…"
|
placeholder="Discord user ID…"
|
||||||
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36"
|
className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-36"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
<button onClick={handleInvite} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1218,13 +1221,13 @@ export default function TripDetailPage() {
|
|||||||
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
|
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
|
||||||
day === selectedDay
|
day === selectedDay
|
||||||
? "bg-indigo-600 text-white"
|
? "bg-indigo-600 text-white"
|
||||||
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200"
|
: "bg-surface border border-line text-ink-2 hover:border-line-strong hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-mono font-bold">{short}</span>
|
<span className="font-mono font-bold">{short}</span>
|
||||||
<span className="font-mono">{date}</span>
|
<span className="font-mono">{date}</span>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}>
|
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-ink-muted"}`}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1238,7 +1241,7 @@ export default function TripDetailPage() {
|
|||||||
|
|
||||||
{/* Selected day */}
|
{/* Selected day */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider">
|
<p className="text-sm font-mono text-ink-2 uppercase tracking-wider">
|
||||||
{fmtDayHeading(selectedDay)}
|
{fmtDayHeading(selectedDay)}
|
||||||
</p>
|
</p>
|
||||||
<DayTimeline
|
<DayTimeline
|
||||||
@@ -1254,7 +1257,7 @@ export default function TripDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI Assistant — sticky */}
|
{/* Right: AI Assistant — sticky */}
|
||||||
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
|
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useTrips } from "@/lib/useTrips";
|
import { useTrips } from "@/lib/useTrips";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { TripRecord } from "@/lib/types";
|
import type { TripRecord } from "@/lib/types";
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
function fmtDate(iso: string) {
|
||||||
@@ -29,25 +30,25 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
|
className="bg-surface border border-line rounded-xl p-5 cursor-pointer hover:border-line-strong transition-colors"
|
||||||
onClick={() => router.push(`/trips/${trip.trip_id}`)}
|
onClick={() => router.push(`/trips/${trip.trip_id}`)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||||
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
|
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-raised text-ink-muted"
|
||||||
}`}>
|
}`}>
|
||||||
{upcoming ? "Upcoming" : "Past"}
|
{upcoming ? "Upcoming" : "Past"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
|
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
|
||||||
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
|
<p className="text-ink-2 text-xs mt-1">{trip.location}</p>
|
||||||
<p className="text-gray-500 text-xs font-mono mt-1">
|
<p className="text-ink-muted text-xs font-mono mt-1">
|
||||||
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
||||||
</p>
|
</p>
|
||||||
{attendeeCount > 0 && (
|
{attendeeCount > 0 && (
|
||||||
<p className="text-gray-500 text-xs mt-1">
|
<p className="text-ink-muted text-xs mt-1">
|
||||||
{attendeeCount} going
|
{attendeeCount} going
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -102,59 +103,59 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold">New Trip</h2>
|
<h2 className="text-white font-bold">New Trip</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
required value={name} onChange={(e) => setName(e.target.value)}
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Road trip to Nashville"
|
placeholder="Road trip to Nashville"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Location</label>
|
<label className="text-xs text-ink-2 block mb-1">Location</label>
|
||||||
<input
|
<input
|
||||||
required value={location} onChange={(e) => setLocation(e.target.value)}
|
required value={location} onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="Nashville, TN"
|
placeholder="Nashville, TN"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Start date</label>
|
<label className="text-xs text-ink-2 block mb-1">Start date</label>
|
||||||
<input
|
<input
|
||||||
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
|
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">End date</label>
|
<label className="text-xs text-ink-2 block mb-1">End date</label>
|
||||||
<input
|
<input
|
||||||
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
||||||
min={start}
|
min={start}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Google Maps link (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
|
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
|
||||||
placeholder="https://maps.google.com/…"
|
placeholder="https://maps.google.com/…"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end">
|
<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">
|
<button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -179,8 +180,9 @@ export default function TripsPage() {
|
|||||||
const past = trips.filter((t) => t.end_date < today);
|
const past = trips.filter((t) => t.end_date < today);
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Delete this trip?")) return;
|
||||||
try { await c2api.deleteTrip(id); }
|
try { await c2api.deleteTrip(id); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -198,12 +200,12 @@ export default function TripsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{upcoming.length > 0 && (
|
{upcoming.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2>
|
<h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Upcoming</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcoming.map((t) => (
|
{upcoming.map((t) => (
|
||||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||||
@@ -214,7 +216,7 @@ export default function TripsPage() {
|
|||||||
|
|
||||||
{past.length > 0 && (
|
{past.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2>
|
<h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Past</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{past.map((t) => (
|
{past.map((t) => (
|
||||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||||
@@ -224,7 +226,7 @@ export default function TripsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{trips.length === 0 && (
|
{trips.length === 0 && (
|
||||||
<p className="text-gray-600 text-sm font-mono">No trips yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No trips yet.</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ export default function WaitlistPage() {
|
|||||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
DRB
|
DRB
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-white text-lg font-bold">You're on the list</h1>
|
<h1 className="text-white text-lg font-bold">You're on the list</h1>
|
||||||
<p className="text-gray-400 text-sm leading-relaxed">
|
<p className="text-ink-2 text-sm leading-relaxed">
|
||||||
Thanks — we'll reach out at the email you gave us. In the meantime, feel free to{" "}
|
Thanks — we'll reach out at the email you gave us. In the meantime, feel free to{" "}
|
||||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
|
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
|
||||||
</p>
|
</p>
|
||||||
@@ -53,39 +53,39 @@ export default function WaitlistPage() {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-lg font-bold">Request access</h1>
|
<h1 className="text-white text-lg font-bold">Request access</h1>
|
||||||
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
|
<p className="text-ink-2 text-xs mt-2 leading-relaxed">
|
||||||
Self-serve signup isn't open yet. Leave your details and we'll follow up to get your organization set up.
|
Self-serve signup isn't open yet. Leave your details and we'll follow up to get your organization set up.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Organization (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={orgName}
|
value={orgName}
|
||||||
onChange={(e) => setOrgName(e.target.value)}
|
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Anything else? (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
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 resize-none"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|||||||
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
|
className={`border-b border-line font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-raised/50" : "hover:bg-raised/30"}`}
|
||||||
onClick={() => hasDetails && setExpanded((v) => !v)}
|
onClick={() => hasDetails && setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
<td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
|
||||||
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
<span className="text-ink-muted">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
||||||
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-300">
|
<td className="px-4 py-2 text-ink-2">
|
||||||
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||||
{call.tags && call.tags.length > 0 && (
|
{call.tags && call.tags.length > 0 && (
|
||||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}>
|
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-raised text-ink-2"}`}>
|
||||||
{call.tags[0]}
|
{call.tags[0]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
<span className="ml-2">{severityBadge(call.severity)}</span>
|
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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-ink-2 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>
|
<td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{call.node_id}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="text-orange-400 animate-pulse">● live</span>
|
<span className="text-orange-400 animate-pulse">● live</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
<span className="text-ink-muted">{duration(call.started_at, call.ended_at)}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs">
|
<td className="px-4 py-2 text-xs">
|
||||||
{hasAudio ? (
|
{hasAudio ? (
|
||||||
<span className="text-blue-400">▶</span>
|
<span className="text-blue-400">▶</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">—</span>
|
<span className="text-ink-muted">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-600 text-xs">
|
<td className="px-4 py-2 text-ink-muted text-xs">
|
||||||
{hasDetails && (expanded ? "▲" : "▼")}
|
{hasDetails && (expanded ? "▲" : "▼")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{expanded && hasDetails && (
|
{expanded && hasDetails && (
|
||||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
<tr className="bg-surface/60 border-b border-line">
|
||||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||||
{/* Audio player */}
|
{/* Audio player */}
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
<p className="text-xs text-ink-muted font-mono">Loading audio…</p>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{call.tags.map((tag) => (
|
{call.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
|
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-raised text-ink-2"}`}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{/* Incident links — one per scene detected in the recording */}
|
{/* Incident links — one per scene detected in the recording */}
|
||||||
{incidentIds.length > 0 && (
|
{incidentIds.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
||||||
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
<span className="text-ink-muted">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||||
{incidentIds.map((id) => (
|
{incidentIds.map((id) => (
|
||||||
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
||||||
{id.slice(0, 8)}…
|
{id.slice(0, 8)}…
|
||||||
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
|
|
||||||
{/* Correlation debug — admin only */}
|
{/* Correlation debug — admin only */}
|
||||||
{isAdmin && call.corr_path && (
|
{isAdmin && call.corr_path && (
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-ink-muted">
|
||||||
<span>corr:</span>
|
<span>corr:</span>
|
||||||
<span className="text-gray-400">{call.corr_path}</span>
|
<span className="text-ink-2">{call.corr_path}</span>
|
||||||
{call.corr_incident_idle_min != null && (
|
{call.corr_incident_idle_min != null && (
|
||||||
<span>idle {call.corr_incident_idle_min}min</span>
|
<span>idle {call.corr_incident_idle_min}min</span>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-xs text-ink font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
|
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink px-3 py-1 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
) : hasSegments ? (
|
) : hasSegments ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-600 font-mono">
|
<span className="text-xs text-ink-muted font-mono">
|
||||||
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
|
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
|
||||||
</span>
|
</span>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasBoth && !showOriginal ? (
|
{hasBoth && !showOriginal ? (
|
||||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
<pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||||
{call.transcript_corrected}
|
{call.transcript_corrected}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
<div className="bg-raised rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
||||||
{call.segments!.map((seg, i) => (
|
{call.segments!.map((seg, i) => (
|
||||||
<div key={i} className="flex gap-3 text-xs font-mono">
|
<div key={i} className="flex gap-3 text-xs font-mono">
|
||||||
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span>
|
<span className="text-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
|
||||||
<span className="text-gray-300">{seg.text}</span>
|
<span className="text-ink-2">{seg.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{hasBoth && (
|
{hasBoth && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
|
||||||
>
|
>
|
||||||
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
||||||
</button>
|
</button>
|
||||||
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
{hasBoth && <span className="text-xs text-ink-muted font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
||||||
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>}
|
{!hasBoth && call.transcript_corrected && <span className="text-xs text-ink-muted font-mono">corrected</span>}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
<pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
||||||
{displayTranscript}
|
{displayTranscript}
|
||||||
</pre>
|
</pre>
|
||||||
{hasBoth && (
|
{hasBoth && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
|
||||||
>
|
>
|
||||||
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
|
<p className="text-xs text-ink-muted font-mono italic">No transcript available.</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -8,105 +8,15 @@
|
|||||||
* memorable thing per §2.4: "where was he when he said that" is answered
|
* memorable thing per §2.4: "where was he when he said that" is answered
|
||||||
* by looking, not cross-referencing timestamps.
|
* by looking, not cross-referencing timestamps.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import type { CallRecord } from "@/lib/types";
|
import type { CallRecord } from "@/lib/types";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||||
|
import { hasAudio as callHasAudio } from "@/lib/audioQueue";
|
||||||
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
import { isKnownSeverity, SEVERITY_COLORS } from "@/lib/severity";
|
||||||
|
|
||||||
function fmtTime(iso: string): string {
|
function fmtTime(iso: string): string {
|
||||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
}
|
}
|
||||||
function fmtClock(s: number): string {
|
|
||||||
if (!Number.isFinite(s)) return "0:00";
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
const r = Math.floor(s % 60);
|
|
||||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InlinePlayer({ callId }: { callId: string }) {
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
|
|
||||||
async function ensureUrl() {
|
|
||||||
if (url || loading) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const full = await c2api.getCall(callId);
|
|
||||||
setUrl(full.audio_url ?? null);
|
|
||||||
if (!full.audio_url) setError(true);
|
|
||||||
} catch {
|
|
||||||
setError(true);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggle() {
|
|
||||||
if (!url) {
|
|
||||||
await ensureUrl();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const el = audioRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
if (playing) el.pause();
|
|
||||||
else el.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once the URL lands, autoplay (the click that fetched it was the play intent).
|
|
||||||
useEffect(() => {
|
|
||||||
if (url && audioRef.current) audioRef.current.play().catch(() => {});
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <span className="text-xs text-sev-major">Audio unavailable</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<button
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={loading}
|
|
||||||
className="w-6 h-6 shrink-0 rounded-full bg-accent text-white flex items-center justify-center text-[10px] disabled:opacity-50"
|
|
||||||
title={playing ? "Pause" : "Play"}
|
|
||||||
>
|
|
||||||
{loading ? "…" : playing ? "❚❚" : "▶"}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={duration || 0}
|
|
||||||
value={current}
|
|
||||||
onChange={(e) => {
|
|
||||||
const t = Number(e.target.value);
|
|
||||||
if (audioRef.current) audioRef.current.currentTime = t;
|
|
||||||
setCurrent(t);
|
|
||||||
}}
|
|
||||||
className="flex-1 h-1 accent-accent"
|
|
||||||
disabled={!url}
|
|
||||||
/>
|
|
||||||
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
|
||||||
{fmtClock(current)}
|
|
||||||
</span>
|
|
||||||
{url && (
|
|
||||||
<audio
|
|
||||||
ref={audioRef}
|
|
||||||
src={url}
|
|
||||||
onPlay={() => setPlaying(true)}
|
|
||||||
onPause={() => setPlaying(false)}
|
|
||||||
onEnded={() => setPlaying(false)}
|
|
||||||
onTimeUpdate={(e) => setCurrent(e.currentTarget.currentTime)}
|
|
||||||
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
function StopMarker({ stopNumber, color }: { stopNumber?: number; color: string }) {
|
||||||
if (!stopNumber) {
|
if (!stopNumber) {
|
||||||
@@ -132,7 +42,7 @@ export function CallSpineEntry({
|
|||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const hasAudio = !!(call.audio_gcs_uri || call.audio_url);
|
const hasAudio = callHasAudio(call);
|
||||||
const transcript = call.transcript_corrected || call.transcript;
|
const transcript = call.transcript_corrected || call.transcript;
|
||||||
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
const color = isKnownSeverity(call.severity) ? SEVERITY_COLORS[call.severity] : "var(--ink-muted)";
|
||||||
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
const substantive = !!transcript || (call.units && call.units.length > 0) || !!call.location_coords;
|
||||||
|
|||||||
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
|
|||||||
{camera.image_url ? "…" : "No still for this camera"}
|
{camera.image_url ? "…" : "No still for this camera"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[10px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
||||||
{badge.text}
|
{badge.text}
|
||||||
</span>
|
</span>
|
||||||
{credit?.attribution && (
|
{credit?.attribution && (
|
||||||
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
|
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[10px] leading-[13px] truncate">
|
||||||
{credit.attribution_url ? (
|
{credit.attribution_url ? (
|
||||||
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
||||||
) : credit.attribution}
|
) : credit.attribution}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { Nav } from "@/components/Nav";
|
import { Nav } from "@/components/Nav";
|
||||||
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
|
import { MarketingHeader } from "@/components/marketing/MarketingHeader";
|
||||||
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
|
import { MarketingFooter } from "@/components/marketing/MarketingFooter";
|
||||||
|
import { useKioskFlag } from "@/lib/useKiosk";
|
||||||
|
|
||||||
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
|
// Public marketing surface — exact paths, not prefixes, so e.g. /features/x
|
||||||
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
|
// (if it ever exists) doesn't accidentally get pulled into marketing chrome.
|
||||||
@@ -36,6 +37,7 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user, loading, orgId } = useAuth();
|
const { user, loading, orgId } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const kiosk = useKioskFlag();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
@@ -62,7 +64,11 @@ export function ChromeSwitcher({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Live ("/") is full-bleed under its own top bar, not the padded
|
// Live ("/") is full-bleed under its own top bar, not the padded
|
||||||
// max-width container the rest of the app uses.
|
// max-width container the rest of the app uses. In kiosk mode (?kiosk=1 or
|
||||||
|
// #kiosk, server-26#234) the wall gets the whole screen: no nav at all.
|
||||||
|
if (pathname === "/" && user && kiosk) {
|
||||||
|
return <main className="h-screen">{children}</main>;
|
||||||
|
}
|
||||||
if (pathname === "/" && user) {
|
if (pathname === "/" && user) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -28,17 +28,12 @@ const SELECT =
|
|||||||
|
|
||||||
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of
|
// Option values for the bot picker: "" = let C2 choose, "tok:<id>" = one of
|
||||||
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
|
// the org's own bots, "sys:<id>" = whichever bot is preferred for that system.
|
||||||
function startRequest(choice: string, data: OrgPreferredBots | null): OAuthStartRequest {
|
function startRequest(choice: string): OAuthStartRequest {
|
||||||
const req: OAuthStartRequest = { return_to: "/settings/organization" };
|
const req: OAuthStartRequest = { return_to: "/settings/organization" };
|
||||||
if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
|
if (choice.startsWith("tok:")) req.token_id = choice.slice(4);
|
||||||
else if (choice.startsWith("sys:")) {
|
// c2-core resolves the system's preferred bot itself, including a pool bot
|
||||||
const id = choice.slice(4);
|
// a platform admin pinned (whose token_id the org never sees).
|
||||||
// The org's own bot preferred for this system: name it by token_id, which
|
else if (choice.startsWith("sys:")) req.system_id = choice.slice(4);
|
||||||
// S6 already accepts. Only a platform-pinned pool bot needs system_id.
|
|
||||||
const pref = data?.systems.find((s) => s.system_id === id)?.preferred_token_id;
|
|
||||||
if (pref) req.token_id = pref;
|
|
||||||
else req.system_id = id;
|
|
||||||
}
|
|
||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +127,7 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
setActionError(null);
|
setActionError(null);
|
||||||
setJustLinked(null);
|
setJustLinked(null);
|
||||||
try {
|
try {
|
||||||
const { url } = await startDiscordOAuth(startRequest(choice, choices));
|
const { url } = await startDiscordOAuth(startRequest(choice));
|
||||||
window.location.assign(url);
|
window.location.assign(url);
|
||||||
// Leave the button in its busy state: the page is navigating to Discord.
|
// Leave the button in its busy state: the page is navigating to Discord.
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -334,12 +329,13 @@ export function DiscordServersCard({ canEdit, isAdmin }: { canEdit: boolean; isA
|
|||||||
|
|
||||||
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
|
<div className="px-5 py-3 border-t border-line bg-raised text-xs text-ink-muted">
|
||||||
{restricted ? (
|
{restricted ? (
|
||||||
<>Unlinked servers see only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
|
<>Where the DRB server bot is present, an unlinked server sees only <b className="text-ink">/drb link</b> and <b className="text-ink">/help</b>. </>
|
||||||
) : isAdmin ? (
|
) : isAdmin ? (
|
||||||
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
||||||
) : null}
|
) : null}
|
||||||
Every command and autocomplete is scoped to the linked organization — no server sees another
|
Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no
|
||||||
organization's nodes, systems or bots.
|
server sees another organization's nodes, systems or bots. The DRB server bot is optional: a radio bot
|
||||||
|
alone is enough to stream.
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-to-listen control for one call — server-26#233. Moved out of
|
||||||
|
* CallSpineEntry.tsx so the live wall's spine, the map's path-stop popups and
|
||||||
|
* rail cards share it. It no longer owns an <audio> element: playback goes
|
||||||
|
* through lib/audioQueue.ts's single element, so starting a call anywhere
|
||||||
|
* stops whatever else was playing, and the map can ring the stop that's
|
||||||
|
* speaking.
|
||||||
|
*
|
||||||
|
* Every hook runs before any early return (the server-26#109 item 2 bug was a
|
||||||
|
* return between hooks). Nothing plays until the button is clicked.
|
||||||
|
*/
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { canPlayAudio, seek, toggle, useAudio } from "@/lib/audioQueue";
|
||||||
|
|
||||||
|
function fmtClock(s: number): string {
|
||||||
|
if (!Number.isFinite(s)) return "0:00";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const r = Math.floor(s % 60);
|
||||||
|
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the round play/pause button — for map popups, rail cards, spine rows. */
|
||||||
|
export function PlayButton({ callId, label, className = "" }: { callId: string; label?: string; className?: string }) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const active = useAudio((s) => s.playingCallId === callId);
|
||||||
|
const status = useAudio((s) => (s.playingCallId === callId ? s.status : "idle"));
|
||||||
|
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
|
||||||
|
if (!canPlayAudio(auth)) return null;
|
||||||
|
const playing = active && status === "playing";
|
||||||
|
const loading = active && status === "loading";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); toggle(callId); }}
|
||||||
|
disabled={loading}
|
||||||
|
title={failed ? "Audio unavailable — click to retry" : playing ? "Pause" : label ?? "Play this call"}
|
||||||
|
aria-label={playing ? "Pause call audio" : label ?? "Play call audio"}
|
||||||
|
aria-pressed={playing}
|
||||||
|
className={`w-6 h-6 shrink-0 rounded-full flex items-center justify-center text-[10px] disabled:opacity-50 ${failed ? "bg-raised text-sev-major border border-sev-major" : "bg-accent text-white"} ${className}`}
|
||||||
|
>
|
||||||
|
{loading ? "…" : playing ? "❚❚" : failed ? "!" : "▶"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button + seek bar + clock. `compact` shows only the button until this call
|
||||||
|
* is the one playing, then expands — for dense lists like the wall's spine.
|
||||||
|
*/
|
||||||
|
export function InlinePlayer({ callId, compact = false }: { callId: string; compact?: boolean }) {
|
||||||
|
const auth = useAuth();
|
||||||
|
const active = useAudio((s) => s.playingCallId === callId);
|
||||||
|
const current = useAudio((s) => (s.playingCallId === callId ? s.current : 0));
|
||||||
|
const duration = useAudio((s) => (s.playingCallId === callId ? s.duration : 0));
|
||||||
|
const failed = useAudio((s) => s.errorCallId === callId && s.playingCallId !== callId);
|
||||||
|
|
||||||
|
if (!canPlayAudio(auth)) return null;
|
||||||
|
if (failed && !compact) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<PlayButton callId={callId} />
|
||||||
|
<span className="text-xs text-sev-major">Audio unavailable</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (compact && !active) return <PlayButton callId={callId} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 w-full max-w-xs" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<PlayButton callId={callId} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step="any"
|
||||||
|
value={Math.min(current, duration || 0)}
|
||||||
|
onChange={(e) => seek(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-accent"
|
||||||
|
disabled={!active || !duration}
|
||||||
|
aria-label="Seek"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-mono text-ink-muted tabular-nums shrink-0">
|
||||||
|
{fmtClock(current)}{duration ? ` / ${fmtClock(duration)}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,34 +7,95 @@
|
|||||||
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
|
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
|
||||||
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
|
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
|
||||||
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
|
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
|
||||||
|
*
|
||||||
|
* Saved views (server-26#234): overlays, basemap and viewport live in the
|
||||||
|
* URL hash too (lib/liveWall.ts wallHash), so a reload or a copied link comes
|
||||||
|
* back the same; each browser also remembers its last view (lib/wallPrefs.ts).
|
||||||
|
* Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the
|
||||||
|
* wall-display mode (lib/useKiosk.ts).
|
||||||
|
*
|
||||||
|
* Filter bar (server-26#235): the `f.*` hash keys (lib/liveFilter.ts) are
|
||||||
|
* applied ONCE here and the filtered lists feed the map (pins, paths, rail),
|
||||||
|
* the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline
|
||||||
|
* health stays on the unfiltered data — a filter must never hide an outage.
|
||||||
|
*
|
||||||
|
* Call window (server-26#239): the 500-call window feeds the ticker, scrubber
|
||||||
|
* and region; the SELECTED incident also loads its own calls by incident id
|
||||||
|
* and merges them in, so a long incident's path and spine are complete. An
|
||||||
|
* `#inc=` that is not active is fetched by id (resolved → banner; missing or
|
||||||
|
* another org → "not found"). The scrubber hatches time the window didn't load.
|
||||||
|
*
|
||||||
|
* Attention cues (server-26#238): lib/attention.ts diffs successive snapshots
|
||||||
|
* (Major opened, severity up, pursuit path stop, priority keyword) after a
|
||||||
|
* silent baseline. Cues, the NEW lane and pin pulses only ever cover the
|
||||||
|
* FILTERED incidents; tone and notifications are opt-in (AttentionControls).
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useNodes } from "@/lib/useNodes";
|
import { useNodes } from "@/lib/useNodes";
|
||||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
import { useSystems } from "@/lib/useSystems";
|
||||||
import { useActiveIncidents } from "@/lib/useIncidents";
|
import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls";
|
||||||
|
import { useActiveIncidentsState, useIncident } from "@/lib/useIncidents";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useAircraft } from "@/lib/useAircraft";
|
import { useAircraft } from "@/lib/useAircraft";
|
||||||
import { useVessels } from "@/lib/useVessels";
|
import { useVessels } from "@/lib/useVessels";
|
||||||
import { useCamerasInBbox } from "@/lib/useCameras";
|
import { useCamerasInBbox } from "@/lib/useCameras";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
import { severityRank } from "@/lib/severity";
|
import { severityRank } from "@/lib/severity";
|
||||||
import type { Camera, Ny511Response } from "@/lib/types";
|
import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
|
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
||||||
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||||
} from "@/lib/liveWall";
|
} from "@/lib/liveWall";
|
||||||
|
import { PRESETS, applyFilter, parseFilter, serialiseFilter, type PresetKey, type WallFilter } from "@/lib/liveFilter";
|
||||||
|
import { FilterBar, type FilterOption } from "@/components/live/FilterBar";
|
||||||
|
import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs";
|
||||||
|
import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk";
|
||||||
|
import { KioskBanner, KioskControls } from "@/components/live/KioskControls";
|
||||||
|
import type { MapViewState } from "@/components/MapView";
|
||||||
import { TimeScrubber } from "@/components/TimeScrubber";
|
import { TimeScrubber } from "@/components/TimeScrubber";
|
||||||
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
|
||||||
import { CameraDrill } from "@/components/live/CameraDrill";
|
import { CameraDrill } from "@/components/live/CameraDrill";
|
||||||
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
|
||||||
|
import { TruthStrip } from "@/components/live/TruthStrip";
|
||||||
|
import { usePipelineHealth } from "@/lib/usePipelineHealth";
|
||||||
|
import { DEFAULT_ATTENTION_PREFS, loadAttentionPrefs, saveAttentionPrefs, useAttention, type AttentionPrefs } from "@/lib/attention";
|
||||||
|
import { NewLane } from "@/components/live/NewLane";
|
||||||
|
import { AttentionControls, useAttentionCues } from "@/components/live/AttentionControls";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const TOUR_MS = 20_000;
|
const TOUR_MS = 20_000;
|
||||||
|
/** The wall's recent-call window (server-26#239: a selected incident loads past it). */
|
||||||
|
const RECENT_CALLS = 500;
|
||||||
|
// 10-foot type for kiosk walls (server-26#234): scales every small text class
|
||||||
|
// under the wall root — including MapView's rail and WallPanels, which are not
|
||||||
|
// edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them.
|
||||||
|
const KIOSK_TYPE = [
|
||||||
|
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
|
||||||
|
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
|
||||||
|
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||||
|
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||||
|
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
|
||||||
|
"[&[data-kiosk]_.w-56]:w-80",
|
||||||
|
].join(" ");
|
||||||
const PIN_KEY = "drb.wall.pinnedCameras";
|
const PIN_KEY = "drb.wall.pinnedCameras";
|
||||||
|
const MAX_TAG_CHIPS = 8;
|
||||||
const MAX_PINNED = 6;
|
const MAX_PINNED = 6;
|
||||||
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
const EVENTS_POLL_MS = 2 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Incident-doc fields the frontend type doesn't declare yet (backend writes both). */
|
||||||
|
type IncidentDoc = IncidentRecord & { org_id?: string | null; resolved_at?: string | null };
|
||||||
|
|
||||||
|
/** "Resolved 14:32", with the date when it wasn't today. */
|
||||||
|
function resolvedLabel(inc: IncidentDoc): string {
|
||||||
|
const d = inc.resolved_at ? new Date(inc.resolved_at) : null;
|
||||||
|
if (!d || Number.isNaN(d.getTime())) return "Resolved";
|
||||||
|
const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||||
|
const today = d.toDateString() === new Date().toDateString();
|
||||||
|
return today ? `Resolved ${hm}` : `Resolved ${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${hm}`;
|
||||||
|
}
|
||||||
|
|
||||||
function timeAgo(date: Date): string {
|
function timeAgo(date: Date): string {
|
||||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
if (s < 60) return `${s}s ago`;
|
if (s < 60) return `${s}s ago`;
|
||||||
@@ -87,18 +148,19 @@ function Kpi({ value, label }: { value: string | number; label: string }) {
|
|||||||
export function LiveView() {
|
export function LiveView() {
|
||||||
const { nodes, loading: nodesLoading } = useNodes();
|
const { nodes, loading: nodesLoading } = useNodes();
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const activeIncidents = useActiveIncidents();
|
const { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState();
|
||||||
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||||
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
|
const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
|
||||||
|
const { orgId } = useAuth();
|
||||||
|
const { systems } = useSystems();
|
||||||
const { aircraft } = useAircraft();
|
const { aircraft } = useAircraft();
|
||||||
const { vessels } = useVessels();
|
const { vessels } = useVessels();
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
|
||||||
const [clock, setClock] = useState<string>("");
|
const [clock, setClock] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
// No heartbeat-driven "last updated" any more (server-26#232): a node
|
||||||
if (!nodesLoading) setLastUpdated(new Date());
|
// check-in kept '● Live' fresh while STT/correlation were dead (#196). The
|
||||||
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
|
// wall's liveness now comes from usePipelineHealth, keyed to pipeline output.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
|
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
|
||||||
tick();
|
tick();
|
||||||
@@ -107,18 +169,93 @@ export function LiveView() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
|
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
|
||||||
|
// `link` = where the wall is (incident/camera/tour/kiosk/filters);
|
||||||
|
// `mapState` = what the map looks like (layers/base/viewport). Both are
|
||||||
|
// written back to the hash by one effect, so the address bar is always a
|
||||||
|
// working "this exact view" link. null until the hash has been read, so
|
||||||
|
// MapView never mounts with the wrong initial view.
|
||||||
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
|
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
|
||||||
|
const [mapState, setMapState] = useState<MapViewState | null>(null);
|
||||||
|
const mapStateRef = useRef<MapViewState | null>(null);
|
||||||
|
mapStateRef.current = mapState;
|
||||||
|
/** Bumped when a hash edit changes layers/base/view: MapView remounts with them. */
|
||||||
|
const [mapGen, setMapGen] = useState(0);
|
||||||
|
const [kioskQuery, setKioskQuery] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const read = () => setLinkState(parseWallHash(window.location.hash));
|
const read = (first: boolean) => {
|
||||||
read();
|
const parsed = parseWallHash(window.location.hash);
|
||||||
window.addEventListener("hashchange", read);
|
const { layers, base, view, ...nav } = parsed;
|
||||||
return () => window.removeEventListener("hashchange", read);
|
setLinkState(nav);
|
||||||
|
const cur = mapStateRef.current;
|
||||||
|
const next = resolveWallView({ layers, base, view }, first || !cur ? loadWallPrefs() : cur);
|
||||||
|
if (first || !cur) setMapState(next);
|
||||||
|
else if (!sameView(cur, next)) { setMapState(next); setMapGen((g) => g + 1); }
|
||||||
|
};
|
||||||
|
setKioskQuery(kioskInQuery());
|
||||||
|
read(true);
|
||||||
|
const onHash = () => read(false);
|
||||||
|
window.addEventListener("hashchange", onHash);
|
||||||
|
return () => window.removeEventListener("hashchange", onHash);
|
||||||
}, []);
|
}, []);
|
||||||
const setLink = useCallback((next: WallLink) => {
|
useEffect(() => {
|
||||||
setLinkState(next);
|
if (!mapState) return;
|
||||||
const h = wallHash(next);
|
const h = wallHash({ ...link, ...mapState });
|
||||||
window.history.replaceState(null, "", h || window.location.pathname + window.location.search);
|
if (h !== window.location.hash) {
|
||||||
|
window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search + h);
|
||||||
|
}
|
||||||
|
announceKioskChange();
|
||||||
|
}, [link, mapState]);
|
||||||
|
const patchLink = useCallback((p: Partial<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
|
||||||
|
// ── Filter (server-26#235) ────────────────────────────────────────────────
|
||||||
|
const filterKey = JSON.stringify(link.filter ?? {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const filter = useMemo(() => parseFilter(link.filter), [filterKey]);
|
||||||
|
const setFilter = useCallback((f: WallFilter) => patchLink({ filter: serialiseFilter(f) }), [patchLink]);
|
||||||
|
/** A preset switches its map layers on through the saved-view layer state (no MapView edit). */
|
||||||
|
const onPreset = useCallback((p: PresetKey) => {
|
||||||
|
const cur = mapStateRef.current;
|
||||||
|
if (!cur) return;
|
||||||
|
const have = cur.layers ?? DEFAULT_LAYERS;
|
||||||
|
if (PRESETS[p].layers.every((l) => have.includes(l))) return;
|
||||||
|
const next = { ...cur, layers: normaliseLayers([...have, ...PRESETS[p].layers]) };
|
||||||
|
saveWallPrefs(next);
|
||||||
|
setMapState(next);
|
||||||
|
setMapGen((g) => g + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
const onViewStateChange = useCallback((s: MapViewState) => {
|
||||||
|
setMapState((cur) => {
|
||||||
|
// Keep a known viewport if the map hasn't reported one yet.
|
||||||
|
const next = { ...s, view: s.view ?? cur?.view };
|
||||||
|
saveWallPrefs(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [copied, setCopied] = useState<"ok" | "fail" | null>(null);
|
||||||
|
const copyViewLink = useCallback(() => {
|
||||||
|
const done = (r: "ok" | "fail") => { setCopied(r); setTimeout(() => setCopied(null), 2000); };
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(window.location.href).then(() => done("ok"), () => done("fail"));
|
||||||
|
} catch {
|
||||||
|
done("fail");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Kiosk ─────────────────────────────────────────────────────────────────
|
||||||
|
const kiosk = kioskQuery || !!link.kiosk;
|
||||||
|
const kioskState = useKiosk(kiosk, callsError);
|
||||||
|
const toggleKiosk = useCallback(() => {
|
||||||
|
if (kiosk) {
|
||||||
|
stripKioskQuery();
|
||||||
|
setKioskQuery(false);
|
||||||
|
patchLink({ kiosk: false });
|
||||||
|
} else {
|
||||||
|
patchLink({ kiosk: true });
|
||||||
|
// This click is the user gesture fullscreen needs.
|
||||||
|
if (!kioskState.fullscreen) kioskState.toggleFullscreen();
|
||||||
|
}
|
||||||
|
}, [kiosk, kioskState, patchLink]);
|
||||||
|
const tourMs = link.tourMs ?? TOUR_MS;
|
||||||
|
|
||||||
const [pinned, setPinned] = useState<Camera[]>([]);
|
const [pinned, setPinned] = useState<Camera[]>([]);
|
||||||
useEffect(() => { setPinned(loadPinned()); }, []);
|
useEffect(() => { setPinned(loadPinned()); }, []);
|
||||||
@@ -133,16 +270,63 @@ export function LiveView() {
|
|||||||
|
|
||||||
// ── Geography ─────────────────────────────────────────────────────────────
|
// ── Geography ─────────────────────────────────────────────────────────────
|
||||||
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
|
||||||
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
|
const filtered = useMemo(
|
||||||
|
() => applyFilter(activeIncidents, recentCalls, filter, callsByIncident),
|
||||||
|
[activeIncidents, recentCalls, filter, callsByIncident],
|
||||||
|
);
|
||||||
|
const sorted = useMemo(() => worstFirst(filtered.incidents), [filtered.incidents]);
|
||||||
const pointsById = useMemo(() => {
|
const pointsById = useMemo(() => {
|
||||||
const m = new Map<string, LatLon[]>();
|
const m = new Map<string, LatLon[]>();
|
||||||
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
|
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
|
||||||
return m;
|
return m;
|
||||||
}, [activeIncidents, callsByIncident]);
|
}, [activeIncidents, callsByIncident]);
|
||||||
|
// ── Selection (server-26#239) ─────────────────────────────────────────────
|
||||||
|
// Active first; an #inc= that isn't active (resolved, or the active list is
|
||||||
|
// still loading) is looked up by id. Another org's incident never selects:
|
||||||
|
// the rules should deny the read, and the org check here covers a project
|
||||||
|
// where they aren't deployed yet (DEFERRED.md, server-26#13).
|
||||||
|
const activeSelected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
||||||
|
const lookupId = link.inc && !activeSelected ? link.inc : null;
|
||||||
|
const { incident: lookedUp, loading: lookupLoading } = useIncident(lookupId);
|
||||||
|
const fetchedSelected: IncidentDoc | null =
|
||||||
|
lookupId && lookedUp && lookedUp.incident_id === lookupId && !!orgId && (lookedUp as IncidentDoc).org_id === orgId
|
||||||
|
? lookedUp
|
||||||
|
: null;
|
||||||
|
const lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId);
|
||||||
|
const notFound = !!lookupId && !fetchedSelected && !lookupPending;
|
||||||
|
// The incident on screen resolving drops it from the active list before its
|
||||||
|
// by-id lookup lands. Hold the last doc for that same id meanwhile, so the
|
||||||
|
// wall doesn't fly back to the region and straight back again.
|
||||||
|
const lastSelected = useRef<IncidentDoc | null>(null);
|
||||||
|
const held = lookupPending && lastSelected.current?.incident_id === link.inc ? lastSelected.current : null;
|
||||||
|
const selected: IncidentDoc | null = activeSelected ?? fetchedSelected ?? held;
|
||||||
|
lastSelected.current = selected;
|
||||||
|
const resolvedSelected = fetchedSelected?.status === "resolved" ? fetchedSelected : null;
|
||||||
|
|
||||||
|
// The selected incident's own calls, by incident id — no limit, so a long
|
||||||
|
// incident keeps every stop the 500-call window has already dropped. The
|
||||||
|
// hook keeps the previous incident's rows until its new snapshot lands, so
|
||||||
|
// rows are re-checked against the current id before merging.
|
||||||
|
const { calls: incidentCalls } = useCallsByIncident(link.inc);
|
||||||
|
const selectedCalls = useMemo<CallRecord[]>(() => {
|
||||||
|
if (!selected) return [];
|
||||||
|
const id = selected.incident_id;
|
||||||
|
const byId = new Map<string, CallRecord>();
|
||||||
|
for (const c of callsByIncident.get(id) ?? []) byId.set(c.call_id, c);
|
||||||
|
for (const c of incidentCalls) {
|
||||||
|
if (c.incident_ids?.includes(id) || c.incident_id === id) byId.set(c.call_id, c);
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}, [selected, callsByIncident, incidentCalls]);
|
||||||
|
const selectedPoints = useMemo(() => (selected ? incidentPoints(selected, selectedCalls) : []), [selected, selectedCalls]);
|
||||||
|
|
||||||
const regionBox = useMemo(() => {
|
const regionBox = useMemo(() => {
|
||||||
const pts = Array.from(pointsById.values()).flat();
|
const pts = Array.from(pointsById.values()).flat();
|
||||||
|
// A resolved incident opened by link sits outside the active set; count it
|
||||||
|
// so its nearby cameras load.
|
||||||
|
if (resolvedSelected) pts.push(...selectedPoints);
|
||||||
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
|
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
|
||||||
}, [pointsById, nodes]);
|
}, [pointsById, nodes, resolvedSelected, selectedPoints]);
|
||||||
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
||||||
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
|
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
|
||||||
// Pins live in this browser and carry their image URLs, so a pin must not
|
// Pins live in this browser and carry their image URLs, so a pin must not
|
||||||
@@ -156,11 +340,56 @@ export function LiveView() {
|
|||||||
}, [pointsById, cams.cameras]);
|
}, [pointsById, cams.cameras]);
|
||||||
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
|
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
|
||||||
const events = useRegionEvents(regionBox);
|
const events = useRegionEvents(regionBox);
|
||||||
|
const health = usePipelineHealth({
|
||||||
|
calls: recentCalls,
|
||||||
|
incidents: activeIncidents,
|
||||||
|
nodes,
|
||||||
|
eventsStatus: events.events_status,
|
||||||
|
cameraProviders: cams.providers,
|
||||||
|
});
|
||||||
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
|
||||||
|
|
||||||
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
|
// A drilled-into incident stays on the map even if the filter excludes it
|
||||||
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
// or it has resolved (a shared #inc= link must still land); it is not added
|
||||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
// to the tour. Its full call list rides along so the path is complete.
|
||||||
|
const keepSelected = !!selected && !filtered.ids.has(selected.incident_id);
|
||||||
|
const mapIncidents = useMemo(
|
||||||
|
() => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents),
|
||||||
|
[keepSelected, selected, filtered.incidents],
|
||||||
|
);
|
||||||
|
const mapCalls = useMemo(() => {
|
||||||
|
if (!selectedCalls.length) return filtered.calls;
|
||||||
|
const have = new Set(filtered.calls.map((c) => c.call_id));
|
||||||
|
const extra = selectedCalls.filter((c) => !have.has(c.call_id));
|
||||||
|
return extra.length ? [...filtered.calls, ...extra] : filtered.calls;
|
||||||
|
}, [selectedCalls, filtered.calls]);
|
||||||
|
|
||||||
|
// Filter choices come from what the wall has loaded (plus system names).
|
||||||
|
const filterOptions = useMemo(() => {
|
||||||
|
const sysNames = new Map(systems.map((s) => [s.system_id, s.name]));
|
||||||
|
const sysIds = new Set<string>();
|
||||||
|
const tgNames = new Map<string, string>();
|
||||||
|
for (const c of recentCalls) {
|
||||||
|
if (c.system_id) sysIds.add(c.system_id);
|
||||||
|
if (c.talkgroup_id != null) {
|
||||||
|
const id = String(c.talkgroup_id);
|
||||||
|
if (c.talkgroup_name || !tgNames.has(id)) tgNames.set(id, c.talkgroup_name || id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tagCount = new Map<string, number>();
|
||||||
|
for (const i of activeIncidents) {
|
||||||
|
for (const s of i.system_ids ?? []) sysIds.add(String(s));
|
||||||
|
for (const t of i.talkgroup_ids ?? []) if (!tgNames.has(String(t))) tgNames.set(String(t), String(t));
|
||||||
|
for (const t of i.tags ?? []) tagCount.set(t.toLowerCase(), (tagCount.get(t.toLowerCase()) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label);
|
||||||
|
return {
|
||||||
|
systems: Array.from(sysIds).map((id) => ({ id, label: sysNames.get(id) ?? id })).sort(byLabel),
|
||||||
|
talkgroups: Array.from(tgNames.entries()).map(([id, name]) => ({ id, label: name === id ? id : `${name} (${id})` })).sort(byLabel),
|
||||||
|
tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t),
|
||||||
|
};
|
||||||
|
}, [systems, recentCalls, activeIncidents]);
|
||||||
|
const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
|
||||||
const drillCam: Camera | null = link.cam
|
const drillCam: Camera | null = link.cam
|
||||||
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
|
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
|
||||||
: null;
|
: null;
|
||||||
@@ -177,13 +406,39 @@ export function LiveView() {
|
|||||||
}, [servingPinned, sorted, nearById]);
|
}, [servingPinned, sorted, nearById]);
|
||||||
|
|
||||||
// ── Navigation ────────────────────────────────────────────────────────────
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
|
const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
|
||||||
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
|
|
||||||
|
// ── Attention cues (server-26#238) ────────────────────────────────────────
|
||||||
|
// Diffed over everything loaded (so widening the filter is never "new");
|
||||||
|
// emitted, laned and pulsed only for what the filter shows.
|
||||||
|
const [attnPrefs, setAttnPrefs] = useState<AttentionPrefs>(DEFAULT_ATTENTION_PREFS);
|
||||||
|
useEffect(() => { setAttnPrefs(loadAttentionPrefs()); }, []);
|
||||||
|
const changeAttnPrefs = useCallback((p: AttentionPrefs) => { setAttnPrefs(p); saveAttentionPrefs(p); }, []);
|
||||||
|
const cues = useAttentionCues(attnPrefs, selectIncident);
|
||||||
|
const attentionCalls = useMemo(() => {
|
||||||
|
if (!incidentCalls.length) return recentCalls;
|
||||||
|
const have = new Set(recentCalls.map((c) => c.call_id));
|
||||||
|
const extra = incidentCalls.filter((c) => !have.has(c.call_id));
|
||||||
|
return extra.length ? [...recentCalls, ...extra] : recentCalls;
|
||||||
|
}, [recentCalls, incidentCalls]);
|
||||||
|
const visibleCallIds = useMemo(() => new Set(filtered.calls.map((c) => c.call_id)), [filtered.calls]);
|
||||||
|
const attention = useAttention({
|
||||||
|
ready: incidentsLoaded && !!orgId,
|
||||||
|
resetKey: orgId ?? null,
|
||||||
|
incidents: activeIncidents,
|
||||||
|
calls: attentionCalls,
|
||||||
|
keywords: attnPrefs.keywords,
|
||||||
|
visible: filtered.incidents,
|
||||||
|
visibleIds: filtered.ids,
|
||||||
|
visibleCallIds,
|
||||||
|
onEvents: cues.fire,
|
||||||
|
});
|
||||||
|
const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]);
|
||||||
const stepUp = useCallback(() => {
|
const stepUp = useCallback(() => {
|
||||||
if (link.cam) setLink({ ...link, cam: null, tour: false });
|
if (link.cam) patchLink({ cam: null, tour: false });
|
||||||
else if (link.inc) setLink({ inc: null, cam: null, tour: false });
|
else if (link.inc) patchLink({ inc: null, cam: null, tour: false });
|
||||||
else if (link.tour) setLink({ ...link, tour: false });
|
else if (link.tour) patchLink({ tour: false });
|
||||||
}, [link, setLink]);
|
}, [link, patchLink]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -203,30 +458,37 @@ export function LiveView() {
|
|||||||
const next = () => {
|
const next = () => {
|
||||||
setLinkState((cur) => {
|
setLinkState((cur) => {
|
||||||
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
|
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
|
||||||
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
|
return { ...cur, inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
|
||||||
window.history.replaceState(null, "", wallHash(nxt));
|
|
||||||
return nxt;
|
|
||||||
});
|
});
|
||||||
setTourStart(Date.now());
|
setTourStart(Date.now());
|
||||||
};
|
};
|
||||||
if (!link.inc || !tourList.includes(link.inc)) next();
|
if (!link.inc || !tourList.includes(link.inc)) next();
|
||||||
else setTourStart(Date.now());
|
else setTourStart(Date.now());
|
||||||
const id = setInterval(next, TOUR_MS);
|
const id = setInterval(next, tourMs);
|
||||||
const prog = setInterval(() => setTourNow(Date.now()), 500);
|
const prog = setInterval(() => setTourNow(Date.now()), 500);
|
||||||
return () => { clearInterval(id); clearInterval(prog); };
|
return () => { clearInterval(id); clearInterval(prog); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [link.tour, tourList.join(",")]);
|
}, [link.tour, tourList.join(","), tourMs]);
|
||||||
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0;
|
// Scrubber honesty (server-26#239): when the window came back full, nothing
|
||||||
|
// older than its oldest call was loaded — that span is unknown, not quiet.
|
||||||
|
const loadedSince = useMemo(() => {
|
||||||
|
if (!windowFull) return null;
|
||||||
|
let oldest: string | null = null;
|
||||||
|
for (const c of recentCalls) if (c.started_at && (!oldest || c.started_at < oldest)) oldest = c.started_at;
|
||||||
|
return oldest;
|
||||||
|
}, [windowFull, recentCalls]);
|
||||||
|
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
|
||||||
|
|
||||||
// ── KPIs ──────────────────────────────────────────────────────────────────
|
// ── KPIs ──────────────────────────────────────────────────────────────────
|
||||||
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
|
const count = (sev: string) => filtered.incidents.filter((i) => i.severity === sev).length;
|
||||||
const nodesUp = nodes.filter((n) => n.status !== "offline").length;
|
// "Nodes hearing": a call in the last N min, not merely not-offline — a node
|
||||||
|
// that checks in but hears nothing is not contributing to the picture.
|
||||||
|
const nodesHearing = health.nodesHearing;
|
||||||
const eventsOff = !!events.events_status?.disabled;
|
const eventsOff = !!events.events_status?.disabled;
|
||||||
|
|
||||||
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
|
// "Listening" only when every stage is healthy; otherwise the amber bar says why.
|
||||||
const mostRecentSeen = configuredButQuiet
|
const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0
|
||||||
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1)
|
&& activeIncidents.length === 0 && !health.degraded;
|
||||||
: null;
|
|
||||||
|
|
||||||
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
|
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
|
||||||
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
|
||||||
@@ -236,29 +498,31 @@ export function LiveView() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div data-kiosk={kiosk ? "" : undefined} className={`flex flex-col h-full ${KIOSK_TYPE}`}>
|
||||||
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
|
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
|
||||||
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
||||||
<nav aria-label="Drill level" className="flex items-center gap-0.5">
|
<nav aria-label="Drill level" className="flex items-center gap-0.5">
|
||||||
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
|
{crumb("Region", !link.inc, true, () => patchLink({ inc: null, cam: null, tour: false }))}
|
||||||
<span className="text-ink-muted">›</span>
|
<span className="text-ink-muted">›</span>
|
||||||
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
|
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
|
||||||
() => setLink({ ...link, cam: null, tour: false }))}
|
() => patchLink({ cam: null, tour: false }))}
|
||||||
<span className="text-ink-muted">›</span>
|
<span className="text-ink-muted">›</span>
|
||||||
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
|
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
||||||
<Kpi value={count("major")} label="major" />
|
<Kpi value={count("major")} label="major" />
|
||||||
<Kpi value={count("moderate")} label="moderate" />
|
<Kpi value={count("moderate")} label="moderate" />
|
||||||
<Kpi value={activeIncidents.length} label="active" />
|
<Kpi value={filtered.active ? `${filtered.incidents.length} of ${activeIncidents.length}` : activeIncidents.length}
|
||||||
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
|
label={filtered.active ? "active · filtered" : "active"} />
|
||||||
|
<Kpi value={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
|
||||||
<Kpi value={cams.cameras.length} label="cams" />
|
<Kpi value={cams.cameras.length} label="cams" />
|
||||||
<Kpi value={aircraft.length} label="aircraft" />
|
<Kpi value={aircraft.length} label="aircraft" />
|
||||||
<Kpi value={vessels.length} label="vessels" />
|
<Kpi value={vessels.length} label="vessels" />
|
||||||
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
|
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
|
<TruthStrip health={health} placement="chips" />
|
||||||
|
<button type="button" aria-pressed={link.tour} onClick={() => patchLink({ cam: null, tour: !link.tour })}
|
||||||
title="Wall mode: cycle through active incidents, worst first"
|
title="Wall mode: cycle through active incidents, worst first"
|
||||||
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||||
Auto-tour
|
Auto-tour
|
||||||
@@ -268,47 +532,108 @@ export function LiveView() {
|
|||||||
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
|
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" onClick={copyViewLink} title="Copy a link to this exact view: layers, map position, selection"
|
||||||
|
className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite">
|
||||||
|
{copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"}
|
||||||
|
</button>
|
||||||
|
<AttentionControls prefs={attnPrefs} onChange={changeAttnPrefs} armed={cues.armed} />
|
||||||
|
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
|
||||||
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<FilterBar
|
||||||
|
filter={filter}
|
||||||
|
onChange={setFilter}
|
||||||
|
onPreset={onPreset}
|
||||||
|
systems={filterOptions.systems}
|
||||||
|
talkgroups={filterOptions.talkgroups}
|
||||||
|
tags={filterOptions.tags}
|
||||||
|
shown={filtered.incidents.length}
|
||||||
|
total={activeIncidents.length}
|
||||||
|
readOnly={kiosk}
|
||||||
|
/>
|
||||||
|
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
|
||||||
|
<TruthStrip health={health} placement="bar" />
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
|
||||||
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
|
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
|
||||||
{nodesLoading || callsLoading ? (
|
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
||||||
|
MapView, so it is wrapped here rather than edited). */}
|
||||||
|
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
||||||
|
{/* Mount the map as soon as its starting view is known. The 500-call
|
||||||
|
window took ~9s on prod and used to hold the whole map back;
|
||||||
|
calls and paths now stream in once it lands. Nodes are only
|
||||||
|
awaited when there's no saved view, since they pick the centre. */}
|
||||||
|
{!mapState || (!mapState.view && nodesLoading) ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||||
) : (
|
) : (
|
||||||
<MapView
|
<MapView
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
activeCalls={activeCalls}
|
activeCalls={activeCalls}
|
||||||
incidents={activeIncidents}
|
incidents={mapIncidents}
|
||||||
calls={recentCalls}
|
calls={mapCalls}
|
||||||
lastUpdated={lastUpdated}
|
|
||||||
selectedIncidentId={selected?.incident_id ?? null}
|
selectedIncidentId={selected?.incident_id ?? null}
|
||||||
onSelectIncident={selectIncident}
|
onSelectIncident={selectIncident}
|
||||||
nearCameras={near}
|
nearCameras={near}
|
||||||
onOpenCamera={openCamera}
|
onOpenCamera={openCamera}
|
||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
camCountByIncident={camCountByIncident}
|
camCountByIncident={camCountByIncident}
|
||||||
|
pulseIncidentIds={attention.pulseIds}
|
||||||
|
filteredFrom={filtered.active ? activeIncidents.length : undefined}
|
||||||
|
key={mapGen}
|
||||||
|
initialLayers={mapState.layers}
|
||||||
|
initialBase={mapState.base}
|
||||||
|
initialView={mapState.view}
|
||||||
|
onViewStateChange={onViewStateChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!nodesLoading && !callsLoading && mapState && (
|
||||||
|
<NewLane entries={attention.lane} now={attention.now} onSelect={selectIncident} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
|
||||||
Activation screen handles the zero-node case). */}
|
Activation screen handles the zero-node case). */}
|
||||||
{configuredButQuiet && (
|
{configuredButQuiet && (
|
||||||
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||||||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
|
||||||
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
|
● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
|
<aside className={`${kiosk ? "lg:w-[480px]" : "lg:w-[360px]"} lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface`}
|
||||||
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
|
aria-live="polite" aria-label={link.inc ? "Incident detail" : "Region overview"}>
|
||||||
{selected ? (
|
{link.inc && !selected ? (
|
||||||
|
<div className="p-4 text-sm">
|
||||||
|
{lookupPending ? (
|
||||||
|
<p className="text-ink-muted">Loading incident…</p>
|
||||||
|
) : notFound ? (
|
||||||
|
<>
|
||||||
|
<p className="font-semibold text-ink">Incident not found</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
|
This link points to an incident that doesn't exist or isn't in your organization.
|
||||||
|
</p>
|
||||||
|
<button type="button" onClick={() => patchLink({ inc: null, cam: null, tour: false })}
|
||||||
|
className="mt-3 px-2 py-0.5 rounded border border-line text-xs text-ink-2 hover:text-ink">
|
||||||
|
Back to region
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : selected ? (
|
||||||
|
<>
|
||||||
|
{resolvedSelected && (
|
||||||
|
<div role="status" className="px-3 py-1.5 border-b border-line bg-raised text-xs text-ink-2">
|
||||||
|
<b className="text-ink font-semibold">{resolvedLabel(resolvedSelected)}</b>
|
||||||
|
{" · no longer active — opened from a link"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<IncidentPanel
|
<IncidentPanel
|
||||||
inc={selected}
|
inc={selected}
|
||||||
calls={callsByIncident.get(selected.incident_id) ?? []}
|
calls={selectedCalls}
|
||||||
points={selectedPoints}
|
points={selectedPoints}
|
||||||
near={near}
|
near={near}
|
||||||
feeds={feeds}
|
feeds={feeds}
|
||||||
@@ -320,10 +645,11 @@ export function LiveView() {
|
|||||||
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
|
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
|
||||||
onOpenCamera={openCamera}
|
onOpenCamera={openCamera}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<RegionPanel
|
<RegionPanel
|
||||||
incidents={activeIncidents}
|
incidents={filtered.incidents}
|
||||||
calls={recentCalls}
|
calls={filtered.calls}
|
||||||
onAirCount={onAir.size}
|
onAirCount={onAir.size}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
wallCameras={wallCameras}
|
wallCameras={wallCameras}
|
||||||
@@ -340,8 +666,9 @@ export function LiveView() {
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
|
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
||||||
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
|
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null}
|
||||||
|
onSelectIncident={selectIncident} loadedSince={loadedSince} windowSize={RECENT_CALLS} />
|
||||||
|
|
||||||
{drillCam && (
|
{drillCam && (
|
||||||
<CameraDrill
|
<CameraDrill
|
||||||
@@ -351,7 +678,7 @@ export function LiveView() {
|
|||||||
incidentTitle={selected?.title}
|
incidentTitle={selected?.title}
|
||||||
pinned={pinnedIds.has(drillCam.id)}
|
pinned={pinnedIds.has(drillCam.id)}
|
||||||
onTogglePin={() => togglePin(drillCam)}
|
onTogglePin={() => togglePin(drillCam)}
|
||||||
onClose={() => setLink({ ...link, cam: null })}
|
onClose={() => patchLink({ cam: null })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+844
-106
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import type { NodeRecord, SystemRecord } from "@/lib/types";
|
import type { NodeRecord, SystemRecord } from "@/lib/types";
|
||||||
|
import { diskState } from "@/lib/nodeDisk";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: NodeRecord;
|
node: NodeRecord;
|
||||||
@@ -19,22 +20,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
|||||||
: "never";
|
: "never";
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
<div className="bg-surface border border-line rounded-lg p-4 hover:border-line-strong transition-colors cursor-pointer">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono font-semibold text-white">{node.name}</p>
|
<p className="font-mono font-semibold text-white">{node.name}</p>
|
||||||
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
<p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-xs font-mono text-gray-400">
|
<div className="space-y-1 text-xs font-mono text-ink-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>System</span>
|
<span>System</span>
|
||||||
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span>
|
<span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Location</span>
|
<span>Location</span>
|
||||||
<span className="text-gray-300">
|
<span className="text-ink-2">
|
||||||
{node.lat != null && node.lon != null
|
{node.lat != null && node.lon != null
|
||||||
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
|
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
|
||||||
: "Unknown"}
|
: "Unknown"}
|
||||||
@@ -42,23 +43,32 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Last seen</span>
|
<span>Last seen</span>
|
||||||
<span className="text-gray-300">{lastSeen}</span>
|
<span className="text-ink-2">{lastSeen}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const disk = diskState(node);
|
||||||
|
if (!disk || disk.level === "ok") return null;
|
||||||
|
return (
|
||||||
|
<div className={`mt-3 text-xs font-mono border-t border-line pt-2 ${disk.level === "critical" ? "text-sev-major" : "text-sev-moderate"}`}>
|
||||||
|
⚠ Disk {disk.level === "critical" ? "nearly full" : "low"}: {disk.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{!node.configured && (
|
{!node.configured && (
|
||||||
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-gray-800 pt-2">
|
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
|
||||||
⚠ Needs configuration
|
⚠ Needs configuration
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.is_overridden && (
|
{node.is_overridden && (
|
||||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-line pt-2 flex justify-between">
|
||||||
<span>⚠ Local Override</span>
|
<span>⚠ Local Override</span>
|
||||||
{node.override_timeout_at ? (
|
{node.override_timeout_at ? (
|
||||||
<span className="text-gray-500">
|
<span className="text-ink-muted">
|
||||||
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">Permanent</span>
|
<span className="text-ink-muted">Permanent</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,20 +51,24 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
<div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||||
<p className="text-gray-400 text-sm mb-5">
|
<p className="text-ink-2 text-sm mb-5">
|
||||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
{node.configured ? (
|
||||||
Assign it a radio system to begin monitoring.
|
<><span className="text-indigo-400">{node.node_id}</span>: choose the radio system it should monitor.</>
|
||||||
|
) : (
|
||||||
|
<><span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||||
|
Assign it a radio system to begin monitoring.</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Radio System</label>
|
<label className="block text-xs text-ink-2 mb-1">Radio System</label>
|
||||||
<select
|
<select
|
||||||
value={systemId}
|
value={systemId}
|
||||||
onChange={(e) => setSystemId(e.target.value)}
|
onChange={(e) => setSystemId(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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select a system…</option>
|
<option value="">Select a system…</option>
|
||||||
@@ -77,41 +81,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">SDR Hardware</label>
|
<label className="block text-xs text-ink-2 mb-1">SDR Hardware</label>
|
||||||
<select
|
<select
|
||||||
value={preset}
|
value={preset}
|
||||||
onChange={(e) => setPreset(e.target.value)}
|
onChange={(e) => setPreset(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{PRESETS.map((p) => (
|
{PRESETS.map((p) => (
|
||||||
<option key={p.value} value={p.value}>{p.label}</option>
|
<option key={p.value} value={p.value}>{p.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{selectedPreset && (
|
{selectedPreset && (
|
||||||
<p className="text-xs text-gray-600 mt-1">{selectedPreset.hint}</p>
|
<p className="text-xs text-ink-muted mt-1">{selectedPreset.hint}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">
|
<label className="block text-xs text-ink-2 mb-1">
|
||||||
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span>
|
PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={ppm}
|
value={ppm}
|
||||||
onChange={(e) => setPpm(e.target.value)}
|
onChange={(e) => setPpm(e.target.value)}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
|
<label className="block text-xs text-ink-2 mb-1">Node Type *</label>
|
||||||
<select
|
<select
|
||||||
value={nodeType}
|
value={nodeType}
|
||||||
onChange={(e) => setNodeType(e.target.value)}
|
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"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select node type...</option>
|
<option value="">Select node type...</option>
|
||||||
@@ -127,9 +131,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
id="enforceTimeout"
|
id="enforceTimeout"
|
||||||
checked={enforceTimeout}
|
checked={enforceTimeout}
|
||||||
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
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"
|
className="rounded bg-raised border-line-strong 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">
|
<label htmlFor="enforceTimeout" className="text-xs text-ink-2 cursor-pointer select-none">
|
||||||
Enforce Local Override Timeout (24 hours)
|
Enforce Local Override Timeout (24 hours)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +152,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
|
// Node page "Discord radio" panel + picker (server-26#213 item 3, #109 item 13).
|
||||||
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
|
// Replaces the raw Guild ID / Voice Channel ID modal. The picker lists only
|
||||||
// the Discord servers linked to this node's org and the voice channels the
|
// the Discord servers linked to this node's org and their voice channels, as
|
||||||
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
|
// read by a radio bot in the server or pushed by the DRB server bot — either
|
||||||
// (desired state — the node reconciles and reports back, shown per row).
|
// is enough; the server bot is optional (server-26#225). A pick becomes a
|
||||||
|
// radio channel pinned to this node (desired state — the node reconciles and
|
||||||
|
// reports back, shown per row).
|
||||||
// A voice channel can carry several radio streams, each its own bot playing
|
// A voice channel can carry several radio streams, each its own bot playing
|
||||||
// its own system (server-26#213 follow-up): picking a channel that already
|
// its own system (server-26#213 follow-up): picking a channel that already
|
||||||
// plays another system ADDS this node's system beside it; only a stream of
|
// plays another system ADDS this node's system beside it; only a stream of
|
||||||
@@ -116,8 +118,8 @@ function DiscordPicker({
|
|||||||
const channelOnHere = channel?.stream?.node_id === nodeId;
|
const channelOnHere = channel?.stream?.node_id === nodeId;
|
||||||
const check = guildId ? checks[guildId] : undefined;
|
const check = guildId ? checks[guildId] : undefined;
|
||||||
const checkOn = check?.data?.enabled !== false;
|
const checkOn = check?.data?.enabled !== false;
|
||||||
const verdictOf = (c: DiscordGuildOption["voice_channels"][number]) => {
|
const verdictOf = (c: DiscordGuildOption["voice_channels"][number], data: VoiceCheck | null | undefined = check?.data) => {
|
||||||
const row = check?.data && checkOn ? check.data.channels[c.channel_id] : undefined;
|
const row = data && data.enabled !== false ? data.channels[c.channel_id] : undefined;
|
||||||
const v = channelVerdict(row);
|
const v = channelVerdict(row);
|
||||||
// This system already plays here: c2-core counts its bot as busy (it holds
|
// This system already plays here: c2-core counts its bot as busy (it holds
|
||||||
// the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
|
// the channel), so the channel can read "Needs fix"/"Bots busy" for a NEW
|
||||||
@@ -128,19 +130,26 @@ function DiscordPicker({
|
|||||||
};
|
};
|
||||||
const chosenVerdict = channel ? verdictOf(channel) : null;
|
const chosenVerdict = channel ? verdictOf(channel) : null;
|
||||||
const definiteDeny = blocksStart(chosenVerdict);
|
const definiteDeny = blocksStart(chosenVerdict);
|
||||||
|
// "Not a voice channel" can't change, so Start stays off. "Needs fix" can:
|
||||||
|
// Start re-checks with Discord first (server-26#256), so fixing it in
|
||||||
|
// Discord and pressing Start again just works — no hunting for Re-check.
|
||||||
|
const startOff = chosenVerdict?.kind === "not_voice";
|
||||||
|
// Not for "invalid": that bot is already in the server; inviting won't help (#257).
|
||||||
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
|
const showInvite = chosenVerdict?.kind === "fix" || chosenVerdict?.kind === "no_bot" || chosenVerdict?.kind === "busy";
|
||||||
|
|
||||||
const runCheck = useCallback(
|
const runCheck = useCallback(
|
||||||
async (gid: string, refresh: boolean) => {
|
async (gid: string, refresh: boolean): Promise<VoiceCheck | null> => {
|
||||||
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
|
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: true, error: null, limited: false } }));
|
||||||
try {
|
try {
|
||||||
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
|
const data = normalizeVoiceCheck(await c2api.getNodeVoiceCheck(nodeId, gid, refresh));
|
||||||
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
|
setChecks((m) => ({ ...m, [gid]: { data, busy: false, error: null, limited: refresh && data.refreshed === false } }));
|
||||||
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = parseC2Error(err);
|
const e = parseC2Error(err);
|
||||||
// 404 = route not deployed yet or server not visible: no badges (fail-open).
|
// 404 = route not deployed yet or server not visible: no badges (fail-open).
|
||||||
const msg = e.status === 404 ? null : e.message;
|
const msg = e.status === 404 ? null : e.message;
|
||||||
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
|
setChecks((m) => ({ ...m, [gid]: { data: m[gid]?.data ?? null, busy: false, error: msg, limited: false } }));
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[nodeId],
|
[nodeId],
|
||||||
@@ -166,6 +175,12 @@ function DiscordPicker({
|
|||||||
setSending(true);
|
setSending(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (definiteDeny) {
|
||||||
|
// Fresh answer from Discord; still refused = stay here, the row shows why.
|
||||||
|
// A failed check fails open, like everywhere else in the picker.
|
||||||
|
const fresh = await runCheck(guild.guild_id, true);
|
||||||
|
if (fresh && blocksStart(verdictOf(channel, fresh))) return;
|
||||||
|
}
|
||||||
await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
|
await c2api.startNodeDiscordStream(nodeId, { guild_id: guild.guild_id, channel_id: channel.channel_id, mode });
|
||||||
onStarted();
|
onStarted();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -198,8 +213,9 @@ function DiscordPicker({
|
|||||||
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
|
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
|
||||||
{usable.length === 0 ? (
|
{usable.length === 0 ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
||||||
No Discord server is linked to your organization yet. Add the DRB bot to your Discord server and
|
No Discord server is linked to your organization yet. The organization owner connects one under
|
||||||
link it to your organization; it shows up here once the bot reports its channels.
|
Settings → Organization → <b className="text-ink">Connect a Discord server</b>: adding a radio bot
|
||||||
|
to the server links it, and its voice channels show up here.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -246,12 +262,13 @@ function DiscordPicker({
|
|||||||
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
||||||
{!guild.synced ? (
|
{!guild.synced ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
||||||
The DRB bot has not reported this server's channels yet. It does when it starts and every 15
|
DRB hasn't read this server's voice channels yet. A radio bot in the server lists them
|
||||||
minutes — check back shortly.
|
(or the DRB server bot, if it's there) — close this and open it again in a moment. If it
|
||||||
|
stays empty, no radio bot is in this server yet — invite one with the link under the server above.
|
||||||
</p>
|
</p>
|
||||||
) : guild.voice_channels.length === 0 ? (
|
) : guild.voice_channels.length === 0 ? (
|
||||||
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
<p className="text-xs text-ink-2 bg-raised border border-line rounded-lg px-3 py-2">
|
||||||
This server has no voice channels the DRB bot can see.
|
This server has no voice channels DRB's bots can see.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -382,11 +399,17 @@ function DiscordPicker({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={start}
|
onClick={start}
|
||||||
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || definiteDeny}
|
disabled={sending || !channel || !options.system_id || (atNodeCap && !channelOnHere) || startOff}
|
||||||
title={definiteDeny ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
title={
|
||||||
|
startOff
|
||||||
|
? "That is not a voice channel. Pick a voice channel."
|
||||||
|
: definiteDeny
|
||||||
|
? "Fixed the permission in Discord? Start checks again first."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
|
className="flex-1 bg-accent hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg py-2 text-sm font-semibold"
|
||||||
>
|
>
|
||||||
{sending ? "Starting…" : channelOnHere ? "Update" : "Start radio"}
|
{sending ? (definiteDeny ? "Checking…" : "Starting…") : channelOnHere ? "Update" : "Start radio"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
|||||||
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
|
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2>
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Conventional Channels</h2>
|
||||||
{encrypted.length > 0 && (
|
{encrypted.length > 0 && (
|
||||||
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
|
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
|
||||||
{subject} encrypted — those calls can't be decoded, so nothing from{" "}
|
{subject} encrypted — those calls can't be decoded, so nothing from{" "}
|
||||||
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
|||||||
)}
|
)}
|
||||||
<ul className="space-y-1 font-mono text-xs">
|
<ul className="space-y-1 font-mono text-xs">
|
||||||
{channels.map((c, i) => (
|
{channels.map((c, i) => (
|
||||||
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300">
|
<li key={i} className="flex flex-wrap gap-x-3 text-ink-2">
|
||||||
<span className="text-white">{c.name || mhz(c.freq)}</span>
|
<span className="text-white">{c.name || mhz(c.freq)}</span>
|
||||||
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>}
|
{c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
|
||||||
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>}
|
{c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
|
||||||
{c.encrypted ? (
|
{c.encrypted ? (
|
||||||
<span className="text-red-400">
|
<span className="text-red-400">
|
||||||
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
|
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
|
||||||
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
|
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">
|
<span className="text-ink-2">
|
||||||
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
|
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
const selectClass =
|
||||||
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
"bg-raised border border-line-strong rounded px-2 py-1 text-ink text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
||||||
|
|
||||||
function DeviceSelect({
|
function DeviceSelect({
|
||||||
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||||
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
|
||||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
<p className="text-xs text-ink-muted font-mono mb-3">
|
||||||
{devicesKnown
|
{devicesKnown
|
||||||
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||||
: "This node hasn't reported its SDRs yet."}
|
: "This node hasn't reported its SDRs yet."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
<div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-gray-200">OP25 SDR</div>
|
<div className="text-ink">OP25 SDR</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-ink-muted">
|
||||||
{op25Moving
|
{op25Moving
|
||||||
? "Saving restarts OP25 on the selected SDR."
|
? "Saving restarts OP25 on the selected SDR."
|
||||||
: node.op25_sdr_serial
|
: node.op25_sdr_serial
|
||||||
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
: "Waiting for SDR";
|
: "Waiting for SDR";
|
||||||
return (
|
return (
|
||||||
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||||
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
|
<span className="w-4 text-right text-ink-muted text-xs">{row.enabled ? ++rank : ""}</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={row.enabled}
|
checked={row.enabled}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
aria-label={`Enable ${meta.name}`}
|
aria-label={`Enable ${meta.name}`}
|
||||||
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
||||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-[7rem]">
|
<div className="flex-1 min-w-[7rem]">
|
||||||
<div className="text-gray-200">{meta.name}</div>
|
<div className="text-ink">{meta.name}</div>
|
||||||
<div className="text-xs text-gray-500">{meta.hint}</div>
|
<div className="text-xs text-ink-muted">{meta.hint}</div>
|
||||||
</div>
|
</div>
|
||||||
<DeviceSelect
|
<DeviceSelect
|
||||||
value={row.pin}
|
value={row.pin}
|
||||||
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
onClick={() => move(i, -1)}
|
onClick={() => move(i, -1)}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
aria-label={`Move ${meta.name} up`}
|
aria-label={`Move ${meta.name} up`}
|
||||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
onClick={() => move(i, 1)}
|
onClick={() => move(i, 1)}
|
||||||
disabled={i === rows.length - 1}
|
disabled={i === rows.length - 1}
|
||||||
aria-label={`Move ${meta.name} down`}
|
aria-label={`Move ${meta.name} down`}
|
||||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{state}
|
{state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 font-mono mt-2">
|
<p className="text-xs text-ink-muted font-mono mt-2">
|
||||||
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
||||||
antenna, or leave it on "Any spare SDR".
|
antenna, or leave it on "Any spare SDR".
|
||||||
</p>
|
</p>
|
||||||
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save"}
|
{saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
{message && <span className="text-xs text-ink-muted font-mono">{message}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
|
|||||||
const styles: Record<NodeStatus, string> = {
|
const styles: Record<NodeStatus, string> = {
|
||||||
online: "bg-green-950 text-green-400 border border-green-800",
|
online: "bg-green-950 text-green-400 border border-green-800",
|
||||||
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
|
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
|
||||||
offline: "bg-gray-900 text-gray-500 border border-gray-700",
|
offline: "bg-surface text-ink-muted border border-line-strong",
|
||||||
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
|
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,20 @@ const BUCKETS = 48;
|
|||||||
* `incidents` (optional) adds one band per active incident from its start to
|
* `incidents` (optional) adds one band per active incident from its start to
|
||||||
* its last update, tinted by severity — click a band to drill into it (live
|
* its last update, tinted by severity — click a band to drill into it (live
|
||||||
* ops wall). The playhead still does not move; see the header.
|
* ops wall). The playhead still does not move; see the header.
|
||||||
|
*
|
||||||
|
* `loadedSince` (server-26#239): ISO time of the oldest call the caller's
|
||||||
|
* window loaded when that window came back FULL (null = everything in range
|
||||||
|
* was loaded). The axis before it is hatched and labelled "not loaded", so a
|
||||||
|
* 7d view never reads as a quiet week for time nobody fetched.
|
||||||
*/
|
*/
|
||||||
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: {
|
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident, loadedSince = null, windowSize }: {
|
||||||
calls: CallRecord[];
|
calls: CallRecord[];
|
||||||
incidents?: IncidentRecord[];
|
incidents?: IncidentRecord[];
|
||||||
selectedIncidentId?: string | null;
|
selectedIncidentId?: string | null;
|
||||||
onSelectIncident?: (id: string) => void;
|
onSelectIncident?: (id: string) => void;
|
||||||
|
loadedSince?: string | null;
|
||||||
|
/** Size of the caller's call window, for the "not loaded" tooltip. */
|
||||||
|
windowSize?: number;
|
||||||
}) {
|
}) {
|
||||||
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
||||||
|
|
||||||
@@ -56,6 +64,24 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
|
|||||||
|
|
||||||
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
||||||
|
|
||||||
|
// Share of the axis (0-100) before the loaded window starts; 0 = all loaded.
|
||||||
|
const unloaded = useMemo(() => {
|
||||||
|
const t = loadedSince ? new Date(loadedSince).getTime() : NaN;
|
||||||
|
if (Number.isNaN(t)) return { pct: 0, firstLoaded: 0, since: null as Date | null };
|
||||||
|
const start = Date.now() - range.ms;
|
||||||
|
if (t <= start) return { pct: 0, firstLoaded: 0, since: null as Date | null };
|
||||||
|
const frac = Math.min(1, (t - start) / range.ms);
|
||||||
|
// Buckets wholly before the window are unknown, not empty: drawn blank.
|
||||||
|
return { pct: frac * 100, firstLoaded: Math.floor(frac * BUCKETS), since: new Date(t) };
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [loadedSince, rangeIdx, calls]);
|
||||||
|
const sinceLabel = unloaded.since
|
||||||
|
? unloaded.since.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false })
|
||||||
|
: "";
|
||||||
|
const unloadedTitle = unloaded.since
|
||||||
|
? `Not loaded: only the latest ${windowSize ?? "few hundred"} calls are on this page (back to ${sinceLabel}). Earlier activity is unknown, not quiet.`
|
||||||
|
: "";
|
||||||
|
|
||||||
const bands = useMemo(() => {
|
const bands = useMemo(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const start = now - range.ms;
|
const start = now - range.ms;
|
||||||
@@ -88,7 +114,28 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{unloaded.pct > 0 && (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={unloadedTitle}
|
||||||
|
title={unloadedTitle}
|
||||||
|
className="absolute left-0 top-0 bottom-0 z-[1] flex items-center justify-center overflow-hidden rounded-sm border border-dashed border-line"
|
||||||
|
style={{
|
||||||
|
width: `${unloaded.pct}%`,
|
||||||
|
background: "repeating-linear-gradient(135deg, var(--line-strong) 0 2px, transparent 2px 7px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{unloaded.pct >= 8 && (
|
||||||
|
<span className="px-1.5 rounded bg-surface/90 text-[10px] font-mono uppercase tracking-wide text-ink-muted whitespace-nowrap">
|
||||||
|
not loaded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{buckets.map((b, i) => {
|
{buckets.map((b, i) => {
|
||||||
|
if (i < unloaded.firstLoaded) {
|
||||||
|
return <div key={i} aria-hidden="true" style={{ flex: 1, minWidth: 2 }} />;
|
||||||
|
}
|
||||||
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
||||||
const color =
|
const color =
|
||||||
b.worstRank === 3
|
b.worstRank === 3
|
||||||
|
|||||||
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<p className="text-gray-400 text-xs leading-snug">
|
<p className="text-ink-2 text-xs leading-snug">
|
||||||
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
||||||
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
|
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
|
||||||
Required attributions render on every camera tile.
|
Required attributions render on every camera tile.
|
||||||
</p>
|
</p>
|
||||||
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
||||||
{!providers ? (
|
{!providers ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
{providers.map((p) => {
|
{providers.map((p) => {
|
||||||
const lic = LICENCE_STYLE[p.licence.status];
|
const lic = LICENCE_STYLE[p.licence.status];
|
||||||
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
||||||
@@ -77,8 +77,8 @@ export function CameraProvidersTab() {
|
|||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
||||||
<p className="text-white text-sm font-semibold">{p.name}</p>
|
<p className="text-white text-sm font-semibold">{p.name}</p>
|
||||||
<p className="text-gray-400 text-xs">{p.licence.label}</p>
|
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||||
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p>
|
<p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right space-y-1">
|
<div className="shrink-0 text-right space-y-1">
|
||||||
<button
|
<button
|
||||||
@@ -93,25 +93,25 @@ export function CameraProvidersTab() {
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
</button>
|
</button>
|
||||||
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
|
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-ink-2">
|
||||||
<span>cameras: {p.health ? p.health.count : "—"}</span>
|
<span>cameras: {p.health ? p.health.count : "—"}</span>
|
||||||
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
||||||
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
||||||
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
||||||
</div>
|
</div>
|
||||||
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
||||||
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>}
|
{p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with imagery: “{p.licence.attribution}”</p>}
|
||||||
{p.acknowledgement && (
|
{p.acknowledgement && (
|
||||||
<p className="text-[11px] text-amber-300">
|
<p className="text-[11px] text-amber-300">
|
||||||
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<details className="text-[11px] text-gray-500">
|
<details className="text-[11px] text-ink-muted">
|
||||||
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
||||||
<p className="mt-1">{p.licence.terms}</p>
|
<p className="mt-1">{p.licence.terms}</p>
|
||||||
<p className="mt-1">{p.notes}</p>
|
<p className="mt-1">{p.notes}</p>
|
||||||
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
|
|||||||
|
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
|
||||||
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
<div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
||||||
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
||||||
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
||||||
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
|
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||||
<label className="flex items-start gap-2 text-sm text-gray-200">
|
<label className="flex items-start gap-2 text-sm text-ink">
|
||||||
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
||||||
<span>I confirm DRB holds the right to show this source's imagery. This confirmation is recorded with my account and the time in the audit log.</span>
|
<span>I confirm DRB holds the right to show this source's imagery. This confirmation is recorded with my account and the time in the audit log.</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
|
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
|
||||||
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
||||||
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin → Outage providers (server-26#184). Same licence gate as Camera
|
||||||
|
* providers: every source is listed with its licence status shown first, all
|
||||||
|
* default OFF. A licence-gated source (unverified / agreement required / paid)
|
||||||
|
* shows a blocking warning and needs an explicit confirmation, which c2-core
|
||||||
|
* records with who/when in config/outage_providers and audit_log. Stubs —
|
||||||
|
* utility storm-center maps, PowerOutage.us, EAGLE-I live — cannot be switched
|
||||||
|
* on at all: DRB holds no right to that data.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
|
||||||
|
|
||||||
|
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||||
|
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||||
|
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
||||||
|
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtEpoch(s: number | null | undefined): string {
|
||||||
|
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutageProvidersTab() {
|
||||||
|
const [providers, setProviders] = useState<OutageProviderAdmin[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [confirming, setConfirming] = useState<OutageProviderAdmin | null>(null);
|
||||||
|
const [ackChecked, setAckChecked] = useState(false);
|
||||||
|
|
||||||
|
const load = () =>
|
||||||
|
c2api.listOutageProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
async function toggle(p: OutageProviderAdmin, enabled: boolean, acknowledge = false) {
|
||||||
|
if (enabled && p.licence.gated && !acknowledge) {
|
||||||
|
setError(null);
|
||||||
|
setAckChecked(false);
|
||||||
|
setConfirming(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(p.id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.setOutageProvider(p.id, enabled, acknowledge);
|
||||||
|
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
|
||||||
|
setConfirming(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<p className="text-ink-2 text-xs leading-snug">
|
||||||
|
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
||||||
|
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
|
||||||
|
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
|
||||||
|
agreement exists.
|
||||||
|
</p>
|
||||||
|
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
||||||
|
{!providers ? (
|
||||||
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
|
{providers.map((p) => {
|
||||||
|
const lic = LICENCE_STYLE[p.licence.status];
|
||||||
|
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
||||||
|
return (
|
||||||
|
<div key={p.id} className="px-5 py-4 space-y-2">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
||||||
|
<p className="text-white text-sm font-semibold">{p.name}</p>
|
||||||
|
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||||
|
<p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={p.enabled}
|
||||||
|
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
|
||||||
|
disabled={busy === p.id || (!!lockReason && !p.enabled)}
|
||||||
|
title={lockReason ?? undefined}
|
||||||
|
onClick={() => toggle(p, !p.enabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
|
</button>
|
||||||
|
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
|
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{p.implemented && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-ink-2">
|
||||||
|
<span>counties out: {p.health ? p.health.count : "—"}</span>
|
||||||
|
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
||||||
|
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
||||||
|
{p.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
|
||||||
|
{p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with the data: “{p.licence.attribution}”</p>}
|
||||||
|
{p.acknowledgement && (
|
||||||
|
<p className="text-[11px] text-amber-300">
|
||||||
|
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<details className="text-[11px] text-ink-muted">
|
||||||
|
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
||||||
|
<p className="mt-1">{p.licence.terms}</p>
|
||||||
|
<p className="mt-1">{p.notes}</p>
|
||||||
|
<p className="mt-1 space-x-2">
|
||||||
|
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
|
||||||
|
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
|
||||||
|
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
|
||||||
|
<div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
||||||
|
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
||||||
|
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
||||||
|
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||||
|
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
|
||||||
|
<label className="flex items-start gap-2 text-sm text-ink">
|
||||||
|
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
||||||
|
<span>I confirm DRB holds the right to show this source's outage data. This confirmation is recorded with my account and the time in the audit log.</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
|
||||||
|
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
||||||
|
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
|
|||||||
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
"bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||||||
const btn =
|
const btn =
|
||||||
"bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
"bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
||||||
|
|
||||||
function toLocalInput(d: Date): string {
|
function toLocalInput(d: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
@@ -88,18 +88,18 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-4">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">From</label>
|
<label className="text-xs text-ink-2 block mb-1">From</label>
|
||||||
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">To</label>
|
<label className="text-xs text-ink-2 block mb-1">To</label>
|
||||||
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
<label className="text-xs text-ink-2 block mb-1">Label</label>
|
||||||
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,14 +109,14 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
|
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
|
||||||
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
||||||
<span className="text-white">{m.label}</span>
|
<span className="text-white">{m.label}</span>
|
||||||
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span>
|
<span className="text-ink-muted text-xs mt-0.5">{m.help}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "reuse" && (
|
{mode === "reuse" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label>
|
<label className="text-xs text-ink-2 block mb-1">Reuse extraction from</label>
|
||||||
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
||||||
<option value="">— pick a finished run —</option>
|
<option value="">— pick a finished run —</option>
|
||||||
{sources.map((r) => (
|
{sources.map((r) => (
|
||||||
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
<p className="text-xs text-ink-muted mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{systems.length > 1 && (
|
{systems.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span className="text-xs text-gray-400">Systems (none = all):</span>
|
<span className="text-xs text-ink-2">Systems (none = all):</span>
|
||||||
{systems.map((s) => (
|
{systems.map((s) => (
|
||||||
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer">
|
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-ink-2 cursor-pointer">
|
||||||
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
||||||
{s.name}
|
{s.name}
|
||||||
</label>
|
</label>
|
||||||
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{est && (
|
{est && (
|
||||||
<p className="text-sm font-mono text-gray-300">
|
<p className="text-sm font-mono text-ink-2">
|
||||||
{est.truncated ? (
|
{est.truncated ? (
|
||||||
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -193,15 +193,15 @@ function RunsTable({
|
|||||||
onChanged();
|
onChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runs.length) return <p className="text-sm text-gray-500 font-mono">No runs yet.</p>;
|
if (!runs.length) return <p className="text-sm text-ink-muted font-mono">No runs yet.</p>;
|
||||||
|
|
||||||
const th = "text-left text-xs text-gray-500 font-normal px-2 py-1.5 whitespace-nowrap";
|
const th = "text-left text-xs text-ink-muted font-normal px-2 py-1.5 whitespace-nowrap";
|
||||||
const td = "px-2 py-1.5 whitespace-nowrap";
|
const td = "px-2 py-1.5 whitespace-nowrap";
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm font-mono">
|
<table className="w-full text-sm font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800">
|
<tr className="border-b border-line">
|
||||||
<th className={th}>Run</th>
|
<th className={th}>Run</th>
|
||||||
<th className={th}>Range</th>
|
<th className={th}>Range</th>
|
||||||
<th className={th}>Status</th>
|
<th className={th}>Status</th>
|
||||||
@@ -222,13 +222,13 @@ function RunsTable({
|
|||||||
<tr
|
<tr
|
||||||
key={r.run_id}
|
key={r.run_id}
|
||||||
onClick={() => onSelect(r.run_id)}
|
onClick={() => onSelect(r.run_id)}
|
||||||
className={`border-b border-gray-800/60 cursor-pointer ${selected === r.run_id ? "bg-gray-800/60" : "hover:bg-gray-900"}`}
|
className={`border-b border-line/60 cursor-pointer ${selected === r.run_id ? "bg-raised/60" : "hover:bg-raised"}`}
|
||||||
>
|
>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
<div className="text-white">{r.label || r.run_id}</div>
|
<div className="text-white">{r.label || r.run_id}</div>
|
||||||
<div className="text-xs text-gray-500">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
<div className="text-xs text-ink-muted">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
<td className={`${td} text-xs text-ink-2`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
{running ? (
|
{running ? (
|
||||||
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
||||||
@@ -245,7 +245,7 @@ function RunsTable({
|
|||||||
<>
|
<>
|
||||||
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
||||||
{" / "}
|
{" / "}
|
||||||
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span>
|
<span className="text-ink-2">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||||
</>
|
</>
|
||||||
) : "—"}
|
) : "—"}
|
||||||
</td>
|
</td>
|
||||||
@@ -255,7 +255,7 @@ function RunsTable({
|
|||||||
{running ? (
|
{running ? (
|
||||||
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
|
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => remove(r.run_id)} className="text-xs text-gray-500 hover:text-red-400">delete</button>
|
<button onClick={() => remove(r.run_id)} className="text-xs text-ink-muted hover:text-red-400">delete</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
|
|||||||
} catch { /* audio is a convenience */ }
|
} catch { /* audio is a convenience */ }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono">
|
<div className="py-1.5 border-t border-line/60 text-xs font-mono">
|
||||||
<div className="flex flex-wrap gap-2 text-gray-500">
|
<div className="flex flex-wrap gap-2 text-ink-muted">
|
||||||
<span>{fmtTime(call.started_at)}</span>
|
<span>{fmtTime(call.started_at)}</span>
|
||||||
<span>{call.talkgroup_name}</span>
|
<span>{call.talkgroup_name}</span>
|
||||||
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
|
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
|
||||||
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
|
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
|
||||||
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
|
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
|
||||||
{call.skip_reason && <span className="text-gray-600">{call.skip_reason}</span>}
|
{call.skip_reason && <span className="text-ink-muted">{call.skip_reason}</span>}
|
||||||
{audio ? (
|
{audio ? (
|
||||||
<audio src={audio} controls autoPlay className="h-6" />
|
<audio src={audio} controls autoPlay className="h-6" />
|
||||||
) : (
|
) : (
|
||||||
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button>
|
<button onClick={play} className="text-ink-2 hover:text-white">▶ audio</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div>
|
<div className="text-ink mt-0.5">{call.transcript || <span className="text-ink-muted">(no transcript)</span>}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
<div className="bg-surface border border-line rounded-lg px-3 py-2">
|
||||||
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
|
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
|
||||||
<span className="text-gray-500">{open ? "▾" : "▸"}</span>
|
<span className="text-ink-muted">{open ? "▾" : "▸"}</span>
|
||||||
<span className="text-white">{inc.title || inc.incident_id}</span>
|
<span className="text-white">{inc.title || inc.incident_id}</span>
|
||||||
<span className="text-gray-500 text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
<span className="text-ink-muted text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
||||||
<span className="text-gray-500 text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
<span className="text-ink-muted text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
||||||
<span className={`text-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span>
|
<span className={`text-xs ${signal ? "text-green-400" : "text-ink-muted"}`}>{inc.resolved_via ?? inc.status}</span>
|
||||||
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
||||||
@@ -347,22 +347,22 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
|||||||
["With units cleared", m.incidents_with_units_cleared],
|
["With units cleared", m.incidents_with_units_cleared],
|
||||||
["With map pin", m.incidents_with_coords],
|
["With map pin", m.incidents_with_coords],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2">
|
<div key={k as string} className="bg-surface border border-line rounded-lg p-2">
|
||||||
<div className="text-gray-500">{k}</div>
|
<div className="text-ink-muted">{k}</div>
|
||||||
<div className="text-white text-base">{v}</div>
|
<div className="text-white text-base">{v}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{m && (
|
{m && (
|
||||||
<p className="text-xs font-mono text-gray-500">
|
<p className="text-xs font-mono text-ink-muted">
|
||||||
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||||
<br />
|
<br />
|
||||||
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
|
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
|
||||||
<p className="text-xs font-mono text-gray-500">
|
<p className="text-xs font-mono text-ink-muted">
|
||||||
Gemini tokens:{" "}
|
Gemini tokens:{" "}
|
||||||
{Object.entries(m.gemini_usage)
|
{Object.entries(m.gemini_usage)
|
||||||
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
|
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
|
||||||
@@ -380,7 +380,7 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
|||||||
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>}
|
{run.status === "running" && <p className="text-sm text-ink-muted font-mono">Incidents appear when the run finishes.</p>}
|
||||||
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
|||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={`px-3 py-1 rounded-md ${filter === f ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
className={`px-3 py-1 rounded-md ${filter === f ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
|
||||||
>
|
>
|
||||||
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowOrphans(!showOrphans)}
|
onClick={() => setShowOrphans(!showOrphans)}
|
||||||
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
|
||||||
>
|
>
|
||||||
Orphans ({data.orphans.length})
|
Orphans ({data.orphans.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showOrphans ? (
|
{showOrphans ? (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
<div className="bg-surface border border-line rounded-lg px-3 py-2">
|
||||||
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -443,7 +443,7 @@ export function ReplayTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Re-run the pipeline over a past time range, in the calls' original order, into a sandbox — live incidents are never
|
Re-run the pipeline over a past time range, in the calls' original order, into a sandbox — live incidents are never
|
||||||
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
|
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
|
||||||
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = parseC2Error(err);
|
const e = parseC2Error(err);
|
||||||
if (e.status === 403) setHidden(true);
|
if (e.status === 403) setHidden(true);
|
||||||
else setLoadError(e.message);
|
// A server error's raw body ("C2 API error 500: ...") means nothing to an owner.
|
||||||
|
else setLoadError(e.status !== null && e.status >= 500 ? "DRB couldn't load them just now." : e.message);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -67,15 +68,20 @@ export function PreferredBotsCard({ canEdit }: { canEdit: boolean }) {
|
|||||||
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
|
subtitle="Which of your radio bots plays each system in Discord. DRB tries it first for every new radio channel of that system; if it can't (busy in that server, missing a permission, or not invited), another free bot is used."
|
||||||
/>
|
/>
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<p role="alert" className="text-sm text-sev-major">Couldn't load radio bots: {loadError}</p>
|
<p role="alert" className="text-sm text-sev-major">
|
||||||
|
Couldn't load radio bots: {loadError}{" "}
|
||||||
|
<button type="button" onClick={() => load()} className="text-accent hover:underline">
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
) : !data ? (
|
) : !data ? (
|
||||||
<p className="text-sm text-ink-muted">Loading…</p>
|
<p className="text-sm text-ink-muted">Loading…</p>
|
||||||
) : data.systems.length === 0 ? (
|
) : data.systems.length === 0 ? (
|
||||||
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
|
<p className="text-sm text-ink-muted">Your organization has no radio systems yet.</p>
|
||||||
) : bots.length === 0 ? (
|
) : bots.length === 0 ? (
|
||||||
<p className="text-sm text-ink-muted">
|
<p className="text-sm text-ink-muted">
|
||||||
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. To use
|
Your organization has no radio bots of its own, so DRB picks a free DRB radio bot for each channel. Adding
|
||||||
your own bot for a system, ask DRB support to add it to your organization.
|
your own bot isn't self-serve yet: DRB support adds it for you, so ask whoever set up your DRB account.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-line">
|
<div className="divide-y divide-line">
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ import { DIM, Field, INPUT, Panel, Status, Table, TD } from "./ui";
|
|||||||
|
|
||||||
// Linked Discord servers = discord_guilds docs, plus any server that has had a
|
// Linked Discord servers = discord_guilds docs, plus any server that has had a
|
||||||
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
|
// radio channel without being linked (it acts for DISCORD_UNLINKED_GUILD_ORG_ID).
|
||||||
// Linking is PUT /discord/guilds/{id}/link (platform admin). The owner-side
|
// Linking here is PUT /discord/guilds/{id}/link (platform admin, support).
|
||||||
// "/drb link code:" flow is server-26#213 item 1 and is not built here.
|
// Org owners link their own from Settings → Organization: "Connect a Discord
|
||||||
|
// server" (a radio bot's OAuth invite, no DRB server bot needed) or a
|
||||||
|
// /drb link code (needs the server bot) — server-26#213 item 1, #225.
|
||||||
|
// The Bots column says which bots are in each server: the DRB server bot is
|
||||||
|
// optional (server-26#225 S8), one radio bot is enough to stream.
|
||||||
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
|
// Remove is DELETE /discord/guilds/{id}: its radio channels are stopped then
|
||||||
// deleted, and the server is unlinked with no fallback org.
|
// deleted, and the server is unlinked with no fallback org.
|
||||||
|
|
||||||
@@ -85,6 +89,30 @@ function LinkForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which bots are in the server. The DRB server bot is optional (server-26#225):
|
||||||
|
// "not there" is only a problem when no radio bot is either.
|
||||||
|
function BotsCell({ guild }: { guild: DiscordGuild }) {
|
||||||
|
const radio = guild.radio_bots ?? [];
|
||||||
|
const main = guild.main_bot_present;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{radio.length > 0 ? (
|
||||||
|
<span title={radio.join(", ")}>
|
||||||
|
{radio.length} radio bot{radio.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={main ? DIM : "text-sev-moderate"}>no radio bot</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={DIM}>
|
||||||
|
DRB server bot: {main === true ? "present" : main === false ? "not there" : "never seen"}
|
||||||
|
{guild.directory_source === "radio_bot" && " · channels via radio bot"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
|
type RemoveResult = { guild: string; streams: number; bots: { name: string; left: boolean; error: string | null }[] };
|
||||||
|
|
||||||
function RemoveForm({
|
function RemoveForm({
|
||||||
@@ -131,7 +159,7 @@ function RemoveForm({
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-ink-muted">
|
<p className="text-ink-muted">
|
||||||
The DRB bot itself stays in the Discord server until someone kicks it there.
|
The DRB server bot, if it is in the Discord server, stays until someone kicks it there.
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="text-sev-major">{error}</p>}
|
{error && <p className="text-sev-major">{error}</p>}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -171,8 +199,9 @@ export function ServersPanel({
|
|||||||
fallback ? (
|
fallback ? (
|
||||||
<>
|
<>
|
||||||
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
|
<b className="text-ink">Unlinked servers act for {fallbackName}</b> (DISCORD_UNLINKED_GUILD_ORG_ID). Any
|
||||||
server the DRB bot is invited to can use that org's systems until it is linked here. Set the variable
|
server the DRB server bot is invited to can use that org's systems until it is linked here (a radio
|
||||||
to an empty string once every real server is linked.
|
bot alone never puts an unlinked server here). Set the variable to an empty string once every real server
|
||||||
|
is linked.
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>Unlinked servers can't use radio commands until they are linked here.</>
|
<>Unlinked servers can't use radio commands until they are linked here.</>
|
||||||
@@ -189,14 +218,16 @@ export function ServersPanel({
|
|||||||
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
|
{removed.bots.map((b) => `${b.name} ${b.left ? "left" : `did not leave (${b.error ?? "unknown error"})`}`).join("; ")}.
|
||||||
</>
|
</>
|
||||||
)}{" "}
|
)}{" "}
|
||||||
The DRB bot is still in that Discord server until someone kicks it.
|
The DRB server bot, if it was there, stays in that Discord server until someone kicks it.
|
||||||
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
|
<button className="ml-3 text-accent hover:underline" onClick={() => setRemoved(null)}>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{overview.guilds.length === 0 ? (
|
{overview.guilds.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-sm text-ink-muted">No Discord servers yet. Link one, or run a radio command in a server the DRB bot is in.</p>
|
<p className="px-4 py-6 text-sm text-ink-muted">
|
||||||
|
No Discord servers yet. Link one here, or an organization owner connects one under Settings → Organization.
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Table head={["Server", "Acts for", "Radio channels", "Linked", "Status", ""]}>
|
<Table head={["Server", "Acts for", "Bots", "Radio channels", "Linked", "Status", ""]}>
|
||||||
{overview.guilds.flatMap((g) => {
|
{overview.guilds.flatMap((g) => {
|
||||||
const row = (
|
const row = (
|
||||||
<tr key={g.guild_id}>
|
<tr key={g.guild_id}>
|
||||||
@@ -208,6 +239,9 @@ export function ServersPanel({
|
|||||||
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
|
{g.acting_org_name || g.acting_org_id || <span className={DIM}>nobody</span>}
|
||||||
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
|
{!g.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
|
||||||
</td>
|
</td>
|
||||||
|
<td className={TD}>
|
||||||
|
<BotsCell guild={g} />
|
||||||
|
</td>
|
||||||
<td className={TD}>
|
<td className={TD}>
|
||||||
{g.live_streams} / {max}
|
{g.live_streams} / {max}
|
||||||
</td>
|
</td>
|
||||||
@@ -251,7 +285,7 @@ export function ServersPanel({
|
|||||||
return [
|
return [
|
||||||
row,
|
row,
|
||||||
<tr key={`${g.guild_id}-remove`}>
|
<tr key={`${g.guild_id}-remove`}>
|
||||||
<td colSpan={6} className="p-0 border-b border-line">
|
<td colSpan={7} className="p-0 border-b border-line">
|
||||||
<RemoveForm
|
<RemoveForm
|
||||||
guild={g}
|
guild={g}
|
||||||
onCancel={() => setRemoving(null)}
|
onCancel={() => setRemoving(null)}
|
||||||
@@ -269,7 +303,7 @@ export function ServersPanel({
|
|||||||
return [
|
return [
|
||||||
row,
|
row,
|
||||||
<tr key={`${g.guild_id}-edit`}>
|
<tr key={`${g.guild_id}-edit`}>
|
||||||
<td colSpan={6} className="p-0 border-b border-line">
|
<td colSpan={7} className="p-0 border-b border-line">
|
||||||
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
|
<LinkForm overview={overview} guild={g} onDone={done} onCancel={() => setEditing(null)} />
|
||||||
</td>
|
</td>
|
||||||
</tr>,
|
</tr>,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type ParsedC2Error,
|
type ParsedC2Error,
|
||||||
type VoiceCheck,
|
type VoiceCheck,
|
||||||
type VoiceCheckBot,
|
type VoiceCheckBot,
|
||||||
|
type VoiceCheckChannel,
|
||||||
} from "@/lib/discordPicker";
|
} from "@/lib/discordPicker";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
import { DIM, Field, INPUT, InviteLinks, Panel, Status, Table, TD } from "./ui";
|
||||||
@@ -70,17 +71,19 @@ function AddChannelForm({
|
|||||||
|
|
||||||
const cid = channelId.trim();
|
const cid = channelId.trim();
|
||||||
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
|
const checkKey = SNOWFLAKE.test(cid) && guildId ? `${guildId}:${cid}` : "";
|
||||||
const runCheck = useCallback(async (key: string, refresh: boolean) => {
|
const runCheck = useCallback(async (key: string, refresh: boolean): Promise<VoiceCheck | null> => {
|
||||||
const [gid, ch] = key.split(":");
|
const [gid, ch] = key.split(":");
|
||||||
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
|
setCheck((c) => ({ key, data: c?.key === key ? c.data : null, busy: true, error: null, limited: false }));
|
||||||
try {
|
try {
|
||||||
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
|
const data = normalizeVoiceCheck(await c2api.getGuildVoiceCheck(gid, ch, refresh));
|
||||||
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
|
setCheck((c) => (c?.key === key ? { key, data, busy: false, error: null, limited: refresh && data.refreshed === false } : c));
|
||||||
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const e = parseStartError(err);
|
const e = parseStartError(err);
|
||||||
setCheck((c) =>
|
setCheck((c) =>
|
||||||
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
|
c?.key === key ? { key, data: c.data, busy: false, error: e.status === 404 ? null : e.message, limited: false } : c,
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
// Check the channel once its ID looks complete (debounced while typing).
|
// Check the channel once its ID looks complete (debounced while typing).
|
||||||
@@ -98,8 +101,8 @@ function AddChannelForm({
|
|||||||
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
|
const verdict = liveCheck?.data && checkOn ? channelVerdict(checked) : null;
|
||||||
const shownName = channelName.trim() || checked?.name || null;
|
const shownName = channelName.trim() || checked?.name || null;
|
||||||
// Per-bot result, by token id (the admin route sends ids) or else by name.
|
// Per-bot result, by token id (the admin route sends ids) or else by name.
|
||||||
const botCheck = (b: RadioBot): VoiceCheckBot | undefined =>
|
const botCheck = (b: RadioBot, row: VoiceCheckChannel | undefined = checked): VoiceCheckBot | undefined =>
|
||||||
checked?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
|
row?.bots.find((x) => (x.token_id ? x.token_id === b.token_id : x.name === b.name || x.name === b.bot_name));
|
||||||
|
|
||||||
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
|
const org = guilds.find((g) => g.guild_id === guildId)?.acting_org_id ?? null;
|
||||||
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
|
const systems = overview.systems.filter((s) => s.org_id == null || s.org_id === org);
|
||||||
@@ -124,6 +127,13 @@ function AddChannelForm({
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (checkKey && (verdict?.kind === "fix" || chosenBotDenied)) {
|
||||||
|
// "Needs fix" can change: ask Discord again before refusing (server-26#256).
|
||||||
|
// Still refused = stay here, the row shows why; a failed check fails open.
|
||||||
|
const fresh = await runCheck(checkKey, true);
|
||||||
|
const row = fresh && fresh.enabled !== false ? fresh.channels[cid] : undefined;
|
||||||
|
if (row && (blocksStart(channelVerdict(row)) || (chosenBot && botCheck(chosenBot, row)?.ok === false))) return;
|
||||||
|
}
|
||||||
const res = await c2api.startDiscordStream({
|
const res = await c2api.startDiscordStream({
|
||||||
guild_id: guildId,
|
guild_id: guildId,
|
||||||
channel_id: channelId.trim(),
|
channel_id: channelId.trim(),
|
||||||
@@ -221,7 +231,7 @@ function AddChannelForm({
|
|||||||
</div>
|
</div>
|
||||||
{verdict && <FixText verdict={verdict} channelName={shownName} />}
|
{verdict && <FixText verdict={verdict} channelName={shownName} />}
|
||||||
{chosenBotDenied && (
|
{chosenBotDenied && (
|
||||||
<div className="text-xs text-sev-major">The bot you picked can't stream into this channel — pick another, or fix it in Discord and Re-check.</div>
|
<div className="text-xs text-sev-major">The bot you picked can't stream into this channel — pick another, or fix it in Discord and press Start again.</div>
|
||||||
)}
|
)}
|
||||||
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
|
{checkOn && <BotDetails channel={checked} channelName={shownName} />}
|
||||||
<CheckFooter
|
<CheckFooter
|
||||||
@@ -252,10 +262,16 @@ function AddChannelForm({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={saving || !systemId || blocksStart(verdict) || chosenBotDenied}
|
disabled={saving || !systemId || verdict?.kind === "not_voice"}
|
||||||
title={blocksStart(verdict) ? "No radio bot in this server can stream into this channel — fix the permission in Discord, then Re-check." : undefined}
|
title={
|
||||||
|
verdict?.kind === "not_voice"
|
||||||
|
? "That is not a voice channel. Pick a voice channel."
|
||||||
|
: blocksStart(verdict) || chosenBotDenied
|
||||||
|
? "Fixed the permission in Discord? Start checks again first."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{saving ? "Starting…" : "Start radio channel"}
|
{saving ? (blocksStart(verdict) || chosenBotDenied ? "Checking…" : "Starting…") : "Start radio channel"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
<Button type="button" size="sm" variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -332,7 +348,8 @@ export function StreamsPanel({
|
|||||||
)}
|
)}
|
||||||
{overview.streams.length === 0 ? (
|
{overview.streams.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-sm text-ink-muted">
|
<p className="px-4 py-6 text-sm text-ink-muted">
|
||||||
No radio channels yet. Start one here, or run <span className="font-mono">/join</span> in a linked Discord server.
|
No radio channels yet. Start one here or from a node's Discord radio panel, or run{" "}
|
||||||
|
<span className="font-mono">/join</span> where the DRB server bot is in a linked Discord server.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const BADGE: Record<ChannelVerdict["kind"], { label: string; cls: string; title?
|
|||||||
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
|
title: "A bot could stream here, but each one that can is already in another channel of this server or on another node.",
|
||||||
},
|
},
|
||||||
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
|
no_bot: { label: "No radio bot", cls: "border-sev-moderate/50 text-sev-moderate", title: "No radio bot is in this Discord server yet." },
|
||||||
|
invalid: {
|
||||||
|
label: "Radio bot needs replacing",
|
||||||
|
cls: "border-sev-major/50 text-sev-major",
|
||||||
|
title: "A radio bot is in this server, but Discord rejected its token. Inviting it again won't help — DRB support has to replace it.",
|
||||||
|
},
|
||||||
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
|
unknown: { label: "Couldn't check", cls: "border-line text-ink-muted", title: "Discord didn't answer the check — DRB will still try." },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,11 +64,19 @@ export function VerdictBadge({ verdict, loading }: { verdict: ChannelVerdict | n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The sentence under a "Needs fix" / "Not a voice channel" channel. */
|
/** The sentence under a "Needs fix" / "Not a voice channel" / "Radio bot needs replacing" channel. */
|
||||||
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
|
export function FixText({ verdict, channelName }: { verdict: ChannelVerdict; channelName: string | null }) {
|
||||||
if (verdict.kind === "not_voice") {
|
if (verdict.kind === "not_voice") {
|
||||||
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
|
return <span className="block text-xs text-sev-major mt-1">That is not a voice channel. Radio can only play in a voice channel.</span>;
|
||||||
}
|
}
|
||||||
|
if (verdict.kind === "invalid") {
|
||||||
|
return (
|
||||||
|
<span className="block text-xs text-sev-major mt-1">
|
||||||
|
Discord rejected the radio bot in this server, so it can't play radio. Inviting it again won't help —
|
||||||
|
DRB support has to replace it.
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (verdict.kind !== "fix") return null;
|
if (verdict.kind !== "fix") return null;
|
||||||
return (
|
return (
|
||||||
<span className="block text-xs text-sev-major mt-1">
|
<span className="block text-xs text-sev-major mt-1">
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attention cues (server-26#238) — the opt-in half: an audible tone and
|
||||||
|
* browser notifications, both OFF by default.
|
||||||
|
*
|
||||||
|
* Tone: Web Audio beep, no asset. Browsers only let audio start from a user
|
||||||
|
* gesture, so the AudioContext is created by the click that turns the tone
|
||||||
|
* on (and that click plays one confirmation beep). After a reload with the
|
||||||
|
* tone already on, the first click/key anywhere on the page re-arms it; until
|
||||||
|
* then nothing plays and the button says so.
|
||||||
|
*
|
||||||
|
* Notifications: Notification.requestPermission() on click; fired only while
|
||||||
|
* the tab is hidden (a visible wall already shows the lane and pulse). The
|
||||||
|
* body ends with the Gate A machine-output notice — the title it carries is
|
||||||
|
* pipeline output. No transcript text goes into a notification.
|
||||||
|
*/
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { DEFAULT_KEYWORDS, parseKeywords, type AttentionEvent, type AttentionPrefs } from "@/lib/attention";
|
||||||
|
import { MACHINE_OUTPUT_NOTICE_TEXT } from "@/components/ui/MachineOutputNotice";
|
||||||
|
|
||||||
|
const TONE_GAP_MS = 4_000;
|
||||||
|
const MAX_NOTIFICATIONS = 3;
|
||||||
|
|
||||||
|
// ── Audio (one context per page, only ever created inside a user gesture) ────
|
||||||
|
let audioCtx: AudioContext | null = null;
|
||||||
|
|
||||||
|
function createAudio(): AudioContext | null {
|
||||||
|
try {
|
||||||
|
if (!audioCtx) {
|
||||||
|
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AC) return null;
|
||||||
|
audioCtx = new AC();
|
||||||
|
}
|
||||||
|
if (audioCtx.state === "suspended") void audioCtx.resume().catch(() => undefined);
|
||||||
|
return audioCtx;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** n short beeps, alternating 880/660 Hz. Silent unless a gesture already started the context. */
|
||||||
|
function beep(n: number) {
|
||||||
|
const a = audioCtx;
|
||||||
|
if (!a || a.state !== "running") return;
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t0 = a.currentTime + i * 0.22;
|
||||||
|
const osc = a.createOscillator();
|
||||||
|
const gain = a.createGain();
|
||||||
|
osc.type = "sine";
|
||||||
|
osc.frequency.value = i % 2 ? 660 : 880;
|
||||||
|
gain.gain.setValueAtTime(0.0001, t0);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.25, t0 + 0.02);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
|
||||||
|
osc.connect(gain).connect(a.destination);
|
||||||
|
osc.start(t0);
|
||||||
|
osc.stop(t0 + 0.2);
|
||||||
|
}
|
||||||
|
} catch { /* audio unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifyState = "unsupported" | "default" | "granted" | "denied";
|
||||||
|
|
||||||
|
function notifyState(): NotifyState {
|
||||||
|
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
|
||||||
|
return Notification.permission as NotifyState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEADLINE: Record<AttentionEvent["kind"], string> = {
|
||||||
|
major: "Major incident",
|
||||||
|
escalated: "Severity escalated",
|
||||||
|
stop: "Pursuit path: new stop",
|
||||||
|
keyword: "Priority keyword",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires the opted-in cues for a batch of in-view events. Returns `fire` (pass
|
||||||
|
* to useAttention's onEvents) and the live audio state for the controls.
|
||||||
|
*/
|
||||||
|
export function useAttentionCues(prefs: AttentionPrefs, onOpenIncident: (id: string) => void) {
|
||||||
|
const [armed, setArmed] = useState(false);
|
||||||
|
const lastTone = useRef(0);
|
||||||
|
const prefsRef = useRef(prefs);
|
||||||
|
prefsRef.current = prefs;
|
||||||
|
const openRef = useRef(onOpenIncident);
|
||||||
|
openRef.current = onOpenIncident;
|
||||||
|
|
||||||
|
// Tone on: track the context's state. Already on from an earlier visit →
|
||||||
|
// the next gesture anywhere on the page creates/resumes the context.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!prefs.tone) { setArmed(false); return; }
|
||||||
|
const sync = () => setArmed(audioCtx?.state === "running");
|
||||||
|
const arm = () => {
|
||||||
|
const a = createAudio();
|
||||||
|
if (a) { a.onstatechange = sync; a.resume().then(sync, () => undefined); }
|
||||||
|
};
|
||||||
|
if (audioCtx) { audioCtx.onstatechange = sync; sync(); }
|
||||||
|
window.addEventListener("pointerdown", arm, { once: true });
|
||||||
|
window.addEventListener("keydown", arm, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", arm);
|
||||||
|
window.removeEventListener("keydown", arm);
|
||||||
|
};
|
||||||
|
}, [prefs.tone]);
|
||||||
|
|
||||||
|
const fire = useCallback((events: AttentionEvent[]) => {
|
||||||
|
if (!events.length) return;
|
||||||
|
const p = prefsRef.current;
|
||||||
|
if (p.tone && Date.now() - lastTone.current > TONE_GAP_MS) {
|
||||||
|
lastTone.current = Date.now();
|
||||||
|
// Three beeps for anything reaching Major or a priority keyword, one otherwise.
|
||||||
|
beep(events.some((e) => e.kind === "major" || e.kind === "keyword" || e.label.endsWith("Major")) ? 3 : 1);
|
||||||
|
}
|
||||||
|
if (!p.notify || typeof document === "undefined" || !document.hidden || notifyState() !== "granted") return;
|
||||||
|
const send = (title: string, body: string, tag: string, incidentId: string | null) => {
|
||||||
|
try {
|
||||||
|
const n = new Notification(title, { body: `${body}\n${MACHINE_OUTPUT_NOTICE_TEXT}`, tag });
|
||||||
|
n.onclick = () => {
|
||||||
|
window.focus();
|
||||||
|
if (incidentId) openRef.current(incidentId);
|
||||||
|
n.close();
|
||||||
|
};
|
||||||
|
} catch { /* e.g. mobile Chrome needs a service worker — silently no notification */ }
|
||||||
|
};
|
||||||
|
for (const e of events.slice(0, MAX_NOTIFICATIONS)) {
|
||||||
|
const head = e.kind === "keyword" ? `${HEADLINE.keyword}: ${e.label}` : e.kind === "escalated" ? `${HEADLINE.escalated}: ${e.label.replace(/^↑\s*/, "")}` : HEADLINE[e.kind];
|
||||||
|
send(`DRB · ${head}`, e.title, `drb-attn:${e.incidentIds[0] ?? e.id}`, e.incidentIds[0] ?? null);
|
||||||
|
}
|
||||||
|
if (events.length > MAX_NOTIFICATIONS) {
|
||||||
|
send("DRB · More activity", `${events.length - MAX_NOTIFICATIONS} more on the live wall.`, "drb-attn:more", null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { fire, armed };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
prefs: AttentionPrefs;
|
||||||
|
onChange: (p: AttentionPrefs) => void;
|
||||||
|
armed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttentionControls({ prefs, onChange, armed }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [perm, setPerm] = useState<NotifyState>("default");
|
||||||
|
const [kwDraft, setKwDraft] = useState(prefs.keywords.join(", "));
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => { setPerm(notifyState()); }, [open]);
|
||||||
|
useEffect(() => { setKwDraft(prefs.keywords.join(", ")); }, [prefs.keywords]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDown = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setOpen(false); };
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => { document.removeEventListener("mousedown", onDown); document.removeEventListener("keydown", onKey); };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const toggleTone = () => {
|
||||||
|
if (prefs.tone) { onChange({ ...prefs, tone: false }); return; }
|
||||||
|
// This click is the gesture that unlocks audio; confirm with one beep.
|
||||||
|
const a = createAudio();
|
||||||
|
onChange({ ...prefs, tone: true });
|
||||||
|
if (a) a.resume().then(() => beep(1), () => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNotify = async () => {
|
||||||
|
if (prefs.notify) { onChange({ ...prefs, notify: false }); return; }
|
||||||
|
if (notifyState() === "unsupported") { setPerm("unsupported"); return; }
|
||||||
|
let p: NotifyState = notifyState();
|
||||||
|
if (p === "default") {
|
||||||
|
try { p = (await Notification.requestPermission()) as NotifyState; } catch { p = notifyState(); }
|
||||||
|
}
|
||||||
|
if (p === "granted") {
|
||||||
|
// Confirm with one notification (as the tone confirms with a beep). It
|
||||||
|
// doubles as the support check: mobile Chrome grants permission but
|
||||||
|
// throws on `new Notification()` without a service worker, which would
|
||||||
|
// otherwise leave a switched-on control that never delivers anything.
|
||||||
|
try {
|
||||||
|
new Notification("DRB · Notifications on", {
|
||||||
|
body: `Cues will appear here while the live wall tab is hidden.\n${MACHINE_OUTPUT_NOTICE_TEXT}`,
|
||||||
|
tag: "drb-attn:on",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
p = "unsupported";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPerm(p);
|
||||||
|
if (p === "granted") onChange({ ...prefs, notify: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitKeywords = () => {
|
||||||
|
const next = parseKeywords(kwDraft);
|
||||||
|
if (next.join("\u0001") !== prefs.keywords.join("\u0001")) onChange({ ...prefs, keywords: next });
|
||||||
|
else setKwDraft(prefs.keywords.join(", "));
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyOn = prefs.notify && perm !== "denied" && perm !== "unsupported";
|
||||||
|
const summary = [prefs.tone ? (armed ? "tone" : "tone (click to arm)") : null, notifyOn ? "notify" : null].filter(Boolean).join(" · ") || "off";
|
||||||
|
const on = prefs.tone || notifyOn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={boxRef} className="relative">
|
||||||
|
<button type="button" aria-expanded={open} aria-haspopup="dialog" onClick={() => setOpen((v) => !v)}
|
||||||
|
title="Attention cues: an audible tone and browser notifications for new Majors, escalations, pursuit stops and priority keywords"
|
||||||
|
className={`px-2 py-0.5 rounded border whitespace-nowrap ${on ? "border-accent text-accent" : "border-line text-ink-2 hover:text-ink"}`}>
|
||||||
|
Cues: {summary}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div role="dialog" aria-label="Attention cues"
|
||||||
|
className="absolute right-0 top-full mt-1 z-[1100] w-72 max-w-[calc(100vw-2rem)] bg-surface border border-line rounded-lg shadow-lg p-3 space-y-3 text-xs text-ink-2">
|
||||||
|
<p className="text-ink-muted leading-snug">
|
||||||
|
Cues fire on: a Major opened, severity escalated, a pursuit path gaining a stop, or a priority keyword in a new
|
||||||
|
call. Only for incidents the current filter shows.
|
||||||
|
</p>
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" className="mt-0.5" checked={prefs.tone} onChange={toggleTone} />
|
||||||
|
<span>
|
||||||
|
<span className="text-ink font-medium">Sound a tone</span>
|
||||||
|
{prefs.tone && !armed && (
|
||||||
|
<span className="block text-ink-muted">Click anywhere on the page to arm it — browsers block sound until you do.</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" className="mt-0.5" checked={notifyOn} disabled={perm === "unsupported"} onChange={() => { void toggleNotify(); }} />
|
||||||
|
<span>
|
||||||
|
<span className="text-ink font-medium">Browser notifications while this tab is hidden</span>
|
||||||
|
{perm === "denied" && <span className="block text-ink-muted">Blocked in this browser's site settings — allow notifications there first.</span>}
|
||||||
|
{perm === "unsupported" && <span className="block text-ink-muted">This browser doesn't show notifications from a web page (mobile browsers often only allow them from installed apps).</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="attn-kw" className="block text-ink font-medium mb-1">Priority keywords</label>
|
||||||
|
<input id="attn-kw" type="text" value={kwDraft} onChange={(e) => setKwDraft(e.target.value)} onBlur={commitKeywords}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") commitKeywords(); }}
|
||||||
|
placeholder="comma-separated"
|
||||||
|
className="w-full px-2 py-1 rounded border border-line bg-raised text-ink" />
|
||||||
|
<div className="mt-1 flex items-center justify-between text-ink-muted">
|
||||||
|
<span>Matched against new call transcripts.</span>
|
||||||
|
<button type="button" className="underline hover:text-ink"
|
||||||
|
onClick={() => onChange({ ...prefs, keywords: [...DEFAULT_KEYWORDS] })}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live wall filter bar (server-26#235): one row under the wall bar — presets,
|
||||||
|
* free-text search, type chips, severity floor, system/talkgroup, age window,
|
||||||
|
* tag chips, clear. Presentation only; the filter lives in the URL hash
|
||||||
|
* (lib/liveFilter.ts) and LiveView applies it once to everything it plots.
|
||||||
|
* Wraps on narrow screens; below md the less-used controls fold behind a
|
||||||
|
* "Filters" toggle so the map keeps its height.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { SEVERITY_LABEL, type Severity } from "@/lib/severity";
|
||||||
|
import {
|
||||||
|
AGE_WINDOWS_H, EMPTY_FILTER, INCIDENT_TYPES, PRESETS, PRESET_KEYS, describeFilter, isFilterActive,
|
||||||
|
type IncidentType, type PresetKey, type WallFilter,
|
||||||
|
} from "@/lib/liveFilter";
|
||||||
|
|
||||||
|
export interface FilterOption { id: string; label: string }
|
||||||
|
|
||||||
|
const TYPE_LABEL: Record<IncidentType, string> = {
|
||||||
|
fire: "Fire", ems: "EMS", police: "Police", accident: "Accident", other: "Other",
|
||||||
|
};
|
||||||
|
const SEV_FLOORS: Severity[] = ["minor", "moderate", "major"];
|
||||||
|
const Q_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
const chip = (on: boolean) =>
|
||||||
|
`px-2 py-0.5 rounded-full border whitespace-nowrap ${on ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`;
|
||||||
|
const select = "px-1.5 py-0.5 rounded border border-line bg-surface text-ink-2 max-w-[11rem]";
|
||||||
|
|
||||||
|
const toggle = <T,>(arr: T[], v: T): T[] => (arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
filter, onChange, onPreset, systems, talkgroups, tags, shown, total, readOnly = false,
|
||||||
|
}: {
|
||||||
|
filter: WallFilter;
|
||||||
|
onChange: (f: WallFilter) => void;
|
||||||
|
/** A preset was switched on — the caller also turns its map layers on. */
|
||||||
|
onPreset: (p: PresetKey) => void;
|
||||||
|
systems: FilterOption[];
|
||||||
|
talkgroups: FilterOption[];
|
||||||
|
/** Most common tags in the loaded incidents (selected ones are always shown). */
|
||||||
|
tags: string[];
|
||||||
|
shown: number;
|
||||||
|
total: number;
|
||||||
|
/** Kiosk: no controls, just the summary when a filter is on. */
|
||||||
|
readOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const active = isFilterActive(filter);
|
||||||
|
const [more, setMore] = useState(false);
|
||||||
|
|
||||||
|
// Search is debounced locally so each keystroke doesn't rewrite the hash
|
||||||
|
// and re-filter every transcript; an outside change (hash edit, Clear) wins.
|
||||||
|
const [q, setQ] = useState(filter.q);
|
||||||
|
const committed = useRef(filter.q);
|
||||||
|
useEffect(() => {
|
||||||
|
// Compare trimmed: the hash stores q trimmed, so a just-typed trailing
|
||||||
|
// space ("main ") must not read as an outside change and get eaten.
|
||||||
|
if (filter.q !== committed.current.trim()) { committed.current = filter.q; setQ(filter.q); }
|
||||||
|
}, [filter.q]);
|
||||||
|
const filterRef = useRef(filter);
|
||||||
|
filterRef.current = filter;
|
||||||
|
useEffect(() => {
|
||||||
|
if (q === committed.current) return;
|
||||||
|
const id = setTimeout(() => { committed.current = q; onChange({ ...filterRef.current, q }); }, Q_DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [q, onChange]);
|
||||||
|
|
||||||
|
const sysName = (id: string) => systems.find((s) => s.id === id)?.label ?? id;
|
||||||
|
const tgName = (id: string) => talkgroups.find((t) => t.id === id)?.label ?? id;
|
||||||
|
const summary = (
|
||||||
|
<span className={`whitespace-nowrap ${active ? "text-ink font-semibold" : "text-ink-muted"}`} aria-live="polite">
|
||||||
|
{active ? `Filtered: ${shown} of ${total}` : `${total} active`}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (readOnly) {
|
||||||
|
if (!active) return null;
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-1 border-b border-line bg-surface/95 text-xs">
|
||||||
|
{summary}
|
||||||
|
<span className="text-ink-2 min-w-0 break-words">{describeFilter(filter, { sys: sysName, tg: tgName })}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = (p: Partial<WallFilter>) => onChange({ ...filter, ...p });
|
||||||
|
const tagList = Array.from(new Set([...filter.tags, ...tags]));
|
||||||
|
const tgValue = filter.talkgroups.length === 1 ? filter.talkgroups[0] : "";
|
||||||
|
const sysValue = filter.systems.length === 1 ? filter.systems[0] : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="search" aria-label="Filter the live map"
|
||||||
|
className="shrink-0 flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
|
||||||
|
<div className="flex items-center gap-1" role="group" aria-label="Presets">
|
||||||
|
{PRESET_KEYS.map((k) => (
|
||||||
|
<button key={k} type="button" aria-pressed={filter.preset === k} title={PRESETS[k].title} className={chip(filter.preset === k)}
|
||||||
|
onClick={() => {
|
||||||
|
if (filter.preset === k) { set({ preset: null }); return; }
|
||||||
|
set({ preset: k });
|
||||||
|
onPreset(k);
|
||||||
|
}}>
|
||||||
|
{PRESETS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title, location, transcript"
|
||||||
|
aria-label="Search incidents and transcripts"
|
||||||
|
className="min-w-0 flex-1 basis-40 max-w-xs px-2 py-0.5 rounded border border-line bg-surface text-ink placeholder:text-ink-muted" />
|
||||||
|
<button type="button" onClick={() => setMore((m) => !m)} aria-expanded={more} className={`md:hidden ${chip(false)}`}>
|
||||||
|
{more ? "Fewer filters" : "Filters"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={`${more ? "flex" : "hidden"} md:flex flex-wrap items-center gap-x-2 gap-y-1 w-full md:w-auto`}>
|
||||||
|
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Incident type">
|
||||||
|
{INCIDENT_TYPES.map((t) => (
|
||||||
|
<button key={t} type="button" aria-pressed={filter.types.includes(t)} className={chip(filter.types.includes(t))}
|
||||||
|
onClick={() => set({ types: toggle(filter.types, t) })}>
|
||||||
|
{TYPE_LABEL[t]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<select aria-label="Severity floor" className={select} value={filter.sevFloor ?? ""}
|
||||||
|
onChange={(e) => set({ sevFloor: (e.target.value || null) as Severity | null })}>
|
||||||
|
<option value="">Any severity</option>
|
||||||
|
{SEV_FLOORS.map((s) => <option key={s} value={s}>{SEVERITY_LABEL[s]}{s === "major" ? "" : "+"}</option>)}
|
||||||
|
</select>
|
||||||
|
<select aria-label="Age window" className={select} value={filter.ageH ?? ""}
|
||||||
|
onChange={(e) => set({ ageH: e.target.value ? Number(e.target.value) : null })}>
|
||||||
|
<option value="">Any age</option>
|
||||||
|
{AGE_WINDOWS_H.map((h) => <option key={h} value={h}>Last {h}h</option>)}
|
||||||
|
{filter.ageH && !(AGE_WINDOWS_H as readonly number[]).includes(filter.ageH) && (
|
||||||
|
<option value={filter.ageH}>Last {filter.ageH}h</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
{systems.length > 1 || sysValue ? (
|
||||||
|
<select aria-label="Radio system" className={select} value={sysValue}
|
||||||
|
onChange={(e) => set({ systems: e.target.value ? [e.target.value] : [] })}>
|
||||||
|
<option value="">All systems</option>
|
||||||
|
{filter.systems.length > 1 && <option value="" disabled>{filter.systems.length} systems</option>}
|
||||||
|
{systems.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{talkgroups.length > 0 || tgValue ? (
|
||||||
|
<select aria-label="Talkgroup" className={select} value={tgValue}
|
||||||
|
onChange={(e) => set({ talkgroups: e.target.value ? [e.target.value] : [] })}>
|
||||||
|
<option value="">All talkgroups</option>
|
||||||
|
{filter.talkgroups.length > 1 && <option value="" disabled>{filter.talkgroups.length} talkgroups</option>}
|
||||||
|
{talkgroups.map((t) => <option key={t.id} value={t.id}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
{tagList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-1" role="group" aria-label="Tags">
|
||||||
|
{tagList.map((t) => (
|
||||||
|
<button key={t} type="button" aria-pressed={filter.tags.includes(t)} className={chip(filter.tags.includes(t))}
|
||||||
|
onClick={() => set({ tags: toggle(filter.tags, t) })}>
|
||||||
|
#{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{summary}
|
||||||
|
{active && (
|
||||||
|
<button type="button" onClick={() => { committed.current = ""; setQ(""); onChange(EMPTY_FILTER); }} className={chip(false)}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live wall kiosk controls + reconnect banner (server-26#234). State lives in
|
||||||
|
* lib/useKiosk.ts; this file is presentation only.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { KioskBanner as Banner, KioskState } from "@/lib/useKiosk";
|
||||||
|
|
||||||
|
const btn = "px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap";
|
||||||
|
|
||||||
|
export function KioskControls({ kiosk, state, onToggleKiosk }: {
|
||||||
|
kiosk: boolean;
|
||||||
|
state: KioskState;
|
||||||
|
onToggleKiosk: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{state.canFullscreen && (
|
||||||
|
<button type="button" onClick={state.toggleFullscreen} aria-pressed={state.fullscreen} className={btn}
|
||||||
|
title={state.fullscreen ? "Leave full screen" : "Full screen"}>
|
||||||
|
{state.fullscreen ? "Exit full screen" : "Full screen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{kiosk && state.wake === "on" && (
|
||||||
|
<span className="text-ink-muted whitespace-nowrap" title="Screen Wake Lock held — this display will not sleep">Awake</span>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={onToggleKiosk} aria-pressed={kiosk} className={btn}
|
||||||
|
title={kiosk ? "Back to the normal app layout" : "Wall display: no nav bar, large type, screen kept awake"}>
|
||||||
|
{kiosk ? "Exit kiosk" : "Kiosk"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KioskBanner({ banner, onRetry }: { banner: Banner | null; onRetry: () => void }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
if (!banner?.retryAt) return;
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [banner?.retryAt]);
|
||||||
|
if (!banner) return null;
|
||||||
|
const secs = banner.retryAt ? Math.max(0, Math.ceil((banner.retryAt - now) / 1000)) : null;
|
||||||
|
return (
|
||||||
|
<div role="alert" className="shrink-0 flex items-center gap-3 px-3 py-1.5 border-b border-line bg-raised text-xs text-ink">
|
||||||
|
<span className="font-semibold">{banner.tone === "offline" ? "Offline" : "Reconnecting"}</span>
|
||||||
|
<span className="text-ink-2">{banner.message}{secs != null ? ` Retrying in ${secs}s.` : ""}</span>
|
||||||
|
{banner.tone === "error" && (
|
||||||
|
<button type="button" onClick={onRetry} className={`${btn} ml-auto`}>Retry now</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "NEW · last 15 min" (server-26#238). The rail is worst-first, so a fresh
|
||||||
|
* minor that is escalating sits under stale Majors; this lane is the
|
||||||
|
* recency view of the same FILTERED incidents, newest first, with why each
|
||||||
|
* one is here ("New", "↑ Major", "+1 stop", a priority keyword).
|
||||||
|
*
|
||||||
|
* Desktop: overlay at the map's top-left, right of the zoom stack and above
|
||||||
|
* MapView's rail (the rail starts at top-[9.5rem], so the lane is capped
|
||||||
|
* just short of it and its rows scroll). The Gate A notice is pinned below
|
||||||
|
* the rows, never inside the scroll area — same rule as the rail (#46).
|
||||||
|
* Mobile: a compact chip under MapView's centred "Live" chip (same top-3
|
||||||
|
* centre slot would cover it), right of the fit-all button; it expands.
|
||||||
|
* Hidden when there is nothing new.
|
||||||
|
*/
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { LaneEntry } from "@/lib/attention";
|
||||||
|
import { SEVERITY_COLORS, isKnownSeverity } from "@/lib/severity";
|
||||||
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
|
|
||||||
|
function ago(at: number, now: number): string {
|
||||||
|
const m = Math.max(0, Math.floor((now - at) / 60_000));
|
||||||
|
return m < 1 ? "now" : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ e, now, onSelect }: { e: LaneEntry; now: number; onSelect: (id: string) => void }) {
|
||||||
|
const color = isKnownSeverity(e.severity) ? SEVERITY_COLORS[e.severity] : "var(--ink-muted)";
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<span className="w-1 self-stretch rounded-full shrink-0" style={{ background: color }} aria-hidden="true" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ink">{e.title}</span>
|
||||||
|
<span className="shrink-0 px-1 rounded bg-accent/15 text-accent font-semibold">{e.reason}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-ink-muted">{ago(e.at, now)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const cls = "w-full flex items-center gap-1.5 px-1.5 py-1 rounded text-xs text-left";
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
{e.incidentId ? (
|
||||||
|
<button type="button" className={`${cls} hover:bg-raised`} onClick={() => onSelect(e.incidentId!)}
|
||||||
|
title={`${e.title} — ${e.reason}`}>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className={cls} title={`${e.title} — ${e.reason} (call not linked to an incident yet)`}>{body}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewLane({ entries, now, onSelect }: { entries: LaneEntry[]; now: number; onSelect: (id: string) => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!entries.length) return null;
|
||||||
|
const notice = <MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start px-1.5 pt-1" />;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section aria-label="New in the last 15 minutes"
|
||||||
|
className="absolute top-3 left-14 z-[1002] hidden md:flex flex-col w-56 max-h-[8.5rem] bg-surface/90 backdrop-blur-sm border border-accent/60 rounded-lg shadow pointer-events-auto">
|
||||||
|
<h2 className="shrink-0 px-2 pt-1.5 text-[10px] font-semibold uppercase tracking-wide text-accent">
|
||||||
|
New · last 15 min <span className="text-ink-muted font-normal">({entries.length})</span>
|
||||||
|
</h2>
|
||||||
|
<ul className="min-h-0 overflow-y-auto px-0.5">
|
||||||
|
{entries.map((e) => <Row key={e.key} e={e} now={now} onSelect={onSelect} />)}
|
||||||
|
</ul>
|
||||||
|
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="absolute top-[5.25rem] left-14 z-[1002] md:hidden flex flex-col items-start">
|
||||||
|
<button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)}
|
||||||
|
className="bg-surface/95 border border-accent/60 rounded-full px-3 py-1 text-xs text-accent font-semibold whitespace-nowrap shadow">
|
||||||
|
● {entries.length} new · 15 min {open ? "▴" : "▾"}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-1 w-64 max-w-[calc(100vw-4.5rem)] bg-surface/95 border border-line rounded-lg shadow p-0.5 flex flex-col">
|
||||||
|
<ul className="max-h-40 overflow-y-auto">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<Row key={e.key} e={e} now={now} onSelect={(id) => { setOpen(false); onSelect(id); }} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="shrink-0 border-t border-line pb-1">{notice}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truth strip (server-26#232) — replaces the heartbeat-driven '● Live' pill.
|
||||||
|
*
|
||||||
|
* Healthy: compact per-stage chips (Radio · Transcripts · AI · Feeds), each
|
||||||
|
* with its last-output age. Any core stage dry past the threshold, or any
|
||||||
|
* /health/ai tier degraded: a full-width amber bar that says what broke and
|
||||||
|
* since when — "DEGRADED — radio heard, not transcribed since 12:15".
|
||||||
|
* Theme tokens only (sev-moderate is the amber), so both themes follow.
|
||||||
|
*/
|
||||||
|
import type { PipelineHealth, PipelineStage } from "@/lib/usePipelineHealth";
|
||||||
|
|
||||||
|
function dotClass(s: PipelineStage["state"]): string {
|
||||||
|
if (s === "degraded") return "bg-sev-moderate";
|
||||||
|
if (s === "unknown") return "bg-ink-muted";
|
||||||
|
return "bg-ink-2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruthChips({ health }: { health: PipelineHealth }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap" role="status" aria-label="Pipeline health">
|
||||||
|
{health.stages.map((s) => (
|
||||||
|
<span key={s.key} title={s.detail}
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded border text-[11px] whitespace-nowrap ${
|
||||||
|
s.state === "degraded" ? "border-sev-moderate text-ink bg-sev-moderate/10" : "border-line text-ink-2"}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${dotClass(s.state)}`} aria-hidden="true" />
|
||||||
|
{s.label}
|
||||||
|
<span className="sr-only">: {s.state === "ok" ? "healthy" : s.state}, {s.detail}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TruthBar({ health }: { health: PipelineHealth }) {
|
||||||
|
if (!health.degraded || !health.message) return null;
|
||||||
|
return (
|
||||||
|
<div role="alert"
|
||||||
|
className="shrink-0 w-full px-3 py-1.5 border-b border-sev-moderate bg-sev-moderate/15 text-xs text-ink flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<b className="font-bold tracking-wide">DEGRADED</b>
|
||||||
|
<span>— {health.message}</span>
|
||||||
|
<span className="ml-auto text-ink-muted whitespace-nowrap">
|
||||||
|
stale after {health.staleMin} min · map shows last-known state
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chips for the wall bar, plus the bar itself directly below it when degraded. */
|
||||||
|
export function TruthStrip({ health, placement }: { health: PipelineHealth; placement: "chips" | "bar" }) {
|
||||||
|
return placement === "chips" ? <TruthChips health={health} /> : <TruthBar health={health} />;
|
||||||
|
}
|
||||||
@@ -14,7 +14,17 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { CameraFeed } from "@/components/CameraFeed";
|
import { CameraFeed } from "@/components/CameraFeed";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||||
|
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
||||||
|
import {
|
||||||
|
CONTEXT_KM, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, PRECIP_ECHO_DBZ, alertsAt, bboxAround, compass, contextFor, pathStops,
|
||||||
|
sampleRadarDbz, stopNumbers, wantsWind, wxStyle, wxTime,
|
||||||
|
type LatLon, type NearCamera,
|
||||||
|
} from "@/lib/liveWall";
|
||||||
|
import { useWeatherAlertsIn, useWind, type Polled } from "@/lib/useWeather";
|
||||||
|
import type { WeatherAlertsResponse } from "@/lib/types";
|
||||||
|
import { useOutagesIn, type OutagesState } from "@/lib/useOutages";
|
||||||
|
import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
|
||||||
|
|
||||||
function sevColor(s: string | null | undefined) {
|
function sevColor(s: string | null | undefined) {
|
||||||
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
||||||
@@ -22,8 +32,10 @@ function sevColor(s: string | null | undefined) {
|
|||||||
function sevLabel(s: string | null | undefined) {
|
function sevLabel(s: string | null | undefined) {
|
||||||
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
||||||
}
|
}
|
||||||
function elapsed(iso: string): string {
|
/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
|
||||||
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
|
function elapsed(iso: string, endIso?: string | null): string {
|
||||||
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||||
|
const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000));
|
||||||
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
||||||
}
|
}
|
||||||
function hhmm(iso: string): string {
|
function hhmm(iso: string): string {
|
||||||
@@ -31,13 +43,15 @@ function hhmm(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
|
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
|
||||||
|
// Spine rows rendered before "show all loaded" (server-26#239).
|
||||||
|
const SPINE_CAP = 30;
|
||||||
const SEC = "px-4 py-3 border-b border-line";
|
const SEC = "px-4 py-3 border-b border-line";
|
||||||
|
|
||||||
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
|
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
||||||
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
||||||
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[10px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
||||||
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -57,6 +71,241 @@ function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Power outages (server-26#184) ────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Why the outage answer is empty or unusable, in one line — or null when the
|
||||||
|
* data is fine to read. Shared by the region Feeds row and the incident panel.
|
||||||
|
*/
|
||||||
|
function OutageProblem({ state, where }: { state: OutagesState; where: string }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return <>checking…</>;
|
||||||
|
if (!data) return <>could not reach server{error ? ` (${error})` : ""}</>;
|
||||||
|
if (data.status === "none_enabled") {
|
||||||
|
return isAdmin
|
||||||
|
? <>no outage source enabled (<Link href="/admin#outages" className="underline">Admin → Outage providers</Link>)</>
|
||||||
|
: <>no outage source enabled — a platform admin can turn one on</>;
|
||||||
|
}
|
||||||
|
if (data.status === "no_source") return <>no licensed outage source covers {where}</>;
|
||||||
|
if (data.status === "error") {
|
||||||
|
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
|
||||||
|
return <>feed error{bad.length ? `: ${bad.map((p) => `${p.name} (${p.error})`).join("; ")}` : ""}</>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outageIsUsable(state: OutagesState): boolean {
|
||||||
|
return !!state.data && state.data.status === "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incident panel "OUTAGE" lines: the cross-match, with source credit and fetch time. */
|
||||||
|
function OutageContext({ inc, points }: { inc: IncidentRecord; points: LatLon[] }) {
|
||||||
|
// 1 km pad: county data is matched by the county bbox containing a point, so
|
||||||
|
// any county the incident touches intersects this box.
|
||||||
|
const box = useMemo(() => bboxAround(points, 1), [points]);
|
||||||
|
const state = useOutagesIn(box);
|
||||||
|
const hazard = isUtilityHazard(inc);
|
||||||
|
const matches = useMemo(
|
||||||
|
() => (state.data ? matchOutages(inc, points, state.data.areas, state.data.providers) : []),
|
||||||
|
[inc, points, state.data],
|
||||||
|
);
|
||||||
|
const staleHere = useMemo(() => (state.data ? staleCountiesAt(points, state.data.areas) : []), [points, state.data]);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
|
||||||
|
if (!points.length) return <li>{tag}—</li>;
|
||||||
|
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this location" /></li>;
|
||||||
|
const covering = state.data!.providers.filter((p) => p.covers_bbox);
|
||||||
|
const refreshNote = state.error ? " · could not refresh, showing the last answer" : "";
|
||||||
|
const feedErr = covering.find((p) => p.error);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hazard && (
|
||||||
|
<li>
|
||||||
|
<span className={`inline-block text-[10px] font-semibold px-1.5 py-0.5 rounded ${matches.length ? "bg-accent text-white" : "bg-raised text-ink-2"}`}>
|
||||||
|
{matches.length
|
||||||
|
? matches[0].kind === "point"
|
||||||
|
? `⚡ Utility-hazard call within ${OUTAGE_POINT_KM} km of a reported outage`
|
||||||
|
: "⚡ Utility-hazard call in a county with reported outages"
|
||||||
|
: "⚡ Utility-hazard call · no reported outage matched"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{matches.map((m) => (
|
||||||
|
<li key={m.area.id}>
|
||||||
|
{tag}{m.text}
|
||||||
|
{m.area.etr ? ` · est. restoration ${new Date(m.area.etr).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : ""}
|
||||||
|
<SourceCredit credit={m.feed} fallbackName={m.feed?.name ?? m.area.provider}
|
||||||
|
updated={m.feed ? `fetched ${fetchedLabel(m.feed.fetched_at) ?? "—"}${refreshNote}` : null} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!matches.length && (
|
||||||
|
<li>
|
||||||
|
{tag}
|
||||||
|
{feedErr
|
||||||
|
? `${feedErr.name} feed error${feedErr.fetched_at ? `, last good data ${fetchedLabel(feedErr.fetched_at)}` : ", no data yet"} — none matched`
|
||||||
|
: `none reported here by ${covering.map((p) => p.name).join(", ") || "the enabled source"}`}
|
||||||
|
{staleHere.length > 0 && ` (${staleHere.map(countyName).join(", ")}: stale upstream rows only, not shown as live)`}
|
||||||
|
{covering.map((p) => (
|
||||||
|
<SourceCredit key={p.id} credit={p} fallbackName={p.name}
|
||||||
|
updated={`fetched ${fetchedLabel(p.fetched_at) ?? "—"}${refreshNote}${p.coverage_note ? ` · ${p.coverage_note}` : ""}`} />
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region Feeds "OUTAGE" row: provider status for the wall's region. */
|
||||||
|
function OutageFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) {
|
||||||
|
const pts = useMemo(() => {
|
||||||
|
const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon);
|
||||||
|
return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon);
|
||||||
|
}, [incidents, nodes]);
|
||||||
|
// Counties are large; pad the region so a county just outside the pins still counts.
|
||||||
|
const box = useMemo(() => bboxAround(pts, 10), [pts]);
|
||||||
|
const state = useOutagesIn(box);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
|
||||||
|
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
|
||||||
|
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this region" /></li>;
|
||||||
|
const { areas, providers } = state.data!;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{providers.filter((p) => p.covers_bbox).map((p) => {
|
||||||
|
const mine = areas.filter((a) => a.provider === p.id);
|
||||||
|
const live = mine.filter((a) => !a.stale && a.customers_out > 0);
|
||||||
|
const out = live.reduce((t, a) => t + a.customers_out, 0);
|
||||||
|
const stale = mine.filter((a) => a.stale).length;
|
||||||
|
return (
|
||||||
|
<li key={p.id}>{tag}{p.name}: {live.length
|
||||||
|
? `${live.length} count${live.length !== 1 ? "ies" : "y"} with outages, ${out.toLocaleString()} meters/customers out`
|
||||||
|
: "no outages reported in region"}
|
||||||
|
{stale ? ` · ${stale} stale` : ""}{p.error ? " · feed error" : ""}{p.fetched_at ? ` · fetched ${fetchedLabel(p.fetched_at)}` : ""}
|
||||||
|
{state.error ? " · could not refresh" : ""} · Power Outages layer (map menu)</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Weather (server-26#237) ──────────────────────────────────────────────────
|
||||||
|
const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null };
|
||||||
|
const METAR_CREDIT: CameraCredit = { attribution: "NOAA Aviation Weather Center (METAR)", attribution_url: "https://aviationweather.gov/", disclaimer: null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the NWS answer is unusable, in one line (same wording as the map's
|
||||||
|
* banner) — or null when it is fine to read.
|
||||||
|
*/
|
||||||
|
function nwsProblem(state: Polled<WeatherAlertsResponse>): string | null {
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return "checking…";
|
||||||
|
if (!data) return `could not reach server${error ? ` (${error})` : ""}`;
|
||||||
|
if (data.error && !data.fetched_at) return "NWS feed error, no data yet";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function nwsFreshness(state: Polled<WeatherAlertsResponse>): string {
|
||||||
|
const d = state.data!;
|
||||||
|
return `fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · NWS feed error, showing the last good data" : ""}${state.error ? " · could not refresh" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incident panel "NWS" lines: the warning polygons the incident's head point sits inside. */
|
||||||
|
function WeatherWarningContext({ head }: { head: LatLon | null }) {
|
||||||
|
const box = useMemo(() => (head ? bboxAround([head], 1) : null), [head]);
|
||||||
|
const state = useWeatherAlertsIn(box);
|
||||||
|
const inside = useMemo(() => (head && state.data ? alertsAt(head, state.data.alerts) : []), [head, state.data]);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
|
||||||
|
if (!head) return <li>{tag}—</li>;
|
||||||
|
const problem = nwsProblem(state);
|
||||||
|
if (problem) return <li>{tag}{problem}</li>;
|
||||||
|
const zoneOnly = state.data!.zone_only.length;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
{tag}
|
||||||
|
{inside.length ? (
|
||||||
|
<span className="inline-flex flex-wrap gap-1 align-middle">
|
||||||
|
{inside.map((a) => {
|
||||||
|
const st = wxStyle(a.event);
|
||||||
|
return (
|
||||||
|
<span key={a.id} title={a.headline ?? a.event}
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-raised text-ink"
|
||||||
|
style={{ borderLeft: `3px ${st.dashArray ? "dashed" : "solid"} ${st.color}` }}>
|
||||||
|
inside {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>not inside any active NWS alert area</>
|
||||||
|
)}
|
||||||
|
{zoneOnly > 0 && <span className="text-ink-muted"> · {zoneOnly} zone-based alert{zoneOnly !== 1 ? "s" : ""} in the served area can't be placed (Weather Warnings layer lists them)</span>}
|
||||||
|
<SourceCredit credit={NWS_CREDIT} updated={nwsFreshness(state)} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arrow pointing where the wind blows TO (METAR gives where it comes FROM). */
|
||||||
|
function WindArrow({ fromDeg }: { fromDeg: number }) {
|
||||||
|
return (
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" className="inline-block align-[-2px] mr-1" aria-hidden>
|
||||||
|
<g transform={`rotate(${(fromDeg + 180) % 360} 7 7)`}>
|
||||||
|
<line x1="7" y1="12" x2="7" y2="2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
<polyline points="3.8,5.6 7,2.2 10.2,5.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incident panel "WIND" line for fire/hazmat incidents: nearest METAR station. */
|
||||||
|
function WindContext({ head }: { head: LatLon | null }) {
|
||||||
|
const state = useWind(head);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">WIND</span>;
|
||||||
|
if (!head) return <li>{tag}— (no geocoded location)</li>;
|
||||||
|
if (!state.loaded) return <li>{tag}checking…</li>;
|
||||||
|
const d = state.data;
|
||||||
|
if (!d) return <li>{tag}could not reach server{state.error ? ` (${state.error})` : ""}</li>;
|
||||||
|
if (!d.available || !d.wind) return <li>{tag}{d.reason ?? "no wind report"}</li>;
|
||||||
|
const w = d.wind;
|
||||||
|
const calm = w.wind_kt === 0;
|
||||||
|
const dir = calm ? "calm" : w.variable || w.wind_dir_deg == null ? "variable" : `from ${compass(w.wind_dir_deg)} (${Math.round(w.wind_dir_deg)}°)`;
|
||||||
|
const obs = w.obs_time ? new Date(w.obs_time) : null;
|
||||||
|
const obsLabel = obs && !isNaN(obs.getTime()) ? obs.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
{tag}
|
||||||
|
{!calm && !w.variable && w.wind_dir_deg != null && <WindArrow fromDeg={w.wind_dir_deg} />}
|
||||||
|
<b>{calm ? "Calm" : `${Math.round(w.wind_kt)} kt`}</b>
|
||||||
|
{w.gust_kt != null && !calm ? <b> G {Math.round(w.gust_kt)} kt</b> : null}
|
||||||
|
{calm ? "" : ` ${dir}`}
|
||||||
|
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
|
||||||
|
{obsLabel ? ` · observed ${obsLabel}` : ""}
|
||||||
|
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
|
||||||
|
<SourceCredit credit={METAR_CREDIT}
|
||||||
|
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region Feeds "NWS" row: active alerts over the wall's region, with fetch time or the error. */
|
||||||
|
function WeatherFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) {
|
||||||
|
const pts = useMemo(() => {
|
||||||
|
const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon);
|
||||||
|
return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon);
|
||||||
|
}, [incidents, nodes]);
|
||||||
|
const box = useMemo(() => bboxAround(pts, 10), [pts]);
|
||||||
|
const state = useWeatherAlertsIn(box);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
|
||||||
|
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
|
||||||
|
const problem = nwsProblem(state);
|
||||||
|
if (problem) return <li>{tag}{problem}</li>;
|
||||||
|
const d = state.data!;
|
||||||
|
const warnings = d.alerts.filter((a) => wxStyle(a.event).kind === "warning").length;
|
||||||
|
return (
|
||||||
|
<li>{tag}{d.alerts.length
|
||||||
|
? `${d.alerts.length} active alert${d.alerts.length !== 1 ? "s" : ""} in region${warnings ? ` (${warnings} warning${warnings !== 1 ? "s" : ""})` : ""}`
|
||||||
|
: "no active alert areas in region"}
|
||||||
|
{d.zone_only.length ? ` · ${d.zone_only.length} zone-based in served area` : ""} · {nwsFreshness(state)} · Weather Warnings layer (map menu)</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Level 1: region ──────────────────────────────────────────────────────────
|
// ── Level 1: region ──────────────────────────────────────────────────────────
|
||||||
export function RegionPanel({
|
export function RegionPanel({
|
||||||
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
||||||
@@ -101,7 +350,7 @@ export function RegionPanel({
|
|||||||
{wallCameras.map(({ cam, km }) => (
|
{wallCameras.map(({ cam, km }) => (
|
||||||
<div key={cam.id} className="relative">
|
<div key={cam.id} className="relative">
|
||||||
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
||||||
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>}
|
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[10px] px-1 rounded-sm">PINNED</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +368,9 @@ export function RegionPanel({
|
|||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
|
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
|
||||||
))}
|
))}
|
||||||
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
|
<OutageFeedRow incidents={incidents} nodes={nodes} />
|
||||||
|
<WeatherFeedRow incidents={incidents} nodes={nodes} />
|
||||||
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu, IEM n0q, with valid time); sampled per incident</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
@@ -173,16 +424,37 @@ export function IncidentPanel({
|
|||||||
onOpenCamera: (c: Camera) => void;
|
onOpenCamera: (c: Camera) => void;
|
||||||
}) {
|
}) {
|
||||||
const color = sevColor(inc.severity);
|
const color = sevColor(inc.severity);
|
||||||
|
// A resolved incident opened from an #inc= link (server-26#275): nothing in
|
||||||
|
// the panel may read as still running. Its duration freezes at resolved_at
|
||||||
|
// (the correlator writes it; the frontend type doesn't declare it yet), and
|
||||||
|
// the live camera / context feeds are labelled as "now", not incident time.
|
||||||
|
const resolved = inc.status === "resolved";
|
||||||
|
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
|
||||||
|
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
|
||||||
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
||||||
const radar = useRadarDbz(points.at(-1) ?? null);
|
const head = points.at(-1) ?? null;
|
||||||
// Stop numbers share the index with the map path: geocoded calls, oldest first.
|
const radar = useRadarDbz(head);
|
||||||
const stopOf = useMemo(() => {
|
// Stop numbers come from the same pathStops() the map draws from, so the
|
||||||
const m = new Map<string, number>();
|
// spine numbers exactly what the map numbers (server-26#236): only movement
|
||||||
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at))
|
// incidents get numbers, and a ghosted (impossible-speed) stop gets none.
|
||||||
.forEach((c, i) => m.set(c.call_id, i + 1));
|
const path = useMemo(() => pathStops(inc, calls), [inc, calls]);
|
||||||
return m;
|
const stopOf = useMemo(() => stopNumbers(path), [path]);
|
||||||
}, [calls]);
|
const ghostIds = useMemo(() => new Set(path.stops.filter((s) => s.ghost).map((s) => s.call.call_id)), [path]);
|
||||||
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
|
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
|
||||||
|
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
|
||||||
|
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
|
||||||
|
const auth = useAuth();
|
||||||
|
const audioOk = canPlayAudio(auth);
|
||||||
|
const playingCallId = useAudio((s) => s.playingCallId);
|
||||||
|
const queueLen = useAudio((s) => s.queue.length);
|
||||||
|
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
|
||||||
|
// Truncation is said out loud, not silent (server-26#239): the spine shows
|
||||||
|
// what this page has loaded, which can be less than the incident holds.
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
|
||||||
|
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
|
||||||
|
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
|
||||||
|
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
|
||||||
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
|
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
|
||||||
const cleared = inc.units_cleared ?? [];
|
const cleared = inc.units_cleared ?? [];
|
||||||
|
|
||||||
@@ -192,14 +464,24 @@ export function IncidentPanel({
|
|||||||
<div className="flex items-center gap-2 text-[11px] mb-1">
|
<div className="flex items-center gap-2 text-[11px] mb-1">
|
||||||
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
||||||
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
||||||
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>}
|
{resolved ? <span className="text-ink-muted">resolved</span>
|
||||||
|
: onAir ? <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold">ON AIR</span>
|
||||||
|
: <span className="text-ink-muted">active</span>}
|
||||||
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
||||||
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
||||||
{inc.location && <span>{inc.location}</span>}
|
{inc.location && <span>{inc.location}</span>}
|
||||||
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
{!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||||
|
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
|
||||||
|
: null}
|
||||||
<span>{inc.call_ids?.length ?? 0} calls</span>
|
<span>{inc.call_ids?.length ?? 0} calls</span>
|
||||||
|
{path.mayBeMerged && (
|
||||||
|
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
|
||||||
|
title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
|
||||||
|
may be merged · {path.lengthKm.toFixed(0)} km
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
|
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +493,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
||||||
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
|
||||||
{near.length ? (
|
{near.length ? (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{near.map(({ cam, km }) => (
|
{near.map(({ cam, km }) => (
|
||||||
@@ -229,6 +512,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Situational context · fused layers</h3>
|
<h3 className={H3}>Situational context · fused layers</h3>
|
||||||
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
|
||||||
<ul className="space-y-1 text-xs text-ink-2">
|
<ul className="space-y-1 text-xs text-ink-2">
|
||||||
{ctx.aircraft.map(({ a, km }) => (
|
{ctx.aircraft.map(({ a, km }) => (
|
||||||
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
||||||
@@ -250,8 +534,12 @@ export function IncidentPanel({
|
|||||||
<li>
|
<li>
|
||||||
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
|
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
|
||||||
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
|
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
|
||||||
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`}
|
: radar.dbz < PRECIP_ECHO_DBZ ? "no precipitation echo within ~5 km"
|
||||||
|
: `up to ~${Math.round(radar.dbz)} dBZ within ~5 km (approx., read from the radar layer's n0q tile)`}
|
||||||
</li>
|
</li>
|
||||||
|
<WeatherWarningContext head={head} />
|
||||||
|
{wantsWind(inc) && <WindContext head={head} />}
|
||||||
|
<OutageContext inc={inc} points={points} />
|
||||||
</ul>
|
</ul>
|
||||||
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
||||||
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
||||||
@@ -260,7 +548,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={H3}>On scene</h3>
|
<h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
|
||||||
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -270,23 +558,61 @@ export function IncidentPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Call spine · newest first</h3>
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
|
||||||
|
{audioOk && playable.length > 0 && (
|
||||||
|
playingHere ? (
|
||||||
|
<button type="button" onClick={stopAudio}
|
||||||
|
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
|
||||||
|
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => playIncident(playable)}
|
||||||
|
title="Play this incident's loaded calls back to back, oldest first"
|
||||||
|
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
|
||||||
|
▶ Play incident · {playable.length}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
|
||||||
|
<p className="text-[10px] text-ink-muted mb-1.5">
|
||||||
|
{showAll || spine.length <= SPINE_CAP
|
||||||
|
? `Showing all ${spine.length} loaded of ${totalCalls}`
|
||||||
|
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
|
||||||
|
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
|
||||||
|
{spine.length > SPINE_CAP && (
|
||||||
|
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
|
||||||
|
{showAll ? "show latest 30" : "show all loaded"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{spine.length ? (
|
{spine.length ? (
|
||||||
<ol className="space-y-2">
|
<ol className="space-y-2">
|
||||||
{spine.slice(0, 30).map((c) => {
|
{shown.map((c) => {
|
||||||
const stop = stopOf.get(c.call_id);
|
const stop = stopOf.get(c.call_id);
|
||||||
|
const ghost = ghostIds.has(c.call_id);
|
||||||
const text = c.transcript_corrected || c.transcript;
|
const text = c.transcript_corrected || c.transcript;
|
||||||
|
const isPlaying = c.call_id === playingCallId;
|
||||||
return (
|
return (
|
||||||
<li key={c.call_id} className="flex gap-2 text-xs">
|
<li key={c.call_id}
|
||||||
|
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
|
||||||
|
aria-current={isPlaying ? "true" : undefined}>
|
||||||
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
||||||
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
|
title={ghost ? "Geocoded too far from the previous stop to reach in time — left off the path" : undefined}
|
||||||
<div className="min-w-0">
|
style={stop ? { background: color, color: "var(--page)" }
|
||||||
|
: ghost ? { border: "1.5px dashed var(--ink-muted)", color: "var(--ink-muted)" }
|
||||||
|
: { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? (ghost ? "?" : "·")}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex gap-1.5 items-center">
|
<div className="flex gap-1.5 items-center">
|
||||||
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
||||||
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
||||||
|
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
|
||||||
</div>
|
</div>
|
||||||
|
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Power Outages" map overlay (server-26#184). County-level areas from the
|
||||||
|
* admin-enabled, licence-gated outage providers, drawn at the county's label
|
||||||
|
* point and sized by meters out. The marker is a county, not an outage
|
||||||
|
* location, and every popup says so.
|
||||||
|
*
|
||||||
|
* Never a silently empty layer: while the overlay is on, a status chip always
|
||||||
|
* says what the layer is showing — loading, N counties, nothing reported in
|
||||||
|
* view (with the source's coverage caveat), no provider enabled, no licensed
|
||||||
|
* source covering this area, or a feed error.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Marker, Popup, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import type { OutageArea, OutageProviderFeed } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
import { useMapOutages, type OutagesState } from "@/lib/useOutages";
|
||||||
|
import { countyName } from "@/lib/outageMatch";
|
||||||
|
|
||||||
|
/** The outage glyph (lightning bolt in a ring), shared with the map legend. */
|
||||||
|
export function outageGlyphSvg(size: number, stale = false): string {
|
||||||
|
const ring = stale
|
||||||
|
? `<circle cx="12" cy="12" r="10.5" fill="#1f2937" fill-opacity="0.55" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="3 2"/>`
|
||||||
|
: `<circle cx="12" cy="12" r="10.5" fill="#1f2937" stroke="#facc15" stroke-width="1.75"/>`;
|
||||||
|
const bolt = `<path d="M13.2 4.5 7.5 13.2h4l-1 6.3 5.9-9h-4.1z" fill="${stale ? "#9ca3af" : "#facc15"}"/>`;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24">${ring}${bolt}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marker diameter in px: grows with log10(meters out), 16–34. */
|
||||||
|
function sizeFor(out: number): number {
|
||||||
|
return Math.round(Math.min(34, 16 + 4 * Math.log10(Math.max(1, out))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function outageIcon(a: OutageArea): L.DivIcon {
|
||||||
|
const stale = a.stale;
|
||||||
|
const out = stale ? a.stale_customers_out : a.customers_out;
|
||||||
|
const s = sizeFor(out);
|
||||||
|
const label = out >= 1000 ? `${(out / 1000).toFixed(out >= 10_000 ? 0 : 1)}k` : String(out);
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="position:relative;width:${s}px;height:${s}px;opacity:${stale ? 0.6 : 1}">${outageGlyphSvg(s, stale)}`
|
||||||
|
+ `<span style="position:absolute;top:-7px;left:${s - 6}px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid ${stale ? "#9ca3af" : "#facc15"};font:700 9px/12px sans-serif;white-space:nowrap">${label}</span></div>`,
|
||||||
|
iconSize: [s, s],
|
||||||
|
iconAnchor: [s / 2, s / 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(iso: string | null): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutagePopupBody({ a, feed }: { a: OutageArea; feed?: OutageProviderFeed }) {
|
||||||
|
const since = fmt(a.since);
|
||||||
|
const etr = fmt(a.etr);
|
||||||
|
const updated = fmt(a.updated_at);
|
||||||
|
const pct = a.customers_served ? (100 * a.customers_out) / a.customers_served : null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="font-semibold text-sm">{countyName(a)}</div>
|
||||||
|
{a.stale ? (
|
||||||
|
<div className="text-ink-muted">
|
||||||
|
Stale upstream rows only: {a.stale_customers_out.toLocaleString()} meters on events whose own restoration
|
||||||
|
estimate passed more than 24 h ago. The source still lists them; they are not shown as live outages.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<b>{a.customers_out.toLocaleString()}</b> meters/customers out (utility-reported)
|
||||||
|
{pct != null ? ` · ${pct < 0.1 ? "<0.1" : pct.toFixed(1)}% of ${a.customers_served!.toLocaleString()} served` : " · % served not known"}
|
||||||
|
{a.stale_customers_out > 0 && <span className="text-ink-muted"> · plus {a.stale_customers_out.toLocaleString()} on stale rows</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(since || etr) && (
|
||||||
|
<div>{since ? `Out since ${since}` : ""}{since && etr ? " · " : ""}{etr ? `Est. restoration ${etr}` : ""}</div>
|
||||||
|
)}
|
||||||
|
{!etr && !a.stale && <div className="text-ink-muted">No restoration estimate given.</div>}
|
||||||
|
{a.utilities.length > 0 && (
|
||||||
|
<ul className="text-ink-2">
|
||||||
|
{a.utilities.map((u, i) => (
|
||||||
|
<li key={u.eia_id ?? i}>
|
||||||
|
{u.name ?? (u.eia_id ? `EIA ${u.eia_id}` : "Utility")}: {u.customers_out.toLocaleString()} out
|
||||||
|
{u.stale_customers_out ? ` (+${u.stale_customers_out.toLocaleString()} stale)` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-ink-muted">County-level count — the marker is the county's centre, not where the outage is.</div>
|
||||||
|
{feed?.coverage_note && <div className="text-[10px] text-ink-muted">{feed.coverage_note}</div>}
|
||||||
|
<SourceCredit credit={feed} fallbackName={feed?.name ?? a.provider}
|
||||||
|
updated={[updated && `utility data ${updated}`, feed && `fetched ${fetchedLabel(feed.fetched_at) ?? "—"}`].filter(Boolean).join(" · ") || null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layer's always-on status chip. Returns the message, or null while there's nothing to say yet. */
|
||||||
|
function statusFor(state: OutagesState, isAdmin: boolean, liveCount: number, staleCount: number): { text: string; adminLink?: boolean; problem: boolean } {
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return { text: "Power outages: loading…", problem: false };
|
||||||
|
if (error && !data) return { text: "Power outages: could not reach server", problem: true };
|
||||||
|
if (!data) return { text: "Power outages: no data", problem: true };
|
||||||
|
const stale = error ? " (could not refresh — showing the last answer)" : "";
|
||||||
|
switch (data.status) {
|
||||||
|
case "none_enabled":
|
||||||
|
return isAdmin
|
||||||
|
? { text: "Power outages: no outage source is enabled —", adminLink: true, problem: true }
|
||||||
|
: { text: "Power outages: no outage source is enabled. A platform admin can turn one on.", problem: true };
|
||||||
|
case "no_source":
|
||||||
|
return { text: `Power outages: ${data.message ?? "No licensed outage source covers this area."}`, problem: true };
|
||||||
|
case "error": {
|
||||||
|
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
|
||||||
|
return { text: `Power outages: ${bad.map((p) => `${p.name} feed error (${p.error})`).join("; ") || data.message || "feed error"}`, problem: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const covering = data.providers.filter((p) => p.covers_bbox);
|
||||||
|
const names = covering.map((p) => p.name).join(", ");
|
||||||
|
const bad = covering.find((p) => p.error);
|
||||||
|
if (bad) {
|
||||||
|
return {
|
||||||
|
text: `Power outages: ${bad.name} feed error, ${bad.fetched_at ? `showing data fetched ${fetchedLabel(bad.fetched_at)}` : "no data yet"}${stale}`,
|
||||||
|
problem: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (covering.some((p) => p.truncated)) return { text: "Power outages: too many areas in view — zoom in", problem: true };
|
||||||
|
const fetched = covering.map((p) => fetchedLabel(p.fetched_at)).find(Boolean);
|
||||||
|
const when = fetched ? ` · fetched ${fetched}` : "";
|
||||||
|
if (!liveCount) {
|
||||||
|
const note = covering.map((p) => p.coverage_note).find(Boolean);
|
||||||
|
return {
|
||||||
|
text: `Power outages: none reported in view by ${names || "the enabled source"}${staleCount ? ` (${staleCount} stale count${staleCount !== 1 ? "ies" : "y"} dimmed)` : ""}${when}.${note ? ` ${note}` : ""}${stale}`,
|
||||||
|
problem: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: `Power outages: ${liveCount} count${liveCount !== 1 ? "ies" : "y"} with outages · ${names}${when}${staleCount ? ` · ${staleCount} stale dimmed` : ""}${stale}`,
|
||||||
|
problem: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutageStatus({ map, state, liveCount, staleCount }: { map: L.Map; state: OutagesState; liveCount: number; staleCount: number }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
||||||
|
});
|
||||||
|
const s = statusFor(state, isAdmin, liveCount, staleCount);
|
||||||
|
return createPortal(
|
||||||
|
<div ref={ref} role="status"
|
||||||
|
className={`absolute bottom-[3.75rem] left-3 z-[1001] max-w-[min(28rem,calc(100%-1.5rem))] bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 ${s.problem ? "border-accent" : "border-line"}`}>
|
||||||
|
{s.text}
|
||||||
|
{s.adminLink && <> <Link href="/admin#outages" className="underline">Admin → Outage providers</Link></>}
|
||||||
|
</div>,
|
||||||
|
map.getContainer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutageLayer({ shown }: { shown: boolean }) {
|
||||||
|
const map = useMap();
|
||||||
|
const state = useMapOutages(map, shown);
|
||||||
|
const areas = useMemo(() => state.data?.areas ?? [], [state.data]);
|
||||||
|
const feeds = useMemo(() => new Map((state.data?.providers ?? []).map((p) => [p.id, p])), [state.data]);
|
||||||
|
const drawable = useMemo(() => areas.filter((a) => a.lat != null && a.lon != null), [areas]);
|
||||||
|
// One icon per area per response: a fresh divIcon each render would make
|
||||||
|
// Leaflet rebuild every marker's DOM whenever MapView re-renders.
|
||||||
|
const icons = useMemo(() => new Map(drawable.map((a) => [a.id, outageIcon(a)])), [drawable]);
|
||||||
|
const liveCount = areas.filter((a) => !a.stale && a.customers_out > 0).length;
|
||||||
|
const staleCount = areas.filter((a) => a.stale).length;
|
||||||
|
if (!shown) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OutageStatus map={map} state={state} liveCount={liveCount} staleCount={staleCount} />
|
||||||
|
{drawable.map((a) => (
|
||||||
|
<Marker key={a.id} position={[a.lat!, a.lon!]} icon={icons.get(a.id)!} zIndexOffset={a.stale ? -100 : 100}
|
||||||
|
title={`${countyName(a)}: ${a.stale ? "stale rows only" : `${a.customers_out.toLocaleString()} out`}`}>
|
||||||
|
<Popup minWidth={240} maxWidth={320}>
|
||||||
|
<OutagePopupBody a={a} feed={feeds.get(a.provider)} />
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ interface EmptyStateProps {
|
|||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
|
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-ink-muted">` scattered across pages. */
|
||||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
|
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ function InfoGlyph({ className }: { className?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LEAD = "Machine-generated and unverified";
|
const LEAD = "Machine-generated and unverified";
|
||||||
|
const SHORT_BODY = "automated output, may contain errors.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The short notice as plain text, for surfaces that cannot render this
|
||||||
|
* component — e.g. a browser notification body (server-26#238). Same copy
|
||||||
|
* rules as above; it is the "popup"/"inline" wording verbatim.
|
||||||
|
*/
|
||||||
|
export const MACHINE_OUTPUT_NOTICE_TEXT = `${LEAD} — ${SHORT_BODY}`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: Variant;
|
variant?: Variant;
|
||||||
@@ -62,7 +70,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
|
|||||||
const body =
|
const body =
|
||||||
detail ??
|
detail ??
|
||||||
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
|
"transcripts, summaries and extracted details are automated output and may contain errors. Check the recording before acting on them.";
|
||||||
const shortBody = detail ?? "automated output, may contain errors.";
|
const shortBody = detail ?? SHORT_BODY;
|
||||||
|
|
||||||
if (variant === "popup") {
|
if (variant === "popup") {
|
||||||
// Leaflet popups render on a white wrapper regardless of theme, so this
|
// Leaflet popups render on a white wrapper regardless of theme, so this
|
||||||
@@ -70,7 +78,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
role="note"
|
role="note"
|
||||||
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
className={["text-[10px] leading-snug text-ink-muted mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* Attention cues for the live wall (server-26#238): what changed between two
|
||||||
|
* snapshots that a person should look at, the "NEW · last 15 min" lane, and
|
||||||
|
* which pins pulse.
|
||||||
|
*
|
||||||
|
* `diffAttention` is pure: (previous state, snapshot) → (next state, events).
|
||||||
|
* The first snapshot is a BASELINE and emits nothing, so opening the page
|
||||||
|
* never fires a cue for every incident already open. Calls baseline
|
||||||
|
* separately, on the first non-empty call snapshot, because the incident and
|
||||||
|
* call subscriptions land independently.
|
||||||
|
*
|
||||||
|
* The diff runs over ALL active incidents, not the filtered list: diffing the
|
||||||
|
* filtered list would make widening a filter look like a burst of "new"
|
||||||
|
* Majors. Visibility is applied when an event is emitted (`isVisibleEvent`),
|
||||||
|
* against the filter in force at that moment — so a cue or notification
|
||||||
|
* never fires for an incident filtered out of view.
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { CallRecord, IncidentRecord } from "@/lib/types";
|
||||||
|
import { SEVERITY_LABEL, isKnownSeverity, severityRank } from "@/lib/severity";
|
||||||
|
import { groupCallsByIncident, pathStops } from "@/lib/liveWall";
|
||||||
|
|
||||||
|
/** How long something counts as "new" on the lane. */
|
||||||
|
export const LANE_WINDOW_MS = 15 * 60_000;
|
||||||
|
/** How long a new / changed incident's pin pulses. */
|
||||||
|
export const PULSE_MS = 2 * 60_000;
|
||||||
|
export const DEFAULT_KEYWORDS: readonly string[] = ["MAYDAY", "10-13", "shots fired", "officer down"];
|
||||||
|
const MAJOR = 3;
|
||||||
|
/** A stop on an incident with no known stops counts as new only if it started after (first seen − this). */
|
||||||
|
const STOP_GRACE_MS = 2 * 60_000;
|
||||||
|
/** Forget incidents gone from the active list this long (state stays bounded on an all-day wall). */
|
||||||
|
const FORGET_MS = 6 * 3600_000;
|
||||||
|
/** Checked-call memory: anything older than the lane window is never cued anyway. */
|
||||||
|
const CALL_MEMORY_MS = LANE_WINDOW_MS + 30 * 60_000;
|
||||||
|
const MAX_LANE = 12;
|
||||||
|
const MAX_LOG = 200;
|
||||||
|
|
||||||
|
export type AttentionKind = "major" | "escalated" | "stop" | "keyword";
|
||||||
|
|
||||||
|
export interface AttentionEvent {
|
||||||
|
/** Stable key (also the notification tag's suffix). */
|
||||||
|
id: string;
|
||||||
|
kind: AttentionKind;
|
||||||
|
/** Incidents this concerns; empty = a call not linked to any incident yet. */
|
||||||
|
incidentIds: string[];
|
||||||
|
callId: string | null;
|
||||||
|
at: number;
|
||||||
|
/** Short reason chip, e.g. "Major", "↑ Moderate", "+1 stop", "MAYDAY". */
|
||||||
|
label: string;
|
||||||
|
/** Incident title, or the talkgroup for an unlinked call. */
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncState {
|
||||||
|
rank: number;
|
||||||
|
/** When this incident first counted as new: its start for the baseline, else when the wall first saw it. */
|
||||||
|
seenAt: number;
|
||||||
|
/** When the wall started tracking it (stop-grace floor). */
|
||||||
|
knownSince: number;
|
||||||
|
lastSeen: number;
|
||||||
|
stopIds: Set<string>;
|
||||||
|
newestStopAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttentionState {
|
||||||
|
baselineAt: number;
|
||||||
|
callsBaselined: boolean;
|
||||||
|
incidents: Map<string, IncState>;
|
||||||
|
/** call_id → started_at ms, for calls whose transcript has been keyword-checked. */
|
||||||
|
checkedCalls: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = (iso: string | null | undefined): number => {
|
||||||
|
const t = iso ? Date.parse(iso) : NaN;
|
||||||
|
return Number.isNaN(t) ? 0 : t;
|
||||||
|
};
|
||||||
|
|
||||||
|
function callIncidentIds(c: CallRecord): string[] {
|
||||||
|
return c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transcriptOf(c: CallRecord): string {
|
||||||
|
return c.transcript_corrected || c.transcript || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non-ghost geocoded stops of a movement incident (pursuit path), oldest first. Empty otherwise. */
|
||||||
|
function movementStops(inc: IncidentRecord, calls: CallRecord[] | undefined): { id: string; at: number }[] {
|
||||||
|
const path = pathStops(inc, calls);
|
||||||
|
if (!path.movement) return [];
|
||||||
|
return path.stops.filter((s) => !s.ghost).map((s) => ({ id: s.call.call_id, at: ms(s.call.started_at) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRe(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configured keywords found in `text`, case-insensitive, whole-word: "10-13"
|
||||||
|
* does not fire on "210-135". Spaces match any whitespace and a hyphen may be
|
||||||
|
* a space or absent ("10 13", "1013"), since transcription renders codes both ways.
|
||||||
|
*/
|
||||||
|
export function matchKeywords(text: string, keywords: readonly string[]): string[] {
|
||||||
|
if (!text) return [];
|
||||||
|
const t = text.toLowerCase();
|
||||||
|
const hits: string[] = [];
|
||||||
|
for (const k of keywords) {
|
||||||
|
const kw = k.trim().toLowerCase();
|
||||||
|
if (!kw) continue;
|
||||||
|
const body = kw.split(/\s+/).map((w) => escapeRe(w).replace(/-/g, "[-\\s]?")).join("\\s+");
|
||||||
|
if (new RegExp(`(^|[^a-z0-9])${body}($|[^a-z0-9])`).test(t)) hits.push(k.trim());
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sevLabel(sev: string | null | undefined): string {
|
||||||
|
return isKnownSeverity(sev) ? SEVERITY_LABEL[sev] : "Unrated";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One snapshot step. `prev === null` → baseline (no events). Never mutates `prev`.
|
||||||
|
* `incidents` = every active incident (unfiltered); `calls` = every call the
|
||||||
|
* wall has loaded (recent window + the selected incident's own calls).
|
||||||
|
*/
|
||||||
|
export function diffAttention(
|
||||||
|
prev: AttentionState | null,
|
||||||
|
incidents: IncidentRecord[],
|
||||||
|
calls: CallRecord[],
|
||||||
|
keywords: readonly string[],
|
||||||
|
now: number = Date.now(),
|
||||||
|
): { state: AttentionState; events: AttentionEvent[] } {
|
||||||
|
const byInc = groupCallsByIncident(calls);
|
||||||
|
const events: AttentionEvent[] = [];
|
||||||
|
|
||||||
|
if (!prev) {
|
||||||
|
const map = new Map<string, IncState>();
|
||||||
|
for (const inc of incidents) {
|
||||||
|
const stops = movementStops(inc, byInc.get(inc.incident_id));
|
||||||
|
map.set(inc.incident_id, {
|
||||||
|
rank: severityRank(inc.severity),
|
||||||
|
seenAt: ms(inc.started_at) || now,
|
||||||
|
knownSince: now,
|
||||||
|
lastSeen: now,
|
||||||
|
stopIds: new Set(stops.map((s) => s.id)),
|
||||||
|
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const checked = new Map<string, number>();
|
||||||
|
for (const c of calls) if (transcriptOf(c)) checked.set(c.call_id, ms(c.started_at));
|
||||||
|
return { state: { baselineAt: now, callsBaselined: calls.length > 0, incidents: map, checkedCalls: checked }, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first non-empty call snapshot is the calls' own baseline: record what
|
||||||
|
// is there, and re-seed every known incident's stops from it, silently.
|
||||||
|
const baselineCalls = !prev.callsBaselined && calls.length > 0;
|
||||||
|
const map = new Map<string, IncState>();
|
||||||
|
const present = new Set<string>();
|
||||||
|
|
||||||
|
for (const inc of incidents) {
|
||||||
|
const id = inc.incident_id;
|
||||||
|
present.add(id);
|
||||||
|
const rank = severityRank(inc.severity);
|
||||||
|
const stops = movementStops(inc, byInc.get(id));
|
||||||
|
const p = prev.incidents.get(id);
|
||||||
|
const title = inc.title || "Untitled incident";
|
||||||
|
|
||||||
|
if (!p) {
|
||||||
|
map.set(id, {
|
||||||
|
rank, seenAt: now, knownSince: now, lastSeen: now,
|
||||||
|
stopIds: new Set(stops.map((s) => s.id)),
|
||||||
|
newestStopAt: stops.length ? Math.max(...stops.map((s) => s.at)) : null,
|
||||||
|
});
|
||||||
|
if (rank === MAJOR) events.push({ id: `major:${id}`, kind: "major", incidentIds: [id], callId: null, at: now, label: "Major", title });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const st: IncState = { ...p, rank, lastSeen: now, stopIds: new Set(p.stopIds) };
|
||||||
|
// An unrated incident getting its first rating is classification, not
|
||||||
|
// escalation — unless that first rating is Major.
|
||||||
|
if (rank > p.rank && (p.rank >= 0 || rank === MAJOR)) {
|
||||||
|
events.push({
|
||||||
|
id: `sev:${id}:${rank}`,
|
||||||
|
kind: rank === MAJOR && p.rank < 0 ? "major" : "escalated",
|
||||||
|
incidentIds: [id], callId: null, at: now,
|
||||||
|
label: p.rank < 0 ? sevLabel(inc.severity) : `↑ ${sevLabel(inc.severity)}`,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stops.length) {
|
||||||
|
const floor = st.newestStopAt ?? st.knownSince - STOP_GRACE_MS;
|
||||||
|
const fresh = baselineCalls ? [] : stops.filter((s) => !p.stopIds.has(s.id) && s.at > floor);
|
||||||
|
for (const s of stops) st.stopIds.add(s.id);
|
||||||
|
st.newestStopAt = Math.max(st.newestStopAt ?? 0, ...stops.map((s) => s.at));
|
||||||
|
if (fresh.length) {
|
||||||
|
const newest = fresh[fresh.length - 1];
|
||||||
|
events.push({
|
||||||
|
id: `stop:${id}:${newest.id}`, kind: "stop", incidentIds: [id], callId: newest.id, at: now,
|
||||||
|
label: fresh.length === 1 ? "+1 stop" : `+${fresh.length} stops`, title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map.set(id, st);
|
||||||
|
}
|
||||||
|
// Keep incidents that dropped out for a while, so one that flickers out of
|
||||||
|
// the active list and back is not "new" again.
|
||||||
|
prev.incidents.forEach((st, id) => {
|
||||||
|
if (!present.has(id) && now - st.lastSeen < FORGET_MS) map.set(id, st);
|
||||||
|
});
|
||||||
|
|
||||||
|
const checked = new Map<string, number>();
|
||||||
|
prev.checkedCalls.forEach((t, id) => { if (now - t < CALL_MEMORY_MS) checked.set(id, t); });
|
||||||
|
const incTitle = new Map(incidents.map((i) => [i.incident_id, i.title || "Untitled incident"]));
|
||||||
|
for (const c of calls) {
|
||||||
|
const text = transcriptOf(c);
|
||||||
|
if (!text || checked.has(c.call_id)) continue;
|
||||||
|
const started = ms(c.started_at);
|
||||||
|
checked.set(c.call_id, started || now);
|
||||||
|
if (baselineCalls || !prev.callsBaselined) continue;
|
||||||
|
if (now - started > LANE_WINDOW_MS) continue; // an old call pulled in late (e.g. by selection) is not news
|
||||||
|
const hits = matchKeywords(text, keywords);
|
||||||
|
if (!hits.length) continue;
|
||||||
|
const ids = callIncidentIds(c);
|
||||||
|
events.push({
|
||||||
|
id: `kw:${c.call_id}`, kind: "keyword", incidentIds: ids, callId: c.call_id, at: now,
|
||||||
|
label: hits.join(" · ").toUpperCase(),
|
||||||
|
title: ids.map((i) => incTitle.get(i)).find(Boolean) ?? c.talkgroup_name ?? (c.talkgroup_id != null ? `TG ${c.talkgroup_id}` : "Unlinked call"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: { baselineAt: prev.baselineAt, callsBaselined: prev.callsBaselined || baselineCalls, incidents: map, checkedCalls: checked },
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In view under the current filter: a linked event needs one of its incidents
|
||||||
|
* in the filtered list (never the call alone — a filtered-out incident's call
|
||||||
|
* must not cue); an unlinked call needs the call itself in the filtered calls.
|
||||||
|
*/
|
||||||
|
export function isVisibleEvent(e: AttentionEvent, visibleIds: ReadonlySet<string>, visibleCallIds: ReadonlySet<string>): boolean {
|
||||||
|
if (e.incidentIds.length) return e.incidentIds.some((id) => visibleIds.has(id));
|
||||||
|
return !!e.callId && visibleCallIds.has(e.callId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LaneEntry {
|
||||||
|
key: string;
|
||||||
|
incidentId: string | null;
|
||||||
|
title: string;
|
||||||
|
severity: string | null;
|
||||||
|
/** Most recent reason to be on the lane (ms). */
|
||||||
|
at: number;
|
||||||
|
/** "New" or the latest event's label. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lane: visible incidents new in the window or with a cue in it, plus unlinked keyword calls; newest first. */
|
||||||
|
export function laneEntries(
|
||||||
|
visible: IncidentRecord[],
|
||||||
|
seenAt: ReadonlyMap<string, number>,
|
||||||
|
log: AttentionEvent[],
|
||||||
|
now: number,
|
||||||
|
visibleCallIds: ReadonlySet<string>,
|
||||||
|
): LaneEntry[] {
|
||||||
|
const since = now - LANE_WINDOW_MS;
|
||||||
|
const latest = new Map<string, AttentionEvent>();
|
||||||
|
for (const e of log) {
|
||||||
|
if (e.at < since) continue;
|
||||||
|
for (const id of e.incidentIds) {
|
||||||
|
const cur = latest.get(id);
|
||||||
|
if (!cur || e.at >= cur.at) latest.set(id, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const out: LaneEntry[] = [];
|
||||||
|
for (const inc of visible) {
|
||||||
|
const seen = seenAt.get(inc.incident_id) ?? 0;
|
||||||
|
const ev = latest.get(inc.incident_id);
|
||||||
|
const isNew = seen >= since;
|
||||||
|
if (!isNew && !ev) continue;
|
||||||
|
const at = Math.max(isNew ? seen : 0, ev?.at ?? 0);
|
||||||
|
out.push({
|
||||||
|
key: inc.incident_id, incidentId: inc.incident_id, title: inc.title || "Untitled incident", severity: inc.severity,
|
||||||
|
at, reason: ev && ev.at >= (isNew ? seen : 0) ? ev.label : "New",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const e of log) {
|
||||||
|
if (e.at < since || e.incidentIds.length || !e.callId || !visibleCallIds.has(e.callId)) continue;
|
||||||
|
out.push({ key: e.id, incidentId: null, title: e.title, severity: null, at: e.at, reason: e.label });
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.at - a.at).slice(0, MAX_LANE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Visible incidents first seen, or cued, within PULSE_MS. */
|
||||||
|
export function pulseIds(visibleIds: ReadonlySet<string>, seenAt: ReadonlyMap<string, number>, log: AttentionEvent[], now: number): Set<string> {
|
||||||
|
const since = now - PULSE_MS;
|
||||||
|
const out = new Set<string>();
|
||||||
|
seenAt.forEach((t, id) => { if (t >= since && visibleIds.has(id)) out.add(id); });
|
||||||
|
for (const e of log) if (e.at >= since) for (const id of e.incidentIds) if (visibleIds.has(id)) out.add(id);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preferences (per browser; a convenience, so storage failure = defaults) ──
|
||||||
|
|
||||||
|
export interface AttentionPrefs {
|
||||||
|
/** Audible cue. OFF until the viewer turns it on (autoplay policy + no surprise noise). */
|
||||||
|
tone: boolean;
|
||||||
|
/** Browser notifications while the tab is hidden. OFF until granted and turned on. */
|
||||||
|
notify: boolean;
|
||||||
|
keywords: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFS_KEY = "drb.wall.attention";
|
||||||
|
export const DEFAULT_ATTENTION_PREFS: AttentionPrefs = { tone: false, notify: false, keywords: [...DEFAULT_KEYWORDS] };
|
||||||
|
|
||||||
|
export function loadAttentionPrefs(): AttentionPrefs {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PREFS_KEY);
|
||||||
|
if (!raw) return DEFAULT_ATTENTION_PREFS;
|
||||||
|
const v = JSON.parse(raw) as Partial<AttentionPrefs>;
|
||||||
|
return {
|
||||||
|
tone: v.tone === true,
|
||||||
|
notify: v.notify === true,
|
||||||
|
keywords: Array.isArray(v.keywords)
|
||||||
|
? v.keywords.filter((k): k is string => typeof k === "string" && !!k.trim()).slice(0, 30)
|
||||||
|
: [...DEFAULT_KEYWORDS],
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ATTENTION_PREFS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAttentionPrefs(p: AttentionPrefs): void {
|
||||||
|
try { window.localStorage.setItem(PREFS_KEY, JSON.stringify(p)); } catch { /* storage blocked */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "MAYDAY, 10-13 , ,shots fired" → ["MAYDAY", "10-13", "shots fired"] (deduped, case-insensitively). */
|
||||||
|
export function parseKeywords(s: string): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const part of s.split(",")) {
|
||||||
|
const k = part.trim().replace(/\s+/g, " ");
|
||||||
|
if (!k || seen.has(k.toLowerCase())) continue;
|
||||||
|
seen.add(k.toLowerCase());
|
||||||
|
out.push(k);
|
||||||
|
}
|
||||||
|
return out.slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface UseAttentionInput {
|
||||||
|
/** True once the incident list's first server snapshot has landed (and the org is known). */
|
||||||
|
ready: boolean;
|
||||||
|
/** Changing this (e.g. the org) starts a fresh baseline. */
|
||||||
|
resetKey: string | null;
|
||||||
|
incidents: IncidentRecord[];
|
||||||
|
calls: CallRecord[];
|
||||||
|
keywords: readonly string[];
|
||||||
|
/** The FILTERED incidents, their ids, and the filtered call ids — what is in view right now. */
|
||||||
|
visible: IncidentRecord[];
|
||||||
|
visibleIds: ReadonlySet<string>;
|
||||||
|
visibleCallIds: ReadonlySet<string>;
|
||||||
|
/** Called with each snapshot's new, in-view events (tone / notification). */
|
||||||
|
onEvents?: (events: AttentionEvent[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAttention({ ready, resetKey, incidents, calls, keywords, visible, visibleIds, visibleCallIds, onEvents }: UseAttentionInput) {
|
||||||
|
const stateRef = useRef<AttentionState | null>(null);
|
||||||
|
const keyRef = useRef<string | null>(resetKey);
|
||||||
|
const [log, setLog] = useState<AttentionEvent[]>([]);
|
||||||
|
const [seenAt, setSeenAt] = useState<Map<string, number>>(new Map());
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const visRef = useRef({ visibleIds, visibleCallIds });
|
||||||
|
visRef.current = { visibleIds, visibleCallIds };
|
||||||
|
const onEventsRef = useRef(onEvents);
|
||||||
|
onEventsRef.current = onEvents;
|
||||||
|
const kwKey = keywords.join("\u0001");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 15_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (keyRef.current !== resetKey) {
|
||||||
|
keyRef.current = resetKey;
|
||||||
|
stateRef.current = null;
|
||||||
|
setLog([]);
|
||||||
|
setSeenAt(new Map());
|
||||||
|
}
|
||||||
|
if (!ready) return;
|
||||||
|
const t = Date.now();
|
||||||
|
const { state, events } = diffAttention(stateRef.current, incidents, calls, keywords, t);
|
||||||
|
stateRef.current = state;
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
state.incidents.forEach((s, id) => seen.set(id, s.seenAt));
|
||||||
|
setSeenAt(seen);
|
||||||
|
setNow(t);
|
||||||
|
if (!events.length) return;
|
||||||
|
setLog((cur) => [...cur.filter((e) => t - e.at < LANE_WINDOW_MS), ...events].slice(-MAX_LOG));
|
||||||
|
const { visibleIds: vi, visibleCallIds: vc } = visRef.current;
|
||||||
|
const shown = events.filter((e) => isVisibleEvent(e, vi, vc));
|
||||||
|
if (shown.length) onEventsRef.current?.(shown);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [ready, resetKey, incidents, calls, kwKey]);
|
||||||
|
|
||||||
|
// A keyword usually fires on the transcript, before correlation links the
|
||||||
|
// call. Re-resolve unlinked events against the calls as they are now, so
|
||||||
|
// the lane row becomes the (clickable) incident and its pin pulses.
|
||||||
|
const linkedLog = useMemo(() => {
|
||||||
|
if (!log.some((e) => !e.incidentIds.length && e.callId)) return log;
|
||||||
|
const links = new Map<string, string[]>();
|
||||||
|
for (const c of calls) { const ids = callIncidentIds(c); if (ids.length) links.set(c.call_id, ids); }
|
||||||
|
return log.map((e) => (e.incidentIds.length || !e.callId || !links.has(e.callId) ? e : { ...e, incidentIds: links.get(e.callId)! }));
|
||||||
|
}, [log, calls]);
|
||||||
|
const lane = useMemo(() => laneEntries(visible, seenAt, linkedLog, now, visibleCallIds), [visible, seenAt, linkedLog, now, visibleCallIds]);
|
||||||
|
const pulse = useMemo(() => pulseIds(visibleIds, seenAt, linkedLog, now), [visibleIds, seenAt, linkedLog, now]);
|
||||||
|
return { lane, pulseIds: pulse, now };
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call-audio playback store — server-26#233.
|
||||||
|
*
|
||||||
|
* ONE module-level <audio> element for the whole app, so exactly one call
|
||||||
|
* plays at a time no matter how many players are on screen (spine rows, map
|
||||||
|
* path-stop popups, rail cards, the incident page). Components read it through
|
||||||
|
* useSyncExternalStore, which is what lets the wall panel and the map share
|
||||||
|
* "now playing" without LiveView.tsx threading state between them.
|
||||||
|
*
|
||||||
|
* Nothing here runs until play()/playIncident() is called from a click, so
|
||||||
|
* nothing autoplays on page load. The playback URL is minted per play by
|
||||||
|
* GET /calls/{id} (a short-lived HMAC link, see c2-core storage.py) rather
|
||||||
|
* than read off the Firestore doc, which only holds the gs:// location.
|
||||||
|
* c2-core serves it with HTTP Range, so a long call starts after the first
|
||||||
|
* span and seeking is a 206 for just the bytes needed.
|
||||||
|
*/
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import type { CallRecord } from "@/lib/types";
|
||||||
|
|
||||||
|
export type AudioStatus = "idle" | "loading" | "playing" | "paused";
|
||||||
|
|
||||||
|
export interface AudioState {
|
||||||
|
/** Calls still to play after the current one, oldest first. */
|
||||||
|
queue: string[];
|
||||||
|
playingCallId: string | null;
|
||||||
|
status: AudioStatus;
|
||||||
|
/** The last call whose audio could not be fetched or decoded. */
|
||||||
|
errorCallId: string | null;
|
||||||
|
current: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IDLE: AudioState = {
|
||||||
|
queue: [], playingCallId: null, status: "idle", errorCallId: null, current: 0, duration: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let state: AudioState = IDLE;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let el: HTMLAudioElement | null = null;
|
||||||
|
// Bumped on every play/stop so a getCall() that resolves after the user has
|
||||||
|
// already moved on is dropped instead of hijacking the player.
|
||||||
|
let token = 0;
|
||||||
|
|
||||||
|
function set(patch: Partial<AudioState>) {
|
||||||
|
state = { ...state, ...patch };
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
function audio(): HTMLAudioElement {
|
||||||
|
if (el) return el;
|
||||||
|
el = new Audio();
|
||||||
|
el.preload = "auto";
|
||||||
|
el.addEventListener("play", () => set({ status: "playing" }));
|
||||||
|
el.addEventListener("pause", () => { if (state.status === "playing") set({ status: "paused" }); });
|
||||||
|
el.addEventListener("timeupdate", () => set({ current: el!.currentTime }));
|
||||||
|
el.addEventListener("loadedmetadata", () => set({ duration: Number.isFinite(el!.duration) ? el!.duration : 0 }));
|
||||||
|
el.addEventListener("ended", advance);
|
||||||
|
el.addEventListener("error", () => {
|
||||||
|
if (!state.playingCallId || !el!.getAttribute("src")) return;
|
||||||
|
set({ errorCallId: state.playingCallId });
|
||||||
|
advance();
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
const [next, ...rest] = state.queue;
|
||||||
|
if (next) void start(next, rest);
|
||||||
|
else stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(callId: string, queue: string[]) {
|
||||||
|
const mine = ++token;
|
||||||
|
const a = audio();
|
||||||
|
a.pause();
|
||||||
|
set({ queue, playingCallId: callId, status: "loading", current: 0, duration: 0,
|
||||||
|
errorCallId: state.errorCallId === callId ? null : state.errorCallId });
|
||||||
|
let url: string | null = null;
|
||||||
|
try {
|
||||||
|
url = (await c2api.getCall(callId)).audio_url ?? null;
|
||||||
|
} catch {
|
||||||
|
url = null;
|
||||||
|
}
|
||||||
|
if (mine !== token) return;
|
||||||
|
if (!url) {
|
||||||
|
set({ errorCallId: callId });
|
||||||
|
advance();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
a.src = url;
|
||||||
|
// The click that got us here is the play intent; a rejection (autoplay
|
||||||
|
// policy after a slow fetch) leaves it paused for one more click.
|
||||||
|
a.play().catch(() => { if (mine === token) set({ status: "paused" }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play one call now, replacing whatever is playing or queued. */
|
||||||
|
export function play(callId: string) {
|
||||||
|
void start(callId, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play an incident's calls back to back. Pass them oldest first. */
|
||||||
|
export function playIncident(callIdsOldestFirst: string[]) {
|
||||||
|
const [first, ...rest] = callIdsOldestFirst;
|
||||||
|
if (first) void start(first, rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop() {
|
||||||
|
token++;
|
||||||
|
if (el) {
|
||||||
|
el.pause();
|
||||||
|
el.removeAttribute("src");
|
||||||
|
el.load();
|
||||||
|
}
|
||||||
|
set({ ...IDLE, errorCallId: state.errorCallId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pause() {
|
||||||
|
el?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resume() {
|
||||||
|
el?.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function seek(t: number) {
|
||||||
|
if (el && Number.isFinite(t)) {
|
||||||
|
el.currentTime = t;
|
||||||
|
set({ current: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play/pause toggle for one call's button. */
|
||||||
|
export function toggle(callId: string) {
|
||||||
|
if (state.playingCallId !== callId) play(callId);
|
||||||
|
else if (state.status === "playing") pause();
|
||||||
|
else if (state.status === "paused") resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(l: () => void): () => void {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(l);
|
||||||
|
// Last player unmounted (navigated away from every page that shows one):
|
||||||
|
// stop rather than keep playing audio nothing on screen can pause.
|
||||||
|
// Deferred so a same-tick remount (StrictMode, route swap) doesn't stop it.
|
||||||
|
if (!listeners.size) setTimeout(() => { if (!listeners.size && state.playingCallId) stop(); }, 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to a slice of the store. Return a primitive from `select` (an id,
|
||||||
|
* a status, a number) so a component re-renders only when that slice changes
|
||||||
|
* — the map must not re-render on every timeupdate.
|
||||||
|
*/
|
||||||
|
export function useAudio<T>(select: (s: AudioState) => T): T {
|
||||||
|
return useSyncExternalStore(subscribe, () => select(state), () => select(IDLE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a call has a stored recording c2-core can serve. */
|
||||||
|
export function hasAudio(c: Pick<CallRecord, "audio_gcs_uri" | "audio_url">): boolean {
|
||||||
|
return !!(c.audio_gcs_uri || c.audio_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONE gate for call-audio playback in the UI. BUSINESS_MODEL.md §3 puts
|
||||||
|
* audio playback in Scanner and up, with the Public tier getting none; no tier
|
||||||
|
* concept exists in the frontend yet (and Gate A keeps tier names off public
|
||||||
|
* surfaces), so today this is true for any signed-in org member. When tiers
|
||||||
|
* land, switch the Public tier off HERE rather than at each player.
|
||||||
|
*/
|
||||||
|
export function canPlayAudio(auth: { user: unknown; orgId: string | null; isAdmin?: boolean }): boolean {
|
||||||
|
return !!auth.user && (!!auth.orgId || !!auth.isAdmin);
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, TalkgroupPending } from "@/lib/types";
|
import { C2ApiError } from "@/lib/c2error";
|
||||||
|
import type {
|
||||||
|
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
||||||
|
WeatherAlertsResponse, WindResponse,
|
||||||
|
} from "@/lib/types";
|
||||||
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
||||||
|
|
||||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||||
@@ -16,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
...(options?.headers as Record<string, string> | undefined),
|
...(options?.headers as Record<string, string> | undefined),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
|
if (!res.ok) throw new C2ApiError(res.status, await res.text());
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -514,6 +518,32 @@ export const c2api = {
|
|||||||
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// Power outages from every admin-enabled provider inside a bbox (server-26#184).
|
||||||
|
// `status` explains an empty list: none_enabled / no_source / error / ok.
|
||||||
|
getOutages: (bbox: { south: number; west: number; north: number; east: number }) =>
|
||||||
|
request<OutagesResponse>(`/outages?${new URLSearchParams({
|
||||||
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
||||||
|
})}`),
|
||||||
|
|
||||||
|
// Admin → Outage providers. Same licence gate as cameras: a gated provider
|
||||||
|
// answers 409 with `requires_acknowledgement` until `acknowledge_licence` is sent.
|
||||||
|
listOutageProviders: () => request<{ providers: OutageProviderAdmin[] }>("/admin/outage-providers"),
|
||||||
|
setOutageProvider: (id: string, enabled: boolean, acknowledgeLicence = false) =>
|
||||||
|
request<OutageProviderAdmin>(`/admin/outage-providers/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Weather context (server-26#237): active NWS alerts intersecting a bbox (bbox
|
||||||
|
// optional — omitted, the whole served area), and nearest-METAR wind. Both are
|
||||||
|
// cached server-side; an upstream failure comes back as `error`, not an HTTP error.
|
||||||
|
getWeatherAlerts: (bbox?: { south: number; west: number; north: number; east: number } | null) =>
|
||||||
|
request<WeatherAlertsResponse>(`/weather/alerts${bbox ? `?${new URLSearchParams({
|
||||||
|
south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east),
|
||||||
|
})}` : ""}`),
|
||||||
|
getWind: (lat: number, lon: number) =>
|
||||||
|
request<WindResponse>(`/weather/wind?${new URLSearchParams({ lat: String(lat), lon: String(lon) })}`),
|
||||||
|
|
||||||
// Public waitlist — no auth, see routers/waitlist.py
|
// Public waitlist — no auth, see routers/waitlist.py
|
||||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||||
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Error thrown by every C2 API request (server-26#286).
|
||||||
|
*
|
||||||
|
* `message` is a sentence a user can read — FastAPI's `detail` when the
|
||||||
|
* server sent one, otherwise a plain mapping of the status. The raw status
|
||||||
|
* and body stay on the error for callers that need the structured parts
|
||||||
|
* (the Discord parsers read `invite`/`code` out of `body`).
|
||||||
|
*/
|
||||||
|
export class C2ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly body: string;
|
||||||
|
|
||||||
|
constructor(status: number, body: string) {
|
||||||
|
super(humanMessage(status, body));
|
||||||
|
this.name = "C2ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call sites render String(e); without this it reads "C2ApiError: …".
|
||||||
|
toString(): string {
|
||||||
|
return this.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailOf(body: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
const d = parsed?.detail;
|
||||||
|
if (typeof d === "string" && d.trim()) return d;
|
||||||
|
// pydantic validation errors: [{loc, msg, type}, ...]
|
||||||
|
if (Array.isArray(d)) {
|
||||||
|
const msgs = d.map((x: { msg?: unknown }) => (typeof x?.msg === "string" ? x.msg : null)).filter(Boolean);
|
||||||
|
if (msgs.length) return msgs.join("; ");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not JSON */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanMessage(status: number, body: string): string {
|
||||||
|
const detail = detailOf(body);
|
||||||
|
if (status === 401) return "Your session has expired. Sign in again.";
|
||||||
|
if (status === 403) return detail ? `Not allowed: ${detail}` : "You don't have permission to do that.";
|
||||||
|
if (detail) return detail;
|
||||||
|
if (status === 404) return "Not found. It may have been deleted.";
|
||||||
|
if (status === 429) return "Too many requests. Wait a moment and try again.";
|
||||||
|
if (status >= 500) return "The server hit an error. Try again in a moment.";
|
||||||
|
return `Request failed (${status}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message for any caught value — use in catch blocks instead of String(e). */
|
||||||
|
export function errorMessage(e: unknown): string {
|
||||||
|
if (e instanceof Error) return e.message;
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the legacy `C2 API error <status>: <body>` string for the
|
||||||
|
* Discord parsers, which predate C2ApiError and match on that shape. */
|
||||||
|
export function legacyErrorText(err: unknown): string {
|
||||||
|
if (err instanceof C2ApiError) return `C2 API error ${err.status}: ${err.body}`;
|
||||||
|
return err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user