2f0597c81b
Includes c2-core (FastAPI/MQTT/Firestore), discord-bot (slash commands), frontend (Next.js admin UI), and mosquitto config.
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useNodes, useUnconfiguredNodes } from "@/lib/useNodes";
|
|
import { useCalls, useActiveCalls } from "@/lib/useCalls";
|
|
import { useSystems } from "@/lib/useSystems";
|
|
import { NodeCard } from "@/components/NodeCard";
|
|
import { CallRow } from "@/components/CallRow";
|
|
import { NodeConfigModal } from "@/components/NodeConfigModal";
|
|
import { useState } from "react";
|
|
import type { NodeRecord } from "@/lib/types";
|
|
|
|
function StatCard({ label, value, accent }: { label: string; value: string | number; accent?: string }) {
|
|
return (
|
|
<div className="bg-gray-900 border border-gray-800 rounded-lg p-4">
|
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
|
<p className={`text-3xl font-bold font-mono ${accent ?? "text-white"}`}>{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const { nodes, error: nodesError } = useNodes();
|
|
const { nodes: pending } = useUnconfiguredNodes();
|
|
const { calls, error: callsError } = useCalls(20);
|
|
const activeCalls = useActiveCalls();
|
|
const { systems, error: systemsError } = useSystems();
|
|
const [configNode, setConfigNode] = useState<NodeRecord | null>(null);
|
|
|
|
const systemMap = Object.fromEntries(systems.map((s) => [s.system_id, s]));
|
|
const onlineCount = nodes.filter((n) => n.status !== "offline").length;
|
|
|
|
const fsError = nodesError ?? callsError ?? systemsError;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-xl font-bold text-white font-mono">Dashboard</h1>
|
|
|
|
{fsError && (
|
|
<div className="bg-red-950 border border-red-800 rounded-lg p-4">
|
|
<p className="text-red-400 text-sm font-mono">Firestore error: {fsError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pending config banner */}
|
|
{pending.length > 0 && (
|
|
<div className="bg-indigo-950 border border-indigo-800 rounded-lg p-4 flex items-center justify-between">
|
|
<p className="text-indigo-300 text-sm font-mono">
|
|
{pending.length} new node{pending.length > 1 ? "s" : ""} connected and need{pending.length === 1 ? "s" : ""} configuration.
|
|
</p>
|
|
<button
|
|
onClick={() => setConfigNode(pending[0])}
|
|
className="text-xs bg-indigo-700 hover:bg-indigo-600 text-white px-3 py-1.5 rounded-lg transition-colors"
|
|
>
|
|
Configure now
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatCard label="Nodes Online" value={onlineCount} accent="text-green-400" />
|
|
<StatCard label="Active Calls" value={activeCalls.length} accent={activeCalls.length > 0 ? "text-orange-400" : undefined} />
|
|
<StatCard label="Total Nodes" value={nodes.length} />
|
|
<StatCard label="Systems" value={systems.length} />
|
|
</div>
|
|
|
|
{/* Nodes */}
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Nodes</h2>
|
|
{nodes.length === 0 ? (
|
|
<p className="text-gray-600 text-sm font-mono">No nodes registered yet.</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{nodes.map((n) => (
|
|
<NodeCard key={n.node_id} node={n} system={systemMap[n.assigned_system_id ?? ""]} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Recent calls */}
|
|
<section>
|
|
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Recent Calls</h2>
|
|
{calls.length === 0 ? (
|
|
<p className="text-gray-600 text-sm font-mono">No calls recorded yet.</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs text-gray-500 uppercase tracking-wider border-b border-gray-800">
|
|
<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">System</th>
|
|
<th className="px-4 py-2 text-left">Node</th>
|
|
<th className="px-4 py-2 text-left">Duration</th>
|
|
<th className="px-4 py-2 text-left">Audio</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{calls.map((c) => (
|
|
<CallRow key={c.call_id} call={c} systemName={systemMap[c.system_id ?? ""]?.name} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{configNode && (
|
|
<NodeConfigModal
|
|
node={configNode}
|
|
systems={systems}
|
|
onClose={() => setConfigNode(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|