Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e009090418 | ||
|
|
3a1dca9533 | ||
|
|
6658c26fe0 | ||
|
|
2b42e5ee9a | ||
|
|
fa41b9a30c | ||
|
|
17da2ff739 | ||
|
|
33bb60b165 |
@@ -31,18 +31,24 @@ from app.config import settings
|
|||||||
# Prompt helpers
|
# Prompt helpers
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _fmt_idle(inc: dict, now: datetime) -> str:
|
def _fmt_since(raw, now: datetime) -> str:
|
||||||
try:
|
try:
|
||||||
raw = inc.get("updated_at") or inc.get("started_at") or ""
|
dt = datetime.fromisoformat(str(raw or "").replace("Z", "+00:00"))
|
||||||
dt = datetime.fromisoformat(str(raw).replace("Z", "+00:00"))
|
|
||||||
if dt.tzinfo is None:
|
if dt.tzinfo is None:
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
minutes = int((now - dt).total_seconds() / 60)
|
# Clamped: the recorrelation sweep back-dates `now` (see
|
||||||
return f"{minutes}min ago" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m ago"
|
# _idle_gate_minutes), which would otherwise print "-12min".
|
||||||
|
minutes = max(int((now - dt).total_seconds() / 60), 0)
|
||||||
|
return f"{minutes}min" if minutes < 60 else f"{minutes // 60}h{minutes % 60:02d}m"
|
||||||
except Exception:
|
except Exception:
|
||||||
return "?"
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_idle(inc: dict, now: datetime) -> str:
|
||||||
|
since = _fmt_since(inc.get("updated_at") or inc.get("started_at"), now)
|
||||||
|
return since if since == "?" else f"{since} ago"
|
||||||
|
|
||||||
|
|
||||||
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
|
# 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,
|
# could not tell that "car alarms, Mohegan Park Ave" and "car alarms,
|
||||||
@@ -65,6 +71,19 @@ def _inc_summary(inc: dict, now: datetime) -> str:
|
|||||||
if tags:
|
if tags:
|
||||||
parts.append(f"tags:[{', '.join(tags[:4])}]")
|
parts.append(f"tags:[{', '.join(tags[:4])}]")
|
||||||
parts.append(f"idle:{_fmt_idle(inc, now)}")
|
parts.append(f"idle:{_fmt_idle(inc, now)}")
|
||||||
|
# server-26#189: idle alone made a 2.5h-old job look as fresh as a
|
||||||
|
# 5-minute one — every link resets idle, so a busy channel's oldest
|
||||||
|
# incident always reads "2min ago" and keeps winning. Age and size let
|
||||||
|
# the model see it has become a catch-all.
|
||||||
|
if inc.get("started_at"):
|
||||||
|
from app.internal.incident_correlator import _incident_span_minutes
|
||||||
|
span = int(_incident_span_minutes(inc, now))
|
||||||
|
parts.append(f"open:{span}min" if span < 60 else f"open:{span // 60}h{span % 60:02d}m")
|
||||||
|
n_calls = inc.get("substantive_call_count")
|
||||||
|
if n_calls is None:
|
||||||
|
n_calls = len(inc.get("call_ids") or [])
|
||||||
|
if n_calls:
|
||||||
|
parts.append(f"calls:{n_calls}")
|
||||||
return " | ".join(parts)
|
return " | ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +109,8 @@ 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.get("reassignment"):
|
||||||
|
lines.append("Reassignment: yes — a unit is being sent to a NEW job")
|
||||||
if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
|
if ctx["talkgroup_name"] or ctx.get("talkgroup_id") is not None:
|
||||||
# Both the name and the id — _inc_summary emits numeric tg ids, so the
|
# 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
|
# id is what makes the "same talkgroup" rule in _RULES evaluable
|
||||||
@@ -116,16 +137,23 @@ def _prompt_incidents(recent: list[dict]) -> list[dict]:
|
|||||||
_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 (this system OVER-SPLITS — a real incident routinely gets shattered into
|
Rules (this system usually OVER-SPLITS — a real incident often gets shattered
|
||||||
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure
|
into 5-10 duplicates, so lean toward "link" for follow-ups. The exception is a
|
||||||
mode. Bias accordingly.):
|
call that reports a new event, below.):
|
||||||
- Prefer "link" when the call plausibly continues a recent incident ON THE SAME
|
- Prefer "link" when the call plausibly continues a recent incident ON THE SAME
|
||||||
TALKGROUP: same or overlapping units, the same or an adjacent location (treat
|
TALKGROUP: the same or an adjacent location (treat "Ave"/"Avenue",
|
||||||
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus
|
"St"/"Street", "Rd"/"Road" as identical; a house number plus the same street
|
||||||
the same street is the same place), the same subject/vehicle/case number, or a
|
is the same place), the same subject/vehicle/case number, or a follow-up beat
|
||||||
follow-up beat ("units clearing", "negative contact", "tow en route", "event
|
("units clearing", "negative contact", "tow en route", "event number
|
||||||
number 214-201", a status update) to an incident that is only a few minutes
|
214-201", a status update) from units already on it. The bar for "link" on
|
||||||
idle. The bar for "link" on the same talkgroup is LOW.
|
the same talkgroup is LOW for follow-ups.
|
||||||
|
- EXCEPT: a call that REPORTS an event is "new" unless it names the same place
|
||||||
|
or subject as the incident: "another accident", "a second MVA", "getting
|
||||||
|
flagged down for", "just got a report of", "respond to <address>", a
|
||||||
|
reassignment. Patrol units on a shared channel work many jobs back to back,
|
||||||
|
so a unit already on an incident is not evidence that its next job is the
|
||||||
|
same one — especially once that incident has been open a long time (see
|
||||||
|
open:/calls:).
|
||||||
- Reserve "new" for a call that clearly describes a DIFFERENT event from every
|
- Reserve "new" for a call that clearly describes a DIFFERENT event from every
|
||||||
recent incident — a different place, different units, and a different subject,
|
recent incident — a different place, different units, and a different subject,
|
||||||
not merely a different transmission about the same job.
|
not merely a different transmission about the same job.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pydantic import BaseModel
|
|||||||
from firebase_admin import auth as firebase_auth
|
from firebase_admin import auth as firebase_auth
|
||||||
from app.internal.auth import require_admin_token
|
from app.internal.auth import require_admin_token
|
||||||
from app.internal import firestore as fstore
|
from app.internal import firestore as fstore
|
||||||
|
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||||
from app.internal import audit
|
from app.internal import audit
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin/users", tags=["users"])
|
router = APIRouter(prefix="/admin/users", tags=["users"])
|
||||||
@@ -22,9 +23,13 @@ class UserCreate(BaseModel):
|
|||||||
role: str = "viewer"
|
role: str = "viewer"
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
owned_node_ids: list[str] = []
|
owned_node_ids: list[str] = []
|
||||||
|
# Org the new user joins as a member. Defaults to the creating admin's own
|
||||||
|
# org — without one, firestore.rules lets the user read nothing at all.
|
||||||
|
org_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
|
org_id: Optional[str] = None # attach an org-less user; defaults to the admin's org
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
owned_node_ids: Optional[list[str]] = None
|
owned_node_ids: Optional[list[str]] = None
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
@@ -34,6 +39,32 @@ class UserUpdate(BaseModel):
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _resolve_org(requested: Optional[str], decoded: dict) -> str:
|
||||||
|
"""The org a user created/edited here belongs to: the one asked for, else
|
||||||
|
the acting admin's own. Every read the frontend makes is gated on the
|
||||||
|
org_id claim (firestore.rules inOrg()), so a user without one sees no
|
||||||
|
incidents, calls or nodes — which is how admin-created viewers came out
|
||||||
|
before this existed."""
|
||||||
|
# A platform admin needn't have an org claim (isPlatformAdmin reads every
|
||||||
|
# org), so fall back to the founding org every pre-tenancy node, call and
|
||||||
|
# incident was stamped with (app/internal/tenancy.py).
|
||||||
|
org_id = requested or decoded.get("org_id") or FOUNDING_ORG_ID
|
||||||
|
if not await fstore.doc_get("organizations", org_id):
|
||||||
|
raise HTTPException(400, f"Organization '{org_id}' does not exist.")
|
||||||
|
return org_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_membership(uid: str, email: Optional[str], org_id: str) -> None:
|
||||||
|
"""Same org_members shape as POST /auth/signup (routers/links.py)."""
|
||||||
|
await fstore.doc_set("org_members", uid, {
|
||||||
|
"uid": uid,
|
||||||
|
"org_id": org_id,
|
||||||
|
"org_role": "member",
|
||||||
|
"email": email,
|
||||||
|
"added_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}, merge=False)
|
||||||
|
|
||||||
|
|
||||||
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
|
def _ms_to_iso(ms: Optional[int]) -> Optional[str]:
|
||||||
if ms is None:
|
if ms is None:
|
||||||
return None
|
return None
|
||||||
@@ -66,6 +97,9 @@ def _format_user(fb_user: firebase_auth.UserRecord, link: Optional[dict] = None)
|
|||||||
"discord_linked": bool(link and link.get("discord_user_id")),
|
"discord_linked": bool(link and link.get("discord_user_id")),
|
||||||
"discord_username": link.get("discord_username") if link else None,
|
"discord_username": link.get("discord_username") if link else None,
|
||||||
"discord_user_id": link.get("discord_user_id") if link else None,
|
"discord_user_id": link.get("discord_user_id") if link else None,
|
||||||
|
# Which org's data this user can read (firestore.rules gates on it).
|
||||||
|
"org_id": (fb_user.custom_claims or {}).get("org_id"),
|
||||||
|
"org_role": (fb_user.custom_claims or {}).get("org_role"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +135,7 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
|||||||
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
raise HTTPException(400, f"Invalid role. Must be one of: {', '.join(sorted(VALID_ROLES))}")
|
||||||
if body.role == "operator" and not body.owned_node_ids:
|
if body.role == "operator" and not body.owned_node_ids:
|
||||||
raise HTTPException(400, "Operator role requires at least one owned node.")
|
raise HTTPException(400, "Operator role requires at least one owned node.")
|
||||||
|
org_id = await _resolve_org(body.org_id, decoded)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
|
fb_user: firebase_auth.UserRecord = await asyncio.to_thread(
|
||||||
@@ -115,10 +150,11 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
|||||||
raise HTTPException(400, f"Failed to create user: {e}")
|
raise HTTPException(400, f"Failed to create user: {e}")
|
||||||
|
|
||||||
# Set custom claims
|
# Set custom claims
|
||||||
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids}
|
claims: dict = {"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id, "org_role": "member"}
|
||||||
if body.role == "admin":
|
if body.role == "admin":
|
||||||
claims["admin"] = True
|
claims["admin"] = True
|
||||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
|
await asyncio.to_thread(firebase_auth.set_custom_user_claims, fb_user.uid, claims)
|
||||||
|
await _write_membership(fb_user.uid, body.email, org_id)
|
||||||
|
|
||||||
# Write Firestore profile
|
# Write Firestore profile
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
@@ -145,7 +181,7 @@ async def create_user(body: UserCreate, decoded: dict = Depends(require_admin_to
|
|||||||
action="user.create",
|
action="user.create",
|
||||||
target_uid=fb_user.uid,
|
target_uid=fb_user.uid,
|
||||||
target_email=body.email,
|
target_email=body.email,
|
||||||
details={"role": body.role, "owned_node_ids": body.owned_node_ids},
|
details={"role": body.role, "owned_node_ids": body.owned_node_ids, "org_id": org_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
return {**_format_user(fb_user), "invite_link": invite_link}
|
return {**_format_user(fb_user), "invite_link": invite_link}
|
||||||
@@ -197,7 +233,26 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir
|
|||||||
else:
|
else:
|
||||||
new_claims.pop("admin", None)
|
new_claims.pop("admin", None)
|
||||||
|
|
||||||
|
# Heal users created before POST /users set an org (they could read
|
||||||
|
# nothing): any edit attaches them to the requested/admin's org. An
|
||||||
|
# existing org is never silently moved.
|
||||||
|
# Heal users created before POST /admin/users set an org (they could read
|
||||||
|
# nothing): any edit attaches them to the requested/admin's org. Moving a
|
||||||
|
# user who already has an org only happens when org_id is passed
|
||||||
|
# explicitly — e.g. a viewer whose first login self-provisioned an empty
|
||||||
|
# org of their own via POST /auth/signup (seen 2026-09-27).
|
||||||
|
attached_org: Optional[str] = None
|
||||||
|
left_org: Optional[str] = None
|
||||||
|
current_org = existing_claims.get("org_id")
|
||||||
|
if not current_org or (body.org_id and body.org_id != current_org):
|
||||||
|
attached_org = await _resolve_org(body.org_id, decoded)
|
||||||
|
left_org = current_org
|
||||||
|
new_claims["org_id"] = attached_org
|
||||||
|
new_claims["org_role"] = "member"
|
||||||
|
|
||||||
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
|
await asyncio.to_thread(firebase_auth.set_custom_user_claims, uid, new_claims)
|
||||||
|
if attached_org:
|
||||||
|
await _write_membership(uid, fb_user.email, attached_org)
|
||||||
|
|
||||||
if body.display_name is not None:
|
if body.display_name is not None:
|
||||||
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
|
await asyncio.to_thread(firebase_auth.update_user, uid, display_name=body.display_name)
|
||||||
@@ -218,6 +273,9 @@ async def update_user(uid: str, body: UserUpdate, decoded: dict = Depends(requir
|
|||||||
"new_role": new_role,
|
"new_role": new_role,
|
||||||
"old_nodes": current_nodes,
|
"old_nodes": current_nodes,
|
||||||
"new_nodes": new_nodes,
|
"new_nodes": new_nodes,
|
||||||
|
**({"attached_org_id": attached_org} if attached_org else {}),
|
||||||
|
# The org left behind is not deleted: it may hold nodes or data.
|
||||||
|
**({"left_org_id": left_org} if left_org else {}),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -65,3 +65,16 @@ def test_prompt_incidents_is_most_recently_active_first_and_capped():
|
|||||||
# falls back to started_at when updated_at is absent, and never raises
|
# falls back to started_at when updated_at is absent, and never raises
|
||||||
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
|
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
|
||||||
{"incident_id": "b"}])[0]["incident_id"] == "a"
|
{"incident_id": "b"}])[0]["incident_id"] == "a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inc_summary_shows_age_and_size_not_just_idle():
|
||||||
|
# server-26#189: every link resets idle, so a 2.5h catch-all read as fresh.
|
||||||
|
s = _inc_summary({
|
||||||
|
"incident_id": "sink",
|
||||||
|
"started_at": "2026-09-07T05:26:00+00:00",
|
||||||
|
"updated_at": "2026-09-07T07:58:00+00:00",
|
||||||
|
"call_ids": [f"c{n}" for n in range(40)],
|
||||||
|
}, NOW)
|
||||||
|
assert "idle:2min ago" in s
|
||||||
|
assert "open:2h34m" in s
|
||||||
|
assert "calls:40" in s
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
Admin-created users must land in an org. firestore.rules gates every read on
|
||||||
|
the org_id claim, so a viewer created via POST /admin/users without one saw no
|
||||||
|
incidents or calls at all (reported 2026-09-27).
|
||||||
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.internal.auth import require_admin_token
|
||||||
|
from app.routers import users
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
ADMIN = {"uid": "admin-1", "email": "a@x", "role": "admin", "org_id": "org-A"}
|
||||||
|
|
||||||
|
|
||||||
|
def _as(decoded):
|
||||||
|
app.dependency_overrides[require_admin_token] = lambda: decoded
|
||||||
|
|
||||||
|
|
||||||
|
def teardown_function():
|
||||||
|
app.dependency_overrides.pop(require_admin_token, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _fb(**kw):
|
||||||
|
base = dict(uid="u1", email="v@x", display_name="", custom_claims={}, disabled=False,
|
||||||
|
email_verified=False, user_metadata=SimpleNamespace(creation_timestamp=0, last_sign_in_timestamp=None))
|
||||||
|
return SimpleNamespace(**{**base, **kw})
|
||||||
|
|
||||||
|
|
||||||
|
def _run(method, path, body, fb_user, orgs=("org-A", "founding")):
|
||||||
|
fa = users.firebase_auth
|
||||||
|
with patch.object(fa, "create_user", return_value=fb_user, create=True), \
|
||||||
|
patch.object(fa, "get_user", return_value=fb_user, create=True), \
|
||||||
|
patch.object(fa, "set_custom_user_claims", create=True) as set_claims, \
|
||||||
|
patch.object(fa, "generate_password_reset_link", return_value="link", create=True), \
|
||||||
|
patch.object(users.fstore, "doc_get", AsyncMock(side_effect=lambda c, i: {"org_id": i} if c == "organizations" and i in orgs else None)), \
|
||||||
|
patch.object(users.fstore, "doc_set", AsyncMock()) as doc_set, \
|
||||||
|
patch.object(users.audit, "write_audit", AsyncMock()):
|
||||||
|
resp = getattr(client, method)(path, json=body)
|
||||||
|
members = [c for c in doc_set.await_args_list if c.args[0] == "org_members"]
|
||||||
|
return resp, set_claims, members
|
||||||
|
|
||||||
|
|
||||||
|
def test_created_viewer_joins_the_admins_org_as_member():
|
||||||
|
_as(ADMIN)
|
||||||
|
resp, set_claims, members = _run("post", "/admin/users", {"email": "v@x", "role": "viewer"}, _fb())
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
claims = set_claims.call_args.args[1]
|
||||||
|
assert (claims["org_id"], claims["org_role"], claims["role"]) == ("org-A", "member", "viewer")
|
||||||
|
assert members and members[0].args[2]["org_id"] == "org-A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_without_an_org_claim_defaults_to_founding():
|
||||||
|
_as({k: v for k, v in ADMIN.items() if k != "org_id"})
|
||||||
|
resp, set_claims, _ = _run("post", "/admin/users", {"email": "v@x", "role": "viewer"}, _fb())
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert set_claims.call_args.args[1]["org_id"] == "founding"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_org_is_rejected():
|
||||||
|
_as(ADMIN)
|
||||||
|
resp, set_claims, _ = _run("post", "/admin/users", {"email": "v@x", "role": "viewer", "org_id": "nope"}, _fb())
|
||||||
|
assert resp.status_code == 400
|
||||||
|
set_claims.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_an_orgless_user_heals_them():
|
||||||
|
_as(ADMIN)
|
||||||
|
resp, set_claims, members = _run("patch", "/admin/users/u1", {"role": "viewer"}, _fb(custom_claims={"role": "viewer"}))
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert set_claims.call_args.args[1]["org_id"] == "org-A"
|
||||||
|
assert members
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_never_silently_moves_an_existing_org():
|
||||||
|
_as(ADMIN)
|
||||||
|
fb = _fb(custom_claims={"role": "viewer", "org_id": "org-B", "org_role": "member"})
|
||||||
|
resp, set_claims, members = _run("patch", "/admin/users/u1", {"role": "viewer"}, fb)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert set_claims.call_args.args[1]["org_id"] == "org-B"
|
||||||
|
assert not members
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_org_id_moves_a_self_provisioned_owner_into_the_network():
|
||||||
|
_as(ADMIN)
|
||||||
|
fb = _fb(custom_claims={"role": "viewer", "org_id": "own-empty-org", "org_role": "owner"})
|
||||||
|
resp, set_claims, members = _run("patch", "/admin/users/u1", {"org_id": "org-A"}, fb)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
claims = set_claims.call_args.args[1]
|
||||||
|
assert (claims["org_id"], claims["org_role"]) == ("org-A", "member")
|
||||||
|
assert members and members[0].args[2]["org_id"] == "org-A"
|
||||||
@@ -367,6 +367,26 @@ function UserDetailPanel({
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showSessions, setShowSessions] = useState(false);
|
const [showSessions, setShowSessions] = useState(false);
|
||||||
|
const { orgId: myOrgId } = useAuth();
|
||||||
|
const [moving, setMoving] = useState(false);
|
||||||
|
|
||||||
|
// A user outside the admin's org reads none of its incidents or calls —
|
||||||
|
// e.g. a viewer whose first login self-provisioned an empty org.
|
||||||
|
async function handleMoveToMyOrg() {
|
||||||
|
if (!myOrgId) return;
|
||||||
|
if (!confirm(`Move ${detail.email ?? "this user"} into your organization as a member? They'll need to sign out and back in.`)) return;
|
||||||
|
setMoving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updated = await c2api.updateUser(user.uid, { org_id: myOrgId });
|
||||||
|
onUpdated(updated);
|
||||||
|
setDetail((d) => ({ ...d, ...updated }));
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setMoving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch full detail (sessions) lazily
|
// Fetch full detail (sessions) lazily
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -496,6 +516,25 @@ function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
||||||
|
<div className="flex justify-between items-center gap-3">
|
||||||
|
<span className="text-gray-500">Organization</span>
|
||||||
|
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
||||||
|
{!detail.org_id
|
||||||
|
? "None: sees no data"
|
||||||
|
: detail.org_id === myOrgId
|
||||||
|
? `Your org (${detail.org_role ?? "member"})`
|
||||||
|
: `Other org ${detail.org_id.slice(0, 8)}… (${detail.org_role ?? "member"})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{myOrgId && detail.org_id !== myOrgId && (
|
||||||
|
<button
|
||||||
|
onClick={handleMoveToMyOrg}
|
||||||
|
disabled={moving}
|
||||||
|
className="w-full bg-yellow-900/60 hover:bg-yellow-800/60 disabled:opacity-50 text-yellow-200 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{moving ? "Moving…" : "Move to my organization"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Status</span>
|
<span className="text-gray-500">Status</span>
|
||||||
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||||||
|
|||||||
@@ -313,6 +313,30 @@ function VesselLayer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Weather radar ────────────────────────────────────────────────────────────
|
||||||
|
// IEM tiles are static once loaded, so refresh them in place with a
|
||||||
|
// cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a
|
||||||
|
// remounted child of an unchecked LayersControl.Overlay is re-registered with
|
||||||
|
// the control but not re-added to the map, which switched radar off every 5 min.
|
||||||
|
const RADAR_URL = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png";
|
||||||
|
const RADAR_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function RadarLayer() {
|
||||||
|
const ref = useRef<L.TileLayer>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<TileLayer
|
||||||
|
ref={ref}
|
||||||
|
url={RADAR_URL}
|
||||||
|
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||||||
|
opacity={0.65}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
||||||
// Overlay names double as the event keys for overlayadd/overlayremove.
|
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||||||
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
||||||
@@ -331,16 +355,24 @@ function useOverlayShown(map: L.Map, name: string): boolean {
|
|||||||
return shown;
|
return shown;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cameraIcon(): L.DivIcon {
|
/** Small count badge for an LOD cluster icon; empty when the marker stands alone. */
|
||||||
|
function countBadge(count: number): string {
|
||||||
|
if (count < 2) return "";
|
||||||
|
const label = count > 99 ? "99+" : String(count);
|
||||||
|
return `<span style="position:absolute;top:-7px;right:-9px;min-width:14px;height:14px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid #fff;font:700 9px/12px sans-serif;text-align:center;box-sizing:border-box">${label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraIcon(count = 1): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>`,
|
html: `<div style="position:relative;width:16px;height:12px"><svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>${countBadge(count)}</div>`,
|
||||||
iconSize: [16, 12],
|
iconSize: [16, 12],
|
||||||
iconAnchor: [8, 6],
|
iconAnchor: [8, 6],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shape + glyph per 511 event type, so the layer reads without colour alone.
|
// Shape + glyph per 511 event type, so the layer reads without colour alone.
|
||||||
|
// Order is LOD priority: when events share a cell, the earliest type represents it.
|
||||||
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
|
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
|
||||||
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
|
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
|
||||||
closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
|
closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
|
||||||
@@ -349,17 +381,65 @@ const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }
|
|||||||
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
|
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
|
||||||
};
|
};
|
||||||
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
|
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
|
||||||
|
const EVENT_RANK = Object.keys(EVENT_STYLE);
|
||||||
|
const eventRank = (e: Ny511Event) => {
|
||||||
|
const r = EVENT_RANK.indexOf(e.type);
|
||||||
|
return r < 0 ? EVENT_RANK.length : r;
|
||||||
|
};
|
||||||
|
|
||||||
function trafficEventIcon(type: string): L.DivIcon {
|
function trafficEventIcon(type: string, count = 1): L.DivIcon {
|
||||||
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
|
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>`,
|
html: `<div style="position:relative;width:16px;height:16px"><svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>${countBadge(count)}</div>`,
|
||||||
iconSize: [16, 16],
|
iconSize: [16, 16],
|
||||||
iconAnchor: [8, 8],
|
iconAnchor: [8, 8],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 511 level of detail ──────────────────────────────────────────────────────
|
||||||
|
// Zoomed out, a county's worth of cameras/events buries the map. Below
|
||||||
|
// LOD_FULL_ZOOM, markers are bucketed into a grid of LOD_CELL_PX screen pixels
|
||||||
|
// and each cell draws one representative with a count badge; clicking a
|
||||||
|
// cluster zooms in on it. The grid is in world pixels (map.project), so it
|
||||||
|
// only changes on zoom — panning never reshuffles which marker represents a cell.
|
||||||
|
const LOD_CELL_PX = 40;
|
||||||
|
const LOD_FULL_ZOOM = 14;
|
||||||
|
|
||||||
|
interface LodCell<T> { rep: T; count: number }
|
||||||
|
|
||||||
|
function useMapZoom(map: L.Map): number {
|
||||||
|
const [zoom, setZoom] = useState(() => map.getZoom());
|
||||||
|
useEffect(() => {
|
||||||
|
const h = () => setZoom(map.getZoom());
|
||||||
|
map.on("zoomend", h);
|
||||||
|
return () => { map.off("zoomend", h); };
|
||||||
|
}, [map]);
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lodCells<T extends { lat: number; lon: number }>(
|
||||||
|
items: T[], map: L.Map, zoom: number, rank: (t: T) => number = () => 0,
|
||||||
|
): LodCell<T>[] {
|
||||||
|
if (zoom >= LOD_FULL_ZOOM) return items.map((rep) => ({ rep, count: 1 }));
|
||||||
|
const cells = new Map<string, LodCell<T>>();
|
||||||
|
for (const it of items) {
|
||||||
|
const p = map.project([it.lat, it.lon], zoom);
|
||||||
|
const key = `${Math.floor(p.x / LOD_CELL_PX)}:${Math.floor(p.y / LOD_CELL_PX)}`;
|
||||||
|
const cell = cells.get(key);
|
||||||
|
if (!cell) cells.set(key, { rep: it, count: 1 });
|
||||||
|
else {
|
||||||
|
cell.count++;
|
||||||
|
if (rank(it) < rank(cell.rep)) cell.rep = it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(cells.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomInto(map: L.Map, lat: number, lon: number) {
|
||||||
|
map.setView([lat, lon], Math.min(map.getZoom() + 2, LOD_FULL_ZOOM));
|
||||||
|
}
|
||||||
|
|
||||||
function fmtLocal(iso: string | null): string | null {
|
function fmtLocal(iso: string | null): string | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local
|
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local
|
||||||
@@ -381,28 +461,79 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pinned cameras: clicking a camera's snapshot pins its live feed as a small
|
// Open cameras: each is either pinned (a small tile anchored at the camera, so
|
||||||
// tile anchored at the camera, so several views along one road can be read
|
// several views along one road read against the map) or docked (a larger tile
|
||||||
// against the map. Oldest pin drops past the cap.
|
// in a strip over the map, which stays put while you pan and survives the
|
||||||
const MAX_CAMERA_PINS = 6;
|
// overlay being switched off). Oldest camera drops past the cap.
|
||||||
|
const MAX_OPEN_CAMERAS = 6;
|
||||||
|
type CameraMode = "pin" | "dock";
|
||||||
|
interface OpenCamera { cam: Ny511Camera; mode: CameraMode }
|
||||||
|
|
||||||
|
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
||||||
|
|
||||||
|
function CameraDock({ map, open, onMode, onClose }: {
|
||||||
|
map: L.Map;
|
||||||
|
open: Ny511Camera[];
|
||||||
|
onMode: (id: string, mode: CameraMode) => void;
|
||||||
|
onClose: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
// Portaled into the Leaflet container — keep clicks/scrolls off the map.
|
||||||
|
if (!ref.current) return;
|
||||||
|
L.DomEvent.disableClickPropagation(ref.current);
|
||||||
|
L.DomEvent.disableScrollPropagation(ref.current);
|
||||||
|
}, [open.length > 0]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
if (!open.length) return null;
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none"
|
||||||
|
>
|
||||||
|
{open.map((c) => (
|
||||||
|
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
|
||||||
|
<div className="flex items-center gap-1 px-1.5 h-5">
|
||||||
|
<span className="flex-1 truncate text-[11px] leading-5 text-ink-2" title={c.name}>{c.name}</span>
|
||||||
|
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
|
||||||
|
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
||||||
|
</div>
|
||||||
|
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
map.getContainer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DotCameraLayer() {
|
function DotCameraLayer() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
|
const zoom = useMapZoom(map);
|
||||||
const { data, error } = use511(map, "cameras", shown);
|
const { data, error } = use511(map, "cameras", shown);
|
||||||
const [pinned, setPinned] = useState<Ny511Camera[]>([]);
|
const [open, setOpen] = useState<OpenCamera[]>([]);
|
||||||
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
|
|
||||||
|
|
||||||
const pin = (c: Ny511Camera) => {
|
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
|
||||||
|
const cells = useMemo(
|
||||||
|
() => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom),
|
||||||
|
[data.cameras, pinnedIds, map, zoom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const show = (cam: Ny511Camera, mode: CameraMode) => {
|
||||||
map.closePopup();
|
map.closePopup();
|
||||||
setPinned((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, c].slice(-MAX_CAMERA_PINS)));
|
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
|
||||||
};
|
};
|
||||||
const unpin = (id: string) => setPinned((prev) => prev.filter((p) => p.id !== id));
|
const setMode = (id: string, mode: CameraMode) =>
|
||||||
|
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
|
||||||
|
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||||||
{(data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)).map((c) => (
|
{cells.map(({ rep: c, count }) => count > 1 ? (
|
||||||
|
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
|
||||||
|
title={`${count} cameras — click to zoom in`}
|
||||||
|
eventHandlers={{ click: () => zoomInto(map, c.lat, c.lon) }} />
|
||||||
|
) : (
|
||||||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||||||
<Popup minWidth={260} maxWidth={340}>
|
<Popup minWidth={260} maxWidth={340}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -410,32 +541,38 @@ function DotCameraLayer() {
|
|||||||
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
||||||
{c.image_url && (
|
{c.image_url && (
|
||||||
// Popup content mounts on open, so the timestamp busts the cache per open.
|
// Popup content mounts on open, so the timestamp busts the cache per open.
|
||||||
<button type="button" onClick={() => pin(c)} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
<button type="button" onClick={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex gap-1.5 pt-0.5">
|
||||||
|
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
|
||||||
|
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
|
||||||
|
</div>
|
||||||
<div className="text-[10px] text-ink-muted">
|
<div className="text-[10px] text-ink-muted">
|
||||||
Click the image to pin {c.video_url ? "the live feed" : "it"} to the map · 511NY / NYSDOT
|
{c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
{/* Players only exist while the overlay is on, so hiding it stops every stream. */}
|
{/* Pinned players only exist while the overlay is on, so hiding it stops their streams. */}
|
||||||
{shown && pinned.map((c) => (
|
{shown && open.filter((o) => o.mode === "pin").map(({ cam: c }) => (
|
||||||
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
|
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
|
||||||
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
|
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
|
||||||
<div className="w-[192px]">
|
<div className="w-[192px]">
|
||||||
<div className="flex items-center gap-1 px-1.5 h-4">
|
<div className="flex items-center gap-1 px-1.5 h-4">
|
||||||
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
|
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
|
||||||
<button type="button" onClick={() => unpin(c.id)} aria-label={`Unpin ${c.name}`} className="text-ink-muted hover:text-ink text-xs leading-none px-0.5">×</button>
|
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
|
||||||
|
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
|
||||||
</div>
|
</div>
|
||||||
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
|
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -443,16 +580,26 @@ function DotCameraLayer() {
|
|||||||
function TrafficEventLayer() {
|
function TrafficEventLayer() {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
|
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
|
||||||
|
const zoom = useMapZoom(map);
|
||||||
const { data, error } = use511(map, "events", shown);
|
const { data, error } = use511(map, "events", shown);
|
||||||
|
const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
|
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
|
||||||
{(data.events ?? []).map((e: Ny511Event) => {
|
{cells.map(({ rep: e, count }) => {
|
||||||
|
const zIndexOffset = e.type === "accidentsAndIncidents" ? 200 : 0;
|
||||||
|
if (count > 1) {
|
||||||
|
return (
|
||||||
|
<Marker key={`lod-${e.id}`} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type, count)} zIndexOffset={zIndexOffset}
|
||||||
|
title={`${count} traffic events — click to zoom in`}
|
||||||
|
eventHandlers={{ click: () => zoomInto(map, e.lat, e.lon) }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
|
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
|
||||||
const start = fmtLocal(e.start_local);
|
const start = fmtLocal(e.start_local);
|
||||||
const end = fmtLocal(e.planned_end_local);
|
const end = fmtLocal(e.planned_end_local);
|
||||||
return (
|
return (
|
||||||
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={e.type === "accidentsAndIncidents" ? 200 : 0}>
|
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={zIndexOffset}>
|
||||||
<Popup minWidth={220} maxWidth={320}>
|
<Popup minWidth={220} maxWidth={320}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
|
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
|
||||||
@@ -837,7 +984,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [agoClock, setAgoClock] = useState(0);
|
const [agoClock, setAgoClock] = useState(0);
|
||||||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
|
||||||
const [aircraftShown, setAircraftShown] = useState(false);
|
const [aircraftShown, setAircraftShown] = useState(false);
|
||||||
|
|
||||||
// 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
|
||||||
@@ -859,12 +1005,6 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Radar tiles are static once loaded — force remount every 5 min to refresh
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||||
|
|
||||||
@@ -997,14 +1137,9 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
|||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
||||||
<LayersControl.Overlay name="Weather Radar">
|
<LayersControl.Overlay name="Weather Radar">
|
||||||
<TileLayer
|
<RadarLayer />
|
||||||
key={radarEpoch}
|
|
||||||
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
|
|
||||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
|
||||||
opacity={0.65}
|
|
||||||
/>
|
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|||||||
@@ -328,7 +328,10 @@ export const c2api = {
|
|||||||
}),
|
}),
|
||||||
getUser: (uid: string) =>
|
getUser: (uid: string) =>
|
||||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
|
||||||
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
|
updateUser: (
|
||||||
|
uid: string,
|
||||||
|
body: { role?: string; owned_node_ids?: string[]; display_name?: string; org_id?: string },
|
||||||
|
) =>
|
||||||
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface UserRecord {
|
|||||||
discord_linked: boolean;
|
discord_linked: boolean;
|
||||||
discord_username: string | null;
|
discord_username: string | null;
|
||||||
discord_user_id: string | null;
|
discord_user_id: string | null;
|
||||||
|
/** The org whose data this user can read; null = none (sees nothing). */
|
||||||
|
org_id?: string | null;
|
||||||
|
org_role?: "owner" | "member" | null;
|
||||||
// only present on GET /admin/users/{uid}
|
// only present on GET /admin/users/{uid}
|
||||||
sessions?: UserSession[];
|
sessions?: UserSession[];
|
||||||
// only present on POST /admin/users response
|
// only present on POST /admin/users response
|
||||||
|
|||||||
Reference in New Issue
Block a user