Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fdfb2758 | ||
|
|
7cd289874a | ||
|
|
f4d78aba77 | ||
|
|
268470b6c0 | ||
|
|
4a93390967 | ||
|
|
94898d1af1 | ||
|
|
33056e2269 | ||
|
|
e8084357c3 | ||
|
|
9702b90d40 | ||
|
|
606b0a103c | ||
|
|
f16f0a9f93 |
@@ -128,6 +128,8 @@ class MQTTHandler:
|
|||||||
"sdr_devices": payload.get("sdr_devices"),
|
"sdr_devices": payload.get("sdr_devices"),
|
||||||
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
"op25_sdr_serial": payload.get("op25_sdr_serial"),
|
||||||
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
||||||
|
"disk_free_bytes": payload.get("disk_free_bytes"),
|
||||||
|
"disk_total_bytes": payload.get("disk_total_bytes"),
|
||||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||||
"is_overridden": False,
|
"is_overridden": False,
|
||||||
"override_system_id": None,
|
"override_system_id": None,
|
||||||
@@ -161,8 +163,11 @@ class MQTTHandler:
|
|||||||
# radio_channels (node-26#24): a conventional node's channels and
|
# radio_channels (node-26#24): a conventional node's channels and
|
||||||
# whether each is encrypted — the one decode failure the owner has
|
# whether each is encrypted — the one decode failure the owner has
|
||||||
# to hear about from C2, since nothing on the node can fix it.
|
# 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",
|
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:
|
if key in payload:
|
||||||
updates[key] = payload[key]
|
updates[key] = payload[key]
|
||||||
if "sdr_count" in payload:
|
if "sdr_count" in payload:
|
||||||
|
|||||||
@@ -118,6 +118,22 @@ async def test_checkin_updates_existing_configured_node(handler):
|
|||||||
assert "last_seen" in updates
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_checkin_does_not_promote_unconfigured_to_online(handler):
|
async def test_checkin_does_not_promote_unconfigured_to_online(handler):
|
||||||
existing = {
|
existing = {
|
||||||
|
|||||||
@@ -1169,8 +1169,10 @@ function SttEvalTab() {
|
|||||||
// scanning", not "done", so this fires again on its own.
|
// scanning", not "done", so this fires again on its own.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (queue.length === 0 && !exhausted) loadBatch();
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [queue.length, exhausted]);
|
}, [queue.length, exhausted, cursor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDraft(current ? (current.transcript_corrected || current.transcript || "") : "");
|
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";
|
const wide = tab === "users" || tab === "audit" || tab === "replay" || tab === "cameras" || tab === "outages";
|
||||||
|
|
||||||
return (
|
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>
|
<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">
|
<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>
|
||||||
|
|
||||||
|
<div className={`space-y-6 ${wide ? "" : "max-w-2xl"}`}>
|
||||||
{tab === "features" && <FeaturesTab />}
|
{tab === "features" && <FeaturesTab />}
|
||||||
{tab === "cameras" && <CameraProvidersTab />}
|
{tab === "cameras" && <CameraProvidersTab />}
|
||||||
{tab === "outages" && <OutageProvidersTab />}
|
{tab === "outages" && <OutageProvidersTab />}
|
||||||
@@ -1335,6 +1341,7 @@ export default function AdminPage() {
|
|||||||
{tab === "eval" && <SttEvalTab />}
|
{tab === "eval" && <SttEvalTab />}
|
||||||
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
{tab === "users" && <UsersTab currentUid={user?.uid ?? ""} />}
|
||||||
{tab === "audit" && <AuditLogTab />}
|
{tab === "audit" && <AuditLogTab />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useAlerts } from "@/lib/useAlerts";
|
import { useAlerts } from "@/lib/useAlerts";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
|
import { ErrorBanner } from "@/components/ui/EmptyState";
|
||||||
import type { AlertRule } from "@/lib/types";
|
import type { AlertRule } from "@/lib/types";
|
||||||
|
|
||||||
function fmtTime(iso: string) {
|
function fmtTime(iso: string) {
|
||||||
@@ -20,6 +22,10 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [webhook, setWebhook] = useState("");
|
const [webhook, setWebhook] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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() {
|
async function load() {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
@@ -27,8 +33,9 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const data = await c2api.getAlertRules() as AlertRule[];
|
const data = await c2api.getAlertRules() as AlertRule[];
|
||||||
setRules(data);
|
setRules(data);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
setListError(null);
|
||||||
} catch (e) {
|
} 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;
|
const created = await c2api.createAlertRule(body) as AlertRule;
|
||||||
setRules((prev) => [created, ...prev]);
|
setRules((prev) => [created, ...prev]);
|
||||||
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
setName(""); setKeywords(""); setTgIds(""); setWebhook("");
|
||||||
} catch {
|
} catch (e) {
|
||||||
setError("Failed to create rule.");
|
setError(`Failed to create rule: ${errorMessage(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -61,14 +68,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
try {
|
try {
|
||||||
await c2api.updateAlertRule(rule.rule_id, { enabled: !rule.enabled });
|
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));
|
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) {
|
async function handleDelete(id: string) {
|
||||||
|
if (!window.confirm("Delete this alert rule?")) return;
|
||||||
try {
|
try {
|
||||||
await c2api.deleteAlertRule(id);
|
await c2api.deleteAlertRule(id);
|
||||||
setRules((prev) => prev.filter((r) => r.rule_id !== 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 (
|
return (
|
||||||
@@ -120,8 +130,12 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{listError && <ErrorBanner message={listError} />}
|
||||||
|
|
||||||
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
<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>
|
<p className="text-ink-muted text-sm font-mono p-4">No alert rules configured.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
@@ -188,11 +202,13 @@ export default function AlertsPage() {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
const { alerts, loading, error } = useAlerts();
|
const { alerts, loading, error } = useAlerts();
|
||||||
const [tab, setTab] = useState<"events" | "rules">("events");
|
const [tab, setTab] = useState<"events" | "rules">("events");
|
||||||
|
const [ackError, setAckError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleAcknowledge(id: string) {
|
async function handleAcknowledge(id: string) {
|
||||||
|
setAckError(null);
|
||||||
try {
|
try {
|
||||||
await c2api.acknowledgeAlert(id);
|
await c2api.acknowledgeAlert(id);
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { setAckError(`Couldn't acknowledge: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
const unacked = alerts.filter((a) => !a.acknowledged);
|
const unacked = alerts.filter((a) => !a.acknowledged);
|
||||||
@@ -223,6 +239,8 @@ export default function AlertsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
|
||||||
|
|
||||||
{tab === "events" && (
|
{tab === "events" && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
@import 'leaflet/dist/leaflet.css';
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
/* Palette: "Iris" (2026-11) — graphite neutrals + one indigo-violet accent.
|
||||||
|
* Tailwind's gray/indigo scales are remapped to match in tailwind.config.ts. */
|
||||||
/* ── Design tokens ────────────────────────────────────────────────────────────
|
/* ── Design tokens ────────────────────────────────────────────────────────────
|
||||||
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
* Single source of truth for surface, ink and encoding colour. `:root` is the
|
||||||
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
|
* LIGHT theme; `.dark` on <html> (set by ThemeProvider, darkMode: ["class"])
|
||||||
@@ -16,43 +18,43 @@
|
|||||||
* Do not add a hue here for a category; add a glyph.
|
* Do not add a hue here for a category; add a glyph.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
--page: #F4F6F9;
|
--page: #F7F7F8;
|
||||||
--surface: #FFFFFF;
|
--surface: #FFFFFF;
|
||||||
--raised: #F7F9FB;
|
--raised: #F2F2F5;
|
||||||
--line: rgba(11,17,27,.11);
|
--line: rgba(24,24,27,.10);
|
||||||
--line-strong: rgba(11,17,27,.22);
|
--line-strong: rgba(24,24,27,.20);
|
||||||
--ink: #101620;
|
--ink: #18181B;
|
||||||
--ink-2: #46536A;
|
--ink-2: #52525B;
|
||||||
--ink-muted: #6B788C;
|
--ink-muted: #6B6B76;
|
||||||
|
|
||||||
--accent: #2A78D6;
|
--accent: #5E6AD2;
|
||||||
--sev-moderate: #B07800;
|
--sev-moderate: #B54708;
|
||||||
--sev-major: #C0281F;
|
--sev-major: #D92D20;
|
||||||
|
|
||||||
--map-bg: #E8ECF1;
|
--map-bg: #ECECF0;
|
||||||
--map-block: #DFE4EB;
|
--map-block: #E1E1E8;
|
||||||
--map-road: #FFFFFF;
|
--map-road: #FFFFFF;
|
||||||
--map-water: #D3E2EE;
|
--map-water: #DADDF2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--page: #0B0E13;
|
--page: #08090A;
|
||||||
--surface: #141922;
|
--surface: #111214;
|
||||||
--raised: #1B2230;
|
--raised: #1A1B1F;
|
||||||
--line: rgba(255,255,255,.09);
|
--line: rgba(255,255,255,.08);
|
||||||
--line-strong: rgba(255,255,255,.17);
|
--line-strong: rgba(255,255,255,.16);
|
||||||
--ink: #E8EBF0;
|
--ink: #EDEDEF;
|
||||||
--ink-2: #A5B0C2;
|
--ink-2: #A9A9B3;
|
||||||
--ink-muted: #8C98AE; /* was #77839A: 4.2:1 on raised, failed AA at the 10-12px it's used at (#136) */
|
--ink-muted: #8B8B96; /* 5.3:1 on raised — holds AA at 10-12px (#136) */
|
||||||
|
|
||||||
--accent: #3987E5;
|
--accent: #8B93F0;
|
||||||
--sev-moderate: #C98500;
|
--sev-moderate: #FDB022;
|
||||||
--sev-major: #F05252; /* was #D03B3B: 3.7:1 as text on surface (#136) */
|
--sev-major: #F97066; /* 6.6:1 as text on surface (#136) */
|
||||||
|
|
||||||
--map-bg: #0E131B;
|
--map-bg: #0D0E11;
|
||||||
--map-block: #161C26;
|
--map-block: #16171B;
|
||||||
--map-road: #232C3A;
|
--map-road: #26272D;
|
||||||
--map-water: #12202E;
|
--map-water: #141729;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
/* ── Base ─────────────────────────────────────────────────────────────────── */
|
||||||
@@ -69,7 +71,7 @@ html, body {
|
|||||||
|
|
||||||
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
/* Dark mode: raw gray-600/700 text sits ~2.4:1 on the near-black page — below
|
||||||
* readable. Lift both to gray-500 (~4:1). */
|
* readable. Lift both to gray-500 (~4:1). */
|
||||||
.dark .text-gray-600, .dark .text-gray-700 { color: #6b7280; }
|
.dark .text-gray-600, .dark .text-gray-700 { color: #7A7A85; }
|
||||||
|
|
||||||
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
/* ── Light mode overrides ─────────────────────────────────────────────────── */
|
||||||
/*
|
/*
|
||||||
@@ -80,34 +82,34 @@ html, body {
|
|||||||
|
|
||||||
/* Structural backgrounds */
|
/* Structural backgrounds */
|
||||||
html:not(.dark),
|
html:not(.dark),
|
||||||
html:not(.dark) body { background-color: #f1f5f9; color: #0f172a; }
|
html:not(.dark) body { background-color: #f2f2f5; color: #18181b; }
|
||||||
html:not(.dark) .bg-gray-950 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-950 { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(241,245,249,0.95) !important; }
|
html:not(.dark) .bg-gray-950\/95 { background-color: rgba(242,242,245,0.95) !important; }
|
||||||
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
html:not(.dark) .bg-gray-900 { background-color: #ffffff !important; }
|
||||||
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
html:not(.dark) .bg-gray-900\/60 { background-color: rgba(255,255,255,0.85) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .bg-gray-900\/50 { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
html:not(.dark) .bg-gray-900\/30 { background-color: rgba(255,255,255,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-800 { background-color: #f1f5f9 !important; }
|
html:not(.dark) .bg-gray-800 { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(226,232,240,0.70) !important; }
|
html:not(.dark) .bg-gray-800\/60 { background-color: rgba(228,228,231,0.70) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(241,245,249,0.60) !important; }
|
html:not(.dark) .bg-gray-800\/40 { background-color: rgba(242,242,245,0.60) !important; }
|
||||||
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(241,245,249,0.50) !important; }
|
html:not(.dark) .bg-gray-800\/30 { background-color: rgba(242,242,245,0.50) !important; }
|
||||||
html:not(.dark) .bg-gray-700 { background-color: #e2e8f0 !important; }
|
html:not(.dark) .bg-gray-700 { background-color: #e4e4e7 !important; }
|
||||||
|
|
||||||
/* Borders */
|
/* Borders */
|
||||||
html:not(.dark) .border-gray-800 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800 { border-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .border-gray-700 { border-color: #cbd5e1 !important; }
|
html:not(.dark) .border-gray-700 { border-color: #d4d4d8 !important; }
|
||||||
html:not(.dark) .border-gray-600 { border-color: #94a3b8 !important; }
|
html:not(.dark) .border-gray-600 { border-color: #a1a1aa !important; }
|
||||||
html:not(.dark) .border-gray-800\/60 { border-color: #e2e8f0 !important; }
|
html:not(.dark) .border-gray-800\/60 { border-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .divide-gray-800 > * + * { border-color: #e2e8f0 !important; }
|
html:not(.dark) .divide-gray-800 > * + * { border-color: #e4e4e7 !important; }
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
html:not(.dark) .text-white { color: #0f172a !important; }
|
html:not(.dark) .text-white { color: #18181b !important; }
|
||||||
html:not(.dark) .text-gray-100 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-100 { color: #27272a !important; }
|
||||||
html:not(.dark) .text-gray-200 { color: #1e293b !important; }
|
html:not(.dark) .text-gray-200 { color: #27272a !important; }
|
||||||
html:not(.dark) .text-gray-300 { color: #334155 !important; }
|
html:not(.dark) .text-gray-300 { color: #3f3f46 !important; }
|
||||||
html:not(.dark) .text-gray-400 { color: #475569 !important; }
|
html:not(.dark) .text-gray-400 { color: #52525b !important; }
|
||||||
html:not(.dark) .text-gray-500 { color: #64748b !important; }
|
html:not(.dark) .text-gray-500 { color: #6b6b76 !important; }
|
||||||
html:not(.dark) .text-gray-600 { color: #94a3b8 !important; }
|
html:not(.dark) .text-gray-600 { color: #a1a1aa !important; }
|
||||||
|
|
||||||
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
/* …except on saturated fills (primary/danger/success buttons), where the fill
|
||||||
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
* stays dark in both themes — remapping to navy made their labels unreadable. */
|
||||||
@@ -116,15 +118,15 @@ html:not(.dark) .text-white:is(.bg-accent, .bg-sev-major, .bg-sev-moderate,
|
|||||||
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
.bg-green-600, .bg-green-700, .bg-yellow-700, .bg-yellow-800, .bg-gray-600) { color: #ffffff !important; }
|
||||||
|
|
||||||
/* Hover states */
|
/* Hover states */
|
||||||
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #f8fafc !important; }
|
html:not(.dark) .hover\:bg-gray-900:hover { background-color: #fafafa !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
html:not(.dark) .hover\:bg-gray-900\/50:hover { background-color: rgba(255,255,255,0.75) !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f1f5f9 !important; }
|
html:not(.dark) .hover\:bg-gray-800:hover { background-color: #f2f2f5 !important; }
|
||||||
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e2e8f0 !important; }
|
html:not(.dark) .hover\:bg-gray-700:hover { background-color: #e4e4e7 !important; }
|
||||||
html:not(.dark) .active\:bg-gray-800:active { background-color: #f1f5f9 !important; }
|
html:not(.dark) .active\:bg-gray-800:active { background-color: #f2f2f5 !important; }
|
||||||
|
|
||||||
/* Hover text */
|
/* Hover text */
|
||||||
html:not(.dark) .hover\:text-gray-300:hover { color: #334155 !important; }
|
html:not(.dark) .hover\:text-gray-300:hover { color: #3f3f46 !important; }
|
||||||
html:not(.dark) .hover\:text-gray-200:hover { color: #1e293b !important; }
|
html:not(.dark) .hover\:text-gray-200:hover { color: #27272a !important; }
|
||||||
|
|
||||||
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
/* ── Accent badge palette (dark → light) ─────────────────────────────────── */
|
||||||
|
|
||||||
@@ -162,11 +164,11 @@ html:not(.dark) .text-green-400 { color: #16a34a !important; }
|
|||||||
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
html:not(.dark) .border-green-800 { border-color: #86efac !important; }
|
||||||
|
|
||||||
/* Unconfigured / Info */
|
/* Unconfigured / Info */
|
||||||
html:not(.dark) .bg-indigo-950 { background-color: #eef2ff !important; }
|
html:not(.dark) .bg-indigo-950 { background-color: #f0f1fd !important; }
|
||||||
html:not(.dark) .bg-indigo-900 { background-color: #eef2ff !important; }
|
html:not(.dark) .bg-indigo-900 { background-color: #f0f1fd !important; }
|
||||||
html:not(.dark) .text-indigo-300 { color: #4338ca !important; }
|
html:not(.dark) .text-indigo-300 { color: #4e58b8 !important; }
|
||||||
html:not(.dark) .text-indigo-400 { color: #6366f1 !important; }
|
html:not(.dark) .text-indigo-400 { color: #5e6ad2 !important; }
|
||||||
html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
html:not(.dark) .border-indigo-800 { border-color: #c8ccf6 !important; }
|
||||||
|
|
||||||
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
/* ── Pulsing ring for recording nodes ────────────────────────────────────── */
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
@@ -191,6 +193,13 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The legend floats above the map's stacking context, so it would cover the
|
||||||
|
* expanded layers list (both live in the right-hand column). Hide it while the
|
||||||
|
* list is open. */
|
||||||
|
.relative:has(.leaflet-control-layers-expanded) > .drb-legend {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
|
/* "Dark" base layer (#136): the prod tile URL is bright OSM, so dark-theme
|
||||||
* overlay cards sat on a bright, busy map. Inverting the raster keeps the
|
* overlay cards sat on a bright, busy map. Inverting the raster keeps the
|
||||||
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
|
* keyless OSM source (CARTO's dark_all CDN proved flaky, #117) while giving
|
||||||
@@ -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);
|
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
|
/* 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
|
* padding and let the tile's own theme colours show, so the tile is no bigger
|
||||||
* than the video it holds. */
|
* than the video it holds. */
|
||||||
@@ -212,14 +228,22 @@ html:not(.dark) .border-indigo-800 { border-color: #a5b4fc !important; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form inputs ─────────────────────────────────────────────────────────── */
|
/* ── 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) input:not([type="submit"]):not([type="button"]):not([type="reset"]),
|
||||||
html:not(.dark) select,
|
html:not(.dark) select,
|
||||||
html:not(.dark) textarea {
|
html:not(.dark) textarea {
|
||||||
color: #0f172a;
|
color: #18181b;
|
||||||
}
|
}
|
||||||
html:not(.dark) input::placeholder,
|
html:not(.dark) input::placeholder,
|
||||||
html:not(.dark) textarea::placeholder {
|
html:not(.dark) textarea::placeholder {
|
||||||
color: #94a3b8;
|
color: #a1a1aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
/* ── Marketing/product surface additions (2026-08 overhaul) ─────────────────
|
||||||
@@ -230,8 +254,8 @@ html:not(.dark) textarea::placeholder {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
/* Tinted accent surfaces (plan highlight cards, "included" checks, danger zones) */
|
||||||
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(79,70,229,0.08) !important; }
|
html:not(.dark) .bg-indigo-600\/10 { background-color: rgba(94,106,210,0.08) !important; }
|
||||||
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(79,70,229,0.35) !important; }
|
html:not(.dark) .border-indigo-600\/40 { border-color: rgba(94,106,210,0.35) !important; }
|
||||||
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
|
html:not(.dark) .bg-green-600\/10 { background-color: rgba(22,163,74,0.08) !important; }
|
||||||
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
|
html:not(.dark) .bg-red-600\/10 { background-color: rgba(220,38,38,0.08) !important; }
|
||||||
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
html:not(.dark) .border-red-600\/40 { border-color: rgba(220,38,38,0.35) !important; }
|
||||||
@@ -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 background — subtle radial glow, brand-neutral in both themes */
|
||||||
.marketing-hero-bg {
|
.marketing-hero-bg {
|
||||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.25), transparent 60%);
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.25), transparent 60%);
|
||||||
}
|
}
|
||||||
html:not(.dark) .marketing-hero-bg {
|
html:not(.dark) .marketing-hero-bg {
|
||||||
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(99,102,241,0.12), transparent 60%);
|
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgba(94,106,210,0.12), transparent 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeleton loading shimmer */
|
/* Skeleton loading shimmer */
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useMemo, useState } from "react";
|
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 { useIncident } from "@/lib/useIncidents";
|
||||||
import { useCallsByIncident } from "@/lib/useCalls";
|
import { useCallsByIncident } from "@/lib/useCalls";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
@@ -13,15 +14,17 @@ import { SeverityMark } from "@/components/marks/SeverityMark";
|
|||||||
import { isKnownSeverity } from "@/lib/severity";
|
import { isKnownSeverity } from "@/lib/severity";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
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";
|
import { MAY_BE_MERGED_KM, pathStops, stopNumbers } from "@/lib/liveWall";
|
||||||
|
|
||||||
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
|
||||||
|
|
||||||
const EARLIER_PAGE_SIZE = 8;
|
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 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));
|
const mins = Math.max(0, Math.round((end - start) / 60000));
|
||||||
if (mins < 60) return `${mins}m`;
|
if (mins < 60) return `${mins}m`;
|
||||||
const hrs = Math.floor(mins / 60);
|
const hrs = Math.floor(mins / 60);
|
||||||
@@ -31,7 +34,6 @@ function elapsedLabel(startedAt: string, active: boolean, updatedAt: string): st
|
|||||||
export default function IncidentDetailPage() {
|
export default function IncidentDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { incident, loading } = useIncident(id);
|
const { incident, loading } = useIncident(id);
|
||||||
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
const { calls, loading: callsLoading } = useCallsByIncident(id);
|
||||||
@@ -39,6 +41,7 @@ export default function IncidentDetailPage() {
|
|||||||
|
|
||||||
const [summarizing, setSummarizing] = useState(false);
|
const [summarizing, setSummarizing] = useState(false);
|
||||||
const [resolving, setResolving] = useState(false);
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
const [earlierShown, setEarlierShown] = useState(EARLIER_PAGE_SIZE);
|
||||||
|
|
||||||
// Stop numbers and path length come from the same liveWall.pathStops() the
|
// Stop numbers and path length come from the same liveWall.pathStops() the
|
||||||
@@ -54,16 +57,20 @@ export default function IncidentDetailPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handleResolve() {
|
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);
|
setResolving(true);
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||||
finally { setResolving(false); }
|
finally { setResolving(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSummarize() {
|
async function handleSummarize() {
|
||||||
setSummarizing(true);
|
setSummarizing(true);
|
||||||
|
setActionError(null);
|
||||||
try { await c2api.summarizeIncident(id); }
|
try { await c2api.summarizeIncident(id); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't regenerate the summary: ${errorMessage(e)}`); }
|
||||||
finally { setSummarizing(false); }
|
finally { setSummarizing(false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,18 +82,23 @@ export default function IncidentDetailPage() {
|
|||||||
const unitsCleared = incident.units_cleared ?? [];
|
const unitsCleared = incident.units_cleared ?? [];
|
||||||
const vehicles = incident.vehicles ?? [];
|
const vehicles = incident.vehicles ?? [];
|
||||||
const active = incident.status === "active";
|
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 visible = newestFirst.slice(0, earlierShown);
|
||||||
const remaining = newestFirst.length - visible.length;
|
const remaining = newestFirst.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
{/* A real link, not router.back(): arriving from Live or a shared URL,
|
||||||
onClick={() => router.back()}
|
"back" went to the map or out of the app. */}
|
||||||
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
<Link href="/incidents" className="text-xs text-ink-muted hover:text-ink-2 transition-colors">
|
||||||
>
|
|
||||||
← Incidents
|
← Incidents
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
{/* Header — glyph, severity chip, status, title at 27px, elapsed/path/count */}
|
{/* 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">
|
<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"}
|
{incident.title ?? "Incident"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-ink-muted font-mono">
|
<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?.movement && path.lengthKm > 0 && <> · {path.lengthKm.toFixed(1)} km path</>}
|
||||||
{path?.mayBeMerged && (
|
{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`}>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{actionError && <ErrorBanner message={actionError} />}
|
||||||
|
|
||||||
{displayTags.length > 0 && (
|
{displayTags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{displayTags.map((t) => (
|
{displayTags.map((t) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/components/AuthProvider";
|
|||||||
import { useIncidents } from "@/lib/useIncidents";
|
import { useIncidents } from "@/lib/useIncidents";
|
||||||
import { useActiveCalls } from "@/lib/useCalls";
|
import { useActiveCalls } from "@/lib/useCalls";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { IncidentRecord } from "@/lib/types";
|
import type { IncidentRecord } from "@/lib/types";
|
||||||
import { PageHeader } from "@/components/ui/PageHeader";
|
import { PageHeader } from "@/components/ui/PageHeader";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -204,6 +205,7 @@ export default function IncidentsPage() {
|
|||||||
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
const { incidents, loading, error, hasMore } = useIncidents(pageLimit, rangeFrom, rangeTo);
|
||||||
const activeCalls = useActiveCalls();
|
const activeCalls = useActiveCalls();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("all");
|
||||||
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
const [sortMode, setSortMode] = useState<SortMode>("recent");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("any");
|
||||||
@@ -257,8 +259,11 @@ export default function IncidentsPage() {
|
|||||||
}, [filtered]);
|
}, [filtered]);
|
||||||
|
|
||||||
async function handleResolve(id: string) {
|
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" }); }
|
try { await c2api.updateIncident(id, { status: "resolved" }); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { setActionError(`Couldn't resolve: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -273,6 +278,8 @@ export default function IncidentsPage() {
|
|||||||
chips are pipeline output, so the notice rides with the list. */}
|
chips are pipeline output, so the notice rides with the list. */}
|
||||||
<MachineOutputNotice variant="inline" />
|
<MachineOutputNotice variant="inline" />
|
||||||
|
|
||||||
|
{actionError && <ErrorBanner message={actionError} />}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<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">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{SEVERITY_FILTERS.map(({ key, label }) => (
|
{SEVERITY_FILTERS.map(({ key, label }) => (
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { CallRow } from "@/components/CallRow";
|
|||||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
|
import { diskState, DISK_TEXT_CLASS } from "@/lib/nodeDisk";
|
||||||
import type { NodeRecord } from "@/lib/types";
|
import type { NodeRecord } from "@/lib/types";
|
||||||
|
|
||||||
function ApprovalBadge({ status }: { status: string | null }) {
|
function ApprovalBadge({ status }: { status: string | null }) {
|
||||||
@@ -54,10 +56,14 @@ export default function NodeDetailPage() {
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
async function sendCommand(action: string) {
|
async function sendCommand(action: string, label: string) {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await c2api.sendCommand(id, { action });
|
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 {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -155,7 +161,8 @@ export default function NodeDetailPage() {
|
|||||||
<> No timeout is currently enforced (permanent override).</>
|
<> No timeout is currently enforced (permanent override).</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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 && (
|
{node.override_timeout_at && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -184,7 +191,7 @@ export default function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
Force Revert Config
|
Force Revert Config
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -212,6 +219,17 @@ export default function NodeDetailPage() {
|
|||||||
<span className="text-ink">{value}</span>
|
<span className="text-ink">{value}</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-ink-muted">Approval</span>
|
<span className="text-ink-muted">Approval</span>
|
||||||
<ApprovalBadge status={node.approval_status ?? null} />
|
<ApprovalBadge status={node.approval_status ?? null} />
|
||||||
@@ -240,20 +258,29 @@ export default function NodeDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 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't configured yet. An admin needs to assign it a radio system.</p>
|
||||||
|
)}
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
{isAdmin && (
|
||||||
onClick={() => setShowConfig(true)}
|
<button
|
||||||
className="px-4 py-2 bg-indigo-700 hover:bg-indigo-600 text-white rounded-lg text-sm font-mono transition-colors"
|
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>
|
{node.configured ? "Reassign System" : "Configure"}
|
||||||
<button
|
</button>
|
||||||
disabled={sending}
|
)}
|
||||||
onClick={() => sendCommand("op25_restart")}
|
{isAdmin && (
|
||||||
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"
|
<button
|
||||||
>
|
disabled={sending}
|
||||||
Restart OP25
|
onClick={() => sendCommand("op25_restart", "Restart OP25")}
|
||||||
</button>
|
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 && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
|
|||||||
@@ -79,9 +79,15 @@ export default function NodesPage() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{pending.map((n) => (
|
{pending.map((n) => (
|
||||||
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
// Configuring is admin-only on the server (server-26#222); for
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
// everyone else the card links to the node page instead.
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1930,7 +1930,8 @@ export default function SystemsPage() {
|
|||||||
if (authLoading || (!isAdmin && !isOperator)) return null;
|
if (authLoading || (!isAdmin && !isOperator)) return null;
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm("Delete this system?")) return;
|
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) {
|
function openEdit(s: SystemRecord) {
|
||||||
@@ -1952,12 +1953,16 @@ export default function SystemsPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
<h1 className="text-xl font-bold text-white font-mono">Systems</h1>
|
||||||
<button
|
{/* Every systems write route is require_admin_token (server-26#283);
|
||||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
operators get a read-only view. */}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
{isAdmin && (
|
||||||
>
|
<button
|
||||||
+ New System
|
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
@@ -2005,7 +2010,7 @@ export default function SystemsPage() {
|
|||||||
{kindLabel(s)}
|
{kindLabel(s)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-3">
|
{isAdmin && <div className="mt-3 flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEdit(s)}
|
onClick={() => openEdit(s)}
|
||||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
@@ -2024,15 +2029,21 @@ export default function SystemsPage() {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
|
{/* GET /tokens and PUT /tokens/{id}/prefer are platform-admin only
|
||||||
(server-26#204 item 6), so for anyone else this panel could
|
(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). */}
|
neither list the pool nor save a choice (server-26#213 item 4). */}
|
||||||
{isAdmin && <PreferredTokenPanel systemId={s.system_id} initialTokenId={s.preferred_token_id} />}
|
{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 ?? {}} />
|
{/* All admin-only routes (server-26#283), incl. area-context and
|
||||||
<AreaContextPanel systemId={s.system_id} />
|
talkgroup-pending GETs and every vocabulary write. */}
|
||||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
{isAdmin && (
|
||||||
<VocabularyPanel systemId={s.system_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} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -483,15 +484,17 @@ function DayTimeline({
|
|||||||
style={{ top, height, left: 60, right: 0 }}
|
style={{ top, height, left: 60, right: 0 }}
|
||||||
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
|
||||||
isConflict
|
isConflict
|
||||||
? "bg-red-950/70 border border-red-700/70"
|
// Theme tokens: the old bg-indigo-950/60 had no light-mode
|
||||||
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
|
// 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="flex items-start justify-between gap-2 h-full">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
|
||||||
{height >= 44 && (
|
{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)}
|
{fmtTime(e.start_time)}
|
||||||
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
|
||||||
{isConflict ? " ⚠ conflict" : ""}
|
{isConflict ? " ⚠ conflict" : ""}
|
||||||
@@ -955,7 +958,7 @@ export default function TripDetailPage() {
|
|||||||
async function handleDeleteTrip() {
|
async function handleDeleteTrip() {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); }
|
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) {
|
async function handleDeleteEvent(eventId: string) {
|
||||||
@@ -963,7 +966,7 @@ export default function TripDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
await c2api.deleteTripEvent(trip.trip_id, eventId);
|
||||||
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
|
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) {
|
async function handleUpdateEvent(body: object) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useAuth } from "@/components/AuthProvider";
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
import { useTrips } from "@/lib/useTrips";
|
import { useTrips } from "@/lib/useTrips";
|
||||||
import { c2api } from "@/lib/c2api";
|
import { c2api } from "@/lib/c2api";
|
||||||
|
import { errorMessage } from "@/lib/c2error";
|
||||||
import type { TripRecord } from "@/lib/types";
|
import type { TripRecord } from "@/lib/types";
|
||||||
|
|
||||||
function fmtDate(iso: string) {
|
function fmtDate(iso: string) {
|
||||||
@@ -179,8 +180,9 @@ export default function TripsPage() {
|
|||||||
const past = trips.filter((t) => t.end_date < today);
|
const past = trips.filter((t) => t.end_date < today);
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Delete this trip?")) return;
|
||||||
try { await c2api.deleteTrip(id); }
|
try { await c2api.deleteTrip(id); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -560,7 +560,11 @@ export function LiveView() {
|
|||||||
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
{/* Degraded → grey the map and its incident rail (the rail lives inside
|
||||||
MapView, so it is wrapped here rather than edited). */}
|
MapView, so it is wrapped here rather than edited). */}
|
||||||
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
|
||||||
{nodesLoading || callsLoading || !mapState ? (
|
{/* Mount the map as soon as its starting view is known. The 500-call
|
||||||
|
window took ~9s on prod and used to hold the whole map back;
|
||||||
|
calls and paths now stream in once it lands. Nodes are only
|
||||||
|
awaited when there's no saved view, since they pick the centre. */}
|
||||||
|
{!mapState || (!mapState.view && nodesLoading) ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
|
||||||
) : (
|
) : (
|
||||||
<MapView
|
<MapView
|
||||||
|
|||||||
@@ -887,7 +887,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
|
|||||||
const hot = highlightIds.has(c.id);
|
const hot = highlightIds.has(c.id);
|
||||||
return (
|
return (
|
||||||
<Polygon key={`vs-${c.id}`} positions={wedge}
|
<Polygon key={`vs-${c.id}`} positions={wedge}
|
||||||
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
|
pathOptions={{ color: hot ? "#8B93F0" : "#A9A9B3", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
|
||||||
<Tooltip sticky>{c.name} — approximate view (from the road's direction of travel, not a surveyed heading)</Tooltip>
|
<Tooltip sticky>{c.name} — approximate view (from the road's direction of travel, not a surveyed heading)</Tooltip>
|
||||||
</Polygon>
|
</Polygon>
|
||||||
);
|
);
|
||||||
@@ -902,7 +902,7 @@ function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
|
|||||||
function nearCameraIcon(): L.DivIcon {
|
function nearCameraIcon(): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: "",
|
className: "",
|
||||||
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
|
html: `<div style="width:20px;height:16px;border-radius:4px;background:#1C1F42;border:2px solid #8B93F0;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
|
||||||
iconSize: [20, 16],
|
iconSize: [20, 16],
|
||||||
iconAnchor: [10, 8],
|
iconAnchor: [10, 8],
|
||||||
});
|
});
|
||||||
@@ -917,7 +917,7 @@ function NearCameraLayer({ cameras, head, onOpenCamera }: {
|
|||||||
<>
|
<>
|
||||||
{head && (
|
{head && (
|
||||||
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
|
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
|
||||||
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
|
pathOptions={{ color: "#8B93F0", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
|
||||||
)}
|
)}
|
||||||
{cameras.map(({ cam, km: d }) => (
|
{cameras.map(({ cam, km: d }) => (
|
||||||
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
|
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
|
||||||
@@ -1620,6 +1620,9 @@ function MapView({
|
|||||||
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
||||||
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
|
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
|
||||||
const loopClock = useMemo(createLoopClock, []);
|
const loopClock = useMemo(createLoopClock, []);
|
||||||
|
// Short viewports (laptops, the 352px incident-page map) start with the
|
||||||
|
// legend folded to a chip so it doesn't cover the right side of the map.
|
||||||
|
const [legendOpenAtStart] = useState(() => typeof window === "undefined" || window.innerHeight >= 760);
|
||||||
|
|
||||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||||
// overlay is actually on (server-26#185); same for the outage key (#184).
|
// overlay is actually on (server-26#185); same for the outage key (#184).
|
||||||
@@ -1931,7 +1934,14 @@ function MapView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||||||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
|
{/* Collapsible (one click to a "Legend" chip) and hidden while the Leaflet
|
||||||
|
layers list is expanded: the map sits in a z-0 stacking context, so the
|
||||||
|
legend at z-1001 used to paint over the bottom of that list. */}
|
||||||
|
<details open={legendOpenAtStart} className="drb-legend group/legend absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg text-xs pointer-events-none max-h-[calc(100%-4rem)] overflow-y-auto">
|
||||||
|
<summary className="pointer-events-auto cursor-pointer list-none select-none px-3 py-1.5 text-ink-muted font-medium text-[10px] uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<span className="group-open/legend:hidden">▸</span><span className="hidden group-open/legend:inline">▾</span> Legend
|
||||||
|
</summary>
|
||||||
|
<div className="px-3 pb-2.5 space-y-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||||||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||||||
@@ -2070,7 +2080,8 @@ function MapView({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||||
{incidents.length > 0 && (
|
{incidents.length > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge } from "@/components/StatusBadge";
|
||||||
import type { NodeRecord, SystemRecord } from "@/lib/types";
|
import type { NodeRecord, SystemRecord } from "@/lib/types";
|
||||||
|
import { diskState } from "@/lib/nodeDisk";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: NodeRecord;
|
node: NodeRecord;
|
||||||
@@ -45,6 +46,15 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
|||||||
<span className="text-ink-2">{lastSeen}</span>
|
<span className="text-ink-2">{lastSeen}</span>
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{!node.configured && (
|
||||||
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
|
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
|
||||||
⚠ Needs configuration
|
⚠ Needs configuration
|
||||||
|
|||||||
@@ -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">
|
<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>
|
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
|
||||||
<p className="text-ink-2 text-sm mb-5">
|
<p className="text-ink-2 text-sm mb-5">
|
||||||
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
|
{node.configured ? (
|
||||||
Assign it a radio system to begin monitoring.
|
<><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>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { C2ApiError } from "@/lib/c2error";
|
||||||
import type {
|
import type {
|
||||||
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
||||||
WeatherAlertsResponse, WindResponse,
|
WeatherAlertsResponse, WindResponse,
|
||||||
@@ -19,7 +20,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
...(options?.headers as Record<string, string> | undefined),
|
...(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;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
// (routers/discord.py GET /discord/admin/overview, routers/tokens.py), and
|
||||||
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
// small helpers shared by /settings/discord and /tokens (server-26#213).
|
||||||
|
|
||||||
|
import { legacyErrorText } from "@/lib/c2error";
|
||||||
|
|
||||||
export interface RadioBotLease {
|
export interface RadioBotLease {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
lease_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.
|
* 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 }[] } {
|
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]*)$/);
|
const m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
// browser: the code exchange happens in c2-core.
|
// browser: the code exchange happens in c2-core.
|
||||||
|
|
||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
|
import { C2ApiError } from "@/lib/c2error";
|
||||||
|
|
||||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
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) } : {}),
|
...(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;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ export async function startDiscordOAuth(req: OAuthStartRequest): Promise<OAuthSt
|
|||||||
try {
|
try {
|
||||||
raw = await post("/discord/oauth/start", req);
|
raw = await post("/discord/oauth/start", req);
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
const url = raw.url ?? raw.authorize_url ?? "";
|
const url = raw.url ?? raw.authorize_url ?? "";
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
|
// Shapes returned by c2-core app/routers/discord_web.py. No token strings —
|
||||||
// the server never sends them to the browser.
|
// the server never sends them to the browser.
|
||||||
|
|
||||||
|
import { legacyErrorText } from "@/lib/c2error";
|
||||||
|
|
||||||
export type StreamState =
|
export type StreamState =
|
||||||
| "pending"
|
| "pending"
|
||||||
| "connecting"
|
| "connecting"
|
||||||
@@ -131,7 +133,7 @@ function reasonOf(b: Record<string, unknown>): string | null {
|
|||||||
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
/** c2api's request() throws `C2 API error <status>: <body>`. Pull the
|
||||||
* detail, code and invite links back out of a JSON body. */
|
* detail, code and invite links back out of a JSON body. */
|
||||||
export function parseC2Error(err: unknown): ParsedC2Error {
|
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 m = raw.match(/^C2 API error (\d+): ([\s\S]*)$/);
|
||||||
const status = m ? Number(m[1]) : null;
|
const status = m ? Number(m[1]) : null;
|
||||||
const idx = raw.indexOf(": ");
|
const idx = raw.indexOf(": ");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -82,6 +82,9 @@ export interface NodeRecord {
|
|||||||
/** The dongle OP25 is actually using, per the node. */
|
/** The dongle OP25 is actually using, per the node. */
|
||||||
op25_sdr_serial?: string | null;
|
op25_sdr_serial?: string | null;
|
||||||
sdr_count?: number | null; // null = never reported
|
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. */
|
/** node-26#24: conventional channels from the last checkin; [] on a trunked system. */
|
||||||
radio_channels?: RadioChannel[] | null;
|
radio_channels?: RadioChannel[] | null;
|
||||||
enforce_override_timeout?: boolean;
|
enforce_override_timeout?: boolean;
|
||||||
@@ -487,6 +490,8 @@ export interface IncidentRecord {
|
|||||||
title: string | null;
|
title: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
status: "active" | "resolved";
|
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: string | null;
|
||||||
location_coords: GeoPoint | null;
|
location_coords: GeoPoint | null;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ const config: Config = {
|
|||||||
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
// work — a bare var() hex gives Tailwind nothing to apply alpha to, and the
|
||||||
// class silently generates no rule (map overlays rendered see-through).
|
// class silently generates no rule (map overlays rendered see-through).
|
||||||
colors: {
|
colors: {
|
||||||
|
// "Iris" palette: the legacy raw gray-*/indigo-* classes (~350 uses)
|
||||||
|
// resolve to graphite + the brand accent instead of Tailwind's slate-blue
|
||||||
|
// defaults, so un-migrated components match the tokens above.
|
||||||
|
gray: {
|
||||||
|
50: "#F7F7F8", 100: "#EDEDEF", 200: "#E4E4E7", 300: "#C9C9D0", 400: "#A1A1AA",
|
||||||
|
500: "#7A7A85", 600: "#4A4B53", 700: "#2A2B31", 800: "#1A1B1F", 900: "#111214", 950: "#08090A",
|
||||||
|
},
|
||||||
|
indigo: {
|
||||||
|
50: "#F0F1FD", 100: "#E2E4FB", 200: "#C8CCF6", 300: "#A8AEF1", 400: "#8B93F0",
|
||||||
|
500: "#6F79E0", 600: "#5E6AD2", 700: "#4E58B8", 800: "#3F4794", 900: "#313770", 950: "#1C1F42",
|
||||||
|
},
|
||||||
page: tok("page"),
|
page: tok("page"),
|
||||||
surface: tok("surface"),
|
surface: tok("surface"),
|
||||||
raised: tok("raised"),
|
raised: tok("raised"),
|
||||||
@@ -51,7 +62,7 @@ const config: Config = {
|
|||||||
boxShadow: {
|
boxShadow: {
|
||||||
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
|
card: "0 1px 2px 0 rgb(0 0 0 / 0.4), 0 1px 3px 0 rgb(0 0 0 / 0.3)",
|
||||||
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
|
"card-hover": "0 4px 12px 0 rgb(0 0 0 / 0.45), 0 2px 4px 0 rgb(0 0 0 / 0.3)",
|
||||||
glow: "0 0 0 1px rgb(99 102 241 / 0.4), 0 0 24px 0 rgb(99 102 241 / 0.25)",
|
glow: "0 0 0 1px rgb(94 106 210 / 0.4), 0 0 24px 0 rgb(94 106 210 / 0.25)",
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
"fade-in": "fade-in 0.4s ease-out",
|
"fade-in": "fade-in 0.4s ease-out",
|
||||||
|
|||||||
Reference in New Issue
Block a user