Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fdfb2758 | ||
|
|
7cd289874a | ||
|
|
f4d78aba77 | ||
|
|
268470b6c0 | ||
|
|
4a93390967 | ||
|
|
94898d1af1 | ||
|
|
33056e2269 | ||
|
|
e8084357c3 | ||
|
|
9702b90d40 | ||
|
|
606b0a103c | ||
|
|
f16f0a9f93 | ||
|
|
6d667e81c9 | ||
|
|
bf206334e3 | ||
|
|
3968b49b80 | ||
|
|
8099871c68 | ||
|
|
49166789f4 | ||
|
|
9698d463ad |
@@ -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 = {
|
||||||
|
|||||||
+118
-111
@@ -85,7 +85,7 @@ function fmtDatetime(iso: string | null | undefined) {
|
|||||||
const ROLE_COLORS: Record<UserRole, string> = {
|
const ROLE_COLORS: Record<UserRole, string> = {
|
||||||
admin: "bg-indigo-900 text-indigo-300",
|
admin: "bg-indigo-900 text-indigo-300",
|
||||||
operator: "bg-green-900 text-green-300",
|
operator: "bg-green-900 text-green-300",
|
||||||
viewer: "bg-gray-800 text-gray-400",
|
viewer: "bg-raised text-ink-2",
|
||||||
};
|
};
|
||||||
|
|
||||||
function RoleBadge({ role }: { role: UserRole }) {
|
function RoleBadge({ role }: { role: UserRole }) {
|
||||||
@@ -136,14 +136,14 @@ function FeaturesTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
{FLAG_META.map(({ key, label, description }) => (
|
{FLAG_META.map(({ key, label, description }) => (
|
||||||
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
<div key={key} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-white text-sm font-semibold">{label}</p>
|
<p className="text-white text-sm font-semibold">{label}</p>
|
||||||
<p className="text-gray-500 text-xs mt-0.5 leading-snug">{description}</p>
|
<p className="text-ink-muted text-xs mt-0.5 leading-snug">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
enabled={flags?.[key] ?? true}
|
enabled={flags?.[key] ?? true}
|
||||||
@@ -238,30 +238,30 @@ function CorrelationDebugTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
|
Fetches recent incidents with per-call correlation debug fields, plus orphaned calls.
|
||||||
Copy the JSON and paste it to Claude to diagnose correlation problems.
|
Copy the JSON and paste it to Claude to diagnose correlation problems.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Incidents (limit)</label>
|
<label className="text-xs text-ink-2 block mb-1">Incidents (limit)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={100}
|
min={1} max={100}
|
||||||
value={limit}
|
value={limit}
|
||||||
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => setLimit(Math.min(100, Math.max(1, Number(e.target.value))))}
|
||||||
className="w-24 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-24 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Orphan scan window (hours)</label>
|
<label className="text-xs text-ink-2 block mb-1">Orphan scan window (hours)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={168}
|
min={1} max={168}
|
||||||
value={orphanHours}
|
value={orphanHours}
|
||||||
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => setOrphanHours(Math.min(168, Math.max(1, Number(e.target.value))))}
|
||||||
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -281,12 +281,12 @@ function CorrelationDebugTab() {
|
|||||||
|
|
||||||
{meta && (
|
{meta && (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
|
{meta.incident_count} incidents · {meta.orphaned_call_count} orphaned calls · {meta.generated_at}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-gray-700 bg-gray-900 text-gray-300 hover:text-white transition-colors"
|
className="text-xs font-mono px-3 py-1.5 rounded-lg border border-line-strong bg-surface text-ink-2 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy JSON"}
|
{copied ? "Copied!" : "Copy JSON"}
|
||||||
</button>
|
</button>
|
||||||
@@ -295,22 +295,22 @@ function CorrelationDebugTab() {
|
|||||||
|
|
||||||
{meta && Object.keys(pathCounts).length > 0 && (
|
{meta && Object.keys(pathCounts).length > 0 && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">corr_path distribution</p>
|
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">corr_path distribution</p>
|
||||||
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
|
{Object.entries(pathCounts).sort((a, b) => b[1] - a[1]).map(([path, n]) => (
|
||||||
<div key={path} className="flex justify-between text-xs font-mono">
|
<div key={path} className="flex justify-between text-xs font-mono">
|
||||||
<span className="text-gray-300">{path}</span>
|
<span className="text-ink-2">{path}</span>
|
||||||
<span className="text-indigo-400">{n}</span>
|
<span className="text-indigo-400">{n}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">fit_signal distribution</p>
|
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">fit_signal distribution</p>
|
||||||
{Object.keys(signalCounts).length === 0
|
{Object.keys(signalCounts).length === 0
|
||||||
? <p className="text-xs text-gray-600 font-mono">No signal data yet — deploy correlator update first</p>
|
? <p className="text-xs text-ink-muted font-mono">No signal data yet — deploy correlator update first</p>
|
||||||
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
|
: Object.entries(signalCounts).sort((a, b) => b[1] - a[1]).map(([sig, n]) => (
|
||||||
<div key={sig} className="flex justify-between text-xs font-mono">
|
<div key={sig} className="flex justify-between text-xs font-mono">
|
||||||
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-gray-300"}>{sig}</span>
|
<span className={sig === "time_fallback" ? "text-yellow-400" : "text-ink-2"}>{sig}</span>
|
||||||
<span className="text-indigo-400">{n}</span>
|
<span className="text-indigo-400">{n}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -320,13 +320,13 @@ function CorrelationDebugTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
|
{meta?.orphans_by_talkgroup && meta.orphans_by_talkgroup.length > 0 && (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-1">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-1">
|
||||||
<p className="text-xs text-gray-400 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
<p className="text-xs text-ink-2 font-mono font-semibold mb-2">orphans by talkgroup</p>
|
||||||
{meta.orphans_by_talkgroup.map((tg) => (
|
{meta.orphans_by_talkgroup.map((tg) => (
|
||||||
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
|
<div key={String(tg.talkgroup_id)} className="flex justify-between text-xs font-mono">
|
||||||
<span className="text-gray-300">{tg.talkgroup_name} <span className="text-gray-600">({tg.talkgroup_id})</span></span>
|
<span className="text-ink-2">{tg.talkgroup_name} <span className="text-ink-muted">({tg.talkgroup_id})</span></span>
|
||||||
<span className="text-gray-400">
|
<span className="text-ink-2">
|
||||||
{tg.count} total · <span className="text-gray-500">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
|
{tg.count} total · <span className="text-ink-muted">{tg.no_type_count} no-type</span> · <span className="text-red-400">{tg.sweep_exhausted_count} exhausted</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -336,7 +336,7 @@ function CorrelationDebugTab() {
|
|||||||
{json && (
|
{json && (
|
||||||
<pre
|
<pre
|
||||||
ref={preRef}
|
ref={preRef}
|
||||||
className="bg-gray-950 border border-gray-800 rounded-xl p-4 text-xs text-gray-300 font-mono overflow-auto max-h-[60vh] whitespace-pre"
|
className="bg-page border border-line rounded-xl p-4 text-xs text-ink-2 font-mono overflow-auto max-h-[60vh] whitespace-pre"
|
||||||
>
|
>
|
||||||
{json}
|
{json}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -455,13 +455,13 @@ function UserDetailPanel({
|
|||||||
const isSelf = user.uid === currentUid;
|
const isSelf = user.uid === currentUid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-5 space-y-5 font-mono">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-semibold">{detail.email}</p>
|
<p className="text-white font-semibold">{detail.email}</p>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{detail.uid}</p>
|
<p className="text-xs text-ink-muted mt-0.5">{detail.uid}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-600 hover:text-gray-300 transition-colors text-xl leading-none">×</button>
|
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors text-xl leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@@ -472,21 +472,21 @@ function UserDetailPanel({
|
|||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Display Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Display Name</label>
|
||||||
<input
|
<input
|
||||||
value={editName}
|
value={editName}
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Full name"
|
placeholder="Full name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
<label className="text-xs text-ink-2 block mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={editRole}
|
value={editRole}
|
||||||
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
onChange={(e) => setEditRole(e.target.value as UserRole)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="admin">Admin — full access</option>
|
<option value="admin">Admin — full access</option>
|
||||||
<option value="operator">Operator — owns nodes</option>
|
<option value="operator">Operator — owns nodes</option>
|
||||||
@@ -496,13 +496,13 @@ function UserDetailPanel({
|
|||||||
|
|
||||||
{editRole === "operator" && (
|
{editRole === "operator" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={editNodes}
|
value={editNodes}
|
||||||
onChange={(e) => setEditNodes(e.target.value)}
|
onChange={(e) => setEditNodes(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="node-abc123, node-def456"
|
placeholder="node-abc123, node-def456"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,10 +517,10 @@ function UserDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4 space-y-2 text-xs">
|
<div className="border-t border-line pt-4 space-y-2 text-xs">
|
||||||
<div className="flex justify-between items-center gap-3">
|
<div className="flex justify-between items-center gap-3">
|
||||||
<span className="text-gray-500">Organization</span>
|
<span className="text-ink-muted">Organization</span>
|
||||||
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-gray-300" : "text-yellow-400"}`}>
|
<span className={`font-mono truncate ${detail.org_id && detail.org_id === myOrgId ? "text-ink-2" : "text-yellow-400"}`}>
|
||||||
{!detail.org_id
|
{!detail.org_id
|
||||||
? "None: sees no data"
|
? "None: sees no data"
|
||||||
: detail.org_id === myOrgId
|
: detail.org_id === myOrgId
|
||||||
@@ -538,34 +538,34 @@ function UserDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Status</span>
|
<span className="text-ink-muted">Status</span>
|
||||||
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
<span className={detail.disabled ? "text-red-400" : "text-green-400"}>
|
||||||
{detail.disabled ? "Disabled" : "Active"}
|
{detail.disabled ? "Disabled" : "Active"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Discord</span>
|
<span className="text-ink-muted">Discord</span>
|
||||||
<span className="text-gray-300">
|
<span className="text-ink-2">
|
||||||
{detail.discord_linked
|
{detail.discord_linked
|
||||||
? `@${detail.discord_username ?? detail.discord_user_id}`
|
? `@${detail.discord_username ?? detail.discord_user_id}`
|
||||||
: "Not linked"}
|
: "Not linked"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Created</span>
|
<span className="text-ink-muted">Created</span>
|
||||||
<span className="text-gray-300">{fmtDate(detail.creation_time)}</span>
|
<span className="text-ink-2">{fmtDate(detail.creation_time)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-500">Last sign-in</span>
|
<span className="text-ink-muted">Last sign-in</span>
|
||||||
<span className="text-gray-300">{fmtDate(detail.last_sign_in)}</span>
|
<span className="text-ink-2">{fmtDate(detail.last_sign_in)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(detail.sessions?.length ?? 0) > 0 && (
|
{(detail.sessions?.length ?? 0) > 0 && (
|
||||||
<div className="border-t border-gray-800 pt-4">
|
<div className="border-t border-line pt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSessions((v) => !v)}
|
onClick={() => setShowSessions((v) => !v)}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{showSessions ? "▲" : "▼"}</span>
|
<span>{showSessions ? "▲" : "▼"}</span>
|
||||||
<span>Login history ({detail.sessions?.length} recent)</span>
|
<span>Login history ({detail.sessions?.length} recent)</span>
|
||||||
@@ -573,9 +573,9 @@ function UserDetailPanel({
|
|||||||
{showSessions && (
|
{showSessions && (
|
||||||
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
<div className="mt-3 space-y-1.5 max-h-48 overflow-y-auto">
|
||||||
{detail.sessions?.map((s) => (
|
{detail.sessions?.map((s) => (
|
||||||
<div key={s.session_id} className="text-xs text-gray-400 flex justify-between gap-4">
|
<div key={s.session_id} className="text-xs text-ink-2 flex justify-between gap-4">
|
||||||
<span>{fmtDatetime(s.timestamp)}</span>
|
<span>{fmtDatetime(s.timestamp)}</span>
|
||||||
<span className="text-gray-600 truncate">{s.ip ?? "—"}</span>
|
<span className="text-ink-muted truncate">{s.ip ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -583,7 +583,7 @@ function UserDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-t border-gray-800 pt-4 flex gap-4 flex-wrap">
|
<div className="border-t border-line pt-4 flex gap-4 flex-wrap">
|
||||||
{!isSelf ? (
|
{!isSelf ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -602,7 +602,7 @@ function UserDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600">Cannot disable or delete your own account.</p>
|
<p className="text-xs text-ink-muted">Cannot disable or delete your own account.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -667,19 +667,19 @@ function CreateUserModal({
|
|||||||
if (inviteLink) {
|
if (inviteLink) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 space-y-4 font-mono">
|
||||||
<h2 className="text-white font-semibold">User Created</h2>
|
<h2 className="text-white font-semibold">User Created</h2>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-ink-2">
|
||||||
Share this one-time invite link with the new user so they can set their password.
|
Share this one-time invite link with the new user so they can set their password.
|
||||||
It expires after use.
|
It expires after use.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={copyLink}
|
onClick={copyLink}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy link"}
|
{copied ? "Copied!" : "Copy link"}
|
||||||
</button>
|
</button>
|
||||||
@@ -697,37 +697,37 @@ function CreateUserModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-md p-6 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-md p-6 font-mono">
|
||||||
<h2 className="text-white font-semibold mb-4">Create User</h2>
|
<h2 className="text-white font-semibold mb-4">Create User</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="user@example.com"
|
placeholder="user@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Display Name <span className="text-gray-600">(optional)</span>
|
Display Name <span className="text-ink-muted">(optional)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Jane Smith"
|
placeholder="Jane Smith"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
<label className="text-xs text-ink-2 block mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={role}
|
value={role}
|
||||||
onChange={(e) => setRole(e.target.value as UserRole)}
|
onChange={(e) => setRole(e.target.value as UserRole)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="admin">Admin — full access</option>
|
<option value="admin">Admin — full access</option>
|
||||||
<option value="operator">Operator — owns nodes</option>
|
<option value="operator">Operator — owns nodes</option>
|
||||||
@@ -736,14 +736,14 @@ function CreateUserModal({
|
|||||||
</div>
|
</div>
|
||||||
{role === "operator" && (
|
{role === "operator" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Owned Node IDs <span className="text-gray-600">(comma-separated, required)</span>
|
Owned Node IDs <span className="text-ink-muted">(comma-separated, required)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={nodeIds}
|
value={nodeIds}
|
||||||
onChange={(e) => setNodeIds(e.target.value)}
|
onChange={(e) => setNodeIds(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="node-abc123, node-def456"
|
placeholder="node-abc123, node-def456"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -760,7 +760,7 @@ function CreateUserModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -820,7 +820,7 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500 font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
<p className="text-xs text-ink-muted font-mono">{users.length} user{users.length !== 1 ? "s" : ""}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowCreate(true)}
|
onClick={() => setShowCreate(true)}
|
||||||
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
@@ -836,14 +836,14 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : users.length === 0 ? (
|
) : users.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No users found.</p>
|
<p className="text-ink-muted text-sm font-mono">No users found.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
<div className="border border-line rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-xs font-mono">
|
<table className="w-full text-xs font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
<tr className="bg-surface text-ink-muted uppercase tracking-wider">
|
||||||
<th className="px-4 py-2.5 text-left">Email</th>
|
<th className="px-4 py-2.5 text-left">Email</th>
|
||||||
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
<th className="px-4 py-2.5 text-left hidden lg:table-cell">Name</th>
|
||||||
<th className="px-4 py-2.5 text-left">Role</th>
|
<th className="px-4 py-2.5 text-left">Role</th>
|
||||||
@@ -857,17 +857,17 @@ function UsersTab({ currentUid }: { currentUid: string }) {
|
|||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<tr
|
<tr
|
||||||
key={u.uid}
|
key={u.uid}
|
||||||
className={`border-t border-gray-800 transition-colors ${
|
className={`border-t border-line transition-colors ${
|
||||||
selectedUid === u.uid ? "bg-gray-800/60" : "hover:bg-gray-900/60"
|
selectedUid === u.uid ? "bg-raised/60" : "hover:bg-raised/60"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2.5 text-gray-200">{u.email ?? "—"}</td>
|
<td className="px-4 py-2.5 text-ink">{u.email ?? "—"}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-400 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
<td className="px-4 py-2.5 text-ink-2 hidden lg:table-cell">{u.display_name ?? "—"}</td>
|
||||||
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
|
<td className="px-4 py-2.5"><RoleBadge role={u.role} /></td>
|
||||||
<td className="px-4 py-2.5 text-gray-500 hidden sm:table-cell">
|
<td className="px-4 py-2.5 text-ink-muted hidden sm:table-cell">
|
||||||
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
|
{u.discord_linked ? `@${u.discord_username ?? "linked"}` : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-gray-500 hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
|
<td className="px-4 py-2.5 text-ink-muted hidden md:table-cell">{fmtDate(u.last_sign_in)}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{u.disabled
|
{u.disabled
|
||||||
? <span className="text-red-500">Disabled</span>
|
? <span className="text-red-500">Disabled</span>
|
||||||
@@ -952,15 +952,15 @@ function AuditLogTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : entries.length === 0 ? (
|
) : entries.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No audit entries yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No audit entries yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="border border-gray-800 rounded-xl overflow-hidden">
|
<div className="border border-line rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-xs font-mono">
|
<table className="w-full text-xs font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-900 text-gray-500 uppercase tracking-wider">
|
<tr className="bg-surface text-ink-muted uppercase tracking-wider">
|
||||||
<th className="px-4 py-2.5 text-left">Time</th>
|
<th className="px-4 py-2.5 text-left">Time</th>
|
||||||
<th className="px-4 py-2.5 text-left">Action</th>
|
<th className="px-4 py-2.5 text-left">Action</th>
|
||||||
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
<th className="px-4 py-2.5 text-left hidden sm:table-cell">Actor</th>
|
||||||
@@ -970,12 +970,12 @@ function AuditLogTab() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((e) => (
|
{entries.map((e) => (
|
||||||
<tr key={e.log_id} className="border-t border-gray-800 hover:bg-gray-900/40">
|
<tr key={e.log_id} className="border-t border-line hover:bg-raised/40">
|
||||||
<td className="px-4 py-2.5 text-gray-500 whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
<td className="px-4 py-2.5 text-ink-muted whitespace-nowrap">{fmtDatetime(e.timestamp)}</td>
|
||||||
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
<td className={`px-4 py-2.5 whitespace-nowrap ${actionColor(e.action)}`}>{e.action}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-400 hidden sm:table-cell">{e.actor_email}</td>
|
<td className="px-4 py-2.5 text-ink-2 hidden sm:table-cell">{e.actor_email}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-400 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
<td className="px-4 py-2.5 text-ink-2 hidden md:table-cell">{e.target_email ?? "—"}</td>
|
||||||
<td className="px-4 py-2.5 text-gray-600 max-w-xs truncate">
|
<td className="px-4 py-2.5 text-ink-muted max-w-xs truncate">
|
||||||
{Object.keys(e.details).length > 0
|
{Object.keys(e.details).length > 0
|
||||||
? Object.entries(e.details)
|
? Object.entries(e.details)
|
||||||
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
||||||
@@ -1029,26 +1029,26 @@ function StaleCallsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Finds calls stuck in <span className="text-gray-300">active</span> status because a node rebooted before sending an end-call event.
|
Finds calls stuck in <span className="text-ink-2">active</span> status because a node rebooted before sending an end-call event.
|
||||||
Preview first, then close.
|
Preview first, then close.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Older than (minutes)</label>
|
<label className="text-xs text-ink-2 block mb-1">Older than (minutes)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={1} max={1440}
|
min={1} max={1440}
|
||||||
value={minutes}
|
value={minutes}
|
||||||
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
onChange={(e) => setMinutes(Math.min(1440, Math.max(1, Number(e.target.value))))}
|
||||||
className="w-28 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-28 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => run(true)}
|
onClick={() => run(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? "Working…" : "Preview"}
|
{loading ? "Working…" : "Preview"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1068,18 +1068,18 @@ function StaleCallsTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-2">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-2">
|
||||||
<p className="text-sm font-mono text-white">
|
<p className="text-sm font-mono text-white">
|
||||||
{result.dry_run ? "Preview: " : "Closed: "}
|
{result.dry_run ? "Preview: " : "Closed: "}
|
||||||
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
<span className={result.count > 0 ? "text-amber-400" : "text-green-400"}>
|
||||||
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
{result.count} stale call{result.count !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
{result.count === 0 && <span className="text-gray-500"> — nothing to clear</span>}
|
{result.count === 0 && <span className="text-ink-muted"> — nothing to clear</span>}
|
||||||
</p>
|
</p>
|
||||||
{result.call_ids.length > 0 && (
|
{result.call_ids.length > 0 && (
|
||||||
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
<div className="max-h-40 overflow-y-auto space-y-0.5">
|
||||||
{result.call_ids.map((id) => (
|
{result.call_ids.map((id) => (
|
||||||
<p key={id} className="text-xs font-mono text-gray-400">{id}</p>
|
<p key={id} className="text-xs font-mono text-ink-2">{id}</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1104,17 +1104,17 @@ function fmtPct(x: number | null | undefined): string {
|
|||||||
|
|
||||||
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
|
function EvalStatsBar({ stats }: { stats: { eval_count: number; raw_wer: number | null; corrected_wer: number | null } | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
|
<div className="bg-surface border border-line rounded-xl p-4 flex flex-wrap gap-x-8 gap-y-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-mono">Calls verified</p>
|
<p className="text-xs text-ink-muted font-mono">Calls verified</p>
|
||||||
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
|
<p className="text-white text-lg font-mono">{stats?.eval_count ?? "—"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-mono">Raw WER (whisper-1)</p>
|
<p className="text-xs text-ink-muted font-mono">Raw WER (whisper-1)</p>
|
||||||
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
|
<p className="text-white text-lg font-mono">{fmtPct(stats?.raw_wer)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 font-mono">Corrected WER (shipped)</p>
|
<p className="text-xs text-ink-muted font-mono">Corrected WER (shipped)</p>
|
||||||
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
|
<p className="text-white text-lg font-mono">{fmtPct(stats?.corrected_wer)}</p>
|
||||||
</div>
|
</div>
|
||||||
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
|
{stats && stats.eval_count > 0 && stats.eval_count < 20 && (
|
||||||
@@ -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 || "") : "");
|
||||||
@@ -1197,7 +1199,7 @@ function SttEvalTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
|
Listen to the audio, correct the transcript below until it matches what was actually said, then save.
|
||||||
This never touches the call's real transcript or re-runs anything — it only records ground truth
|
This never touches the call's real transcript or re-runs anything — it only records ground truth
|
||||||
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
|
for measuring the pipeline. Do as many or as few as you have time for; it picks up where you left off.
|
||||||
@@ -1212,8 +1214,8 @@ function SttEvalTab() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{current ? (
|
{current ? (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-gray-400">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-ink-2">
|
||||||
<span>{new Date(current.started_at).toLocaleString()}</span>
|
<span>{new Date(current.started_at).toLocaleString()}</span>
|
||||||
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
<span>{current.talkgroup_name || (current.talkgroup_id ? `TGID ${current.talkgroup_id}` : "unknown talkgroup")}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1222,18 +1224,18 @@ function SttEvalTab() {
|
|||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
<audio controls src={current.audio_url} className="w-full h-9" />
|
<audio controls src={current.audio_url} className="w-full h-9" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500 italic">No audio on this call — skip it.</p>
|
<p className="text-xs text-ink-muted italic">No audio on this call — skip it.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Machine transcript (pre-filled) — correct it into what was actually said
|
Machine transcript (pre-filled) — correct it into what was actually said
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1248,15 +1250,15 @@ function SttEvalTab() {
|
|||||||
<button
|
<button
|
||||||
onClick={skip}
|
onClick={skip}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
className="bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
<div className="bg-surface border border-line rounded-xl p-4">
|
||||||
<p className="text-sm font-mono text-gray-400">
|
<p className="text-sm font-mono text-ink-2">
|
||||||
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
|
{loadingBatch ? "Loading calls…" : exhausted ? "Nothing left to verify right now — check back after more calls come in." : "Loading…"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1309,16 +1311,19 @@ 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-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{TAB_LABELS.map(({ key, label }) => (
|
{TAB_LABELS.map(({ key, label }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setTab(key)}
|
onClick={() => setTab(key)}
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors ${
|
||||||
tab === key ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
tab === key ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -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 />}
|
||||||
@@ -1336,5 +1342,6 @@ export default function AdminPage() {
|
|||||||
{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,52 +68,55 @@ 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 (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<form onSubmit={handleCreate} className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
|
<form onSubmit={handleCreate} className="bg-surface border border-line rounded-xl p-5 space-y-4">
|
||||||
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
|
<h3 className="text-white font-mono text-sm font-bold">New Alert Rule</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
required value={name} onChange={(e) => setName(e.target.value)}
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Structure Fire"
|
placeholder="e.g. Structure Fire"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Keywords (comma-separated)</label>
|
<label className="text-xs text-ink-2 block mb-1">Keywords (comma-separated)</label>
|
||||||
<input
|
<input
|
||||||
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
value={keywords} onChange={(e) => setKeywords(e.target.value)}
|
||||||
placeholder="fire, smoke, structure"
|
placeholder="fire, smoke, structure"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Talkgroup IDs (comma-separated)</label>
|
<label className="text-xs text-ink-2 block mb-1">Talkgroup IDs (comma-separated)</label>
|
||||||
<input
|
<input
|
||||||
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
value={tgIds} onChange={(e) => setTgIds(e.target.value)}
|
||||||
placeholder="9048, 9600"
|
placeholder="9048, 9600"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Discord Webhook URL (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Discord Webhook URL (optional)</label>
|
||||||
<input
|
<input
|
||||||
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
value={webhook} onChange={(e) => setWebhook(e.target.value)}
|
||||||
placeholder="https://discord.com/api/webhooks/…"
|
placeholder="https://discord.com/api/webhooks/…"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -120,13 +130,17 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
{listError && <ErrorBanner message={listError} />}
|
||||||
{rules.length === 0 ? (
|
|
||||||
<p className="text-gray-600 text-sm font-mono p-4">No alert rules configured.</p>
|
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
|
{!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">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
<tr className="border-b border-line text-xs text-ink-muted uppercase">
|
||||||
<th className="px-4 py-3">Name</th>
|
<th className="px-4 py-3">Name</th>
|
||||||
<th className="px-4 py-3">Keywords</th>
|
<th className="px-4 py-3">Keywords</th>
|
||||||
<th className="px-4 py-3">Talkgroups</th>
|
<th className="px-4 py-3">Talkgroups</th>
|
||||||
@@ -137,11 +151,11 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rules.map((rule) => (
|
{rules.map((rule) => (
|
||||||
<tr key={rule.rule_id} className="border-b border-gray-800">
|
<tr key={rule.rule_id} className="border-b border-line">
|
||||||
<td className="px-4 py-3 text-white text-sm">{rule.name}</td>
|
<td className="px-4 py-3 text-white text-sm">{rule.name}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.keywords.join(", ") || "—"}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{rule.talkgroup_ids.join(", ") || "—"}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs">
|
<td className="px-4 py-3 text-ink-2 text-xs">
|
||||||
{rule.discord_webhook ? (
|
{rule.discord_webhook ? (
|
||||||
<span className="text-green-400">configured</span>
|
<span className="text-green-400">configured</span>
|
||||||
) : "—"}
|
) : "—"}
|
||||||
@@ -153,13 +167,13 @@ function RulesTab({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
|
||||||
rule.enabled
|
rule.enabled
|
||||||
? "bg-green-900 text-green-300 hover:bg-green-800"
|
? "bg-green-900 text-green-300 hover:bg-green-800"
|
||||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
: "bg-raised text-ink-2 hover:bg-gray-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{rule.enabled ? "enabled" : "disabled"}
|
{rule.enabled ? "enabled" : "disabled"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-gray-500"}`}>
|
<span className={`text-xs ${rule.enabled ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{rule.enabled ? "enabled" : "disabled"}
|
{rule.enabled ? "enabled" : "disabled"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -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);
|
||||||
@@ -209,13 +225,13 @@ export default function AlertsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit">
|
<div className="flex gap-1 bg-surface border border-line rounded-lg p-1 w-fit">
|
||||||
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
|
{(["events", ...(isAdmin ? ["rules"] : [])] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t as "events" | "rules")}
|
onClick={() => setTab(t as "events" | "rules")}
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors capitalize ${
|
||||||
tab === t ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"
|
tab === t ? "bg-raised text-white" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t === "events" ? "Triggered Alerts" : "Rules"}
|
{t === "events" ? "Triggered Alerts" : "Rules"}
|
||||||
@@ -223,9 +239,11 @@ export default function AlertsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tab === "events" && ackError && <ErrorBanner message={ackError} />}
|
||||||
|
|
||||||
{tab === "events" && (
|
{tab === "events" && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-red-400 text-sm font-mono">
|
<p className="text-red-400 text-sm font-mono">
|
||||||
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
{/requires an index|PERMISSION_DENIED|insufficient permissions/i.test(error)
|
||||||
@@ -233,7 +251,7 @@ export default function AlertsPage() {
|
|||||||
: `Couldn't load alerts: ${error}`}
|
: `Couldn't load alerts: ${error}`}
|
||||||
</p>
|
</p>
|
||||||
) : alerts.length === 0 ? (
|
) : alerts.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No alerts triggered yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No alerts triggered yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
{/* Gate A / A2 (server-26#46) — the Snippet column is transcript text,
|
||||||
@@ -242,10 +260,10 @@ export default function AlertsPage() {
|
|||||||
variant="inline"
|
variant="inline"
|
||||||
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
detail="alerts match against automated transcripts and may fire on, or miss, the wrong words."
|
||||||
/>
|
/>
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<div className="bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
<table className="w-full text-left">
|
<table className="w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800 text-xs text-gray-500 uppercase">
|
<tr className="border-b border-line text-xs text-ink-muted uppercase">
|
||||||
<th className="px-4 py-3">Rule</th>
|
<th className="px-4 py-3">Rule</th>
|
||||||
<th className="px-4 py-3">Talkgroup</th>
|
<th className="px-4 py-3">Talkgroup</th>
|
||||||
<th className="px-4 py-3">Matched</th>
|
<th className="px-4 py-3">Matched</th>
|
||||||
@@ -258,10 +276,10 @@ export default function AlertsPage() {
|
|||||||
{alerts.map((alert) => (
|
{alerts.map((alert) => (
|
||||||
<tr
|
<tr
|
||||||
key={alert.alert_id}
|
key={alert.alert_id}
|
||||||
className={`border-b border-gray-800 ${alert.acknowledged ? "opacity-50" : ""}`}
|
className={`border-b border-line ${alert.acknowledged ? "opacity-50" : ""}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
|
<td className="px-4 py-3 text-white text-sm">{alert.rule_name}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">
|
||||||
{alert.talkgroup_name || alert.talkgroup_id || "—"}
|
{alert.talkgroup_name || alert.talkgroup_id || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -273,17 +291,17 @@ export default function AlertsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono max-w-xs truncate">
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono max-w-xs truncate">
|
||||||
{alert.transcript_snippet || "—"}
|
{alert.transcript_snippet || "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs font-mono">{fmtTime(alert.triggered_at)}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{alert.acknowledged ? (
|
{alert.acknowledged ? (
|
||||||
<span className="text-xs text-gray-500">acked</span>
|
<span className="text-xs text-ink-muted">acked</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAcknowledge(alert.alert_id)}
|
onClick={() => handleAcknowledge(alert.alert_id)}
|
||||||
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-2 py-1 rounded transition-colors"
|
className="text-xs bg-raised hover:bg-gray-700 text-ink-2 px-2 py-1 rounded transition-colors"
|
||||||
>
|
>
|
||||||
Acknowledge
|
Acknowledge
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function ChevronIcon({ open }: { open: boolean }) {
|
|||||||
<svg
|
<svg
|
||||||
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||||
strokeLinecap="round" strokeLinejoin="round"
|
strokeLinecap="round" strokeLinejoin="round"
|
||||||
className={`text-gray-500 shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
className={`text-ink-muted shrink-0 transition-transform ${open ? "rotate-180" : ""}`}
|
||||||
>
|
>
|
||||||
<polyline points="6 9 12 15 18 9" />
|
<polyline points="6 9 12 15 18 9" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -83,10 +83,10 @@ export default function FaqPage() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Badge tone="brand">FAQ</Badge>
|
<Badge tone="brand">FAQ</Badge>
|
||||||
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
<h1 className="text-display-sm md:text-display text-white mt-5">Frequently asked questions</h1>
|
||||||
<p className="text-gray-400 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
<p className="text-ink-2 mt-4">Can't find what you're looking for? Sign in and reach out from your account.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 divide-y divide-gray-800 border-t border-b border-gray-800">
|
<div className="mt-12 divide-y divide-line border-t border-b border-line">
|
||||||
{FAQS.map((item, i) => {
|
{FAQS.map((item, i) => {
|
||||||
const open = openIndex === i;
|
const open = openIndex === i;
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +100,7 @@ export default function FaqPage() {
|
|||||||
<ChevronIcon open={open} />
|
<ChevronIcon open={open} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="text-gray-400 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
|
<div className="text-ink-2 text-sm leading-relaxed pb-5 pr-8 animate-fade-in">{item.a}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function FeaturesPage() {
|
|||||||
<div className="max-w-2xl">
|
<div className="max-w-2xl">
|
||||||
<Badge tone="brand">Features</Badge>
|
<Badge tone="brand">Features</Badge>
|
||||||
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
|
<h1 className="text-display-sm md:text-display text-white mt-5">Everything between the radio and the map</h1>
|
||||||
<p className="text-gray-400 mt-4 leading-relaxed">
|
<p className="text-ink-2 mt-4 leading-relaxed">
|
||||||
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
|
DRB is the pipeline from decoded radio traffic to a picture your team can act on: transcription,
|
||||||
correlation, mapping, and a live relay — end to end.
|
correlation, mapping, and a live relay — end to end.
|
||||||
</p>
|
</p>
|
||||||
@@ -88,12 +88,12 @@ export default function FeaturesPage() {
|
|||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
|
<p className="text-indigo-400 text-xs font-mono uppercase tracking-wider font-semibold">{s.eyebrow}</p>
|
||||||
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
|
<h2 className="text-white text-2xl font-bold mt-2">{s.title}</h2>
|
||||||
<p className="text-gray-400 mt-3 leading-relaxed">{s.body}</p>
|
<p className="text-ink-2 mt-3 leading-relaxed">{s.body}</p>
|
||||||
</div>
|
</div>
|
||||||
<Card padding="lg" className="lg:col-span-3">
|
<Card padding="lg" className="lg:col-span-3">
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{s.points.map((p) => (
|
{s.points.map((p) => (
|
||||||
<li key={p} className="flex items-start gap-3 text-sm text-gray-300">
|
<li key={p} className="flex items-start gap-3 text-sm text-ink-2">
|
||||||
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
|
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" />
|
||||||
{p}
|
{p}
|
||||||
</li>
|
</li>
|
||||||
@@ -107,7 +107,7 @@ export default function FeaturesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center mt-20 pt-16 border-t border-gray-800">
|
<div className="text-center mt-20 pt-16 border-t border-line">
|
||||||
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
|
<h2 className="text-display-sm text-white">See it running on your own traffic</h2>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
<LinkButton href="/login" size="lg">Get started</LinkButton>
|
||||||
|
|||||||
@@ -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: #77839A;
|
--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: #D03B3B;
|
--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,28 @@ 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
|
||||||
|
* 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
|
||||||
|
* the overlays the dark ground their palette was chosen for. */
|
||||||
|
.leaflet-tile-pane .drb-tiles-dark {
|
||||||
|
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. */
|
||||||
@@ -204,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) ─────────────────
|
||||||
@@ -222,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; }
|
||||||
@@ -232,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 }) => (
|
||||||
|
|||||||
@@ -87,30 +87,30 @@ export default function LoginPage() {
|
|||||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
DRB
|
DRB
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
<h1 className="text-white text-lg font-bold">Sign in</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
<label className="text-xs text-ink-2 block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -127,7 +127,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
<span className="text-xs text-gray-500">or</span>
|
<span className="text-xs text-ink-muted">or</span>
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ export default function LoginPage() {
|
|||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-500">
|
<p className="text-center text-xs text-ink-muted">
|
||||||
Don't have an account?{" "}
|
Don't have an account?{" "}
|
||||||
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
<Link href="/signup" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign up</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -125,7 +131,7 @@ export default function NodeDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return <p className="text-gray-500 font-mono text-sm">Loading node…</p>;
|
return <p className="text-ink-muted font-mono text-sm">Loading node…</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const system = systemMap[node.assigned_system_id ?? ""];
|
const system = systemMap[node.assigned_system_id ?? ""];
|
||||||
@@ -135,7 +141,7 @@ export default function NodeDetailPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
|
<h1 className="text-xl font-bold text-white font-mono">{node.name}</h1>
|
||||||
<p className="text-gray-500 text-sm font-mono">{node.node_id}</p>
|
<p className="text-ink-muted text-sm font-mono">{node.node_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +153,7 @@ export default function NodeDetailPage() {
|
|||||||
<span className="text-base">⚠</span>
|
<span className="text-base">⚠</span>
|
||||||
<span>Local System Override Active</span>
|
<span>Local System Override Active</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-xs leading-relaxed">
|
<p className="text-ink-2 text-xs leading-relaxed">
|
||||||
This node is operating on a local system override.
|
This node is operating on a local system override.
|
||||||
{node.override_timeout_at ? (
|
{node.override_timeout_at ? (
|
||||||
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
<> Resets automatically on: <span className="text-white font-bold">{new Date(node.override_timeout_at).toLocaleString()}</span>.</>
|
||||||
@@ -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,12 +191,12 @@ export default function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
Force Revert Config
|
Force Revert Config
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
<div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
|
||||||
{[
|
{[
|
||||||
["System", system?.name ?? "Unassigned"],
|
["System", system?.name ?? "Unassigned"],
|
||||||
["Location", `${node.lat}, ${node.lon}`],
|
["Location", `${node.lat}, ${node.lon}`],
|
||||||
@@ -208,12 +215,23 @@ export default function NodeDetailPage() {
|
|||||||
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
...(node.node_type !== "portable" ? [["Enforce Timeout", node.enforce_override_timeout ? "Yes" : "No"]] : []),
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={label} className="flex justify-between px-4 py-2.5">
|
<div key={label} className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">{label}</span>
|
<span className="text-ink-muted">{label}</span>
|
||||||
<span className="text-gray-200">{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">
|
<div className="flex justify-between px-4 py-2.5">
|
||||||
<span className="text-gray-500">Approval</span>
|
<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} />
|
<ApprovalBadge status={node.approval_status ?? null} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,7 +250,7 @@ export default function NodeDetailPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleReject}
|
onClick={handleReject}
|
||||||
disabled={approving}
|
disabled={approving}
|
||||||
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-gray-300 rounded-lg text-sm font-mono transition-colors"
|
className="px-4 py-2 bg-red-900 hover:bg-red-800 disabled:opacity-50 text-ink-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
@@ -240,25 +258,34 @@ 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">
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowConfig(true)}
|
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"
|
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"}
|
{node.configured ? "Reassign System" : "Configure"}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onClick={() => sendCommand("op25_restart")}
|
onClick={() => sendCommand("op25_restart", "Restart OP25")}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
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
|
Restart OP25
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
disabled={sending}
|
disabled={sending}
|
||||||
onClick={handleRequestUpdate}
|
onClick={handleRequestUpdate}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
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"
|
||||||
>
|
>
|
||||||
Update Node Software
|
Update Node Software
|
||||||
</button>
|
</button>
|
||||||
@@ -283,16 +310,16 @@ export default function NodeDetailPage() {
|
|||||||
|
|
||||||
{/* Recent calls */}
|
{/* Recent calls */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Recent Calls</h2>
|
||||||
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
|
{/* Gate A / A2 (server-26#46) — each row expands to a transcript. */}
|
||||||
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
{nodeCalls.length > 0 && <MachineOutputNotice variant="inline" className="mb-3" />}
|
||||||
{nodeCalls.length === 0 ? (
|
{nodeCalls.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No calls recorded from this node.</p>
|
<p className="text-ink-muted text-sm font-mono">No calls recorded from this node.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
|
||||||
<th className="px-4 py-2 text-left">Time</th>
|
<th className="px-4 py-2 text-left">Time</th>
|
||||||
<th className="px-4 py-2 text-left">Talkgroup</th>
|
<th className="px-4 py-2 text-left">Talkgroup</th>
|
||||||
<th className="px-4 py-2 text-left">System</th>
|
<th className="px-4 py-2 text-left">System</th>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function NodesPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleUpdateAll}
|
onClick={handleUpdateAll}
|
||||||
disabled={updatingAll}
|
disabled={updatingAll}
|
||||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg text-sm font-mono transition-colors disabled:opacity-50"
|
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"
|
||||||
>
|
>
|
||||||
{updatingAll ? "Requesting…" : "Update All Nodes"}
|
{updatingAll ? "Requesting…" : "Update All Nodes"}
|
||||||
</button>
|
</button>
|
||||||
@@ -79,22 +79,28 @@ 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) => (
|
||||||
|
// 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">
|
<div key={n.node_id} onClick={() => setConfigNode(n)} className="cursor-pointer">
|
||||||
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
<NodeCard node={n} system={systemMap[n.assigned_system_id ?? ""]} linkToDetail={false} />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider">
|
||||||
All Nodes ({nodes.length})
|
All Nodes ({nodes.length})
|
||||||
</h2>
|
</h2>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : nodes.length === 0 ? (
|
) : nodes.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
|
<p className="text-ink-muted text-sm font-mono">No nodes registered yet. Boot a Pi to get started.</p>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
||||||
{nodes.map((n) => (
|
{nodes.map((n) => (
|
||||||
|
|||||||
@@ -55,17 +55,17 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm mx-auto pt-16">
|
<div className="max-w-sm mx-auto pt-16">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
|
<h1 className="text-white text-lg font-bold">Set up your organization</h1>
|
||||||
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
|
<p className="text-ink-2 text-xs mt-2 leading-relaxed">
|
||||||
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
|
One more step — name the organization your nodes, calls, and incidents will belong to. You can change this later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
<label className="text-xs text-ink-2 block mb-1">Organization name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={orgName}
|
value={orgName}
|
||||||
@@ -73,7 +73,7 @@ export default function OnboardingPage() {
|
|||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
placeholder="e.g. Riverside County Scanner"
|
placeholder="e.g. Riverside County Scanner"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function MarketingHomePage() {
|
|||||||
<h1 className="text-display-sm md:text-display mt-5 text-white">
|
<h1 className="text-display-sm md:text-display mt-5 text-white">
|
||||||
See what's happening on the radio, as an incident — not a wall of calls.
|
See what's happening on the radio, as an incident — not a wall of calls.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-400 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
<p className="text-ink-2 text-base md:text-lg mt-5 max-w-2xl leading-relaxed">
|
||||||
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
|
DRB turns field SDR nodes into a live public-safety picture: police/fire radio is decoded, transcribed,
|
||||||
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
|
and correlated into incidents you can watch on a map or scrub back through in history — with a Discord
|
||||||
bot to relay the audio live to your team.
|
bot to relay the audio live to your team.
|
||||||
@@ -68,7 +68,7 @@ function MarketingHomePage() {
|
|||||||
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<section className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
<div className="max-w-2xl mb-10">
|
<div className="max-w-2xl mb-10">
|
||||||
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
<h2 className="text-display-sm text-white">The unit of value is the incident</h2>
|
||||||
<p className="text-gray-400 mt-3">
|
<p className="text-ink-2 mt-3">
|
||||||
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
|
A scanner feed is noise. DRB's job is to turn that noise into a small number of things you actually care
|
||||||
about — and let you click into any one of them for the full picture.
|
about — and let you click into any one of them for the full picture.
|
||||||
</p>
|
</p>
|
||||||
@@ -77,14 +77,14 @@ function MarketingHomePage() {
|
|||||||
{CAPABILITIES.map((c) => (
|
{CAPABILITIES.map((c) => (
|
||||||
<Card key={c.title} padding="lg" hover>
|
<Card key={c.title} padding="lg" hover>
|
||||||
<h3 className="text-white font-semibold">{c.title}</h3>
|
<h3 className="text-white font-semibold">{c.title}</h3>
|
||||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{c.body}</p>
|
<p className="text-ink-2 text-sm mt-2 leading-relaxed">{c.body}</p>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<section className="border-t border-gray-800">
|
<section className="border-t border-line">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
<h2 className="text-display-sm text-white mb-10">How it works</h2>
|
<h2 className="text-display-sm text-white mb-10">How it works</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
@@ -92,7 +92,7 @@ function MarketingHomePage() {
|
|||||||
<div key={s.n}>
|
<div key={s.n}>
|
||||||
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
<p className="text-indigo-400 font-mono text-sm font-bold">{s.n}</p>
|
||||||
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
|
<h3 className="text-white font-semibold mt-2">{s.title}</h3>
|
||||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">{s.body}</p>
|
<p className="text-ink-2 text-sm mt-2 leading-relaxed">{s.body}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,12 +100,12 @@ function MarketingHomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
|
{/* Pricing — Gate A (minutes #42/#62): no price on a public surface. */}
|
||||||
<section className="border-t border-gray-800">
|
<section className="border-t border-line">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-display-sm text-white">Pricing is in development</h2>
|
<h2 className="text-display-sm text-white">Pricing is in development</h2>
|
||||||
<p className="text-gray-400 mt-2 max-w-xl">
|
<p className="text-ink-2 mt-2 max-w-xl">
|
||||||
We would rather talk to you about what you need than post a number we would have to
|
We would rather talk to you about what you need than post a number we would have to
|
||||||
walk back. Tell us about your coverage area and we will figure it out together.
|
walk back. Tell us about your coverage area and we will figure it out together.
|
||||||
</p>
|
</p>
|
||||||
@@ -118,10 +118,10 @@ function MarketingHomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Final CTA */}
|
{/* Final CTA */}
|
||||||
<section className="border-t border-gray-800">
|
<section className="border-t border-line">
|
||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20 text-center">
|
||||||
<h2 className="text-display-sm text-white">Bring your first node online</h2>
|
<h2 className="text-display-sm text-white">Bring your first node online</h2>
|
||||||
<p className="text-gray-400 mt-3 max-w-xl mx-auto">
|
<p className="text-ink-2 mt-3 max-w-xl mx-auto">
|
||||||
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
|
Tell us about your coverage area. We will get you a node online and you will see incidents within minutes of your first call.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function PricingPage() {
|
|||||||
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
<div className="max-w-screen-xl mx-auto px-4 md:px-6 py-16 md:py-20">
|
||||||
<div className="text-center max-w-2xl mx-auto">
|
<div className="text-center max-w-2xl mx-auto">
|
||||||
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
|
<h1 className="text-display-sm md:text-display text-white">Pricing is in development</h1>
|
||||||
<p className="text-gray-400 mt-4">
|
<p className="text-ink-2 mt-4">
|
||||||
We are still working out what a fair price looks like for the people who actually use this.
|
We are still working out what a fair price looks like for the people who actually use this.
|
||||||
Rather than post a number we would have to walk back, we would rather talk to you about what
|
Rather than post a number we would have to walk back, we would rather talk to you about what
|
||||||
you need and what it is worth.
|
you need and what it is worth.
|
||||||
@@ -24,7 +24,7 @@ export default function PricingPage() {
|
|||||||
|
|
||||||
<Card padding="lg" className="mt-12 max-w-2xl mx-auto">
|
<Card padding="lg" className="mt-12 max-w-2xl mx-auto">
|
||||||
<h2 className="text-white text-lg font-bold">What you get today</h2>
|
<h2 className="text-white text-lg font-bold">What you get today</h2>
|
||||||
<p className="text-gray-400 text-sm mt-2 leading-relaxed">
|
<p className="text-ink-2 text-sm mt-2 leading-relaxed">
|
||||||
The full incident pipeline — transcription, correlation into incidents, mapping, and the
|
The full incident pipeline — transcription, correlation into incidents, mapping, and the
|
||||||
Discord relay. Node counts, seats, and history length are set per account while we work out
|
Discord relay. Node counts, seats, and history length are set per account while we work out
|
||||||
the plan structure.
|
the plan structure.
|
||||||
@@ -35,7 +35,7 @@ export default function PricingPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="text-center mt-16">
|
<div className="text-center mt-16">
|
||||||
<p className="text-gray-400">
|
<p className="text-ink-2">
|
||||||
Questions?{" "}
|
Questions?{" "}
|
||||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link>
|
||||||
{" "}or{" "}
|
{" "}or{" "}
|
||||||
|
|||||||
@@ -63,18 +63,18 @@ export default function PrivacyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-display-sm text-white">Privacy Policy</h1>
|
<h1 className="text-display-sm text-white">Privacy Policy</h1>
|
||||||
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
<p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
||||||
|
|
||||||
<div className="mt-10 space-y-8">
|
<div className="mt-10 space-y-8">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<section key={s.heading}>
|
<section key={s.heading}>
|
||||||
<h2 className="text-white font-semibold">{s.heading}</h2>
|
<h2 className="text-white font-semibold">{s.heading}</h2>
|
||||||
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
<p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-xs font-mono mt-16">
|
<p className="text-ink-muted text-xs font-mono mt-16">
|
||||||
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
||||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -92,13 +92,13 @@ export default function ProfilePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
<h1 className="text-white text-xl font-bold">{displayName}</h1>
|
||||||
{user.displayName && user.email && (
|
{user.displayName && user.email && (
|
||||||
<p className="text-gray-400 text-sm mt-0.5">{user.email}</p>
|
<p className="text-ink-2 text-sm mt-0.5">{user.email}</p>
|
||||||
)}
|
)}
|
||||||
{role && (
|
{role && (
|
||||||
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
<span className={`inline-block mt-1 text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||||
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
role === "admin" ? "bg-indigo-900 text-indigo-300" :
|
||||||
role === "operator" ? "bg-green-900 text-green-300" :
|
role === "operator" ? "bg-green-900 text-green-300" :
|
||||||
"bg-gray-800 text-gray-400"
|
"bg-raised text-ink-2"
|
||||||
}`}>
|
}`}>
|
||||||
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
{role === "admin" ? "Admin" : role === "operator" ? "Operator" : "Viewer"}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,9 +107,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Firebase account */}
|
{/* Firebase account */}
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<section className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-2">Account</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-2">Account</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Row label="Email" value={user.email ?? "—"} />
|
<Row label="Email" value={user.email ?? "—"} />
|
||||||
<Row label="UID" value={user.uid} mono truncate />
|
<Row label="UID" value={user.uid} mono truncate />
|
||||||
@@ -125,12 +125,12 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Discord linking */}
|
{/* Discord linking */}
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<section className="bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono mb-3">Discord</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono mb-3">Discord</p>
|
||||||
|
|
||||||
{linkLoading ? (
|
{linkLoading ? (
|
||||||
<p className="text-gray-500 text-sm">Loading…</p>
|
<p className="text-ink-muted text-sm">Loading…</p>
|
||||||
) : linkStatus?.linked ? (
|
) : linkStatus?.linked ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -156,21 +156,21 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-ink-2">
|
||||||
Link your Discord account to access private trips from both the web and Discord.
|
Link your Discord account to access private trips from both the web and Discord.
|
||||||
</p>
|
</p>
|
||||||
{code ? (
|
{code ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="bg-gray-800 rounded-lg px-4 py-3 flex items-center gap-3">
|
<div className="bg-raised rounded-lg px-4 py-3 flex items-center gap-3">
|
||||||
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
|
<span className="font-mono text-2xl tracking-[0.4em] text-white select-all">{code}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-ink-2">
|
||||||
Run <span className="font-mono text-gray-200">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
Run <span className="font-mono text-ink">/link {code}</span> in Discord. Code expires in {codeExpiry} minutes.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={generateCode}
|
onClick={generateCode}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-50"
|
className="text-xs text-ink-muted hover:text-ink transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Generate new code
|
Generate new code
|
||||||
</button>
|
</button>
|
||||||
@@ -190,9 +190,9 @@ export default function ProfilePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Sign out */}
|
{/* Sign out */}
|
||||||
<section className="bg-gray-900 border border-gray-800 rounded-xl">
|
<section className="bg-surface border border-line rounded-xl">
|
||||||
<div className="px-4 py-3 flex items-center justify-between">
|
<div className="px-4 py-3 flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-400">Sign out of this device</p>
|
<p className="text-sm text-ink-2">Sign out of this device</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
className="text-sm text-red-500 hover:text-red-400 transition-colors"
|
className="text-sm text-red-500 hover:text-red-400 transition-colors"
|
||||||
@@ -213,8 +213,8 @@ function Row({ label, value, mono = false, truncate = false }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<span className="text-xs text-gray-500 shrink-0">{label}</span>
|
<span className="text-xs text-ink-muted shrink-0">{label}</span>
|
||||||
<span className={`text-sm text-gray-200 text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
<span className={`text-sm text-ink text-right ${mono ? "font-mono text-xs" : ""} ${truncate ? "truncate max-w-[200px]" : ""}`}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,11 +39,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
<Card padding="lg" className="w-full max-w-lg space-y-4">
|
||||||
<h2 className="text-white font-semibold">Key created</h2>
|
<h2 className="text-white font-semibold">Key created</h2>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-ink-2">
|
||||||
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
Copy this key now — it won't be shown again. This is a sample key from the demo module in{" "}
|
||||||
<code className="text-gray-300">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
<code className="text-ink-2">lib/apiKeys.ts</code>; it doesn't authenticate against anything.
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||||
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
<p className="text-xs text-indigo-300 break-all font-mono">{rawKey}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -61,11 +61,11 @@ function CreateKeyModal({ onClose, onCreated }: { onClose: () => void; onCreated
|
|||||||
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
<h2 className="text-white font-semibold mb-4">New API key</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
<label className="text-xs text-ink-2 block mb-1">Label</label>
|
||||||
<input
|
<input
|
||||||
required value={name} onChange={(e) => setName(e.target.value)}
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="e.g. Ops dashboard integration"
|
placeholder="e.g. Ops dashboard integration"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -96,9 +96,9 @@ export default function ApiKeysSettingsPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
<div className="bg-indigo-600/10 border border-indigo-600/40 rounded-xl p-4">
|
||||||
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
<p className="text-indigo-300 text-sm font-semibold">Preview feature</p>
|
||||||
<p className="text-gray-400 text-xs mt-1 leading-relaxed">
|
<p className="text-ink-2 text-xs mt-1 leading-relaxed">
|
||||||
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
Organization API keys aren't backed by a real endpoint yet — this screen runs against an in-memory
|
||||||
demo module (<code className="text-gray-300">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
demo module (<code className="text-ink-2">lib/apiKeys.ts</code>) so the flow can be reviewed end to
|
||||||
end. See that file for the exact backend routes a real integration needs.
|
end. See that file for the exact backend routes a real integration needs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +108,7 @@ export default function ApiKeysSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-500">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
<p className="text-sm text-ink-muted">{loading ? "Loading…" : `${active.length} active key${active.length !== 1 ? "s" : ""}`}</p>
|
||||||
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
<Button size="sm" onClick={() => setShowCreate(true)}>+ Create key</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ export default function ApiKeysSettingsPage() {
|
|||||||
<Card padding="none" className="overflow-hidden">
|
<Card padding="none" className="overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
||||||
<th className="px-4 py-3 text-left">Label</th>
|
<th className="px-4 py-3 text-left">Label</th>
|
||||||
<th className="px-4 py-3 text-left">Key</th>
|
<th className="px-4 py-3 text-left">Key</th>
|
||||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Created</th>
|
||||||
@@ -129,11 +129,11 @@ export default function ApiKeysSettingsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{keys.map((k) => (
|
{keys.map((k) => (
|
||||||
<tr key={k.key_id} className="border-b border-gray-800 last:border-0">
|
<tr key={k.key_id} className="border-b border-line last:border-0">
|
||||||
<td className="px-4 py-3 text-white">{k.name}</td>
|
<td className="px-4 py-3 text-white">{k.name}</td>
|
||||||
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{k.key_prefix}…</td>
|
<td className="px-4 py-3 text-ink-muted font-mono text-xs">{k.key_prefix}…</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{fmtDate(k.created_at)}</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">{k.last_used_at ? fmtDate(k.last_used_at) : "Never"}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
{k.revoked ? <Badge tone="danger">Revoked</Badge> : <Badge tone="success">Active</Badge>}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ function UsageBar({ label, used, limit }: { label: string; used: number; limit:
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-baseline justify-between mb-1.5">
|
<div className="flex items-baseline justify-between mb-1.5">
|
||||||
<span className="text-xs text-gray-400 font-mono">{label}</span>
|
<span className="text-xs text-ink-2 font-mono">{label}</span>
|
||||||
<span className="text-xs font-mono text-gray-300">
|
<span className="text-xs font-mono text-ink-2">
|
||||||
{used} / {limit === "unlimited" ? "∞" : limit}
|
{used} / {limit === "unlimited" ? "∞" : limit}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-gray-800 overflow-hidden">
|
<div className="h-2 rounded-full bg-raised overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
className={`h-full rounded-full transition-all ${nearLimit ? "bg-orange-500" : "bg-indigo-500"}`}
|
||||||
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
style={{ width: limit === "unlimited" ? "8%" : `${pct}%` }}
|
||||||
@@ -142,7 +142,7 @@ export default function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<p className="text-xs text-gray-600 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
Demo data — this page isn't connected to a live payment processor. See lib/billing.ts for the integration plan.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ export default function BillingSettingsPage() {
|
|||||||
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
<UsageBar label="Seats" used={usage.seatsUsed} limit={usage.seatsLimit} />
|
||||||
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
<UsageBar label="Nodes" used={usage.nodesUsed} limit={usage.nodesLimit} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 pt-5 border-t border-gray-800">
|
<div className="mt-5 pt-5 border-t border-line">
|
||||||
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
<Button variant="secondary" size="sm" onClick={handleManageBilling} disabled={portalBusy}>
|
||||||
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
{portalBusy ? "Opening…" : "Manage payment method & invoices"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -179,10 +179,10 @@ export default function BillingSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-gray-800"}`}
|
className={`rounded-xl border p-4 flex flex-col ${p.highlighted ? "border-indigo-600/40" : "border-line"}`}
|
||||||
>
|
>
|
||||||
<p className="text-white font-semibold text-sm">{p.name}</p>
|
<p className="text-white font-semibold text-sm">{p.name}</p>
|
||||||
<p className="text-gray-500 text-xs mt-1 flex-1">{p.tagline}</p>
|
<p className="text-ink-muted text-xs mt-1 flex-1">{p.tagline}</p>
|
||||||
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
|
{(UNBUILT_CLAIMS[p.id] ?? []).length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
{(UNBUILT_CLAIMS[p.id] ?? []).map((claim) => (
|
||||||
@@ -212,17 +212,17 @@ export default function BillingSettingsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="Invoice history" />
|
<CardHeader title="Invoice history" />
|
||||||
{invoices.length === 0 ? (
|
{invoices.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm">No invoices yet.</p>
|
<p className="text-ink-muted text-sm">No invoices yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-800">
|
<div className="divide-y divide-line">
|
||||||
{invoices.map((inv) => (
|
{invoices.map((inv) => (
|
||||||
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
<div key={inv.id} className="flex items-center justify-between gap-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-gray-200 text-sm">{inv.description}</p>
|
<p className="text-ink text-sm">{inv.description}</p>
|
||||||
<p className="text-gray-600 text-xs font-mono">{fmtDate(inv.date)}</p>
|
<p className="text-ink-muted text-xs font-mono">{fmtDate(inv.date)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<span className="text-gray-300 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
<span className="text-ink-2 text-sm font-mono">${inv.amountUsd.toFixed(2)}</span>
|
||||||
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
<Badge tone={INVOICE_TONE[inv.status]}>{inv.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||||||
description="Organization profile, team access, node ownership, API keys, and billing."
|
description="Organization profile, team access, node ownership, API keys, and billing."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1 bg-gray-900 border border-gray-800 rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
<div className="flex flex-wrap gap-1 bg-surface border border-line rounded-lg p-1 w-fit max-w-full overflow-x-auto">
|
||||||
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
|
{[...TABS, ...(isAdmin ? ADMIN_TABS : [])].map((t) => (
|
||||||
<Link
|
<Link
|
||||||
key={t.href}
|
key={t.href}
|
||||||
href={t.href}
|
href={t.href}
|
||||||
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
className={`text-sm font-mono px-4 py-1.5 rounded-md transition-colors whitespace-nowrap ${
|
||||||
pathname === t.href || pathname.startsWith(t.href + "/")
|
pathname === t.href || pathname.startsWith(t.href + "/")
|
||||||
? "bg-gray-800 text-white"
|
? "bg-raised text-white"
|
||||||
: "text-gray-500 hover:text-gray-300"
|
: "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
|
|||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<Card padding="lg" className="w-full max-w-md space-y-4">
|
<Card padding="lg" className="w-full max-w-md space-y-4">
|
||||||
<h2 className="text-white font-semibold">Member invited</h2>
|
<h2 className="text-white font-semibold">Member invited</h2>
|
||||||
<p className="text-xs text-gray-400">Share this one-time invite link so they can set their password. It expires after use.</p>
|
<p className="text-xs text-ink-2">Share this one-time invite link so they can set their password. It expires after use.</p>
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-3">
|
<div className="bg-raised border border-line-strong rounded-lg p-3">
|
||||||
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
<p className="text-xs text-indigo-300 break-all">{inviteLink}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onClose} fullWidth>Done</Button>
|
<Button onClick={onClose} fullWidth>Done</Button>
|
||||||
@@ -62,18 +62,18 @@ function InviteModal({ onClose, onCreated }: { onClose: () => void; onCreated: (
|
|||||||
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
<h2 className="text-white font-semibold mb-4">Invite a member</h2>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="teammate@example.com"
|
placeholder="teammate@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Role</label>
|
<label className="text-xs text-ink-2 block mb-1">Role</label>
|
||||||
<select
|
<select
|
||||||
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
value={role} onChange={(e) => setRole(e.target.value as UserRole)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
<option value="admin">Admin — full access</option>
|
<option value="admin">Admin — full access</option>
|
||||||
<option value="operator">Operator — owns nodes</option>
|
<option value="operator">Operator — owns nodes</option>
|
||||||
@@ -130,7 +130,7 @@ export default function MembersSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-ink-muted">
|
||||||
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
{loading ? "Loading members…" : `${users.length} member${users.length !== 1 ? "s" : ""}`}
|
||||||
</p>
|
</p>
|
||||||
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
<Button size="sm" onClick={() => setShowInvite(true)}>+ Invite member</Button>
|
||||||
@@ -144,7 +144,7 @@ export default function MembersSettingsPage() {
|
|||||||
<Card padding="none" className="overflow-hidden">
|
<Card padding="none" className="overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
||||||
<th className="px-4 py-3 text-left">Member</th>
|
<th className="px-4 py-3 text-left">Member</th>
|
||||||
<th className="px-4 py-3 text-left">Role</th>
|
<th className="px-4 py-3 text-left">Role</th>
|
||||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Owned nodes</th>
|
||||||
@@ -155,10 +155,10 @@ export default function MembersSettingsPage() {
|
|||||||
{loading
|
{loading
|
||||||
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
? Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={4} />)
|
||||||
: users.map((u) => (
|
: users.map((u) => (
|
||||||
<tr key={u.uid} className="border-b border-gray-800 last:border-0">
|
<tr key={u.uid} className="border-b border-line last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<p className="text-white">{u.display_name || u.email}</p>
|
<p className="text-white">{u.display_name || u.email}</p>
|
||||||
{u.display_name && <p className="text-gray-600 text-xs">{u.email}</p>}
|
{u.display_name && <p className="text-ink-muted text-xs">{u.email}</p>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{u.uid === user?.uid ? (
|
{u.uid === user?.uid ? (
|
||||||
@@ -168,7 +168,7 @@ export default function MembersSettingsPage() {
|
|||||||
value={u.role}
|
value={u.role}
|
||||||
disabled={savingUid === u.uid}
|
disabled={savingUid === u.uid}
|
||||||
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
onChange={(e) => handleRoleChange(u, e.target.value as UserRole)}
|
||||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
className="bg-raised border border-line-strong rounded-lg px-2 py-1 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
<option value="operator">Operator</option>
|
<option value="operator">Operator</option>
|
||||||
@@ -176,7 +176,7 @@ export default function MembersSettingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-gray-400 text-xs hidden sm:table-cell">
|
<td className="px-4 py-3 text-ink-2 text-xs hidden sm:table-cell">
|
||||||
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
{u.role === "operator" ? u.owned_node_ids.length : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 hidden md:table-cell">
|
<td className="px-4 py-3 hidden md:table-cell">
|
||||||
@@ -189,7 +189,7 @@ export default function MembersSettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-600 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
Need to disable or delete a member? Use the full user admin panel under Admin → Users.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function EnrollmentTokensPanel() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-gray-900 rounded px-2 py-1.5">{justMinted}</p>
|
<p className="flex-1 text-xs text-indigo-100 font-mono break-all bg-surface rounded px-2 py-1.5">{justMinted}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
onClick={() => navigator.clipboard?.writeText(justMinted).then(() => {
|
||||||
@@ -133,7 +133,7 @@ function EnrollmentTokensPanel() {
|
|||||||
: <>(check the broker host)</>}:
|
: <>(check the broker host)</>}:
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-gray-900 rounded px-2 py-1.5">{installCmd}</pre>
|
<pre className="flex-1 text-xs text-indigo-100 font-mono whitespace-pre-wrap break-all bg-surface rounded px-2 py-1.5">{installCmd}</pre>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
onClick={() => navigator.clipboard?.writeText(installCmd).then(() => {
|
||||||
@@ -163,7 +163,7 @@ function EnrollmentTokensPanel() {
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="Node ID, e.g. node-003"
|
placeholder="Node ID, e.g. node-003"
|
||||||
className="flex-1 min-w-[12rem] bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="flex-1 min-w-[12rem] bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
<Button type="submit" size="sm" disabled={minting || !label.trim()}>
|
||||||
{minting ? "Minting…" : "New token"}
|
{minting ? "Minting…" : "New token"}
|
||||||
@@ -176,11 +176,11 @@ function EnrollmentTokensPanel() {
|
|||||||
<SkeletonRow cols={1} />
|
<SkeletonRow cols={1} />
|
||||||
</div>
|
</div>
|
||||||
) : tokens.length === 0 ? (
|
) : tokens.length === 0 ? (
|
||||||
<p className="text-gray-600 text-xs">No enrollment tokens yet.</p>
|
<p className="text-ink-muted text-xs">No enrollment tokens yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line">
|
||||||
<th className="py-2 text-left">Label</th>
|
<th className="py-2 text-left">Label</th>
|
||||||
<th className="py-2 text-left hidden sm:table-cell">Created</th>
|
<th className="py-2 text-left hidden sm:table-cell">Created</th>
|
||||||
<th className="py-2 text-left">Status</th>
|
<th className="py-2 text-left">Status</th>
|
||||||
@@ -189,14 +189,14 @@ function EnrollmentTokensPanel() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tokens.map((t) => (
|
{tokens.map((t) => (
|
||||||
<tr key={t.token_id} className="border-b border-gray-800 last:border-0">
|
<tr key={t.token_id} className="border-b border-line last:border-0">
|
||||||
<td className="py-2 text-white">{t.label}</td>
|
<td className="py-2 text-white">{t.label}</td>
|
||||||
<td className="py-2 text-gray-500 text-xs hidden sm:table-cell">
|
<td className="py-2 text-ink-muted text-xs hidden sm:table-cell">
|
||||||
{new Date(t.created_at).toLocaleDateString()}
|
{new Date(t.created_at).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs">
|
<td className="py-2 text-xs">
|
||||||
{t.revoked ? (
|
{t.revoked ? (
|
||||||
<span className="text-gray-600">Revoked</span>
|
<span className="text-ink-muted">Revoked</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
|
<span className="text-green-400">Active · {t.uses} use{t.uses !== 1 ? "s" : ""}</span>
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +283,7 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
<EnrollmentTokensPanel />
|
<EnrollmentTokensPanel />
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-ink-muted">
|
||||||
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
Assign each node to the operator responsible for it. Operators only see and manage the nodes assigned to them here.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
<Card padding="none" className="overflow-hidden">
|
<Card padding="none" className="overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800 bg-gray-900">
|
<tr className="text-xs text-ink-muted uppercase tracking-wider border-b border-line bg-surface">
|
||||||
<th className="px-4 py-3 text-left">Node</th>
|
<th className="px-4 py-3 text-left">Node</th>
|
||||||
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
|
<th className="px-4 py-3 text-left hidden sm:table-cell">Status</th>
|
||||||
<th className="px-4 py-3 text-left">Owner</th>
|
<th className="px-4 py-3 text-left">Owner</th>
|
||||||
@@ -302,17 +302,17 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
Array.from({ length: 3 }).map((_, i) => <SkeletonRow key={i} cols={3} />)
|
||||||
) : nodes.length === 0 ? (
|
) : nodes.length === 0 ? (
|
||||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-gray-600 text-sm">No nodes registered yet.</td></tr>
|
<tr><td colSpan={3} className="px-4 py-8 text-center text-ink-muted text-sm">No nodes registered yet.</td></tr>
|
||||||
) : (
|
) : (
|
||||||
nodes.map((n) => {
|
nodes.map((n) => {
|
||||||
const owner = ownerByNode.get(n.node_id);
|
const owner = ownerByNode.get(n.node_id);
|
||||||
return (
|
return (
|
||||||
<tr key={n.node_id} className="border-b border-gray-800 last:border-0">
|
<tr key={n.node_id} className="border-b border-line last:border-0">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
<Link href={`/nodes/${n.node_id}`} className="text-white hover:text-indigo-300 transition-colors">
|
||||||
{n.name}
|
{n.name}
|
||||||
</Link>
|
</Link>
|
||||||
<p className="text-gray-600 text-xs font-mono">{n.node_id}</p>
|
<p className="text-ink-muted text-xs font-mono">{n.node_id}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
<td className="px-4 py-3 hidden sm:table-cell"><StatusBadge status={n.status} /></td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
@@ -320,7 +320,7 @@ export default function NodeOwnershipSettingsPage() {
|
|||||||
value={owner?.uid ?? UNASSIGNED}
|
value={owner?.uid ?? UNASSIGNED}
|
||||||
disabled={savingNodeId === n.node_id}
|
disabled={savingNodeId === n.node_id}
|
||||||
onChange={(e) => reassign(n.node_id, e.target.value)}
|
onChange={(e) => reassign(n.node_id, e.target.value)}
|
||||||
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
|
className="bg-raised border border-line-strong rounded-lg px-2 py-1.5 text-xs text-white focus:outline-none focus:border-indigo-500 disabled:opacity-50 max-w-[14rem]"
|
||||||
>
|
>
|
||||||
<option value={UNASSIGNED}>Unassigned</option>
|
<option value={UNASSIGNED}>Unassigned</option>
|
||||||
{assignable.map((u) => (
|
{assignable.map((u) => (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { PreferredBotsCard } from "@/components/discord/PreferredBotsCard";
|
|||||||
function StatTile({ label, value }: { label: string; value: string | number }) {
|
function StatTile({ label, value }: { label: string; value: string | number }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">{label}</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono">{label}</p>
|
||||||
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
<p className="text-2xl font-bold text-white font-mono mt-1">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -69,12 +69,12 @@ export default function OrganizationSettingsPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Organization name</label>
|
<label className="text-xs text-ink-2 block mb-1">Organization name</label>
|
||||||
<input
|
<input
|
||||||
value={orgName}
|
value={orgName}
|
||||||
disabled={orgLoading || !canEdit}
|
disabled={orgLoading || !canEdit}
|
||||||
onChange={(e) => setOrgName(e.target.value)}
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
|
{saveError && <p className="text-red-400 text-xs">{saveError}</p>}
|
||||||
@@ -110,7 +110,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
value={sub ? sub.status.replace("_", " ") : "—"}
|
value={sub ? sub.status.replace("_", " ") : "—"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 pt-5 border-t border-gray-800 flex items-center gap-4 text-sm">
|
<div className="mt-5 pt-5 border-t border-line flex items-center gap-4 text-sm">
|
||||||
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
<Link href="/settings/billing" className="text-indigo-400 hover:text-indigo-300 transition-colors">
|
||||||
Manage plan & billing →
|
Manage plan & billing →
|
||||||
</Link>
|
</Link>
|
||||||
@@ -124,7 +124,7 @@ export default function OrganizationSettingsPage() {
|
|||||||
|
|
||||||
<PreferredBotsCard canEdit={canEdit} />
|
<PreferredBotsCard canEdit={canEdit} />
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-red-800/60 rounded-xl p-5">
|
<div className="bg-surface border border-red-800/60 rounded-xl p-5">
|
||||||
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
<CardHeader title="Danger zone" subtitle="Destructive organization-level actions." />
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
<Button variant="danger" size="sm" disabled title="Not available in this build — contact support">
|
||||||
|
|||||||
@@ -61,23 +61,23 @@ export default function SignupPage() {
|
|||||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
DRB
|
DRB
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
<h1 className="text-white text-lg font-bold">Create your account</h1>
|
<h1 className="text-white text-lg font-bold">Create your account</h1>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Password</label>
|
<label className="text-xs text-ink-2 block mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -85,7 +85,7 @@ export default function SignupPage() {
|
|||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -102,7 +102,7 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
<span className="text-xs text-gray-500">or</span>
|
<span className="text-xs text-ink-muted">or</span>
|
||||||
<div className="flex-1 h-px bg-gray-700" />
|
<div className="flex-1 h-px bg-gray-700" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export default function SignupPage() {
|
|||||||
Continue with Google
|
Continue with Google
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-500">
|
<p className="text-center text-xs text-ink-muted">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">Sign in</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+127
-116
@@ -438,17 +438,17 @@ function RRImportModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl w-full max-w-lg max-h-[90vh] flex flex-col font-mono">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-5 pt-5 pb-4 border-b border-gray-800">
|
<div className="px-5 pt-5 pb-4 border-b border-line">
|
||||||
<h2 className="text-white font-semibold">{system.name}</h2>
|
<h2 className="text-white font-semibold">{system.name}</h2>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-ink-muted mt-0.5">
|
||||||
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
{system.systemType}{system.location ? ` · ${system.location}` : ""}
|
||||||
</p>
|
</p>
|
||||||
{system.sysIds && (
|
{system.sysIds && (
|
||||||
<p className="text-xs text-gray-600 mt-0.5">System IDs: {system.sysIds}</p>
|
<p className="text-xs text-ink-muted mt-0.5">System IDs: {system.sysIds}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-ink-muted mt-1">
|
||||||
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
{system.categories.length} categor{system.categories.length !== 1 ? "ies" : "y"} · {total} talkgroups
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -456,7 +456,7 @@ function RRImportModal({
|
|||||||
{/* Category list */}
|
{/* Category list */}
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1.5">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="text-xs text-gray-400 uppercase tracking-wider">Talkgroup Categories</p>
|
<p className="text-xs text-ink-2 uppercase tracking-wider">Talkgroup Categories</p>
|
||||||
<div className="flex gap-3 text-xs text-indigo-400">
|
<div className="flex gap-3 text-xs text-indigo-400">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -480,18 +480,18 @@ function RRImportModal({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected.has(cat.name)}
|
checked={selected.has(cat.name)}
|
||||||
onChange={() => toggle(cat.name)}
|
onChange={() => toggle(cat.name)}
|
||||||
className="rounded border-gray-600 bg-gray-800 accent-indigo-500"
|
className="rounded border-line-strong bg-raised accent-indigo-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-200 flex-1 group-hover:text-white transition-colors truncate">
|
<span className="text-sm text-ink flex-1 group-hover:text-white transition-colors truncate">
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-gray-600 shrink-0">{cat.talkgroups.length} TGs</span>
|
<span className="text-xs text-ink-muted shrink-0">{cat.talkgroups.length} TGs</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="px-5 pb-5 pt-4 border-t border-gray-800 flex gap-3">
|
<div className="px-5 pb-5 pt-4 border-t border-line flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
@@ -503,7 +503,7 @@ function RRImportModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -533,10 +533,10 @@ function LocalKnowledgeField({
|
|||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const cls =
|
const cls =
|
||||||
"w-full mt-0.5 bg-gray-900 border border-gray-700 rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
|
"w-full mt-0.5 bg-surface border border-line-strong rounded px-2 py-1 text-white text-xs focus:outline-none focus:border-indigo-500";
|
||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-xs text-gray-500 font-sans">{label}</span>
|
<span className="text-xs text-ink-muted font-sans">{label}</span>
|
||||||
{multiline ? (
|
{multiline ? (
|
||||||
<textarea
|
<textarea
|
||||||
value={value}
|
value={value}
|
||||||
@@ -656,8 +656,8 @@ function TalkgroupEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-gray-400">
|
<label className="text-xs text-ink-2">
|
||||||
Talkgroups{talkgroups.length > 0 && <span className="text-gray-600 ml-1">({talkgroups.length})</span>}
|
Talkgroups{talkgroups.length > 0 && <span className="text-ink-muted ml-1">({talkgroups.length})</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* RadioReference file import */}
|
{/* RadioReference file import */}
|
||||||
@@ -704,10 +704,10 @@ function TalkgroupEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showPaste && (
|
{showPaste && (
|
||||||
<div className="space-y-2 p-3 bg-gray-800 rounded-lg border border-gray-700">
|
<div className="space-y-2 p-3 bg-raised rounded-lg border border-line-strong">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-ink-muted">
|
||||||
Paste rows from RadioReference — tab- or comma-separated:{" "}
|
Paste rows from RadioReference — tab- or comma-separated:{" "}
|
||||||
<span className="text-gray-400">ID, Name, Tag</span>
|
<span className="text-ink-2">ID, Name, Tag</span>
|
||||||
<br />Tags: fire · police · ems · transit · public works · other
|
<br />Tags: fire · police · ems · transit · public works · other
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -715,7 +715,7 @@ function TalkgroupEditor({
|
|||||||
onChange={(e) => setPasteText(e.target.value)}
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
|
placeholder={"1234\tFire Dispatch\tfire\n5678\tPolice Zone 1\tpolice\n9012\tEMS\tems"}
|
||||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-surface border border-line-strong rounded px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -729,10 +729,10 @@ function TalkgroupEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{talkgroups.length > 0 ? (
|
{talkgroups.length > 0 ? (
|
||||||
<div className="border border-gray-800 rounded-lg overflow-hidden">
|
<div className="border border-line rounded-lg overflow-hidden">
|
||||||
<table className="w-full text-xs font-mono">
|
<table className="w-full text-xs font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-800 text-gray-400">
|
<tr className="bg-raised text-ink-2">
|
||||||
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
|
<th className="px-3 py-1.5 text-left w-20">Dec ID</th>
|
||||||
<th className="px-3 py-1.5 text-left">Name</th>
|
<th className="px-3 py-1.5 text-left">Name</th>
|
||||||
<th className="px-3 py-1.5 text-left w-28">Tag</th>
|
<th className="px-3 py-1.5 text-left w-28">Tag</th>
|
||||||
@@ -742,7 +742,7 @@ function TalkgroupEditor({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{talkgroups.map((tg, i) => (
|
{talkgroups.map((tg, i) => (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
<tr className="border-t border-gray-800 hover:bg-gray-800/30">
|
<tr className="border-t border-line hover:bg-raised/30">
|
||||||
<td className="px-2 py-1">
|
<td className="px-2 py-1">
|
||||||
<input
|
<input
|
||||||
value={tg.id}
|
value={tg.id}
|
||||||
@@ -763,7 +763,7 @@ function TalkgroupEditor({
|
|||||||
<select
|
<select
|
||||||
value={tg.tag}
|
value={tg.tag}
|
||||||
onChange={(e) => updateRow(i, "tag", e.target.value)}
|
onChange={(e) => updateRow(i, "tag", e.target.value)}
|
||||||
className="w-full bg-gray-900 text-gray-300 focus:outline-none rounded px-1"
|
className="w-full bg-surface text-ink-2 focus:outline-none rounded px-1"
|
||||||
>
|
>
|
||||||
{TG_TAGS.map((t) => (
|
{TG_TAGS.map((t) => (
|
||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
@@ -776,7 +776,7 @@ function TalkgroupEditor({
|
|||||||
title="Local knowledge for the transcript corrector"
|
title="Local knowledge for the transcript corrector"
|
||||||
onClick={() => setExpanded(expanded === i ? null : i)}
|
onClick={() => setExpanded(expanded === i ? null : i)}
|
||||||
className={`transition-colors font-bold mr-2 ${
|
className={`transition-colors font-bold mr-2 ${
|
||||||
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-gray-600 hover:text-gray-300"
|
hasLocalKnowledge(tg) ? "text-indigo-400" : "text-ink-muted hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{expanded === i ? "▾" : "▸"}
|
{expanded === i ? "▾" : "▸"}
|
||||||
@@ -784,7 +784,7 @@ function TalkgroupEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeRow(i)}
|
onClick={() => removeRow(i)}
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors font-bold"
|
className="text-ink-muted hover:text-red-400 transition-colors font-bold"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -794,11 +794,11 @@ function TalkgroupEditor({
|
|||||||
talkgroups and most inherit the system's context rather than
|
talkgroups and most inherit the system's context rather than
|
||||||
override it. */}
|
override it. */}
|
||||||
{expanded === i && (
|
{expanded === i && (
|
||||||
<tr className="border-t border-gray-800 bg-gray-900/60">
|
<tr className="border-t border-line bg-surface/60">
|
||||||
<td colSpan={4} className="px-3 py-3">
|
<td colSpan={4} className="px-3 py-3">
|
||||||
<p className="text-xs text-gray-500 mb-2 font-sans">
|
<p className="text-xs text-ink-muted mb-2 font-sans">
|
||||||
Given to the transcript corrector for this talkgroup only, ranked
|
Given to the transcript corrector for this talkgroup only, ranked
|
||||||
<span className="text-gray-300"> above</span> the system's own list.
|
<span className="text-ink-2"> above</span> the system's own list.
|
||||||
Leave blank to inherit the system's.
|
Leave blank to inherit the system's.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
@@ -849,7 +849,7 @@ function TalkgroupEditor({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 italic py-1">
|
<p className="text-xs text-ink-muted italic py-1">
|
||||||
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
|
No talkgroups — add rows, paste from RadioReference, or use the RadioReference import button.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -864,52 +864,52 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">NAC (hex)</label>
|
<label className="text-xs text-ink-2 block mb-1">NAC (hex)</label>
|
||||||
<input
|
<input
|
||||||
value={value.nac}
|
value={value.nac}
|
||||||
onChange={(e) => onChange({ ...value, nac: e.target.value })}
|
onChange={(e) => onChange({ ...value, nac: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="0x293"
|
placeholder="0x293"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">System ID</label>
|
<label className="text-xs text-ink-2 block mb-1">System ID</label>
|
||||||
<input
|
<input
|
||||||
value={value.system_id}
|
value={value.system_id}
|
||||||
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
|
onChange={(e) => onChange({ ...value, system_id: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="50513"
|
placeholder="50513"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">WACN (hex)</label>
|
<label className="text-xs text-ink-2 block mb-1">WACN (hex)</label>
|
||||||
<input
|
<input
|
||||||
value={value.wacn}
|
value={value.wacn}
|
||||||
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
|
onChange={(e) => onChange({ ...value, wacn: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="0xBEE00"
|
placeholder="0xBEE00"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Control Channels <span className="text-gray-600">(MHz, comma-separated)</span>
|
Control Channels <span className="text-ink-muted">(MHz, comma-separated)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={value.control_channels}
|
value={value.control_channels}
|
||||||
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
|
onChange={(e) => onChange({ ...value, control_channels: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="851.0125, 851.5125, 852.0125"
|
placeholder="851.0125, 851.5125, 852.0125"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Voice Channels <span className="text-gray-600">(MHz, comma-separated — leave blank for auto-discovery)</span>
|
Voice Channels <span className="text-ink-muted">(MHz, comma-separated — leave blank for auto-discovery)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={value.voice_channels}
|
value={value.voice_channels}
|
||||||
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
|
onChange={(e) => onChange({ ...value, voice_channels: e.target.value })}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Optional"
|
placeholder="Optional"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -923,7 +923,7 @@ function P25Form({ value, onChange }: { value: P25Config; onChange: (v: P25Confi
|
|||||||
|
|
||||||
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
|
// ── Fixed-channel editors (node-26#24) ────────────────────────────────────────
|
||||||
|
|
||||||
const INPUT = "w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
const INPUT = "w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||||||
|
|
||||||
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
|
function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v: P25Config) => void }) {
|
||||||
const chs = value.channels;
|
const chs = value.channels;
|
||||||
@@ -931,13 +931,13 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
|
|||||||
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
|
onChange({ ...value, channels: chs.map((ch, j) => (j === i ? { ...ch, [field]: v } : ch)) });
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-ink-muted">
|
||||||
Fixed voice channels with no control channel — RadioReference lists them as conventional
|
Fixed voice channels with no control channel — RadioReference lists them as conventional
|
||||||
frequencies with mode P25 (or "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
frequencies with mode P25 (or "DE"). A node decodes up to {MAX_CONVENTIONAL_CHANNELS} on
|
||||||
one SDR, within about 1.5 MHz of each other. Each channel's calls are labelled with its name.
|
one SDR, within about 1.5 MHz of each other. Each channel's calls are labelled with its name.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-gray-400">
|
<div className="grid grid-cols-[1fr_1.5fr_0.8fr_auto] gap-2 text-xs text-ink-2">
|
||||||
<span>Frequency (MHz)</span>
|
<span>Frequency (MHz)</span>
|
||||||
<span>Channel name</span>
|
<span>Channel name</span>
|
||||||
<span>NAC (hex, optional)</span>
|
<span>NAC (hex, optional)</span>
|
||||||
@@ -992,7 +992,7 @@ function ConventionalForm({ value, onChange }: { value: P25Config; onChange: (v:
|
|||||||
talkgroups={value.talkgroups}
|
talkgroups={value.talkgroups}
|
||||||
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
|
onChange={(tgs) => onChange({ ...value, talkgroups: tgs })}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-600 -mt-2">
|
<p className="text-xs text-ink-muted -mt-2">
|
||||||
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
|
Talkgroups are optional here: only needed if the radios send distinct talkgroup IDs you want named.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,7 +1004,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
|
|||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="analog-freq" className="text-xs text-gray-400 block mb-1">Frequency (MHz)</label>
|
<label htmlFor="analog-freq" className="text-xs text-ink-2 block mb-1">Frequency (MHz)</label>
|
||||||
<input
|
<input
|
||||||
id="analog-freq"
|
id="analog-freq"
|
||||||
value={ch.freq}
|
value={ch.freq}
|
||||||
@@ -1016,7 +1016,7 @@ function AnalogForm({ value, onChange }: { value: AnalogConfig; onChange: (v: An
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="analog-name" className="text-xs text-gray-400 block mb-1">Channel name</label>
|
<label htmlFor="analog-name" className="text-xs text-ink-2 block mb-1">Channel name</label>
|
||||||
<input
|
<input
|
||||||
id="analog-name"
|
id="analog-name"
|
||||||
value={ch.name}
|
value={ch.name}
|
||||||
@@ -1124,27 +1124,27 @@ function SystemForm({
|
|||||||
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
const title = initial && !createOnly ? "Edit System" : createOnly ? "Duplicate System" : "New System";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4 font-mono">
|
<form onSubmit={handleSubmit} className="bg-surface border border-line-strong rounded-xl p-5 space-y-4 font-mono">
|
||||||
<h3 className="text-white font-semibold">{title}</h3>
|
<h3 className="text-white font-semibold">{title}</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="Westchester County P25"
|
placeholder="Westchester County P25"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="system-kind" className="text-xs text-gray-400 block mb-1">Type</label>
|
<label htmlFor="system-kind" className="text-xs text-ink-2 block mb-1">Type</label>
|
||||||
<select
|
<select
|
||||||
id="system-kind"
|
id="system-kind"
|
||||||
value={kind}
|
value={kind}
|
||||||
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
|
onChange={(e) => handleKindChange(e.target.value as SystemKind)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{KIND_OPTIONS.map((o) => (
|
{KIND_OPTIONS.map((o) => (
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
@@ -1165,12 +1165,12 @@ function SystemForm({
|
|||||||
<AnalogForm value={analog} onChange={setAnalog} />
|
<AnalogForm value={analog} onChange={setAnalog} />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Config (JSON)</label>
|
<label className="text-xs text-ink-2 block mb-1">Config (JSON)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={rawJson}
|
value={rawJson}
|
||||||
onChange={(e) => setRawJson(e.target.value)}
|
onChange={(e) => setRawJson(e.target.value)}
|
||||||
rows={10}
|
rows={10}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1179,7 +1179,7 @@ function SystemForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleRaw}
|
onClick={toggleRaw}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink-2 transition-colors"
|
||||||
>
|
>
|
||||||
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
|
{showRaw ? "← Use structured form" : "Edit raw JSON →"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1198,7 +1198,7 @@ function SystemForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -1267,10 +1267,10 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
const currentToken = tokens?.find((t) => t.token_id === preferredId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
@@ -1282,14 +1282,14 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-2 font-mono text-xs">
|
<div className="mt-3 space-y-2 font-mono text-xs">
|
||||||
{tokens === null ? (
|
{tokens === null ? (
|
||||||
<p className="text-gray-600 italic">Loading tokens…</p>
|
<p className="text-ink-muted italic">Loading tokens…</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="text-red-400">{error}</p>
|
<p className="text-red-400">{error}</p>
|
||||||
) : tokens.length === 0 ? (
|
) : tokens.length === 0 ? (
|
||||||
<p className="text-gray-600 italic">No tokens in pool.</p>
|
<p className="text-ink-muted italic">No tokens in pool.</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-600">
|
<p className="text-ink-muted">
|
||||||
When a node on this system joins a voice channel, this token is tried first.
|
When a node on this system joins a voice channel, this token is tried first.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -1303,7 +1303,7 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="accent-indigo-500"
|
className="accent-indigo-500"
|
||||||
/>
|
/>
|
||||||
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-gray-600" : "text-gray-300"}`}>
|
<span className={`flex-1 ${t.in_use && preferredId !== t.token_id ? "text-ink-muted" : "text-ink-2"}`}>
|
||||||
{t.name}
|
{t.name}
|
||||||
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
{t.in_use && <span className="ml-1.5 text-green-600">in use</span>}
|
||||||
</span>
|
</span>
|
||||||
@@ -1314,13 +1314,13 @@ function PreferredTokenPanel({ systemId, initialTokenId }: { systemId: string; i
|
|||||||
<button
|
<button
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="text-gray-600 hover:text-gray-400 transition-colors disabled:opacity-50"
|
className="text-ink-muted hover:text-ink-2 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Clear preference (use any free token)
|
Clear preference (use any free token)
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!preferredId && (
|
{!preferredId && (
|
||||||
<p className="text-gray-700">No preference — any free token will be used.</p>
|
<p className="text-ink-muted">No preference — any free token will be used.</p>
|
||||||
)}
|
)}
|
||||||
{currentToken && (
|
{currentToken && (
|
||||||
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
<p className="text-indigo-500">Preferred: {currentToken.name}</p>
|
||||||
@@ -1371,10 +1371,10 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>AI Flags</span>
|
<span>AI Flags</span>
|
||||||
@@ -1400,23 +1400,23 @@ function AiFlagsPanel({ systemId, initial }: { systemId: string; initial: System
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
|
<span className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${isOn ? "translate-x-4" : "translate-x-0.5"}`} />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-300 flex-1">{label}</span>
|
<span className="text-ink-2 flex-1">{label}</span>
|
||||||
{isSet ? (
|
{isSet ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClear(key)}
|
onClick={() => handleClear(key)}
|
||||||
disabled={saving === key}
|
disabled={saving === key}
|
||||||
className="text-gray-600 hover:text-gray-400 transition-colors"
|
className="text-ink-muted hover:text-ink-2 transition-colors"
|
||||||
title="Clear override (inherit global)"
|
title="Clear override (inherit global)"
|
||||||
>
|
>
|
||||||
reset
|
reset
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">inherits global</span>
|
<span className="text-ink-muted">inherits global</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<p className="text-gray-700 pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
|
<p className="text-ink-muted pt-1">Overrides apply on top of global AI flags. "reset" restores global default.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1456,15 +1456,15 @@ function SourceCallPlayer({ callId }: { callId: string }) {
|
|||||||
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
{loading ? "loading…" : open ? "▲ source" : "▶ source"}
|
||||||
</button>
|
</button>
|
||||||
{open && call && (
|
{open && call && (
|
||||||
<div className="mt-1.5 space-y-1 pl-2 border-l border-gray-700">
|
<div className="mt-1.5 space-y-1 pl-2 border-l border-line-strong">
|
||||||
{call.audio_url ? (
|
{call.audio_url ? (
|
||||||
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
<audio src={call.audio_url} controls className="w-full" style={{ height: "1.75rem" }} />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 italic">No audio</p>
|
<p className="text-ink-muted italic">No audio</p>
|
||||||
)}
|
)}
|
||||||
{transcript && (
|
{transcript && (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-500 italic line-clamp-2">{transcript}</p>
|
<p className="text-ink-muted italic line-clamp-2">{transcript}</p>
|
||||||
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
{/* Gate A / A2 (server-26#46) — this is a raw pipeline transcript. */}
|
||||||
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
<MachineOutputNotice variant="inline" className="text-[10px]" />
|
||||||
</>
|
</>
|
||||||
@@ -1528,16 +1528,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Local Area
|
Local Area
|
||||||
{area !== null && (
|
{area !== null && (
|
||||||
<span className="text-gray-600 ml-1">
|
<span className="text-ink-muted ml-1">
|
||||||
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
({filled > 0 ? `${filled} field${filled === 1 ? "" : "s"} set` : "not set"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1546,16 +1546,16 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-3 text-xs">
|
<div className="mt-3 space-y-3 text-xs">
|
||||||
{loading && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
{loading && <p className="text-ink-muted italic font-mono">Loading…</p>}
|
||||||
{area && (
|
{area && (
|
||||||
<>
|
<>
|
||||||
<p className="text-gray-500">
|
<p className="text-ink-muted">
|
||||||
Given to the transcript corrector for every talkgroup on this system.
|
Given to the transcript corrector for every talkgroup on this system.
|
||||||
Whisper mishears local names constantly — naming them here is what lets
|
Whisper mishears local names constantly — naming them here is what lets
|
||||||
them be put back.
|
them be put back.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-500">
|
<p className="text-ink-muted">
|
||||||
Fill this in <span className="text-gray-300">only if it is true of every
|
Fill this in <span className="text-ink-2">only if it is true of every
|
||||||
talkgroup</span> on the system. One town, one department — fill it once here.
|
talkgroup</span> on the system. One town, one department — fill it once here.
|
||||||
A system spanning several counties — leave it blank and describe each
|
A system spanning several counties — leave it blank and describe each
|
||||||
talkgroup in the edit form instead.
|
talkgroup in the edit form instead.
|
||||||
@@ -1592,7 +1592,7 @@ function AreaContextPanel({ systemId }: { systemId: string }) {
|
|||||||
{/* The anchor is the backend's answer to what was typed above, and
|
{/* The anchor is the backend's answer to what was typed above, and
|
||||||
it decides whether location checking runs at all — so it is
|
it decides whether location checking runs at all — so it is
|
||||||
worth showing rather than leaving as invisible state. */}
|
worth showing rather than leaving as invisible state. */}
|
||||||
<p className="text-gray-600 font-mono">{anchorLabel(area)}</p>
|
<p className="text-ink-muted font-mono">{anchorLabel(area)}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={save}
|
onClick={save}
|
||||||
@@ -1659,16 +1659,16 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
const total = (rows ?? []).reduce((n, r) => n + r.pending.length, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Proposed Terms
|
Proposed Terms
|
||||||
{rows !== null && (
|
{rows !== null && (
|
||||||
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-gray-600 ml-1"}>
|
<span className={total > 0 ? "text-indigo-400 ml-1" : "text-ink-muted ml-1"}>
|
||||||
({total > 0 ? `${total} awaiting review` : "none"})
|
({total > 0 ? `${total} awaiting review` : "none"})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1677,9 +1677,9 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-3 text-xs">
|
<div className="mt-3 space-y-3 text-xs">
|
||||||
{rows === null && <p className="text-gray-600 italic font-mono">Loading…</p>}
|
{rows === null && <p className="text-ink-muted italic font-mono">Loading…</p>}
|
||||||
{rows !== null && total === 0 && (
|
{rows !== null && total === 0 && (
|
||||||
<p className="text-gray-600 italic">
|
<p className="text-ink-muted italic">
|
||||||
Nothing proposed yet. Terms appear here when a corrected place name
|
Nothing proposed yet. Terms appear here when a corrected place name
|
||||||
turns out to be real nearby, or when the induction loop spots one in
|
turns out to be real nearby, or when the induction loop spots one in
|
||||||
recent traffic.
|
recent traffic.
|
||||||
@@ -1687,19 +1687,19 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
)}
|
)}
|
||||||
{(rows ?? []).map((row) => (
|
{(rows ?? []).map((row) => (
|
||||||
<div key={row.talkgroup_id}>
|
<div key={row.talkgroup_id}>
|
||||||
<p className="text-gray-400 font-mono mb-1">
|
<p className="text-ink-2 font-mono mb-1">
|
||||||
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
|
{row.talkgroup_name || `TGID ${row.talkgroup_id}`}
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{row.pending.map((p) => (
|
{row.pending.map((p) => (
|
||||||
<li
|
<li
|
||||||
key={p.term}
|
key={p.term}
|
||||||
className="flex items-start gap-2 bg-gray-900/60 border border-gray-800 rounded px-2 py-1"
|
className="flex items-start gap-2 bg-surface/60 border border-line rounded px-2 py-1"
|
||||||
>
|
>
|
||||||
<span className="flex-1">
|
<span className="flex-1">
|
||||||
<span className="text-white font-mono">{p.term}</span>
|
<span className="text-white font-mono">{p.term}</span>
|
||||||
{p.meaning && <span className="text-gray-500"> — {p.meaning}</span>}
|
{p.meaning && <span className="text-ink-muted"> — {p.meaning}</span>}
|
||||||
<span className="text-gray-700 ml-2">{p.source}</span>
|
<span className="text-ink-muted ml-2">{p.source}</span>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1713,7 +1713,7 @@ function TalkgroupPendingPanel({ systemId }: { systemId: string }) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
disabled={busy === `${row.talkgroup_id}:${p.term}`}
|
||||||
onClick={() => act(row.talkgroup_id, p.term, false)}
|
onClick={() => act(row.talkgroup_id, p.term, false)}
|
||||||
className="text-gray-600 hover:text-red-400 disabled:opacity-40 font-semibold"
|
className="text-ink-muted hover:text-red-400 disabled:opacity-40 font-semibold"
|
||||||
>
|
>
|
||||||
Drop
|
Drop
|
||||||
</button>
|
</button>
|
||||||
@@ -1802,16 +1802,16 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 border-t border-gray-800 pt-3">
|
<div className="mt-3 border-t border-line pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 font-mono transition-colors flex items-center gap-1"
|
className="text-xs text-ink-muted hover:text-ink font-mono transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<span>{open ? "▲" : "▼"}</span>
|
<span>{open ? "▲" : "▼"}</span>
|
||||||
<span>
|
<span>
|
||||||
Vocabulary
|
Vocabulary
|
||||||
{vocab !== null && (
|
{vocab !== null && (
|
||||||
<span className="text-gray-600 ml-1">
|
<span className="text-ink-muted ml-1">
|
||||||
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
|
({vocab.length} terms{pending.length > 0 ? `, ${pending.length} pending` : ""})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1820,7 +1820,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="mt-3 space-y-3 font-mono text-xs">
|
<div className="mt-3 space-y-3 font-mono text-xs">
|
||||||
{loading && <p className="text-gray-600 italic">Loading…</p>}
|
{loading && <p className="text-ink-muted italic">Loading…</p>}
|
||||||
|
|
||||||
{!loading && vocab !== null && (
|
{!loading && vocab !== null && (
|
||||||
<>
|
<>
|
||||||
@@ -1832,19 +1832,19 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
>
|
>
|
||||||
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
|
{bootstrapping ? "Bootstrapping…" : bootstrapped ? "Re-bootstrap with AI" : "Bootstrap with AI"}
|
||||||
</button>
|
</button>
|
||||||
<span className="text-gray-600">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
<span className="text-ink-muted">GPT-4o generates local knowledge (agencies, units, streets)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
<p className="text-ink-muted uppercase tracking-wider mb-1.5">Approved ({vocab.length})</p>
|
||||||
{vocab.length > 0 ? (
|
{vocab.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{vocab.map((term) => (
|
{vocab.map((term) => (
|
||||||
<span key={term} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 px-2 py-0.5 rounded-full">
|
<span key={term} className="inline-flex items-center gap-1 bg-raised text-ink-2 px-2 py-0.5 rounded-full">
|
||||||
{term}
|
{term}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(term)}
|
onClick={() => handleRemove(term)}
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors leading-none"
|
className="text-ink-muted hover:text-red-400 transition-colors leading-none"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -1852,7 +1852,7 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-gray-600 italic">No terms yet — bootstrap or add manually.</p>
|
<p className="text-ink-muted italic">No terms yet — bootstrap or add manually.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1861,12 +1861,12 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
value={newTerm}
|
value={newTerm}
|
||||||
onChange={(e) => setNewTerm(e.target.value)}
|
onChange={(e) => setNewTerm(e.target.value)}
|
||||||
placeholder="Add term (e.g. 5-baker, YVAC)"
|
placeholder="Add term (e.g. 5-baker, YVAC)"
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-xs font-mono focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={adding || !newTerm.trim()}
|
disabled={adding || !newTerm.trim()}
|
||||||
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-gray-200 px-3 py-1.5 rounded-lg transition-colors"
|
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-ink px-3 py-1.5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
@@ -1874,17 +1874,17 @@ function VocabularyPanel({ systemId }: { systemId: string }) {
|
|||||||
|
|
||||||
{pending.length > 0 && (
|
{pending.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500 uppercase tracking-wider mb-1.5">
|
<p className="text-ink-muted uppercase tracking-wider mb-1.5">
|
||||||
Induction suggestions ({pending.length})
|
Induction suggestions ({pending.length})
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{pending.map((p) => (
|
{pending.map((p) => (
|
||||||
<div key={p.term} className="space-y-1">
|
<div key={p.term} className="space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-gray-300 flex-1">{p.term}</span>
|
<span className="text-ink-2 flex-1">{p.term}</span>
|
||||||
<span className="text-gray-600">{p.source}</span>
|
<span className="text-ink-muted">{p.source}</span>
|
||||||
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
<button onClick={() => handleApprove(p.term)} className="text-green-500 hover:text-green-400 transition-colors px-1">✓</button>
|
||||||
<button onClick={() => handleDismiss(p.term)} className="text-gray-600 hover:text-red-400 transition-colors px-1">✕</button>
|
<button onClick={() => handleDismiss(p.term)} className="text-ink-muted hover:text-red-400 transition-colors px-1">✕</button>
|
||||||
</div>
|
</div>
|
||||||
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
{p.source_call_ids && p.source_call_ids.length > 0 && (
|
||||||
<div className="pl-1 space-y-1">
|
<div className="pl-1 space-y-1">
|
||||||
@@ -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>
|
||||||
|
{/* Every systems write route is require_admin_token (server-26#283);
|
||||||
|
operators get a read-only view. */}
|
||||||
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditing("new"); setEditIsDuplicate(false); }}
|
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"
|
className="bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-sm font-mono transition-colors"
|
||||||
>
|
>
|
||||||
+ New System
|
+ New System
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
@@ -1970,9 +1975,9 @@ export default function SystemsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : systems.length === 0 ? (
|
) : systems.length === 0 ? (
|
||||||
<p className="text-gray-600 text-sm font-mono">No systems defined yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No systems defined yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{systems.map((s) => {
|
{systems.map((s) => {
|
||||||
@@ -1987,13 +1992,13 @@ export default function SystemsPage() {
|
|||||||
? (s.config.channels as unknown[]).length
|
? (s.config.channels as unknown[]).length
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={s.system_id} className="bg-gray-900 border border-gray-800 rounded-lg p-4 font-mono">
|
<div key={s.system_id} className="bg-surface border border-line rounded-lg p-4 font-mono">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-semibold">{s.name}</p>
|
<p className="text-white font-semibold">{s.name}</p>
|
||||||
<p className="text-xs text-gray-500">{s.system_id}</p>
|
<p className="text-xs text-ink-muted">{s.system_id}</p>
|
||||||
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
|
{(s.config.nac || ccCount !== null || chCount !== null || tgCount !== null) && (
|
||||||
<p className="text-xs text-gray-600 mt-0.5 space-x-2">
|
<p className="text-xs text-ink-muted mt-0.5 space-x-2">
|
||||||
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
|
{!!s.config.nac && <span>NAC {String(s.config.nac)}</span>}
|
||||||
{ccCount !== null && <span>· {ccCount} CC</span>}
|
{ccCount !== null && <span>· {ccCount} CC</span>}
|
||||||
{chCount !== null && <span>· {chCount} ch</span>}
|
{chCount !== null && <span>· {chCount} ch</span>}
|
||||||
@@ -2001,11 +2006,11 @@ export default function SystemsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs bg-gray-800 border border-gray-700 text-gray-400 px-2 py-0.5 rounded">
|
<span className="text-xs bg-raised border border-line-strong text-ink-2 px-2 py-0.5 rounded">
|
||||||
{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"
|
||||||
@@ -2014,7 +2019,7 @@ export default function SystemsPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openDuplicate(s)}
|
onClick={() => openDuplicate(s)}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
@@ -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} />}
|
||||||
|
{/* 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 ?? {}} />
|
<AiFlagsPanel systemId={s.system_id} initial={(s as unknown as { ai_flags?: SystemAiFlags }).ai_flags ?? {}} />
|
||||||
<AreaContextPanel systemId={s.system_id} />
|
<AreaContextPanel systemId={s.system_id} />
|
||||||
<TalkgroupPendingPanel systemId={s.system_id} />
|
<TalkgroupPendingPanel systemId={s.system_id} />
|
||||||
<VocabularyPanel systemId={s.system_id} />
|
<VocabularyPanel systemId={s.system_id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -69,18 +69,18 @@ export default function TermsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-display-sm text-white">Terms of Service</h1>
|
<h1 className="text-display-sm text-white">Terms of Service</h1>
|
||||||
<p className="text-gray-500 text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
<p className="text-ink-muted text-sm mt-2 font-mono">Draft — last structured {new Date().getFullYear()}</p>
|
||||||
|
|
||||||
<div className="mt-10 space-y-8">
|
<div className="mt-10 space-y-8">
|
||||||
{SECTIONS.map((s) => (
|
{SECTIONS.map((s) => (
|
||||||
<section key={s.heading}>
|
<section key={s.heading}>
|
||||||
<h2 className="text-white font-semibold">{s.heading}</h2>
|
<h2 className="text-white font-semibold">{s.heading}</h2>
|
||||||
<p className="text-gray-500 text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
<p className="text-ink-muted text-sm mt-2 leading-relaxed italic">{s.note}</p>
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-xs font-mono mt-16">
|
<p className="text-ink-muted text-xs font-mono mt-16">
|
||||||
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
Questions in the meantime? <Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">Check the FAQ</Link> or{" "}
|
||||||
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
<Link href="/login" className="text-indigo-400 hover:text-indigo-300 transition-colors">sign in to reach us directly</Link>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,7 +142,7 @@ function PlaceSearch({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
|
||||||
placeholder="Search a place…"
|
placeholder="Search a place…"
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -153,7 +154,7 @@ function PlaceSearch({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{results.length > 0 && (
|
{results.length > 0 && (
|
||||||
<div className="bg-gray-800 border border-gray-700 rounded-lg overflow-hidden divide-y divide-gray-700">
|
<div className="bg-raised border border-line-strong rounded-lg overflow-hidden divide-y divide-line-strong">
|
||||||
{results.map((p) => (
|
{results.map((p) => (
|
||||||
<button
|
<button
|
||||||
key={p.place_id}
|
key={p.place_id}
|
||||||
@@ -162,7 +163,7 @@ function PlaceSearch({
|
|||||||
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
|
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
|
||||||
>
|
>
|
||||||
<p className="text-white text-sm font-medium">{p.name}</p>
|
<p className="text-white text-sm font-medium">{p.name}</p>
|
||||||
<p className="text-gray-400 text-xs">{p.address}</p>
|
<p className="text-ink-2 text-xs">{p.address}</p>
|
||||||
{p.rating && (
|
{p.rating && (
|
||||||
<p className="text-yellow-400 text-xs">★ {p.rating}</p>
|
<p className="text-yellow-400 text-xs">★ {p.rating}</p>
|
||||||
)}
|
)}
|
||||||
@@ -240,60 +241,60 @@ function AddEventModal({
|
|||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
|
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Title</label>
|
<label className="text-xs text-ink-2 block mb-1">Title</label>
|
||||||
<input
|
<input
|
||||||
required value={title} onChange={(e) => setTitle(e.target.value)}
|
required value={title} onChange={(e) => setTitle(e.target.value)}
|
||||||
placeholder="Dinner at the bar"
|
placeholder="Dinner at the bar"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Date</label>
|
<label className="text-xs text-ink-2 block mb-1">Date</label>
|
||||||
<input
|
<input
|
||||||
required type="date"
|
required type="date"
|
||||||
min={trip.start_date} max={trip.end_date}
|
min={trip.start_date} max={trip.end_date}
|
||||||
value={date} onChange={(e) => setDate(e.target.value)}
|
value={date} onChange={(e) => setDate(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Start</label>
|
<label className="text-xs text-ink-2 block mb-1">Start</label>
|
||||||
<input
|
<input
|
||||||
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
|
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">End</label>
|
<label className="text-xs text-ink-2 block mb-1">End</label>
|
||||||
<input
|
<input
|
||||||
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
|
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
|
||||||
min={startTime}
|
min={startTime}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Find a place</label>
|
<label className="text-xs text-ink-2 block mb-1">Find a place</label>
|
||||||
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
|
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">
|
<label className="text-xs text-ink-2 block mb-1">
|
||||||
Location{" "}
|
Location{" "}
|
||||||
<span className="text-gray-600">(auto-filled from search, or type manually)</span>
|
<span className="text-ink-muted">(auto-filled from search, or type manually)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={location} onChange={(e) => setLocation(e.target.value)}
|
value={location} onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder={`Inherits: ${trip.location}`}
|
placeholder={`Inherits: ${trip.location}`}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -307,16 +308,16 @@ function AddEventModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Notes (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Notes (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
|
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableTags.length > 0 && (
|
{availableTags.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Tags</label>
|
<label className="text-xs text-ink-2 block mb-1">Tags</label>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{availableTags.map((tag) => {
|
{availableTags.map((tag) => {
|
||||||
const active = tags.includes(tag);
|
const active = tags.includes(tag);
|
||||||
@@ -326,7 +327,7 @@ function AddEventModal({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
|
||||||
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
|
||||||
active ? tagColor(tag, availableTags) : "bg-gray-800 text-gray-500 border-gray-700 hover:border-gray-600 hover:text-gray-400"
|
active ? tagColor(tag, availableTags) : "bg-raised text-ink-muted border-line-strong hover:border-line-strong hover:text-ink-2"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
@@ -340,7 +341,7 @@ function AddEventModal({
|
|||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end pt-1">
|
<div className="flex gap-3 justify-end pt-1">
|
||||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
<button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -389,7 +390,7 @@ function DayTimeline({
|
|||||||
|
|
||||||
if (timed.length === 0 && untimed.length === 0) {
|
if (timed.length === 0 && untimed.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-gray-600 text-sm font-mono py-8 text-center">
|
<p className="text-ink-muted text-sm font-mono py-8 text-center">
|
||||||
No events on this day yet.
|
No events on this day yet.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
@@ -424,10 +425,10 @@ function DayTimeline({
|
|||||||
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
|
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
|
||||||
className="absolute inset-x-0 flex items-center pointer-events-none"
|
className="absolute inset-x-0 flex items-center pointer-events-none"
|
||||||
>
|
>
|
||||||
<span className="w-14 text-right pr-3 text-xs text-gray-600 font-mono shrink-0 -translate-y-2">
|
<span className="w-14 text-right pr-3 text-xs text-ink-muted font-mono shrink-0 -translate-y-2">
|
||||||
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
|
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex-1 border-t border-gray-800/70" />
|
<div className="flex-1 border-t border-line/70" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -444,7 +445,7 @@ function DayTimeline({
|
|||||||
{e.end_time && (
|
{e.end_time && (
|
||||||
<div
|
<div
|
||||||
style={{ top, height, left: 60, right: 0 }}
|
style={{ top, height, left: 60, right: 0 }}
|
||||||
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
|
className="absolute bg-raised/30 border-l-2 border-line-strong/30 z-0 pointer-events-none"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Dashed marker line + label */}
|
{/* Dashed marker line + label */}
|
||||||
@@ -452,15 +453,15 @@ function DayTimeline({
|
|||||||
style={{ top, left: 60, right: 0 }}
|
style={{ top, left: 60, right: 0 }}
|
||||||
className="absolute z-10 flex items-center gap-2"
|
className="absolute z-10 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<div className="flex-1 border-t border-dashed border-gray-600/40" />
|
<div className="flex-1 border-t border-dashed border-line-strong/40" />
|
||||||
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
|
<span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
|
||||||
{fmtTime(e.start_time)}
|
{fmtTime(e.start_time)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
|
<span className="text-ink-muted text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
||||||
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
|
<button onClick={() => onEdit(e)} className="text-ink-muted hover:text-indigo-400 text-[10px]">Edit</button>
|
||||||
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
|
<button onClick={() => onDelete(e.event_id)} className="text-ink-muted hover:text-red-400 text-xs leading-none">×</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -483,25 +484,27 @@ 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" : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{height >= 60 && !e.location_inherited && e.location && (
|
{height >= 60 && !e.location_inherited && e.location && (
|
||||||
<p className="text-gray-500 text-xs mt-0.5 truncate">{e.location}</p>
|
<p className="text-ink-muted text-xs mt-0.5 truncate">{e.location}</p>
|
||||||
)}
|
)}
|
||||||
{height >= 60 && e.notes && (
|
{height >= 60 && e.notes && (
|
||||||
<p className="text-gray-600 text-xs mt-0.5 italic truncate">{e.notes}</p>
|
<p className="text-ink-muted text-xs mt-0.5 italic truncate">{e.notes}</p>
|
||||||
)}
|
)}
|
||||||
{e.tags?.length > 0 && (
|
{e.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
@@ -525,13 +528,13 @@ function DayTimeline({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => onEdit(e)}
|
onClick={() => onEdit(e)}
|
||||||
className="text-gray-600 hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
className="text-ink-muted hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(e.event_id)}
|
onClick={() => onDelete(e.event_id)}
|
||||||
className="text-gray-600 hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
className="text-ink-muted hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -543,9 +546,9 @@ function DayTimeline({
|
|||||||
{drive && (
|
{drive && (
|
||||||
<div
|
<div
|
||||||
style={{ top: top + height + 2, left: 60 }}
|
style={{ top: top + height + 2, left: 60 }}
|
||||||
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
|
className="absolute text-xs text-ink-muted font-mono flex items-center gap-1 z-20"
|
||||||
>
|
>
|
||||||
<span className="text-gray-700">↓</span> {drive} drive
|
<span className="text-ink-muted">↓</span> {drive} drive
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -557,18 +560,18 @@ function DayTimeline({
|
|||||||
{/* Untimed / all-day events */}
|
{/* Untimed / all-day events */}
|
||||||
{untimed.length > 0 && (
|
{untimed.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-mono text-gray-600 uppercase tracking-wider">Unscheduled</p>
|
<p className="text-xs font-mono text-ink-muted uppercase tracking-wider">Unscheduled</p>
|
||||||
{untimed.map((e) => (
|
{untimed.map((e) => (
|
||||||
<div
|
<div
|
||||||
key={e.event_id}
|
key={e.event_id}
|
||||||
className="bg-gray-900 border border-gray-800 rounded-lg px-4 py-2.5 flex items-center justify-between group"
|
className="bg-surface border border-line rounded-lg px-4 py-2.5 flex items-center justify-between group"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-white text-sm">{e.title}</p>
|
<p className="text-white text-sm">{e.title}</p>
|
||||||
{!e.location_inherited && e.location && (
|
{!e.location_inherited && e.location && (
|
||||||
<p className="text-gray-500 text-xs mt-0.5">{e.location}</p>
|
<p className="text-ink-muted text-xs mt-0.5">{e.location}</p>
|
||||||
)}
|
)}
|
||||||
{e.notes && <p className="text-gray-600 text-xs italic mt-0.5">{e.notes}</p>}
|
{e.notes && <p className="text-ink-muted text-xs italic mt-0.5">{e.notes}</p>}
|
||||||
{e.tags?.length > 0 && (
|
{e.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
|
||||||
@@ -585,11 +588,11 @@ function DayTimeline({
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => onEdit(e)}
|
<button onClick={() => onEdit(e)}
|
||||||
className="text-xs text-gray-600 hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
className="text-xs text-ink-muted hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => onDelete(e.event_id)}
|
<button onClick={() => onDelete(e.event_id)}
|
||||||
className="text-gray-600 hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
className="text-ink-muted hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -728,17 +731,17 @@ function AssistantPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-gray-800 shrink-0 flex items-start justify-between gap-2">
|
<div className="px-4 py-3 border-b border-line shrink-0 flex items-start justify-between gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white text-sm font-semibold">Trip Assistant</p>
|
<p className="text-white text-sm font-semibold">Trip Assistant</p>
|
||||||
<p className="text-gray-500 text-xs mt-0.5">
|
<p className="text-ink-muted text-xs mt-0.5">
|
||||||
Tell me what you want to do — I can search places and suggest events.
|
Tell me what you want to do — I can search places and suggest events.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearChat}
|
onClick={clearChat}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 transition-colors shrink-0 mt-0.5"
|
className="text-xs text-ink-muted hover:text-ink-2 transition-colors shrink-0 mt-0.5"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -758,7 +761,7 @@ function AssistantPanel({
|
|||||||
<button
|
<button
|
||||||
key={prompt}
|
key={prompt}
|
||||||
onClick={() => { setInput(prompt); }}
|
onClick={() => { setInput(prompt); }}
|
||||||
className="w-full text-left bg-gray-800/60 hover:bg-gray-800 border border-gray-700/50 rounded-lg px-3 py-2 text-gray-400 text-xs transition-colors"
|
className="w-full text-left bg-raised/60 hover:bg-raised border border-line-strong/50 rounded-lg px-3 py-2 text-ink-2 text-xs transition-colors"
|
||||||
>
|
>
|
||||||
{prompt}
|
{prompt}
|
||||||
</button>
|
</button>
|
||||||
@@ -772,7 +775,7 @@ function AssistantPanel({
|
|||||||
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
|
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
|
||||||
msg.role === "user"
|
msg.role === "user"
|
||||||
? "bg-indigo-600 text-white"
|
? "bg-indigo-600 text-white"
|
||||||
: "bg-gray-800 text-gray-200"
|
: "bg-raised text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.role === "user" ? msg.content : (
|
{msg.role === "user" ? msg.content : (
|
||||||
@@ -804,11 +807,11 @@ function AssistantPanel({
|
|||||||
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
|
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
|
||||||
s.added
|
s.added
|
||||||
? "border-green-800/50 bg-green-950/30"
|
? "border-green-800/50 bg-green-950/30"
|
||||||
: "border-gray-700 bg-gray-900"
|
: "border-line-strong bg-surface"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-white text-sm font-semibold">{s.title}</p>
|
<p className="text-white text-sm font-semibold">{s.title}</p>
|
||||||
<div className="text-xs text-gray-400 space-y-0.5">
|
<div className="text-xs text-ink-2 space-y-0.5">
|
||||||
{s.date && (
|
{s.date && (
|
||||||
<p>
|
<p>
|
||||||
{fmtDate(s.date)}
|
{fmtDate(s.date)}
|
||||||
@@ -817,7 +820,7 @@ function AssistantPanel({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{s.location && <p className="truncate">{s.location}</p>}
|
{s.location && <p className="truncate">{s.location}</p>}
|
||||||
{s.notes && <p className="text-gray-500 italic">{s.notes}</p>}
|
{s.notes && <p className="text-ink-muted italic">{s.notes}</p>}
|
||||||
{s.tags && s.tags.length > 0 && (
|
{s.tags && s.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
|
||||||
@@ -840,7 +843,7 @@ function AssistantPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => dismissSuggestion(msg.id, idx)}
|
onClick={() => dismissSuggestion(msg.id, idx)}
|
||||||
className="px-3 text-gray-500 hover:text-gray-300 text-xs transition-colors"
|
className="px-3 text-ink-muted hover:text-ink text-xs transition-colors"
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</button>
|
</button>
|
||||||
@@ -856,7 +859,7 @@ function AssistantPanel({
|
|||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<div className="bg-gray-800 rounded-xl px-3 py-2 text-gray-400 text-sm">
|
<div className="bg-raised rounded-xl px-3 py-2 text-ink-2 text-sm">
|
||||||
<span className="animate-pulse">Thinking…</span>
|
<span className="animate-pulse">Thinking…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -865,7 +868,7 @@ function AssistantPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
<div className="px-3 py-3 border-t border-gray-800 shrink-0">
|
<div className="px-3 py-3 border-t border-line shrink-0">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -875,7 +878,7 @@ function AssistantPanel({
|
|||||||
placeholder="What do you want to do?"
|
placeholder="What do you want to do?"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
|
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={send}
|
onClick={send}
|
||||||
@@ -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) {
|
||||||
@@ -1055,8 +1058,8 @@ export default function TripDetailPage() {
|
|||||||
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading…</p>;
|
if (loading) return <p className="text-ink-muted text-sm font-mono">Loading…</p>;
|
||||||
if (!trip) return <p className="text-gray-500 text-sm font-mono">Trip not found.</p>;
|
if (!trip) return <p className="text-ink-muted text-sm font-mono">Trip not found.</p>;
|
||||||
|
|
||||||
const days = dateRange(trip.start_date, trip.end_date);
|
const days = dateRange(trip.start_date, trip.end_date);
|
||||||
const attendees = Object.values(trip.attendees ?? {});
|
const attendees = Object.values(trip.attendees ?? {});
|
||||||
@@ -1070,7 +1073,7 @@ export default function TripDetailPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/trips")}
|
onClick={() => router.push("/trips")}
|
||||||
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
|
className="text-sm font-mono text-ink-muted hover:text-ink transition-colors"
|
||||||
>
|
>
|
||||||
← Trips
|
← Trips
|
||||||
</button>
|
</button>
|
||||||
@@ -1078,7 +1081,7 @@ export default function TripDetailPage() {
|
|||||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
|
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-400">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
|
||||||
<span>{trip.location}</span>
|
<span>{trip.location}</span>
|
||||||
{trip.maps_link && (
|
{trip.maps_link && (
|
||||||
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
|
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
|
||||||
@@ -1086,11 +1089,11 @@ export default function TripDetailPage() {
|
|||||||
Maps
|
Maps
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<span className="text-gray-700">•</span>
|
<span className="text-ink-muted">•</span>
|
||||||
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
|
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
|
||||||
{attendees.length > 0 && (
|
{attendees.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-gray-700">•</span>
|
<span className="text-ink-muted">•</span>
|
||||||
<span className="text-xs">{attendees.join(", ")}</span>
|
<span className="text-xs">{attendees.join(", ")}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -1114,7 +1117,7 @@ export default function TripDetailPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleVisibility}
|
onClick={handleToggleVisibility}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 transition-colors border border-gray-700 rounded-lg px-3 py-2"
|
className="text-xs text-ink-muted hover:text-ink transition-colors border border-line-strong rounded-lg px-3 py-2"
|
||||||
>
|
>
|
||||||
{trip.visibility === "private" ? "Make public" : "Make private"}
|
{trip.visibility === "private" ? "Make public" : "Make private"}
|
||||||
</button>
|
</button>
|
||||||
@@ -1159,9 +1162,9 @@ export default function TripDetailPage() {
|
|||||||
onChange={(e) => setTagInput(e.target.value)}
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
|
||||||
placeholder="Add tag…"
|
placeholder="Add tag…"
|
||||||
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24"
|
className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-24"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleAddTag} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
<button onClick={handleAddTag} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1170,13 +1173,13 @@ export default function TripDetailPage() {
|
|||||||
{/* Invite management — admin only, only when private */}
|
{/* Invite management — admin only, only when private */}
|
||||||
{isAdmin && trip.visibility === "private" && (
|
{isAdmin && trip.visibility === "private" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wider font-mono">Invited</p>
|
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono">Invited</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{(trip.invited_discord_ids ?? []).length === 0 && (
|
{(trip.invited_discord_ids ?? []).length === 0 && (
|
||||||
<span className="text-xs text-gray-600">No invites yet</span>
|
<span className="text-xs text-ink-muted">No invites yet</span>
|
||||||
)}
|
)}
|
||||||
{(trip.invited_discord_ids ?? []).map((discordId) => (
|
{(trip.invited_discord_ids ?? []).map((discordId) => (
|
||||||
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-gray-800 border border-gray-700 rounded-full px-2.5 py-0.5 text-gray-300">
|
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-raised border border-line-strong rounded-full px-2.5 py-0.5 text-ink-2">
|
||||||
<span className="font-mono">{discordId}</span>
|
<span className="font-mono">{discordId}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRevokeInvite(discordId)}
|
onClick={() => handleRevokeInvite(discordId)}
|
||||||
@@ -1192,9 +1195,9 @@ export default function TripDetailPage() {
|
|||||||
onChange={(e) => setInviteInput(e.target.value)}
|
onChange={(e) => setInviteInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
|
||||||
placeholder="Discord user ID…"
|
placeholder="Discord user ID…"
|
||||||
className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-36"
|
className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-36"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleInvite} className="text-xs text-gray-500 hover:text-gray-300 transition-colors">+</button>
|
<button onClick={handleInvite} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1218,13 +1221,13 @@ export default function TripDetailPage() {
|
|||||||
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
|
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
|
||||||
day === selectedDay
|
day === selectedDay
|
||||||
? "bg-indigo-600 text-white"
|
? "bg-indigo-600 text-white"
|
||||||
: "bg-gray-900 border border-gray-800 text-gray-400 hover:border-gray-700 hover:text-gray-200"
|
: "bg-surface border border-line text-ink-2 hover:border-line-strong hover:text-ink"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="font-mono font-bold">{short}</span>
|
<span className="font-mono font-bold">{short}</span>
|
||||||
<span className="font-mono">{date}</span>
|
<span className="font-mono">{date}</span>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-gray-600"}`}>
|
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-ink-muted"}`}>
|
||||||
{count}
|
{count}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -1238,7 +1241,7 @@ export default function TripDetailPage() {
|
|||||||
|
|
||||||
{/* Selected day */}
|
{/* Selected day */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm font-mono text-gray-400 uppercase tracking-wider">
|
<p className="text-sm font-mono text-ink-2 uppercase tracking-wider">
|
||||||
{fmtDayHeading(selectedDay)}
|
{fmtDayHeading(selectedDay)}
|
||||||
</p>
|
</p>
|
||||||
<DayTimeline
|
<DayTimeline
|
||||||
@@ -1254,7 +1257,7 @@ export default function TripDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI Assistant — sticky */}
|
{/* Right: AI Assistant — sticky */}
|
||||||
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
|
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-surface border border-line rounded-xl overflow-hidden">
|
||||||
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
|
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -29,25 +30,25 @@ function TripCard({ trip, isAdmin, onDelete }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-gray-900 border border-gray-800 rounded-xl p-5 cursor-pointer hover:border-gray-700 transition-colors"
|
className="bg-surface border border-line rounded-xl p-5 cursor-pointer hover:border-line-strong transition-colors"
|
||||||
onClick={() => router.push(`/trips/${trip.trip_id}`)}
|
onClick={() => router.push(`/trips/${trip.trip_id}`)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
|
<span className={`text-xs font-mono px-2 py-0.5 rounded-full ${
|
||||||
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-gray-800 text-gray-500"
|
upcoming ? "bg-indigo-900 text-indigo-300" : "bg-raised text-ink-muted"
|
||||||
}`}>
|
}`}>
|
||||||
{upcoming ? "Upcoming" : "Past"}
|
{upcoming ? "Upcoming" : "Past"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
|
<h3 className="text-white font-semibold text-sm leading-snug">{trip.name}</h3>
|
||||||
<p className="text-gray-400 text-xs mt-1">{trip.location}</p>
|
<p className="text-ink-2 text-xs mt-1">{trip.location}</p>
|
||||||
<p className="text-gray-500 text-xs font-mono mt-1">
|
<p className="text-ink-muted text-xs font-mono mt-1">
|
||||||
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}
|
||||||
</p>
|
</p>
|
||||||
{attendeeCount > 0 && (
|
{attendeeCount > 0 && (
|
||||||
<p className="text-gray-500 text-xs mt-1">
|
<p className="text-ink-muted text-xs mt-1">
|
||||||
{attendeeCount} going
|
{attendeeCount} going
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -102,59 +103,59 @@ function CreateModal({ onClose, onCreate }: {
|
|||||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h2 className="text-white font-bold">New Trip</h2>
|
<h2 className="text-white font-bold">New Trip</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Name</label>
|
<label className="text-xs text-ink-2 block mb-1">Name</label>
|
||||||
<input
|
<input
|
||||||
required value={name} onChange={(e) => setName(e.target.value)}
|
required value={name} onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Road trip to Nashville"
|
placeholder="Road trip to Nashville"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Location</label>
|
<label className="text-xs text-ink-2 block mb-1">Location</label>
|
||||||
<input
|
<input
|
||||||
required value={location} onChange={(e) => setLocation(e.target.value)}
|
required value={location} onChange={(e) => setLocation(e.target.value)}
|
||||||
placeholder="Nashville, TN"
|
placeholder="Nashville, TN"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Start date</label>
|
<label className="text-xs text-ink-2 block mb-1">Start date</label>
|
||||||
<input
|
<input
|
||||||
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
|
required type="date" value={start} onChange={(e) => setStart(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">End date</label>
|
<label className="text-xs text-ink-2 block mb-1">End date</label>
|
||||||
<input
|
<input
|
||||||
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
required type="date" value={end} onChange={(e) => setEnd(e.target.value)}
|
||||||
min={start}
|
min={start}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Google Maps link (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Google Maps link (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
|
type="url" value={mapsLink} onChange={(e) => setMapsLink(e.target.value)}
|
||||||
placeholder="https://maps.google.com/…"
|
placeholder="https://maps.google.com/…"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end">
|
<div className="flex gap-3 justify-end">
|
||||||
<button type="button" onClick={onClose} className="text-sm text-gray-400 hover:text-gray-200 px-4 py-2">
|
<button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -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 (
|
||||||
@@ -198,12 +200,12 @@ export default function TripsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{upcoming.length > 0 && (
|
{upcoming.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Upcoming</h2>
|
<h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Upcoming</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcoming.map((t) => (
|
{upcoming.map((t) => (
|
||||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||||
@@ -214,7 +216,7 @@ export default function TripsPage() {
|
|||||||
|
|
||||||
{past.length > 0 && (
|
{past.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-mono text-gray-400 uppercase tracking-wider mb-3">Past</h2>
|
<h2 className="text-sm font-mono text-ink-2 uppercase tracking-wider mb-3">Past</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{past.map((t) => (
|
{past.map((t) => (
|
||||||
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
<TripCard key={t.trip_id} trip={t} isAdmin={isAdmin} onDelete={handleDelete} />
|
||||||
@@ -224,7 +226,7 @@ export default function TripsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{trips.length === 0 && (
|
{trips.length === 0 && (
|
||||||
<p className="text-gray-600 text-sm font-mono">No trips yet.</p>
|
<p className="text-ink-muted text-sm font-mono">No trips yet.</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ export default function WaitlistPage() {
|
|||||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
<span className="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-indigo-600 text-white">D</span>
|
||||||
DRB
|
DRB
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-8 space-y-5 font-mono">
|
<div className="bg-surface border border-line-strong rounded-xl p-8 space-y-5 font-mono">
|
||||||
{done ? (
|
{done ? (
|
||||||
<>
|
<>
|
||||||
<h1 className="text-white text-lg font-bold">You're on the list</h1>
|
<h1 className="text-white text-lg font-bold">You're on the list</h1>
|
||||||
<p className="text-gray-400 text-sm leading-relaxed">
|
<p className="text-ink-2 text-sm leading-relaxed">
|
||||||
Thanks — we'll reach out at the email you gave us. In the meantime, feel free to{" "}
|
Thanks — we'll reach out at the email you gave us. In the meantime, feel free to{" "}
|
||||||
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
|
<Link href="/faq" className="text-indigo-400 hover:text-indigo-300 transition-colors">read the FAQ</Link>.
|
||||||
</p>
|
</p>
|
||||||
@@ -53,39 +53,39 @@ export default function WaitlistPage() {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-white text-lg font-bold">Request access</h1>
|
<h1 className="text-white text-lg font-bold">Request access</h1>
|
||||||
<p className="text-gray-400 text-xs mt-2 leading-relaxed">
|
<p className="text-ink-2 text-xs mt-2 leading-relaxed">
|
||||||
Self-serve signup isn't open yet. Leave your details and we'll follow up to get your organization set up.
|
Self-serve signup isn't open yet. Leave your details and we'll follow up to get your organization set up.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Email</label>
|
<label className="text-xs text-ink-2 block mb-1">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Organization (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Organization (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={orgName}
|
value={orgName}
|
||||||
onChange={(e) => setOrgName(e.target.value)}
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Anything else? (optional)</label>
|
<label className="text-xs text-ink-2 block mb-1">Anything else? (optional)</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => setNote(e.target.value)}
|
onChange={(e) => setNote(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||||
|
|||||||
@@ -85,17 +85,17 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
className={`border-b border-gray-800 font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-gray-900/50" : "hover:bg-gray-900/30"}`}
|
className={`border-b border-line font-mono text-sm ${hasDetails ? "cursor-pointer hover:bg-raised/50" : "hover:bg-raised/30"}`}
|
||||||
onClick={() => hasDetails && setExpanded((v) => !v)}
|
onClick={() => hasDetails && setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 text-gray-400 text-xs whitespace-nowrap">
|
<td className="px-4 py-2 text-ink-2 text-xs whitespace-nowrap">
|
||||||
<span className="text-gray-600">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
<span className="text-ink-muted">{new Date(call.started_at).toLocaleDateString([], { month: "short", day: "numeric" })} </span>
|
||||||
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
{new Date(call.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-300">
|
<td className="px-4 py-2 text-ink-2">
|
||||||
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
|
||||||
{call.tags && call.tags.length > 0 && (
|
{call.tags && call.tags.length > 0 && (
|
||||||
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-gray-800 text-gray-400"}`}>
|
<span className={`ml-2 text-xs px-1.5 py-0.5 rounded-full capitalize ${TAG_COLORS[call.tags[0]] ?? "bg-raised text-ink-2"}`}>
|
||||||
{call.tags[0]}
|
{call.tags[0]}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -104,29 +104,29 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
<span className="ml-2">{severityBadge(call.severity)}</span>
|
<span className="ml-2">{severityBadge(call.severity)}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
<td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{systemName ?? call.system_id ?? "—"}</td>
|
||||||
<td className="px-4 py-2 text-gray-400 hidden sm:table-cell">{call.node_id}</td>
|
<td className="px-4 py-2 text-ink-2 hidden sm:table-cell">{call.node_id}</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<span className="text-orange-400 animate-pulse">● live</span>
|
<span className="text-orange-400 animate-pulse">● live</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
|
<span className="text-ink-muted">{duration(call.started_at, call.ended_at)}</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-xs">
|
<td className="px-4 py-2 text-xs">
|
||||||
{hasAudio ? (
|
{hasAudio ? (
|
||||||
<span className="text-blue-400">▶</span>
|
<span className="text-blue-400">▶</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-700">—</span>
|
<span className="text-ink-muted">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-gray-600 text-xs">
|
<td className="px-4 py-2 text-ink-muted text-xs">
|
||||||
{hasDetails && (expanded ? "▲" : "▼")}
|
{hasDetails && (expanded ? "▲" : "▼")}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{expanded && hasDetails && (
|
{expanded && hasDetails && (
|
||||||
<tr className="bg-gray-900/60 border-b border-gray-800">
|
<tr className="bg-surface/60 border-b border-line">
|
||||||
<td colSpan={7} className="px-6 py-3 space-y-2">
|
<td colSpan={7} className="px-6 py-3 space-y-2">
|
||||||
{/* Audio player */}
|
{/* Audio player */}
|
||||||
{hasAudio && (
|
{hasAudio && (
|
||||||
@@ -140,7 +140,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 font-mono">Loading audio…</p>
|
<p className="text-xs text-ink-muted font-mono">Loading audio…</p>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{call.tags.map((tag) => (
|
{call.tags.map((tag) => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
|
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-raised text-ink-2"}`}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -161,7 +161,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{/* Incident links — one per scene detected in the recording */}
|
{/* Incident links — one per scene detected in the recording */}
|
||||||
{incidentIds.length > 0 && (
|
{incidentIds.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-indigo-400">
|
||||||
<span className="text-gray-600">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
<span className="text-ink-muted">{incidentIds.length === 1 ? "Incident:" : "Incidents:"}</span>
|
||||||
{incidentIds.map((id) => (
|
{incidentIds.map((id) => (
|
||||||
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
<a key={id} href={`/incidents/${id}`} className="underline hover:text-indigo-300">
|
||||||
{id.slice(0, 8)}…
|
{id.slice(0, 8)}…
|
||||||
@@ -172,9 +172,9 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
|
|
||||||
{/* Correlation debug — admin only */}
|
{/* Correlation debug — admin only */}
|
||||||
{isAdmin && call.corr_path && (
|
{isAdmin && call.corr_path && (
|
||||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-gray-600">
|
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs font-mono text-ink-muted">
|
||||||
<span>corr:</span>
|
<span>corr:</span>
|
||||||
<span className="text-gray-400">{call.corr_path}</span>
|
<span className="text-ink-2">{call.corr_path}</span>
|
||||||
{call.corr_incident_idle_min != null && (
|
{call.corr_incident_idle_min != null && (
|
||||||
<span>idle {call.corr_incident_idle_min}min</span>
|
<span>idle {call.corr_incident_idle_min}min</span>
|
||||||
)}
|
)}
|
||||||
@@ -200,7 +200,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
value={editText}
|
value={editText}
|
||||||
onChange={(e) => setEditText(e.target.value)}
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full bg-gray-800 border border-gray-600 rounded-lg px-3 py-2 text-xs text-gray-200 font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-xs text-ink font-mono focus:outline-none focus:border-indigo-500 resize-none"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
@@ -212,7 +212,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
|
onClick={(e) => { e.stopPropagation(); setEditing(false); }}
|
||||||
className="text-xs text-gray-500 hover:text-gray-300 px-3 py-1 transition-colors"
|
className="text-xs text-ink-muted hover:text-ink px-3 py-1 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -224,23 +224,23 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
) : hasSegments ? (
|
) : hasSegments ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-600 font-mono">
|
<span className="text-xs text-ink-muted font-mono">
|
||||||
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
|
{hasBoth && !showOriginal ? "corrected" : `${call.segments!.length} transmissions`}
|
||||||
</span>
|
</span>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{hasBoth && !showOriginal ? (
|
{hasBoth && !showOriginal ? (
|
||||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
<pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-48 overflow-y-auto">
|
||||||
{call.transcript_corrected}
|
{call.transcript_corrected}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-800 rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
<div className="bg-raised rounded-lg px-4 py-3 space-y-2 max-h-48 overflow-y-auto">
|
||||||
{call.segments!.map((seg, i) => (
|
{call.segments!.map((seg, i) => (
|
||||||
<div key={i} className="flex gap-3 text-xs font-mono">
|
<div key={i} className="flex gap-3 text-xs font-mono">
|
||||||
<span className="text-gray-600 shrink-0">{i + 1}. [{seg.start}s]</span>
|
<span className="text-ink-muted shrink-0">{i + 1}. [{seg.start}s]</span>
|
||||||
<span className="text-gray-300">{seg.text}</span>
|
<span className="text-ink-2">{seg.text}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -248,7 +248,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
{hasBoth && (
|
{hasBoth && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
|
||||||
>
|
>
|
||||||
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
||||||
</button>
|
</button>
|
||||||
@@ -258,27 +258,27 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasBoth && <span className="text-xs text-gray-600 font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
{hasBoth && <span className="text-xs text-ink-muted font-mono">{showOriginal ? "original" : "corrected"}</span>}
|
||||||
{!hasBoth && call.transcript_corrected && <span className="text-xs text-gray-600 font-mono">corrected</span>}
|
{!hasBoth && call.transcript_corrected && <span className="text-xs text-ink-muted font-mono">corrected</span>}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors">edit</button>
|
<button onClick={(e) => { e.stopPropagation(); startEdit(); }} className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors">edit</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<pre className="text-xs text-gray-300 bg-gray-800 rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
<pre className="text-xs text-ink-2 bg-raised rounded-lg px-4 py-3 whitespace-pre-wrap font-mono leading-relaxed max-h-40 overflow-y-auto">
|
||||||
{displayTranscript}
|
{displayTranscript}
|
||||||
</pre>
|
</pre>
|
||||||
{hasBoth && (
|
{hasBoth && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
onClick={(e) => { e.stopPropagation(); setShowOriginal((v) => !v); }}
|
||||||
className="text-xs text-gray-600 hover:text-gray-400 font-mono transition-colors"
|
className="text-xs text-ink-muted hover:text-ink-2 font-mono transition-colors"
|
||||||
>
|
>
|
||||||
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
{showOriginal ? "show corrected ↑" : "show original ↓"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
|
<p className="text-xs text-ink-muted font-mono italic">No transcript available.</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -283,11 +283,11 @@ export function CameraFeed({ camera, className, mode = "auto", priority = "norma
|
|||||||
{camera.image_url ? "…" : "No still for this camera"}
|
{camera.image_url ? "…" : "No still for this camera"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[10px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
|
||||||
{badge.text}
|
{badge.text}
|
||||||
</span>
|
</span>
|
||||||
{credit?.attribution && (
|
{credit?.attribution && (
|
||||||
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
|
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[10px] leading-[13px] truncate">
|
||||||
{credit.attribution_url ? (
|
{credit.attribution_url ? (
|
||||||
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
|
||||||
) : credit.attribution}
|
) : credit.attribution}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const RECENT_CALLS = 500;
|
|||||||
const KIOSK_TYPE = [
|
const KIOSK_TYPE = [
|
||||||
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
|
"[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug",
|
||||||
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
|
"[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug",
|
||||||
"[&[data-kiosk]_[class*='text-[9px]']]:text-[18px]",
|
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||||
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
"[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]",
|
||||||
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
|
"[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]",
|
||||||
"[&[data-kiosk]_.w-56]:w-80",
|
"[&[data-kiosk]_.w-56]:w-80",
|
||||||
@@ -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
|
||||||
@@ -575,6 +579,7 @@ export function LiveView() {
|
|||||||
onAir={onAir}
|
onAir={onAir}
|
||||||
camCountByIncident={camCountByIncident}
|
camCountByIncident={camCountByIncident}
|
||||||
pulseIncidentIds={attention.pulseIds}
|
pulseIncidentIds={attention.pulseIds}
|
||||||
|
filteredFrom={filtered.active ? activeIncidents.length : undefined}
|
||||||
key={mapGen}
|
key={mapGen}
|
||||||
initialLayers={mapState.layers}
|
initialLayers={mapState.layers}
|
||||||
initialBase={mapState.base}
|
initialBase={mapState.base}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
@@ -440,7 +440,7 @@ function RadarBadge({ meta, loop, loopClock, onLoop }: {
|
|||||||
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
|
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}>
|
<div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}>
|
||||||
{stale && <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide"
|
{stale && <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold tracking-wide"
|
||||||
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
|
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
|
||||||
<span className="tabular-nums whitespace-nowrap">{text}</span>
|
<span className="tabular-nums whitespace-nowrap">{text}</span>
|
||||||
{meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
|
{meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
|
||||||
@@ -754,12 +754,18 @@ interface OpenCamera { cam: Camera; mode: CameraMode }
|
|||||||
|
|
||||||
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
|
||||||
|
|
||||||
function CameraDock({ map, open, credits, onMode, onClose }: {
|
function CameraDock({ map, open, credits, onMode, onClose, footer }: {
|
||||||
map: L.Map;
|
map: L.Map;
|
||||||
open: Camera[];
|
open: Camera[];
|
||||||
credits: Map<string, CameraCredit>;
|
credits: Map<string, CameraCredit>;
|
||||||
onMode: (id: string, mode: CameraMode) => void;
|
onMode: (id: string, mode: CameraMode) => void;
|
||||||
onClose: (id: string) => void;
|
onClose: (id: string) => void;
|
||||||
|
/**
|
||||||
|
* Stacked under the tiles (server-26#278): the weather status chip and radar
|
||||||
|
* badge live in the same bottom-centre spot, so while the dock is open they
|
||||||
|
* ride in its column instead of being covered by it.
|
||||||
|
*/
|
||||||
|
footer?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -772,8 +778,9 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none"
|
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-col items-center gap-2 max-h-[calc(100%-8rem)] pointer-events-none"
|
||||||
>
|
>
|
||||||
|
<div className="flex flex-wrap-reverse justify-center gap-2 min-h-0 max-w-full overflow-y-auto">
|
||||||
{open.map((c) => (
|
{open.map((c) => (
|
||||||
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
|
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
|
||||||
<div className="flex items-center gap-1 px-1.5 h-5">
|
<div className="flex items-center gap-1 px-1.5 h-5">
|
||||||
@@ -784,12 +791,19 @@ function CameraDock({ map, open, credits, onMode, onClose }: {
|
|||||||
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
|
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
|
||||||
</div>,
|
</div>,
|
||||||
map.getContainer(),
|
map.getContainer(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
|
function DotCameraLayer({ onOpenCamera, dockFooter, onDockedChange }: {
|
||||||
|
onOpenCamera?: (c: Camera) => void;
|
||||||
|
dockFooter?: ReactNode;
|
||||||
|
/** How many tiles are docked bottom-centre, so MapView can move what sits there (server-26#278). */
|
||||||
|
onDockedChange?: (n: number) => void;
|
||||||
|
}) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||||||
const zoom = useMapZoom(map);
|
const zoom = useMapZoom(map);
|
||||||
@@ -818,6 +832,9 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|||||||
const setMode = (id: string, mode: CameraMode) =>
|
const setMode = (id: string, mode: CameraMode) =>
|
||||||
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
|
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
|
||||||
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
|
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
|
||||||
|
const docked = useMemo(() => open.filter((o) => o.mode === "dock").map((o) => o.cam), [open]);
|
||||||
|
useEffect(() => { onDockedChange?.(docked.length); }, [docked.length, onDockedChange]);
|
||||||
|
useEffect(() => () => onDockedChange?.(0), [onDockedChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -849,7 +866,7 @@ function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>
|
||||||
))}
|
))}
|
||||||
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} />
|
<CameraDock map={map} open={docked} credits={feeds} onMode={setMode} onClose={close} footer={dockFooter} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -870,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>
|
||||||
);
|
);
|
||||||
@@ -885,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],
|
||||||
});
|
});
|
||||||
@@ -900,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}
|
||||||
@@ -1029,9 +1046,32 @@ function teardropSvg(type: string | null, severity: string | null | undefined, p
|
|||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Incident icons are cached by their visual inputs (server-26#279). react-leaflet
|
||||||
|
// calls setIcon whenever the icon prop's identity changes, and DivIcon rewrites
|
||||||
|
// innerHTML — so building a fresh icon on every render replaced every pin's DOM
|
||||||
|
// on each parent re-render and restarted the 1.8s pulse ring before it finished.
|
||||||
|
// Same inputs → same L.DivIcon object → no setIcon. Bounded: cleared when large
|
||||||
|
// (keys are a handful of type/severity/precision/pulse combos plus fan stacks).
|
||||||
|
const INCIDENT_ICON_CACHE_MAX = 500;
|
||||||
|
const incidentIconCache = new Map<string, L.DivIcon>();
|
||||||
|
function cachedIcon(key: string, build: () => L.DivIcon): L.DivIcon {
|
||||||
|
let icon = incidentIconCache.get(key);
|
||||||
|
if (!icon) {
|
||||||
|
if (incidentIconCache.size >= INCIDENT_ICON_CACHE_MAX) incidentIconCache.clear();
|
||||||
|
icon = build();
|
||||||
|
incidentIconCache.set(key, icon);
|
||||||
|
}
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
|
|
||||||
function incidentIcon(
|
function incidentIcon(
|
||||||
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
|
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
|
||||||
): L.DivIcon {
|
): L.DivIcon {
|
||||||
|
return cachedIcon(`pin|${type ?? ""}|${severity ?? ""}|${precision}|${pulse ? 1 : 0}`,
|
||||||
|
() => buildIncidentIcon(type, severity, precision, pulse));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIncidentIcon(type: string | null, severity: string | null | undefined, precision: PinPrecision, pulse: boolean): L.DivIcon {
|
||||||
const ring = pulse
|
const ring = pulse
|
||||||
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
|
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
|
||||||
: "";
|
: "";
|
||||||
@@ -1044,6 +1084,11 @@ function incidentIcon(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon {
|
function incidentFanIcon(members: IncidentRecord[], pulse = false): L.DivIcon {
|
||||||
|
const key = `fan|${pulse ? 1 : 0}|${members.map((m) => `${m.type ?? ""}:${m.severity ?? ""}`).join(",")}`;
|
||||||
|
return cachedIcon(key, () => buildIncidentFanIcon(members, pulse));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIncidentFanIcon(members: IncidentRecord[], pulse: boolean): L.DivIcon {
|
||||||
const n = members.length;
|
const n = members.length;
|
||||||
const CARD = 16;
|
const CARD = 16;
|
||||||
const STEP = 9;
|
const STEP = 9;
|
||||||
@@ -1188,8 +1233,8 @@ function FanNodeLayer({
|
|||||||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||||||
>
|
>
|
||||||
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
|
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
|
||||||
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
<p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
|
||||||
<p className="text-xs capitalize text-gray-700">{node.status}</p>
|
<p className="text-xs capitalize text-ink-muted">{node.status}</p>
|
||||||
{activeByNode[node.node_id] && (
|
{activeByNode[node.node_id] && (
|
||||||
<p className="text-xs text-orange-600 mt-0.5">
|
<p className="text-xs text-orange-600 mt-0.5">
|
||||||
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
|
||||||
@@ -1301,8 +1346,8 @@ function FanIncidentLayer({
|
|||||||
<p className="text-xs capitalize font-medium" style={{ color }}>
|
<p className="text-xs capitalize font-medium" style={{ color }}>
|
||||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
|
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
|
||||||
</p>
|
</p>
|
||||||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
{inc.location && <p className="text-xs text-ink-muted">{inc.location}</p>}
|
||||||
<p className="text-[11px] text-gray-500">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
|
<p className="text-[11px] text-ink-muted">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</p>
|
||||||
<a
|
<a
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||||
@@ -1445,19 +1490,19 @@ function IncidentPathLayer({
|
|||||||
>
|
>
|
||||||
<Popup minWidth={160}>
|
<Popup minWidth={160}>
|
||||||
<div className="text-gray-900">
|
<div className="text-gray-900">
|
||||||
<p className="text-xs text-gray-500">{heading}</p>
|
<p className="text-xs text-ink-muted">{heading}</p>
|
||||||
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
||||||
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
|
{c.location && <p className="text-xs text-ink-muted mt-0.5">{c.location}</p>}
|
||||||
{c.location_coords?.location_type === "GEOMETRIC_CENTER" && (
|
{c.location_coords?.location_type === "GEOMETRIC_CENTER" && (
|
||||||
<p className="text-[11px] text-gray-500">Approximate: centre of a road, junction or place</p>
|
<p className="text-[11px] text-ink-muted">Approximate: centre of a road, junction or place</p>
|
||||||
)}
|
)}
|
||||||
{s.dashedIn && !s.ghost && path.movement && (
|
{s.dashedIn && !s.ghost && path.movement && (
|
||||||
<p className="text-[11px] text-gray-500">Dashed in: long gap or talkgroup change — route unknown</p>
|
<p className="text-[11px] text-ink-muted">Dashed in: long gap or talkgroup change — route unknown</p>
|
||||||
)}
|
)}
|
||||||
{hasAudio(c) && (
|
{hasAudio(c) && (
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} />
|
<PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} />
|
||||||
<span className="text-xs text-gray-600">Play this call</span>
|
<span className="text-xs text-ink-muted">Play this call</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||||
@@ -1534,6 +1579,11 @@ interface Props {
|
|||||||
* view last seen with nothing selected is reported instead.
|
* view last seen with nothing selected is reported instead.
|
||||||
*/
|
*/
|
||||||
onViewStateChange?: (state: MapViewState) => void;
|
onViewStateChange?: (state: MapViewState) => void;
|
||||||
|
/**
|
||||||
|
* Set while the caller's incident filter is on (server-26#268): the
|
||||||
|
* unfiltered active count, so the rail header reads "N of M · filtered".
|
||||||
|
*/
|
||||||
|
filteredFrom?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport }
|
export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport }
|
||||||
@@ -1545,10 +1595,15 @@ function currentViewport(map: L.Map): MapViewport {
|
|||||||
return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng });
|
return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({
|
// Memoised (server-26#279): the live wall re-renders every second for its
|
||||||
|
// clock; with stable props that tick no longer re-renders the whole map tree.
|
||||||
|
// MapView keeps its own 10s tick (agoClock) for the rail's relative ages.
|
||||||
|
export default memo(MapView);
|
||||||
|
|
||||||
|
function MapView({
|
||||||
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
|
||||||
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
|
||||||
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange,
|
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -1565,6 +1620,9 @@ export default 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).
|
||||||
@@ -1607,6 +1665,14 @@ export default function MapView({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||||
|
|
||||||
|
const [dockedCams, setDockedCams] = useState(0);
|
||||||
|
const weatherStatus = radarShown || wxShown ? (
|
||||||
|
<>
|
||||||
|
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
|
||||||
|
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
|
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
|
||||||
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
|
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
|
||||||
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
|
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
|
||||||
@@ -1739,6 +1805,7 @@ export default function MapView({
|
|||||||
<TileLayer
|
<TileLayer
|
||||||
url={MAP_TILE_URL}
|
url={MAP_TILE_URL}
|
||||||
attribution={MAP_TILE_ATTRIBUTION}
|
attribution={MAP_TILE_ATTRIBUTION}
|
||||||
|
className={MAP_TILE_URL.includes("dark") ? undefined : "drb-tiles-dark"}
|
||||||
/>
|
/>
|
||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
<LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}>
|
<LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}>
|
||||||
@@ -1792,7 +1859,7 @@ export default function MapView({
|
|||||||
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||||||
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
|
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
|
||||||
<FeatureGroup>
|
<FeatureGroup>
|
||||||
<DotCameraLayer onOpenCamera={onOpenCamera} />
|
<DotCameraLayer onOpenCamera={onOpenCamera} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
|
||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
|
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
|
||||||
@@ -1844,11 +1911,12 @@ export default function MapView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail
|
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail
|
||||||
(left), the legend (right) and the 511/outage banners (bottom-left). */}
|
(left), the legend (right) and the 511/outage banners (bottom-left).
|
||||||
{(radarShown || wxShown) && (
|
While camera tiles are docked in that same spot it moves into the
|
||||||
|
dock's column, under the tiles, so STALE / feed errors stay visible (#278). */}
|
||||||
|
{weatherStatus && dockedCams === 0 && (
|
||||||
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
|
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
|
||||||
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
|
{weatherStatus}
|
||||||
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1866,7 +1934,14 @@ export default 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) => (
|
||||||
@@ -1932,7 +2007,7 @@ export default function MapView({
|
|||||||
<span className="text-ink-2">Call location (not a pursuit)</span>
|
<span className="text-ink-2">Call location (not a pursuit)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[9px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
|
<span className="text-[10px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
|
||||||
<span className="text-ink-2">Path > {MAY_BE_MERGED_KM} km — may be 2+ events</span>
|
<span className="text-ink-2">Path > {MAY_BE_MERGED_KM} km — may be 2+ events</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2006,6 +2081,7 @@ export default function MapView({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||||||
{incidents.length > 0 && (
|
{incidents.length > 0 && (
|
||||||
@@ -2019,7 +2095,10 @@ export default function MapView({
|
|||||||
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
||||||
unit counts are pipeline output. Pinned above the scroll area
|
unit counts are pipeline output. Pinned above the scroll area
|
||||||
so it cannot be scrolled off the screen it qualifies. */}
|
so it cannot be scrolled off the screen it qualifies. */}
|
||||||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
|
<div className="bg-surface border border-line-strong rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto shadow">
|
||||||
|
{filteredFrom != null && (
|
||||||
|
<p className="text-[11px] text-ink-2 mb-1"><b className="text-ink font-semibold tabular-nums">{incidents.length} of {filteredFrom}</b> · filtered</p>
|
||||||
|
)}
|
||||||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
||||||
@@ -2037,15 +2116,16 @@ export default function MapView({
|
|||||||
const latest = (callsByIncident.get(inc.incident_id) ?? [])
|
const latest = (callsByIncident.get(inc.incident_id) ?? [])
|
||||||
.filter(hasAudio)
|
.filter(hasAudio)
|
||||||
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
|
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
|
||||||
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
|
// Opaque, not glass (#136): any see-through over a busy basemap put text on map colour.
|
||||||
|
const baseClass = `w-full text-left bg-surface border shadow rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
|
||||||
const cardBody = (
|
const cardBody = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-1.5 mb-0.5">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} />
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} />
|
||||||
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
|
<span className="uppercase tracking-wide font-semibold text-[11px]" style={{ color }}>
|
||||||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
|
||||||
</span>
|
</span>
|
||||||
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>}
|
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[10px] font-semibold tracking-wide">ON AIR</span>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-ink font-semibold leading-snug truncate">
|
<p className="text-ink font-semibold leading-snug truncate">
|
||||||
{inc.title ?? "Incident"}
|
{inc.title ?? "Incident"}
|
||||||
@@ -2056,9 +2136,9 @@ export default function MapView({
|
|||||||
{onSelectIncident && units.length > 0 && (
|
{onSelectIncident && units.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{units.slice(0, 4).map((u) => (
|
{units.slice(0, 4).map((u) => (
|
||||||
<span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>
|
<span key={u} className="font-mono text-[11px] px-1 rounded bg-raised text-ink-2">{u}</span>
|
||||||
))}
|
))}
|
||||||
{units.length > 4 && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">+{units.length - 4}</span>}
|
{units.length > 4 && <span className="font-mono text-[11px] px-1 rounded bg-raised text-ink-2">+{units.length - 4}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-0.5">
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
@@ -2070,7 +2150,7 @@ export default function MapView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!inc.location_coords && !onSelectIncident && (
|
{!inc.location_coords && !onSelectIncident && (
|
||||||
<p className="text-[10px] text-accent mt-1">View details →</p>
|
<p className="text-[11px] text-accent mt-1">View details →</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2086,7 +2166,7 @@ export default function MapView({
|
|||||||
onClick={() => handleIncidentSelect(inc)}
|
onClick={() => handleIncidentSelect(inc)}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
className={`${baseClass} ${latest ? "pr-10" : ""}`}
|
className={`${baseClass} ${latest ? "pr-10" : ""}`}
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
|
||||||
>
|
>
|
||||||
{cardBody}
|
{cardBody}
|
||||||
</button>
|
</button>
|
||||||
@@ -2099,7 +2179,7 @@ export default function MapView({
|
|||||||
<a
|
<a
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
|
className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
|
||||||
>
|
>
|
||||||
{cardBody}
|
{cardBody}
|
||||||
</a>
|
</a>
|
||||||
@@ -2114,13 +2194,13 @@ export default function MapView({
|
|||||||
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
||||||
className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
|
className="w-full bg-surface border-t border-line-strong px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span>Incidents ({incidents.length})</span>
|
<span>Incidents ({filteredFrom != null ? `${incidents.length} of ${filteredFrom} · filtered` : incidents.length})</span>
|
||||||
<span>{drawerOpen ? "▼" : "▲"}</span>
|
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||||||
</button>
|
</button>
|
||||||
{drawerOpen && (
|
{drawerOpen && (
|
||||||
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
<div className="bg-surface border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||||||
{/* Gate A / A2 (server-26#46) */}
|
{/* Gate A / A2 (server-26#46) */}
|
||||||
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
||||||
{railIncidents.map((inc) => {
|
{railIncidents.map((inc) => {
|
||||||
@@ -2143,7 +2223,7 @@ export default function MapView({
|
|||||||
handleIncidentSelect(inc);
|
handleIncidentSelect(inc);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left border rounded px-2 py-1.5 text-xs"
|
className="w-full text-left border rounded px-2 py-1.5 text-xs"
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -2154,7 +2234,7 @@ export default function MapView({
|
|||||||
key={inc.incident_id}
|
key={inc.incident_id}
|
||||||
href={`/incidents/${inc.incident_id}`}
|
href={`/incidents/${inc.incident_id}`}
|
||||||
className="block w-full text-left border rounded px-2 py-1.5 text-xs"
|
className="block w-full text-left border rounded px-2 py-1.5 text-xs"
|
||||||
style={{ borderColor: color + "55" }}
|
style={{ borderColor: `color-mix(in srgb, ${color} 60%, transparent)` }}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,22 +20,22 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
|||||||
: "never";
|
: "never";
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
|
<div className="bg-surface border border-line rounded-lg p-4 hover:border-line-strong transition-colors cursor-pointer">
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-mono font-semibold text-white">{node.name}</p>
|
<p className="font-mono font-semibold text-white">{node.name}</p>
|
||||||
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
|
<p className="text-xs text-ink-muted font-mono">{node.node_id}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge status={node.status} />
|
<StatusBadge status={node.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 text-xs font-mono text-gray-400">
|
<div className="space-y-1 text-xs font-mono text-ink-2">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>System</span>
|
<span>System</span>
|
||||||
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span>
|
<span className="text-ink-2">{system?.name ?? "Unassigned"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Location</span>
|
<span>Location</span>
|
||||||
<span className="text-gray-300">
|
<span className="text-ink-2">
|
||||||
{node.lat != null && node.lon != null
|
{node.lat != null && node.lon != null
|
||||||
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
|
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
|
||||||
: "Unknown"}
|
: "Unknown"}
|
||||||
@@ -42,23 +43,32 @@ export function NodeCard({ node, system, linkToDetail = true }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Last seen</span>
|
<span>Last seen</span>
|
||||||
<span className="text-gray-300">{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-gray-800 pt-2">
|
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-line pt-2">
|
||||||
⚠ Needs configuration
|
⚠ Needs configuration
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{node.is_overridden && (
|
{node.is_overridden && (
|
||||||
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-gray-800 pt-2 flex justify-between">
|
<div className="mt-3 text-xs text-yellow-500 font-mono border-t border-line pt-2 flex justify-between">
|
||||||
<span>⚠ Local Override</span>
|
<span>⚠ Local Override</span>
|
||||||
{node.override_timeout_at ? (
|
{node.override_timeout_at ? (
|
||||||
<span className="text-gray-500">
|
<span className="text-ink-muted">
|
||||||
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
Resets: {new Date(node.override_timeout_at).toLocaleTimeString()}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-500">Permanent</span>
|
<span className="text-ink-muted">Permanent</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,20 +51,24 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono max-h-[90vh] overflow-y-auto">
|
<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-gray-400 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">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Radio System</label>
|
<label className="block text-xs text-ink-2 mb-1">Radio System</label>
|
||||||
<select
|
<select
|
||||||
value={systemId}
|
value={systemId}
|
||||||
onChange={(e) => setSystemId(e.target.value)}
|
onChange={(e) => setSystemId(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select a system…</option>
|
<option value="">Select a system…</option>
|
||||||
@@ -77,41 +81,41 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">SDR Hardware</label>
|
<label className="block text-xs text-ink-2 mb-1">SDR Hardware</label>
|
||||||
<select
|
<select
|
||||||
value={preset}
|
value={preset}
|
||||||
onChange={(e) => setPreset(e.target.value)}
|
onChange={(e) => setPreset(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{PRESETS.map((p) => (
|
{PRESETS.map((p) => (
|
||||||
<option key={p.value} value={p.value}>{p.label}</option>
|
<option key={p.value} value={p.value}>{p.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{selectedPreset && (
|
{selectedPreset && (
|
||||||
<p className="text-xs text-gray-600 mt-1">{selectedPreset.hint}</p>
|
<p className="text-xs text-ink-muted mt-1">{selectedPreset.hint}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">
|
<label className="block text-xs text-ink-2 mb-1">
|
||||||
PPM offset <span className="text-gray-600">(0 = use preset default; calibrate via OP25 web UI)</span>
|
PPM offset <span className="text-ink-muted">(0 = use preset default; calibrate via OP25 web UI)</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={ppm}
|
value={ppm}
|
||||||
onChange={(e) => setPpm(e.target.value)}
|
onChange={(e) => setPpm(e.target.value)}
|
||||||
step="0.1"
|
step="0.1"
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-indigo-500"
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-400 mb-1">Node Type *</label>
|
<label className="block text-xs text-ink-2 mb-1">Node Type *</label>
|
||||||
<select
|
<select
|
||||||
value={nodeType}
|
value={nodeType}
|
||||||
onChange={(e) => setNodeType(e.target.value)}
|
onChange={(e) => setNodeType(e.target.value)}
|
||||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select node type...</option>
|
<option value="">Select node type...</option>
|
||||||
@@ -127,9 +131,9 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
id="enforceTimeout"
|
id="enforceTimeout"
|
||||||
checked={enforceTimeout}
|
checked={enforceTimeout}
|
||||||
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
onChange={(e) => setEnforceTimeout(e.target.checked)}
|
||||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="enforceTimeout" className="text-xs text-gray-400 cursor-pointer select-none">
|
<label htmlFor="enforceTimeout" className="text-xs text-ink-2 cursor-pointer select-none">
|
||||||
Enforce Local Override Timeout (24 hours)
|
Enforce Local Override Timeout (24 hours)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +152,7 @@ export function NodeConfigModal({ node, systems, onClose }: Props) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
|
className="flex-1 bg-raised hover:bg-gray-700 text-ink-2 rounded-lg py-2 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
|||||||
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
|
: `${encrypted.length} of ${channels.length} channels ${encrypted.length === 1 ? "is" : "are"} now`;
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Conventional Channels</h2>
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-3">Conventional Channels</h2>
|
||||||
{encrypted.length > 0 && (
|
{encrypted.length > 0 && (
|
||||||
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
|
<p role="status" className="text-xs text-red-300 bg-red-950 border border-red-900 rounded-lg px-3 py-2 mb-3 font-mono">
|
||||||
{subject} encrypted — those calls can't be decoded, so nothing from{" "}
|
{subject} encrypted — those calls can't be decoded, so nothing from{" "}
|
||||||
@@ -37,17 +37,17 @@ export function RadioChannels({ channels }: { channels?: RadioChannel[] | null }
|
|||||||
)}
|
)}
|
||||||
<ul className="space-y-1 font-mono text-xs">
|
<ul className="space-y-1 font-mono text-xs">
|
||||||
{channels.map((c, i) => (
|
{channels.map((c, i) => (
|
||||||
<li key={i} className="flex flex-wrap gap-x-3 text-gray-300">
|
<li key={i} className="flex flex-wrap gap-x-3 text-ink-2">
|
||||||
<span className="text-white">{c.name || mhz(c.freq)}</span>
|
<span className="text-white">{c.name || mhz(c.freq)}</span>
|
||||||
{c.name && <span className="text-gray-500">{mhz(c.freq)}</span>}
|
{c.name && <span className="text-ink-muted">{mhz(c.freq)}</span>}
|
||||||
{c.nac != null && <span className="text-gray-500">NAC {hex(c.nac, 3)}</span>}
|
{c.nac != null && <span className="text-ink-muted">NAC {hex(c.nac, 3)}</span>}
|
||||||
{c.encrypted ? (
|
{c.encrypted ? (
|
||||||
<span className="text-red-400">
|
<span className="text-red-400">
|
||||||
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
|
✕ Encrypted{c.algid != null ? ` · ${ALGIDS[c.algid] ?? `ALGID ${hex(c.algid, 2)}`}` : ""}
|
||||||
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
|
{c.keyid != null ? ` · key ${hex(c.keyid, 4)}` : ""}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">
|
<span className="text-ink-2">
|
||||||
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
|
{c.clear_calls > 0 ? `Clear · ${c.clear_calls} calls heard` : "No traffic heard yet"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function rowsFrom(priority: string[], pins: Record<string, string>): Row[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectClass =
|
const selectClass =
|
||||||
"bg-gray-800 border border-gray-700 rounded px-2 py-1 text-gray-200 text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
"bg-raised border border-line-strong rounded px-2 py-1 text-ink text-xs focus:outline-none focus:border-indigo-500 disabled:opacity-60 max-w-[13rem]";
|
||||||
|
|
||||||
function DeviceSelect({
|
function DeviceSelect({
|
||||||
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
value, devices, devicesKnown, autoLabel, label, disabled, onChange,
|
||||||
@@ -116,18 +116,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-1">SDRs</h2>
|
<h2 className="text-sm font-semibold text-ink-2 uppercase tracking-wider mb-1">SDRs</h2>
|
||||||
<p className="text-xs text-gray-500 font-mono mb-3">
|
<p className="text-xs text-ink-muted font-mono mb-3">
|
||||||
{devicesKnown
|
{devicesKnown
|
||||||
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
? `This node reports ${devices.length} SDR${devices.length === 1 ? "" : "s"}.`
|
||||||
: "This node hasn't reported its SDRs yet."}
|
: "This node hasn't reported its SDRs yet."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg divide-y divide-gray-800 font-mono text-sm">
|
<div className="bg-surface border border-line rounded-lg divide-y divide-line font-mono text-sm">
|
||||||
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
<div className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-gray-200">OP25 SDR</div>
|
<div className="text-ink">OP25 SDR</div>
|
||||||
<div className="text-xs text-gray-500">
|
<div className="text-xs text-ink-muted">
|
||||||
{op25Moving
|
{op25Moving
|
||||||
? "Saving restarts OP25 on the selected SDR."
|
? "Saving restarts OP25 on the selected SDR."
|
||||||
: node.op25_sdr_serial
|
: node.op25_sdr_serial
|
||||||
@@ -162,18 +162,18 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
: "Waiting for SDR";
|
: "Waiting for SDR";
|
||||||
return (
|
return (
|
||||||
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
<div key={row.mode} className="flex flex-wrap items-center gap-3 px-4 py-2.5">
|
||||||
<span className="w-4 text-right text-gray-600 text-xs">{row.enabled ? ++rank : ""}</span>
|
<span className="w-4 text-right text-ink-muted text-xs">{row.enabled ? ++rank : ""}</span>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={row.enabled}
|
checked={row.enabled}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
aria-label={`Enable ${meta.name}`}
|
aria-label={`Enable ${meta.name}`}
|
||||||
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
onChange={(e) => edit(rows.map((r, j) => (j === i ? { ...r, enabled: e.target.checked } : r)))}
|
||||||
className="rounded bg-gray-800 border-gray-700 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
className="rounded bg-raised border-line-strong text-indigo-600 focus:ring-indigo-500 focus:ring-offset-gray-900"
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-[7rem]">
|
<div className="flex-1 min-w-[7rem]">
|
||||||
<div className="text-gray-200">{meta.name}</div>
|
<div className="text-ink">{meta.name}</div>
|
||||||
<div className="text-xs text-gray-500">{meta.hint}</div>
|
<div className="text-xs text-ink-muted">{meta.hint}</div>
|
||||||
</div>
|
</div>
|
||||||
<DeviceSelect
|
<DeviceSelect
|
||||||
value={row.pin}
|
value={row.pin}
|
||||||
@@ -191,7 +191,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
onClick={() => move(i, -1)}
|
onClick={() => move(i, -1)}
|
||||||
disabled={i === 0}
|
disabled={i === 0}
|
||||||
aria-label={`Move ${meta.name} up`}
|
aria-label={`Move ${meta.name} up`}
|
||||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
▲
|
▲
|
||||||
</button>
|
</button>
|
||||||
@@ -200,13 +200,13 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
onClick={() => move(i, 1)}
|
onClick={() => move(i, 1)}
|
||||||
disabled={i === rows.length - 1}
|
disabled={i === rows.length - 1}
|
||||||
aria-label={`Move ${meta.name} down`}
|
aria-label={`Move ${meta.name} down`}
|
||||||
className="w-7 h-7 rounded bg-gray-800 hover:bg-gray-700 text-gray-300 disabled:opacity-30"
|
className="w-7 h-7 rounded bg-raised hover:bg-gray-700 text-ink-2 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-gray-500"}`}>
|
<span className={`w-32 text-right text-xs ${state === "Running" ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{state}
|
{state}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,7 +214,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 font-mono mt-2">
|
<p className="text-xs text-ink-muted font-mono mt-2">
|
||||||
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
Every SDR besides OP25's runs the next enabled service, top first. Pin a service to the SDR with its
|
||||||
antenna, or leave it on "Any spare SDR".
|
antenna, or leave it on "Any spare SDR".
|
||||||
</p>
|
</p>
|
||||||
@@ -235,7 +235,7 @@ export function SdrSettings({ node, canEdit }: { node: NodeRecord; canEdit: bool
|
|||||||
>
|
>
|
||||||
{saving ? "Saving…" : "Save"}
|
{saving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
{message && <span className="text-xs text-gray-500 font-mono">{message}</span>}
|
{message && <span className="text-xs text-ink-muted font-mono">{message}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { NodeStatus } from "@/lib/types";
|
|||||||
const styles: Record<NodeStatus, string> = {
|
const styles: Record<NodeStatus, string> = {
|
||||||
online: "bg-green-950 text-green-400 border border-green-800",
|
online: "bg-green-950 text-green-400 border border-green-800",
|
||||||
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
|
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
|
||||||
offline: "bg-gray-900 text-gray-500 border border-gray-700",
|
offline: "bg-surface text-ink-muted border border-line-strong",
|
||||||
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
|
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -58,16 +58,16 @@ export function CameraProvidersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<p className="text-gray-400 text-xs leading-snug">
|
<p className="text-ink-2 text-xs leading-snug">
|
||||||
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
||||||
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
|
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
|
||||||
Required attributions render on every camera tile.
|
Required attributions render on every camera tile.
|
||||||
</p>
|
</p>
|
||||||
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
||||||
{!providers ? (
|
{!providers ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
{providers.map((p) => {
|
{providers.map((p) => {
|
||||||
const lic = LICENCE_STYLE[p.licence.status];
|
const lic = LICENCE_STYLE[p.licence.status];
|
||||||
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
||||||
@@ -77,8 +77,8 @@ export function CameraProvidersTab() {
|
|||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
||||||
<p className="text-white text-sm font-semibold">{p.name}</p>
|
<p className="text-white text-sm font-semibold">{p.name}</p>
|
||||||
<p className="text-gray-400 text-xs">{p.licence.label}</p>
|
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||||
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p>
|
<p className="text-ink-muted text-xs">{p.region} · {p.formats}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right space-y-1">
|
<div className="shrink-0 text-right space-y-1">
|
||||||
<button
|
<button
|
||||||
@@ -93,25 +93,25 @@ export function CameraProvidersTab() {
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
</button>
|
</button>
|
||||||
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
|
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-ink-2">
|
||||||
<span>cameras: {p.health ? p.health.count : "—"}</span>
|
<span>cameras: {p.health ? p.health.count : "—"}</span>
|
||||||
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
||||||
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
||||||
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
|
||||||
</div>
|
</div>
|
||||||
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
||||||
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>}
|
{p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with imagery: “{p.licence.attribution}”</p>}
|
||||||
{p.acknowledgement && (
|
{p.acknowledgement && (
|
||||||
<p className="text-[11px] text-amber-300">
|
<p className="text-[11px] text-amber-300">
|
||||||
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<details className="text-[11px] text-gray-500">
|
<details className="text-[11px] text-ink-muted">
|
||||||
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
||||||
<p className="mt-1">{p.licence.terms}</p>
|
<p className="mt-1">{p.licence.terms}</p>
|
||||||
<p className="mt-1">{p.notes}</p>
|
<p className="mt-1">{p.notes}</p>
|
||||||
@@ -129,16 +129,16 @@ export function CameraProvidersTab() {
|
|||||||
|
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
|
||||||
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
<div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
||||||
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
||||||
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
||||||
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
|
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||||
<label className="flex items-start gap-2 text-sm text-gray-200">
|
<label className="flex items-start gap-2 text-sm text-ink">
|
||||||
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
||||||
<span>I confirm DRB holds the right to show this source's imagery. This confirmation is recorded with my account and the time in the audit log.</span>
|
<span>I confirm DRB holds the right to show this source's imagery. This confirmation is recorded with my account and the time in the audit log.</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
|
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
|
||||||
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
||||||
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function OutageProvidersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<p className="text-gray-400 text-xs leading-snug">
|
<p className="text-ink-2 text-xs leading-snug">
|
||||||
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
Power-outage sources for the map. Every provider is <b>off</b> until enabled here. Licence status comes from the
|
||||||
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
|
source-terms research on server-26#184 (2026-10-05); a red or amber licence means DRB does not hold a confirmed right
|
||||||
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
|
to show that data. Scraped utility outage maps are never shipped — those sources stay locked until a data-sharing
|
||||||
@@ -68,9 +68,9 @@ export function OutageProvidersTab() {
|
|||||||
</p>
|
</p>
|
||||||
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
|
||||||
{!providers ? (
|
{!providers ? (
|
||||||
<p className="text-gray-500 text-sm font-mono">Loading…</p>
|
<p className="text-ink-muted text-sm font-mono">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
|
<div className="bg-surface border border-line rounded-xl divide-y divide-line">
|
||||||
{providers.map((p) => {
|
{providers.map((p) => {
|
||||||
const lic = LICENCE_STYLE[p.licence.status];
|
const lic = LICENCE_STYLE[p.licence.status];
|
||||||
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
|
||||||
@@ -80,8 +80,8 @@ export function OutageProvidersTab() {
|
|||||||
<div className="min-w-0 space-y-1">
|
<div className="min-w-0 space-y-1">
|
||||||
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
|
||||||
<p className="text-white text-sm font-semibold">{p.name}</p>
|
<p className="text-white text-sm font-semibold">{p.name}</p>
|
||||||
<p className="text-gray-400 text-xs">{p.licence.label}</p>
|
<p className="text-ink-2 text-xs">{p.licence.label}</p>
|
||||||
<p className="text-gray-500 text-xs">{p.region} · {p.granularity}</p>
|
<p className="text-ink-muted text-xs">{p.region} · {p.granularity}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right space-y-1">
|
<div className="shrink-0 text-right space-y-1">
|
||||||
<button
|
<button
|
||||||
@@ -96,27 +96,27 @@ export function OutageProvidersTab() {
|
|||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
|
||||||
</button>
|
</button>
|
||||||
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
|
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-ink-muted"}`}>
|
||||||
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{p.implemented && (
|
{p.implemented && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-gray-400">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-[11px] font-mono text-ink-2">
|
||||||
<span>counties out: {p.health ? p.health.count : "—"}</span>
|
<span>counties out: {p.health ? p.health.count : "—"}</span>
|
||||||
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
|
||||||
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
|
||||||
{p.coverage_note && <p className="text-[11px] text-gray-400">{p.coverage_note}</p>}
|
{p.coverage_note && <p className="text-[11px] text-ink-2">{p.coverage_note}</p>}
|
||||||
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with the data: “{p.licence.attribution}”</p>}
|
{p.licence.attribution && <p className="text-[11px] text-ink-2">Attribution shown with the data: “{p.licence.attribution}”</p>}
|
||||||
{p.acknowledgement && (
|
{p.acknowledgement && (
|
||||||
<p className="text-[11px] text-amber-300">
|
<p className="text-[11px] text-amber-300">
|
||||||
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<details className="text-[11px] text-gray-500">
|
<details className="text-[11px] text-ink-muted">
|
||||||
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
|
||||||
<p className="mt-1">{p.licence.terms}</p>
|
<p className="mt-1">{p.licence.terms}</p>
|
||||||
<p className="mt-1">{p.notes}</p>
|
<p className="mt-1">{p.notes}</p>
|
||||||
@@ -134,17 +134,17 @@ export function OutageProvidersTab() {
|
|||||||
|
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
|
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="outage-ack-title">
|
||||||
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
<div className="bg-surface border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
|
||||||
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
<h2 id="outage-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
|
||||||
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
|
||||||
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
|
<p className="text-ink-2 text-sm leading-snug">{confirming.licence.warning}</p>
|
||||||
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
|
{error && <p className="text-red-400 text-xs font-mono break-words">{error}</p>}
|
||||||
<label className="flex items-start gap-2 text-sm text-gray-200">
|
<label className="flex items-start gap-2 text-sm text-ink">
|
||||||
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
|
||||||
<span>I confirm DRB holds the right to show this source's outage data. This confirmation is recorded with my account and the time in the audit log.</span>
|
<span>I confirm DRB holds the right to show this source's outage data. This confirmation is recorded with my account and the time in the audit log.</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
|
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-line-strong text-ink-2">Cancel</button>
|
||||||
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
|
||||||
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ const MODES: { key: ReplayMode; label: string; help: string }[] = [
|
|||||||
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
const SIGNAL_RESOLVES = ["units_cleared", "llm_closure", "reassignment", "children_resolved"];
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
"bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
"bg-raised border border-line-strong rounded-lg px-3 py-1.5 text-white text-sm font-mono focus:outline-none focus:border-indigo-500";
|
||||||
const btn =
|
const btn =
|
||||||
"bg-gray-800 hover:bg-gray-700 disabled:opacity-50 border border-gray-700 text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
"bg-raised hover:bg-gray-700 disabled:opacity-50 border border-line-strong text-white text-sm font-mono px-4 py-1.5 rounded-lg transition-colors";
|
||||||
|
|
||||||
function toLocalInput(d: Date): string {
|
function toLocalInput(d: Date): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
@@ -88,18 +88,18 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
const canStart = est && !est.truncated && est.calls > 0 && !busy && (mode !== "reuse" || sourceRun);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-4">
|
<div className="bg-surface border border-line rounded-xl p-4 space-y-4">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">From</label>
|
<label className="text-xs text-ink-2 block mb-1">From</label>
|
||||||
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
<input type="datetime-local" value={from} max={to} onChange={(e) => setFrom(e.target.value)} className={input} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">To</label>
|
<label className="text-xs text-ink-2 block mb-1">To</label>
|
||||||
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
<input type="datetime-local" value={to} min={from} onChange={(e) => setTo(e.target.value)} className={input} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Label</label>
|
<label className="text-xs text-ink-2 block mb-1">Label</label>
|
||||||
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
<input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="what changed?" className={`${input} w-56`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,14 +109,14 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
|
<label key={m.key} className="flex items-start gap-2 text-sm font-mono cursor-pointer">
|
||||||
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
<input type="radio" checked={mode === m.key} onChange={() => setMode(m.key)} className="mt-1" />
|
||||||
<span className="text-white">{m.label}</span>
|
<span className="text-white">{m.label}</span>
|
||||||
<span className="text-gray-500 text-xs mt-0.5">{m.help}</span>
|
<span className="text-ink-muted text-xs mt-0.5">{m.help}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === "reuse" && (
|
{mode === "reuse" && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 block mb-1">Reuse extraction from</label>
|
<label className="text-xs text-ink-2 block mb-1">Reuse extraction from</label>
|
||||||
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
<select value={sourceRun} onChange={(e) => setSourceRun(e.target.value)} className={input}>
|
||||||
<option value="">— pick a finished run —</option>
|
<option value="">— pick a finished run —</option>
|
||||||
{sources.map((r) => (
|
{sources.map((r) => (
|
||||||
@@ -125,15 +125,15 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<p className="text-xs text-gray-500 mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
<p className="text-xs text-ink-muted mt-1">Set the range to match that run; calls outside it are skipped.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{systems.length > 1 && (
|
{systems.length > 1 && (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span className="text-xs text-gray-400">Systems (none = all):</span>
|
<span className="text-xs text-ink-2">Systems (none = all):</span>
|
||||||
{systems.map((s) => (
|
{systems.map((s) => (
|
||||||
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-gray-300 cursor-pointer">
|
<label key={s.system_id} className="flex items-center gap-1 text-xs font-mono text-ink-2 cursor-pointer">
|
||||||
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
<input type="checkbox" checked={systemIds.includes(s.system_id)} onChange={() => toggleSystem(s.system_id)} />
|
||||||
{s.name}
|
{s.name}
|
||||||
</label>
|
</label>
|
||||||
@@ -154,7 +154,7 @@ function NewRunForm({ runs, busy, onStarted }: { runs: ReplayRun[]; busy: boolea
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{est && (
|
{est && (
|
||||||
<p className="text-sm font-mono text-gray-300">
|
<p className="text-sm font-mono text-ink-2">
|
||||||
{est.truncated ? (
|
{est.truncated ? (
|
||||||
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
<span className="text-red-400">More than {est.max_calls} calls — narrow the range.</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -193,15 +193,15 @@ function RunsTable({
|
|||||||
onChanged();
|
onChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!runs.length) return <p className="text-sm text-gray-500 font-mono">No runs yet.</p>;
|
if (!runs.length) return <p className="text-sm text-ink-muted font-mono">No runs yet.</p>;
|
||||||
|
|
||||||
const th = "text-left text-xs text-gray-500 font-normal px-2 py-1.5 whitespace-nowrap";
|
const th = "text-left text-xs text-ink-muted font-normal px-2 py-1.5 whitespace-nowrap";
|
||||||
const td = "px-2 py-1.5 whitespace-nowrap";
|
const td = "px-2 py-1.5 whitespace-nowrap";
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm font-mono">
|
<table className="w-full text-sm font-mono">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-gray-800">
|
<tr className="border-b border-line">
|
||||||
<th className={th}>Run</th>
|
<th className={th}>Run</th>
|
||||||
<th className={th}>Range</th>
|
<th className={th}>Range</th>
|
||||||
<th className={th}>Status</th>
|
<th className={th}>Status</th>
|
||||||
@@ -222,13 +222,13 @@ function RunsTable({
|
|||||||
<tr
|
<tr
|
||||||
key={r.run_id}
|
key={r.run_id}
|
||||||
onClick={() => onSelect(r.run_id)}
|
onClick={() => onSelect(r.run_id)}
|
||||||
className={`border-b border-gray-800/60 cursor-pointer ${selected === r.run_id ? "bg-gray-800/60" : "hover:bg-gray-900"}`}
|
className={`border-b border-line/60 cursor-pointer ${selected === r.run_id ? "bg-raised/60" : "hover:bg-raised"}`}
|
||||||
>
|
>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
<div className="text-white">{r.label || r.run_id}</div>
|
<div className="text-white">{r.label || r.run_id}</div>
|
||||||
<div className="text-xs text-gray-500">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
<div className="text-xs text-ink-muted">{r.mode} · {r.git_sha?.slice(0, 7)} · {fmtTime(r.created_at)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className={`${td} text-xs text-gray-400`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
<td className={`${td} text-xs text-ink-2`}>{fmtTime(r.date_from)} → {fmtTime(r.date_to)}</td>
|
||||||
<td className={td}>
|
<td className={td}>
|
||||||
{running ? (
|
{running ? (
|
||||||
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
<span className="text-amber-400">{r.progress.done}/{r.progress.total}</span>
|
||||||
@@ -245,7 +245,7 @@ function RunsTable({
|
|||||||
<>
|
<>
|
||||||
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
<span className="text-green-400">{sum(m.resolved_via, SIGNAL_RESOLVES)}</span>
|
||||||
{" / "}
|
{" / "}
|
||||||
<span className="text-gray-400">{m.resolved_via.idle_timeout ?? 0}</span>
|
<span className="text-ink-2">{m.resolved_via.idle_timeout ?? 0}</span>
|
||||||
</>
|
</>
|
||||||
) : "—"}
|
) : "—"}
|
||||||
</td>
|
</td>
|
||||||
@@ -255,7 +255,7 @@ function RunsTable({
|
|||||||
{running ? (
|
{running ? (
|
||||||
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
|
<button onClick={() => cancel(r.run_id)} className="text-xs text-amber-400 hover:text-amber-300">cancel</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={() => remove(r.run_id)} className="text-xs text-gray-500 hover:text-red-400">delete</button>
|
<button onClick={() => remove(r.run_id)} className="text-xs text-ink-muted hover:text-red-400">delete</button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -278,21 +278,21 @@ function CallLine({ call }: { call: ReplayCallRow }) {
|
|||||||
} catch { /* audio is a convenience */ }
|
} catch { /* audio is a convenience */ }
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="py-1.5 border-t border-gray-800/60 text-xs font-mono">
|
<div className="py-1.5 border-t border-line/60 text-xs font-mono">
|
||||||
<div className="flex flex-wrap gap-2 text-gray-500">
|
<div className="flex flex-wrap gap-2 text-ink-muted">
|
||||||
<span>{fmtTime(call.started_at)}</span>
|
<span>{fmtTime(call.started_at)}</span>
|
||||||
<span>{call.talkgroup_name}</span>
|
<span>{call.talkgroup_name}</span>
|
||||||
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
|
{call.corr_path.map((p, i) => <span key={i} className="text-indigo-400">{p}</span>)}
|
||||||
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
|
{call.units?.length ? <span>units {call.units.join(", ")}</span> : null}
|
||||||
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
|
{call.cleared_units?.length ? <span className="text-green-400">cleared {call.cleared_units.join(", ")}</span> : null}
|
||||||
{call.skip_reason && <span className="text-gray-600">{call.skip_reason}</span>}
|
{call.skip_reason && <span className="text-ink-muted">{call.skip_reason}</span>}
|
||||||
{audio ? (
|
{audio ? (
|
||||||
<audio src={audio} controls autoPlay className="h-6" />
|
<audio src={audio} controls autoPlay className="h-6" />
|
||||||
) : (
|
) : (
|
||||||
<button onClick={play} className="text-gray-400 hover:text-white">▶ audio</button>
|
<button onClick={play} className="text-ink-2 hover:text-white">▶ audio</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-gray-200 mt-0.5">{call.transcript || <span className="text-gray-600">(no transcript)</span>}</div>
|
<div className="text-ink mt-0.5">{call.transcript || <span className="text-ink-muted">(no transcript)</span>}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -301,13 +301,13 @@ function IncidentCard({ inc }: { inc: ReplayIncident }) {
|
|||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
const signal = inc.resolved_via && SIGNAL_RESOLVES.includes(inc.resolved_via);
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
<div className="bg-surface border border-line rounded-lg px-3 py-2">
|
||||||
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
|
<button onClick={() => setOpen(!open)} className="w-full text-left flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-mono">
|
||||||
<span className="text-gray-500">{open ? "▾" : "▸"}</span>
|
<span className="text-ink-muted">{open ? "▾" : "▸"}</span>
|
||||||
<span className="text-white">{inc.title || inc.incident_id}</span>
|
<span className="text-white">{inc.title || inc.incident_id}</span>
|
||||||
<span className="text-gray-500 text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
<span className="text-ink-muted text-xs">{inc.calls.length} call{inc.calls.length !== 1 ? "s" : ""}</span>
|
||||||
<span className="text-gray-500 text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
<span className="text-ink-muted text-xs">{fmtTime(inc.started_at)} → {fmtTime(inc.resolved_at)}</span>
|
||||||
<span className={`text-xs ${signal ? "text-green-400" : "text-gray-500"}`}>{inc.resolved_via ?? inc.status}</span>
|
<span className={`text-xs ${signal ? "text-green-400" : "text-ink-muted"}`}>{inc.resolved_via ?? inc.status}</span>
|
||||||
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
{inc.location_coords && <span className="text-xs text-indigo-400">📍 {inc.location}</span>}
|
||||||
</button>
|
</button>
|
||||||
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
{open && <div className="mt-2">{inc.calls.map((c) => <CallLine key={c.call_id} call={c} />)}</div>}
|
||||||
@@ -347,22 +347,22 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
|||||||
["With units cleared", m.incidents_with_units_cleared],
|
["With units cleared", m.incidents_with_units_cleared],
|
||||||
["With map pin", m.incidents_with_coords],
|
["With map pin", m.incidents_with_coords],
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k as string} className="bg-gray-900 border border-gray-800 rounded-lg p-2">
|
<div key={k as string} className="bg-surface border border-line rounded-lg p-2">
|
||||||
<div className="text-gray-500">{k}</div>
|
<div className="text-ink-muted">{k}</div>
|
||||||
<div className="text-white text-base">{v}</div>
|
<div className="text-white text-base">{v}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{m && (
|
{m && (
|
||||||
<p className="text-xs font-mono text-gray-500">
|
<p className="text-xs font-mono text-ink-muted">
|
||||||
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
resolved: {Object.entries(m.resolved_via).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||||
<br />
|
<br />
|
||||||
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
paths: {Object.entries(m.corr_path).map(([k, v]) => `${k} ${v}`).join(" · ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
|
{m?.gemini_usage && Object.keys(m.gemini_usage).length > 0 && (
|
||||||
<p className="text-xs font-mono text-gray-500">
|
<p className="text-xs font-mono text-ink-muted">
|
||||||
Gemini tokens:{" "}
|
Gemini tokens:{" "}
|
||||||
{Object.entries(m.gemini_usage)
|
{Object.entries(m.gemini_usage)
|
||||||
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
|
.map(([k, u]) => `${k} ${u.calls} calls, in ${u.in.toLocaleString()} / out ${u.out.toLocaleString()} / thinking ${u.thinking.toLocaleString()}`)
|
||||||
@@ -380,7 +380,7 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
|||||||
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
{run.errors.map((e, i) => <div key={i}>{e}</div>)}
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{run.status === "running" && <p className="text-sm text-gray-500 font-mono">Incidents appear when the run finishes.</p>}
|
{run.status === "running" && <p className="text-sm text-ink-muted font-mono">Incidents appear when the run finishes.</p>}
|
||||||
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
{error && <p className="text-red-400 text-sm font-mono">{error}</p>}
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
@@ -389,20 +389,20 @@ function RunDetail({ run }: { run: ReplayRun }) {
|
|||||||
<button
|
<button
|
||||||
key={f}
|
key={f}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f)}
|
||||||
className={`px-3 py-1 rounded-md ${filter === f ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
className={`px-3 py-1 rounded-md ${filter === f ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
|
||||||
>
|
>
|
||||||
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
{f === "all" ? `All (${data.incidents.length})` : f === "multi" ? "Multi-call" : "Single-call"}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowOrphans(!showOrphans)}
|
onClick={() => setShowOrphans(!showOrphans)}
|
||||||
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-gray-800 text-white" : "text-gray-500 hover:text-gray-300"}`}
|
className={`px-3 py-1 rounded-md ${showOrphans ? "bg-raised text-white" : "text-ink-muted hover:text-ink"}`}
|
||||||
>
|
>
|
||||||
Orphans ({data.orphans.length})
|
Orphans ({data.orphans.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{showOrphans ? (
|
{showOrphans ? (
|
||||||
<div className="bg-gray-900 border border-gray-800 rounded-lg px-3 py-2">
|
<div className="bg-surface border border-line rounded-lg px-3 py-2">
|
||||||
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
{data.orphans.map((c) => <CallLine key={c.call_id} call={c} />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -443,7 +443,7 @@ export function ReplayTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<p className="text-xs text-gray-500 font-mono">
|
<p className="text-xs text-ink-muted font-mono">
|
||||||
Re-run the pipeline over a past time range, in the calls' original order, into a sandbox — live incidents are never
|
Re-run the pipeline over a past time range, in the calls' original order, into a sandbox — live incidents are never
|
||||||
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
|
touched. Replay the same range after each change and compare the rows below. A run starts with no incidents
|
||||||
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
open, so its first ~90 minutes split more than live did — compare runs over the same range, not a run against live.
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ function sevColor(s: string | null | undefined) {
|
|||||||
function sevLabel(s: string | null | undefined) {
|
function sevLabel(s: string | null | undefined) {
|
||||||
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
|
||||||
}
|
}
|
||||||
function elapsed(iso: string): string {
|
/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
|
||||||
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
|
function elapsed(iso: string, endIso?: string | null): string {
|
||||||
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
||||||
|
const m = Math.max(0, Math.round((end - new Date(iso).getTime()) / 60_000));
|
||||||
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
||||||
}
|
}
|
||||||
function hhmm(iso: string): string {
|
function hhmm(iso: string): string {
|
||||||
@@ -49,7 +51,7 @@ export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: Came
|
|||||||
return (
|
return (
|
||||||
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
||||||
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
||||||
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[9px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[10px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
||||||
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -275,7 +277,7 @@ function WindContext({ head }: { head: LatLon | null }) {
|
|||||||
{calm ? "" : ` ${dir}`}
|
{calm ? "" : ` ${dir}`}
|
||||||
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
|
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
|
||||||
{obsLabel ? ` · observed ${obsLabel}` : ""}
|
{obsLabel ? ` · observed ${obsLabel}` : ""}
|
||||||
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[9px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
|
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
|
||||||
<SourceCredit credit={METAR_CREDIT}
|
<SourceCredit credit={METAR_CREDIT}
|
||||||
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
|
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
|
||||||
</li>
|
</li>
|
||||||
@@ -348,7 +350,7 @@ export function RegionPanel({
|
|||||||
{wallCameras.map(({ cam, km }) => (
|
{wallCameras.map(({ cam, km }) => (
|
||||||
<div key={cam.id} className="relative">
|
<div key={cam.id} className="relative">
|
||||||
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
||||||
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[9px] px-1 rounded-sm">PINNED</span>}
|
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[10px] px-1 rounded-sm">PINNED</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -422,6 +424,13 @@ export function IncidentPanel({
|
|||||||
onOpenCamera: (c: Camera) => void;
|
onOpenCamera: (c: Camera) => void;
|
||||||
}) {
|
}) {
|
||||||
const color = sevColor(inc.severity);
|
const color = sevColor(inc.severity);
|
||||||
|
// A resolved incident opened from an #inc= link (server-26#275): nothing in
|
||||||
|
// the panel may read as still running. Its duration freezes at resolved_at
|
||||||
|
// (the correlator writes it; the frontend type doesn't declare it yet), and
|
||||||
|
// the live camera / context feeds are labelled as "now", not incident time.
|
||||||
|
const resolved = inc.status === "resolved";
|
||||||
|
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
|
||||||
|
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
|
||||||
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
||||||
const head = points.at(-1) ?? null;
|
const head = points.at(-1) ?? null;
|
||||||
const radar = useRadarDbz(head);
|
const radar = useRadarDbz(head);
|
||||||
@@ -455,13 +464,17 @@ export function IncidentPanel({
|
|||||||
<div className="flex items-center gap-2 text-[11px] mb-1">
|
<div className="flex items-center gap-2 text-[11px] mb-1">
|
||||||
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
||||||
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
||||||
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold">ON AIR</span> : <span className="text-ink-muted">active</span>}
|
{resolved ? <span className="text-ink-muted">resolved</span>
|
||||||
|
: onAir ? <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold">ON AIR</span>
|
||||||
|
: <span className="text-ink-muted">active</span>}
|
||||||
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
||||||
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
||||||
{inc.location && <span>{inc.location}</span>}
|
{inc.location && <span>{inc.location}</span>}
|
||||||
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
{!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||||
|
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
|
||||||
|
: null}
|
||||||
<span>{inc.call_ids?.length ?? 0} calls</span>
|
<span>{inc.call_ids?.length ?? 0} calls</span>
|
||||||
{path.mayBeMerged && (
|
{path.mayBeMerged && (
|
||||||
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
|
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
|
||||||
@@ -480,6 +493,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
||||||
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
|
||||||
{near.length ? (
|
{near.length ? (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{near.map(({ cam, km }) => (
|
{near.map(({ cam, km }) => (
|
||||||
@@ -498,6 +512,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
<h3 className={H3}>Situational context · fused layers</h3>
|
<h3 className={H3}>Situational context · fused layers</h3>
|
||||||
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
|
||||||
<ul className="space-y-1 text-xs text-ink-2">
|
<ul className="space-y-1 text-xs text-ink-2">
|
||||||
{ctx.aircraft.map(({ a, km }) => (
|
{ctx.aircraft.map(({ a, km }) => (
|
||||||
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
||||||
@@ -533,7 +548,7 @@ export function IncidentPanel({
|
|||||||
|
|
||||||
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={H3}>On scene</h3>
|
<h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
|
||||||
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface EmptyStateProps {
|
|||||||
action?: ReactNode;
|
action?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-gray-600">` scattered across pages. */
|
/** Consistent "nothing here yet" panel — replaces the ad-hoc `<p className="text-ink-muted">` scattered across pages. */
|
||||||
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
export function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
|
<div className="flex flex-col items-center justify-center text-center py-12 px-6 border border-dashed border-line-strong rounded-xl">
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function MachineOutputNotice({ variant = "block", detail, className }: Pr
|
|||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
role="note"
|
role="note"
|
||||||
className={["text-[10px] leading-snug text-gray-500 mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
className={["text-[10px] leading-snug text-ink-muted mt-1.5 pt-1.5 border-t border-gray-200", className ?? ""]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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