Init working UI
This commit is contained in:
1069
src/app/page.tsx
1069
src/app/page.tsx
File diff suppressed because it is too large
Load Diff
68
src/components/AppContent.tsx
Normal file
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
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
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
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
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
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
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
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
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;
|
||||
1
src/public/file.svg
Normal file
1
src/public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
src/public/globe.svg
Normal file
1
src/public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
src/public/next.svg
Normal file
1
src/public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
src/public/vercel.svg
Normal file
1
src/public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
src/public/window.svg
Normal file
1
src/public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
65
src/types/index.ts
Normal file
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;
|
||||
}
|
||||
Reference in New Issue
Block a user