Massive update

This commit is contained in:
Logan
2026-04-11 13:44:08 -04:00
parent fd6c2fd8bf
commit 3b3a136d04
31 changed files with 1919 additions and 94 deletions
+96 -31
View File
@@ -1,3 +1,6 @@
"use client";
import { useState } from "react";
import type { CallRecord } from "@/lib/types";
interface Props {
@@ -12,40 +15,102 @@ function duration(started: string, ended: string | null): string {
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
const TAG_COLORS: Record<string, string> = {
fire: "bg-red-900 text-red-300",
police: "bg-blue-900 text-blue-300",
ems: "bg-yellow-900 text-yellow-300",
accident: "bg-orange-900 text-orange-300",
};
export function CallRow({ call, systemName }: Props) {
const [expanded, setExpanded] = useState(false);
const isActive = call.status === "active";
const hasDetails = call.transcript || (call.tags && call.tags.length > 0) || call.incident_id;
return (
<tr className="border-b border-gray-800 hover:bg-gray-900/50 font-mono text-sm">
<td className="px-4 py-2 text-gray-400 text-xs">
{new Date(call.started_at).toLocaleTimeString()}
</td>
<td className="px-4 py-2 text-gray-300">
{call.talkgroup_name || call.talkgroup_id || "—"}
</td>
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
<td className="px-4 py-2">
{isActive ? (
<span className="text-orange-400 animate-pulse"> live</span>
) : (
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
)}
</td>
<td className="px-4 py-2">
{call.audio_url ? (
<a
href={call.audio_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 text-xs"
>
audio
</a>
) : (
<span className="text-gray-700 text-xs"></span>
)}
</td>
</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"}`}
onClick={() => hasDetails && setExpanded((v) => !v)}
>
<td className="px-4 py-2 text-gray-400 text-xs">
{new Date(call.started_at).toLocaleTimeString()}
</td>
<td className="px-4 py-2 text-gray-300">
<span>{call.talkgroup_name || call.talkgroup_id || "—"}</span>
{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"}`}>
{call.tags[0]}
</span>
)}
</td>
<td className="px-4 py-2 text-gray-400">{systemName ?? call.system_id ?? "—"}</td>
<td className="px-4 py-2 text-gray-400">{call.node_id}</td>
<td className="px-4 py-2">
{isActive ? (
<span className="text-orange-400 animate-pulse"> live</span>
) : (
<span className="text-gray-500">{duration(call.started_at, call.ended_at)}</span>
)}
</td>
<td className="px-4 py-2">
{call.audio_url ? (
<a
href={call.audio_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-400 hover:text-blue-300 text-xs"
>
audio
</a>
) : (
<span className="text-gray-700 text-xs"></span>
)}
</td>
<td className="px-4 py-2 text-gray-600 text-xs">
{hasDetails && (expanded ? "▲" : "▼")}
</td>
</tr>
{expanded && hasDetails && (
<tr className="bg-gray-900/60 border-b border-gray-800">
<td colSpan={7} className="px-6 py-3 space-y-2">
{/* Tags */}
{call.tags && call.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{call.tags.map((tag) => (
<span
key={tag}
className={`text-xs px-2 py-0.5 rounded-full capitalize ${TAG_COLORS[tag] ?? "bg-gray-800 text-gray-400"}`}
>
{tag}
</span>
))}
</div>
)}
{/* Incident link */}
{call.incident_id && (
<p className="text-xs font-mono text-indigo-400">
Incident:{" "}
<a href="/incidents" className="underline hover:text-indigo-300">
{call.incident_id.slice(0, 8)}
</a>
</p>
)}
{/* Transcript */}
{call.transcript ? (
<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">
{call.transcript}
</pre>
) : (
<p className="text-xs text-gray-600 font-mono italic">No transcript available.</p>
)}
</td>
</tr>
)}
</>
);
}
+56 -4
View File
@@ -1,9 +1,9 @@
"use client";
import { useEffect } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import L from "leaflet";
import type { NodeRecord, CallRecord } from "@/lib/types";
import type { NodeRecord, CallRecord, IncidentRecord } from "@/lib/types";
// Fix Leaflet default icon paths broken by webpack
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
L.Icon.Default.mergeOptions({
@@ -25,16 +25,45 @@ const nodeIcon = (status: string) =>
iconAnchor: [7, 7],
});
const INCIDENT_COLORS: Record<string, string> = {
fire: "#ef4444",
police: "#3b82f6",
ems: "#eab308",
accident: "#f97316",
other: "#6b7280",
};
const incidentIcon = (type: string | null) => {
const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other;
return L.divIcon({
className: "",
html: `<div style="
width:16px;height:16px;border-radius:3px;
background:${color};border:2px solid #111827;
display:flex;align-items:center;justify-content:center;
font-size:9px;color:#111827;font-weight:bold;line-height:1;
">!</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
});
};
interface Props {
nodes: NodeRecord[];
activeCalls: CallRecord[];
incidents?: IncidentRecord[];
}
export default function MapView({ nodes, activeCalls }: Props) {
export default function MapView({ nodes, activeCalls, incidents = [] }: Props) {
const activeByNode = Object.fromEntries(
activeCalls.map((c) => [c.node_id, c])
);
// Only show incidents that have coordinates
const mappableIncidents = incidents.filter(
(i) => i.location && i.location.lat != null && i.location.lng != null
);
const center: [number, number] =
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35];
@@ -49,6 +78,8 @@ export default function MapView({ nodes, activeCalls }: Props) {
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
/>
{/* Node markers */}
{nodes.map((node) => (
<Marker
key={node.node_id}
@@ -70,6 +101,27 @@ export default function MapView({ nodes, activeCalls }: Props) {
</Popup>
</Marker>
))}
{/* Incident markers */}
{mappableIncidents.map((inc) => (
<Marker
key={inc.incident_id}
position={[inc.location!.lat, inc.location!.lng]}
icon={incidentIcon(inc.type)}
>
<Popup className="font-mono">
<div className="text-gray-900">
<p className="font-bold">{inc.title ?? "Incident"}</p>
<p className="text-xs capitalize" style={{ color: INCIDENT_COLORS[inc.type ?? "other"] }}>
{inc.type ?? "other"}
</p>
<p className="text-xs mt-1 capitalize">{inc.status}</p>
<p className="text-xs text-gray-500">{inc.call_ids.length} call{inc.call_ids.length !== 1 ? "s" : ""}</p>
{inc.summary && <p className="text-xs mt-1">{inc.summary}</p>}
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}
+18 -9
View File
@@ -3,14 +3,17 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes";
import { useUnacknowledgedAlerts } from "@/lib/useAlerts";
import { useAuth } from "@/components/AuthProvider";
const links = [
{ href: "/dashboard", label: "Dashboard" },
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/calls", label: "Calls" },
{ href: "/map", label: "Map" },
{ href: "/dashboard", label: "Dashboard" },
{ href: "/nodes", label: "Nodes" },
{ href: "/systems", label: "Systems" },
{ href: "/calls", label: "Calls" },
{ href: "/incidents", label: "Incidents" },
{ href: "/map", label: "Map" },
{ href: "/alerts", label: "Alerts" },
];
const adminLinks = [
@@ -21,17 +24,18 @@ export function Nav() {
const { user, isAdmin, signOut } = useAuth();
const pathname = usePathname();
const { nodes: pending } = useUnconfiguredNodes();
const unackedAlerts = useUnacknowledgedAlerts();
if (!user) return null;
return (
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6">
<span className="font-mono font-bold text-white tracking-tight mr-4">DRB</span>
<nav className="border-b border-gray-800 bg-gray-950 px-6 py-3 flex items-center gap-6 overflow-x-auto">
<span className="font-mono font-bold text-white tracking-tight mr-4 shrink-0">DRB</span>
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
<Link
key={href}
href={href}
className={`text-sm font-mono transition-colors ${
className={`text-sm font-mono transition-colors shrink-0 ${
pathname.startsWith(href)
? "text-white"
: "text-gray-500 hover:text-gray-300"
@@ -43,9 +47,14 @@ export function Nav() {
{pending.length}
</span>
)}
{label === "Alerts" && unackedAlerts.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center min-w-[1rem] h-4 rounded-full bg-red-600 text-white text-xs font-bold px-1">
{unackedAlerts.length}
</span>
)}
</Link>
))}
<div className="ml-auto">
<div className="ml-auto shrink-0">
<button
onClick={signOut}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"