Compare commits
31
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 |
@@ -441,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 "
|
||||
"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."),
|
||||
"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 "
|
||||
"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 "
|
||||
|
||||
@@ -690,8 +690,15 @@ async def _geocode_location(
|
||||
"""
|
||||
Geocode using Google Maps Geocoding API, biased toward the channel's area.
|
||||
|
||||
Returns {"lat": float, "lng": float}, or None if geocoding fails or the
|
||||
result lands outside the area this channel covers.
|
||||
Returns {"lat": float, "lng": float, "location_type": str}, or None if
|
||||
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
|
||||
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"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(
|
||||
f"Geocoded '{location_str}' → {coords} "
|
||||
f"({dist_km:.1f}km from {ref_label}) [{location_type}]"
|
||||
|
||||
@@ -128,6 +128,8 @@ class MQTTHandler:
|
||||
"sdr_devices": payload.get("sdr_devices"),
|
||||
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
||||
"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),
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
@@ -161,8 +163,11 @@ class MQTTHandler:
|
||||
# radio_channels (node-26#24): a conventional node's channels and
|
||||
# whether each is encrypted — the one decode failure the owner has
|
||||
# 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",
|
||||
"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:
|
||||
updates[key] = payload[key]
|
||||
if "sdr_count" in payload:
|
||||
|
||||
@@ -100,31 +100,11 @@ async def upload_audio(data: bytes, filename: str, call_id: str = "") -> Optiona
|
||||
return gcs_uri
|
||||
|
||||
|
||||
async def download_audio(gcs_uri: str) -> Optional[bytes]:
|
||||
"""Fetch an object back out of GCS. Server-side read — no signing involved."""
|
||||
bucket_name, blob_path = split_gcs_uri(gcs_uri)
|
||||
if not bucket_name:
|
||||
return None
|
||||
|
||||
def _download() -> bytes:
|
||||
from google.cloud import storage
|
||||
if settings.gcp_credentials_path:
|
||||
client = storage.Client.from_service_account_json(settings.gcp_credentials_path)
|
||||
else:
|
||||
client = storage.Client()
|
||||
return client.bucket(bucket_name).blob(blob_path).download_as_bytes()
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_download)
|
||||
except Exception as e:
|
||||
logger.warning(f"GCS download failed for {gcs_uri}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ── Ranged reads for /media playback (server-26#233) ────────────────────────
|
||||
# A 600 s FLAC call is ~13 MB. download_audio() above pulls the whole object
|
||||
# into memory, so /media used to make the browser wait for all of it before
|
||||
# the first sample played. These two read only what a request asks for: the
|
||||
# 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
|
||||
|
||||
@@ -269,6 +269,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
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
|
||||
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)
|
||||
if g.get("guild_id")}
|
||||
@@ -284,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
|
||||
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"),
|
||||
"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
|
||||
# streams.resolve_guild_org once server-26#218 lands).
|
||||
"_no_fallback": bool(link.get("unlinked_at"))}
|
||||
@@ -291,7 +301,8 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
for s in all_streams:
|
||||
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,
|
||||
"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"):
|
||||
g["guild_name"] = s["guild_name"]
|
||||
node = nodes.get(s.get("node_id")) or {}
|
||||
@@ -305,6 +316,11 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
|
||||
g["acting_org_id"] = acting
|
||||
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")
|
||||
# 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.
|
||||
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
|
||||
|
||||
@@ -21,9 +21,20 @@ Where the directory comes from (server-26#225 S5 — the main bot is optional):
|
||||
stored list is missing (inline, inside the permission-check time budget)
|
||||
or older than ``discord_directory_refresh_seconds`` (in the background).
|
||||
|
||||
So C2 does call Discord when a signed-in user opens the picker — with a
|
||||
radio bot's token, for a server already linked to the caller's org, through
|
||||
the rate-limited, cached client. No token string ever reaches the browser.
|
||||
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``):
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
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
|
||||
@@ -110,7 +110,7 @@ async def test_named_region_geocode_within_node_distance_is_unaffected():
|
||||
node_lat=NODE_LAT, node_lon=NODE_LON,
|
||||
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
|
||||
|
||||
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
|
||||
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
|
||||
async def test_checkin_does_not_promote_unconfigured_to_online(handler):
|
||||
existing = {
|
||||
|
||||
+118
-111
@@ -85,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
|
||||
const ROLE_COLORS: Record<UserRole, string> = {
|
||||
admin: "bg-indigo-900 text-indigo-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 }) {
|
||||
@@ -136,14 +136,14 @@ function FeaturesTab() {
|
||||
</div>
|
||||
)}
|
||||
{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 }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
<Toggle
|
||||
enabled={flags?.[key] ?? true}
|
||||
@@ -238,30 +238,30 @@ function CorrelationDebugTab() {
|
||||
|
||||
return (
|
||||
<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.
|
||||
Copy the JSON and paste it to Claude to diagnose correlation problems.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<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
|
||||
type="number"
|
||||
min={1} max={100}
|
||||
value={limit}
|
||||
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>
|
||||
<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
|
||||
type="number"
|
||||
min={1} max={168}
|
||||
value={orphanHours}
|
||||
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>
|
||||
<button
|
||||
@@ -281,12 +281,12 @@ function CorrelationDebugTab() {
|
||||
|
||||
{meta && (
|
||||
<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}
|
||||
</p>
|
||||
<button
|
||||
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"}
|
||||
</button>
|
||||
@@ -295,22 +295,22 @@ function CorrelationDebugTab() {
|
||||
|
||||
{meta && Object.keys(pathCounts).length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p>
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||
<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]) => (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p>
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">fit_signal distribution</p>
|
||||
{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]) => (
|
||||
<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>
|
||||
</div>
|
||||
))
|
||||
@@ -320,13 +320,13 @@ function CorrelationDebugTab() {
|
||||
)}
|
||||
|
||||
{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">
|
||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
||||
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
||||
{meta.orphans_by_talkgroup.map((tg) => (
|
||||
<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-gray-400">
|
||||
{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>
|
||||
<span className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
|
||||
<span className="text-ink-2">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
@@ -336,7 +336,7 @@ function CorrelationDebugTab() {
|
||||
{json && (
|
||||
<pre
|
||||
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}
|
||||
</pre>
|
||||
@@ -455,13 +455,13 @@ function UserDetailPanel({
|
||||
const isSelf = user.uid === currentUid;
|
||||
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
@@ -472,21 +472,21 @@ function UserDetailPanel({
|
||||
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
value={editName}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
value={editRole}
|
||||
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="operator">Operator — owns nodes</option>
|
||||
@@ -496,13 +496,13 @@ function UserDetailPanel({
|
||||
|
||||
{editRole === "operator" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
||||
<label className="text-xs text-ink-2 block mb-1">
|
||||
Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
|
||||
</label>
|
||||
<input
|
||||
value={editNodes}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -517,10 +517,10 @@ function UserDetailPanel({
|
||||
</button>
|
||||
</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">
|
||||
<span className="text-gray-500">Organization</span>
|
||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
||||
<span className="text-ink-muted">Organization</span>
|
||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
|
||||
{!detail.org_id
|
||||
? "None: sees no data"
|
||||
: detail.org_id === myOrgId
|
||||
@@ -538,34 +538,34 @@ function UserDetailPanel({
|
||||
</button>
|
||||
)}
|
||||
<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"}>
|
||||
{detail.disabled ? "Disabled" : "Active"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Discord</span>
|
||||
<span className="text-gray-300">
|
||||
<span className="text-ink-muted">Discord</span>
|
||||
<span className="text-ink-2">
|
||||
{detail.discord_linked
|
||||
? `@${detail.discord_username ?? detail.discord_user_id}`
|
||||
: "Not linked"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Created</span>
|
||||
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
|
||||
<span className="text-ink-muted">Created</span>
|
||||
<span className="text-ink-2">{fmtDate(detail.creation_time)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Last sign-in</span>
|
||||
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
|
||||
<span className="text-ink-muted">Last sign-in</span>
|
||||
<span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(detail.sessions?.length ?? 0) > 0 && (
|
||||
<div className="border-t border-gray-800 pt-4">
|
||||
<div className="border-t border-line pt-4">
|
||||
<button
|
||||
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>Login history ({detail.sessions?.length} recent)</span>
|
||||
@@ -573,9 +573,9 @@ function UserDetailPanel({
|
||||
{showSessions && (
|
||||
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{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 className="text-gray-600 truncate">{s.ip ?? "—"}</span>
|
||||
<span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -583,7 +583,7 @@ function UserDetailPanel({
|
||||
</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 ? (
|
||||
<>
|
||||
<button
|
||||
@@ -602,7 +602,7 @@ function UserDetailPanel({
|
||||
</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>
|
||||
@@ -667,19 +667,19 @@ function CreateUserModal({
|
||||
if (inviteLink) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-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>
|
||||
<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.
|
||||
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>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
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"}
|
||||
</button>
|
||||
@@ -697,37 +697,37 @@ function CreateUserModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
Display Name <span className="text-gray-600">(optional)</span>
|
||||
<label className="text-xs text-ink-2 block mb-1">
|
||||
Display Name <span className="text-ink-muted">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={displayName}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
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="operator">Operator — owns nodes</option>
|
||||
@@ -736,14 +736,14 @@ function CreateUserModal({
|
||||
</div>
|
||||
{role === "operator" && (
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
||||
<label className="text-xs text-ink-2 block mb-1">
|
||||
Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
|
||||
</label>
|
||||
<input
|
||||
value={nodeIds}
|
||||
onChange={(e) => setNodeIds(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -760,7 +760,7 @@ function CreateUserModal({
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -820,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
||||
)}
|
||||
|
||||
<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
|
||||
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"
|
||||
@@ -836,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
<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">
|
||||
<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 hidden lg:table-cell">Name</th>
|
||||
<th className="px-4 py-2.5 text-left">Role</th>
|
||||
@@ -857,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
||||
{users.map((u) => (
|
||||
<tr
|
||||
key={u.uid}
|
||||
className={`border-t border-gray-800 transition-colors ${
|
||||
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
|
||||
className={`border-t border-line transition-colors ${
|
||||
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-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink">{u.email ?? "—"}</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 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"}` : "—"}
|
||||
</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">
|
||||
{u.disabled
|
||||
? <span className="text-red-500">Disabled</span>
|
||||
@@ -952,15 +952,15 @@ function AuditLogTab() {
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
<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">
|
||||
<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">Action</th>
|
||||
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
||||
@@ -970,12 +970,12 @@ function AuditLogTab() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => (
|
||||
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
|
||||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
||||
<tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
|
||||
<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 text-gray-400 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-gray-600 max-w-xs truncate">
|
||||
<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-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||||
<td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
|
||||
{Object.keys(e.details).length > 0
|
||||
? Object.entries(e.details)
|
||||
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||||
@@ -1029,26 +1029,26 @@ function StaleCallsTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<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
|
||||
type="number"
|
||||
min={1} max={1440}
|
||||
value={minutes}
|
||||
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>
|
||||
<button
|
||||
onClick={() => run(true)}
|
||||
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"}
|
||||
</button>
|
||||
@@ -1068,18 +1068,18 @@ function StaleCallsTab() {
|
||||
)}
|
||||
|
||||
{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">
|
||||
{result.dry_run ? "Preview: " : "Closed: "}
|
||||
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
||||
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
||||
</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>
|
||||
{result.call_ids.length > 0 && (
|
||||
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
||||
{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>
|
||||
)}
|
||||
@@ -1104,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 }) {
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
|
||||
@@ -1169,8 +1169,10 @@ function SttEvalTab() {
|
||||
// scanning", not "done", so this fires again on its own.
|
||||
useEffect(() => {
|
||||
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
|
||||
}, [queue.length, exhausted]);
|
||||
}, [queue.length, exhausted, cursor]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
||||
@@ -1197,7 +1199,7 @@ function SttEvalTab() {
|
||||
|
||||
return (
|
||||
<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.
|
||||
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.
|
||||
@@ -1212,8 +1214,8 @@ function SttEvalTab() {
|
||||
)}
|
||||
|
||||
{current ? (
|
||||
<div className="bg-gray-900 border border-gray-800 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="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-ink-2">
|
||||
<span>{new Date(current.started_at).toLocaleString()}</span>
|
||||
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
||||
</div>
|
||||
@@ -1222,18 +1224,18 @@ function SttEvalTab() {
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<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>
|
||||
<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
|
||||
</label>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
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>
|
||||
|
||||
@@ -1248,15 +1250,15 @@ function SttEvalTab() {
|
||||
<button
|
||||
onClick={skip}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-sm font-mono text-gray-400">
|
||||
<div className="bg-surface border border-line rounded-xl p-4">
|
||||
<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…"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1309,16 +1311,19 @@ export default function AdminPage() {
|
||||
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
|
||||
|
||||
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>
|
||||
|
||||
<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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
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}
|
||||
@@ -1326,6 +1331,7 @@ export default function AdminPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||
{tab === "features" && <FeaturesTab />}
|
||||
{tab === "cameras" && <CameraProvidersTab />}
|
||||
{tab === "outages" && <OutageProvidersTab />}
|
||||
@@ -1335,6 +1341,7 @@ export default function AdminPage() {
|
||||
{tab === "eval" && <SttEvalTab />}
|
||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||
{tab === "audit" && <AuditLogTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
|
||||
import { useAlerts } from "@/lib/useAlerts";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { errorMessage } from "@/lib/c2error";
|
||||
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||
import type { AlertRule } from "@/lib/types";
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
const [webhook, setWebhook] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
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() {
|
||||
if (loaded) return;
|
||||
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
const data = await c2api.getAlertRules() as AlertRule[];
|
||||
setRules(data);
|
||||
setLoaded(true);
|
||||
setListError(null);
|
||||
} 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;
|
||||
setRules((prev) => [created, ...prev]);
|
||||
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
||||
} catch {
|
||||
setError("Failed to create rule.");
|
||||
} catch (e) {
|
||||
setError(`Failed to create rule: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -61,52 +68,55 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
try {
|
||||
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
||||
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
|
||||
} catch (e) { console.error(e); }
|
||||
setListError(null);
|
||||
} catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!window.confirm("Delete this alert rule?")) return;
|
||||
try {
|
||||
await c2api.deleteAlertRule(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 (
|
||||
<div className="space-y-6">
|
||||
{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>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Structure Fire"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
<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
|
||||
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
||||
placeholder="fire, smoke, structure"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
<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
|
||||
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
||||
placeholder="9048, 9600"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
<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
|
||||
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/…"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
@@ -120,13 +130,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
||||
{rules.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p>
|
||||
{listError && <ErrorBanner message={listError} />}
|
||||
|
||||
<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">
|
||||
<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">Keywords</th>
|
||||
<th className="px-4 py-3">Talkgroups</th>
|
||||
@@ -137,11 +151,11 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">
|
||||
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.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-ink-2 text-xs">
|
||||
{rule.discord_webhook ? (
|
||||
<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 ${
|
||||
rule.enabled
|
||||
? "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"}
|
||||
</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"}
|
||||
</span>
|
||||
)}
|
||||
@@ -188,11 +202,13 @@ export default function AlertsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { alerts, loading, error } = useAlerts();
|
||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||
const [ackError, setAckError] = useState<string | null>(null);
|
||||
|
||||
async function handleAcknowledge(id: string) {
|
||||
setAckError(null);
|
||||
try {
|
||||
await c2api.acknowledgeAlert(id);
|
||||
} catch (e) { console.error(e); }
|
||||
} catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
|
||||
}
|
||||
|
||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||
@@ -209,13 +225,13 @@ export default function AlertsPage() {
|
||||
</div>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t as "events" | "rules")}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
||||
tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{t === "events" ? "Triggered Alerts" : "Rules"}
|
||||
@@ -223,9 +239,11 @@ export default function AlertsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
|
||||
|
||||
{tab === "events" && (
|
||||
loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||
) : error ? (
|
||||
<p className="text-red-400 text-sm font-mono">
|
||||
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||
@@ -233,7 +251,7 @@ export default function AlertsPage() {
|
||||
: `Couldn't load alerts: ${error}`}
|
||||
</p>
|
||||
) : 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">
|
||||
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
||||
@@ -242,10 +260,10 @@ export default function AlertsPage() {
|
||||
variant="inline"
|
||||
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">
|
||||
<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">Talkgroup</th>
|
||||
<th className="px-4 py-3">Matched</th>
|
||||
@@ -258,10 +276,10 @@ export default function AlertsPage() {
|
||||
{alerts.map((alert) => (
|
||||
<tr
|
||||
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-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 || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -273,17 +291,17 @@ export default function AlertsPage() {
|
||||
))}
|
||||
</div>
|
||||
</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 || "—"}
|
||||
</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">
|
||||
{alert.acknowledged ? (
|
||||
<span className="text-xs text-gray-500">acked</span>
|
||||
<span className="text-xs text-ink-muted">acked</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAcknowledge(alert.alert_id)}
|
||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
||||
className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
|
||||
@@ -68,7 +68,7 @@ function ChevronIcon({ open }: { open: boolean }) {
|
||||
<svg
|
||||
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
className={`text-ink-muted shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
@@ -83,10 +83,10 @@ export default function FaqPage() {
|
||||
<div className="text-center">
|
||||
<Badge tone="brand">FAQ</Badge>
|
||||
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
||||
<p className="text-gray-400 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
||||
<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 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) => {
|
||||
const open = openIndex === i;
|
||||
return (
|
||||
@@ -100,7 +100,7 @@ export default function FaqPage() {
|
||||
<ChevronIcon open={open} />
|
||||
</button>
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function FeaturesPage() {
|
||||
<div className="max-w-2xl">
|
||||
<Badge tone="brand">Features</Badge>
|
||||
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
|
||||
<p className="text-gray-400 mt-4 leading-relaxed">
|
||||
<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,
|
||||
correlation, mapping, and a live relay — end to end.
|
||||
</p>
|
||||
@@ -88,12 +88,12 @@ export default function FeaturesPage() {
|
||||
<div className="lg:col-span-2">
|
||||
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
|
||||
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
|
||||
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p>
|
||||
<p className="text-ink-2 mt-3 leading-relaxed">{s.body}</p>
|
||||
</div>
|
||||
<Card padding="lg" className="lg:col-span-3">
|
||||
<ul className="space-y-3">
|
||||
{s.points.map((p) => (
|
||||
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
|
||||
<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" />
|
||||
{p}
|
||||
</li>
|
||||
@@ -107,7 +107,7 @@ export default function FeaturesPage() {
|
||||
))}
|
||||
</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>
|
||||
<div className="mt-6">
|
||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
@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 ────────────────────────────────────────────────────────────
|
||||
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
||||
* 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.
|
||||
*/
|
||||
:root {
|
||||
--page: #F4F6F9;
|
||||
--page: #F7F7F8;
|
||||
--surface: #FFFFFF;
|
||||
--raised: #F7F9FB;
|
||||
--line: rgba(11,17,27,.11);
|
||||
--line-strong: rgba(11,17,27,.22);
|
||||
--ink: #101620;
|
||||
--ink-2: #46536A;
|
||||
--ink-muted: #6B788C;
|
||||
--raised: #F2F2F5;
|
||||
--line: rgba(24,24,27,.10);
|
||||
--line-strong: rgba(24,24,27,.20);
|
||||
--ink: #18181B;
|
||||
--ink-2: #52525B;
|
||||
--ink-muted: #6B6B76;
|
||||
|
||||
--accent: #2A78D6;
|
||||
--sev-moderate: #B07800;
|
||||
--sev-major: #C0281F;
|
||||
--accent: #5E6AD2;
|
||||
--sev-moderate: #B54708;
|
||||
--sev-major: #D92D20;
|
||||
|
||||
--map-bg: #E8ECF1;
|
||||
--map-block: #DFE4EB;
|
||||
--map-bg: #ECECF0;
|
||||
--map-block: #E1E1E8;
|
||||
--map-road: #FFFFFF;
|
||||
--map-water: #D3E2EE;
|
||||
--map-water: #DADDF2;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--page: #0B0E13;
|
||||
--surface: #141922;
|
||||
--raised: #1B2230;
|
||||
--line: rgba(255,255,255,.09);
|
||||
--line-strong: rgba(255,255,255,.17);
|
||||
--ink: #E8EBF0;
|
||||
--ink-2: #A5B0C2;
|
||||
--ink-muted: #77839A;
|
||||
--page: #08090A;
|
||||
--surface: #111214;
|
||||
--raised: #1A1B1F;
|
||||
--line: rgba(255,255,255,.08);
|
||||
--line-strong: rgba(255,255,255,.16);
|
||||
--ink: #EDEDEF;
|
||||
--ink-2: #A9A9B3;
|
||||
--ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
|
||||
|
||||
--accent: #3987E5;
|
||||
--sev-moderate: #C98500;
|
||||
--sev-major: #D03B3B;
|
||||
--accent: #8B93F0;
|
||||
--sev-moderate: #FDB022;
|
||||
--sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
|
||||
|
||||
--map-bg: #0E131B;
|
||||
--map-block: #161C26;
|
||||
--map-road: #232C3A;
|
||||
--map-water: #12202E;
|
||||
--map-bg: #0D0E11;
|
||||
--map-block: #16171B;
|
||||
--map-road: #26272D;
|
||||
--map-water: #141729;
|
||||
}
|
||||
|
||||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||
@@ -69,7 +71,7 @@ html, body {
|
||||
|
||||
/* 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). */
|
||||
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
||||
.dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
|
||||
|
||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||
/*
|
||||
@@ -80,34 +82,34 @@ html, body {
|
||||
|
||||
/* Structural backgrounds */
|
||||
html:not(.dark),
|
||||
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
||||
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
||||
html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
|
||||
html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !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\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,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\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !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(242,242,245,0.60) !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: #e4e4e7 !important; }
|
||||
|
||||
/* Borders */
|
||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
||||
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
||||
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
|
||||
html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
|
||||
html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
|
||||
html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
|
||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
|
||||
|
||||
/* Text */
|
||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
||||
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
||||
html:not(.dark) .text-white { color: #18181b !important; }
|
||||
html:not(.dark) .text-gray-100 { color: #27272a !important; }
|
||||
html:not(.dark) .text-gray-200 { color: #27272a !important; }
|
||||
html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
|
||||
html:not(.dark) .text-gray-400 { color: #52525b !important; }
|
||||
html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
|
||||
html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
|
||||
|
||||
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||
* 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; }
|
||||
|
||||
/* 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-800:hover { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
||||
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
|
||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
|
||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
|
||||
|
||||
/* Hover text */
|
||||
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
||||
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
||||
html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
|
||||
html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* Unconfigured / Info */
|
||||
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
||||
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
||||
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
|
||||
html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
|
||||
html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
|
||||
html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
|
||||
html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
|
||||
|
||||
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
||||
@keyframes pulse-ring {
|
||||
@@ -191,6 +193,28 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
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
|
||||
* padding and let the tile's own theme colours show, so the tile is no bigger
|
||||
* than the video it holds. */
|
||||
@@ -204,14 +228,22 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
}
|
||||
|
||||
/* ── 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) select,
|
||||
html:not(.dark) textarea {
|
||||
color: #0f172a;
|
||||
color: #18181b;
|
||||
}
|
||||
html:not(.dark) input::placeholder,
|
||||
html:not(.dark) textarea::placeholder {
|
||||
color: #94a3b8;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* ── 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) */
|
||||
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
|
||||
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
|
||||
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !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-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; }
|
||||
@@ -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-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 {
|
||||
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 */
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
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 { useCallsByIncident } from "@/lib/useCalls";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
@@ -13,25 +14,17 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
|
||||
import { isKnownSeverity } from "@/lib/severity";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
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 EARLIER_PAGE_SIZE = 8;
|
||||
|
||||
function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
|
||||
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 {
|
||||
function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
|
||||
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));
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
@@ -41,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
|
||||
export default function IncidentDetailPage() {
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const router = useRouter();
|
||||
|
||||
const { incident, loading } = useIncident(id);
|
||||
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
||||
@@ -49,31 +41,15 @@ export default function IncidentDetailPage() {
|
||||
|
||||
const [summarizing, setSummarizing] = useState(false);
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||
|
||||
// Same ordering/filtering MapView's IncidentPathLayer uses, so the stop
|
||||
// number shown on a spine entry matches the number on its map marker.
|
||||
const geocodedCalls = useMemo(
|
||||
() =>
|
||||
calls
|
||||
.filter((c): c is CallRecord & { location_coords: { lat: number; lng: number } } => !!c.location_coords)
|
||||
.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]);
|
||||
// Stop numbers and path length come from the same liveWall.pathStops() the
|
||||
// map's IncidentPathLayer draws from (server-26#236), so the spine numbers
|
||||
// exactly what the map numbers: only a movement incident (police/accident
|
||||
// with a pursuit tag) is numbered, and an impossible-speed stop never is.
|
||||
const path = useMemo(() => (incident ? pathStops(incident, calls) : null), [incident, calls]);
|
||||
const stopNumberByCallId = useMemo(() => (path ? stopNumbers(path) : new Map<string, number>()), [path]);
|
||||
|
||||
const newestFirst = useMemo(
|
||||
() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)),
|
||||
@@ -81,16 +57,20 @@ export default function IncidentDetailPage() {
|
||||
);
|
||||
|
||||
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);
|
||||
setActionError(null);
|
||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||
catch (e) { console.error(e); }
|
||||
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||
finally { setResolving(false); }
|
||||
}
|
||||
|
||||
async function handleSummarize() {
|
||||
setSummarizing(true);
|
||||
setActionError(null);
|
||||
try { await c2api.summarizeIncident(id); }
|
||||
catch (e) { console.error(e); }
|
||||
catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
|
||||
finally { setSummarizing(false); }
|
||||
}
|
||||
|
||||
@@ -102,18 +82,23 @@ export default function IncidentDetailPage() {
|
||||
const unitsCleared = incident.units_cleared ?? [];
|
||||
const vehicles = incident.vehicles ?? [];
|
||||
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 remaining = newestFirst.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
||||
>
|
||||
{/* A real link, not router.back(): arriving from Live or a shared URL,
|
||||
"back" went to the map or out of the app. */}
|
||||
<Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
|
||||
← Incidents
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
{/* 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">
|
||||
@@ -129,8 +114,13 @@ export default function IncidentDetailPage() {
|
||||
{incident.title ?? "Incident"}
|
||||
</h1>
|
||||
<p className="text-xs text-ink-muted font-mono">
|
||||
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
|
||||
{pathLengthKm > 0 && <> · {pathLengthKm.toFixed(1)} km path</>}
|
||||
{elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
|
||||
{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" : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -148,6 +138,8 @@ export default function IncidentDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionError && <ErrorBanner message={actionError} />}
|
||||
|
||||
{displayTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{displayTags.map((t) => (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
|
||||
import { useIncidents } from "@/lib/useIncidents";
|
||||
import { useActiveCalls } from "@/lib/useCalls";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { errorMessage } from "@/lib/c2error";
|
||||
import type { IncidentRecord } from "@/lib/types";
|
||||
import { PageHeader } from "@/components/ui/PageHeader";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
|
||||
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
||||
const activeCalls = useActiveCalls();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
||||
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
|
||||
}, [filtered]);
|
||||
|
||||
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" }); }
|
||||
catch (e) { console.error(e); }
|
||||
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
|
||||
chips are pipeline output, so the notice rides with the list. */}
|
||||
<MachineOutputNotice variant="inline" />
|
||||
|
||||
{actionError && <ErrorBanner message={actionError} />}
|
||||
|
||||
<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">
|
||||
{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>
|
||||
DRB
|
||||
</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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
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>
|
||||
<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
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
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>
|
||||
{error && (
|
||||
@@ -127,7 +127,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-gray-700" />
|
||||
<span className="text-xs text-gray-500">or</span>
|
||||
<span className="text-xs text-ink-muted">or</span>
|
||||
<div className="flex-1 h-px bg-gray-700" />
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function LoginPage() {
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
<p className="text-center text-xs text-ink-muted">
|
||||
Don't have an account?{" "}
|
||||
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
||||
</p>
|
||||
|
||||
@@ -15,6 +15,8 @@ import { CallRow } from "@/components/CallRow";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { errorMessage } from "@/lib/c2error";
|
||||
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
|
||||
function ApprovalBadge({ status }: { status: string | null }) {
|
||||
@@ -54,10 +56,14 @@ export default function NodeDetailPage() {
|
||||
return unsub;
|
||||
}, [id]);
|
||||
|
||||
async function sendCommand(action: string) {
|
||||
async function sendCommand(action: string, label: string) {
|
||||
setSending(true);
|
||||
try {
|
||||
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 {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -125,7 +131,7 @@ export default function NodeDetailPage() {
|
||||
}
|
||||
|
||||
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 ?? ""];
|
||||
@@ -135,7 +141,7 @@ export default function NodeDetailPage() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<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>
|
||||
<StatusBadge status={node.status} />
|
||||
</div>
|
||||
@@ -147,7 +153,7 @@ export default function NodeDetailPage() {
|
||||
<span className="text-base">⚠</span>
|
||||
<span>Local System Override Active</span>
|
||||
</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.
|
||||
{node.override_timeout_at ? (
|
||||
<> 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).</>
|
||||
)}
|
||||
</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 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
@@ -184,12 +191,12 @@ export default function NodeDetailPage() {
|
||||
>
|
||||
Force Revert Config
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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"],
|
||||
["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"]] : []),
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||
<span className="text-gray-500">{label}</span>
|
||||
<span className="text-gray-200">{value}</span>
|
||||
<span className="text-ink-muted">{label}</span>
|
||||
<span className="text-ink">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{(() => {
|
||||
const disk = diskState(node);
|
||||
return (
|
||||
<div className="flex justify-between px-4 py-2.5">
|
||||
<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-gray-500">Approval</span>
|
||||
<span className="text-ink-muted">Approval</span>
|
||||
<ApprovalBadge status={node.approval_status ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +250,7 @@ export default function NodeDetailPage() {
|
||||
<button
|
||||
onClick={handleReject}
|
||||
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
|
||||
</button>
|
||||
@@ -240,25 +258,34 @@ export default function NodeDetailPage() {
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{node.configured ? "Reassign System" : "Configure"}
|
||||
</button>
|
||||
<button
|
||||
disabled={sending}
|
||||
onClick={() => sendCommand("op25_restart")}
|
||||
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"
|
||||
>
|
||||
Restart OP25
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{node.configured ? "Reassign System" : "Configure"}
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
disabled={sending}
|
||||
onClick={() => sendCommand("op25_restart", "Restart OP25")}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
disabled={sending}
|
||||
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
|
||||
</button>
|
||||
@@ -283,16 +310,16 @@ export default function NodeDetailPage() {
|
||||
|
||||
{/* Recent calls */}
|
||||
<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. */}
|
||||
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
||||
{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">
|
||||
<table className="w-full text-sm">
|
||||
<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">Talkgroup</th>
|
||||
<th className="px-4 py-2 text-left">System</th>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function NodesPage() {
|
||||
<button
|
||||
onClick={handleUpdateAll}
|
||||
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"}
|
||||
</button>
|
||||
@@ -79,22 +79,28 @@ export default function NodesPage() {
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{pending.map((n) => (
|
||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||
</div>
|
||||
// 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">
|
||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||
</div>
|
||||
) : (
|
||||
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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})
|
||||
</h2>
|
||||
{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 ? (
|
||||
<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">
|
||||
{nodes.map((n) => (
|
||||
|
||||
@@ -55,17 +55,17 @@ export default function OnboardingPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
type="text"
|
||||
value={orgName}
|
||||
@@ -73,7 +73,7 @@ export default function OnboardingPage() {
|
||||
required
|
||||
autoFocus
|
||||
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>
|
||||
{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">
|
||||
See what's happening on the radio, as an incident — not a wall of calls.
|
||||
</h1>
|
||||
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
||||
<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,
|
||||
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.
|
||||
@@ -68,7 +68,7 @@ function MarketingHomePage() {
|
||||
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||
<div className="max-w-2xl mb-10">
|
||||
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
||||
<p className="text-gray-400 mt-3">
|
||||
<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
|
||||
about — and let you click into any one of them for the full picture.
|
||||
</p>
|
||||
@@ -77,14 +77,14 @@ function MarketingHomePage() {
|
||||
{CAPABILITIES.map((c) => (
|
||||
<Card key={c.title} padding="lg" hover>
|
||||
<h3 className="text-white font-semibold">{c.title}</h3>
|
||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
|
||||
<p className="text-ink-2 text-sm mt-2 leading-relaxed">{c.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
@@ -92,7 +92,7 @@ function MarketingHomePage() {
|
||||
<div key={s.n}>
|
||||
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
||||
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
|
||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p>
|
||||
<p className="text-ink-2 text-sm mt-2 leading-relaxed">{s.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -100,12 +100,12 @@ function MarketingHomePage() {
|
||||
</section>
|
||||
|
||||
{/* 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="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<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
|
||||
walk back. Tell us about your coverage area and we will figure it out together.
|
||||
</p>
|
||||
@@ -118,10 +118,10 @@ function MarketingHomePage() {
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<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.
|
||||
</p>
|
||||
<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="text-center max-w-2xl mx-auto">
|
||||
<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.
|
||||
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.
|
||||
@@ -24,7 +24,7 @@ export default function PricingPage() {
|
||||
|
||||
<Card padding="lg" className="mt-12 max-w-2xl mx-auto">
|
||||
<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
|
||||
Discord relay. Node counts, seats, and history length are set per account while we work out
|
||||
the plan structure.
|
||||
@@ -35,7 +35,7 @@ export default function PricingPage() {
|
||||
</Card>
|
||||
|
||||
<div className="text-center mt-16">
|
||||
<p className="text-gray-400">
|
||||
<p className="text-ink-2">
|
||||
Questions?{" "}
|
||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||
{" "}or{" "}
|
||||
|
||||
@@ -63,18 +63,18 @@ export default function PrivacyPage() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{SECTIONS.map((s) => (
|
||||
<section key={s.heading}>
|
||||
<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>
|
||||
))}
|
||||
</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{" "}
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||
</p>
|
||||
|
||||
@@ -92,13 +92,13 @@ export default function ProfilePage() {
|
||||
<div>
|
||||
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
||||
{user.displayName && user.email && (
|
||||
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
|
||||
<p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
|
||||
)}
|
||||
{role && (
|
||||
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
||||
role === "operator" ? "bg-green-900 text-green-300" :
|
||||
"bg-gray-800 text-gray-400"
|
||||
"bg-raised text-ink-2"
|
||||
}`}>
|
||||
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
||||
</span>
|
||||
@@ -107,9 +107,9 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<Row label="Email" value={user.email ?? "—"} />
|
||||
<Row label="UID" value={user.uid} mono truncate />
|
||||
@@ -125,12 +125,12 @@ export default function ProfilePage() {
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<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 ? (
|
||||
<p className="text-gray-500 text-sm">Loading…</p>
|
||||
<p className="text-ink-muted text-sm">Loading…</p>
|
||||
) : linkStatus?.linked ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
@@ -156,21 +156,21 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
) : (
|
||||
<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.
|
||||
</p>
|
||||
{code ? (
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
||||
<p className="text-xs text-ink-2">
|
||||
Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
||||
</p>
|
||||
<button
|
||||
onClick={generateCode}
|
||||
disabled={generating}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
|
||||
className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
|
||||
>
|
||||
Generate new code
|
||||
</button>
|
||||
@@ -190,9 +190,9 @@ export default function ProfilePage() {
|
||||
</section>
|
||||
|
||||
{/* 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">
|
||||
<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
|
||||
onClick={handleSignOut}
|
||||
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 (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<span className="text-xs text-gray-500 shrink-0">{label}</span>
|
||||
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
||||
<span className="text-xs text-ink-muted shrink-0">{label}</span>
|
||||
<span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
||||
{value}
|
||||
</span>
|
||||
</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">
|
||||
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||
<h2 className="text-white font-semibold">Key created</h2>
|
||||
<p className="text-xs text-gray-400">
|
||||
<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{" "}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Ops dashboard integration"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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 className="flex gap-3">
|
||||
@@ -96,9 +96,9 @@ export default function ApiKeysSettingsPage() {
|
||||
<div className="space-y-4">
|
||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
||||
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,7 +108,7 @@ export default function ApiKeysSettingsPage() {
|
||||
)}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function ApiKeysSettingsPage() {
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<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">Key</th>
|
||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
||||
@@ -129,11 +129,11 @@ export default function ApiKeysSettingsPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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-gray-500 font-mono text-xs">{k.key_prefix}…</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||
<td className="px-4 py-3 text-ink-muted font-mono text-xs">{k.key_prefix}…</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-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">
|
||||
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||
</td>
|
||||
|
||||
@@ -54,12 +54,12 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-1.5">
|
||||
<span className="text-xs text-gray-400 font-mono">{label}</span>
|
||||
<span className="text-xs font-mono text-gray-300">
|
||||
<span className="text-xs text-ink-2 font-mono">{label}</span>
|
||||
<span className="text-xs font-mono text-ink-2">
|
||||
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
||||
<div className="h-2 rounded-full bg-raised overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||
@@ -142,7 +142,7 @@ export default function BillingSettingsPage() {
|
||||
|
||||
return (
|
||||
<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.
|
||||
</p>
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function BillingSettingsPage() {
|
||||
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||
</div>
|
||||
<div className="mt-5 pt-5 border-t border-gray-800">
|
||||
<div className="mt-5 pt-5 border-t border-line">
|
||||
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||
</Button>
|
||||
@@ -179,10 +179,10 @@ export default function BillingSettingsPage() {
|
||||
return (
|
||||
<div
|
||||
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-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 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
||||
@@ -212,17 +212,17 @@ export default function BillingSettingsPage() {
|
||||
<Card>
|
||||
<CardHeader title="Invoice history" />
|
||||
{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) => (
|
||||
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-gray-200 text-sm">{inv.description}</p>
|
||||
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||
<p className="text-ink text-sm">{inv.description}</p>
|
||||
<p className="text-ink-muted text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||
<span className="text-ink-2 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,8 +43,8 @@ function Kpi({ k, v, s }: { k: string; v: string; s: string }) {
|
||||
|
||||
function HowItWorks() {
|
||||
const steps = [
|
||||
["1 · Desired", "/join in Discord or this page 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."],
|
||||
["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 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)."],
|
||||
["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."],
|
||||
@@ -54,6 +54,12 @@ function HowItWorks() {
|
||||
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."
|
||||
>
|
||||
<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">
|
||||
{steps.map(([t, d]) => (
|
||||
<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."
|
||||
/>
|
||||
|
||||
<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) => (
|
||||
<Link
|
||||
key={t.href}
|
||||
href={t.href}
|
||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
? "bg-raised text-white"
|
||||
: "text-ink-muted hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{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">
|
||||
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||
<h2 className="text-white font-semibold">Member invited</h2>
|
||||
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
||||
<p className="text-xs text-ink-2">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||
</div>
|
||||
<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>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
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="operator">Operator — owns nodes</option>
|
||||
@@ -130,7 +130,7 @@ export default function MembersSettingsPage() {
|
||||
)}
|
||||
|
||||
<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" : ""}`}
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||
@@ -144,7 +144,7 @@ export default function MembersSettingsPage() {
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<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">Role</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
|
||||
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||
: users.map((u) => (
|
||||
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
||||
<tr key={u.uid} className="border-b border-line last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-white">{u.display_name || u.email}</p>
|
||||
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
||||
{u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{u.uid === user?.uid ? (
|
||||
@@ -168,7 +168,7 @@ export default function MembersSettingsPage() {
|
||||
value={u.role}
|
||||
disabled={savingUid === u.uid}
|
||||
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||
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="operator">Operator</option>
|
||||
@@ -176,7 +176,7 @@ export default function MembersSettingsPage() {
|
||||
</select>
|
||||
)}
|
||||
</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 : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden md:table-cell">
|
||||
@@ -189,7 +189,7 @@ export default function MembersSettingsPage() {
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ function EnrollmentTokensPanel() {
|
||||
</p>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
||||
@@ -133,7 +133,7 @@ function EnrollmentTokensPanel() {
|
||||
: <>(check the broker host)</>}:
|
||||
</p>
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
||||
@@ -163,7 +163,7 @@ function EnrollmentTokensPanel() {
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
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()}>
|
||||
{minting ? "Minting…" : "New token"}
|
||||
@@ -176,11 +176,11 @@ function EnrollmentTokensPanel() {
|
||||
<SkeletonRow cols={1} />
|
||||
</div>
|
||||
) : 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">
|
||||
<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 hidden sm:table-cell">Created</th>
|
||||
<th className="py-2 text-left">Status</th>
|
||||
@@ -189,14 +189,14 @@ function EnrollmentTokensPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{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-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()}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{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>
|
||||
)}
|
||||
@@ -283,7 +283,7 @@ export default function NodeOwnershipSettingsPage() {
|
||||
<EnrollmentTokensPanel />
|
||||
|
||||
<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.
|
||||
</p>
|
||||
|
||||
@@ -292,7 +292,7 @@ export default function NodeOwnershipSettingsPage() {
|
||||
<Card padding="none" className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
||||
<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 hidden sm:table-cell">Status</th>
|
||||
<th className="px-4 py-3 text-left">Owner</th>
|
||||
@@ -302,17 +302,17 @@ export default function NodeOwnershipSettingsPage() {
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||
) : nodes.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
|
||||
<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) => {
|
||||
const owner = ownerByNode.get(n.node_id);
|
||||
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">
|
||||
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||
{n.name}
|
||||
</Link>
|
||||
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
||||
<p className="text-ink-muted text-xs font-mono">{n.node_id}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -320,7 +320,7 @@ export default function NodeOwnershipSettingsPage() {
|
||||
value={owner?.uid ?? UNASSIGNED}
|
||||
disabled={savingNodeId === n.node_id}
|
||||
onChange={(e) => reassign(n.node_id, e.target.value)}
|
||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
|
||||
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>
|
||||
{assignable.map((u) => (
|
||||
|
||||
@@ -16,7 +16,7 @@ import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
|
||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
||||
<p className="text-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>
|
||||
</div>
|
||||
);
|
||||
@@ -69,12 +69,12 @@ export default function OrganizationSettingsPage() {
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<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
|
||||
value={orgName}
|
||||
disabled={orgLoading || !canEdit}
|
||||
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>
|
||||
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
|
||||
@@ -110,7 +110,7 @@ export default function OrganizationSettingsPage() {
|
||||
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||
/>
|
||||
</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">
|
||||
Manage plan & billing →
|
||||
</Link>
|
||||
@@ -124,7 +124,7 @@ export default function OrganizationSettingsPage() {
|
||||
|
||||
<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." />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<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>
|
||||
DRB
|
||||
</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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
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>
|
||||
<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
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -85,7 +85,7 @@ export default function SignupPage() {
|
||||
required
|
||||
minLength={6}
|
||||
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>
|
||||
{error && (
|
||||
@@ -102,7 +102,7 @@ export default function SignupPage() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-px bg-gray-700" />
|
||||
<span className="text-xs text-gray-500">or</span>
|
||||
<span className="text-xs text-ink-muted">or</span>
|
||||
<div className="flex-1 h-px bg-gray-700" />
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function SignupPage() {
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
<p className="text-center text-xs text-ink-muted">
|
||||
Already have an account?{" "}
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
||||
</p>
|
||||
|
||||
+137
-126
@@ -438,17 +438,17 @@ function RRImportModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||||
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
|
||||
<div className="px-5 pt-5 pb-4 border-b border-line">
|
||||
<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}` : ""}
|
||||
</p>
|
||||
{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
|
||||
</p>
|
||||
</div>
|
||||
@@ -456,7 +456,7 @@ function RRImportModal({
|
||||
{/* Category list */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
|
||||
<p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
|
||||
<div className="flex gap-3 text-xs text-indigo-400">
|
||||
<button
|
||||
type="button"
|
||||
@@ -480,18 +480,18 @@ function RRImportModal({
|
||||
type="checkbox"
|
||||
checked={selected.has(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}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={handleImport}
|
||||
@@ -503,7 +503,7 @@ function RRImportModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
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 (
|
||||
<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 ? (
|
||||
<textarea
|
||||
value={value}
|
||||
@@ -656,8 +656,8 @@ function TalkgroupEditor({
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-gray-400">
|
||||
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
||||
<label className="text-xs text-ink-2">
|
||||
Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{/* RadioReference file import */}
|
||||
@@ -704,10 +704,10 @@ function TalkgroupEditor({
|
||||
)}
|
||||
|
||||
{showPaste && (
|
||||
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<p className="text-xs text-gray-500">
|
||||
<div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
|
||||
<p className="text-xs text-ink-muted">
|
||||
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
|
||||
</p>
|
||||
<textarea
|
||||
@@ -715,7 +715,7 @@ function TalkgroupEditor({
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
rows={6}
|
||||
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
|
||||
type="button"
|
||||
@@ -729,10 +729,10 @@ function TalkgroupEditor({
|
||||
)}
|
||||
|
||||
{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">
|
||||
<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">Name</th>
|
||||
<th className="px-3 py-1.5 text-left w-28">Tag</th>
|
||||
@@ -742,7 +742,7 @@ function TalkgroupEditor({
|
||||
<tbody>
|
||||
{talkgroups.map((tg, 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">
|
||||
<input
|
||||
value={tg.id}
|
||||
@@ -763,7 +763,7 @@ function TalkgroupEditor({
|
||||
<select
|
||||
value={tg.tag}
|
||||
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) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
@@ -776,7 +776,7 @@ function TalkgroupEditor({
|
||||
title="Local knowledge for the transcript corrector"
|
||||
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||
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 ? "▾" : "▸"}
|
||||
@@ -784,7 +784,7 @@ function TalkgroupEditor({
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -794,11 +794,11 @@ function TalkgroupEditor({
|
||||
talkgroups and most inherit the system's context rather than
|
||||
override it. */}
|
||||
{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">
|
||||
<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
|
||||
<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.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
@@ -849,7 +849,7 @@ function TalkgroupEditor({
|
||||
</table>
|
||||
</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.
|
||||
</p>
|
||||
)}
|
||||
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<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
|
||||
value={value.nac}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
value={value.system_id}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
value={value.wacn}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span>
|
||||
<label className="text-xs text-ink-2 block mb-1">
|
||||
Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
|
||||
</label>
|
||||
<input
|
||||
value={value.control_channels}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span>
|
||||
<label className="text-xs text-ink-2 block mb-1">
|
||||
Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
|
||||
</label>
|
||||
<input
|
||||
value={value.voice_channels}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
||||
|
||||
// ── 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 }) {
|
||||
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)) });
|
||||
return (
|
||||
<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
|
||||
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.
|
||||
</p>
|
||||
<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>Channel name</span>
|
||||
<span>NAC (hex, optional)</span>
|
||||
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
|
||||
talkgroups={value.talkgroups}
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
id="analog-freq"
|
||||
value={ch.freq}
|
||||
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
|
||||
/>
|
||||
</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
|
||||
id="analog-name"
|
||||
value={ch.name}
|
||||
@@ -1124,27 +1124,27 @@ function SystemForm({
|
||||
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
id="system-kind"
|
||||
value={kind}
|
||||
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) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
@@ -1165,12 +1165,12 @@ function SystemForm({
|
||||
<AnalogForm value={analog} onChange={setAnalog} />
|
||||
) : (
|
||||
<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
|
||||
value={rawJson}
|
||||
onChange={(e) => setRawJson(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
@@ -1179,7 +1179,7 @@ function SystemForm({
|
||||
<button
|
||||
type="button"
|
||||
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 →"}
|
||||
</button>
|
||||
@@ -1198,7 +1198,7 @@ function SystemForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
||||
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
||||
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<button
|
||||
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>
|
||||
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
||||
{open && (
|
||||
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||
{tokens === null ? (
|
||||
<p className="text-gray-600 italic">Loading tokens…</p>
|
||||
<p className="text-ink-muted italic">Loading tokens…</p>
|
||||
) : error ? (
|
||||
<p className="text-red-400">{error}</p>
|
||||
) : 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.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
||||
disabled={saving}
|
||||
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.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||||
</span>
|
||||
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
||||
<button
|
||||
onClick={handleClear}
|
||||
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)
|
||||
</button>
|
||||
)}
|
||||
{!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 && (
|
||||
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||||
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
||||
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||
>
|
||||
<span>{open ? "▲" : "▼"}</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"}`} />
|
||||
</button>
|
||||
<span className="text-gray-300 flex-1">{label}</span>
|
||||
<span className="text-ink-2 flex-1">{label}</span>
|
||||
{isSet ? (
|
||||
<button
|
||||
onClick={() => handleClear(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)"
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-700">inherits global</span>
|
||||
<span className="text-ink-muted">inherits global</span>
|
||||
)}
|
||||
</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>
|
||||
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
||||
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
||||
</button>
|
||||
{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 ? (
|
||||
<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 && (
|
||||
<>
|
||||
<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. */}
|
||||
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
||||
</>
|
||||
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<button
|
||||
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>
|
||||
Local Area
|
||||
{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"})
|
||||
</span>
|
||||
)}
|
||||
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
||||
|
||||
{open && (
|
||||
<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 && (
|
||||
<>
|
||||
<p className="text-gray-500">
|
||||
<p className="text-ink-muted">
|
||||
Given to the transcript corrector for every talkgroup on this system.
|
||||
Whisper mishears local names constantly — naming them here is what lets
|
||||
them be put back.
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
Fill this in <span className="text-gray-300">only if it is true of every
|
||||
<p className="text-ink-muted">
|
||||
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.
|
||||
A system spanning several counties — leave it blank and describe each
|
||||
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
|
||||
it decides whether location checking runs at all — so it is
|
||||
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
|
||||
type="button"
|
||||
onClick={save}
|
||||
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
||||
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<button
|
||||
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>
|
||||
Proposed Terms
|
||||
{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"})
|
||||
</span>
|
||||
)}
|
||||
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
||||
|
||||
{open && (
|
||||
<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 && (
|
||||
<p className="text-gray-600 italic">
|
||||
<p className="text-ink-muted italic">
|
||||
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
|
||||
recent traffic.
|
||||
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
||||
)}
|
||||
{(rows ?? []).map((row) => (
|
||||
<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}`}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{row.pending.map((p) => (
|
||||
<li
|
||||
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="text-white font-mono">{p.term}</span>
|
||||
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
|
||||
<span className="text-gray-700 ml-2">{p.source}</span>
|
||||
{p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
|
||||
<span className="text-ink-muted ml-2">{p.source}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
||||
type="button"
|
||||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||||
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
|
||||
</button>
|
||||
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
||||
<div className="mt-3 border-t border-line pt-3">
|
||||
<button
|
||||
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>
|
||||
Vocabulary
|
||||
{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` : ""})
|
||||
</span>
|
||||
)}
|
||||
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
|
||||
{open && (
|
||||
<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 && (
|
||||
<>
|
||||
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
>
|
||||
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
|
||||
</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>
|
||||
<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 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{vocab.map((term) => (
|
||||
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
||||
<span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
|
||||
{term}
|
||||
<button
|
||||
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>
|
||||
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
value={newTerm}
|
||||
onChange={(e) => setNewTerm(e.target.value)}
|
||||
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
|
||||
type="submit"
|
||||
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
|
||||
</button>
|
||||
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
||||
|
||||
{pending.length > 0 && (
|
||||
<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})
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{pending.map((p) => (
|
||||
<div key={p.term} className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
||||
<span className="text-gray-600">{p.source}</span>
|
||||
<span className="text-ink-2 flex-1">{p.term}</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={() => 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>
|
||||
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||||
<div className="pl-1 space-y-1">
|
||||
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
|
||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||
async function handleDelete(id: string) {
|
||||
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) {
|
||||
@@ -1952,12 +1953,16 @@ export default function SystemsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||
<button
|
||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||
>
|
||||
+ New System
|
||||
</button>
|
||||
{/* Every systems write route is require_admin_token (server-26#283);
|
||||
operators get a read-only view. */}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||
>
|
||||
+ New System
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
@@ -1970,9 +1975,9 @@ export default function SystemsPage() {
|
||||
)}
|
||||
|
||||
{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 ? (
|
||||
<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">
|
||||
{systems.map((s) => {
|
||||
@@ -1987,13 +1992,13 @@ export default function SystemsPage() {
|
||||
? (s.config.channels as unknown[]).length
|
||||
: null;
|
||||
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>
|
||||
<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) && (
|
||||
<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>}
|
||||
{ccCount !== null && <span>· {ccCount} CC</span>}
|
||||
{chCount !== null && <span>· {chCount} ch</span>}
|
||||
@@ -2001,11 +2006,11 @@ export default function SystemsPage() {
|
||||
</p>
|
||||
)}
|
||||
</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)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-3">
|
||||
{isAdmin && <div className="mt-3 flex gap-3">
|
||||
<button
|
||||
onClick={() => openEdit(s)}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
@@ -2014,7 +2019,7 @@ export default function SystemsPage() {
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -2024,15 +2029,21 @@ export default function SystemsPage() {
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>}
|
||||
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
|
||||
(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). */}
|
||||
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
|
||||
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||
<AreaContextPanel systemId={s.system_id} />
|
||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
||||
<VocabularyPanel systemId={s.system_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 ?? {}} />
|
||||
<AreaContextPanel systemId={s.system_id} />
|
||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
||||
<VocabularyPanel systemId={s.system_id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -69,18 +69,18 @@ export default function TermsPage() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{SECTIONS.map((s) => (
|
||||
<section key={s.heading}>
|
||||
<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>
|
||||
))}
|
||||
</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{" "}
|
||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||
</p>
|
||||
|
||||
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { errorMessage } from "@/lib/c2error";
|
||||
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -141,7 +142,7 @@ function PlaceSearch({
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
|
||||
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
|
||||
type="button"
|
||||
@@ -153,7 +154,7 @@ function PlaceSearch({
|
||||
</button>
|
||||
</div>
|
||||
{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) => (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<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 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">
|
||||
<form
|
||||
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>
|
||||
|
||||
<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
|
||||
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
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 className="grid grid-cols-3 gap-3">
|
||||
<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
|
||||
required type="date"
|
||||
min={trip.start_date} max={trip.end_date}
|
||||
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>
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
|
||||
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>
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
<label className="text-xs text-ink-2 block mb-1">
|
||||
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>
|
||||
<input
|
||||
value={location} onChange={(e) => setLocation(e.target.value)}
|
||||
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>
|
||||
|
||||
@@ -307,16 +308,16 @@ function AddEventModal({
|
||||
)}
|
||||
|
||||
<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
|
||||
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>
|
||||
|
||||
{availableTags.length > 0 && (
|
||||
<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">
|
||||
{availableTags.map((tag) => {
|
||||
const active = tags.includes(tag);
|
||||
@@ -326,7 +327,7 @@ function AddEventModal({
|
||||
type="button"
|
||||
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
||||
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}
|
||||
@@ -340,7 +341,7 @@ function AddEventModal({
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
@@ -389,7 +390,7 @@ function DayTimeline({
|
||||
|
||||
if (timed.length === 0 && untimed.length === 0) {
|
||||
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.
|
||||
</p>
|
||||
);
|
||||
@@ -424,10 +425,10 @@ function DayTimeline({
|
||||
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
|
||||
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`}
|
||||
</span>
|
||||
<div className="flex-1 border-t border-gray-800/70" />
|
||||
<div className="flex-1 border-t border-line/70" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -444,7 +445,7 @@ function DayTimeline({
|
||||
{e.end_time && (
|
||||
<div
|
||||
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 */}
|
||||
@@ -452,15 +453,15 @@ function DayTimeline({
|
||||
style={{ top, left: 60, right: 0 }}
|
||||
className="absolute z-10 flex items-center gap-2"
|
||||
>
|
||||
<div className="flex-1 border-t border-dashed border-gray-600/40" />
|
||||
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
|
||||
<div className="flex-1 border-t border-dashed border-line-strong/40" />
|
||||
<span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
|
||||
{fmtTime(e.start_time)}
|
||||
</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 && (
|
||||
<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={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</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-ink-muted hover:text-red-400 text-xs leading-none">×</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -483,25 +484,27 @@ function DayTimeline({
|
||||
style={{ top, height, left: 60, right: 0 }}
|
||||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
||||
isConflict
|
||||
? "bg-red-950/70 border border-red-700/70"
|
||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
||||
// Theme tokens: the old bg-indigo-950/60 had no light-mode
|
||||
// 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="min-w-0 flex-1">
|
||||
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
||||
{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)}
|
||||
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
||||
{isConflict ? " ⚠ conflict" : ""}
|
||||
</p>
|
||||
)}
|
||||
{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 && (
|
||||
<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 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
@@ -525,13 +528,13 @@ function DayTimeline({
|
||||
<>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
@@ -543,9 +546,9 @@ function DayTimeline({
|
||||
{drive && (
|
||||
<div
|
||||
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>
|
||||
@@ -557,18 +560,18 @@ function DayTimeline({
|
||||
{/* Untimed / all-day events */}
|
||||
{untimed.length > 0 && (
|
||||
<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) => (
|
||||
<div
|
||||
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">
|
||||
<p className="text-white text-sm">{e.title}</p>
|
||||
{!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 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||||
@@ -585,11 +588,11 @@ function DayTimeline({
|
||||
{isAdmin && (
|
||||
<>
|
||||
<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
|
||||
</button>
|
||||
<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>
|
||||
</>
|
||||
@@ -728,17 +731,17 @@ function AssistantPanel({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -758,7 +761,7 @@ function AssistantPanel({
|
||||
<button
|
||||
key={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}
|
||||
</button>
|
||||
@@ -772,7 +775,7 @@ function AssistantPanel({
|
||||
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
|
||||
msg.role === "user"
|
||||
? "bg-indigo-600 text-white"
|
||||
: "bg-gray-800 text-gray-200"
|
||||
: "bg-raised text-ink"
|
||||
}`}
|
||||
>
|
||||
{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 ${
|
||||
s.added
|
||||
? "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>
|
||||
<div className="text-xs text-gray-400 space-y-0.5">
|
||||
<div className="text-xs text-ink-2 space-y-0.5">
|
||||
{s.date && (
|
||||
<p>
|
||||
{fmtDate(s.date)}
|
||||
@@ -817,7 +820,7 @@ function AssistantPanel({
|
||||
</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 && (
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
||||
@@ -840,7 +843,7 @@ function AssistantPanel({
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -856,7 +859,7 @@ function AssistantPanel({
|
||||
|
||||
{loading && (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -865,7 +868,7 @@ function AssistantPanel({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
@@ -875,7 +878,7 @@ function AssistantPanel({
|
||||
placeholder="What do you want to do?"
|
||||
disabled={loading}
|
||||
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
|
||||
onClick={send}
|
||||
@@ -955,7 +958,7 @@ export default function TripDetailPage() {
|
||||
async function handleDeleteTrip() {
|
||||
if (!trip) return;
|
||||
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) {
|
||||
@@ -963,7 +966,7 @@ export default function TripDetailPage() {
|
||||
try {
|
||||
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
||||
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) {
|
||||
@@ -1055,8 +1058,8 @@ export default function TripDetailPage() {
|
||||
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
||||
}
|
||||
|
||||
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
||||
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>;
|
||||
if (loading) return <p className="text-ink-muted text-sm font-mono">Loading…</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 attendees = Object.values(trip.attendees ?? {});
|
||||
@@ -1070,7 +1073,7 @@ export default function TripDetailPage() {
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -1078,7 +1081,7 @@ export default function TripDetailPage() {
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<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>
|
||||
{trip.maps_link && (
|
||||
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
|
||||
@@ -1086,11 +1089,11 @@ export default function TripDetailPage() {
|
||||
Maps
|
||||
</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>
|
||||
{attendees.length > 0 && (
|
||||
<>
|
||||
<span className="text-gray-700">•</span>
|
||||
<span className="text-ink-muted">•</span>
|
||||
<span className="text-xs">{attendees.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
@@ -1114,7 +1117,7 @@ export default function TripDetailPage() {
|
||||
</button>
|
||||
<button
|
||||
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"}
|
||||
</button>
|
||||
@@ -1159,9 +1162,9 @@ export default function TripDetailPage() {
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
||||
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>
|
||||
@@ -1170,13 +1173,13 @@ export default function TripDetailPage() {
|
||||
{/* Invite management — admin only, only when private */}
|
||||
{isAdmin && trip.visibility === "private" && (
|
||||
<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">
|
||||
{(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) => (
|
||||
<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>
|
||||
<button
|
||||
onClick={() => handleRevokeInvite(discordId)}
|
||||
@@ -1192,9 +1195,9 @@ export default function TripDetailPage() {
|
||||
onChange={(e) => setInviteInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
||||
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>
|
||||
@@ -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 ${
|
||||
day === selectedDay
|
||||
? "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">{date}</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
@@ -1238,7 +1241,7 @@ export default function TripDetailPage() {
|
||||
|
||||
{/* Selected day */}
|
||||
<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)}
|
||||
</p>
|
||||
<DayTimeline
|
||||
@@ -1254,7 +1257,7 @@ export default function TripDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
import { useTrips } from "@/lib/useTrips";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { errorMessage } from "@/lib/c2error";
|
||||
import type { TripRecord } from "@/lib/types";
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
@@ -29,25 +30,25 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
||||
|
||||
return (
|
||||
<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}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
|
||||
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-raised text-ink-muted"
|
||||
}`}>
|
||||
{upcoming ? "Upcoming" : "Past"}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
|
||||
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
|
||||
<p className="text-gray-500 text-xs font-mono mt-1">
|
||||
<p className="text-ink-2 text-xs mt-1">{trip.location}</p>
|
||||
<p className="text-ink-muted text-xs font-mono mt-1">
|
||||
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
||||
</p>
|
||||
{attendeeCount > 0 && (
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
<p className="text-ink-muted text-xs mt-1">
|
||||
{attendeeCount} going
|
||||
</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">
|
||||
<form
|
||||
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>
|
||||
|
||||
<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
|
||||
required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Road trip to Nashville"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
<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
|
||||
required value={location} onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="Nashville, TN"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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 className="grid grid-cols-2 gap-3">
|
||||
<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
|
||||
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>
|
||||
<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
|
||||
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
||||
min={start}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
<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
|
||||
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
|
||||
placeholder="https://maps.google.com/…"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
||||
<button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@@ -179,8 +180,9 @@ export default function TripsPage() {
|
||||
const past = trips.filter((t) => t.end_date < today);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("Delete this trip?")) return;
|
||||
try { await c2api.deleteTrip(id); }
|
||||
catch (e) { console.error(e); }
|
||||
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -198,12 +200,12 @@ export default function TripsPage() {
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
<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">
|
||||
{upcoming.map((t) => (
|
||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||
@@ -214,7 +216,7 @@ export default function TripsPage() {
|
||||
|
||||
{past.length > 0 && (
|
||||
<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">
|
||||
{past.map((t) => (
|
||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||
@@ -224,7 +226,7 @@ export default function TripsPage() {
|
||||
)}
|
||||
|
||||
{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>
|
||||
DRB
|
||||
</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 ? (
|
||||
<>
|
||||
<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{" "}
|
||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
|
||||
</p>
|
||||
@@ -53,39 +53,39 @@ export default function WaitlistPage() {
|
||||
<>
|
||||
<div>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
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>
|
||||
<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
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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>
|
||||
<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
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
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>
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
return (
|
||||
<>
|
||||
<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)}
|
||||
>
|
||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
||||
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
||||
<td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
|
||||
<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" })}
|
||||
</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>
|
||||
{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]}
|
||||
</span>
|
||||
)}
|
||||
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
||||
<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-ink-2 hidden sm:table-cell">{call.node_id}</td>
|
||||
<td className="px-4 py-2">
|
||||
{isActive ? (
|
||||
<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 className="px-4 py-2 text-xs">
|
||||
{hasAudio ? (
|
||||
<span className="text-blue-400">▶</span>
|
||||
) : (
|
||||
<span className="text-gray-700">—</span>
|
||||
<span className="text-ink-muted">—</span>
|
||||
)}
|
||||
</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 ? "▲" : "▼")}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{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">
|
||||
{/* Audio player */}
|
||||
{hasAudio && (
|
||||
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
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) => (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{/* Incident links — one per scene detected in the recording */}
|
||||
{incidentIds.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
||||
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||
<span className="text-ink-muted">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||
{incidentIds.map((id) => (
|
||||
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
||||
{id.slice(0, 8)}…
|
||||
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
|
||||
{/* Correlation debug — admin only */}
|
||||
{isAdmin && call.corr_path && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-ink-muted">
|
||||
<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 && (
|
||||
<span>idle {call.corr_incident_idle_min}min</span>
|
||||
)}
|
||||
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
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">
|
||||
<button
|
||||
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
) : hasSegments ? (
|
||||
<div className="space-y-1">
|
||||
<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`}
|
||||
</span>
|
||||
{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>
|
||||
{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}
|
||||
</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) => (
|
||||
<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-gray-300">{seg.text}</span>
|
||||
<span className="text-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
|
||||
<span className="text-ink-2">{seg.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
{hasBoth && (
|
||||
<button
|
||||
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 ↓"}
|
||||
</button>
|
||||
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
||||
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">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-ink-muted font-mono">corrected</span>}
|
||||
</div>
|
||||
{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>
|
||||
<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}
|
||||
</pre>
|
||||
{hasBoth && (
|
||||
<button
|
||||
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 ↓"}
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
</tr>
|
||||
|
||||
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
|
||||
{camera.image_url ? "…" : "No still for this camera"}
|
||||
</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}
|
||||
</span>
|
||||
{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 ? (
|
||||
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
||||
) : credit.attribution}
|
||||
|
||||
@@ -329,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">
|
||||
{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 ? (
|
||||
<>Platform setting: unlinked servers still act for the default organization (<span className="font-mono">DISCORD_UNLINKED_GUILD_ORG_ID</span>) until linked. </>
|
||||
) : null}
|
||||
Every command and autocomplete is scoped to the linked organization — no server sees another
|
||||
organization's nodes, systems or bots.
|
||||
Radio channels, and every DRB server bot command and autocomplete, are scoped to the linked organization — no
|
||||
server sees another organization's nodes, systems or bots. The DRB server bot is optional: a radio bot
|
||||
alone is enough to stream.
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -18,19 +18,31 @@
|
||||
* 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, useRef, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useNodes } from "@/lib/useNodes";
|
||||
import { useSystems } from "@/lib/useSystems";
|
||||
import { useActiveCalls, useCalls } from "@/lib/useCalls";
|
||||
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 { useVessels } from "@/lib/useVessels";
|
||||
import { useCamerasInBbox } from "@/lib/useCameras";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import { severityRank } from "@/lib/severity";
|
||||
import type { Camera, Ny511Response } from "@/lib/types";
|
||||
import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types";
|
||||
import {
|
||||
DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers,
|
||||
onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
|
||||
@@ -47,17 +59,22 @@ import { CameraDrill } from "@/components/live/CameraDrill";
|
||||
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 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-[9px]']]:text-[18px]",
|
||||
"[&[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",
|
||||
@@ -67,6 +84,18 @@ const MAX_TAG_CHIPS = 8;
|
||||
const MAX_PINNED = 6;
|
||||
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 {
|
||||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
@@ -119,10 +148,11 @@ function Kpi({ value, label }: { value: string | number; label: string }) {
|
||||
export function LiveView() {
|
||||
const { nodes, loading: nodesLoading } = useNodes();
|
||||
const activeCalls = useActiveCalls();
|
||||
const activeIncidents = useActiveIncidents();
|
||||
const { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState();
|
||||
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
|
||||
// and the scrubber's density bars. Real recent calls, not a fixture.
|
||||
const { calls: recentCalls, loading: callsLoading, error: callsError } = useCalls(500);
|
||||
const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS);
|
||||
const { orgId } = useAuth();
|
||||
const { systems } = useSystems();
|
||||
const { aircraft } = useAircraft();
|
||||
const { vessels } = useVessels();
|
||||
@@ -250,10 +280,53 @@ export function LiveView() {
|
||||
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
|
||||
return m;
|
||||
}, [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 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);
|
||||
}, [pointsById, nodes]);
|
||||
}, [pointsById, nodes, resolvedSelected, selectedPoints]);
|
||||
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
|
||||
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
|
||||
@@ -276,19 +349,20 @@ export function LiveView() {
|
||||
});
|
||||
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
|
||||
// (a shared #inc= link must still land); it is not added to the tour.
|
||||
// or it has resolved (a shared #inc= link must still land); it is not added
|
||||
// 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 (!keepSelected || !selected) return filtered.calls;
|
||||
if (!selectedCalls.length) return filtered.calls;
|
||||
const have = new Set(filtered.calls.map((c) => c.call_id));
|
||||
return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))];
|
||||
}, [keepSelected, selected, filtered.calls, callsByIncident]);
|
||||
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(() => {
|
||||
@@ -315,8 +389,7 @@ export function LiveView() {
|
||||
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 selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
|
||||
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
|
||||
const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]);
|
||||
const drillCam: Camera | null = link.cam
|
||||
? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null
|
||||
: null;
|
||||
@@ -334,6 +407,32 @@ export function LiveView() {
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]);
|
||||
|
||||
// ── 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(() => {
|
||||
if (link.cam) patchLink({ cam: null, tour: false });
|
||||
@@ -370,6 +469,14 @@ export function LiveView() {
|
||||
return () => { clearInterval(id); clearInterval(prog); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [link.tour, tourList.join(","), tourMs]);
|
||||
// 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 ──────────────────────────────────────────────────────────────────
|
||||
@@ -429,6 +536,7 @@ export function LiveView() {
|
||||
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>
|
||||
</div>
|
||||
@@ -452,7 +560,11 @@ export function LiveView() {
|
||||
{/* 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" : ""}`}>
|
||||
{nodesLoading || callsLoading || !mapState ? (
|
||||
{/* 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>
|
||||
) : (
|
||||
<MapView
|
||||
@@ -466,6 +578,8 @@ export function LiveView() {
|
||||
onOpenCamera={openCamera}
|
||||
onAir={onAir}
|
||||
camCountByIncident={camCountByIncident}
|
||||
pulseIncidentIds={attention.pulseIds}
|
||||
filteredFrom={filtered.active ? activeIncidents.length : undefined}
|
||||
key={mapGen}
|
||||
initialLayers={mapState.layers}
|
||||
initialBase={mapState.base}
|
||||
@@ -475,6 +589,10 @@ export function LiveView() {
|
||||
)}
|
||||
</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
|
||||
Activation screen handles the zero-node case). */}
|
||||
{configuredButQuiet && (
|
||||
@@ -487,11 +605,35 @@ export function LiveView() {
|
||||
</div>
|
||||
|
||||
<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"}>
|
||||
{selected ? (
|
||||
aria-live="polite" aria-label={link.inc ? "Incident detail" : "Region overview"}>
|
||||
{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
|
||||
inc={selected}
|
||||
calls={callsByIncident.get(selected.incident_id) ?? []}
|
||||
calls={selectedCalls}
|
||||
points={selectedPoints}
|
||||
near={near}
|
||||
feeds={feeds}
|
||||
@@ -503,6 +645,7 @@ export function LiveView() {
|
||||
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
|
||||
onOpenCamera={openCamera}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<RegionPanel
|
||||
incidents={filtered.incidents}
|
||||
@@ -524,7 +667,8 @@ export function LiveView() {
|
||||
</div>
|
||||
|
||||
<TranscriptTicker calls={filtered.calls} onSelectIncident={selectIncident} />
|
||||
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} 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 && (
|
||||
<CameraDrill
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import type { NodeRecord, SystemRecord } from "@/lib/types";
|
||||
import { diskState } from "@/lib/nodeDisk";
|
||||
|
||||
interface Props {
|
||||
node: NodeRecord;
|
||||
@@ -19,22 +20,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||
: "never";
|
||||
|
||||
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>
|
||||
<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>
|
||||
<StatusBadge status={node.status} />
|
||||
</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">
|
||||
<span>System</span>
|
||||
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span>
|
||||
<span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Location</span>
|
||||
<span className="text-gray-300">
|
||||
<span className="text-ink-2">
|
||||
{node.lat != null && node.lon != null
|
||||
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
|
||||
: "Unknown"}
|
||||
@@ -42,23 +43,32 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Last seen</span>
|
||||
<span className="text-gray-300">{lastSeen}</span>
|
||||
<span className="text-ink-2">{lastSeen}</span>
|
||||
</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 && (
|
||||
<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
|
||||
</div>
|
||||
)}
|
||||
{node.is_overridden && (
|
||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-line pt-2 flex justify-between">
|
||||
<span>⚠ Local Override</span>
|
||||
{node.override_timeout_at ? (
|
||||
<span className="text-gray-500">
|
||||
<span className="text-ink-muted">
|
||||
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">Permanent</span>
|
||||
<span className="text-ink-muted">Permanent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -51,20 +51,24 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<p className="text-gray-400 text-sm mb-5">
|
||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||
Assign it a radio system to begin monitoring.
|
||||
<p className="text-ink-2 text-sm mb-5">
|
||||
{node.configured ? (
|
||||
<><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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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
|
||||
value={systemId}
|
||||
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
|
||||
>
|
||||
<option value="">Select a system…</option>
|
||||
@@ -77,41 +81,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
</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
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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) => (
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{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>
|
||||
<label className="block text-xs text-gray-400 mb-1">
|
||||
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||
<label className="block text-xs text-ink-2 mb-1">
|
||||
PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={ppm}
|
||||
onChange={(e) => setPpm(e.target.value)}
|
||||
step="0.1"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||
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"
|
||||
/>
|
||||
</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
|
||||
value={nodeType}
|
||||
onChange={(e) => setNodeType(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||
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
|
||||
>
|
||||
<option value="">Select node type...</option>
|
||||
@@ -127,9 +131,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
id="enforceTimeout"
|
||||
checked={enforceTimeout}
|
||||
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||
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)
|
||||
</label>
|
||||
</div>
|
||||
@@ -148,7 +152,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
// 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
|
||||
// the Discord servers linked to this node's org and the voice channels the
|
||||
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
|
||||
// (desired state — the node reconciles and reports back, shown per row).
|
||||
// the Discord servers linked to this node's org and their voice channels, as
|
||||
// read by a radio bot in the server or pushed by the DRB server bot — either
|
||||
// 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
|
||||
// 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
|
||||
@@ -211,8 +213,9 @@ function DiscordPicker({
|
||||
<legend className="text-xs text-ink-muted mb-2">Discord server</legend>
|
||||
{usable.length === 0 ? (
|
||||
<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
|
||||
link it to your organization; it shows up here once the bot reports its channels.
|
||||
No Discord server is linked to your organization yet. The organization owner connects one under
|
||||
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>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
@@ -259,12 +262,13 @@ function DiscordPicker({
|
||||
<legend className="text-xs text-ink-muted mb-2">Voice channel</legend>
|
||||
{!guild.synced ? (
|
||||
<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
|
||||
minutes — check back shortly.
|
||||
DRB hasn't read this server's voice channels yet. A radio bot in the server lists them
|
||||
(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>
|
||||
) : guild.voice_channels.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
||||
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
|
||||
return (
|
||||
<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 && (
|
||||
<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{" "}
|
||||
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
||||
)}
|
||||
<ul className="space-y-1 font-mono text-xs">
|
||||
{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>
|
||||
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>}
|
||||
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>}
|
||||
{c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
|
||||
{c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
|
||||
{c.encrypted ? (
|
||||
<span className="text-red-400">
|
||||
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
|
||||
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
|
||||
</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"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
|
||||
}
|
||||
|
||||
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({
|
||||
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
||||
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
|
||||
<p className="text-xs text-ink-muted font-mono mb-3">
|
||||
{devicesKnown
|
||||
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||
: "This node hasn't reported its SDRs yet."}
|
||||
</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-1 min-w-0">
|
||||
<div className="text-gray-200">OP25 SDR</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-ink">OP25 SDR</div>
|
||||
<div className="text-xs text-ink-muted">
|
||||
{op25Moving
|
||||
? "Saving restarts OP25 on the selected SDR."
|
||||
: node.op25_sdr_serial
|
||||
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
: "Waiting for SDR";
|
||||
return (
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={row.enabled}
|
||||
disabled={!canEdit}
|
||||
aria-label={`Enable ${meta.name}`}
|
||||
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="text-gray-200">{meta.name}</div>
|
||||
<div className="text-xs text-gray-500">{meta.hint}</div>
|
||||
<div className="text-ink">{meta.name}</div>
|
||||
<div className="text-xs text-ink-muted">{meta.hint}</div>
|
||||
</div>
|
||||
<DeviceSelect
|
||||
value={row.pin}
|
||||
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0}
|
||||
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>
|
||||
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === rows.length - 1}
|
||||
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>
|
||||
</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}
|
||||
</span>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
})}
|
||||
</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
|
||||
antenna, or leave it on "Any spare SDR".
|
||||
</p>
|
||||
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
|
||||
const styles: Record<NodeStatus, string> = {
|
||||
online: "bg-green-950 text-green-400 border border-green-800",
|
||||
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",
|
||||
};
|
||||
|
||||
|
||||
@@ -26,12 +26,20 @@ const BUCKETS = 48;
|
||||
* `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
|
||||
* 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[];
|
||||
incidents?: IncidentRecord[];
|
||||
selectedIncidentId?: string | null;
|
||||
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
|
||||
|
||||
@@ -56,6 +64,24 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
|
||||
|
||||
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 now = Date.now();
|
||||
const start = now - range.ms;
|
||||
@@ -88,7 +114,28 @@ export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null,
|
||||
})}
|
||||
</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) => {
|
||||
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 color =
|
||||
b.worstRank === 3
|
||||
|
||||
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
|
||||
|
||||
return (
|
||||
<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
|
||||
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.
|
||||
</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-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) => {
|
||||
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;
|
||||
@@ -77,8 +77,8 @@ export function CameraProvidersTab() {
|
||||
<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-gray-400 text-xs">{p.licence.label}</p>
|
||||
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p>
|
||||
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||
<p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right space-y-1">
|
||||
<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"}`} />
|
||||
</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>
|
||||
</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>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.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
||||
</div>
|
||||
{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 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-gray-500">
|
||||
<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>
|
||||
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
|
||||
|
||||
{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="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>
|
||||
<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>
|
||||
<label className="flex items-start gap-2 text-sm text-gray-200">
|
||||
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</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 imagery. 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-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)}
|
||||
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function OutageProvidersTab() {
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<p className="text-gray-400 text-xs leading-snug">
|
||||
<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
|
||||
@@ -68,9 +68,9 @@ export function OutageProvidersTab() {
|
||||
</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-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) => {
|
||||
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;
|
||||
@@ -80,8 +80,8 @@ export function OutageProvidersTab() {
|
||||
<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-gray-400 text-xs">{p.licence.label}</p>
|
||||
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</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
|
||||
@@ -96,27 +96,27 @@ export function OutageProvidersTab() {
|
||||
>
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
{p.implemented && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400">
|
||||
<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-gray-400">{p.coverage_note}</p>}
|
||||
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</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-gray-500">
|
||||
<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>
|
||||
@@ -134,17 +134,17 @@ export function OutageProvidersTab() {
|
||||
|
||||
{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-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="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-gray-300 text-sm leading-snug">{confirming.licence.warning}</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-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" />
|
||||
<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-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)}
|
||||
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||
</div>
|
||||
|
||||
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
|
||||
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
||||
|
||||
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 =
|
||||
"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 {
|
||||
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);
|
||||
|
||||
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>
|
||||
<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} />
|
||||
</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} />
|
||||
</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`} />
|
||||
</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">
|
||||
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "reuse" && (
|
||||
<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}>
|
||||
<option value="">— pick a finished run —</option>
|
||||
{sources.map((r) => (
|
||||
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{systems.length > 1 && (
|
||||
<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) => (
|
||||
<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)} />
|
||||
{s.name}
|
||||
</label>
|
||||
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
||||
</div>
|
||||
|
||||
{est && (
|
||||
<p className="text-sm font-mono text-gray-300">
|
||||
<p className="text-sm font-mono text-ink-2">
|
||||
{est.truncated ? (
|
||||
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
||||
) : (
|
||||
@@ -193,15 +193,15 @@ function RunsTable({
|
||||
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";
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm font-mono">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800">
|
||||
<tr className="border-b border-line">
|
||||
<th className={th}>Run</th>
|
||||
<th className={th}>Range</th>
|
||||
<th className={th}>Status</th>
|
||||
@@ -222,13 +222,13 @@ function RunsTable({
|
||||
<tr
|
||||
key={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}>
|
||||
<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 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}>
|
||||
{running ? (
|
||||
<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-gray-400">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||
<span className="text-ink-2">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||
</>
|
||||
) : "—"}
|
||||
</td>
|
||||
@@ -255,7 +255,7 @@ function RunsTable({
|
||||
{running ? (
|
||||
<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>
|
||||
</tr>
|
||||
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
|
||||
} catch { /* audio is a convenience */ }
|
||||
}
|
||||
return (
|
||||
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono">
|
||||
<div className="flex flex-wrap gap-2 text-gray-500">
|
||||
<div className="py-1.5 border-t border-line/60 text-xs font-mono">
|
||||
<div className="flex flex-wrap gap-2 text-ink-muted">
|
||||
<span>{fmtTime(call.started_at)}</span>
|
||||
<span>{call.talkgroup_name}</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.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 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 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>
|
||||
);
|
||||
}
|
||||
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
||||
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">
|
||||
<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-gray-500 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-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span>
|
||||
<span className="text-ink-muted text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</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-ink-muted"}`}>{inc.resolved_via ?? inc.status}</span>
|
||||
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
||||
</button>
|
||||
{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 map pin", m.incidents_with_coords],
|
||||
].map(([k, v]) => (
|
||||
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2">
|
||||
<div className="text-gray-500">{k}</div>
|
||||
<div key={k as string} className="bg-surface border border-line rounded-lg p-2">
|
||||
<div className="text-ink-muted">{k}</div>
|
||||
<div className="text-white text-base">{v}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{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(" · ")}
|
||||
<br />
|
||||
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{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:{" "}
|
||||
{Object.entries(m.gemini_usage)
|
||||
.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>)}
|
||||
</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>}
|
||||
{data && (
|
||||
<>
|
||||
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
||||
<button
|
||||
key={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"}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
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})
|
||||
</button>
|
||||
</div>
|
||||
{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} />)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -443,7 +443,7 @@ export function ReplayTab() {
|
||||
|
||||
return (
|
||||
<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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// "/drb link code:" flow is server-26#213 item 1 and is not built here.
|
||||
// Linking here is PUT /discord/guilds/{id}/link (platform admin, support).
|
||||
// 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
|
||||
// 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 }[] };
|
||||
|
||||
function RemoveForm({
|
||||
@@ -131,7 +159,7 @@ function RemoveForm({
|
||||
</span>
|
||||
</label>
|
||||
<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>
|
||||
{error && <p className="text-sev-major">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
@@ -171,8 +199,9 @@ export function ServersPanel({
|
||||
fallback ? (
|
||||
<>
|
||||
<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
|
||||
to an empty string once every real server is linked.
|
||||
server the DRB server bot is invited to can use that org's systems until it is linked here (a radio
|
||||
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.</>
|
||||
@@ -189,14 +218,16 @@ export function ServersPanel({
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{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) => {
|
||||
const row = (
|
||||
<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.linked && g.acting_org_id && <div className={DIM}>fallback, not linked</div>}
|
||||
</td>
|
||||
<td className={TD}>
|
||||
<BotsCell guild={g} />
|
||||
</td>
|
||||
<td className={TD}>
|
||||
{g.live_streams} / {max}
|
||||
</td>
|
||||
@@ -251,7 +285,7 @@ export function ServersPanel({
|
||||
return [
|
||||
row,
|
||||
<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
|
||||
guild={g}
|
||||
onCancel={() => setRemoving(null)}
|
||||
@@ -269,7 +303,7 @@ export function ServersPanel({
|
||||
return [
|
||||
row,
|
||||
<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)} />
|
||||
</td>
|
||||
</tr>,
|
||||
|
||||
@@ -348,7 +348,8 @@ export function StreamsPanel({
|
||||
)}
|
||||
{overview.streams.length === 0 ? (
|
||||
<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>
|
||||
) : (
|
||||
<Table head={["Server · channel", "Source", "Talkgroups", "Mode", "Desired → actual", "Bot", ""]}>
|
||||
|
||||
@@ -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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,13 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
||||
import { CONTEXT_KM, NEAR_CAMERA_KM, bboxAround, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
||||
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";
|
||||
|
||||
@@ -26,8 +32,10 @@ function sevColor(s: string | null | undefined) {
|
||||
function sevLabel(s: string | null | undefined) {
|
||||
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
||||
}
|
||||
function elapsed(iso: string): string {
|
||||
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
|
||||
/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
|
||||
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`;
|
||||
}
|
||||
function hhmm(iso: string): string {
|
||||
@@ -43,7 +51,7 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came
|
||||
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">
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
@@ -178,6 +186,126 @@ function OutageFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; node
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── 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 ──────────────────────────────────────────────────────────
|
||||
export function RegionPanel({
|
||||
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
||||
@@ -222,7 +350,7 @@ export function RegionPanel({
|
||||
{wallCameras.map(({ cam, km }) => (
|
||||
<div key={cam.id} className="relative">
|
||||
<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>
|
||||
@@ -241,7 +369,8 @@ export function RegionPanel({
|
||||
<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>
|
||||
))}
|
||||
<OutageFeedRow incidents={incidents} nodes={nodes} />
|
||||
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
|
||||
<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>
|
||||
</div>
|
||||
<div className={SEC}>
|
||||
@@ -295,15 +424,22 @@ export function IncidentPanel({
|
||||
onOpenCamera: (c: Camera) => void;
|
||||
}) {
|
||||
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 radar = useRadarDbz(points.at(-1) ?? null);
|
||||
// Stop numbers share the index with the map path: geocoded calls, oldest first.
|
||||
const stopOf = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||
.forEach((c, i) => m.set(c.call_id, i + 1));
|
||||
return m;
|
||||
}, [calls]);
|
||||
const head = points.at(-1) ?? null;
|
||||
const radar = useRadarDbz(head);
|
||||
// Stop numbers come from the same pathStops() the map draws from, so the
|
||||
// spine numbers exactly what the map numbers (server-26#236): only movement
|
||||
// incidents get numbers, and a ghosted (impossible-speed) stop gets none.
|
||||
const path = useMemo(() => pathStops(inc, calls), [inc, calls]);
|
||||
const stopOf = useMemo(() => stopNumbers(path), [path]);
|
||||
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]);
|
||||
// "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]);
|
||||
@@ -328,14 +464,24 @@ export function IncidentPanel({
|
||||
<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="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>
|
||||
</div>
|
||||
<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">
|
||||
{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>
|
||||
{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>
|
||||
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
|
||||
</div>
|
||||
@@ -347,6 +493,7 @@ export function IncidentPanel({
|
||||
|
||||
<div className={SEC}>
|
||||
<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 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{near.map(({ cam, km }) => (
|
||||
@@ -365,6 +512,7 @@ export function IncidentPanel({
|
||||
|
||||
<div className={SEC}>
|
||||
<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">
|
||||
{ctx.aircraft.map(({ a, km }) => (
|
||||
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
||||
@@ -386,8 +534,11 @@ export function IncidentPanel({
|
||||
<li>
|
||||
<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"
|
||||
: 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>
|
||||
<WeatherWarningContext head={head} />
|
||||
{wantsWind(inc) && <WindContext head={head} />}
|
||||
<OutageContext inc={inc} points={points} />
|
||||
</ul>
|
||||
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
||||
@@ -397,7 +548,7 @@ export function IncidentPanel({
|
||||
|
||||
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
||||
<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>
|
||||
<div>
|
||||
@@ -441,6 +592,7 @@ export function IncidentPanel({
|
||||
<ol className="space-y-2">
|
||||
{shown.map((c) => {
|
||||
const stop = stopOf.get(c.call_id);
|
||||
const ghost = ghostIds.has(c.call_id);
|
||||
const text = c.transcript_corrected || c.transcript;
|
||||
const isPlaying = c.call_id === playingCallId;
|
||||
return (
|
||||
@@ -448,7 +600,10 @@ export function IncidentPanel({
|
||||
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]"
|
||||
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}
|
||||
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">
|
||||
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
||||
|
||||
@@ -7,7 +7,7 @@ interface EmptyStateProps {
|
||||
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) {
|
||||
return (
|
||||
<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 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 {
|
||||
variant?: Variant;
|
||||
@@ -62,7 +70,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
|
||||
const body =
|
||||
detail ??
|
||||
"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") {
|
||||
// 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 (
|
||||
<p
|
||||
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)
|
||||
.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 };
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
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";
|
||||
|
||||
@@ -18,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
...(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;
|
||||
return res.json();
|
||||
}
|
||||
@@ -532,6 +534,16 @@ export const c2api = {
|
||||
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
|
||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||
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);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
||||
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
||||
|
||||
import { legacyErrorText } from "@/lib/c2error";
|
||||
|
||||
export interface RadioBotLease {
|
||||
node_id: string;
|
||||
lease_id?: string;
|
||||
@@ -85,6 +87,14 @@ export interface DiscordGuild {
|
||||
acting_org_id: string | null;
|
||||
acting_org_name: string | null;
|
||||
live_streams: number;
|
||||
/** How it was linked: "admin" | "code" | "oauth" (absent on older links). */
|
||||
linked_via?: string | null;
|
||||
/** The DRB server bot (optional, server-26#225): true/false as it last reported, null = never heard. */
|
||||
main_bot_present?: boolean | null;
|
||||
/** Who listed its voice channels: "main_bot" | "radio_bot". */
|
||||
directory_source?: string | null;
|
||||
/** Radio bots Discord last said are in this server (names). */
|
||||
radio_bots?: string[];
|
||||
}
|
||||
|
||||
export interface DiscordAdminOverview {
|
||||
@@ -152,7 +162,7 @@ export const SNOWFLAKE = /^\d{15,21}$/;
|
||||
* so "no bot in this server" can render its invite links.
|
||||
*/
|
||||
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const raw = legacyErrorText(err);
|
||||
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||
if (m) {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
// browser: the code exchange happens in c2-core.
|
||||
|
||||
import { auth } from "@/lib/firebase";
|
||||
import { C2ApiError } from "@/lib/c2error";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -24,7 +25,7 @@ async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: o
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
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;
|
||||
return res.json();
|
||||
}
|
||||
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
|
||||
try {
|
||||
raw = await post("/discord/oauth/start", req);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message);
|
||||
if (err instanceof C2ApiError && err.status === 404) throw new OAuthNotConfigured(err.message);
|
||||
throw err;
|
||||
}
|
||||
const url = raw.url ?? raw.authorize_url ?? "";
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
|
||||
// the server never sends them to the browser.
|
||||
|
||||
import { legacyErrorText } from "@/lib/c2error";
|
||||
|
||||
export type StreamState =
|
||||
| "pending"
|
||||
| "connecting"
|
||||
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
|
||||
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
||||
* detail, code and invite links back out of a JSON body. */
|
||||
export function parseC2Error(err: unknown): ParsedC2Error {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
const raw = legacyErrorText(err);
|
||||
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||
const status = m ? Number(m[1]) : null;
|
||||
const idx = raw.indexOf(": ");
|
||||
|
||||
+348
-30
@@ -3,7 +3,9 @@
|
||||
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in
|
||||
* isolation and shared by MapView and LiveView.
|
||||
*/
|
||||
import type { AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack } from "@/lib/types";
|
||||
import type {
|
||||
AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack, WeatherAlert, WeatherGeometry,
|
||||
} from "@/lib/types";
|
||||
import { severityRank } from "@/lib/severity";
|
||||
|
||||
export type LatLon = [number, number];
|
||||
@@ -34,18 +36,125 @@ export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecor
|
||||
/**
|
||||
* An incident's geography, oldest first: its geocoded call stops (the path —
|
||||
* same order as the call spine) or, failing that, its pin. Empty = not plotted.
|
||||
* Ghosted stops (an implied speed no vehicle makes, server-26#236) are left
|
||||
* out, so one bad geocode can't drag "nearest cameras" across the county.
|
||||
*/
|
||||
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
|
||||
const stops = (calls ?? [])
|
||||
.filter((c) => c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||
.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon);
|
||||
const stops = pathStops(inc, calls).stops.filter((s) => !s.ghost).map((s) => s.at);
|
||||
if (stops.length >= 2) return stops;
|
||||
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
|
||||
return stops;
|
||||
}
|
||||
|
||||
// ── Honest paths (server-26#236) ─────────────────────────────────────────────
|
||||
// Pure, and shared by MapView and the call spine (WallPanels), so a stop's
|
||||
// number is identical on both — and only what the map numbers is numbered.
|
||||
|
||||
/**
|
||||
* Mirrors `_PURSUIT_TAGS` in drb-c2-core/app/internal/incident_correlator.py.
|
||||
* Copied, not imported: keep the two in step by hand.
|
||||
*/
|
||||
export const PURSUIT_TAGS: ReadonlySet<string> = new Set([
|
||||
"vehicle-pursuit", "pursuit", "foot-pursuit", "chase",
|
||||
"fleeing-vehicle", "suspect-vehicle", "eluding",
|
||||
]);
|
||||
|
||||
/** Mirrors `_MAX_PURSUIT_SPEED_KM_PER_MIN` (incident_correlator.py) — ~300 km/h. */
|
||||
export const MAX_PATH_SPEED_KM_PER_MIN = 8.0;
|
||||
/**
|
||||
* Mirrors settings.location_proximity_km: the backend only runs its speed
|
||||
* check past this distance, so two geocodes of one scene a few seconds apart
|
||||
* are never called impossible.
|
||||
*/
|
||||
export const SPEED_CHECK_MIN_KM = 0.5;
|
||||
/** A segment spanning a longer silence than this is drawn dashed — we don't know the route. */
|
||||
export const PATH_GAP_DASH_MIN = 10;
|
||||
/** Mirrors `_PURSUIT_PROXIMITY_KM`: a path longer than a pursuit's own link radius is a merge suspect. */
|
||||
export const MAY_BE_MERGED_KM = 20;
|
||||
|
||||
/** Police or accident incident carrying a pursuit tag — the only kind that draws a numbered path. */
|
||||
export function isMovementIncident(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
|
||||
if (inc.type !== "police" && inc.type !== "accident") return false;
|
||||
return (inc.tags ?? []).some((t) => PURSUIT_TAGS.has(t.toLowerCase()));
|
||||
}
|
||||
|
||||
export interface PathStop {
|
||||
call: CallRecord;
|
||||
at: LatLon;
|
||||
/** Shared map/spine stop number; null = not numbered (ghosted, or not a movement incident). */
|
||||
n: number | null;
|
||||
/** Reaching this stop from the previous kept one implies > MAX_PATH_SPEED_KM_PER_MIN. */
|
||||
ghost: boolean;
|
||||
/** The segment INTO this stop is dashed: a long silence or a talkgroup change. Never set on the first kept stop. */
|
||||
dashedIn: boolean;
|
||||
}
|
||||
|
||||
export interface IncidentPath {
|
||||
movement: boolean;
|
||||
/** Every geocoded call, oldest first — ghosts included, so the map can still show them faintly. */
|
||||
stops: PathStop[];
|
||||
/** Sum of the kept (non-ghost) segments. */
|
||||
lengthKm: number;
|
||||
mayBeMerged: boolean;
|
||||
}
|
||||
|
||||
function minutesBetween(a: CallRecord, b: CallRecord): number {
|
||||
return Math.abs(Date.parse(b.started_at) - Date.parse(a.started_at)) / 60_000;
|
||||
}
|
||||
|
||||
function impossible(a: PathStop, b: PathStop): boolean {
|
||||
const d = km(a.at, b.at);
|
||||
if (d <= SPEED_CHECK_MIN_KM) return false;
|
||||
// Same floor the backend uses (incident_correlator.py: max(idle, 0.1)).
|
||||
return d / Math.max(minutesBetween(a.call, b.call), 0.1) > MAX_PATH_SPEED_KM_PER_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* An incident's geocoded calls as path stops, oldest first.
|
||||
*
|
||||
* Ghosting walks forward from the last kept stop: a stop that could only be
|
||||
* reached faster than a pursuit can drive is ghosted and does not move the
|
||||
* anchor. A bad FIRST geocode would otherwise ghost everything after it, so
|
||||
* when stop 1 is impossible from stop 0 but stop 2 agrees with stop 1 and not
|
||||
* with stop 0, stop 0 is the one ghosted.
|
||||
*/
|
||||
export function pathStops(inc: IncidentRecord, calls: CallRecord[] | undefined): IncidentPath {
|
||||
const movement = isMovementIncident(inc);
|
||||
const stops: PathStop[] = (calls ?? [])
|
||||
.filter((c) => c.location_coords)
|
||||
.slice()
|
||||
.sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||||
.map((c) => ({ call: c, at: [c.location_coords!.lat, c.location_coords!.lng] as LatLon, n: null, ghost: false, dashedIn: false }));
|
||||
|
||||
let start = 0;
|
||||
if (stops.length >= 3 && impossible(stops[0], stops[1]) && !impossible(stops[1], stops[2]) && impossible(stops[0], stops[2])) {
|
||||
stops[0].ghost = true;
|
||||
start = 1;
|
||||
}
|
||||
let prev: PathStop | null = null;
|
||||
let lengthKm = 0;
|
||||
let n = 0;
|
||||
for (let i = start; i < stops.length; i++) {
|
||||
const s = stops[i];
|
||||
if (prev && impossible(prev, s)) { s.ghost = true; continue; }
|
||||
if (prev) {
|
||||
lengthKm += km(prev.at, s.at);
|
||||
s.dashedIn = minutesBetween(prev.call, s.call) > PATH_GAP_DASH_MIN
|
||||
|| (prev.call.talkgroup_id != null && s.call.talkgroup_id != null && prev.call.talkgroup_id !== s.call.talkgroup_id);
|
||||
}
|
||||
if (movement) s.n = ++n;
|
||||
prev = s;
|
||||
}
|
||||
return { movement, stops, lengthKm, mayBeMerged: lengthKm > MAY_BE_MERGED_KM };
|
||||
}
|
||||
|
||||
/** call_id → stop number, for the spine. Empty for a non-movement incident. */
|
||||
export function stopNumbers(path: IncidentPath): Map<string, number> {
|
||||
const m = new Map<string, number>();
|
||||
for (const s of path.stops) if (s.n != null) m.set(s.call.call_id, s.n);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
|
||||
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
|
||||
return incidents
|
||||
@@ -154,7 +263,7 @@ export function bboxAround(points: LatLon[], padKm: number) {
|
||||
|
||||
// ── Deep links / saved views ────────────────────────────────────────────────
|
||||
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
|
||||
// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light
|
||||
// &layers=nodes,inc,radar,wx&z=11&lat=41.12345&lon=-73.98765&base=light
|
||||
// &f.<name>=<value>
|
||||
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
|
||||
// only) still parses and a newer one degrades on an older build. Pure string
|
||||
@@ -171,6 +280,7 @@ export const LAYER_NAMES = {
|
||||
vs: "Camera Viewsheds (approx.)",
|
||||
traffic: "Traffic Events",
|
||||
radar: "Weather Radar",
|
||||
wx: "Weather Warnings",
|
||||
} as const;
|
||||
export type LayerKey = keyof typeof LAYER_NAMES;
|
||||
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
|
||||
@@ -300,28 +410,103 @@ export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallR
|
||||
);
|
||||
}
|
||||
|
||||
// ── NEXRAD sample near an incident ───────────────────────────────────────────
|
||||
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM
|
||||
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale.
|
||||
// Approximate by construction — label it so.
|
||||
const N0R_PALETTE: [number, number, number, number][] = [
|
||||
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0],
|
||||
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0],
|
||||
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253],
|
||||
];
|
||||
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913";
|
||||
// ── NEXRAD (IEM n0q composite) ───────────────────────────────────────────────
|
||||
// The Weather Radar layer, its valid-time badge, its legend and the per-incident
|
||||
// sample all use ONE product: IEM's n0q (base reflectivity, 0.5 dBZ steps,
|
||||
// -32..95 dBZ). Verified first-hand 2026-10-05:
|
||||
// tiles https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png
|
||||
// (RGBA PNG, ACAO: *), and nexrad-n0q-900913-m05m … -m55m for the
|
||||
// 5-minute frames of the last hour (-m60m is a 404);
|
||||
// valid https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json
|
||||
// → {"meta": {"valid": "2026-10-05T07:35:00Z", "product": "N0Q", …}}
|
||||
// (n0q_<k>.json is k × 5 min earlier; ACAO: *);
|
||||
// ramp https://mesonet.agron.iastate.edu/GIS/rasters.php?rid=2 — 255 colours,
|
||||
// index i (1..255) = -32 + (i-1)/2 dBZ. Every opaque pixel of five
|
||||
// sampled live tiles matched this table exactly.
|
||||
export const RADAR_TILE_BASE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913";
|
||||
export const RADAR_META_URL = "https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json";
|
||||
/** A frame older than this is flagged STALE on the map (#237 acceptance). */
|
||||
export const RADAR_STALE_MIN = 15;
|
||||
/** Loop frames, oldest first, as minutes before the current composite (IEM keeps -m05m … -m55m). */
|
||||
export const RADAR_LOOP_OFFSETS_MIN = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] as const;
|
||||
/** Light rain starts around here; weaker returns are clear-air / clutter. */
|
||||
export const PRECIP_ECHO_DBZ = 15;
|
||||
|
||||
function pixelDbz(r: number, g: number, b: number): number {
|
||||
let best = 0;
|
||||
let bestD = Infinity;
|
||||
for (const [dbz, pr, pg, pb] of N0R_PALETTE) {
|
||||
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
||||
if (d < bestD) { bestD = d; best = dbz; }
|
||||
}
|
||||
return bestD < 60 * 60 ? best : 0;
|
||||
/** Tile URL for the composite `offsetMin` minutes before the current one (0 = current). */
|
||||
export function radarTileUrl(offsetMin = 0): string {
|
||||
const layer = offsetMin > 0 ? `${RADAR_TILE_BASE}-m${String(offsetMin).padStart(2, "0")}m` : RADAR_TILE_BASE;
|
||||
return `${layer}/{z}/{x}/{y}.png`;
|
||||
}
|
||||
|
||||
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */
|
||||
/** The composite's valid time from IEM's n0q metadata JSON, or null if the shape isn't the verified one. */
|
||||
export function parseRadarMeta(body: unknown): Date | null {
|
||||
const valid = (body as { meta?: { valid?: unknown } } | null)?.meta?.valid;
|
||||
if (typeof valid !== "string") return null;
|
||||
const d = new Date(valid);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
/** IEM's published n0q colour table, 6 hex digits per entry, index 1..255 (index 0 = missing). */
|
||||
const N0Q_RAMP_HEX =
|
||||
"85718f85728f86738d87758b87768b887789897987897a878a7b858b7d848b7e848c7f828d81808d82808e837e8f847c" +
|
||||
"8f857c90877b918879918979928b77938d759691539894579b975b9d9a60a09d64a3a068a5a36da8a671aaa976adac7a" +
|
||||
"b0af7eb2b283b7b88cbabb90bdbe94bfc199c2c49dc4c7a2c7caa6cacdaaccd0afd2d4b4cfd2b4c9ccb4c6c9b4c3c7b4" +
|
||||
"c0c4b4bdc1b4b9beb4b6bbb4b3b9b4b0b6b4adb3b4aab0b4a4abb4a0a8b49da5b49aa2b497a0b4949db4919ab4949bb5" +
|
||||
"9098b48c95b38892b2808cb07c89af7886ae7483ac7080ab6c7daa6779a96376a85f73a75b70a6576da44f67a24b64a1" +
|
||||
"4761a0435e9f415b9e4361a24568a6486faa4a76ae4d7db24f84b6518bbb5699c3599fc75ba6cb5eadcf60b4d462bbd8" +
|
||||
"65c2dc67c9e06ad0e46fd6e868d6d759d6b352d6a24bd69043d67e3cd66d35d65b11d51811d11710cd1710c81610c416" +
|
||||
"0fbc150fb7140eb3140eaf130eab130da6120da2120d9e110c99110c95100c91100b880f0b840e0a800e0a7c0d0a770d" +
|
||||
"09730c096f0c096b0b08660b08620a095e09327308467d085b88076f9207849d0698a806adb205c1bd05d6c704ead204" +
|
||||
"ffe200ffd800ffd300ffce00ffc900ffc400ffc000ffbb00ffb600ffb100ffac00ffa700ffa200ff9900ff9400ff8f00" +
|
||||
"ff8a00ff8500ff8000ff0000f80000f10000ea0000e30000d50000cd0000c60000bf0000b80000b10000aa0000a30000" +
|
||||
"9b00009400008d00007f0000780000710000fffffffff5ffffeaffffdfffffd4ffffc9ffffbeffffb3ffff9dffff92ff" +
|
||||
"ff75fffc6bfdf960faf656f7f34bf4f040f1ed36efea2bece720e9e10be3b200ffac00fca400f79b00f49300ef8800ea" +
|
||||
"8300e87900e27200dd6900db05ecf005ebf005eaf005dde005dce005dbe005cdd005ccd004bdc004bcc004bbc004aeb0" +
|
||||
"04adb0049ea0049da0049ca0038e90038d90038c90037e80037d80036f70036e70036d70025f60025e60024f50024e50" +
|
||||
"024d50023f40023e40023d40013030012f30012020011f20011e203a67b53a66b53a65b53a64b53a63b53a62b5";
|
||||
|
||||
function n0qIndex(dbz: number): number {
|
||||
return Math.min(255, Math.max(1, Math.round((dbz + 32) * 2) + 1));
|
||||
}
|
||||
|
||||
/** "#rrggbb" the radar layer draws for a reflectivity. */
|
||||
export function n0qColor(dbz: number): string {
|
||||
const i = n0qIndex(dbz) - 1;
|
||||
return `#${N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6)}`;
|
||||
}
|
||||
|
||||
let rgbToDbz: Map<number, number> | null = null;
|
||||
function n0qLookup(): Map<number, number> {
|
||||
if (!rgbToDbz) {
|
||||
rgbToDbz = new Map();
|
||||
for (let i = 0; i < 255; i++) rgbToDbz.set(parseInt(N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6), 16), -32 + i / 2);
|
||||
}
|
||||
return rgbToDbz;
|
||||
}
|
||||
|
||||
/** dBZ for a radar-tile pixel. Exact table hit (tiles are lossless); else nearest colour within a small distance; else null. */
|
||||
export function pixelDbz(r: number, g: number, b: number): number | null {
|
||||
const lut = n0qLookup();
|
||||
const exact = lut.get((r << 16) | (g << 8) | b);
|
||||
if (exact != null) return exact;
|
||||
let best: number | null = null;
|
||||
let bestD = 12 * 12;
|
||||
lut.forEach((dbz, rgb) => {
|
||||
const d = (r - ((rgb >> 16) & 255)) ** 2 + (g - ((rgb >> 8) & 255)) ** 2 + (b - (rgb & 255)) ** 2;
|
||||
if (d < bestD) { bestD = d; best = dbz; }
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** No opaque radar pixel near the point (the tile is transparent there). */
|
||||
export const NO_ECHO_DBZ = -Infinity;
|
||||
|
||||
/**
|
||||
* Max reflectivity (dBZ) within ~radiusKm of a point, read from the same n0q
|
||||
* tile the Weather Radar layer draws (cache-busted on the same 5-min clock).
|
||||
* Approximate by construction — label it so. NO_ECHO_DBZ when nothing is
|
||||
* drawn there; null when the tile can't be read.
|
||||
*/
|
||||
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
|
||||
const z = 9;
|
||||
const n = 2 ** z;
|
||||
@@ -332,7 +517,7 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
|
||||
const ty = Math.floor(yf);
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = `${RADAR_TILE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
|
||||
img.src = `${RADAR_TILE_BASE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
|
||||
try {
|
||||
await img.decode();
|
||||
} catch {
|
||||
@@ -359,10 +544,143 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
|
||||
} catch {
|
||||
return null; // tainted canvas — CORS changed upstream
|
||||
}
|
||||
let max = 0;
|
||||
let max = NO_ECHO_DBZ;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] < 32) continue;
|
||||
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2]));
|
||||
if (data[i + 3] < 128) continue; // transparent: nothing drawn
|
||||
const dbz = pixelDbz(data[i], data[i + 1], data[i + 2]);
|
||||
if (dbz != null && dbz > max) max = dbz;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
// ── NWS warnings (server-26#237) ─────────────────────────────────────────────
|
||||
|
||||
/** Ray-cast point-in-ring; `ring` is GeoJSON [lon, lat] pairs. */
|
||||
function inRing(lat: number, lon: number, ring: number[][]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [lat, lon] is inside a GeoJSON Polygon / MultiPolygon (holes
|
||||
* respected: inside the outer ring and outside every hole).
|
||||
*/
|
||||
export function pointInPolygon(point: LatLon, geom: WeatherGeometry | null | undefined): boolean {
|
||||
if (!geom) return false;
|
||||
const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.type === "MultiPolygon" ? geom.coordinates : [];
|
||||
const [lat, lon] = point;
|
||||
return polys.some((rings) =>
|
||||
rings.length > 0 && inRing(lat, lon, rings[0]) && !rings.slice(1).some((hole) => inRing(lat, lon, hole)));
|
||||
}
|
||||
|
||||
/** Active alerts whose drawn area contains the point, worst-looking (Warning) first. */
|
||||
export function alertsAt(point: LatLon, alerts: WeatherAlert[]): WeatherAlert[] {
|
||||
return alerts
|
||||
.filter((a) => pointInPolygon(point, a.geometry))
|
||||
.sort((a, b) => wxKindRank(a.event) - wxKindRank(b.event));
|
||||
}
|
||||
|
||||
export type WxKind = "warning" | "watch" | "advisory" | "statement";
|
||||
|
||||
export function wxKind(event: string): WxKind {
|
||||
const e = event.toLowerCase();
|
||||
if (e.includes("warning") || e.includes("emergency")) return "warning";
|
||||
if (e.includes("watch")) return "watch";
|
||||
if (e.includes("advisory") || e.includes("alert")) return "advisory";
|
||||
return "statement";
|
||||
}
|
||||
function wxKindRank(event: string): number {
|
||||
return ["warning", "watch", "advisory", "statement"].indexOf(wxKind(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* NWS hazard-map colours (weather.gov/help-map) for the events this region
|
||||
* sees most. Unlisted events fall back by kind. Colour is never the only cue:
|
||||
* the outline dash says the kind (warning solid, watch dashed, advisory and
|
||||
* statement dotted), and the legend explains it.
|
||||
*/
|
||||
const NWS_EVENT_COLORS: Record<string, string> = {
|
||||
"tornado warning": "#ff0000",
|
||||
"severe thunderstorm warning": "#ffa500",
|
||||
"flash flood warning": "#8b0000",
|
||||
"flood warning": "#00ff00",
|
||||
"flood advisory": "#00ff7f",
|
||||
"flood watch": "#2e8b57",
|
||||
"flash flood watch": "#2e8b57",
|
||||
"tornado watch": "#ffff00",
|
||||
"severe thunderstorm watch": "#db7093",
|
||||
"special marine warning": "#ffa500",
|
||||
"winter storm warning": "#ff69b4",
|
||||
"winter storm watch": "#4682b4",
|
||||
"winter weather advisory": "#7b68ee",
|
||||
"blizzard warning": "#ff4500",
|
||||
"ice storm warning": "#8b008b",
|
||||
"lake effect snow warning": "#008b8b",
|
||||
"high wind warning": "#daa520",
|
||||
"high wind watch": "#b8860b",
|
||||
"wind advisory": "#d2b48c",
|
||||
"heat advisory": "#ff7f50",
|
||||
"excessive heat warning": "#c71585",
|
||||
"extreme heat warning": "#c71585",
|
||||
"excessive heat watch": "#800000",
|
||||
"extreme heat watch": "#800000",
|
||||
"coastal flood warning": "#228b22",
|
||||
"coastal flood watch": "#66cdaa",
|
||||
"coastal flood advisory": "#7cfc00",
|
||||
"dense fog advisory": "#708090",
|
||||
"special weather statement": "#ffe4b5",
|
||||
"red flag warning": "#ff1493",
|
||||
"fire weather watch": "#ffdead",
|
||||
"freeze warning": "#483d8b",
|
||||
"frost advisory": "#6495ed",
|
||||
"small craft advisory": "#d8bfd8",
|
||||
"gale warning": "#dda0dd",
|
||||
"storm warning": "#9400d3",
|
||||
"hurricane warning": "#dc143c",
|
||||
"tropical storm warning": "#b22222",
|
||||
"air quality alert": "#808080",
|
||||
};
|
||||
const WX_KIND_FALLBACK: Record<WxKind, string> = {
|
||||
warning: "#dc2626", watch: "#eab308", advisory: "#60a5fa", statement: "#a3a3a3",
|
||||
};
|
||||
export const WX_KIND_DASH: Record<WxKind, string | undefined> = {
|
||||
warning: undefined, watch: "8 5", advisory: "2 4", statement: "2 4",
|
||||
};
|
||||
|
||||
export function wxStyle(event: string): { color: string; kind: WxKind; dashArray: string | undefined; weight: number } {
|
||||
const kind = wxKind(event);
|
||||
return {
|
||||
color: NWS_EVENT_COLORS[event.toLowerCase()] ?? WX_KIND_FALLBACK[kind],
|
||||
kind,
|
||||
dashArray: WX_KIND_DASH[kind],
|
||||
weight: kind === "warning" ? 2.5 : kind === "watch" ? 2 : 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
/** Local "Oct 5, 2:30 PM" for an NWS ISO timestamp (they carry their own offset). */
|
||||
export function wxTime(iso: string | null | undefined): 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" });
|
||||
}
|
||||
|
||||
// ── Wind at the incident (server-26#237) ─────────────────────────────────────
|
||||
// Fire and hazmat are where wind direction changes the call (smoke / plume
|
||||
// drift, approach side). There is no hazmat incident type, so tags decide.
|
||||
const WIND_TAG_RE = /(^|-)(hazmat|haz-mat|gas-leak|natural-gas|chemical|spill|smoke|fumes|odor|odour|plume|brush|wildfire|propane|ammonia|chlorine|carbon-monoxide)(-|$)/;
|
||||
|
||||
export function wantsWind(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
|
||||
if (inc.type === "fire") return true;
|
||||
return (inc.tags ?? []).some((t) => WIND_TAG_RE.test(t.toLowerCase()));
|
||||
}
|
||||
|
||||
/** 16-point compass name for the direction the wind blows FROM. */
|
||||
export function compass(deg: number): string {
|
||||
const pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
|
||||
return pts[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
|
||||
// Free disk on the node's host (node-26#41). A full disk stopped node-002
|
||||
// recording for 17 days while every surface still showed it Online, so low
|
||||
// space is flagged wherever a node is shown.
|
||||
|
||||
export type DiskLevel = "ok" | "low" | "critical";
|
||||
|
||||
export interface DiskState {
|
||||
level: DiskLevel;
|
||||
freePct: number;
|
||||
label: string; // e.g. "43.2 GB free of 98 GB (44%)"
|
||||
}
|
||||
|
||||
const LOW_PCT = 15;
|
||||
const CRITICAL_PCT = 5;
|
||||
const CRITICAL_BYTES = 2 * 1024 ** 3; // a 2 GB floor regardless of disk size
|
||||
|
||||
function gb(bytes: number): string {
|
||||
const v = bytes / 1024 ** 3;
|
||||
return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
/** null when the node has never reported (older node software). */
|
||||
export function diskState(node: Pick<NodeRecord, "disk_free_bytes" | "disk_total_bytes">): DiskState | null {
|
||||
const free = node.disk_free_bytes;
|
||||
const total = node.disk_total_bytes;
|
||||
if (free == null || !total) return null;
|
||||
const freePct = Math.max(0, Math.min(100, (free / total) * 100));
|
||||
const level: DiskLevel =
|
||||
freePct < CRITICAL_PCT || free < CRITICAL_BYTES ? "critical" : freePct < LOW_PCT ? "low" : "ok";
|
||||
return { level, freePct, label: `${gb(free)} free of ${gb(total)} (${Math.round(freePct)}%)` };
|
||||
}
|
||||
|
||||
export const DISK_TEXT_CLASS: Record<DiskLevel, string> = {
|
||||
ok: "text-ink-2",
|
||||
low: "text-sev-moderate",
|
||||
critical: "text-sev-major",
|
||||
};
|
||||
@@ -82,6 +82,9 @@ export interface NodeRecord {
|
||||
/** The dongle OP25 is actually using, per the node. */
|
||||
op25_sdr_serial?: string | null;
|
||||
sdr_count?: number | null; // null = never reported
|
||||
/** Host disk the node records to (node-26#41); null/absent = not reported. */
|
||||
disk_free_bytes?: number | null;
|
||||
disk_total_bytes?: number | null;
|
||||
/** node-26#24: conventional channels from the last checkin; [] on a trunked system. */
|
||||
radio_channels?: RadioChannel[] | null;
|
||||
enforce_override_timeout?: boolean;
|
||||
@@ -319,6 +322,77 @@ export interface Ny511Response {
|
||||
credit?: CameraCredit;
|
||||
}
|
||||
|
||||
// ── Weather context (server-26#237) — GET /weather/alerts, GET /weather/wind ──
|
||||
|
||||
/** GeoJSON (Multi)Polygon, [lon, lat] order. */
|
||||
export type WeatherGeometry =
|
||||
| { type: "Polygon"; coordinates: number[][][] }
|
||||
| { type: "MultiPolygon"; coordinates: number[][][][] };
|
||||
|
||||
/** One active NWS alert as c2-core normalises it (app/internal/weather.py normalize_alert). */
|
||||
export interface WeatherAlertBase {
|
||||
id: string;
|
||||
event: string;
|
||||
severity: string | null;
|
||||
urgency: string | null;
|
||||
headline: string | null;
|
||||
onset: string | null;
|
||||
expires: string | null;
|
||||
ends: string | null;
|
||||
/** An NWS office ("NWS Upton NY"), never a person. */
|
||||
sender_name: string | null;
|
||||
area_desc: string | null;
|
||||
zones: string[];
|
||||
}
|
||||
|
||||
export interface WeatherAlert extends WeatherAlertBase {
|
||||
geometry: WeatherGeometry;
|
||||
/** "alert": NWS drew the polygon; "zones": assembled from the affected forecast/county zones. */
|
||||
geometry_source: "alert" | "zones";
|
||||
/** [south, west, north, east]. */
|
||||
bbox: [number, number, number, number] | null;
|
||||
/** >0: some zones could not be fetched, so the drawn area is smaller than the real one. */
|
||||
zones_unresolved?: number;
|
||||
}
|
||||
|
||||
export interface WeatherAlertsResponse {
|
||||
alerts: WeatherAlert[];
|
||||
/** Alerts with no resolvable shape — listed, never dropped, never bbox-filtered. */
|
||||
zone_only: WeatherAlertBase[];
|
||||
/** Epoch s of the server's last SUCCESSFUL NWS fetch; null = never. */
|
||||
fetched_at: number | null;
|
||||
/** Set when the latest refresh failed; `alerts` is then the last good data. */
|
||||
error: string | null;
|
||||
credit: string;
|
||||
}
|
||||
|
||||
export interface WindObs {
|
||||
station: string;
|
||||
station_name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
obs_time: string | null;
|
||||
/** Direction the wind blows FROM, degrees true; null when variable or calm. */
|
||||
wind_dir_deg: number | null;
|
||||
variable: boolean;
|
||||
wind_kt: number;
|
||||
gust_kt: number | null;
|
||||
raw: string | null;
|
||||
distance_km: number;
|
||||
/** Report older than 2 h. */
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export interface WindResponse {
|
||||
available: boolean;
|
||||
/** Why there is no wind, when `available` is false. */
|
||||
reason: string | null;
|
||||
wind: WindObs | null;
|
||||
fetched_at: number | null;
|
||||
error: string | null;
|
||||
credit: string;
|
||||
}
|
||||
|
||||
export interface VocabularyPendingTerm {
|
||||
term: string;
|
||||
source: "induction" | "correction";
|
||||
@@ -344,6 +418,20 @@ export interface TranscriptSegment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Geocoding precision, persisted with the coords since server-26#236.
|
||||
* ROOFTOP = a street address; RANGE_INTERPOLATED = estimated between two
|
||||
* address points; GEOMETRIC_CENTER = the centre of a road, intersection or
|
||||
* named place — can be hundreds of metres off. Absent on older docs.
|
||||
*/
|
||||
export type GeoLocationType = "ROOFTOP" | "RANGE_INTERPOLATED" | "GEOMETRIC_CENTER";
|
||||
|
||||
export interface GeoPoint {
|
||||
lat: number;
|
||||
lng: number;
|
||||
location_type?: GeoLocationType | string | null;
|
||||
}
|
||||
|
||||
export interface CallRecord {
|
||||
call_id: string;
|
||||
node_id: string;
|
||||
@@ -370,7 +458,7 @@ export interface CallRecord {
|
||||
incident_id?: string | null;
|
||||
location: string | null;
|
||||
/** Per-call geocode, written by intelligence.py. Powers the incident-path polyline on the map. */
|
||||
location_coords?: { lat: number; lng: number } | null;
|
||||
location_coords?: GeoPoint | null;
|
||||
/** Unit callsigns mentioned in this specific transmission (e.g. "15-9", "E-41"). */
|
||||
units?: string[];
|
||||
vehicles?: string[];
|
||||
@@ -402,8 +490,16 @@ export interface IncidentRecord {
|
||||
title: string | null;
|
||||
type: string | null;
|
||||
status: "active" | "resolved";
|
||||
/** Set by the idle sweep; a manual resolve (PUT /incidents) doesn't set it. */
|
||||
resolved_at?: string | null;
|
||||
location: string | null;
|
||||
location_coords: { lat: number; lng: number } | null;
|
||||
location_coords: GeoPoint | null;
|
||||
/**
|
||||
* The label the pin was geocoded from (server-26#23). Absent on incidents
|
||||
* written before that fix — their pin cannot be tied to their label and is
|
||||
* drawn ghosted (DEFERRED.md `_verified_pin` row, server-26#28).
|
||||
*/
|
||||
location_coords_source?: string | null;
|
||||
call_ids: string[];
|
||||
system_ids: string[];
|
||||
talkgroup_ids: string[];
|
||||
|
||||
@@ -11,6 +11,10 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// True when the query returned a full page (snap.size, counted BEFORE the
|
||||
// duplicate_of filter below) — there may be older calls past the limit.
|
||||
// calls.length can't answer that: dedup drops rows from a full page.
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const { orgId } = useAuth();
|
||||
|
||||
// Stable ms values so the effect dependency doesn't fire on every render
|
||||
@@ -63,6 +67,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
||||
// index alongside the existing org_id/started_at query.
|
||||
.filter((c) => !c.duplicate_of);
|
||||
setCalls(docs);
|
||||
setHasMore(snap.size >= limitCount);
|
||||
setLoading(false);
|
||||
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
|
||||
});
|
||||
@@ -73,7 +78,7 @@ export function useCalls(limitCount = 50, dateFrom?: Date, dateTo?: Date) {
|
||||
};
|
||||
}, [limitCount, dateFromMs, dateToMs, orgId]);
|
||||
|
||||
return { calls, loading, error };
|
||||
return { calls, loading, error, hasMore };
|
||||
}
|
||||
|
||||
export function useCallsByIncident(incidentId: string | null) {
|
||||
|
||||
@@ -86,14 +86,19 @@ export function useIncidents(limitCount = 100, dateFrom?: Date, dateTo?: Date) {
|
||||
export function useIncident(incidentId: string | null) {
|
||||
const [incident, setIncident] = useState<IncidentRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// The id the current `incident`/`loading` answer belongs to. On the render
|
||||
// where the id changes the effect hasn't run yet, so `loading` alone would
|
||||
// still read false from the previous id and a caller would flash "not found".
|
||||
const [settledId, setSettledId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!incidentId) { setLoading(false); return; }
|
||||
setLoading(true);
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
if (!user) { setLoading(false); return; }
|
||||
if (!user) { setLoading(false); setSettledId(incidentId); return; }
|
||||
|
||||
const ref = doc(db, "incidents", incidentId);
|
||||
unsubFirestore = onSnapshot(ref, (snap) => {
|
||||
@@ -108,27 +113,39 @@ export function useIncident(incidentId: string | null) {
|
||||
setIncident(null);
|
||||
}
|
||||
setLoading(false);
|
||||
setSettledId(incidentId);
|
||||
}, (err: FirestoreError) => {
|
||||
console.error("useIncident:", err);
|
||||
setIncident(null); // a denied/failed read must not leave the previous id's doc
|
||||
setLoading(false);
|
||||
setSettledId(incidentId);
|
||||
});
|
||||
});
|
||||
|
||||
return () => { unsubAuth(); if (unsubFirestore) unsubFirestore(); };
|
||||
}, [incidentId]);
|
||||
|
||||
return { incident, loading };
|
||||
return { incident, loading: loading || (!!incidentId && settledId !== incidentId) };
|
||||
}
|
||||
|
||||
export function useActiveIncidents() {
|
||||
/**
|
||||
* Active incidents plus whether the first SERVER snapshot has landed
|
||||
* (server-26#238). `loaded` is what lets the wall tell "nothing active" from
|
||||
* "not loaded yet" — attention cues baseline on it, so opening the page never
|
||||
* fires a cue for every incident already open. Reset on sign-out / org change.
|
||||
*/
|
||||
export function useActiveIncidentsState(): { incidents: IncidentRecord[]; loaded: boolean } {
|
||||
const [incidents, setIncidents] = useState<IncidentRecord[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const { orgId } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
setLoaded(false);
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
setLoaded(false);
|
||||
|
||||
if (!user) {
|
||||
setIncidents([]);
|
||||
@@ -140,7 +157,16 @@ export function useActiveIncidents() {
|
||||
}
|
||||
|
||||
const q = query(collection(db, "incidents"), where("org_id", "==", orgId), where("status", "==", "active"));
|
||||
unsubFirestore = onSnapshot(q, (snap) => {
|
||||
// includeMetadataChanges: the fromCache → server flip arrives as a
|
||||
// metadata-only snapshot when the data didn't change (e.g. an org with
|
||||
// nothing active, or a start while offline). Without it `loaded` would
|
||||
// stay false until some incident changed, and that change would be
|
||||
// swallowed into the baseline instead of cued.
|
||||
let first = true;
|
||||
unsubFirestore = onSnapshot(q, { includeMetadataChanges: true }, (snap) => {
|
||||
if (!snap.metadata.fromCache) setLoaded(true);
|
||||
if (!first && snap.docChanges().length === 0) return; // metadata only — no new array, no re-render storm
|
||||
first = false;
|
||||
setIncidents(snap.docs.map((d) => {
|
||||
const data = d.data();
|
||||
return {
|
||||
@@ -158,5 +184,9 @@ export function useActiveIncidents() {
|
||||
};
|
||||
}, [orgId]);
|
||||
|
||||
return incidents;
|
||||
return { incidents, loaded };
|
||||
}
|
||||
|
||||
export function useActiveIncidents() {
|
||||
return useActiveIncidentsState().incidents;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type L from "leaflet";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { WeatherAlertsResponse, WindResponse } from "@/lib/types";
|
||||
import type { BBox } from "@/lib/useCameras";
|
||||
import { RADAR_META_URL, parseRadarMeta, type LatLon } from "@/lib/liveWall";
|
||||
|
||||
// Weather context (server-26#237). NWS alerts and METAR wind come from c2-core
|
||||
// (cached server-side, shared by every viewer — NWS rate-limits and wants a
|
||||
// contact User-Agent); the radar valid time comes straight from IEM's n0q
|
||||
// metadata JSON, the same product the radar tiles are cut from.
|
||||
//
|
||||
// Everything polls only while wanted AND the tab is visible, and refetches as
|
||||
// soon as a hidden tab comes back if a poll was missed — a wall left in a
|
||||
// background tab must never show an hour-old warning set as current.
|
||||
const ALERTS_POLL_MS = 2 * 60 * 1000;
|
||||
const RADAR_META_POLL_MS = 2 * 60 * 1000;
|
||||
const WIND_POLL_MS = 5 * 60 * 1000;
|
||||
const MOVE_DEBOUNCE_MS = 400;
|
||||
const BBOX_PAD = 0.2;
|
||||
|
||||
export interface Polled<T> {
|
||||
/** Last successful answer for the current target; null until the first one. */
|
||||
data: T | null;
|
||||
/** Last request failure (network/HTTP), cleared by the next success. */
|
||||
error: string | null;
|
||||
/** At least one request for the current target has finished. */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch `fetcher()` now, every `pollMs` while the tab is visible, and on return
|
||||
* to a visible tab once a poll is due. A newer request's answer always wins.
|
||||
* A new target (deps change) drops the previous answer, so it is never shown
|
||||
* as if it were about the new one.
|
||||
*/
|
||||
function usePolled<T>(
|
||||
fetcher: (() => Promise<T> | null) | null,
|
||||
deps: unknown[],
|
||||
pollMs: number,
|
||||
subscribe?: (reload: () => void) => () => void,
|
||||
): Polled<T> {
|
||||
const [state, setState] = useState<Polled<T>>({ data: null, error: null, loaded: false });
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setState({ data: null, error: null, loaded: false });
|
||||
if (!fetcher) return;
|
||||
const run = fetcher;
|
||||
let last = 0;
|
||||
const load = async () => {
|
||||
const p = run();
|
||||
if (!p) return;
|
||||
const mine = ++seq.current;
|
||||
last = Date.now();
|
||||
try {
|
||||
const data = await p;
|
||||
if (mine !== seq.current) return;
|
||||
setState({ data, error: null, loaded: true });
|
||||
} catch (e) {
|
||||
if (mine !== seq.current) return;
|
||||
console.error("useWeather:", e);
|
||||
setState((s) => ({ ...s, error: e instanceof Error ? e.message : String(e), loaded: true }));
|
||||
}
|
||||
};
|
||||
const onVisible = () => { if (!document.hidden && Date.now() - last >= pollMs) load(); };
|
||||
load();
|
||||
const unsub = subscribe?.(load);
|
||||
const poll = setInterval(() => { if (!document.hidden) load(); }, pollMs);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
unsub?.();
|
||||
clearInterval(poll);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
seq.current++; // drop any in-flight answer
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
|
||||
|
||||
/** Active NWS alerts intersecting the current map view, while `enabled` (the Weather Warnings overlay is on). */
|
||||
export function useMapWeatherAlerts(map: L.Map | null, enabled: boolean): Polled<WeatherAlertsResponse> {
|
||||
return usePolled(
|
||||
map && enabled
|
||||
? () => {
|
||||
// Leaflet bounds run past ±180/±90 zoomed out; the API answers 422 to that.
|
||||
const b = map.getBounds().pad(BBOX_PAD);
|
||||
return c2api.getWeatherAlerts({
|
||||
south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180),
|
||||
north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180),
|
||||
});
|
||||
}
|
||||
: null,
|
||||
[map, enabled],
|
||||
ALERTS_POLL_MS,
|
||||
map
|
||||
? (reload) => {
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
|
||||
map.on("moveend", onMove);
|
||||
return () => { map.off("moveend", onMove); clearTimeout(t); };
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Active NWS alerts intersecting a fixed bbox (an incident, the wall's region). `null` = don't fetch. */
|
||||
export function useWeatherAlertsIn(box: BBox | null): Polled<WeatherAlertsResponse> {
|
||||
const key = box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(3)).join(",") : "";
|
||||
const boxRef = useRef(box);
|
||||
boxRef.current = box;
|
||||
return usePolled(key ? () => (boxRef.current ? c2api.getWeatherAlerts(boxRef.current) : null) : null, [key], ALERTS_POLL_MS);
|
||||
}
|
||||
|
||||
/** Nearest-METAR wind at a point. `null` = don't fetch. */
|
||||
export function useWind(point: LatLon | null): Polled<WindResponse> {
|
||||
// ~100 m key: a re-render with an equal point (new array) must not refetch.
|
||||
const key = point ? `${point[0].toFixed(3)},${point[1].toFixed(3)}` : "";
|
||||
const ref = useRef(point);
|
||||
ref.current = point;
|
||||
return usePolled(key ? () => (ref.current ? c2api.getWind(ref.current[0], ref.current[1]) : null) : null, [key], WIND_POLL_MS);
|
||||
}
|
||||
|
||||
export interface RadarMeta {
|
||||
/** Valid time of the n0q composite (last one successfully read); null when never read. */
|
||||
valid: Date | null;
|
||||
/** Latest metadata refresh failure (fetch failed, or IEM changed the JSON shape). */
|
||||
error: string | null;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
async function fetchRadarMeta(): Promise<Date> {
|
||||
// IEM serves this with ACAO: *, so the browser reads it directly — one tiny
|
||||
// JSON per viewer per poll, like the tiles themselves.
|
||||
const res = await fetch(RADAR_META_URL, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`IEM radar metadata HTTP ${res.status}`);
|
||||
const valid = parseRadarMeta(await res.json());
|
||||
if (!valid) throw new Error("IEM radar metadata has no meta.valid — format changed?");
|
||||
return valid;
|
||||
}
|
||||
|
||||
/** The radar composite's valid time, polled while `enabled` (the Weather Radar overlay is on). */
|
||||
export function useRadarMeta(enabled: boolean): RadarMeta {
|
||||
const s = usePolled(enabled ? fetchRadarMeta : null, [enabled], RADAR_META_POLL_MS);
|
||||
// On a failed refresh the last known valid time is kept AND the error is
|
||||
// reported; the badge shows both, and the stale check keeps ageing it.
|
||||
return { valid: s.data, error: s.error, loaded: s.loaded };
|
||||
}
|
||||
@@ -22,6 +22,17 @@ const config: Config = {
|
||||
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
||||
// class silently generates no rule (map overlays rendered see-through).
|
||||
colors: {
|
||||
// "Iris" palette: the legacy raw gray-*/indigo-* classes (~350 uses)
|
||||
// resolve to graphite + the brand accent instead of Tailwind's slate-blue
|
||||
// defaults, so un-migrated components match the tokens above.
|
||||
gray: {
|
||||
50: "#F7F7F8", 100: "#EDEDEF", 200: "#E4E4E7", 300: "#C9C9D0", 400: "#A1A1AA",
|
||||
500: "#7A7A85", 600: "#4A4B53", 700: "#2A2B31", 800: "#1A1B1F", 900: "#111214", 950: "#08090A",
|
||||
},
|
||||
indigo: {
|
||||
50: "#F0F1FD", 100: "#E2E4FB", 200: "#C8CCF6", 300: "#A8AEF1", 400: "#8B93F0",
|
||||
500: "#6F79E0", 600: "#5E6AD2", 700: "#4E58B8", 800: "#3F4794", 900: "#313770", 950: "#1C1F42",
|
||||
},
|
||||
page: tok("page"),
|
||||
surface: tok("surface"),
|
||||
raised: tok("raised"),
|
||||
@@ -51,7 +62,7 @@ const config: Config = {
|
||||
boxShadow: {
|
||||
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
|
||||
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
|
||||
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
|
||||
glow: "0 0 0 1px rgb(94 106 210 / 0.4), 0 0 24px 0 rgb(94 106 210 / 0.25)",
|
||||
},
|
||||
animation: {
|
||||
"fade-in": "fade-in 0.4s ease-out",
|
||||
|
||||
@@ -4,15 +4,22 @@ Server directory push (server-26#213 item 3).
|
||||
The web node page picks a Discord server + voice channel instead of taking raw
|
||||
IDs. It needs each server's name and voice channels, and this bot already has
|
||||
them in its gateway cache (default intents include ``guilds``), so it pushes
|
||||
them to C2 (PUT /discord/guilds/{id}/directory, service key). C2 never calls
|
||||
Discord on the browser's behalf and no token reaches the browser.
|
||||
them to C2 (PUT /discord/guilds/{id}/directory, service key).
|
||||
|
||||
This bot is optional (server-26#225): in a server it is not in, C2 reads the
|
||||
same list over Discord REST with a radio bot's token (routers/discord_web.py).
|
||||
Where it is present, its push wins while fresh and also tells C2 to drop its
|
||||
cached Discord answers for the server, so a permission fixed in Discord shows
|
||||
up on the next check instead of after the cache expires. No token ever
|
||||
reaches the browser either way.
|
||||
|
||||
Pushed: server name, and per voice channel its id, name, category name and
|
||||
order. Nothing about members, messages or roles.
|
||||
|
||||
When: every server on ready, on join/leave/rename, on any voice-channel or
|
||||
category create/delete/update (debounced per server), and a full re-push every
|
||||
15 minutes in case C2 missed one (it was down, or redeployed).
|
||||
category create/delete/update — including a permission-overwrite change — and
|
||||
on a role permission change or role delete (debounced per server), and a full
|
||||
re-push every 15 minutes in case C2 missed one (it was down, or redeployed).
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
@@ -123,10 +130,22 @@ class DirectorySync(commands.Cog):
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_channel_update(self, before: discord.abc.GuildChannel, after: discord.abc.GuildChannel):
|
||||
# Overwrites too: the push makes C2 drop its cached permission answers
|
||||
# for this server (server-26#225), so "Re-check" sees the fix at once.
|
||||
if _relevant(after) and (before.name != after.name or before.position != after.position
|
||||
or getattr(before, "category_id", None) != getattr(after, "category_id", None)):
|
||||
or getattr(before, "category_id", None) != getattr(after, "category_id", None)
|
||||
or before.overwrites != after.overwrites):
|
||||
self.schedule(after.guild)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_role_update(self, before: discord.Role, after: discord.Role):
|
||||
if before.permissions != after.permissions:
|
||||
self.schedule(after.guild)
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_guild_role_delete(self, role: discord.Role):
|
||||
self.schedule(role.guild)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(DirectorySync(bot))
|
||||
|
||||
@@ -284,7 +284,9 @@ class RadioCommands(commands.Cog):
|
||||
value=(
|
||||
"Link this server to your DRB organization — needed before anything below works. "
|
||||
"An organization owner gets the code in DRB under Settings → Organization → Discord servers; "
|
||||
"someone with Manage Server runs this."
|
||||
"someone with Manage Server runs this.\n"
|
||||
"Or skip this bot entirely: **Connect a Discord server** on that same page adds a radio bot "
|
||||
"and links the server in one step, and radio channels can be started from a node's page in DRB."
|
||||
),
|
||||
inline=False,
|
||||
)
|
||||
@@ -314,7 +316,9 @@ class RadioCommands(commands.Cog):
|
||||
name="Radio bots",
|
||||
value=(
|
||||
"A radio bot has to be a member of this server before it can join a channel — "
|
||||
"if none is, `/join` replies with an invite link.\n"
|
||||
"if none is, `/join` replies with an invite link. It needs View Channel, Connect and Speak "
|
||||
"there; if it can't, `/join` says which permission is missing and where to allow it.\n"
|
||||
"Radio bots work without this bot: it only adds these commands.\n"
|
||||
"The bot's green speaking ring lights up only while radio is transmitting."
|
||||
),
|
||||
inline=False,
|
||||
|
||||
Reference in New Issue
Block a user