Author SHA1 Message Date
logan cea094d66b Merge pull request 'c2-core: fix CORS so the browser can call the REST API (#110)' (#120) from fix/110-c2-core-cors into main
Build & Deploy / Deploy to VM (push) Canceled after 0s
Build & Deploy / Report a failed deploy (push) Canceled after 0s
Build & Deploy / Build & push images (push) Canceled after 1m31s
2026-09-07 19:06:25 -04:00
logan 01c146e21e Merge pull request 'ci: bake NEXT_PUBLIC_MAP_TILE_URL into the frontend build (#117)' (#119) from fix/117-map-tile-build-arg into main
Build & Deploy / Build & push images (push) Failing after 14s
Build & Deploy / Deploy to VM (push) Skipped
Build & Deploy / Report a failed deploy (push) Successful in 1s
2026-09-07 19:06:21 -04:00
Logan CusanoandClaude Sonnet 5 d60fef67ad c2-core: add CORS middleware so the browser can call the REST API (#110)
The Archive page's GET /calls/search failed its CORS preflight (OPTIONS -> 405, no Access-Control-* headers). Allow the app origin(s) explicitly for the standard methods and the authorization/content-type headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:52:38 -04:00
Logan CusanoandClaude Sonnet 5 fe643924c7 ci: bake NEXT_PUBLIC_MAP_TILE_URL into the frontend build (#117)
The map override var was added to MapView.tsx but never passed as a build-arg, so prod still shipped the dead Carto tile URL. Point it at OSM raster tiles.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Tbknwttzou4s46PAykmtix
2026-09-07 18:48:42 -04:00
logan bccb3e0316 correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (#116)
Build & Deploy / Build & push images (push) Successful in 4m6s
Build & Deploy / Deploy to VM (push) Successful in 2m25s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 16:59:46 -04:00
Logan CusanoandClaude Sonnet 5 1a631d65d0 correlator: address #116 review — call talkgroup id in the prompt, sort candidates
drb-correlation-review: ship, with two bounds the low-bar link rule needs.

1. _call_block emitted only the talkgroup NAME while _inc_summary emits
   numeric tg ids, so the "same talkgroup" precondition in _RULES was
   unevaluable and the low link bar applied unconditionally. _call_block now
   prints "Talkgroup: <name> (id <n>)".
2. ctx["recent"] is an unordered Firestore slice with no order_by; a busy 2h
   window (~40 active incidents) showed the model an arbitrary half of the
   candidates. _prompt_incidents() sorts by updated_at desc before the [:20]
   cap — also makes each row's idle: field monotonic.

+2 tests. Full c2-core suite green (sandboxed venv).

Review follow-ups (not blockers): _parse_response demotes an unresolvable
link to orphan (drops the call) rather than falling back to rules — now on
rising link volume; the 45% tiebreak escalation rate / smart-model cost is
untouched; _ROAD_RE swallows leading tokens so "10 Parker Street" still
won't road-overlap "Parker St".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:59:29 -04:00
Logan CusanoandClaude Sonnet 5 3a944f35c1 correlator: give the LLM tier what it needs to link, stop it defaulting to "new" (server-26#115)
The 2026-09-07 measurement window (CORRELATION_REVIEW_0907.md) showed the
consensus tiebreaker was the dominant over-split driver: it ran on 45% of
calls and resolved link/orphan disagreements as "new" ~24/25 of the time,
shattering one Mohegan Park car-alarm job into 9 incidents and opening ~7
incidents from radio checks / roll calls.

Two causes, two fixes:

1. `_inc_summary` gave the model `id|type|loc|units|tags|idle` — no title,
   no talkgroup. It literally could not see that two "car alarms, Mohegan
   Park Ave/Avenue" incidents on TG 9560 were the same. Now includes the
   incident title (the strongest same-event signal) and talkgroup.

2. `_RULES` told the model "orphan when in doubt — conservative is always
   correct". For a system that over-splits, that is backwards: a wrong link
   is cheap, a duplicate incident is the failure. Rewritten to: prefer link
   for a plausible same-talkgroup continuation (low bar), reserve "new" for a
   genuinely different event, and explicitly "orphan" non-incidents (radio
   checks, roll call, 10-8/10-98, mileage logs).

Plus `_extract_road_ids` now canonicalises street-type synonyms
(Avenue→ave, Street→st, Road→rd, ...), so "Mohegan Park Avenue" and
"Mohegan Park Ave" share a road id — that one difference was splitting the
car-alarm incident.

+tests/test_correlator_115.py. Full c2-core suite green (sandboxed venv).
Bigger levers deferred to follow-ups: the consensus escalation itself (should
a cheap-LLM "orphan" ever reach a tiebreak?), a first-class road-overlap fit
signal in _call_fits_incident, geocode coverage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:53:03 -04:00
logan 0712e7a437 correlator: LLM tier reads the scene transcript, not the whole call (#112)
Build & Deploy / Build & push images (push) Successful in 4m1s
Build & Deploy / Deploy to VM (push) Successful in 2m2s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 04:40:34 -04:00
logan a739fa64f0 frontend: safe fixes from the #109 punch-list (#113)
Build & Deploy / Build & push images (push) Successful in 4m5s
Build & Deploy / Deploy to VM (push) Successful in 2m33s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-07 00:13:35 -04:00
Logan CusanoandClaude Sonnet 5 d67b2057e6 frontend: safe fixes from the #109 punch-list
- CallSpineEntry.tsx: drop the dead `hasAudio` prop + the early `return null`
  that sat between hooks in InlinePlayer (React #310 risk). Parent already
  gates the mount on audio presence.
- NodeCard.tsx + nodes/page.tsx: pending-node card no longer double-fires.
  NodeCard gains `linkToDetail` (default true); the pending branch passes
  false so the wrapping onClick (open config modal) isn't swallowed by the
  inner <Link> navigation. List view unchanged.
- trips/page.tsx: TripCard badge now buckets on end_date >= today, matching
  the list's own upcoming/past split — an in-progress trip no longer shows a
  "Past" badge under "Upcoming".
- trips/page.tsx, NodeConfigModal.tsx, nodes/[id]/page.tsx: tall modals get
  `p-4` on the overlay + `max-h-[90vh] overflow-y-auto` on the panel so they
  don't clip on short viewports (incidents' CreateModal pattern).
- lib/types.ts: IncidentRecord.units / vehicles are optional now, matching
  Firestore (older docs omit them); incidents/[id] gains a `?? []` guard.

Untypechecked (no node/npm locally). next build in deploy.yml gates it.
Full list of remaining items in server-26 #109.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 00:07:54 -04:00
logan c1c3e89e1d frontend: fix map stacking + honest infra error states (#108)
Build & Deploy / Build & push images (push) Successful in 4m3s
Build & Deploy / Deploy to VM (push) Successful in 2m3s
Build & Deploy / Report a failed deploy (push) Skipped
2026-09-06 23:49:19 -04:00
Logan CusanoandClaude Sonnet 5 968134f8ee frontend: fix map stacking + honest infra error states
From a live review of drb.cusano.net.

MapView.tsx / globals.css:
- The Leaflet map painted above the sticky Nav (z-40) and modal overlays, so
  on Live the account dropdown opened *behind* the map. Pin .leaflet-container
  to its own stacking context (position:relative; z-index:0) — keeps Leaflet's
  internal pane order, drops the whole map below app chrome. The map's own
  overlay UI (legend, rail, clock, fit-all) is outside .leaflet-container and
  unaffected. Chosen over raising Nav's z-index, which would float the sticky
  header over modal backdrops on ~7 pages.
- Basemap: the "Dark" tile URL is already CARTO's keyless dark raster (so a
  prod "API KEY REQUIRED" watermark is a stale build or CARTO rate-limiting
  the origin, not this code). Add NEXT_PUBLIC_MAP_TILE_URL as a build-time
  override so a keyed style drops in without a code change; add the OSM
  attribution the keyless CARTO tiles require.

incidents/page.tsx, alerts/page.tsx:
- Both dumped raw Firestore "requires an index / PERMISSION_DENIED" strings
  (with a console.firebase URL) straight into the UI when the composite
  indexes aren't deployed (server-26 #13/#51). Collapse those known infra
  failures to a plain sentence; any other error passes through verbatim so a
  real bug still shows. alerts also now surfaces the events-query error at
  all — it was swallowed, showing a false "No alerts triggered yet." on a
  public-safety screen.

onboarding/page.tsx: stale comment (/dashboard -> "/").

Untypechecked (no node/npm locally); presentational only — one string
helper, one added error branch, a CSS rule, two tile-URL constants, a
comment. next build in deploy.yml gates it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 23:43:22 -04:00
22 changed files with 331 additions and 72 deletions
+1
View File
@@ -63,6 +63,7 @@ jobs:
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }} NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }} NEXT_PUBLIC_FIREBASE_APP_ID=${{ secrets.FIREBASE_APP_ID }}
NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }} NEXT_PUBLIC_FIRESTORE_DATABASE=${{ secrets.FIRESTORE_DATABASE }}
NEXT_PUBLIC_MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
deploy: deploy:
name: Deploy to VM name: Deploy to VM
+7
View File
@@ -33,6 +33,13 @@ SUMMARY_INTERVAL_MINUTES=15
CORRELATION_WINDOW_HOURS=4 CORRELATION_WINDOW_HOURS=4
EMBEDDING_SIMILARITY_THRESHOLD=0.82 EMBEDDING_SIMILARITY_THRESHOLD=0.82
# Browser origins allowed to call this API cross-origin (JSON list). The only
# browser caller is the frontend's Archive page (GET /calls/search). Set this
# to the exact origin the frontend is served from — scheme + host, no path.
# Defaults to https://drb.cusano.net. A "*" entry works for local dev but is
# logged as a probable misconfiguration and never gets a credentialed response.
CORS_ORIGINS=["https://drb.cusano.net"]
# Fleet-wide token edge nodes present as X-Enrollment-Token on first boot # Fleet-wide token edge nodes present as X-Enrollment-Token on first boot
# (POST /nodes/enroll). Shared across every node — NOT a per-node secret. # (POST /nodes/enroll). Shared across every node — NOT a per-node secret.
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
+11 -9
View File
@@ -180,16 +180,18 @@ class Settings(BaseSettings):
# between genuinely separate transmissions on a busy dispatch channel. # between genuinely separate transmissions on a busy dispatch channel.
duplicate_window_seconds: int = 10 duplicate_window_seconds: int = 10
# CORS — set to your frontend origin(s) in production, e.g. ["https://app.example.com"] # Browser origins allowed to call this API cross-origin. The only browser
# Defaults to "*" for local development only. # caller is the frontend's Archive page (GET /calls/search) — every other
# page reads Firestore directly. The frontend is served on the BARE domain
# (see infra Caddyfile.j2 — only drb. and api. have DNS records), so the
# default is that origin, not app.<domain>. Override via CORS_ORIGINS (JSON
# list) if the frontend ever moves; keep infra/.../c2-core.env.j2 in sync.
# #
# Leaving this as "*" is not merely permissive: main.py turns OFF # A "*" entry here still works for local dev but is refused a credentialed
# allow_credentials when it sees a wildcard, because Starlette would # response: main.py never enables allow_credentials (auth is a Bearer
# otherwise reflect each caller's origin back WITH # header, not a cookie), and it logs a loud ERROR when it sees a wildcard
# Access-Control-Allow-Credentials. So a production deployment that # in a deployment so a forgotten override is visible.
# forgets to set this gets a loud ERROR at startup and loses credentialed cors_origins: list[str] = ["https://drb.cusano.net"]
# cross-origin requests, rather than silently accepting every origin.
cors_origins: list[str] = ["*"]
# Discord webhook URL that app/internal/ai_health.py posts to when an AI # Discord webhook URL that app/internal/ai_health.py posts to when an AI
# tier (transcription/correlation) transitions into or out of degraded # tier (transcription/correlation) transitions into or out of degraded
@@ -108,16 +108,31 @@ _ROAD_RE = re.compile(
) )
# Street-type synonyms collapsed to one token so "Mohegan Park Avenue" and
# "Mohegan Park Ave" produce the same road id (server-26#115 — that one
# difference was splitting a car-alarm incident into two).
_ROAD_SUFFIX_CANON = {
"avenue": "ave", "street": "st", "road": "rd", "drive": "dr",
"boulevard": "blvd", "lane": "ln", "court": "ct", "place": "pl",
"highway": "hwy", "parkway": "pkwy",
}
def _extract_road_ids(text: str) -> set[str]: def _extract_road_ids(text: str) -> set[str]:
""" """
Extract normalised road/route identifiers from a location string. Extract normalised road/route identifiers from a location string.
e.g. "suspect east on Route 202" → {"route 202"} e.g. "suspect east on Route 202" → {"route 202"}
"at Main Street and Oak Ave" → {"main street", "oak ave"} "at Main Street and Oak Ave" → {"main st", "oak ave"}
""" """
return { ids: set[str] = set()
re.sub(r"[\s.\-]+", " ", m.group().lower()).strip() for m in _ROAD_RE.finditer(text):
for m in _ROAD_RE.finditer(text) key = re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
} parts = key.split()
if parts and parts[-1] in _ROAD_SUFFIX_CANON:
parts[-1] = _ROAD_SUFFIX_CANON[parts[-1]]
key = " ".join(parts)
ids.add(key)
return ids
def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool: def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool:
+51 -9
View File
@@ -45,7 +45,18 @@ def _fmt_idle(inc: dict, now: datetime) -> str:
def _inc_summary(inc: dict, now: datetime) -> str: def _inc_summary(inc: dict, now: datetime) -> str:
# server-26#115: the model was given no title and no talkgroup, so it
# could not tell that "car alarms, Mohegan Park Ave" and "car alarms,
# Mohegan Park Avenue" on the same channel were one incident — it defaulted
# to "new". Title is the single strongest human-readable signal for "is
# this the same event"; talkgroup is what makes same-channel continuation
# obvious.
parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"] parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"]
tgs = inc.get("talkgroup_ids") or []
if tgs:
parts.append(f"tg:[{', '.join(str(t) for t in tgs[:3])}]")
if inc.get("title"):
parts.append(f"title:{inc['title']!r}")
if inc.get("location"): if inc.get("location"):
parts.append(f"loc:{inc['location']}") parts.append(f"loc:{inc['location']}")
units = inc.get("units") or [] units = inc.get("units") or []
@@ -80,19 +91,50 @@ def _call_block(ctx: dict) -> str:
lines.append(f"Units: {ctx['call_units']}") lines.append(f"Units: {ctx['call_units']}")
if ctx["call_vehicles"]: if ctx["call_vehicles"]:
lines.append(f"Vehicles: {ctx['call_vehicles']}") lines.append(f"Vehicles: {ctx['call_vehicles']}")
if ctx["talkgroup_name"]: if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
lines.append(f"Talkgroup: {ctx['talkgroup_name']}") # Both the name and the id — _inc_summary emits numeric tg ids, so the
# id is what makes the "same talkgroup" rule in _RULES evaluable
# (server-26#115 review).
tgid = ctx.get("talkgroup_id")
name = ctx["talkgroup_name"] or "?"
lines.append(f"Talkgroup: {name}" + (f" (id {tgid})" if tgid is not None else ""))
return "\n".join(lines) if lines else "(no details)" return "\n".join(lines) if lines else "(no details)"
def _prompt_incidents(recent: list[dict]) -> list[dict]:
"""The ≤20 candidates shown to the model, most-recently-active first.
`ctx["recent"]` is an unordered slice of a Firestore result with no
order_by, so a busy 2h window (~40 active incidents) meant the model saw
an arbitrary half of the candidates (server-26#115 review). Sorting by
updated_at desc also makes each row's `idle:` field monotonic.
"""
def _key(inc: dict):
return str(inc.get("updated_at") or inc.get("started_at") or "")
return sorted(recent, key=_key, reverse=True)[:20]
_SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}' _SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
_RULES = """ _RULES = """
Rules: Rules (this system OVER-SPLITS — a real incident routinely gets shattered into
- "link" only with clear positive evidence: same units, same geocoded location, or semantically identical scene on the same talkgroup within the last few minutes. 5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure
- A call on a DIFFERENT talkgroup than an incident requires unit overlap or geocoded location match — topic similarity alone is not enough. mode. Bias accordingly.):
- "new" only if the call has a clear incident_type AND describes a distinct, identifiable scene. - Prefer "link" when the call plausibly continues a recent incident ON THE SAME
- "orphan" when in doubt — conservative is always correct. TALKGROUP: same or overlapping units, the same or an adjacent location (treat
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus
the same street is the same place), the same subject/vehicle/case number, or a
follow-up beat ("units clearing", "negative contact", "tow en route", "event
number 214-201", a status update) to an incident that is only a few minutes
idle. The bar for "link" on the same talkgroup is LOW.
- Reserve "new" for a call that clearly describes a DIFFERENT event from every
recent incident — a different place, different units, and a different subject,
not merely a different transmission about the same job.
- "orphan" a call that is not an incident at all: radio checks, roll call,
a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare
acknowledgement. Do not open a "new" incident for these.
- A call on a DIFFERENT talkgroup than an incident still requires unit overlap
or a geocoded/location match — topic similarity alone is not enough there.
- Do NOT link just because both calls involve police or both mention a road. - Do NOT link just because both calls involve police or both mention a road.
""" """
@@ -101,7 +143,7 @@ def _build_decide_prompt(ctx: dict) -> str:
now = ctx["now"] now = ctx["now"]
recent = ctx["recent"] recent = ctx["recent"]
inc_block = ( inc_block = (
"\n".join(_inc_summary(inc, now) for inc in recent[:20]) "\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
if recent else "(none)" if recent else "(none)"
) )
return ( return (
@@ -119,7 +161,7 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
now = ctx["now"] now = ctx["now"]
recent = ctx["recent"] recent = ctx["recent"]
inc_block = ( inc_block = (
"\n".join(_inc_summary(inc, now) for inc in recent[:20]) "\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
if recent else "(none)" if recent else "(none)"
) )
+24 -17
View File
@@ -78,33 +78,40 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="DRB C2 Core", lifespan=lifespan) app = FastAPI(title="DRB C2 Core", lifespan=lifespan)
# "*" plus allow_credentials=True is not the permissive-but-harmless setting it # The browser needs CORS to reach this API at all: the frontend's Archive page
# looks like. Starlette does not refuse the combination -- it reflects the # calls GET /calls/search with Authorization + Content-Type headers, which
# caller's Origin back and still sends Access-Control-Allow-Credentials: true, # forces a preflight. Without this middleware the OPTIONS gets a bare 405 and
# so the effective policy becomes "any origin, with credentials", the opposite # the fetch fails (#110). allow_origins is an explicit list -- never "*" in a
# of what a wildcard normally means. Rather than trust every deployment to # deployment -- so name every host the frontend is served from in CORS_ORIGINS.
# remember to override CORS_ORIGINS, make the dangerous pair unrepresentable. #
# allow_credentials stays False on purpose: auth here is a Bearer header, not a
# cookie, so credentialed CORS is never needed, and keeping it False is what
# lets an explicit-origin allowlist work without Starlette's "*"-only
# restriction. "*" + credentials is the dangerous pair (Starlette reflects the
# caller's Origin back WITH Access-Control-Allow-Credentials: true); this code
# cannot produce it because credentials are hard-off.
def cors_allows_credentials(origins: list[str]) -> bool: def cors_allows_credentials(origins: list[str]) -> bool:
"""False when any entry is a wildcard. Extracted so it can be tested """Always False -- credentialed CORS is never enabled here (Bearer auth,
without re-importing this module, which drags in every router.""" not cookies). Kept as a named predicate so a future edit that wants to
return "*" not in origins turn credentials on has to go through here and confront the "*" case.
A wildcard entry would additionally be refused a credentialed response."""
return False
_cors_is_wildcard = not cors_allows_credentials(settings.cors_origins) _cors_is_wildcard = "*" in settings.cors_origins
if _cors_is_wildcard: if _cors_is_wildcard:
logger.error( logger.error(
"CORS_ORIGINS is '*', so credentialed cross-origin requests are being " "CORS_ORIGINS contains '*'. That is fine for local dev but is almost "
"DISABLED to avoid reflecting every caller's origin back with " "certainly a misconfigured deployment -- set CORS_ORIGINS to your "
"Access-Control-Allow-Credentials. Set CORS_ORIGINS to your frontend " "frontend origin(s), e.g. [\"https://drb.cusano.net\"]."
"origin(s) in production, e.g. [\"https://app.example.com\"]."
) )
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_methods=["*"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["authorization", "content-type"],
allow_credentials=not _cors_is_wildcard, allow_credentials=False,
) )
app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(nodes.router, dependencies=[Depends(require_service_or_firebase_token)])
+67
View File
@@ -0,0 +1,67 @@
"""
server-26#115 — the tiebreaker manufactured incidents because it was blind to
what would tell it two incidents are one.
Two low-risk supports for the reframed prompt:
1. `_extract_road_ids` collapses street-type synonyms, so "Mohegan Park Ave"
and "Mohegan Park Avenue" share a road id (they were splitting one
car-alarm incident into two).
2. `_inc_summary` now carries the incident title and talkgroup, the two
signals the model needs to recognise a same-channel continuation.
"""
from datetime import datetime, timezone
from app.internal.incident_correlator import (
_extract_road_ids, _location_mentions_road_overlap,
)
from app.internal.llm_correlator import _inc_summary, _prompt_incidents
NOW = datetime(2026, 9, 7, 8, 0, 0, tzinfo=timezone.utc)
def test_avenue_and_ave_are_the_same_road_id():
assert _extract_road_ids("Mohegan Park Avenue") == _extract_road_ids("Mohegan Park Ave")
assert _extract_road_ids("191 Broadway Street") == _extract_road_ids("191 Broadway St")
assert _extract_road_ids("North State Road") == _extract_road_ids("North State Rd")
def test_road_overlap_matches_across_the_synonym():
assert _location_mentions_road_overlap("multiple car alarms Mohegan Park Avenue",
["patrol to Mohegan Park Ave"]) is True
# still discriminates genuinely different streets
assert _location_mentions_road_overlap("Oak Avenue", ["Elm Avenue"]) is False
def test_inc_summary_carries_title_and_talkgroup():
s = _inc_summary({
"incident_id": "abc123",
"type": "police",
"talkgroup_ids": [9560],
"title": "Nuisance Alarm at Mohegan Park Ave",
"location": "Mohegan Park Ave",
"units": ["Headquarters"],
"tags": ["car-alarm"],
"updated_at": NOW.isoformat(),
}, NOW)
assert "title:'Nuisance Alarm at Mohegan Park Ave'" in s
assert "tg:[9560]" in s
assert "id:abc123" in s
def test_inc_summary_omits_missing_optional_fields():
s = _inc_summary({"incident_id": "x", "updated_at": NOW.isoformat()}, NOW)
assert "title:" not in s and "tg:" not in s and "loc:" not in s
assert s.startswith("id:x")
def test_prompt_incidents_is_most_recently_active_first_and_capped():
recent = [
{"incident_id": f"i{n}", "updated_at": f"2026-09-07T0{n}:00:00+00:00"}
for n in range(1, 8)
]
ordered = _prompt_incidents(recent)
assert [i["incident_id"] for i in ordered] == ["i7", "i6", "i5", "i4", "i3", "i2", "i1"]
assert len(_prompt_incidents(recent * 5)) == 20
# falls back to started_at when updated_at is absent, and never raises
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
{"incident_id": "b"}])[0]["incident_id"] == "a"
+66
View File
@@ -0,0 +1,66 @@
"""
End-to-end CORS wiring for the one browser-facing REST surface.
The frontend's Archive page calls GET /calls/search with Authorization +
Content-Type headers, which forces the browser to send a CORS preflight
first. Before #110 that OPTIONS got a bare 405 with no Access-Control-*
headers and the fetch failed with "TypeError: Failed to fetch". These
tests drive the real app through TestClient so a regression in the
middleware wiring (not just the helper) is caught.
TestClient is NOT used as a context manager on purpose: that would run the
lifespan (mqtt_handler.connect(), the sweeper loops, dynsec bootstrap),
none of which is needed here -- CORSMiddleware answers a preflight before
routing or dependencies run.
"""
from fastapi.testclient import TestClient
from app.config import settings
from app.main import app
client = TestClient(app)
ALLOWED_ORIGIN = "https://drb.cusano.net"
DISALLOWED_ORIGIN = "https://evil.example.com"
def test_default_allowed_origin_matches_the_deployed_frontend():
# The frontend is served on the bare domain (infra Caddyfile.j2), so the
# default must allow exactly that origin without any env override.
assert ALLOWED_ORIGIN in settings.cors_origins
def test_preflight_for_calls_search_is_allowed():
resp = client.options(
"/calls/search",
headers={
"Origin": ALLOWED_ORIGIN,
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
allow_methods = resp.headers.get("access-control-allow-methods", "").upper()
assert "GET" in allow_methods
# Bearer auth, not cookies -- credentials must never be advertised.
assert "access-control-allow-credentials" not in resp.headers
def test_preflight_from_disallowed_origin_gets_no_allow_origin():
resp = client.options(
"/calls/search",
headers={
"Origin": DISALLOWED_ORIGIN,
"Access-Control-Request-Method": "GET",
},
)
assert resp.headers.get("access-control-allow-origin") is None
def test_simple_get_from_allowed_origin_is_annotated():
# Even a non-preflight GET must carry Access-Control-Allow-Origin or the
# browser hides the response body from the page.
resp = client.get("/health", headers={"Origin": ALLOWED_ORIGIN})
assert resp.status_code == 200
assert resp.headers.get("access-control-allow-origin") == ALLOWED_ORIGIN
+9 -7
View File
@@ -5,8 +5,9 @@ Starlette does not reject `allow_origins=["*"]` combined with
`allow_credentials=True`. It reflects the caller's Origin back in `allow_credentials=True`. It reflects the caller's Origin back in
Access-Control-Allow-Origin and still sends Access-Control-Allow-Origin and still sends
Access-Control-Allow-Credentials: true, so the effective policy is the Access-Control-Allow-Credentials: true, so the effective policy is the
opposite of what a wildcard usually means. main.py defuses that by turning opposite of what a wildcard usually means. main.py never enables
credentials off whenever it sees a wildcard; these tests hold it to that. credentials at all (auth is a Bearer header, not a cookie), which makes
that pair unrepresentable; these tests hold it to that.
The policy lives in a pure function so it can be exercised directly -- The policy lives in a pure function so it can be exercised directly --
reloading app.main to vary settings drags every router back through import reloading app.main to vary settings drags every router back through import
@@ -28,11 +29,11 @@ def test_wildcard_among_real_origins_still_disables_credentials():
assert cors_allows_credentials(["https://app.example.com", "*"]) is False assert cors_allows_credentials(["https://app.example.com", "*"]) is False
def test_named_origins_keep_credentials(): def test_credentials_never_enabled_even_for_named_origins():
# Naming your origins is how you ask for credentialed requests, so a # Auth here is a Bearer header, not a cookie, so credentialed CORS is
# correctly configured deployment must not be penalised. # never needed. The predicate is hard-off regardless of the origin list.
assert cors_allows_credentials(["https://app.example.com"]) is True assert cors_allows_credentials(["https://app.example.com"]) is False
assert cors_allows_credentials([]) is True assert cors_allows_credentials([]) is False
def test_the_app_actually_mounted_that_policy(): def test_the_app_actually_mounted_that_policy():
@@ -42,6 +43,7 @@ def test_the_app_actually_mounted_that_policy():
(mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None (mw.kwargs for mw in app.user_middleware if mw.cls is CORSMiddleware), None
) )
assert opts is not None, "CORSMiddleware is not mounted at all" assert opts is not None, "CORSMiddleware is not mounted at all"
assert opts["allow_credentials"] is False
assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins) assert opts["allow_credentials"] is cors_allows_credentials(settings.cors_origins)
+7 -1
View File
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
export default function AlertsPage() { export default function AlertsPage() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const { alerts, loading } = useAlerts(); const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events"); const [tab, setTab] = useState<"events" | "rules">("events");
async function handleAcknowledge(id: string) { async function handleAcknowledge(id: string) {
@@ -226,6 +226,12 @@ export default function AlertsPage() {
{tab === "events" && ( {tab === "events" && (
loading ? ( loading ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p> <p className="text-gray-500 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)
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
: `Couldn't load alerts: ${error}`}
</p>
) : alerts.length === 0 ? ( ) : alerts.length === 0 ? (
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p> <p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
) : ( ) : (
+14
View File
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
animation: pulse-ring 1.8s ease-out infinite; animation: pulse-ring 1.8s ease-out infinite;
} }
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
* map container to its own low stacking context keeps Leaflet's internal layer
* order intact while dropping the whole map (tiles + controls) below the app
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
* outside .leaflet-container, so it is unaffected and still renders on top.
*/
.leaflet-container {
position: relative;
z-index: 0;
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */ /* ── Form inputs ─────────────────────────────────────────────────────────── */
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]), html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select, html:not(.dark) select,
+3 -2
View File
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
const displayTags = incident.tags.filter((t) => t !== "auto-generated"); const displayTags = incident.tags.filter((t) => t !== "auto-generated");
const unitsActive = incident.units_active ?? incident.units ?? []; const unitsActive = incident.units_active ?? incident.units ?? [];
const unitsCleared = incident.units_cleared ?? []; const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? [];
const active = incident.status === "active"; const active = incident.status === "active";
const visible = newestFirst.slice(0, earlierShown); const visible = newestFirst.slice(0, earlierShown);
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
</div> </div>
</div> </div>
{incident.vehicles?.length > 0 && ( {vehicles.length > 0 && (
<div> <div>
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p> <p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{incident.vehicles.map((v) => ( {vehicles.map((v) => (
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span> <span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
))} ))}
</div> </div>
+12 -1
View File
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
type SortMode = "recent" | "severity"; type SortMode = "recent" | "severity";
// The Firestore client surfaces a missing composite index or an undeployed
// ruleset as a raw multi-line string with a console URL in it — not something
// to put in front of an operator. Collapse the known infra failures to a plain
// line; pass anything else straight through so a real bug still shows.
function friendlyIncidentsError(raw: string): string {
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
}
return `Couldn't load incidents: ${raw}`;
}
function fmtTime(iso: string) { function fmtTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; } try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
} }
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
recorded yet" over the top of it told the operator the radio was recorded yet" over the top of it told the operator the radio was
quiet when the page had simply failed to load — server-26#13. */} quiet when the page had simply failed to load — server-26#13. */}
{filtered.length === 0 && error && ( {filtered.length === 0 && error && (
<ErrorBanner message={`Couldn't load incidents: ${error}`} /> <ErrorBanner message={friendlyIncidentsError(error)} />
)} )}
{filtered.length === 0 && !error && ( {filtered.length === 0 && !error && (
+1 -1
View File
@@ -60,7 +60,7 @@ function DiscordJoinModal({
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm" className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
> >
<h3 className="text-white font-semibold">Join Discord Voice</h3> <h3 className="text-white font-semibold">Join Discord Voice</h3>
<div> <div>
+1 -1
View File
@@ -42,7 +42,7 @@ export default function NodesPage() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => ( {pending.map((n) => (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer"> <div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} /> <NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div> </div>
))} ))}
</div> </div>
+1 -1
View File
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
// Firebase custom claims only show up in a *freshly fetched* ID token — // Firebase custom claims only show up in a *freshly fetched* ID token —
// getIdTokenResult(true) inside refreshClaims forces that fetch, then // getIdTokenResult(true) inside refreshClaims forces that fetch, then
// AuthProvider's own state (orgId) updates and the effect above // AuthProvider's own state (orgId) updates and the effect above
// redirects to /dashboard. // redirects to "/" (Live).
await refreshClaims(); await refreshClaims();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again."); setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
+5 -3
View File
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
}) { }) {
const router = useRouter(); const router = useRouter();
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const upcoming = trip.start_date >= today; // Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
// so a trip isn't "Past" until it's over, not when it starts.
const upcoming = trip.end_date >= today;
const attendeeCount = Object.keys(trip.attendees ?? {}).length; const attendeeCount = Object.keys(trip.attendees ?? {}).length;
return ( return (
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
} }
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4" 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"
> >
<h2 className="text-white font-bold">New Trip</h2> <h2 className="text-white font-bold">New Trip</h2>
+2 -4
View File
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
return `${m}:${r.toString().padStart(2, "0")}`; return `${m}:${r.toString().padStart(2, "0")}`;
} }
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) { function InlinePlayer({ callId }: { callId: string }) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
if (!hasAudio) return null;
async function ensureUrl() { async function ensureUrl() {
if (url || loading) return; if (url || loading) return;
setLoading(true); setLoading(true);
@@ -169,7 +167,7 @@ export function CallSpineEntry({
{hasAudio && ( {hasAudio && (
<div className="mt-1.5"> <div className="mt-1.5">
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} /> <InlinePlayer callId={call.call_id} />
</div> </div>
)} )}
+14 -3
View File
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
}); });
// ── Basemap tiles ─────────────────────────────────────────────────────────────
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const MAP_TILE_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/">CARTO</a>';
// ── Colour ──────────────────────────────────────────────────────────────────── // ── Colour ────────────────────────────────────────────────────────────────────
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident // Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
// type is carried by the glyph knocked out of the pin, never by colour, and // type is carried by the glyph knocked out of the pin, never by colour, and
@@ -540,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Base layers */} {/* Base layers */}
<LayersControl.BaseLayer checked name="Dark"> <LayersControl.BaseLayer checked name="Dark">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url={MAP_TILE_URL}
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light"> <LayersControl.BaseLayer name="Light">
<TileLayer <TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>' attribution={MAP_TILE_ATTRIBUTION}
/> />
</LayersControl.BaseLayer> </LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets"> <LayersControl.BaseLayer name="Streets">
+10 -4
View File
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
interface Props { interface Props {
node: NodeRecord; node: NodeRecord;
system?: SystemRecord; system?: SystemRecord;
/**
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
* parent click handler can take the interaction (pending nodes open the
* config modal instead of navigating). Defaults to true.
*/
linkToDetail?: boolean;
} }
export function NodeCard({ node, system }: Props) { export function NodeCard({ node, system, linkToDetail = true }: Props) {
const lastSeen = node.last_seen const lastSeen = node.last_seen
? new Date(node.last_seen).toLocaleTimeString() ? new Date(node.last_seen).toLocaleTimeString()
: "never"; : "never";
return ( const body = (
<Link href={`/nodes/${node.node_id}`}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer"> <div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
<div className="flex items-start justify-between mb-3"> <div className="flex items-start justify-between mb-3">
<div> <div>
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
</div> </div>
)} )}
</div> </div>
</Link>
); );
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
} }
+2 -2
View File
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
const selectedPreset = PRESETS.find((p) => p.value === preset); const selectedPreset = PRESETS.find((p) => p.value === preset);
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono"> <div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-1">Configure Node</h2> <h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-gray-400 text-sm mb-5"> <p className="text-gray-400 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time. <span className="text-indigo-400">{node.node_id}</span> connected for the first time.
+3 -2
View File
@@ -143,8 +143,9 @@ export interface IncidentRecord {
call_ids: string[]; call_ids: string[];
system_ids: string[]; system_ids: string[];
talkgroup_ids: string[]; talkgroup_ids: string[];
units: string[]; /** Omitted on incident docs written before these fields existed. */
vehicles: string[]; units?: string[];
vehicles?: string[];
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */ /** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
units_active?: string[]; units_active?: string[];
/** Units that reported clearing/back in service on this incident. */ /** Units that reported clearing/back in service on this incident. */