Init working UI
35
Dockerfile
@@ -11,7 +11,7 @@ RUN npm ci
|
|||||||
# COPY package.json yarn.lock ./
|
# COPY package.json yarn.lock ./
|
||||||
# RUN yarn install --frozen-lockfile
|
# 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
|
FROM node:22-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -19,32 +19,33 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the Next.js application
|
# Build the Next.js application for production
|
||||||
# This will create the .next directory with optimized production build
|
ENV NODE_ENV production
|
||||||
RUN npm run build
|
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
|
FROM node:22-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Set environment variables for Next.js production server
|
# Copy necessary files for both development and production
|
||||||
ENV NODE_ENV production
|
COPY --from=builder /app/src/public ./public
|
||||||
|
|
||||||
# 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 --from=builder /app/.next ./.next
|
COPY --from=builder /app/.next ./.next
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./package.json
|
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 the port Next.js runs on (default is 3000)
|
||||||
EXPOSE 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
|
# 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"]
|
||||||
97
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
|
# Define variables
|
||||||
APP_NAME := drb-frontend
|
APP_NAME := drb-frontend
|
||||||
DOCKER_IMAGE_NAME := $(APP_NAME):latest
|
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
|
all: dev
|
||||||
|
|
||||||
# Install Node.js dependencies
|
# Install Node.js dependencies
|
||||||
install:
|
install:
|
||||||
@echo "Installing Node.js dependencies..."
|
@echo "➡️ Installing Node.js dependencies..."
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
# Build the Next.js application for production
|
# Build the Next.js application for production
|
||||||
build: install
|
build: install
|
||||||
@echo "Building Next.js application for production..."
|
@echo "➡️ Building Next.js application for production..."
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Start the Next.js application in production mode
|
# Start the Next.js application in production mode (locally)
|
||||||
start: build
|
start: build
|
||||||
@echo "Starting Next.js application in production mode..."
|
@echo "➡️ Starting Next.js application in production mode locally..."
|
||||||
npm start
|
npm start
|
||||||
|
|
||||||
# Start the Next.js application in development mode
|
# Start the Next.js application in development mode (locally)
|
||||||
dev: install
|
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
|
npm run dev
|
||||||
|
|
||||||
# Clean up build artifacts and node_modules
|
# Clean up build artifacts and node_modules
|
||||||
clean:
|
clean:
|
||||||
@echo "Cleaning up build artifacts and node_modules..."
|
@echo "➡️ Cleaning up build artifacts and node_modules..."
|
||||||
rm -rf .next out node_modules
|
rm -rf .next out node_modules
|
||||||
@echo "Clean up complete."
|
@echo "✅ Clean up complete."
|
||||||
|
|
||||||
|
# --- Docker Operations ---
|
||||||
|
|
||||||
# Build the Docker image
|
# Build the Docker image
|
||||||
docker-build:
|
docker-build:
|
||||||
@echo "Building Docker image: $(DOCKER_IMAGE_NAME)..."
|
@echo "🐳 Building Docker image: $(DOCKER_IMAGE_NAME)..."
|
||||||
docker build -t $(DOCKER_IMAGE_NAME) .
|
docker build -t $(DOCKER_IMAGE_NAME) .
|
||||||
@echo "Docker image built successfully."
|
@echo "✅ Docker image built successfully."
|
||||||
|
|
||||||
# Run the Docker container
|
# Run the Docker container in development mode
|
||||||
docker-run: docker-build
|
docker-dev: #docker-build
|
||||||
@echo "Running Docker container: $(DOCKER_CONTAINER_NAME)..."
|
@echo "🚀 Running Docker container in DEVELOPMENT mode: $(DOCKER_DEV_CONTAINER_NAME)..."
|
||||||
docker run -it --rm --name $(DOCKER_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME)
|
@echo " (Volume mounted for Fast Refresh: $(PWD):/app)"
|
||||||
@echo "Docker container started. Access at http://localhost:3000"
|
# 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
|
# Run the Docker container in production mode
|
||||||
docker-stop:
|
docker-prod: docker-build docker-stop-prod
|
||||||
@echo "Stopping and removing Docker container: $(DOCKER_CONTAINER_NAME)..."
|
@echo "🚀 Running Docker container in PRODUCTION mode: $(DOCKER_PROD_CONTAINER_NAME)..."
|
||||||
docker stop $(DOCKER_CONTAINER_NAME) || true
|
docker run -d --rm --name $(DOCKER_PROD_CONTAINER_NAME) -p 3000:3000 $(DOCKER_IMAGE_NAME)
|
||||||
docker rm $(DOCKER_CONTAINER_NAME) || true
|
@echo "✅ Docker container started in production. Access at http://localhost:3000"
|
||||||
@echo "Docker container stopped and removed."
|
|
||||||
|
|
||||||
# Optional: Rebuild and rerun the Docker container
|
# Stop and remove the DEVELOPMENT Docker container
|
||||||
docker-rebuild-run: docker-stop docker-run
|
docker-stop-dev:
|
||||||
@echo "Rebuilding and rerunning Docker container."
|
@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."
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|||||||
1069
src/app/page.tsx
68
src/components/AppContent.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import LoginPage from '@/components/LoginPage';
|
||||||
|
import BotsManagement from '@/components/BotsManagement';
|
||||||
|
import SystemsManagement from '@/components/SystemsManagement';
|
||||||
|
import { UserRoles } from '@/types';
|
||||||
|
|
||||||
|
const AppContent: React.FC = () => {
|
||||||
|
const { user, loading, logout, hasPermission, token } = useAuth();
|
||||||
|
const [currentPage, setCurrentPage] = useState<'login' | 'management'>(loading ? 'login' : (user ? 'management' : 'login'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
if (user && hasPermission(UserRoles.MOD)) {
|
||||||
|
setCurrentPage('management');
|
||||||
|
} else {
|
||||||
|
setCurrentPage('login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [user, loading, hasPermission]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
|
||||||
|
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
<h1 className="text-xl font-bold">Radio App Admin</h1>
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
|
||||||
|
<Button onClick={() => {
|
||||||
|
logout();
|
||||||
|
setCurrentPage('login');
|
||||||
|
}} variant="outline">Logout</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="p-6">
|
||||||
|
{currentPage === 'login' && <LoginPage />}
|
||||||
|
{currentPage === 'management' && user && token && hasPermission(UserRoles.MOD) && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<BotsManagement token={token} />
|
||||||
|
<SystemsManagement token={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentPage === 'management' && user && !hasPermission(UserRoles.MOD) && (
|
||||||
|
<div className="text-center text-red-500 text-lg">
|
||||||
|
You do not have permission to view this page. Your role: {user.role}. Required: {UserRoles.MOD}.
|
||||||
|
<Button onClick={() => setCurrentPage('login')} className="mt-4">Go to Login</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentPage === 'management' && !user && (
|
||||||
|
<div className="text-center text-orange-500 text-lg">
|
||||||
|
Session might have expired. Please login again.
|
||||||
|
<Button onClick={() => setCurrentPage('login')} className="mt-4">Go to Login</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppContent;
|
||||||
425
src/components/BotsManagement.tsx
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { API_BASE_URL } from '@/constants/api';
|
||||||
|
import { DiscordId, System, ErrorResponse } from '@/types'; // Import types
|
||||||
|
|
||||||
|
interface BotsManagementProps {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BotsManagement: React.FC<BotsManagementProps> = ({ token }) => {
|
||||||
|
const [bots, setBots] = useState<string[]>([]);
|
||||||
|
const [discordIds, setDiscordIds] = useState<DiscordId[]>([]);
|
||||||
|
const [systems, setSystems] = useState<System[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const [isAddIdDialogOpen, setIsAddIdDialogOpen] = useState<boolean>(false);
|
||||||
|
const [newIdData, setNewIdData] = useState<Omit<DiscordId, '_id' | 'guild_ids'> & { guild_ids: string }>({
|
||||||
|
discord_id: '',
|
||||||
|
name: '',
|
||||||
|
token: '',
|
||||||
|
active: false,
|
||||||
|
guild_ids: '',
|
||||||
|
});
|
||||||
|
const [editingId, setEditingId] = useState<DiscordId | null>(null);
|
||||||
|
|
||||||
|
const [isAssignDismissDialogOpen, setIsAssignDismissDialogOpen] = useState<boolean>(false);
|
||||||
|
const [selectedBotClientId, setSelectedBotClientId] = useState<string>('');
|
||||||
|
const [selectedSystemId, setSelectedSystemId] = useState<string>('');
|
||||||
|
const [assignDismissAction, setAssignDismissAction] = useState<'assign' | 'dismiss'>('assign');
|
||||||
|
|
||||||
|
const fetchData = async (): Promise<void> => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
let accumulatedError = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [nodesRes, discordIdsRes, systemsRes] = await Promise.all([
|
||||||
|
fetch(`${API_BASE_URL}/nodes/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
fetch(`${API_BASE_URL}/bots/tokens/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
fetch(`${API_BASE_URL}/systems/`, { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (nodesRes.ok) {
|
||||||
|
const botsData: any = await nodesRes.json();
|
||||||
|
setBots(botsData);
|
||||||
|
} else {
|
||||||
|
let errorMsg = `Failed to fetch bots (${nodesRes.status})`;
|
||||||
|
try {
|
||||||
|
const errorData: ErrorResponse = await nodesRes.json();
|
||||||
|
errorMsg += `: ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || nodesRes.statusText}`;
|
||||||
|
} catch (e) { errorMsg += `: ${nodesRes.statusText}`; }
|
||||||
|
accumulatedError += errorMsg + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const payload: Omit<DiscordId, '_id'> = {
|
||||||
|
...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) {
|
||||||
|
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<void> => {
|
||||||
|
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) {
|
||||||
|
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<void> => {
|
||||||
|
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) {
|
||||||
|
fetchData();
|
||||||
|
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 <p>Loading bots and Discord IDs...</p>;
|
||||||
|
if (error && !loading) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Bots Management</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{error && <p className="text-red-500 whitespace-pre-line">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Online Bots (Clients)</h3>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Client ID</TableHead>
|
||||||
|
<TableHead>Assigned Discord ID (Name)</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{bots.length > 0 ? (
|
||||||
|
bots.map((botClientId) => {
|
||||||
|
const assignedSystem = systems.find(sys => sys.avail_on_nodes.includes(botClientId));
|
||||||
|
const assignedDiscordBot = discordIds.find(did => did.name === assignedSystem?.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={botClientId}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link href={`/nodes/${botClientId}`} className="text-blue-600 hover:underline">
|
||||||
|
{botClientId}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignedSystem ? `${assignedSystem.name} (System)` : "N/A or requires different mapping"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={2} className="text-center">No bots currently online or reported by /bots.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">Manage Discord IDs</h3>
|
||||||
|
<Button onClick={() => { setEditingId(null); setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' }); setIsAddIdDialogOpen(true);}} className="mb-4">Add New Discord ID</Button>
|
||||||
|
<Button onClick={() => setIsAssignDismissDialogOpen(true)} className="mb-4 ml-2">Assign/Dismiss Bot System</Button>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Discord ID</TableHead>
|
||||||
|
<TableHead>Token (Partial)</TableHead>
|
||||||
|
<TableHead>Active</TableHead>
|
||||||
|
<TableHead>Guilds</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{discordIds.length > 0 ? (
|
||||||
|
discordIds.map((dId) => (
|
||||||
|
<TableRow key={dId._id}>
|
||||||
|
<TableCell className="font-medium">{dId.name}</TableCell>
|
||||||
|
<TableCell>{dId.discord_id}</TableCell>
|
||||||
|
<TableCell className="truncate max-w-xs">{dId.token ? dId.token.substring(0, 8) + '...' : 'N/A'}</TableCell>
|
||||||
|
<TableCell>{dId.active ? 'Yes' : 'No'}</TableCell>
|
||||||
|
<TableCell>{dId.guild_ids.join(', ')}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm" className="mr-2"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(dId);
|
||||||
|
setNewIdData({
|
||||||
|
discord_id: dId.discord_id,
|
||||||
|
name: dId.name,
|
||||||
|
token: dId.token,
|
||||||
|
active: dId.active,
|
||||||
|
guild_ids: dId.guild_ids.join(', '),
|
||||||
|
});
|
||||||
|
setIsAddIdDialogOpen(true);
|
||||||
|
}}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => handleDeleteId(dId._id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center">No Discord IDs found.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isAddIdDialogOpen} onOpenChange={setIsAddIdDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingId ? 'Edit Discord ID' : 'Add New Discord ID'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={newIdData.name}
|
||||||
|
onChange={(e) => setNewIdData({ ...newIdData, name: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="discord_id" className="text-right">Discord ID</Label>
|
||||||
|
<Input
|
||||||
|
id="discord_id"
|
||||||
|
value={newIdData.discord_id}
|
||||||
|
onChange={(e) => setNewIdData({ ...newIdData, discord_id: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="token" className="text-right">Token</Label>
|
||||||
|
<Input
|
||||||
|
id="token"
|
||||||
|
value={newIdData.token}
|
||||||
|
onChange={(e) => setNewIdData({ ...newIdData, token: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={editingId ? "Token hidden for security, re-enter to change" : ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="guild_ids" className="text-right">Guild IDs (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="guild_ids"
|
||||||
|
value={newIdData.guild_ids}
|
||||||
|
onChange={(e) => setNewIdData({ ...newIdData, guild_ids: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="active" className="text-right">Active</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="active"
|
||||||
|
checked={newIdData.active}
|
||||||
|
onCheckedChange={(checked) => setNewIdData({ ...newIdData, active: checked === true })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleAddId}>{editingId ? 'Save Changes' : 'Add Discord ID'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={isAssignDismissDialogOpen} onOpenChange={setIsAssignDismissDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Assign/Dismiss Bot to System</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="bot-client-id" className="text-right">Bot Client ID</Label>
|
||||||
|
<Select onValueChange={setSelectedBotClientId} value={selectedBotClientId}>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select a bot client ID" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{bots.map((botId) => (
|
||||||
|
<SelectItem key={botId} value={botId}>{botId}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="system-id" className="text-right">System</Label>
|
||||||
|
<Select onValueChange={setSelectedSystemId} value={selectedSystemId}>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select a system" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{systems.map((system) => (
|
||||||
|
<SelectItem key={system._id} value={system._id}>{system.name} ({system._id})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="action" className="text-right">Action</Label>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value: string) => setAssignDismissAction(value as 'assign' | 'dismiss')}
|
||||||
|
value={assignDismissAction}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="assign">Assign</SelectItem>
|
||||||
|
<SelectItem value="dismiss">Dismiss</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleAssignDismiss} disabled={!selectedBotClientId || !selectedSystemId}>Perform Action</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BotsManagement;
|
||||||
221
src/components/IndividualClientPage.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
// components/IndividualClientPage.tsx
|
||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { API_BASE_URL } from '@/constants/api';
|
||||||
|
import { ErrorResponse, NodeStatusResponse, System } from '@/types';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface IndividualClientPageProps {
|
||||||
|
clientId: string; // Now received as a prop
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, token }) => {
|
||||||
|
const [message, setMessage] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [currentClientDiscordStatus, setCurrentClientDiscordStatus] = useState<string>('Unknown');
|
||||||
|
const [currentClientOp25Status, setCurrentClientOp25Status] = useState<string>('Unknown');
|
||||||
|
|
||||||
|
// OP25 Set Config Popup States
|
||||||
|
const [selectedSystem, setSelectedSystem] = useState<string | null>(null);
|
||||||
|
const [isSetConfigDialogOpen, setIsSetConfigDialogOpen] = useState<boolean>(false);
|
||||||
|
const [availableSystems, setAvailableSystems] = useState<System[]>([]);
|
||||||
|
|
||||||
|
const fetchClientStatus = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data: NodeStatusResponse = await response.json();
|
||||||
|
console.log(data)
|
||||||
|
setCurrentClientDiscordStatus(data.status.discord_status);
|
||||||
|
setCurrentClientOp25Status(data.status.op25_status)
|
||||||
|
} else {
|
||||||
|
setCurrentClientDiscordStatus('Failed to fetch status');
|
||||||
|
setCurrentClientOp25Status('Failed to fetch status');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setCurrentClientDiscordStatus('Error fetching status');
|
||||||
|
setCurrentClientOp25Status('Error fetching status');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSystems = async (): Promise<void> => {
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/systems/`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data: System[] = await response.json();
|
||||||
|
// Filter systems to those available on the current clientId
|
||||||
|
const filteredSystems = data.filter(system => system.avail_on_nodes.includes(clientId));
|
||||||
|
setAvailableSystems(filteredSystems);
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClientStatus();
|
||||||
|
fetchSystems(); // Fetch systems when the component mounts or clientId/token changes
|
||||||
|
}, [clientId, token]);
|
||||||
|
|
||||||
|
const handleAction = async (action: 'join' | 'leave' | 'op25_start' | 'op25_stop' | 'op25_set'): Promise<void> => {
|
||||||
|
setMessage('');
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const httpOptions: RequestInit = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "op25_set") {
|
||||||
|
if (!selectedSystem) {
|
||||||
|
setError("Please select a system to set configuration.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
httpOptions.body = JSON.stringify({
|
||||||
|
"system_id": selectedSystem
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/nodes/${clientId}/${action}`, httpOptions);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage(`Client "${clientId}" successfully ${action === 'op25_set' ? 'set config for system ' + selectedSystem : action + 'ed'}.`);
|
||||||
|
fetchClientStatus();
|
||||||
|
setIsSetConfigDialogOpen(false); // Close dialog on success
|
||||||
|
} else {
|
||||||
|
const errorData = data as ErrorResponse;
|
||||||
|
setError(`Failed to ${action} client "${clientId}": ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(`Network error during ${action} client: ${err.message}`);
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-md mx-auto mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Manage Client: {clientId}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p>Current Discord Status: {currentClientDiscordStatus}</p>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAction('join')}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading && message.includes('join') ? 'Joining...' : 'Join Client'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAction('leave')}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading && message.includes('leave') ? 'Leaving...' : 'Leave Client'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p>Current OP25 Status: {currentClientOp25Status}</p>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAction('op25_start')}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading && message.includes('start') ? 'Starting...' : 'Start OP25'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsSetConfigDialogOpen(true)} // Open dialog for set config
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Set OP25 Config
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => handleAction('op25_stop')}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading && message.includes('stop') ? 'Stopping...' : 'Stop OP25'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{message && <p className="text-green-500 text-sm">{message}</p>}
|
||||||
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
{loading && <p className="text-blue-500 text-sm">Processing request...</p>}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Set Config Dialog */}
|
||||||
|
<Dialog open={isSetConfigDialogOpen} onOpenChange={setIsSetConfigDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Set OP25 System Configuration</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="system-select" className="text-right">Select System</Label>
|
||||||
|
<Select onValueChange={setSelectedSystem} value={selectedSystem || ''}>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Choose a system" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableSystems.map((system) => (
|
||||||
|
<SelectItem key={system._id} value={system._id}>
|
||||||
|
{system.name} ({system._id})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => handleAction('op25_set')} disabled={!selectedSystem || loading}>
|
||||||
|
{loading ? 'Setting...' : 'Confirm Set Config'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsSetConfigDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndividualClientPage;
|
||||||
67
src/components/LoginPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState } 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 { useAuth } from '@/context/AuthContext';
|
||||||
|
|
||||||
|
const LoginPage: React.FC = () => {
|
||||||
|
const [username, setUsername] = useState<string>('');
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
const { login } = useAuth();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
const success = await login(username, password);
|
||||||
|
if (!success) {
|
||||||
|
setError('Invalid username or password. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||||
|
<Card className="w-full max-w-md p-6 space-y-4 rounded-lg shadow-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-center text-2xl font-bold">Login</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="username">Username</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
354
src/components/SystemsManagement.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect } 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 { API_BASE_URL } from '@/constants/api';
|
||||||
|
import { DemodTypes, TalkgroupTag, System, ErrorResponse } from '@/types'; // Import types
|
||||||
|
|
||||||
|
interface SystemsManagementProps {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemsManagement: React.FC<SystemsManagementProps> = ({ token }) => {
|
||||||
|
const [systems, setSystems] = useState<System[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const [isAddSystemDialogOpen, setIsAddSystemDialogOpen] = useState<boolean>(false);
|
||||||
|
const [newSystemData, setNewSystemData] = useState<
|
||||||
|
Omit<System, '_id' | 'frequencies' | 'avail_on_nodes' | 'tags' | 'whitelist'> & {
|
||||||
|
_id?: string;
|
||||||
|
frequencies: string;
|
||||||
|
avail_on_nodes: string;
|
||||||
|
tags: string;
|
||||||
|
whitelist: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
type: DemodTypes.P25,
|
||||||
|
name: '',
|
||||||
|
frequencies: '',
|
||||||
|
location: '',
|
||||||
|
avail_on_nodes: '',
|
||||||
|
description: '',
|
||||||
|
tags: '[]',
|
||||||
|
whitelist: '',
|
||||||
|
});
|
||||||
|
const [editingSystem, setEditingSystem] = useState<System | null>(null);
|
||||||
|
|
||||||
|
const fetchSystems = async (): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<System, '_id' | 'frequencies' | 'avail_on_nodes' | 'tags' | 'whitelist'> & {
|
||||||
|
_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) {
|
||||||
|
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) {
|
||||||
|
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<void> => {
|
||||||
|
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) {
|
||||||
|
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 <p>Loading systems...</p>;
|
||||||
|
if (error && !loading) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Systems Management</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{error && <p className="text-red-500 whitespace-pre-line">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">All Systems</h3>
|
||||||
|
<Button onClick={() => {
|
||||||
|
setEditingSystem(null);
|
||||||
|
setNewSystemData({
|
||||||
|
_id: '', type: DemodTypes.P25, name: '', frequencies: '', location: '',
|
||||||
|
avail_on_nodes: '', description: '', tags: '[]', whitelist: '',
|
||||||
|
});
|
||||||
|
setIsAddSystemDialogOpen(true);
|
||||||
|
}} className="mb-4">Add New System</Button>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name (_id)</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Frequencies</TableHead>
|
||||||
|
<TableHead>Location</TableHead>
|
||||||
|
<TableHead>Available Nodes</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{systems.length > 0 ? (
|
||||||
|
systems.map((system: System) => (
|
||||||
|
<TableRow key={system._id}>
|
||||||
|
<TableCell className="font-medium">{system.name} <span className="text-xs text-gray-500">({system._id})</span></TableCell>
|
||||||
|
<TableCell>{system.type}</TableCell>
|
||||||
|
<TableCell>{system.frequencies.join(', ')}</TableCell>
|
||||||
|
<TableCell>{system.location}</TableCell>
|
||||||
|
<TableCell>{system.avail_on_nodes.join(', ')}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="outline" size="sm" className="mr-2"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingSystem(system);
|
||||||
|
setNewSystemData({
|
||||||
|
_id: system._id,
|
||||||
|
type: system.type,
|
||||||
|
name: system.name,
|
||||||
|
frequencies: system.frequencies.join(', '),
|
||||||
|
location: system.location,
|
||||||
|
avail_on_nodes: system.avail_on_nodes.join(', '),
|
||||||
|
description: system.description || '',
|
||||||
|
tags: system.tags ? JSON.stringify(system.tags, null, 2) : '[]',
|
||||||
|
whitelist: system.whitelist ? system.whitelist.join(', ') : '',
|
||||||
|
});
|
||||||
|
setIsAddSystemDialogOpen(true);
|
||||||
|
}}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => handleDeleteSystem(system._id)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center">No systems found.</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<Dialog open={isAddSystemDialogOpen} onOpenChange={setIsAddSystemDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingSystem ? 'Edit System' : 'Add New System'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="system_id_input" className="text-right">ID (Optional for new)</Label>
|
||||||
|
<Input
|
||||||
|
id="system_id_input"
|
||||||
|
value={newSystemData._id || ''}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, _id: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
disabled={!!editingSystem}
|
||||||
|
placeholder={editingSystem ? newSystemData._id : "Leave blank to auto-generate, or specify"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="name" className="text-right">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={newSystemData.name}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, name: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="type" className="text-right">Type</Label>
|
||||||
|
<Select onValueChange={(value: DemodTypes) => setNewSystemData({ ...newSystemData, type: value })} value={newSystemData.type}>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="Select type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={DemodTypes.P25}>P25</SelectItem>
|
||||||
|
<SelectItem value={DemodTypes.DMR}>DMR</SelectItem>
|
||||||
|
<SelectItem value={DemodTypes.NBFM}>NBFM (Analog)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="frequencies" className="text-right">Frequencies (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="frequencies"
|
||||||
|
value={newSystemData.frequencies}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, frequencies: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="location" className="text-right">Location</Label>
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
value={newSystemData.location}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, location: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="avail_on_nodes" className="text-right">Available Nodes (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="avail_on_nodes"
|
||||||
|
value={newSystemData.avail_on_nodes}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, avail_on_nodes: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="description" className="text-right">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={newSystemData.description || ''}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewSystemData({ ...newSystemData, description: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="tags" className="text-right">Tags (JSON string)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="tags"
|
||||||
|
value={newSystemData.tags}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewSystemData({ ...newSystemData, tags: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder='[{"talkgroup": "TG1", "tagDec": 123}, {"talkgroup": "TG2", "tagDec": 456}]'
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="whitelist" className="text-right">Whitelist TGs (comma-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="whitelist"
|
||||||
|
value={newSystemData.whitelist}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewSystemData({ ...newSystemData, whitelist: e.target.value })}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder='123,456,789'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleAddSystem}>{editingSystem ? 'Save Changes' : 'Add System'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemsManagement;
|
||||||
1
src/constants/api.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const API_BASE_URL = "http://172.16.100.81:5000";
|
||||||
89
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useState, useEffect, createContext, useContext, ReactNode } from 'react';
|
||||||
|
import { API_BASE_URL } from '@/constants/api';
|
||||||
|
import { UserDetails, UserRoles, ErrorResponse, AuthContextType } from '@/types'; // Import types
|
||||||
|
|
||||||
|
export const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [user, setUser] = useState<UserDetails | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string): Promise<boolean> => {
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setToken(data.access_token);
|
||||||
|
const tempUser: UserDetails = { id: data.user_id || 'some-id', username: data.username, role: data.role || UserRoles.USER };
|
||||||
|
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');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPermission = (requiredRole: UserRoles): boolean => {
|
||||||
|
if (!user || !user.role) return false;
|
||||||
|
const roleOrder: Record<UserRoles, number> = {
|
||||||
|
[UserRoles.USER]: 0,
|
||||||
|
[UserRoles.MOD]: 1,
|
||||||
|
[UserRoles.ADMIN]: 2
|
||||||
|
};
|
||||||
|
return roleOrder[user.role] >= roleOrder[requiredRole];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ token, user, loading, login, logout, hasPermission }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextType => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (context === null) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
14
src/pages/_app.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// pages/_app.tsx
|
||||||
|
import type { AppProps } from 'next/app';
|
||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
|
import '@/app/globals.css'; // Assuming your global styles are here
|
||||||
|
|
||||||
|
function DRB({ Component, pageProps }: AppProps) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DRB;
|
||||||
64
src/pages/nodes/[clientId].tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useRouter } from 'next/router'; // For Pages Router
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import IndividualClientPage from '@/components/IndividualClientPage';
|
||||||
|
import LoginPage from '@/components/LoginPage';
|
||||||
|
import { UserRoles } from '@/types';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
|
||||||
|
const ClientDetailPage: React.FC = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { clientId } = router.query; // Get clientId from the URL
|
||||||
|
const { user, loading, token, hasPermission } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="flex items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">Loading Authentication...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !token || !hasPermission(UserRoles.MOD)) {
|
||||||
|
// Redirect to login or show access denied if not authenticated or authorized
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
|
||||||
|
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
<h1 className="text-xl font-bold">Radio App Admin</h1>
|
||||||
|
</header>
|
||||||
|
<main className="p-6">
|
||||||
|
{!user ? (
|
||||||
|
<LoginPage />
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-red-500 text-lg">
|
||||||
|
You do not have permission to view this page. Your role: {user.role}. Required: {UserRoles.MOD}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure clientId is a string before passing
|
||||||
|
const clientIdentifier = Array.isArray(clientId) ? clientId[0] : clientId;
|
||||||
|
|
||||||
|
if (!clientIdentifier) {
|
||||||
|
return <div className="text-center text-red-500 text-lg mt-10">Client ID not found in URL.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans">
|
||||||
|
<header className="flex justify-between items-center p-4 bg-white dark:bg-gray-800 shadow-md">
|
||||||
|
<h1 className="text-xl font-bold">Radio App Admin</h1>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-sm">Logged in as: {user.username} ({user.role})</span>
|
||||||
|
<Button onClick={() => router.push('/')} variant="outline">Back to Management</Button> {/* Add a back button */}
|
||||||
|
<Button onClick={useAuth().logout} variant="outline">Logout</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="p-6">
|
||||||
|
<IndividualClientPage clientId={clientIdentifier} token={token} />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientDetailPage;
|
||||||
|
Before Width: | Height: | Size: 391 B After Width: | Height: | Size: 391 B |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 128 B After Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 385 B |
65
src/types/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
export enum DemodTypes {
|
||||||
|
P25 = "P25",
|
||||||
|
DMR = "DMR",
|
||||||
|
NBFM = "NBFM"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TalkgroupTag {
|
||||||
|
talkgroup: string;
|
||||||
|
tagDec: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordId {
|
||||||
|
_id: string;
|
||||||
|
discord_id: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
active: boolean;
|
||||||
|
guild_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface System {
|
||||||
|
_id: string;
|
||||||
|
type: DemodTypes;
|
||||||
|
name: string;
|
||||||
|
frequencies: number[];
|
||||||
|
location: string;
|
||||||
|
avail_on_nodes: string[];
|
||||||
|
description?: string;
|
||||||
|
tags?: TalkgroupTag[];
|
||||||
|
whitelist?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum UserRoles {
|
||||||
|
ADMIN = "admin",
|
||||||
|
MOD = "mod",
|
||||||
|
USER = "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetails {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: UserRoles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorResponse {
|
||||||
|
message?: string;
|
||||||
|
detail?: string | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStatusResponse {
|
||||||
|
status: {
|
||||||
|
op25_status: string;
|
||||||
|
discord_status: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Context Types
|
||||||
|
export interface AuthContextType {
|
||||||
|
token: string | null;
|
||||||
|
user: UserDetails | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
hasPermission: (requiredRole: UserRoles) => boolean;
|
||||||
|
}
|
||||||