Initial commit — DRB server stack

Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands),
frontend (Next.js admin UI), and mosquitto config.
This commit is contained in:
Logan
2026-04-05 19:01:39 -04:00
commit 2f0597c81b
77 changed files with 4126 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { onAuthStateChanged, signOut as firebaseSignOut, User } from "firebase/auth";
import { auth } from "@/lib/firebase";
interface AuthContextType {
user: User | null;
loading: boolean;
isAdmin: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
isAdmin: false,
signOut: async () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
return onAuthStateChanged(auth, async (u) => {
setUser(u);
setLoading(false);
if (u) {
document.cookie = "drb_session=1; path=/; SameSite=Strict";
// Read custom claims to determine admin status
const result = await u.getIdTokenResult(true);
setIsAdmin(!!result.claims.admin);
} else {
document.cookie = "drb_session=; path=/; max-age=0";
setIsAdmin(false);
}
});
}, []);
async function signOut() {
await firebaseSignOut(auth);
document.cookie = "drb_session=; path=/; max-age=0";
}
return (
<AuthContext.Provider value={{ user, loading, isAdmin, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}
+51
View File
@@ -0,0 +1,51 @@
import type { CallRecord } from "@/lib/types";
interface Props {
call: CallRecord;
systemName?: string;
}
function duration(started: string, ended: string | null): string {
if (!ended) return "active";
const ms = new Date(ended).getTime() - new Date(started).getTime();
const s = Math.floor(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
export function CallRow({ call, systemName }: Props) {
const isActive = call.status === "active";
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>
);
}
+77
View File
@@ -0,0 +1,77 @@
"use client";
import { useEffect } from "react";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import L from "leaflet";
import type { NodeRecord, CallRecord } from "@/lib/types";
import "leaflet/dist/leaflet.css";
// Fix Leaflet default icon paths broken by webpack
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
const nodeIcon = (status: string) =>
L.divIcon({
className: "",
html: `<div style="
width:14px;height:14px;border-radius:50%;
background:${status === "online" || status === "recording" ? "#4ade80" : status === "unconfigured" ? "#818cf8" : "#6b7280"};
border:2px solid #111827;
box-shadow:0 0 6px ${status === "recording" ? "#fb923c" : "transparent"};
"></div>`,
iconSize: [14, 14],
iconAnchor: [7, 7],
});
interface Props {
nodes: NodeRecord[];
activeCalls: CallRecord[];
}
export default function MapView({ nodes, activeCalls }: Props) {
const activeByNode = Object.fromEntries(
activeCalls.map((c) => [c.node_id, c])
);
const center: [number, number] =
nodes.length > 0 ? [nodes[0].lat, nodes[0].lon] : [39.5, -98.35];
return (
<MapContainer
center={center}
zoom={nodes.length > 0 ? 10 : 4}
className="w-full h-full rounded-lg"
style={{ background: "#111827" }}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
/>
{nodes.map((node) => (
<Marker
key={node.node_id}
position={[node.lat, node.lon]}
icon={nodeIcon(node.status)}
>
<Popup className="font-mono">
<div className="text-gray-900">
<p className="font-bold">{node.name}</p>
<p className="text-xs text-gray-500">{node.node_id}</p>
<p className="text-xs mt-1 capitalize">{node.status}</p>
{activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-1">
TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
{activeByNode[node.node_id].talkgroup_name}
</p>
)}
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUnconfiguredNodes } from "@/lib/useNodes";
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" },
];
const adminLinks = [
{ href: "/tokens", label: "Tokens" },
];
export function Nav() {
const { user, isAdmin, signOut } = useAuth();
const pathname = usePathname();
const { nodes: pending } = useUnconfiguredNodes();
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>
{[...links, ...(isAdmin ? adminLinks : [])].map(({ href, label }) => (
<Link
key={href}
href={href}
className={`text-sm font-mono transition-colors ${
pathname.startsWith(href)
? "text-white"
: "text-gray-500 hover:text-gray-300"
}`}
>
{label}
{label === "Nodes" && pending.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-yellow-500 text-gray-950 text-xs font-bold">
{pending.length}
</span>
)}
</Link>
))}
<div className="ml-auto">
<button
onClick={signOut}
className="text-sm font-mono text-gray-500 hover:text-gray-300 transition-colors"
>
Sign out
</button>
</div>
</nav>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { StatusBadge } from "@/components/StatusBadge";
import type { NodeRecord, SystemRecord } from "@/lib/types";
interface Props {
node: NodeRecord;
system?: SystemRecord;
}
export function NodeCard({ node, system }: Props) {
const lastSeen = node.last_seen
? new Date(node.last_seen).toLocaleTimeString()
: "never";
return (
<Link href={`/nodes/${node.node_id}`}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-gray-600 transition-colors cursor-pointer">
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-mono font-semibold text-white">{node.name}</p>
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
</div>
<StatusBadge status={node.status} />
</div>
<div className="space-y-1 text-xs font-mono text-gray-400">
<div className="flex justify-between">
<span>System</span>
<span className="text-gray-300">{system?.name ?? "Unassigned"}</span>
</div>
<div className="flex justify-between">
<span>Location</span>
<span className="text-gray-300">
{node.lat != null && node.lon != null
? `${node.lat.toFixed(4)}, ${node.lon.toFixed(4)}`
: "Unknown"}
</span>
</div>
<div className="flex justify-between">
<span>Last seen</span>
<span className="text-gray-300">{lastSeen}</span>
</div>
</div>
{!node.configured && (
<div className="mt-3 text-xs text-indigo-400 font-mono border-t border-gray-800 pt-2">
Needs configuration
</div>
)}
</div>
</Link>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { c2api } from "@/lib/c2api";
import type { NodeRecord, SystemRecord } from "@/lib/types";
interface Props {
node: NodeRecord;
systems: SystemRecord[];
onClose: () => void;
}
export function NodeConfigModal({ node, systems, onClose }: Props) {
const [systemId, setSystemId] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!systemId) return;
setSaving(true);
setError(null);
try {
await c2api.assignSystem(node.node_id, systemId);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to assign system.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md font-mono">
<h2 className="text-white font-semibold mb-1">Configure Node</h2>
<p className="text-gray-400 text-sm mb-5">
<span className="text-indigo-400">{node.node_id}</span> connected for the first time.
Assign it a radio system to begin monitoring.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1">Radio System</label>
<select
value={systemId}
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"
required
>
<option value="">Select a system</option>
{systems.map((s) => (
<option key={s.system_id} value={s.system_id}>
{s.name} ({s.type})
</option>
))}
</select>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 pt-1">
<button
type="submit"
disabled={saving || !systemId}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg py-2 text-sm font-semibold transition-colors"
>
{saving ? "Saving…" : "Assign & Configure"}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg py-2 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { NodeStatus } from "@/lib/types";
const styles: Record<NodeStatus, string> = {
online: "bg-green-950 text-green-400 border border-green-800",
recording: "bg-orange-950 text-orange-400 border border-orange-800 animate-pulse",
offline: "bg-gray-900 text-gray-500 border border-gray-700",
unconfigured: "bg-indigo-950 text-indigo-400 border border-indigo-800",
};
export function StatusBadge({ status }: { status: NodeStatus }) {
return (
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-semibold uppercase tracking-wide ${styles[status] ?? styles.offline}`}>
{status}
</span>
);
}