Init working UI
35
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"]
|
||||
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
|
||||
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."
|
||||
@@ -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",
|
||||
|
||||
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;
|
||||
}
|
||||