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:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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='© <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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user