"use client"; import { useEffect, useState } from "react"; import { useParams } from "next/navigation"; import { doc, onSnapshot } from "firebase/firestore"; import { db } from "@/lib/firebase"; import { useSystems } from "@/lib/useSystems"; import { useCalls } from "@/lib/useCalls"; import { StatusBadge } from "@/components/StatusBadge"; import { NodeConfigModal } from "@/components/NodeConfigModal"; import { CallRow } from "@/components/CallRow"; import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; import type { NodeRecord } from "@/lib/types"; function ApprovalBadge({ status }: { status: string | null }) { if (status === "approved") return ( Approved ); if (status === "rejected") return ( Rejected ); return ( Pending approval ); } function DiscordJoinModal({ nodeId, onClose, }: { nodeId: string; onClose: () => void; }) { const [guildId, setGuildId] = useState(""); const [channelId, setChannelId] = useState(""); const [sending, setSending] = useState(false); const [error, setError] = useState(null); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSending(true); setError(null); try { await c2api.sendCommand(nodeId, { action: "discord_join", guild_id: guildId, channel_id: channelId, }); onClose(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to send join command."); } finally { setSending(false); } } return (

Join Discord Voice

setGuildId(e.target.value)} required pattern="[0-9]+" 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" placeholder="123456789012345678" />
setChannelId(e.target.value)} required pattern="[0-9]+" 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" placeholder="123456789012345678" />

A token will be drawn from the pool automatically. Make sure the bot is a member of the server.

{error &&

{error}

}
); } export default function NodeDetailPage() { const { id } = useParams<{ id: string }>(); const [node, setNode] = useState(null); const [showConfig, setShowConfig] = useState(false); const [showDiscordJoin, setShowDiscordJoin] = useState(false); const [sending, setSending] = useState(false); const [approving, setApproving] = useState(false); const { systems } = useSystems(); const { calls } = useCalls(20); const { isAdmin } = useAuth(); const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s])); const nodeCalls = calls.filter((c) => c.node_id === id); useEffect(() => { const unsub = onSnapshot(doc(db, "nodes", id), (snap) => { setNode(snap.exists() ? (snap.data() as NodeRecord) : null); }); return unsub; }, [id]); async function sendCommand(action: string) { setSending(true); try { await c2api.sendCommand(id, { action }); } finally { setSending(false); } } async function handleApprove() { setApproving(true); try { await c2api.approveNode(id); } catch (err) { alert(err instanceof Error ? err.message : "Approval failed."); } finally { setApproving(false); } } async function handleReject() { if (!confirm("Reject this node? It will not be able to upload recordings.")) return; setApproving(true); try { await c2api.rejectNode(id); } catch (err) { alert(err instanceof Error ? err.message : "Rejection failed."); } finally { setApproving(false); } } if (!node) { return

Loading node…

; } const system = systemMap[node.assigned_system_id ?? ""]; return (

{node.name}

{node.node_id}

{/* Info */}
{[ ["System", system?.name ?? "Unassigned"], ["Location", `${node.lat}, ${node.lon}`], ["Last Seen", node.last_seen ? new Date(node.last_seen).toLocaleString() : "never"], ["Configured", node.configured ? "Yes" : "No"], ].map(([label, value]) => (
{label} {value}
))}
Approval
{/* Admin approval actions */} {isAdmin && node.approval_status === "pending" && (
This node is awaiting approval.
)} {/* Actions */}
{/* Recent calls */}

Recent Calls

{nodeCalls.length === 0 ? (

No calls recorded from this node.

) : (
{nodeCalls.map((c) => ( ))}
Time Talkgroup System Node Duration Audio
)}
{showConfig && ( setShowConfig(false)} /> )} {showDiscordJoin && ( setShowDiscordJoin(false)} /> )}
); }