Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a631d65d0 | ||
|
|
3a944f35c1 | ||
|
|
0712e7a437 | ||
|
|
a739fa64f0 | ||
|
|
d67b2057e6 | ||
|
|
c1c3e89e1d | ||
|
|
968134f8ee |
@@ -108,16 +108,31 @@ _ROAD_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# Street-type synonyms collapsed to one token so "Mohegan Park Avenue" and
|
||||
# "Mohegan Park Ave" produce the same road id (server-26#115 — that one
|
||||
# difference was splitting a car-alarm incident into two).
|
||||
_ROAD_SUFFIX_CANON = {
|
||||
"avenue": "ave", "street": "st", "road": "rd", "drive": "dr",
|
||||
"boulevard": "blvd", "lane": "ln", "court": "ct", "place": "pl",
|
||||
"highway": "hwy", "parkway": "pkwy",
|
||||
}
|
||||
|
||||
|
||||
def _extract_road_ids(text: str) -> set[str]:
|
||||
"""
|
||||
Extract normalised road/route identifiers from a location string.
|
||||
e.g. "suspect east on Route 202" → {"route 202"}
|
||||
"at Main Street and Oak Ave" → {"main street", "oak ave"}
|
||||
"at Main Street and Oak Ave" → {"main st", "oak ave"}
|
||||
"""
|
||||
return {
|
||||
re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
|
||||
for m in _ROAD_RE.finditer(text)
|
||||
}
|
||||
ids: set[str] = set()
|
||||
for m in _ROAD_RE.finditer(text):
|
||||
key = re.sub(r"[\s.\-]+", " ", m.group().lower()).strip()
|
||||
parts = key.split()
|
||||
if parts and parts[-1] in _ROAD_SUFFIX_CANON:
|
||||
parts[-1] = _ROAD_SUFFIX_CANON[parts[-1]]
|
||||
key = " ".join(parts)
|
||||
ids.add(key)
|
||||
return ids
|
||||
|
||||
|
||||
def _location_mentions_road_overlap(new_location: str, inc_mentions: list[str]) -> bool:
|
||||
|
||||
@@ -45,7 +45,18 @@ def _fmt_idle(inc: dict, now: datetime) -> str:
|
||||
|
||||
|
||||
def _inc_summary(inc: dict, now: datetime) -> str:
|
||||
# server-26#115: the model was given no title and no talkgroup, so it
|
||||
# could not tell that "car alarms, Mohegan Park Ave" and "car alarms,
|
||||
# Mohegan Park Avenue" on the same channel were one incident — it defaulted
|
||||
# to "new". Title is the single strongest human-readable signal for "is
|
||||
# this the same event"; talkgroup is what makes same-channel continuation
|
||||
# obvious.
|
||||
parts = [f"id:{inc['incident_id']}", f"type:{inc.get('type') or '?'}"]
|
||||
tgs = inc.get("talkgroup_ids") or []
|
||||
if tgs:
|
||||
parts.append(f"tg:[{', '.join(str(t) for t in tgs[:3])}]")
|
||||
if inc.get("title"):
|
||||
parts.append(f"title:{inc['title']!r}")
|
||||
if inc.get("location"):
|
||||
parts.append(f"loc:{inc['location']}")
|
||||
units = inc.get("units") or []
|
||||
@@ -80,19 +91,50 @@ def _call_block(ctx: dict) -> str:
|
||||
lines.append(f"Units: {ctx['call_units']}")
|
||||
if ctx["call_vehicles"]:
|
||||
lines.append(f"Vehicles: {ctx['call_vehicles']}")
|
||||
if ctx["talkgroup_name"]:
|
||||
lines.append(f"Talkgroup: {ctx['talkgroup_name']}")
|
||||
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
|
||||
# id is what makes the "same talkgroup" rule in _RULES evaluable
|
||||
# (server-26#115 review).
|
||||
tgid = ctx.get("talkgroup_id")
|
||||
name = ctx["talkgroup_name"] or "?"
|
||||
lines.append(f"Talkgroup: {name}" + (f" (id {tgid})" if tgid is not None else ""))
|
||||
return "\n".join(lines) if lines else "(no details)"
|
||||
|
||||
|
||||
def _prompt_incidents(recent: list[dict]) -> list[dict]:
|
||||
"""The ≤20 candidates shown to the model, most-recently-active first.
|
||||
|
||||
`ctx["recent"]` is an unordered slice of a Firestore result with no
|
||||
order_by, so a busy 2h window (~40 active incidents) meant the model saw
|
||||
an arbitrary half of the candidates (server-26#115 review). Sorting by
|
||||
updated_at desc also makes each row's `idle:` field monotonic.
|
||||
"""
|
||||
def _key(inc: dict):
|
||||
return str(inc.get("updated_at") or inc.get("started_at") or "")
|
||||
return sorted(recent, key=_key, reverse=True)[:20]
|
||||
|
||||
|
||||
_SCHEMA = '{"action": "link" | "new" | "orphan", "incident_id": "<id_string or null>", "reasoning": "<one sentence>"}'
|
||||
|
||||
_RULES = """
|
||||
Rules:
|
||||
- "link" only with clear positive evidence: same units, same geocoded location, or semantically identical scene on the same talkgroup within the last few minutes.
|
||||
- A call on a DIFFERENT talkgroup than an incident requires unit overlap or geocoded location match — topic similarity alone is not enough.
|
||||
- "new" only if the call has a clear incident_type AND describes a distinct, identifiable scene.
|
||||
- "orphan" when in doubt — conservative is always correct.
|
||||
Rules (this system OVER-SPLITS — a real incident routinely gets shattered into
|
||||
5-10 duplicates. A wrong link is cheap; a duplicate incident is the failure
|
||||
mode. Bias accordingly.):
|
||||
- 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
|
||||
"Ave"/"Avenue", "St"/"Street", "Rd"/"Road" as identical; a house number plus
|
||||
the same street is the same place), the same subject/vehicle/case number, or a
|
||||
follow-up beat ("units clearing", "negative contact", "tow en route", "event
|
||||
number 214-201", a status update) to an incident that is only a few minutes
|
||||
idle. The bar for "link" on the same talkgroup is LOW.
|
||||
- Reserve "new" for a call that clearly describes a DIFFERENT event from every
|
||||
recent incident — a different place, different units, and a different subject,
|
||||
not merely a different transmission about the same job.
|
||||
- "orphan" a call that is not an incident at all: radio checks, roll call,
|
||||
a unit marking on/off duty or 10-8/10-98, mileage/log entries, a bare
|
||||
acknowledgement. Do not open a "new" incident for these.
|
||||
- A call on a DIFFERENT talkgroup than an incident still requires unit overlap
|
||||
or a geocoded/location match — topic similarity alone is not enough there.
|
||||
- Do NOT link just because both calls involve police or both mention a road.
|
||||
"""
|
||||
|
||||
@@ -101,7 +143,7 @@ def _build_decide_prompt(ctx: dict) -> str:
|
||||
now = ctx["now"]
|
||||
recent = ctx["recent"]
|
||||
inc_block = (
|
||||
"\n".join(_inc_summary(inc, now) for inc in recent[:20])
|
||||
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
|
||||
if recent else "(none)"
|
||||
)
|
||||
return (
|
||||
@@ -119,7 +161,7 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
|
||||
now = ctx["now"]
|
||||
recent = ctx["recent"]
|
||||
inc_block = (
|
||||
"\n".join(_inc_summary(inc, now) for inc in recent[:20])
|
||||
"\n".join(_inc_summary(inc, now) for inc in _prompt_incidents(recent))
|
||||
if recent else "(none)"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
server-26#115 — the tiebreaker manufactured incidents because it was blind to
|
||||
what would tell it two incidents are one.
|
||||
|
||||
Two low-risk supports for the reframed prompt:
|
||||
1. `_extract_road_ids` collapses street-type synonyms, so "Mohegan Park Ave"
|
||||
and "Mohegan Park Avenue" share a road id (they were splitting one
|
||||
car-alarm incident into two).
|
||||
2. `_inc_summary` now carries the incident title and talkgroup, the two
|
||||
signals the model needs to recognise a same-channel continuation.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.internal.incident_correlator import (
|
||||
_extract_road_ids, _location_mentions_road_overlap,
|
||||
)
|
||||
from app.internal.llm_correlator import _inc_summary, _prompt_incidents
|
||||
|
||||
NOW = datetime(2026, 9, 7, 8, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_avenue_and_ave_are_the_same_road_id():
|
||||
assert _extract_road_ids("Mohegan Park Avenue") == _extract_road_ids("Mohegan Park Ave")
|
||||
assert _extract_road_ids("191 Broadway Street") == _extract_road_ids("191 Broadway St")
|
||||
assert _extract_road_ids("North State Road") == _extract_road_ids("North State Rd")
|
||||
|
||||
|
||||
def test_road_overlap_matches_across_the_synonym():
|
||||
assert _location_mentions_road_overlap("multiple car alarms Mohegan Park Avenue",
|
||||
["patrol to Mohegan Park Ave"]) is True
|
||||
# still discriminates genuinely different streets
|
||||
assert _location_mentions_road_overlap("Oak Avenue", ["Elm Avenue"]) is False
|
||||
|
||||
|
||||
def test_inc_summary_carries_title_and_talkgroup():
|
||||
s = _inc_summary({
|
||||
"incident_id": "abc123",
|
||||
"type": "police",
|
||||
"talkgroup_ids": [9560],
|
||||
"title": "Nuisance Alarm at Mohegan Park Ave",
|
||||
"location": "Mohegan Park Ave",
|
||||
"units": ["Headquarters"],
|
||||
"tags": ["car-alarm"],
|
||||
"updated_at": NOW.isoformat(),
|
||||
}, NOW)
|
||||
assert "title:'Nuisance Alarm at Mohegan Park Ave'" in s
|
||||
assert "tg:[9560]" in s
|
||||
assert "id:abc123" in s
|
||||
|
||||
|
||||
def test_inc_summary_omits_missing_optional_fields():
|
||||
s = _inc_summary({"incident_id": "x", "updated_at": NOW.isoformat()}, NOW)
|
||||
assert "title:" not in s and "tg:" not in s and "loc:" not in s
|
||||
assert s.startswith("id:x")
|
||||
|
||||
|
||||
def test_prompt_incidents_is_most_recently_active_first_and_capped():
|
||||
recent = [
|
||||
{"incident_id": f"i{n}", "updated_at": f"2026-09-07T0{n}:00:00+00:00"}
|
||||
for n in range(1, 8)
|
||||
]
|
||||
ordered = _prompt_incidents(recent)
|
||||
assert [i["incident_id"] for i in ordered] == ["i7", "i6", "i5", "i4", "i3", "i2", "i1"]
|
||||
assert len(_prompt_incidents(recent * 5)) == 20
|
||||
# falls back to started_at when updated_at is absent, and never raises
|
||||
assert _prompt_incidents([{"incident_id": "a", "started_at": NOW.isoformat()},
|
||||
{"incident_id": "b"}])[0]["incident_id"] == "a"
|
||||
@@ -186,7 +186,7 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
||||
|
||||
export default function AlertsPage() {
|
||||
const { isAdmin } = useAuth();
|
||||
const { alerts, loading } = useAlerts();
|
||||
const { alerts, loading, error } = useAlerts();
|
||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||
|
||||
async function handleAcknowledge(id: string) {
|
||||
@@ -226,6 +226,12 @@ export default function AlertsPage() {
|
||||
{tab === "events" && (
|
||||
loading ? (
|
||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
||||
) : error ? (
|
||||
<p className="text-red-400 text-sm font-mono">
|
||||
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||
? "Couldn't load alerts — a database index or security rule isn't deployed on the server yet (server-26 #13 / #51)."
|
||||
: `Couldn't load alerts: ${error}`}
|
||||
</p>
|
||||
) : alerts.length === 0 ? (
|
||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
||||
) : (
|
||||
|
||||
@@ -163,6 +163,20 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
||||
animation: pulse-ring 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
/* ── Leaflet stacking fix ─────────────────────────────────────────────────────
|
||||
* Leaflet's internal panes (z-index 200–700) and its zoom / layers controls
|
||||
* (z-index 1000) otherwise paint above the sticky app Nav (z-40) and any modal
|
||||
* overlay — on Live this put the account dropdown *behind* the map. Pinning the
|
||||
* map container to its own low stacking context keeps Leaflet's internal layer
|
||||
* order intact while dropping the whole map (tiles + controls) below the app
|
||||
* chrome. The map's own overlay UI (legend, incident rail, clock, fit-all) sits
|
||||
* outside .leaflet-container, so it is unaffected and still renders on top.
|
||||
*/
|
||||
.leaflet-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
||||
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||
html:not(.dark) select,
|
||||
|
||||
@@ -100,6 +100,7 @@ export default function IncidentDetailPage() {
|
||||
const displayTags = incident.tags.filter((t) => t !== "auto-generated");
|
||||
const unitsActive = incident.units_active ?? incident.units ?? [];
|
||||
const unitsCleared = incident.units_cleared ?? [];
|
||||
const vehicles = incident.vehicles ?? [];
|
||||
const active = incident.status === "active";
|
||||
|
||||
const visible = newestFirst.slice(0, earlierShown);
|
||||
@@ -213,11 +214,11 @@ export default function IncidentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{incident.vehicles?.length > 0 && (
|
||||
{vehicles.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-ink-muted uppercase tracking-wide mb-2">Vehicles</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{incident.vehicles.map((v) => (
|
||||
{vehicles.map((v) => (
|
||||
<span key={v} className="text-xs bg-raised text-ink-2 px-2 py-0.5 rounded font-mono">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,17 @@ const FILTER_THRESHOLD: Record<SeverityFilter, number> = { all: -1, minor: 1, mo
|
||||
|
||||
type SortMode = "recent" | "severity";
|
||||
|
||||
// The Firestore client surfaces a missing composite index or an undeployed
|
||||
// ruleset as a raw multi-line string with a console URL in it — not something
|
||||
// to put in front of an operator. Collapse the known infra failures to a plain
|
||||
// line; pass anything else straight through so a real bug still shows.
|
||||
function friendlyIncidentsError(raw: string): string {
|
||||
if (/requires an index|PERMISSION_DENIED|Missing or insufficient permissions|failed-precondition/i.test(raw)) {
|
||||
return "Couldn't load incidents — the incidents database index isn't deployed on the server yet. This is a one-time backend deploy step (server-26 #13 / #51), not a problem with your data.";
|
||||
}
|
||||
return `Couldn't load incidents: ${raw}`;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return iso; }
|
||||
}
|
||||
@@ -286,7 +297,7 @@ export default function IncidentsPage() {
|
||||
recorded yet" over the top of it told the operator the radio was
|
||||
quiet when the page had simply failed to load — server-26#13. */}
|
||||
{filtered.length === 0 && error && (
|
||||
<ErrorBanner message={`Couldn't load incidents: ${error}`} />
|
||||
<ErrorBanner message={friendlyIncidentsError(error)} />
|
||||
)}
|
||||
|
||||
{filtered.length === 0 && !error && (
|
||||
|
||||
@@ -60,7 +60,7 @@ function DiscordJoinModal({
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm"
|
||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 space-y-4 font-mono w-full max-w-sm max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<h3 className="text-white font-semibold">Join Discord Voice</h3>
|
||||
<div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function NodesPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{pending.map((n) => (
|
||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function OnboardingPage() {
|
||||
// Firebase custom claims only show up in a *freshly fetched* ID token —
|
||||
// getIdTokenResult(true) inside refreshClaims forces that fetch, then
|
||||
// AuthProvider's own state (orgId) updates and the effect above
|
||||
// redirects to /dashboard.
|
||||
// redirects to "/" (Live).
|
||||
await refreshClaims();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not set up your organization. Try again.");
|
||||
|
||||
@@ -22,7 +22,9 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const upcoming = trip.start_date >= today;
|
||||
// Bucket and badge must agree: the list groups on end_date (page.tsx ~L176),
|
||||
// so a trip isn't "Past" until it's over, not when it starts.
|
||||
const upcoming = trip.end_date >= today;
|
||||
const attendeeCount = Object.keys(trip.attendees ?? {}).length;
|
||||
|
||||
return (
|
||||
@@ -97,10 +99,10 @@ function CreateModal({ onClose, onCreate }: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4"
|
||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<h2 className="text-white font-bold">New Trip</h2>
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ function fmtClock(s: number): string {
|
||||
return `${m}:${r.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean }) {
|
||||
function InlinePlayer({ callId }: { callId: string }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -32,8 +32,6 @@ function InlinePlayer({ callId, hasAudio }: { callId: string; hasAudio: boolean
|
||||
const [duration, setDuration] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
if (!hasAudio) return null;
|
||||
|
||||
async function ensureUrl() {
|
||||
if (url || loading) return;
|
||||
setLoading(true);
|
||||
@@ -169,7 +167,7 @@ export function CallSpineEntry({
|
||||
|
||||
{hasAudio && (
|
||||
<div className="mt-1.5">
|
||||
<InlinePlayer callId={call.call_id} hasAudio={hasAudio} />
|
||||
<InlinePlayer callId={call.call_id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
});
|
||||
|
||||
// ── Basemap tiles ─────────────────────────────────────────────────────────────
|
||||
// Default is CARTO's keyless dark raster basemap — no token, fits the dark UI.
|
||||
// Overridable via NEXT_PUBLIC_MAP_TILE_URL so a keyed style (a CARTO account
|
||||
// style, MapTiler, Mapbox, …) can be dropped in for prod without a code change.
|
||||
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme.
|
||||
const MAP_TILE_URL =
|
||||
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
|
||||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||||
const MAP_TILE_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/">CARTO</a>';
|
||||
|
||||
// ── Colour ────────────────────────────────────────────────────────────────────
|
||||
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
|
||||
// type is carried by the glyph knocked out of the pin, never by colour, and
|
||||
@@ -540,14 +551,14 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
|
||||
{/* Base layers */}
|
||||
<LayersControl.BaseLayer checked name="Dark">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
url={MAP_TILE_URL}
|
||||
attribution={MAP_TILE_ATTRIBUTION}
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Light">
|
||||
<TileLayer
|
||||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||
attribution={MAP_TILE_ATTRIBUTION}
|
||||
/>
|
||||
</LayersControl.BaseLayer>
|
||||
<LayersControl.BaseLayer name="Streets">
|
||||
|
||||
@@ -5,15 +5,20 @@ import type { NodeRecord, SystemRecord } from "@/lib/types";
|
||||
interface Props {
|
||||
node: NodeRecord;
|
||||
system?: SystemRecord;
|
||||
/**
|
||||
* When false, the card renders without its `/nodes/[id]` Link wrapper so a
|
||||
* parent click handler can take the interaction (pending nodes open the
|
||||
* config modal instead of navigating). Defaults to true.
|
||||
*/
|
||||
linkToDetail?: boolean;
|
||||
}
|
||||
|
||||
export function NodeCard({ node, system }: Props) {
|
||||
export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
||||
const lastSeen = node.last_seen
|
||||
? new Date(node.last_seen).toLocaleTimeString()
|
||||
: "never";
|
||||
|
||||
return (
|
||||
<Link href={`/nodes/${node.node_id}`}>
|
||||
const body = (
|
||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
@@ -58,6 +63,7 @@ export function NodeCard({ node, system }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return linkToDetail ? <Link href={`/nodes/${node.node_id}`}>{body}</Link> : body;
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
||||
const selectedPreset = PRESETS.find((p) => p.value === preset);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||
<p className="text-gray-400 text-sm mb-5">
|
||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
||||
|
||||
@@ -143,8 +143,9 @@ export interface IncidentRecord {
|
||||
call_ids: string[];
|
||||
system_ids: string[];
|
||||
talkgroup_ids: string[];
|
||||
units: string[];
|
||||
vehicles: string[];
|
||||
/** Omitted on incident docs written before these fields existed. */
|
||||
units?: string[];
|
||||
vehicles?: string[];
|
||||
/** Units currently believed on scene — maintained by incident_correlator.py `_attach`. */
|
||||
units_active?: string[];
|
||||
/** Units that reported clearing/back in service on this incident. */
|
||||
|
||||
Reference in New Issue
Block a user