Compare commits
7
Commits
4fab959ddb
..
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7cdb4452f | ||
|
|
5cdde85d55 | ||
|
|
cbf45ff004 | ||
|
|
cf232e313f | ||
|
|
92c9b245f6 | ||
|
|
cc41d1badb | ||
|
|
df837d7d81 |
@@ -12,9 +12,27 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
name: Build & push images
|
name: Build & push images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# node-26#49: this build's release, vMAJOR.MINOR.PATCH. Tagged only once
|
||||||
|
# the deploy is healthy (job "tag" below), so a failed deploy's number is
|
||||||
|
# reused by the next push.
|
||||||
|
- name: Work out the release version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
git fetch --force --tags origin
|
||||||
|
TAG="$(bash scripts/next-version.sh)"
|
||||||
|
if [ -z "$TAG" ]; then
|
||||||
|
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k3,3n | tail -1)"
|
||||||
|
fi
|
||||||
|
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release ${TAG}"
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -33,6 +51,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ gitea.sha }}
|
GIT_SHA=${{ gitea.sha }}
|
||||||
|
DRB_VERSION=${{ steps.version.outputs.version }}
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/c2-core:latest
|
${{ env.REGISTRY }}/c2-core:latest
|
||||||
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
||||||
@@ -459,3 +478,26 @@ jobs:
|
|||||||
PY
|
PY
|
||||||
curl -sS -X POST -H "Content-Type: application/json" \
|
curl -sS -X POST -H "Content-Type: application/json" \
|
||||||
--data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed"
|
--data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed"
|
||||||
|
|
||||||
|
# node-26#49: a healthy deploy is a release. Tag it vMAJOR.MINOR.PATCH with
|
||||||
|
# the number the build stamped into /health.
|
||||||
|
tag:
|
||||||
|
name: Tag release
|
||||||
|
needs: [build, deploy]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Tag this commit
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
|
run: |
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "no release version from build ('$VERSION')"; exit 1; }
|
||||||
|
git fetch --force --tags origin
|
||||||
|
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
|
||||||
|
echo "v$VERSION already exists"; exit 0
|
||||||
|
fi
|
||||||
|
git tag "v$VERSION"
|
||||||
|
git push origin "refs/tags/v$VERSION"
|
||||||
|
echo "released v$VERSION"
|
||||||
|
|||||||
@@ -13,5 +13,8 @@ COPY tests/ ./tests/
|
|||||||
# -- exactly how production served two-day-old code for two days.
|
# -- exactly how production served two-day-old code for two days.
|
||||||
ARG GIT_SHA=unknown
|
ARG GIT_SHA=unknown
|
||||||
ENV GIT_SHA=$GIT_SHA
|
ENV GIT_SHA=$GIT_SHA
|
||||||
|
# The release tag this build becomes (node-26#49), e.g. 1.1.4.
|
||||||
|
ARG DRB_VERSION=untagged
|
||||||
|
ENV DRB_VERSION=$DRB_VERSION
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal import radios
|
||||||
from app.internal import talkgroups
|
from app.internal import talkgroups
|
||||||
from app.internal.tenancy import FOUNDING_ORG_ID
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ class MQTTHandler:
|
|||||||
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
||||||
"disk_free_bytes": payload.get("disk_free_bytes"),
|
"disk_free_bytes": payload.get("disk_free_bytes"),
|
||||||
"disk_total_bytes": payload.get("disk_total_bytes"),
|
"disk_total_bytes": payload.get("disk_total_bytes"),
|
||||||
|
"version": payload.get("version"), # installed release (node-26#49)
|
||||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||||
"is_overridden": False,
|
"is_overridden": False,
|
||||||
"override_system_id": None,
|
"override_system_id": None,
|
||||||
@@ -167,7 +169,7 @@ class MQTTHandler:
|
|||||||
# records to — a full disk silently stopped node-002 for 17 days.
|
# records to — a full disk silently stopped node-002 for 17 days.
|
||||||
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
||||||
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
|
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
|
||||||
"disk_free_bytes", "disk_total_bytes"):
|
"disk_free_bytes", "disk_total_bytes", "version"):
|
||||||
if key in payload:
|
if key in payload:
|
||||||
updates[key] = payload[key]
|
updates[key] = payload[key]
|
||||||
if "sdr_count" in payload:
|
if "sdr_count" in payload:
|
||||||
@@ -307,6 +309,12 @@ class MQTTHandler:
|
|||||||
updates["org_id"] = node["org_id"]
|
updates["org_id"] = node["org_id"]
|
||||||
if payload.get("audio_url"):
|
if payload.get("audio_url"):
|
||||||
updates["audio_url"] = payload["audio_url"]
|
updates["audio_url"] = payload["audio_url"]
|
||||||
|
# The node usually learns the talker after call_start went out; only
|
||||||
|
# call_start used to write srcaddr, so most calls kept None
|
||||||
|
# (server-26#304).
|
||||||
|
if payload.get("srcaddr") is not None:
|
||||||
|
updates["srcaddr"] = payload["srcaddr"]
|
||||||
|
updates.update(radios.call_fields(payload.get("radios")))
|
||||||
|
|
||||||
await fstore.doc_set("calls", call_id, updates)
|
await fstore.doc_set("calls", call_id, updates)
|
||||||
logger.info(f"Call end: {call_id}")
|
logger.info(f"Call end: {call_id}")
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Every radio heard on a call, as the edge node reports it (server-26#304).
|
||||||
|
|
||||||
|
The node sends `radios`: a list of {radio, start, end}, one entry per
|
||||||
|
transmission, offsets in seconds into the uploaded audio file, so a talker's
|
||||||
|
wall-clock time is audio_start_epoch + offset (the same mapping the node uses
|
||||||
|
for the audio itself). It arrives twice — on
|
||||||
|
/upload (as a JSON string form field, because the pipeline starts there) and
|
||||||
|
on the MQTT call_end — and both go through `call_fields` so the call doc gets
|
||||||
|
the same shape either way.
|
||||||
|
|
||||||
|
`radio_ids` is the distinct IDs in first-heard order, kept alongside so a
|
||||||
|
correlator or query can ask "was radio X on this call" without walking the list.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from app.internal.logger import logger
|
||||||
|
|
||||||
|
# A 600 s recording polled every 0.5 s cannot honestly hold more than this.
|
||||||
|
MAX_TRANSMISSIONS = 200
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value: Any) -> Optional[float]:
|
||||||
|
try:
|
||||||
|
number = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return number if number >= 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def parse(value: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""The node's radio list, validated. Accepts a list or its JSON string; junk entries are dropped."""
|
||||||
|
if value is None or value == "":
|
||||||
|
return []
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"radios: not JSON, ignored ({value[:80]!r})")
|
||||||
|
return []
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for item in value[:MAX_TRANSMISSIONS]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
radio = int(item.get("radio"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
start, end = _num(item.get("start")), _num(item.get("end"))
|
||||||
|
if radio <= 0 or start is None or end is None:
|
||||||
|
continue
|
||||||
|
out.append({"radio": radio, "start": start, "end": max(start, end)})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def call_fields(value: Any) -> Dict[str, Any]:
|
||||||
|
"""Call-doc fields for a radio list; empty when the node sent none (older nodes, analog)."""
|
||||||
|
radios = parse(value)
|
||||||
|
if not radios:
|
||||||
|
return {}
|
||||||
|
return {"radios": radios, "radio_ids": list(dict.fromkeys(r["radio"] for r in radios))}
|
||||||
@@ -187,6 +187,7 @@ app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signe
|
|||||||
# build stamp baked in by the Dockerfile, not configuration anyone sets or
|
# build stamp baked in by the Dockerfile, not configuration anyone sets or
|
||||||
# tunes, and keeping it out of Settings avoids implying it can be changed.
|
# tunes, and keeping it out of Settings avoids implying it can be changed.
|
||||||
_GIT_SHA = os.getenv("GIT_SHA", "unknown")
|
_GIT_SHA = os.getenv("GIT_SHA", "unknown")
|
||||||
|
_VERSION = os.getenv("DRB_VERSION", "untagged")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -197,6 +198,7 @@ async def health():
|
|||||||
# CI asserts this equals the commit it just deployed. Without it a
|
# CI asserts this equals the commit it just deployed. Without it a
|
||||||
# deploy can "succeed" while the previous container is still serving.
|
# deploy can "succeed" while the previous container is still serving.
|
||||||
"git_sha": _GIT_SHA,
|
"git_sha": _GIT_SHA,
|
||||||
|
"version": _VERSION,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPExce
|
|||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from app.internal.storage import upload_audio
|
from app.internal.storage import upload_audio
|
||||||
from app.internal import dedup
|
from app.internal import dedup
|
||||||
|
from app.internal import radios
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
from app.internal import clock
|
from app.internal import clock
|
||||||
from app.internal.logger import logger
|
from app.internal.logger import logger
|
||||||
@@ -24,6 +25,7 @@ async def upload_call_audio(
|
|||||||
talkgroup_id: Optional[int] = Form(None),
|
talkgroup_id: Optional[int] = Form(None),
|
||||||
talkgroup_name: Optional[str] = Form(None),
|
talkgroup_name: Optional[str] = Form(None),
|
||||||
system_id: Optional[str] = Form(None),
|
system_id: Optional[str] = Form(None),
|
||||||
|
radios_json: Optional[str] = Form(None, alias="radios"),
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@@ -101,6 +103,14 @@ async def upload_call_audio(
|
|||||||
updates = {"audio_gcs_uri": gcs_uri}
|
updates = {"audio_gcs_uri": gcs_uri}
|
||||||
if node and node.get("org_id"):
|
if node and node.get("org_id"):
|
||||||
updates["org_id"] = node["org_id"]
|
updates["org_id"] = node["org_id"]
|
||||||
|
# Every radio heard on the call (server-26#304). Written here, not
|
||||||
|
# only on call_end, because the pipeline below starts before the
|
||||||
|
# node publishes call_end.
|
||||||
|
radio_fields = radios.call_fields(radios_json)
|
||||||
|
updates.update(radio_fields)
|
||||||
|
if radio_fields and existing.get("srcaddr") is None:
|
||||||
|
# Same meaning call_end gives it: the latest talker.
|
||||||
|
updates["srcaddr"] = radio_fields["radios"][-1]["radio"]
|
||||||
await fstore.doc_set("calls", call_id, updates)
|
await fstore.doc_set("calls", call_id, updates)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Every radio on a call reaches the call doc (server-26#304) — from /upload,
|
||||||
|
which starts the pipeline, and from call_end, which used to drop srcaddr.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal import radios
|
||||||
|
from app.internal.mqtt_handler import MQTTHandler
|
||||||
|
from app.main import app
|
||||||
|
from app.routers import upload
|
||||||
|
|
||||||
|
LIST = [{"radio": 555, "start": 0.0, "end": 1.0}, {"radio": 777, "start": 2.0, "end": 2.5},
|
||||||
|
{"radio": 555, "start": 3.5, "end": 4.0}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_accepts_json_and_drops_junk():
|
||||||
|
raw = json.dumps(LIST + [{"radio": 0, "start": 1, "end": 2}, {"radio": "x"}, "junk",
|
||||||
|
{"radio": 9, "start": -1, "end": 2}, {"radio": 8, "start": 5, "end": 4}])
|
||||||
|
assert radios.parse(raw) == LIST + [{"radio": 8, "start": 5.0, "end": 5.0}]
|
||||||
|
assert radios.parse("not json") == []
|
||||||
|
assert radios.parse({"radio": 1}) == []
|
||||||
|
assert radios.parse(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_fields_keeps_distinct_ids_in_first_heard_order():
|
||||||
|
assert radios.call_fields(LIST)["radio_ids"] == [555, 777]
|
||||||
|
assert radios.call_fields([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_end_writes_srcaddr_and_radios():
|
||||||
|
"""call_start used to be the only writer of srcaddr; the talker is usually learned later."""
|
||||||
|
handler = MQTTHandler()
|
||||||
|
with patch("app.internal.mqtt_handler.fstore") as fs:
|
||||||
|
fs.doc_get_cached = AsyncMock(return_value=None)
|
||||||
|
fs.doc_set = AsyncMock()
|
||||||
|
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": 555, "radios": LIST})
|
||||||
|
updates = fs.doc_set.call_args[0][2]
|
||||||
|
assert updates["srcaddr"] == 555
|
||||||
|
assert updates["radios"] == LIST
|
||||||
|
assert updates["radio_ids"] == [555, 777]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_end_without_radios_leaves_them_alone():
|
||||||
|
"""An older node sends neither field; a merge write must not blank what /upload stored."""
|
||||||
|
handler = MQTTHandler()
|
||||||
|
with patch("app.internal.mqtt_handler.fstore") as fs:
|
||||||
|
fs.doc_get_cached = AsyncMock(return_value=None)
|
||||||
|
fs.doc_set = AsyncMock()
|
||||||
|
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": None})
|
||||||
|
updates = fs.doc_set.call_args[0][2]
|
||||||
|
assert "srcaddr" not in updates and "radios" not in updates
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(existing_call, form_radios):
|
||||||
|
store = {
|
||||||
|
"node_keys": {"api_key": "k"},
|
||||||
|
"nodes": {"approval_status": "approved", "org_id": "org"},
|
||||||
|
"calls": existing_call,
|
||||||
|
}
|
||||||
|
writes = []
|
||||||
|
|
||||||
|
async def doc_get(c, i):
|
||||||
|
return store.get(c)
|
||||||
|
|
||||||
|
async def doc_set(c, i, data, merge=True):
|
||||||
|
writes.append((c, data))
|
||||||
|
|
||||||
|
data = {"call_id": "c1", "node_id": "n1"}
|
||||||
|
if form_radios is not None:
|
||||||
|
data["radios"] = form_radios
|
||||||
|
with patch.object(upload.fstore, "doc_get", doc_get), \
|
||||||
|
patch.object(upload.fstore, "doc_set", doc_set), \
|
||||||
|
patch.object(upload, "upload_audio", AsyncMock(return_value="gs://b/c1.mp3")), \
|
||||||
|
patch.object(upload.dedup, "find_duplicate_of", AsyncMock(return_value="other")):
|
||||||
|
resp = TestClient(app).post(
|
||||||
|
"/upload", data=data,
|
||||||
|
files={"file": ("a.mp3", b"\x00\x01", "audio/mpeg")},
|
||||||
|
headers={"Authorization": "Bearer k"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
return writes[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_stores_the_radio_list_before_the_pipeline_runs():
|
||||||
|
first = _upload({}, json.dumps(LIST))
|
||||||
|
assert first["radios"] == LIST
|
||||||
|
assert first["radio_ids"] == [555, 777]
|
||||||
|
# call_start had no talker yet: take the latest one, as call_end would.
|
||||||
|
assert first["srcaddr"] == 555
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_keeps_an_existing_srcaddr_and_tolerates_no_list():
|
||||||
|
assert "srcaddr" not in _upload({"srcaddr": 999}, json.dumps(LIST))
|
||||||
|
assert "radios" not in _upload({}, None)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""node-26#49 — releases are visible: /health reports the server's, checkin carries the node's."""
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.internal.mqtt_handler import MQTTHandler
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_reports_the_release():
|
||||||
|
with patch("app.main._VERSION", "1.1.4"):
|
||||||
|
body = TestClient(app).get("/health").json()
|
||||||
|
assert body["version"] == "1.1.4"
|
||||||
|
assert "git_sha" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checkin_stores_the_node_release():
|
||||||
|
with patch("app.internal.mqtt_handler.fstore") as fs:
|
||||||
|
fs.doc_get = AsyncMock(return_value=None)
|
||||||
|
fs.doc_set = AsyncMock()
|
||||||
|
await MQTTHandler()._handle_checkin("n1", {"name": "Pi", "version": "1.1.10"})
|
||||||
|
assert fs.doc_set.call_args[0][2]["version"] == "1.1.10"
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
@import 'leaflet/dist/leaflet.css';
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
|
||||||
|
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
|
||||||
/* ── Design tokens ────────────────────────────────────────────────────────────
|
/* ── Design tokens ────────────────────────────────────────────────────────────
|
||||||
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
||||||
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
|
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
|
||||||
@@ -16,43 +18,43 @@
|
|||||||
* Do not add a hue here for a category; add a glyph.
|
* Do not add a hue here for a category; add a glyph.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--page: #F4F6F9;
|
--page: #F7F7F8;
|
||||||
--surface: #FFFFFF;
|
--surface: #FFFFFF;
|
||||||
--raised: #F7F9FB;
|
--raised: #F2F2F5;
|
||||||
--line: rgba(11,17,27,.11);
|
--line: rgba(24,24,27,.10);
|
||||||
--line-strong: rgba(11,17,27,.22);
|
--line-strong: rgba(24,24,27,.20);
|
||||||
--ink: #101620;
|
--ink: #18181B;
|
||||||
--ink-2: #46536A;
|
--ink-2: #52525B;
|
||||||
--ink-muted: #6B788C;
|
--ink-muted: #6B6B76;
|
||||||
|
|
||||||
--accent: #2A78D6;
|
--accent: #5E6AD2;
|
||||||
--sev-moderate: #B07800;
|
--sev-moderate: #B54708;
|
||||||
--sev-major: #C0281F;
|
--sev-major: #D92D20;
|
||||||
|
|
||||||
--map-bg: #E8ECF1;
|
--map-bg: #ECECF0;
|
||||||
--map-block: #DFE4EB;
|
--map-block: #E1E1E8;
|
||||||
--map-road: #FFFFFF;
|
--map-road: #FFFFFF;
|
||||||
--map-water: #D3E2EE;
|
--map-water: #DADDF2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--page: #0B0E13;
|
--page: #08090A;
|
||||||
--surface: #141922;
|
--surface: #111214;
|
||||||
--raised: #1B2230;
|
--raised: #1A1B1F;
|
||||||
--line: rgba(255,255,255,.09);
|
--line: rgba(255,255,255,.08);
|
||||||
--line-strong: rgba(255,255,255,.17);
|
--line-strong: rgba(255,255,255,.16);
|
||||||
--ink: #E8EBF0;
|
--ink: #EDEDEF;
|
||||||
--ink-2: #A5B0C2;
|
--ink-2: #A9A9B3;
|
||||||
--ink-muted: #8C98AE; /* was #77839A: 4.2:1 on raised, failed AA at the 10-12px it's used at (#136) */
|
--ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
|
||||||
|
|
||||||
--accent: #3987E5;
|
--accent: #8B93F0;
|
||||||
--sev-moderate: #C98500;
|
--sev-moderate: #FDB022;
|
||||||
--sev-major: #F05252; /* was #D03B3B: 3.7:1 as text on surface (#136) */
|
--sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
|
||||||
|
|
||||||
--map-bg: #0E131B;
|
--map-bg: #0D0E11;
|
||||||
--map-block: #161C26;
|
--map-block: #16171B;
|
||||||
--map-road: #232C3A;
|
--map-road: #26272D;
|
||||||
--map-water: #12202E;
|
--map-water: #141729;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||||
@@ -69,7 +71,7 @@ html, body {
|
|||||||
|
|
||||||
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
||||||
* readable. Lift both to gray-500 (~4:1). */
|
* readable. Lift both to gray-500 (~4:1). */
|
||||||
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
.dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
|
||||||
|
|
||||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
@@ -80,34 +82,34 @@ html, body {
|
|||||||
|
|
||||||
/* Structural backgrounds */
|
/* Structural backgrounds */
|
||||||
html:not(.dark),
|
html:not(.dark),
|
||||||
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
|
||||||
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
|
||||||
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
||||||
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
|
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,0.70) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(242,242,245,0.60) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(242,242,245,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
|
||||||
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
|
||||||
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
html:not(.dark) .text-white { color: #18181b !important; }
|
||||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-100 { color: #27272a !important; }
|
||||||
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-200 { color: #27272a !important; }
|
||||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
|
||||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
html:not(.dark) .text-gray-400 { color: #52525b !important; }
|
||||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
|
||||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
|
||||||
|
|
||||||
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||||
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
||||||
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
|
|||||||
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
||||||
|
|
||||||
/* Hover states */
|
/* Hover states */
|
||||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #fafafa !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
|
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
|
||||||
|
|
||||||
/* Hover text */
|
/* Hover text */
|
||||||
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
|
||||||
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
|
||||||
|
|
||||||
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
||||||
|
|
||||||
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
|
|||||||
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
||||||
|
|
||||||
/* Unconfigured / Info */
|
/* Unconfigured / Info */
|
||||||
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
|
||||||
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
|
||||||
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
|
||||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
|
||||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
|
||||||
|
|
||||||
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
@@ -191,6 +193,13 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The legend floats above the map's stacking context, so it would cover the
|
||||||
|
* expanded layers list (both live in the right-hand column). Hide it while the
|
||||||
|
* list is open. */
|
||||||
|
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
|
/* "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
|
* 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
|
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
|
||||||
@@ -203,7 +212,7 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
|
* 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). */
|
* white — is pinned to a grey that holds AA on white (~6:1). */
|
||||||
.leaflet-popup-content .text-ink-muted {
|
.leaflet-popup-content .text-ink-muted {
|
||||||
color: #5B6577;
|
color: #5F5F69;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
|
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
|
||||||
@@ -224,17 +233,17 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
|
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
|
||||||
.dark input::placeholder,
|
.dark input::placeholder,
|
||||||
.dark textarea::placeholder {
|
.dark textarea::placeholder {
|
||||||
color: #5F6B80;
|
color: #6E6E78;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
html:not(.dark) textarea {
|
html:not(.dark) textarea {
|
||||||
color: #0f172a;
|
color: #18181b;
|
||||||
}
|
}
|
||||||
html:not(.dark) input::placeholder,
|
html:not(.dark) input::placeholder,
|
||||||
html:not(.dark) textarea::placeholder {
|
html:not(.dark) textarea::placeholder {
|
||||||
color: #94a3b8;
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
||||||
@@ -245,8 +254,8 @@ html:not(.dark) textarea::placeholder {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
||||||
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
|
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
|
||||||
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
|
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
|
||||||
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
|
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
|
||||||
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
|
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
|
||||||
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
||||||
@@ -255,10 +264,10 @@ html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !imp
|
|||||||
|
|
||||||
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
|
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
|
||||||
.marketing-hero-bg {
|
.marketing-hero-bg {
|
||||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
|
||||||
}
|
}
|
||||||
html:not(.dark) .marketing-hero-bg {
|
html:not(.dark) .marketing-hero-bg {
|
||||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton loading shimmer */
|
/* Skeleton loading shimmer */
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs">
|
<td className="px-4 py-2 text-xs">
|
||||||
{hasAudio ? (
|
{hasAudio ? (
|
||||||
<span className="text-blue-400">▶</span>
|
<span className="text-accent">▶</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-ink-muted">—</span>
|
<span className="text-ink-muted">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -560,7 +560,11 @@ export function LiveView() {
|
|||||||
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
||||||
MapView, so it is wrapped here rather than edited). */}
|
MapView, so it is wrapped here rather than edited). */}
|
||||||
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
<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>
|
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||||
) : (
|
) : (
|
||||||
<MapView
|
<MapView
|
||||||
|
|||||||
@@ -887,7 +887,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
|
|||||||
const hot = highlightIds.has(c.id);
|
const hot = highlightIds.has(c.id);
|
||||||
return (
|
return (
|
||||||
<Polygon key={`vs-${c.id}`} positions={wedge}
|
<Polygon key={`vs-${c.id}`} positions={wedge}
|
||||||
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
|
pathOptions={{ color: hot ? "#8B93F0" : "#A9A9B3", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
|
||||||
<Tooltip sticky>{c.name} — approximate view (from the road's direction of travel, not a surveyed heading)</Tooltip>
|
<Tooltip sticky>{c.name} — approximate view (from the road's direction of travel, not a surveyed heading)</Tooltip>
|
||||||
</Polygon>
|
</Polygon>
|
||||||
);
|
);
|
||||||
@@ -902,7 +902,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
|
|||||||
function nearCameraIcon(): L.DivIcon {
|
function nearCameraIcon(): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
|
html: `<div style="width:20px;height:16px;border-radius:4px;background:#1C1F42;border:2px solid #8B93F0;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
|
||||||
iconSize: [20, 16],
|
iconSize: [20, 16],
|
||||||
iconAnchor: [10, 8],
|
iconAnchor: [10, 8],
|
||||||
});
|
});
|
||||||
@@ -917,7 +917,7 @@ function NearCameraLayer({ cameras, head, onOpenCamera }: {
|
|||||||
<>
|
<>
|
||||||
{head && (
|
{head && (
|
||||||
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
|
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
|
||||||
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
|
pathOptions={{ color: "#8B93F0", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
|
||||||
)}
|
)}
|
||||||
{cameras.map(({ cam, km: d }) => (
|
{cameras.map(({ cam, km: d }) => (
|
||||||
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
|
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
|
||||||
@@ -1351,7 +1351,7 @@ function FanIncidentLayer({
|
|||||||
<a
|
<a
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
className="text-xs text-indigo-600 hover:underline block mt-0.5"
|
||||||
>
|
>
|
||||||
View incident →
|
View incident →
|
||||||
</a>
|
</a>
|
||||||
@@ -1505,7 +1505,7 @@ function IncidentPathLayer({
|
|||||||
<span className="text-xs text-ink-muted">Play this call</span>
|
<span className="text-xs text-ink-muted">Play this call</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-indigo-600 hover:underline block mt-1">
|
||||||
View incident →
|
View incident →
|
||||||
</a>
|
</a>
|
||||||
{/* Gate A / A2 (server-26#46) — the stop location and its
|
{/* Gate A / A2 (server-26#46) — the stop location and its
|
||||||
@@ -1620,6 +1620,9 @@ function MapView({
|
|||||||
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
||||||
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
|
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
|
||||||
const loopClock = useMemo(createLoopClock, []);
|
const loopClock = useMemo(createLoopClock, []);
|
||||||
|
// Short viewports (laptops, the 352px incident-page map) start with the
|
||||||
|
// legend folded to a chip so it doesn't cover the right side of the map.
|
||||||
|
const [legendOpenAtStart] = useState(() => typeof window === "undefined" || window.innerHeight >= 760);
|
||||||
|
|
||||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||||
// overlay is actually on (server-26#185); same for the outage key (#184).
|
// overlay is actually on (server-26#185); same for the outage key (#184).
|
||||||
@@ -1931,7 +1934,14 @@ function MapView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||||||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
|
{/* Collapsible (one click to a "Legend" chip) and hidden while the Leaflet
|
||||||
|
layers list is expanded: the map sits in a z-0 stacking context, so the
|
||||||
|
legend at z-1001 used to paint over the bottom of that list. */}
|
||||||
|
<details open={legendOpenAtStart} className="drb-legend group/legend absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg text-xs pointer-events-none max-h-[calc(100%-4rem)] overflow-y-auto">
|
||||||
|
<summary className="pointer-events-auto cursor-pointer list-none select-none px-3 py-1.5 text-ink-muted font-medium text-[10px] uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<span className="group-open/legend:hidden">▸</span><span className="hidden group-open/legend:inline">▾</span> Legend
|
||||||
|
</summary>
|
||||||
|
<div className="px-3 pb-2.5 space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||||
@@ -2070,7 +2080,8 @@ function MapView({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||||
{incidents.length > 0 && (
|
{incidents.length > 0 && (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
|
|||||||
|
|
||||||
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||||
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||||
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
|
||||||
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
|
|||||||
|
|
||||||
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||||
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||||
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
|
||||||
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||||
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ const config: Config = {
|
|||||||
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
||||||
// class silently generates no rule (map overlays rendered see-through).
|
// class silently generates no rule (map overlays rendered see-through).
|
||||||
colors: {
|
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"),
|
page: tok("page"),
|
||||||
surface: tok("surface"),
|
surface: tok("surface"),
|
||||||
raised: tok("raised"),
|
raised: tok("raised"),
|
||||||
@@ -51,7 +62,7 @@ const config: Config = {
|
|||||||
boxShadow: {
|
boxShadow: {
|
||||||
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
|
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)",
|
"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: {
|
animation: {
|
||||||
"fade-in": "fade-in 0.4s ease-out",
|
"fade-in": "fade-in 0.4s ease-out",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# node-26#49 (versioning, shared with Client/scripts) — the release tag this commit should get, or nothing if it has one.
|
||||||
|
#
|
||||||
|
# VERSION holds MAJOR.MINOR and is edited by hand. PATCH is automatic: one
|
||||||
|
# more than the highest existing vMAJOR.MINOR.PATCH tag, compared as an
|
||||||
|
# integer (so .10 follows .9), starting at 0 for a new MAJOR.MINOR.
|
||||||
|
#
|
||||||
|
# Usage: scripts/next-version.sh (from the repo root, tags fetched)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
base="$(tr -d ' \r\n' < VERSION)"
|
||||||
|
[[ "$base" =~ ^[0-9]+\.[0-9]+$ ]] || { echo "VERSION must be MAJOR.MINOR, got '$base'" >&2; exit 1; }
|
||||||
|
pattern="^v${base//./\\.}\\.[0-9]+$"
|
||||||
|
|
||||||
|
if git tag --points-at HEAD | grep -qE "$pattern"; then
|
||||||
|
exit 0 # this commit is already released
|
||||||
|
fi
|
||||||
|
|
||||||
|
last="$(git tag -l "v${base}.*" | grep -E "$pattern" | sed "s/^v${base}\.//" | sort -n | tail -1 || true)"
|
||||||
|
echo "v${base}.$(( ${last:--1} + 1 ))"
|
||||||
Reference in New Issue
Block a user