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,58 @@
|
||||
import { auth } from "@/lib/firebase";
|
||||
|
||||
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const user = auth.currentUser;
|
||||
const token = user ? await user.getIdToken() : null;
|
||||
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options?.headers as Record<string, string> | undefined),
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const c2api = {
|
||||
// Nodes
|
||||
getNodes: () => request<unknown[]>("/nodes"),
|
||||
getNode: (id: string) => request<unknown>(`/nodes/${id}`),
|
||||
sendCommand: (nodeId: string, payload: object) =>
|
||||
request(`/nodes/${nodeId}/command`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
assignSystem: (nodeId: string, systemId: string) =>
|
||||
request(`/nodes/${nodeId}/config/${systemId}`, { method: "POST" }),
|
||||
|
||||
// Systems
|
||||
getSystems: () => request<unknown[]>("/systems"),
|
||||
createSystem: (body: object) =>
|
||||
request("/systems", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateSystem: (id: string, body: object) =>
|
||||
request(`/systems/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||
deleteSystem: (id: string) =>
|
||||
request(`/systems/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Tokens
|
||||
getTokens: () => request<unknown[]>("/tokens"),
|
||||
addToken: (body: { name: string; token: string }) =>
|
||||
request("/tokens", { method: "POST", body: JSON.stringify(body) }),
|
||||
deleteToken: (id: string) =>
|
||||
request(`/tokens/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Node approval
|
||||
approveNode: (id: string) =>
|
||||
request(`/nodes/${id}/approve`, { method: "POST" }),
|
||||
rejectNode: (id: string) =>
|
||||
request(`/nodes/${id}/reject`, { method: "POST" }),
|
||||
|
||||
// Calls
|
||||
getCalls: (params?: Record<string, string>) => {
|
||||
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
|
||||
return request<unknown[]>(`/calls${qs}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { initializeApp, getApps, getApp } from "firebase/app";
|
||||
import { getFirestore } from "firebase/firestore";
|
||||
import { getAuth } from "firebase/auth";
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
|
||||
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
|
||||
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
|
||||
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
|
||||
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
|
||||
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
|
||||
};
|
||||
|
||||
const app = getApps().length ? getApp() : initializeApp(firebaseConfig);
|
||||
const databaseId = process.env.NEXT_PUBLIC_FIRESTORE_DATABASE || "(default)";
|
||||
export const db = getFirestore(app, databaseId);
|
||||
export const auth = getAuth(app);
|
||||
export { app };
|
||||
@@ -0,0 +1,51 @@
|
||||
export type NodeStatus = "online" | "offline" | "recording" | "unconfigured";
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export interface NodeRecord {
|
||||
node_id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
status: NodeStatus;
|
||||
configured: boolean;
|
||||
last_seen: string | null;
|
||||
assigned_system_id: string | null;
|
||||
approval_status: ApprovalStatus | null;
|
||||
}
|
||||
|
||||
export interface SystemRecord {
|
||||
system_id: string;
|
||||
name: string;
|
||||
type: string; // P25 | DMR | NBFM
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CallRecord {
|
||||
call_id: string;
|
||||
node_id: string;
|
||||
system_id: string | null;
|
||||
talkgroup_id: number | null;
|
||||
talkgroup_name: string | null;
|
||||
freq: number | null;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
audio_url: string | null;
|
||||
transcript: string | null;
|
||||
incident_id: string | null;
|
||||
location: { lat: number; lng: number } | null;
|
||||
tags: string[];
|
||||
status: "active" | "ended";
|
||||
}
|
||||
|
||||
export interface IncidentRecord {
|
||||
incident_id: string;
|
||||
title: string | null;
|
||||
type: string | null;
|
||||
status: "active" | "resolved";
|
||||
location: { lat: number; lng: number } | null;
|
||||
call_ids: string[];
|
||||
started_at: string;
|
||||
updated_at: string;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { collection, onSnapshot, query, orderBy, limit, where, FirestoreError } from "firebase/firestore";
|
||||
import { onAuthStateChanged } from "firebase/auth";
|
||||
import { db, auth } from "@/lib/firebase";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
export function useCalls(limitCount = 50) {
|
||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
|
||||
if (!user) {
|
||||
setCalls([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query(
|
||||
collection(db, "calls"),
|
||||
orderBy("started_at", "desc"),
|
||||
limit(limitCount)
|
||||
);
|
||||
unsubFirestore = onSnapshot(q, (snap) => {
|
||||
setCalls(snap.docs.map((d) => d.data() as CallRecord));
|
||||
setLoading(false);
|
||||
}, (err: FirestoreError) => { console.error("useCalls:", err); setError(err.message); setLoading(false); });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
if (unsubFirestore) unsubFirestore();
|
||||
};
|
||||
}, [limitCount]);
|
||||
|
||||
return { calls, loading, error };
|
||||
}
|
||||
|
||||
export function useActiveCalls() {
|
||||
const [calls, setCalls] = useState<CallRecord[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
|
||||
if (!user) {
|
||||
setCalls([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query(collection(db, "calls"), where("status", "==", "active"));
|
||||
unsubFirestore = onSnapshot(q, (snap) => {
|
||||
setCalls(snap.docs.map((d) => d.data() as CallRecord));
|
||||
}, (err: FirestoreError) => { console.error("useActiveCalls:", err); });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
if (unsubFirestore) unsubFirestore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return calls;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { collection, onSnapshot, query, FirestoreError } from "firebase/firestore";
|
||||
import { onAuthStateChanged } from "firebase/auth";
|
||||
import { db, auth } from "@/lib/firebase";
|
||||
import type { NodeRecord } from "@/lib/types";
|
||||
|
||||
export function useNodes() {
|
||||
const [nodes, setNodes] = useState<NodeRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
|
||||
if (!user) {
|
||||
setNodes([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const q = query(collection(db, "nodes"));
|
||||
unsubFirestore = onSnapshot(q, (snap) => {
|
||||
setNodes(snap.docs.map((d) => d.data() as NodeRecord));
|
||||
setLoading(false);
|
||||
}, (err: FirestoreError) => { console.error("useNodes:", err); setError(err.message); setLoading(false); });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
if (unsubFirestore) unsubFirestore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { nodes, loading, error };
|
||||
}
|
||||
|
||||
export function useUnconfiguredNodes() {
|
||||
const { nodes, loading } = useNodes();
|
||||
return {
|
||||
nodes: nodes.filter((n) => !n.configured),
|
||||
loading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { collection, onSnapshot, FirestoreError } from "firebase/firestore";
|
||||
import { onAuthStateChanged } from "firebase/auth";
|
||||
import { db, auth } from "@/lib/firebase";
|
||||
import type { SystemRecord } from "@/lib/types";
|
||||
|
||||
export function useSystems() {
|
||||
const [systems, setSystems] = useState<SystemRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubFirestore: (() => void) | undefined;
|
||||
|
||||
const unsubAuth = onAuthStateChanged(auth, (user) => {
|
||||
if (unsubFirestore) { unsubFirestore(); unsubFirestore = undefined; }
|
||||
|
||||
if (!user) {
|
||||
setSystems([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
unsubFirestore = onSnapshot(collection(db, "systems"), (snap) => {
|
||||
setSystems(snap.docs.map((d) => d.data() as SystemRecord));
|
||||
setLoading(false);
|
||||
}, (err: FirestoreError) => { console.error("useSystems:", err); setError(err.message); setLoading(false); });
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAuth();
|
||||
if (unsubFirestore) unsubFirestore();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { systems, loading, error };
|
||||
}
|
||||
Reference in New Issue
Block a user