From 30707fc0d565327fabf525431352c0b041ecef0e Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Sun, 25 May 2025 23:20:48 -0400 Subject: [PATCH] Init working UI --- Dockerfile | 35 +- makefile | 97 +- package.json | 2 +- src/app/page.tsx | 1069 +---------------------- src/components/AppContent.tsx | 68 ++ src/components/BotsManagement.tsx | 425 +++++++++ src/components/IndividualClientPage.tsx | 221 +++++ src/components/LoginPage.tsx | 67 ++ src/components/SystemsManagement.tsx | 354 ++++++++ src/constants/api.ts | 1 + src/context/AuthContext.tsx | 89 ++ src/pages/_app.tsx | 14 + src/pages/nodes/[clientId].tsx | 64 ++ {public => src/public}/file.svg | 0 {public => src/public}/globe.svg | 0 {public => src/public}/next.svg | 0 {public => src/public}/vercel.svg | 0 {public => src/public}/window.svg | 0 src/types/index.ts | 65 ++ 19 files changed, 1458 insertions(+), 1113 deletions(-) create mode 100644 src/components/AppContent.tsx create mode 100644 src/components/BotsManagement.tsx create mode 100644 src/components/IndividualClientPage.tsx create mode 100644 src/components/LoginPage.tsx create mode 100644 src/components/SystemsManagement.tsx create mode 100644 src/constants/api.ts create mode 100644 src/context/AuthContext.tsx create mode 100644 src/pages/_app.tsx create mode 100644 src/pages/nodes/[clientId].tsx rename {public => src/public}/file.svg (100%) rename {public => src/public}/globe.svg (100%) rename {public => src/public}/next.svg (100%) rename {public => src/public}/vercel.svg (100%) rename {public => src/public}/window.svg (100%) create mode 100644 src/types/index.ts diff --git a/Dockerfile b/Dockerfile index 901a577..26dc562 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN npm ci # COPY package.json yarn.lock ./ # RUN yarn install --frozen-lockfile -# Stage 2: Build the Next.js application +# Stage 2: Build the Next.js application (production build) FROM node:22-alpine AS builder WORKDIR /app @@ -19,32 +19,33 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . -# Build the Next.js application -# This will create the .next directory with optimized production build +# Build the Next.js application for production +ENV NODE_ENV production RUN npm run build -# Stage 3: Run the Next.js application in production +# Stage 3: Run the Next.js application (flexible for dev and prod) FROM node:22-alpine AS runner WORKDIR /app -# Set environment variables for Next.js production server -ENV NODE_ENV production - -# Next.js requires specific environment variables for standalone output -# If you are using `output: 'standalone'` in next.config.js, uncomment the following: -# ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp -# COPY --from=builder /app/.next/standalone ./ -# COPY --from=builder /app/.next/static ./.next/static -# COPY --from=builder /app/public ./public - -# If not using standalone output, copy the necessary files -COPY --from=builder /app/public ./public +# Copy necessary files for both development and production +COPY --from=builder /app/src/public ./public COPY --from=builder /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +# If you are using `output: 'standalone'` in next.config.js, uncomment the following: +# ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp +# COPY --from=builder /app/.next/standalone ./ +# COPY --from=builder /app/.next/static ./.next/static +# COPY --from=builder /app/public ./public # Already copied above, but keep if standalone specific + # Expose the port Next.js runs on (default is 3000) EXPOSE 3000 +# Argument to set NODE_ENV at runtime +ARG NODE_ENV=production +ENV NODE_ENV ${NODE_ENV} + # Command to run the Next.js application -CMD ["npm", "start"] +# This will run 'npm run dev' if NODE_ENV is 'development', otherwise 'npm start' (production) +CMD ["sh", "-c", "if [ \"$NODE_ENV\" = \"development\" ]; then npm run dev; else npm start; fi"] \ No newline at end of file diff --git a/makefile b/makefile index 26341cb..5bfb2a7 100644 --- a/makefile +++ b/makefile @@ -1,64 +1,97 @@ -.PHONY: install build start dev clean docker-build docker-run docker-stop +.PHONY: install build start dev clean docker-build docker-dev docker-prod docker-stop docker-rebuild-dev docker-rebuild-prod # Define variables APP_NAME := drb-frontend DOCKER_IMAGE_NAME := $(APP_NAME):latest -DOCKER_CONTAINER_NAME := $(APP_NAME)-container +DOCKER_DEV_CONTAINER_NAME := $(APP_NAME)-dev-container +DOCKER_PROD_CONTAINER_NAME := $(APP_NAME)-prod-container -# Default target when `make` is run without arguments +# --- Local Development and Build --- + +# Default target all: dev # Install Node.js dependencies install: - @echo "Installing Node.js dependencies..." + @echo "➡️ Installing Node.js dependencies..." npm install # Build the Next.js application for production build: install - @echo "Building Next.js application for production..." + @echo "➡️ Building Next.js application for production..." npm run build -# Start the Next.js application in production mode +# Start the Next.js application in production mode (locally) start: build - @echo "Starting Next.js application in production mode..." + @echo "➡️ Starting Next.js application in production mode locally..." npm start -# Start the Next.js application in development mode +# Start the Next.js application in development mode (locally) dev: install - @echo "Starting Next.js application in development mode..." + @echo "➡️ Starting Next.js application in development mode locally (Fast Refresh enabled)..." npm run dev # Clean up build artifacts and node_modules clean: - @echo "Cleaning up build artifacts and node_modules..." + @echo "➡️ Cleaning up build artifacts and node_modules..." rm -rf .next out node_modules - @echo "Clean up complete." + @echo "✅ Clean up complete." + +# --- Docker Operations --- # Build the Docker image docker-build: - @echo "Building Docker image: $(DOCKER_IMAGE_NAME)..." + @echo "🐳 Building Docker image: $(DOCKER_IMAGE_NAME)..." docker build -t $(DOCKER_IMAGE_NAME) . - @echo "Docker image built successfully." + @echo "✅ Docker image built successfully." -# Run the Docker container -docker-run: docker-build - @echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..." - docker run -it --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME) - @echo "Docker container started. Access at http://localhost:3000" +# Run the Docker container in development mode +docker-dev: #docker-build + @echo "🚀 Running Docker container in DEVELOPMENT mode: $(DOCKER_DEV_CONTAINER_NAME)..." + @echo " (Volume mounted for Fast Refresh: $(PWD):/app)" + # Ensure node_modules is also mounted, as it's typically excluded from the main volume mount + # to avoid issues with host OS vs. container OS binary compatibility. + # The bind mount for node_modules effectively hides the one from the image. + docker run \ + -it \ + --rm \ + --name $(DOCKER_DEV_CONTAINER_NAME) \ + -p 3000:3000 \ + -v "$(PWD):/app" \ + -v "/app/node_modules" \ + -e NODE_ENV=development \ + $(DOCKER_IMAGE_NAME) + @echo "✨ Docker container started in development. Access at http://localhost:3000" -# Run the Docker container -docker-deploy: docker-build - @echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..." - docker run -d --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME) - @echo "Docker container started. Access at http://localhost:3000" -# Stop and remove the Docker container -docker-stop: - @echo "Stopping and removing Docker container: $(DOCKER_CONTAINER_NAME)..." - docker stop $(DOCKER_CONTAINER_NAME) || true - docker rm $(DOCKER_CONTAINER_NAME) || true - @echo "Docker container stopped and removed." +# Run the Docker container in production mode +docker-prod: docker-build docker-stop-prod + @echo "🚀 Running Docker container in PRODUCTION mode: $(DOCKER_PROD_CONTAINER_NAME)..." + docker run -d --rm --name $(DOCKER_PROD_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME) + @echo "✅ Docker container started in production. Access at http://localhost:3000" -# Optional: Rebuild and rerun the Docker container -docker-rebuild-run: docker-stop docker-run - @echo "Rebuilding and rerunning Docker container." +# Stop and remove the DEVELOPMENT Docker container +docker-stop-dev: + @echo "🛑 Stopping and removing DEVELOPMENT Docker container: $(DOCKER_DEV_CONTAINER_NAME)..." + -docker stop $(DOCKER_DEV_CONTAINER_NAME) 2>/dev/null || true + -docker rm $(DOCKER_DEV_CONTAINER_NAME) 2>/dev/null || true + @echo "✅ Development Docker container stopped and removed (if it was running)." + +# Stop and remove the PRODUCTION Docker container +docker-stop-prod: + @echo "🛑 Stopping and removing PRODUCTION Docker container: $(DOCKER_PROD_CONTAINER_NAME)..." + -docker stop $(DOCKER_PROD_CONTAINER_NAME) 2>/dev/null || true + -docker rm $(DOCKER_PROD_CONTAINER_NAME) 2>/dev/null || true + @echo "✅ Production Docker container stopped and removed (if it was running)." + +# Stop and remove ALL Docker containers related to this app +docker-stop-all: docker-stop-dev docker-stop-prod + @echo "🛑 Stopped all active containers for $(APP_NAME)." + +# Rebuild and rerun the Docker container in development mode +docker-rebuild-dev: docker-build docker-dev + @echo "🔄 Rebuilding and rerunning Docker container in development." + +# Rebuild and rerun the Docker container in production mode +docker-rebuild-prod: docker-build docker-prod + @echo "🔄 Rebuilding and rerunning Docker container in production." \ No newline at end of file diff --git a/package.json b/package.json index 5f2b948..04669fb 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", diff --git a/src/app/page.tsx b/src/app/page.tsx index b0cf0af..120b3f6 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,1069 +1,12 @@ "use client"; -import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; -import { Label } from '@/components/ui/label'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { Textarea } from '@/components/ui/textarea'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Checkbox } from '@/components/ui/checkbox'; +import React from 'react'; +import { AuthProvider } from '@/context/AuthContext'; +import AppContent from '@/components/AppContent'; - -const API_BASE_URL = "http://localhost:5000"; - - -enum DemodTypes { - P25 = "P25", - DMR = "DMR", - NBFM = "NBFM" // Corrected from ANALOG to NBFM as per server.py -} - -interface TalkgroupTag { - talkgroup: string; - tagDec: number; -} - -interface DiscordId { - _id: string; - discord_id: string; - name: string; - token: string; - active: boolean; - guild_ids: string[]; -} - -interface System { - _id: string; - type: DemodTypes; - name: string; - frequencies: number[]; - location: string; - avail_on_nodes: string[]; - description?: string; // Optional - tags?: TalkgroupTag[]; // Optional - whitelist?: number[]; // Optional -} - -enum UserRoles { - ADMIN = "admin", - MOD = "mod", - USER = "user" -} - -interface UserDetails { - id: string; // Renamed from _id to id for client-side representation - username: string; - role: UserRoles; - // password_hash and api_key would not be sent to client -} - -// --- API Error Response Type --- -interface ErrorResponse { - message?: string; - detail?: string | any; // FastAPI often uses 'detail' which can be string or complex object -} - -// --- Auth Context --- -const AuthContext = createContext(null); - -interface AuthProviderProps { - children: ReactNode; -} - -// --- Auth Context Types --- -interface AuthContextType { - token: string | null; - user: UserDetails | null; - loading: boolean; - login: (username: string, password: string) => Promise; - logout: () => void; - hasPermission: (requiredRole: UserRoles) => boolean; -} - -const AuthProvider: React.FC = ({ children }) => { - const [token, setToken] = useState(null); - const [user, setUser] = useState(null); // { id, username, role } - const [loading, setLoading] = useState(true); - - useEffect(() => { - const storedToken = localStorage.getItem('jwt_token'); - const storedUser = localStorage.getItem('user_data'); - if (storedToken && storedUser) { - setToken(storedToken); - try { - setUser(JSON.parse(storedUser)); - } catch (error) { - console.error("Failed to parse stored user data:", error); - localStorage.removeItem('user_data'); // Clear corrupted data - } - } - setLoading(false); - }, []); - - const login = async (username: string, password: string): Promise => { - try { - const response = await fetch(`${API_BASE_URL}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - const data = await response.json(); // Assume server always returns JSON - - if (response.ok) { - setToken(data.access_token); - // In a real app, you'd decode the token or fetch user details - // For now, placeholder user details are set. - // TODO: Fetch actual user details from a dedicated endpoint after login - const tempUser: UserDetails = { id: data.user_id || 'some-id', username, role: data.role || UserRoles.MOD }; // Placeholder, adjust with actual response - setUser(tempUser); - localStorage.setItem('jwt_token', data.access_token); - localStorage.setItem('user_data', JSON.stringify(tempUser)); - return true; - } else { - const errorData = data as ErrorResponse; - console.error('Login failed:', errorData.message || errorData.detail || response.statusText); - return false; - } - } catch (error) { - console.error('Network error during login:', error); - return false; - } - }; - - const logout = () => { - setToken(null); - setUser(null); - localStorage.removeItem('jwt_token'); - localStorage.removeItem('user_data'); - // Optionally redirect to login page or refresh to trigger AppContent's logic - }; - - const hasPermission = (requiredRole: UserRoles): boolean => { - if (!user || !user.role) return false; - const roleOrder: Record = { - [UserRoles.USER]: 0, - [UserRoles.MOD]: 1, - [UserRoles.ADMIN]: 2 - }; - return roleOrder[user.role] >= roleOrder[requiredRole]; - }; - - return ( - - {children} - - ); -}; - -// Custom hook to consume AuthContext -const useAuth = (): AuthContextType => { - const context = useContext(AuthContext); - if (context === null) { - throw new Error("useAuth must be used within an AuthProvider"); - } - return context; -}; - -// --- Login Page Component --- -const LoginPage: React.FC = () => { - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [error, setError] = useState(''); - const { login } = useAuth(); // This will now work or throw error if not in provider - - const handleSubmit = async (e: React.FormEvent): Promise => { - e.preventDefault(); - setError(''); - const success = await login(username, password); - if (!success) { - setError('Invalid username or password. Please try again.'); - } - // If login is successful, the AppContent component will handle navigation - }; - - return ( -
- - - Login - - -
-
- - ) => setUsername(e.target.value)} - required - className="mt-1" - /> -
-
- - ) => setPassword(e.target.value)} - required - className="mt-1" - /> -
- {error &&

{error}

} - -
-
-
-
- ); -}; - -interface BotsManagementProps { - token: string; -} - -// --- Bots Management Component --- -const BotsManagement: React.FC = ({ token }) => { - const [bots, setBots] = useState([]); - const [discordIds, setDiscordIds] = useState([]); - const [systems, setSystems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const [isAddIdDialogOpen, setIsAddIdDialogOpen] = useState(false); - const [newIdData, setNewIdData] = useState & { guild_ids: string }>({ - discord_id: '', - name: '', - token: '', - active: false, - guild_ids: '', - }); - const [editingId, setEditingId] = useState(null); - - const [isAssignDismissDialogOpen, setIsAssignDismissDialogOpen] = useState(false); - const [selectedBotClientId, setSelectedBotClientId] = useState(''); - const [selectedSystemId, setSelectedSystemId] = useState(''); - const [assignDismissAction, setAssignDismissAction] = useState<'assign' | 'dismiss'>('assign'); - - const fetchData = async (): Promise => { - setLoading(true); - setError(''); - let accumulatedError = ''; - - try { - const [botsRes, discordIdsRes, systemsRes] = await Promise.all([ - fetch(`${API_BASE_URL}/bots/`, { headers: { Authorization: `Bearer ${token}` } }), - fetch(`${API_BASE_URL}/discord_ids/`, { headers: { Authorization: `Bearer ${token}` } }), - fetch(`${API_BASE_URL}/systems/`, { headers: { Authorization: `Bearer ${token}` } }), - ]); - - // Bots - if (botsRes.ok) { - const botsData: string[] = await botsRes.json(); - setBots(botsData); - } else { - let errorMsg = `Failed to fetch bots (${botsRes.status})`; - try { - const errorData: ErrorResponse = await botsRes.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || botsRes.statusText}`; - } catch (e) { errorMsg += `: ${botsRes.statusText}`; } - accumulatedError += errorMsg + '\n'; - } - - // Discord IDs - if (discordIdsRes.ok) { - const discordIdsData: DiscordId[] = await discordIdsRes.json(); - setDiscordIds(discordIdsData); - } else { - let errorMsg = `Failed to fetch Discord IDs (${discordIdsRes.status})`; - try { - const errorData: ErrorResponse = await discordIdsRes.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || discordIdsRes.statusText}`; - } catch (e) { errorMsg += `: ${discordIdsRes.statusText}`; } - accumulatedError += errorMsg + '\n'; - } - - // Systems - if (systemsRes.ok) { - const systemsData: System[] = await systemsRes.json(); - setSystems(systemsData); - } else { - let errorMsg = `Failed to fetch systems (${systemsRes.status})`; - try { - const errorData: ErrorResponse = await systemsRes.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || systemsRes.statusText}`; - } catch (e) { errorMsg += `: ${systemsRes.statusText}`; } - accumulatedError += errorMsg + '\n'; - } - - if (accumulatedError) { - setError(accumulatedError.trim()); - } - - } catch (err: any) { - setError('Failed to fetch data. Check server connection or console.'); - console.error(err); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (token) { - fetchData(); - } - }, [token]); - - - const handleAddId = async (): Promise => { - setError(''); - try { - const payload: Omit = { - ...newIdData, - guild_ids: newIdData.guild_ids.split(',').map(id => id.trim()).filter(id => id), - }; - const method = editingId ? 'PUT' : 'POST'; - const url = editingId ? `${API_BASE_URL}/discord_ids/${editingId._id}` : `${API_BASE_URL}/discord_ids/`; - - const response = await fetch(url, { - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - // const data = await response.json(); // if needed - fetchData(); - setIsAddIdDialogOpen(false); - setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' }); - setEditingId(null); - } else { - let errorMsg = `Failed to ${editingId ? 'update' : 'add'} Discord ID (${response.status})`; - try { - const errorData: ErrorResponse = await response.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; - } catch (e) { errorMsg += `: ${response.statusText}`; } - setError(errorMsg); - } - } catch (err: any) { - setError(`Network error during ${editingId ? 'update' : 'add'} Discord ID.`); - console.error(err); - } - }; - - const handleDeleteId = async (id: string): Promise => { - if (!window.confirm('Are you sure you want to delete this Discord ID?')) return; - setError(''); - try { - const response = await fetch(`${API_BASE_URL}/discord_ids/${id}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - // const data = await response.json(); // if needed - fetchData(); - } else { - let errorMsg = `Failed to delete Discord ID (${response.status})`; - try { - const errorData: ErrorResponse = await response.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; - } catch (e) { errorMsg += `: ${response.statusText}`; } - setError(errorMsg); - } - } catch (err: any) { - setError('Network error during delete Discord ID.'); - console.error(err); - } - }; - - const handleAssignDismiss = async (): Promise => { - setError(''); - if (!selectedBotClientId || !selectedSystemId) { - setError("Bot Client ID and System must be selected."); - return; - } - try { - const endpoint = assignDismissAction === 'assign' ? 'assign' : 'dismiss'; - const response = await fetch(`${API_BASE_URL}/systems/${selectedSystemId}/${endpoint}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ client_id: selectedBotClientId }), - }); - if (response.ok) { - // const data = await response.json(); // if needed - fetchData(); // Refetch all data to reflect changes in system assignments - setIsAssignDismissDialogOpen(false); - setSelectedBotClientId(''); - setSelectedSystemId(''); - } else { - let errorMsg = `Failed to ${assignDismissAction} bot (${response.status})`; - try { - const errorData: ErrorResponse = await response.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; - } catch (e) { errorMsg += `: ${response.statusText}`; } - setError(errorMsg); - } - } catch (err: any) { - setError(`Network error during ${assignDismissAction} bot.`); - console.error(err); - } - }; - - if (loading) return

Loading bots and Discord IDs...

; - // Error display is now more consolidated from fetchData - if (error && !loading) return

{error}

; - - - return ( - - - Bots Management - - - {error &&

{error}

} -
-

Online Bots (Clients)

- {/* TODO: This table needs data from a different endpoint (e.g., /nodes/get_online_bots) - to accurately show which Discord ID is assigned to an online client. - The current `bots` state is just a list of client_ids, not their full status. - For now, displaying client_id only. - */} - - - - Client ID - Assigned Discord ID (Name) - {/* This column requires mapping client_id to DiscordId which is not directly available from /bots endpoint */} - - - - {bots.length > 0 ? ( - bots.map((botClientId) => { - // Placeholder: To accurately display assigned Discord ID, need to fetch and map - // from an endpoint that provides { client_id: discord_id_details } - const assignedSystem = systems.find(sys => sys.avail_on_nodes.includes(botClientId)); - const assignedDiscordBot = discordIds.find(did => did.name === assignedSystem?.name); // This mapping is speculative - - return ( - - {botClientId} - - {assignedSystem ? `${assignedSystem.name} (System)` : "N/A or requires different mapping"} - {/* Displaying Discord ID's name if a system is found on this node. - This is an assumption. A better API would link client_id to discord_id directly. */} - - - ); - }) - ) : ( - - No bots currently online or reported by /bots. - - )} - -
-
- -
-

Manage Discord IDs

- - - - - - - Name - Discord ID - Token (Partial) - Active - Guilds - Actions - - - - {discordIds.length > 0 ? ( - discordIds.map((dId) => ( - - {dId.name} - {dId.discord_id} - {dId.token ? dId.token.substring(0, 8) + '...' : 'N/A'} - {dId.active ? 'Yes' : 'No'} - {dId.guild_ids.join(', ')} - - - - - - )) - ) : ( - - No Discord IDs found. - - )} - -
-
-
- - {/* Add/Edit Discord ID Dialog */} - - - - {editingId ? 'Edit Discord ID' : 'Add New Discord ID'} - -
-
- - setNewIdData({ ...newIdData, name: e.target.value })} - className="col-span-3" - /> -
-
- - setNewIdData({ ...newIdData, discord_id: e.target.value })} - className="col-span-3" - /> -
-
- - setNewIdData({ ...newIdData, token: e.target.value })} - className="col-span-3" - placeholder={editingId ? "Token hidden for security, re-enter to change" : ""} - /> -
-
- - setNewIdData({ ...newIdData, guild_ids: e.target.value })} - className="col-span-3" - /> -
-
- - setNewIdData({ ...newIdData, active: checked === true })} - className="col-span-3" - /> -
-
- - - -
-
- - {/* Assign/Dismiss Bot Dialog */} - - - - Assign/Dismiss Bot to System - -
-
- - -
-
- - -
-
- - -
-
- - - -
-
-
- ); -}; - -// --- Systems Management Component --- -interface SystemsManagementProps { - token: string; -} - -const SystemsManagement: React.FC = ({ token }) => { - const [systems, setSystems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - - const [isAddSystemDialogOpen, setIsAddSystemDialogOpen] = useState(false); - const [newSystemData, setNewSystemData] = useState< - Omit & { - _id?: string; // Make _id optional for new system data - frequencies: string; - avail_on_nodes: string; - tags: string; - whitelist: string; - } - >({ - type: DemodTypes.P25, - name: '', - frequencies: '', - location: '', - avail_on_nodes: '', - description: '', - tags: '[]', // Default to empty JSON array string - whitelist: '', - }); - const [editingSystem, setEditingSystem] = useState(null); - - const fetchSystems = async (): Promise => { - setLoading(true); - setError(''); - try { - const response = await fetch(`${API_BASE_URL}/systems/`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - const data: System[] = await response.json(); - setSystems(data); - } else { - let errorMsg = `Failed to fetch systems (${response.status})`; - try { - const errorData: ErrorResponse = await response.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; - } catch (e) { errorMsg += `: ${response.statusText}`; } - setError(errorMsg); - } - } catch (err: any) { - setError('Failed to fetch systems. Check server connection or console.'); - console.error(err); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - if (token) { - fetchSystems(); - } - }, [token]); - - const handleAddSystem = async (): Promise => { - setError(''); - try { - let parsedTags: TalkgroupTag[] | undefined = undefined; - try { - parsedTags = newSystemData.tags ? JSON.parse(newSystemData.tags) : []; - if (!Array.isArray(parsedTags)) throw new Error("Tags must be a JSON array."); - } catch (e: any) { - setError(`Invalid JSON format for Tags: ${e.message}`); - return; - } - - const payload: Omit & { - _id?: string; - frequencies: number[]; - avail_on_nodes: string[]; - tags?: TalkgroupTag[]; - whitelist?: number[]; - } = { - name: newSystemData.name, - type: newSystemData.type, - location: newSystemData.location, - description: newSystemData.description || undefined, - frequencies: newSystemData.frequencies.split(',').map(f => parseInt(f.trim())).filter(f => !isNaN(f)), - avail_on_nodes: newSystemData.avail_on_nodes.split(',').map(node => node.trim()).filter(node => node), - tags: parsedTags, - whitelist: newSystemData.whitelist.split(',').map(w => parseInt(w.trim())).filter(w => !isNaN(w)), - }; - - if (editingSystem) { - payload._id = editingSystem._id; - } else if (newSystemData._id) { // Only include _id if provided for new system - payload._id = newSystemData._id; - } - - - const method = editingSystem ? 'PUT' : 'POST'; - const url = editingSystem ? `${API_BASE_URL}/systems/${editingSystem._id}` : - (payload._id ? `${API_BASE_URL}/systems/${payload._id}` : `${API_BASE_URL}/systems/`); - - - const response = await fetch(url, { - method, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - // const data = await response.json(); // if needed - fetchSystems(); - setIsAddSystemDialogOpen(false); - setNewSystemData({ - _id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '', - avail_on_nodes: '', description: '', tags: '[]', whitelist: '', - }); - setEditingSystem(null); - } else { - let errorMsg = `Failed to ${editingSystem ? 'update' : 'add'} system (${response.status})`; - try { - const errorData: ErrorResponse = await response.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; - } catch (e) { errorMsg += `: ${response.statusText}`; } - setError(errorMsg); - } - } catch (err: any) { - setError(`Network error during ${editingSystem ? 'update' : 'add'} system. ${err.message}`); - console.error(err); - } - }; - - const handleDeleteSystem = async (id: string): Promise => { - if (!window.confirm('Are you sure you want to delete this system?')) return; - setError(''); - try { - const response = await fetch(`${API_BASE_URL}/systems/${id}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${token}` }, - }); - if (response.ok) { - // const data = await response.json(); // if needed - fetchSystems(); - } else { - let errorMsg = `Failed to delete system (${response.status})`; - try { - const errorData: ErrorResponse = await response.json(); - errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`; - } catch (e) { errorMsg += `: ${response.statusText}`; } - setError(errorMsg); - } - } catch (err: any) { - setError('Network error during delete system.'); - console.error(err); - } - }; - - if (loading) return

Loading systems...

; - if (error && !loading) return

{error}

; - - return ( - - - Systems Management - - - {error &&

{error}

} -
-

All Systems

- - - - - - Name (_id) - Type - Frequencies - Location - Available Nodes - Actions - - - - {systems.length > 0 ? ( - systems.map((system: System) => ( - - {system.name} ({system._id}) - {system.type} - {system.frequencies.join(', ')} - {system.location} - {system.avail_on_nodes.join(', ')} - - - - - - )) - ) : ( - - No systems found. - - )} - -
-
-
- - {/* Add/Edit System Dialog */} - - - - {editingSystem ? 'Edit System' : 'Add New System'} - -
-
- - ) => setNewSystemData({ ...newSystemData, _id: e.target.value })} - className="col-span-3" - disabled={!!editingSystem} - placeholder={editingSystem ? newSystemData._id : "Leave blank to auto-generate, or specify"} - /> -
-
- - ) => setNewSystemData({ ...newSystemData, name: e.target.value })} - className="col-span-3" - /> -
-
- - -
-
- - ) => setNewSystemData({ ...newSystemData, frequencies: e.target.value })} - className="col-span-3" - /> -
-
- - ) => setNewSystemData({ ...newSystemData, location: e.target.value })} - className="col-span-3" - /> -
-
- - ) => setNewSystemData({ ...newSystemData, avail_on_nodes: e.target.value })} - className="col-span-3" - /> -
-
- -