Author SHA1 Message Date
Logan CusanoandClaude Opus 5.5 36fdfb2758 map: mount without waiting on the 500-call window; legend collapsible, hidden under open layers list
The Live map was held behind useCalls(500), measured at ~9.2s to first map
paint on prod; it now mounts as soon as the saved view is known (nodes are
awaited only when there is no view). The legend painted over the expanded
Leaflet layers list (z-1001 vs the map's z-0 stacking context): it is now a
<details> that hides while the list is open and starts folded on short
viewports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:40:57 -05:00
Logan CusanoandClaude Opus 5.5 7cd289874a frontend: Iris palette — graphite neutrals + indigo-violet accent
Tokens in globals.css, Tailwind gray/indigo scales remapped so the ~350
legacy raw-class uses follow the palette, light-mode override sheet moved
off slate, map accent hexes updated. Severity hues retuned for AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:38:59 -05:00
logan f4d78aba77 Merge pull request 'Nodes: store and show host disk free, warn when low (node-26#41)' (#295) from feat/41-node-disk into main
Build & Deploy / Build & push images (push) Successful in 4m43s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m43s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-26 02:28:10 -04:00
Logan CusanoandClaude Opus 5.5 268470b6c0 nodes: store and show host disk free; warn when low (node-26#41)
Checkin now carries disk_free_bytes/disk_total_bytes. Stored on the node doc
(not overwritten by older nodes that don't send them). Node page shows a Disk
row; node cards warn amber under 15% free, red under 5% or 2 GB. A full disk
stopped node-002 recording for 17 days while it showed Online.

Frontend unverified locally (no Node, #207); CI build typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:24:10 -04:00
logan 4a93390967 Merge pull request 'Frontend: fixes from the 10-25 Chrome sweep' (#294) from fix/site-sweep-1025 into main
Build & Deploy / Build & push images (push) Successful in 4m17s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 29s
Build & Deploy / Deploy to VM (push) Successful in 2m26s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 23:13:55 -04:00
Logan CusanoandClaude Opus 5.5 94898d1af1 fix(frontend): issues from the 10-25 Chrome sweep
- Incident detail "← Incidents" used router.back(), so arriving from Live
  or a shared link it went to the map / out of the app. Now a Link.
- Resolved incidents showed "0m elapsed" (manual resolve sets no
  resolved_at, updated_at can predate the last call): end is now the
  latest of resolved_at, updated_at and the newest call.
- Admin: the max-width toggle wrapped the tab bar, so it reflowed on every
  tab switch and left the cursor over e.g. "Close calls". Body only now.
- STT Eval sat on "Loading…" forever when a scanned page had zero matches
  but a next_cursor — the refill effect didn't depend on cursor.
- Trip event blocks used dark-only bg-indigo-950/60: unreadable in light
  mode. Theme tokens now.
- Reassign modal said "connected for the first time" on configured nodes.
- Dark-mode placeholders dimmed + italic; they read as filled-in values.

Frontend unverified locally (no Node, #207); CI build typechecks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:12:07 -04:00
logan 33056e2269 Merge pull request 'Frontend: hide admin-only node/system controls from non-admins (#222, #283) + popup contrast' (#291) from fix/222-283-admin-gating into main
Build & Deploy / Build & push images (push) Successful in 4m18s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 28s
Build & Deploy / Deploy to VM (push) Successful in 1m27s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 22:17:26 -04:00
Logan CusanoandClaude Opus 5.5 e8084357c3 fix(map): keep muted popup text AA on Leaflet's white popups
Token text-ink-muted is #8C98AE in dark mode, ~2.9:1 on the always-white
popups (node, incident, path-stop, AIS, 511 camera). Scoped override to
#5B6577 (~6:1). Found in tonight's regression review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:09:57 -04:00
Logan CusanoandClaude Opus 5.5 9702b90d40 fix(frontend): hide admin-only node and system controls from non-admins (#222, #283)
UI gate only — no route auth changes. Node page: override Ack/Force
Revert, Configure/Reassign and Restart OP25 are admin-only; non-admins on
an unconfigured node get a "an admin needs to assign it" note. /nodes
"Needs Configuration" cards open the config modal only for admins, and
link to the node page otherwise. /systems: New/Edit/Duplicate/Delete and
the AI-flags, area-context, talkgroup-pending and vocabulary panels are
admin-only; operators get a read-only list. (Delete's missing catch was
fixed in #290.)

Widening any of these routes to org owners/operators is a separate,
security-reviewed backend change.

Frontend unverified: no Node on the authoring box (#207).

Closes #222
Closes #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:07:22 -04:00
logan 606b0a103c Merge pull request 'Frontend: readable API errors, no silent action failures (#284, #286)' (#290) from fix/284-286-error-feedback into main
Build & Deploy / Build & push images (push) Successful in 4m16s
Build & Deploy / Deploy Firestore rules & indexes (push) Successful in 27s
Build & Deploy / Deploy to VM (push) Successful in 1m24s
Build & Deploy / Report a failed deploy (push) Skipped
2026-10-25 21:39:15 -04:00
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00
24 changed files with 435 additions and 141 deletions
+6 -1
View File
@@ -128,6 +128,8 @@ class MQTTHandler:
"sdr_devices": payload.get("sdr_devices"),
"op25_sdr_serial": payload.get("op25_sdr_serial"),
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
"disk_free_bytes": payload.get("disk_free_bytes"),
"disk_total_bytes": payload.get("disk_total_bytes"),
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
"is_overridden": False,
"override_system_id": None,
@@ -161,8 +163,11 @@ class MQTTHandler:
# radio_channels (node-26#24): a conventional node's channels and
# whether each is encrypted — the one decode failure the owner has
# to hear about from C2, since nothing on the node can fix it.
# disk_* (node-26#41): free/total bytes of the host disk the node
# records to — a full disk silently stopped node-002 for 17 days.
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels"):
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
"disk_free_bytes", "disk_total_bytes"):
if key in payload:
updates[key] = payload[key]
if "sdr_count" in payload:
+16
View File
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
assert "last_seen" in updates
@pytest.mark.asyncio
async def test_checkin_stores_disk_free_and_total(handler):
# node-26#41: a full disk stopped node-002 recording while it showed Online.
existing = {"node_id": "node-01", "status": "online", "configured": True}
with patch("app.internal.mqtt_handler.fstore") as mock_fstore:
mock_fstore.doc_get = AsyncMock(return_value=existing)
mock_fstore.doc_update = AsyncMock()
await handler._handle_checkin("node-01", {"disk_free_bytes": 5, "disk_total_bytes": 100})
updates = mock_fstore.doc_update.call_args[0][2]
assert updates["disk_free_bytes"] == 5 and updates["disk_total_bytes"] == 100
# An older node that doesn't report disk must not wipe the fields.
await handler._handle_checkin("node-01", {"name": "x"})
assert "disk_free_bytes" not in mock_fstore.doc_update.call_args[0][2]
@pytest.mark.asyncio
async def test_checkin_does_not_promote_unconfigured_to_online(handler):
existing = {
+9 -2
View File
@@ -1169,8 +1169,10 @@ function SttEvalTab() {
// scanning", not "done", so this fires again on its own.
useEffect(() => {
if (queue.length === 0 && !exhausted) loadBatch();
// `cursor` is a dep so an empty page that advanced the cursor scans the
// next one; without it the tab sat on "Loading…" forever.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queue.length, exhausted]);
}, [queue.length, exhausted, cursor]);
useEffect(() => {
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
@@ -1309,7 +1311,10 @@ export default function AdminPage() {
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
return (
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
// The width toggle wraps the tab body only. It used to wrap the tab bar
// too, so the bar reflowed on every switch and left the cursor over
// whatever landed under it (e.g. Calls -> "Close calls").
<div className="space-y-6">
<h1 className="text-white text-xl font-bold font-mono">Admin</h1>
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
@@ -1326,6 +1331,7 @@ export default function AdminPage() {
))}
</div>
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
{tab === "features" && <FeaturesTab />}
{tab === "cameras" && <CameraProvidersTab />}
{tab === "outages" && <OutageProvidersTab />}
@@ -1335,6 +1341,7 @@ export default function AdminPage() {
{tab === "eval" && <SttEvalTab />}
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
{tab === "audit" && <AuditLogTab />}
</div>
</div>
);
}
+25 -7
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
import { useAlerts } from "@/lib/useAlerts";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import { ErrorBanner } from "@/components/ui/EmptyState";
import type { AlertRule } from "@/lib/types";
function fmtTime(iso: string) {
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const [webhook, setWebhook] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load and toggle/delete failures (server-26#284) — separate from the create
// form's own `error`, and it keeps the list from claiming "no rules" when
// the fetch actually failed.
const [listError, setListError] = useState<string | null>(null);
async function load() {
if (loaded) return;
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const data = await c2api.getAlertRules() as AlertRule[];
setRules(data);
setLoaded(true);
setListError(null);
} catch (e) {
console.error(e);
setListError(`Couldn't load alert rules: ${errorMessage(e)}`);
}
}
@@ -50,8 +57,8 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
const created = await c2api.createAlertRule(body) as AlertRule;
setRules((prev) => [created, ...prev]);
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
} catch {
setError("Failed to create rule.");
} catch (e) {
setError(`Failed to create rule: ${errorMessage(e)}`);
} finally {
setSaving(false);
}
@@ -61,14 +68,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
try {
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
setRules((prev) => prev.map((r) => r.rule_id === rule.rule_id ? { ...r, enabled: !r.enabled } : r));
} catch (e) { console.error(e); }
setListError(null);
} catch (e) { setListError(`Couldn't update "${rule.name}": ${errorMessage(e)}`); }
}
async function handleDelete(id: string) {
if (!window.confirm("Delete this alert rule?")) return;
try {
await c2api.deleteAlertRule(id);
setRules((prev) => prev.filter((r) => r.rule_id !== id));
} catch (e) { console.error(e); }
setListError(null);
} catch (e) { setListError(`Couldn't delete the rule: ${errorMessage(e)}`); }
}
return (
@@ -120,8 +130,12 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
</form>
)}
{listError && <ErrorBanner message={listError} />}
<div className="bg-surface border border-line rounded-xl overflow-hidden">
{rules.length === 0 ? (
{!loaded ? (
<p className="text-ink-muted text-sm font-mono p-4">{listError ? "Rules unavailable." : "Loading…"}</p>
) : rules.length === 0 ? (
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
) : (
<table className="w-full text-left">
@@ -188,11 +202,13 @@ export default function AlertsPage() {
const { isAdmin } = useAuth();
const { alerts, loading, error } = useAlerts();
const [tab, setTab] = useState<"events" | "rules">("events");
const [ackError, setAckError] = useState<string | null>(null);
async function handleAcknowledge(id: string) {
setAckError(null);
try {
await c2api.acknowledgeAlert(id);
} catch (e) { console.error(e); }
} catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
}
const unacked = alerts.filter((a) => !a.acknowledged);
@@ -223,6 +239,8 @@ export default function AlertsPage() {
))}
</div>
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
{tab === "events" && (
loading ? (
<p className="text-ink-muted text-sm font-mono">Loading…</p>
+90 -66
View File
@@ -4,6 +4,8 @@
@import 'leaflet/dist/leaflet.css';
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
/* ── Design tokens ────────────────────────────────────────────────────────────
* Single source of truth for surface, ink and encoding colour. `:root` is the
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
@@ -16,43 +18,43 @@
* Do not add a hue here for a category; add a glyph.
*/
:root {
--page: #F4F6F9;
--page: #F7F7F8;
--surface: #FFFFFF;
--raised: #F7F9FB;
--line: rgba(11,17,27,.11);
--line-strong: rgba(11,17,27,.22);
--ink: #101620;
--ink-2: #46536A;
--ink-muted: #6B788C;
--raised: #F2F2F5;
--line: rgba(24,24,27,.10);
--line-strong: rgba(24,24,27,.20);
--ink: #18181B;
--ink-2: #52525B;
--ink-muted: #6B6B76;
--accent: #2A78D6;
--sev-moderate: #B07800;
--sev-major: #C0281F;
--accent: #5E6AD2;
--sev-moderate: #B54708;
--sev-major: #D92D20;
--map-bg: #E8ECF1;
--map-block: #DFE4EB;
--map-bg: #ECECF0;
--map-block: #E1E1E8;
--map-road: #FFFFFF;
--map-water: #D3E2EE;
--map-water: #DADDF2;
}
.dark {
--page: #0B0E13;
--surface: #141922;
--raised: #1B2230;
--line: rgba(255,255,255,.09);
--line-strong: rgba(255,255,255,.17);
--ink: #E8EBF0;
--ink-2: #A5B0C2;
--ink-muted: #8C98AE; /* was #77839A: 4.2:1 on raised, failed AA at the 10-12px it's used at (#136) */
--page: #08090A;
--surface: #111214;
--raised: #1A1B1F;
--line: rgba(255,255,255,.08);
--line-strong: rgba(255,255,255,.16);
--ink: #EDEDEF;
--ink-2: #A9A9B3;
--ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
--accent: #3987E5;
--sev-moderate: #C98500;
--sev-major: #F05252; /* was #D03B3B: 3.7:1 as text on surface (#136) */
--accent: #8B93F0;
--sev-moderate: #FDB022;
--sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
--map-bg: #0E131B;
--map-block: #161C26;
--map-road: #232C3A;
--map-water: #12202E;
--map-bg: #0D0E11;
--map-block: #16171B;
--map-road: #26272D;
--map-water: #141729;
}
/* ── Base ─────────────────────────────────────────────────────────────────── */
@@ -69,7 +71,7 @@ html, body {
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
* readable. Lift both to gray-500 (~4:1). */
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
.dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
/* ── Light mode overrides ─────────────────────────────────────────────────── */
/*
@@ -80,34 +82,34 @@ html, body {
/* Structural backgrounds */
html:not(.dark),
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !important; }
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,0.70) !important; }
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(242,242,245,0.60) !important; }
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(242,242,245,0.50) !important; }
html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
/* Borders */
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
/* Text */
html:not(.dark) .text-white { color: #0f172a !important; }
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
html:not(.dark) .text-gray-300 { color: #334155 !important; }
html:not(.dark) .text-gray-400 { color: #475569 !important; }
html:not(.dark) .text-gray-500 { color: #64748b !important; }
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
html:not(.dark) .text-white { color: #18181b !important; }
html:not(.dark) .text-gray-100 { color: #27272a !important; }
html:not(.dark) .text-gray-200 { color: #27272a !important; }
html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
html:not(.dark) .text-gray-400 { color: #52525b !important; }
html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
/* …except on saturated fills (primary/danger/success buttons), where the fill
* stays dark in both themes — remapping to navy made their labels unreadable. */
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
/* Hover states */
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #fafafa !important; }
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
/* Hover text */
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
/* Unconfigured / Info */
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
@keyframes pulse-ring {
@@ -191,6 +193,13 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
z-index: 0;
}
/* The legend floats above the map's stacking context, so it would cover the
* expanded layers list (both live in the right-hand column). Hide it while the
* list is open. */
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
visibility: hidden;
}
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
* overlay cards sat on a bright, busy map. Inverting the raster keeps the
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
@@ -199,6 +208,13 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9) saturate(0.6);
}
/* Leaflet popups stay white in both themes (their body text is a fixed
* gray-900), so the theme's muted ink — light grey in dark mode, ~2.9:1 on
* white — is pinned to a grey that holds AA on white (~6:1). */
.leaflet-popup-content .text-ink-muted {
color: #5F5F69;
}
/* Pinned 511 camera tiles (MapView DotCameraLayer): strip Leaflet's tooltip
* padding and let the tile's own theme colours show, so the tile is no bigger
* than the video it holds. */
@@ -212,14 +228,22 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
}
/* ── Form inputs ─────────────────────────────────────────────────────────── */
/* Placeholders must read as hints, not values: Tailwind's default #9ca3af on
* the dark surface looked like filled-in text (e.g. /systems Local Area showed
* "Ossining / Westchester" next to "no area set"). Dimmer + italic in dark. */
.dark input::placeholder,
.dark textarea::placeholder {
color: #6E6E78;
font-style: italic;
}
html:not(.dark) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
html:not(.dark) select,
html:not(.dark) textarea {
color: #0f172a;
color: #18181b;
}
html:not(.dark) input::placeholder,
html:not(.dark) textarea::placeholder {
color: #94a3b8;
color: #a1a1aa;
}
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
@@ -230,8 +254,8 @@ html:not(.dark) textarea::placeholder {
*/
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
@@ -240,10 +264,10 @@ html:not(.dark) .border-yellow-600\/40 { border-color: rgba(202,138,4,0.35) !imp
/* Marketing hero background — subtle radial glow, brand-neutral in both themes */
.marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
}
html:not(.dark) .marketing-hero-bg {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
}
/* Skeleton loading shimmer */
+26 -12
View File
@@ -2,7 +2,8 @@
import dynamic from "next/dynamic";
import { useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useIncident } from "@/lib/useIncidents";
import { useCallsByIncident } from "@/lib/useCalls";
import { useAuth } from "@/components/AuthProvider";
@@ -13,15 +14,17 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
import { isKnownSeverity } from "@/lib/severity";
import { Button } from "@/components/ui/Button";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { ErrorBanner } from "@/components/ui/EmptyState";
import { errorMessage } from "@/lib/c2error";
import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const EARLIER_PAGE_SIZE = 8;
function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): string {
function elapsedLabel(startedAt: string, active: boolean, endedAt: string): string {
const start = new Date(startedAt).getTime();
const end = active ? Date.now() : new Date(updatedAt).getTime();
const end = active ? Date.now() : new Date(endedAt).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000));
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
@@ -31,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
export default function IncidentDetailPage() {
const params = useParams();
const id = params.id as string;
const router = useRouter();
const { incident, loading } = useIncident(id);
const { calls, loading: callsLoading } = useCallsByIncident(id);
@@ -39,6 +41,7 @@ export default function IncidentDetailPage() {
const [summarizing, setSummarizing] = useState(false);
const [resolving, setResolving] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
// Stop numbers and path length come from the same liveWall.pathStops() the
@@ -54,16 +57,20 @@ export default function IncidentDetailPage() {
);
async function handleResolve() {
// No reopen path exists (server-26#284), so resolving is final from the UI.
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
setResolving(true);
setActionError(null);
try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); }
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
finally { setResolving(false); }
}
async function handleSummarize() {
setSummarizing(true);
setActionError(null);
try { await c2api.summarizeIncident(id); }
catch (e) { console.error(e); }
catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
finally { setSummarizing(false); }
}
@@ -75,18 +82,23 @@ export default function IncidentDetailPage() {
const unitsCleared = incident.units_cleared ?? [];
const vehicles = incident.vehicles ?? [];
const active = incident.status === "active";
// A manual resolve doesn't set resolved_at and updated_at can predate the
// last call, which showed "0m elapsed" on an 11-call incident — so a
// resolved incident runs to the latest of those and its newest call.
const resolvedEnd = [incident.resolved_at, incident.updated_at, newestFirst[0]?.ended_at, newestFirst[0]?.started_at]
.filter((t): t is string => !!t)
.reduce((a, b) => (b > a ? b : a), incident.started_at);
const visible = newestFirst.slice(0, earlierShown);
const remaining = newestFirst.length - visible.length;
return (
<div className="space-y-4">
<button
onClick={() => router.back()}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
>
{/* A real link, not router.back(): arriving from Live or a shared URL,
"back" went to the map or out of the app. */}
<Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
← Incidents
</button>
</Link>
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
@@ -102,7 +114,7 @@ export default function IncidentDetailPage() {
{incident.title ?? "Incident"}
</h1>
<p className="text-xs text-ink-muted font-mono">
{elapsedLabel(incident.started_at, active, incident.updated_at)} elapsed
{elapsedLabel(incident.started_at, active, resolvedEnd)} elapsed
{path?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
{path?.mayBeMerged && (
<span title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
@@ -126,6 +138,8 @@ export default function IncidentDetailPage() {
)}
</div>
{actionError && <ErrorBanner message={actionError} />}
{displayTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{displayTags.map((t) => (
+8 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
import { useIncidents } from "@/lib/useIncidents";
import { useActiveCalls } from "@/lib/useCalls";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { IncidentRecord } from "@/lib/types";
import { PageHeader } from "@/components/ui/PageHeader";
import { Button } from "@/components/ui/Button";
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
const activeCalls = useActiveCalls();
const [showCreate, setShowCreate] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
const [sortMode, setSortMode] = useState<SortMode>("recent");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
}, [filtered]);
async function handleResolve(id: string) {
// No reopen path exists (server-26#284), so resolving is final from the UI.
if (!window.confirm("Mark this incident resolved? This can't be undone from the app.")) return;
setActionError(null);
try { await c2api.updateIncident(id, { status: "resolved" }); }
catch (e) { console.error(e); }
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
}
return (
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
chips are pipeline output, so the notice rides with the list. */}
<MachineOutputNotice variant="inline" />
{actionError && <ErrorBanner message={actionError} />}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
{SEVERITY_FILTERS.map(({ key, label }) => (
+43 -16
View File
@@ -15,6 +15,8 @@ import { CallRow } from "@/components/CallRow";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
import type { NodeRecord } from "@/lib/types";
function ApprovalBadge({ status }: { status: string | null }) {
@@ -54,10 +56,14 @@ export default function NodeDetailPage() {
return unsub;
}, [id]);
async function sendCommand(action: string) {
async function sendCommand(action: string, label: string) {
setSending(true);
try {
await c2api.sendCommand(id, { action });
alert(`${label} sent to the node.`);
} catch (e) {
// Was try/finally only: an unhandled rejection and no feedback (server-26#284).
alert(`Couldn't send ${label.toLowerCase()}: ${errorMessage(e)}`);
} finally {
setSending(false);
}
@@ -155,7 +161,8 @@ export default function NodeDetailPage() {
<> No timeout is currently enforced (permanent override).</>
)}
</p>
<div className="flex gap-2">
{/* Override routes are require_service_key_or_admin (server-26#222). */}
{isAdmin && <div className="flex gap-2">
{node.override_timeout_at && (
<button
onClick={async () => {
@@ -184,7 +191,7 @@ export default function NodeDetailPage() {
>
Force Revert Config
</button>
</div>
</div>}
</div>
)}
@@ -212,6 +219,17 @@ export default function NodeDetailPage() {
<span className="text-ink">{value}</span>
</div>
))}
{(() => {
const disk = diskState(node);
return (
<div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Disk</span>
<span className={disk ? DISK_TEXT_CLASS[disk.level] : "text-ink-muted"}>
{disk ? `${disk.level !== "ok" ? "⚠ " : ""}${disk.label}` : "not reported (node software too old)"}
</span>
</div>
);
})()}
<div className="flex justify-between px-4 py-2.5">
<span className="text-ink-muted">Approval</span>
<ApprovalBadge status={node.approval_status ?? null} />
@@ -240,20 +258,29 @@ export default function NodeDetailPage() {
)}
{/* Actions */}
{/* Configure and Restart are admin-only on the server (server-26#222);
non-admins get a note instead of a button that 403s. */}
{!isAdmin && !node.configured && (
<p className="text-sm text-ink-2">This node isn&apos;t configured yet. An admin needs to assign it a radio system.</p>
)}
<div className="flex flex-wrap gap-3">
<button
onClick={() => setShowConfig(true)}
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
>
{node.configured ? "Reassign System" : "Configure"}
</button>
<button
disabled={sending}
onClick={() => sendCommand("op25_restart")}
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Restart OP25
</button>
{isAdmin && (
<button
onClick={() => setShowConfig(true)}
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
>
{node.configured ? "Reassign System" : "Configure"}
</button>
)}
{isAdmin && (
<button
disabled={sending}
onClick={() => sendCommand("op25_restart", "Restart OP25")}
className="px-4 py-2 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
>
Restart OP25
</button>
)}
{isAdmin && (
<button
disabled={sending}
+9 -3
View File
@@ -79,9 +79,15 @@ export default function NodesPage() {
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{pending.map((n) => (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div>
// Configuring is admin-only on the server (server-26#222); for
// everyone else the card links to the node page instead.
isAdmin ? (
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
</div>
) : (
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
)
))}
</div>
</div>
+24 -13
View File
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
if (authLoading || (!isAdmin && !isOperator)) return null;
async function handleDelete(id: string) {
if (!confirm("Delete this system?")) return;
await c2api.deleteSystem(id);
try { await c2api.deleteSystem(id); }
catch (e) { alert(`Couldn't delete the system: ${e instanceof Error ? e.message : String(e)}`); }
}
function openEdit(s: SystemRecord) {
@@ -1952,12 +1953,16 @@ export default function SystemsPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
<button
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ New System
</button>
{/* Every systems write route is require_admin_token (server-26#283);
operators get a read-only view. */}
{isAdmin && (
<button
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
>
+ New System
</button>
)}
</div>
{editing && (
@@ -2005,7 +2010,7 @@ export default function SystemsPage() {
{kindLabel(s)}
</span>
</div>
<div className="mt-3 flex gap-3">
{isAdmin && <div className="mt-3 flex gap-3">
<button
onClick={() => openEdit(s)}
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
@@ -2024,15 +2029,21 @@ export default function SystemsPage() {
>
Delete
</button>
</div>
</div>}
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
(server-26#204 item 6), so for anyone else this panel could
neither list the pool nor save a choice (server-26#213 item 4). */}
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} />
{/* All admin-only routes (server-26#283), incl. area-context and
talkgroup-pending GETs and every vocabulary write. */}
{isAdmin && (
<>
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
<AreaContextPanel systemId={s.system_id} />
<TalkgroupPendingPanel systemId={s.system_id} />
<VocabularyPanel systemId={s.system_id} />
</>
)}
</div>
);
})}
+8 -5
View File
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
// ---------------------------------------------------------------------------
@@ -483,15 +484,17 @@ function DayTimeline({
style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict
? "bg-red-950/70 border border-red-700/70"
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
// Theme tokens: the old bg-indigo-950/60 had no light-mode
// override, so light mode got dark text on a dark block.
? "bg-sev-major/15 border border-sev-major/50"
: "bg-accent/15 border border-accent/40 hover:border-accent/70"
}`}
>
<div className="flex items-start justify-between gap-2 h-full">
<div className="min-w-0 flex-1">
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
{height >= 44 && (
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}>
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-sev-major" : "text-accent"}`}>
{fmtTime(e.start_time)}
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
{isConflict ? " ⚠ conflict" : ""}
@@ -955,7 +958,7 @@ export default function TripDetailPage() {
async function handleDeleteTrip() {
if (!trip) return;
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
catch (e) { console.error(e); }
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
}
async function handleDeleteEvent(eventId: string) {
@@ -963,7 +966,7 @@ export default function TripDetailPage() {
try {
await c2api.deleteTripEvent(trip.trip_id, eventId);
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
} catch (e) { console.error(e); }
} catch (e) { alert(`Couldn't delete the event: ${errorMessage(e)}`); }
}
async function handleUpdateEvent(body: object) {
+3 -1
View File
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { useTrips } from "@/lib/useTrips";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripRecord } from "@/lib/types";
function fmtDate(iso: string) {
@@ -179,8 +180,9 @@ export default function TripsPage() {
const past = trips.filter((t) => t.end_date < today);
async function handleDelete(id: string) {
if (!confirm("Delete this trip?")) return;
try { await c2api.deleteTrip(id); }
catch (e) { console.error(e); }
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
}
return (
+5 -1
View File
@@ -560,7 +560,11 @@ export function LiveView() {
{/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading || !mapState ? (
{/* Mount the map as soon as its starting view is known. The 500-call
window took ~9s on prod and used to hold the whole map back;
calls and paths now stream in once it lands. Nodes are only
awaited when there's no saved view, since they pick the centre. */}
{!mapState || (!mapState.view && nodesLoading) ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : (
<MapView
+16 -5
View File
@@ -887,7 +887,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
const hot = highlightIds.has(c.id);
return (
<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&apos;s direction of travel, not a surveyed heading)</Tooltip>
</Polygon>
);
@@ -902,7 +902,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
function nearCameraIcon(): L.DivIcon {
return L.divIcon({
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],
iconAnchor: [10, 8],
});
@@ -917,7 +917,7 @@ function NearCameraLayer({ cameras, head, onOpenCamera }: {
<>
{head && (
<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 }) => (
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
@@ -1620,6 +1620,9 @@ function MapView({
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
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
// overlay is actually on (server-26#185); same for the outage key (#184).
@@ -1931,7 +1934,14 @@ function MapView({
</div>
{/* ── 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">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
@@ -2070,7 +2080,8 @@ function MapView({
</div>
))}
</div>
</div>
</div>
</details>
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
{incidents.length > 0 && (
+10
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import { StatusBadge } from "@/components/StatusBadge";
import type { NodeRecord, SystemRecord } from "@/lib/types";
import { diskState } from "@/lib/nodeDisk";
interface Props {
node: NodeRecord;
@@ -45,6 +46,15 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
<span className="text-ink-2">{lastSeen}</span>
</div>
</div>
{(() => {
const disk = diskState(node);
if (!disk || disk.level === "ok") return null;
return (
<div className={`mt-3 text-xs font-mono border-t border-line pt-2 ${disk.level === "critical" ? "text-sev-major" : "text-sev-moderate"}`}>
⚠ Disk {disk.level === "critical" ? "nearly full" : "low"}: {disk.label}
</div>
);
})()}
{!node.configured && (
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
⚠ Needs configuration
+6 -2
View File
@@ -54,8 +54,12 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
<div className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-ink-2 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
Assign it a radio system to begin monitoring.
{node.configured ? (
<><span className="text-indigo-400">{node.node_id}</span>: choose the radio system it should monitor.</>
) : (
<><span className="text-indigo-400">{node.node_id}</span> connected for the first time.
Assign it a radio system to begin monitoring.</>
)}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
+2 -1
View File
@@ -1,4 +1,5 @@
import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
import type {
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
WeatherAlertsResponse, WindResponse,
@@ -19,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...(options?.headers as Record<string, string> | undefined),
},
});
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T;
return res.json();
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Error thrown by every C2 API request (server-26#286).
*
* `message` is a sentence a user can read — FastAPI's `detail` when the
* server sent one, otherwise a plain mapping of the status. The raw status
* and body stay on the error for callers that need the structured parts
* (the Discord parsers read `invite`/`code` out of `body`).
*/
export class C2ApiError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string) {
super(humanMessage(status, body));
this.name = "C2ApiError";
this.status = status;
this.body = body;
}
// Call sites render String(e); without this it reads "C2ApiError: …".
toString(): string {
return this.message;
}
}
function detailOf(body: string): string | null {
try {
const parsed = JSON.parse(body);
const d = parsed?.detail;
if (typeof d === "string" && d.trim()) return d;
// pydantic validation errors: [{loc, msg, type}, ...]
if (Array.isArray(d)) {
const msgs = d.map((x: { msg?: unknown }) => (typeof x?.msg === "string" ? x.msg : null)).filter(Boolean);
if (msgs.length) return msgs.join("; ");
}
} catch {
/* not JSON */
}
return null;
}
function humanMessage(status: number, body: string): string {
const detail = detailOf(body);
if (status === 401) return "Your session has expired. Sign in again.";
if (status === 403) return detail ? `Not allowed: ${detail}` : "You don't have permission to do that.";
if (detail) return detail;
if (status === 404) return "Not found. It may have been deleted.";
if (status === 429) return "Too many requests. Wait a moment and try again.";
if (status >= 500) return "The server hit an error. Try again in a moment.";
return `Request failed (${status}).`;
}
/** Message for any caught value — use in catch blocks instead of String(e). */
export function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
/** Rebuilds the legacy `C2 API error <status>: <body>` string for the
* Discord parsers, which predate C2ApiError and match on that shape. */
export function legacyErrorText(err: unknown): string {
if (err instanceof C2ApiError) return `C2 API error ${err.status}: ${err.body}`;
return err instanceof Error ? err.message : String(err);
}
+3 -1
View File
@@ -2,6 +2,8 @@
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
// small helpers shared by /settings/discord and /tokens (server-26#213).
import { legacyErrorText } from "@/lib/c2error";
export interface RadioBotLease {
node_id: string;
lease_id?: string;
@@ -160,7 +162,7 @@ export const SNOWFLAKE = /^\d{15,21}$/;
* so "no bot in this server" can render its invite links.
*/
export function parseC2Error(err: unknown): { message: string; invite: { name: string; invite_url: string | null }[] } {
const raw = err instanceof Error ? err.message : String(err);
const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
if (m) {
try {
+3 -2
View File
@@ -9,6 +9,7 @@
// browser: the code exchange happens in c2-core.
import { auth } from "@/lib/firebase";
import { C2ApiError } from "@/lib/c2error";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
@@ -24,7 +25,7 @@ async function send<T>(method: "POST" | "PUT" | "DELETE", path: string, body?: o
},
...(body ? { body: JSON.stringify(body) } : {}),
});
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
if (!res.ok) throw new C2ApiError(res.status, await res.text());
if (res.status === 204) return undefined as T;
return res.json();
}
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
try {
raw = await post("/discord/oauth/start", req);
} catch (err) {
if (err instanceof Error && /^C2 API error 404:/.test(err.message)) throw new OAuthNotConfigured(err.message);
if (err instanceof C2ApiError && err.status === 404) throw new OAuthNotConfigured(err.message);
throw err;
}
const url = raw.url ?? raw.authorize_url ?? "";
+3 -1
View File
@@ -2,6 +2,8 @@
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
// the server never sends them to the browser.
import { legacyErrorText } from "@/lib/c2error";
export type StreamState =
| "pending"
| "connecting"
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
* detail, code and invite links back out of a JSON body. */
export function parseC2Error(err: unknown): ParsedC2Error {
const raw = err instanceof Error ? err.message : String(err);
const raw = legacyErrorText(err);
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
const status = m ? Number(m[1]) : null;
const idx = raw.indexOf(": ");
+39
View File
@@ -0,0 +1,39 @@
import type { NodeRecord } from "@/lib/types";
// Free disk on the node's host (node-26#41). A full disk stopped node-002
// recording for 17 days while every surface still showed it Online, so low
// space is flagged wherever a node is shown.
export type DiskLevel = "ok" | "low" | "critical";
export interface DiskState {
level: DiskLevel;
freePct: number;
label: string; // e.g. "43.2 GB free of 98 GB (44%)"
}
const LOW_PCT = 15;
const CRITICAL_PCT = 5;
const CRITICAL_BYTES = 2 * 1024 ** 3; // a 2 GB floor regardless of disk size
function gb(bytes: number): string {
const v = bytes / 1024 ** 3;
return `${v >= 10 ? v.toFixed(0) : v.toFixed(1)} GB`;
}
/** null when the node has never reported (older node software). */
export function diskState(node: Pick<NodeRecord, "disk_free_bytes" | "disk_total_bytes">): DiskState | null {
const free = node.disk_free_bytes;
const total = node.disk_total_bytes;
if (free == null || !total) return null;
const freePct = Math.max(0, Math.min(100, (free / total) * 100));
const level: DiskLevel =
freePct < CRITICAL_PCT || free < CRITICAL_BYTES ? "critical" : freePct < LOW_PCT ? "low" : "ok";
return { level, freePct, label: `${gb(free)} free of ${gb(total)} (${Math.round(freePct)}%)` };
}
export const DISK_TEXT_CLASS: Record<DiskLevel, string> = {
ok: "text-ink-2",
low: "text-sev-moderate",
critical: "text-sev-major",
};
+5
View File
@@ -82,6 +82,9 @@ export interface NodeRecord {
/** The dongle OP25 is actually using, per the node. */
op25_sdr_serial?: string | null;
sdr_count?: number | null; // null = never reported
/** Host disk the node records to (node-26#41); null/absent = not reported. */
disk_free_bytes?: number | null;
disk_total_bytes?: number | null;
/** node-26#24: conventional channels from the last checkin; [] on a trunked system. */
radio_channels?: RadioChannel[] | null;
enforce_override_timeout?: boolean;
@@ -487,6 +490,8 @@ export interface IncidentRecord {
title: string | null;
type: string | null;
status: "active" | "resolved";
/** Set by the idle sweep; a manual resolve (PUT /incidents) doesn't set it. */
resolved_at?: string | null;
location: string | null;
location_coords: GeoPoint | null;
/**
+12 -1
View File
@@ -22,6 +22,17 @@ const config: Config = {
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
// class silently generates no rule (map overlays rendered see-through).
colors: {
// "Iris" palette: the legacy raw gray-*/indigo-* classes (~350 uses)
// resolve to graphite + the brand accent instead of Tailwind's slate-blue
// defaults, so un-migrated components match the tokens above.
gray: {
50: "#F7F7F8", 100: "#EDEDEF", 200: "#E4E4E7", 300: "#C9C9D0", 400: "#A1A1AA",
500: "#7A7A85", 600: "#4A4B53", 700: "#2A2B31", 800: "#1A1B1F", 900: "#111214", 950: "#08090A",
},
indigo: {
50: "#F0F1FD", 100: "#E2E4FB", 200: "#C8CCF6", 300: "#A8AEF1", 400: "#8B93F0",
500: "#6F79E0", 600: "#5E6AD2", 700: "#4E58B8", 800: "#3F4794", 900: "#313770", 950: "#1C1F42",
},
page: tok("page"),
surface: tok("surface"),
raised: tok("raised"),
@@ -51,7 +62,7 @@ const config: Config = {
boxShadow: {
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
glow: "0 0 0 1px rgb(94 106 210 / 0.4), 0 0 24px 0 rgb(94 106 210 / 0.25)",
},
animation: {
"fade-in": "fade-in 0.4s ease-out",