468 lines
20 KiB
TypeScript
468 lines
20 KiB
TypeScript
// BotsManagement.tsx
|
|
"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 { DRB_BASE_API_URL } from '@/constants/api';
|
|
import { DiscordId, System, ErrorResponse, ActiveClient } from '@/types';
|
|
import { authAwareFetch } from '@/utils/AuthAwareFetch';
|
|
|
|
interface BotsManagementProps {
|
|
token: string | null; // Allow token to be null initially
|
|
logoutUser: () => void;
|
|
}
|
|
|
|
const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) => {
|
|
const [bots, setBots] = useState<ActiveClient[]>([]);
|
|
const [discordIds, setDiscordIds] = useState<DiscordId[]>([]);
|
|
const [systems, setSystems] = useState<System[]>([]);
|
|
const [loading, setLoading] = useState<boolean>(true); // Set to true initially
|
|
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> => {
|
|
// Only attempt to fetch if a token is present
|
|
if (!token) {
|
|
setLoading(false); // Stop loading if no token
|
|
setError('Authentication token is missing. Please log in.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError('');
|
|
let accumulatedError = '';
|
|
|
|
try {
|
|
const [nodesRes, discordIdsRes, systemsRes] = await Promise.all([
|
|
authAwareFetch('/nodes/', { token }, logoutUser),
|
|
authAwareFetch('/bots/tokens/', { token }, logoutUser),
|
|
authAwareFetch('/systems/', { token }, logoutUser),
|
|
]);
|
|
|
|
if (nodesRes.ok) {
|
|
const botsData: ActiveClient[] = 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) {
|
|
// Catch specific unauthorized errors and let authAwareFetch handle logout
|
|
if (err.message === 'Unauthorized: Session expired or invalid token.') {
|
|
setLoading(false); // already handled by authAwareFetch
|
|
return;
|
|
}
|
|
setError('Failed to fetch data. Check server connection or console.');
|
|
console.error(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
// This effect runs whenever 'token' changes.
|
|
// If token becomes available, it triggers fetchData.
|
|
if (token) {
|
|
fetchData();
|
|
} else {
|
|
// If token is explicitly null (e.g., after logout or initial load without a token)
|
|
setLoading(false);
|
|
// No need to set error "Please log in" here, as AppContent handles the redirect
|
|
// This is a component internal message if it somehow renders without a token when it shouldn't
|
|
setError('Authentication required to load data.');
|
|
setBots([]); // Clear data if token is gone
|
|
setDiscordIds([]);
|
|
setSystems([]);
|
|
}
|
|
}, [token]); // Dependency on token ensures fetchData is called when token becomes available
|
|
|
|
|
|
const handleAddId = async (): Promise<void> => {
|
|
setError('');
|
|
if (!token) {
|
|
setError('Authentication token is missing. Please log in.');
|
|
return;
|
|
}
|
|
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 ? `/bots/token/${editingId._id}` : `/bots/token`;
|
|
|
|
const response = await authAwareFetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
token,
|
|
}, logoutUser);
|
|
|
|
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) {
|
|
if (err.message === 'Unauthorized: Session expired or invalid token.') {
|
|
setLoading(false); // already handled by authAwareFetch
|
|
return;
|
|
}
|
|
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('');
|
|
if (!token) {
|
|
setError('Authentication token is missing. Please log in.');
|
|
return;
|
|
}
|
|
try {
|
|
const response = await authAwareFetch(`/bots/token/${id}`, { method: 'DELETE', token }, logoutUser);
|
|
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) {
|
|
if (err.message === 'Unauthorized: Session expired or invalid token.') {
|
|
setLoading(false); // already handled by authAwareFetch
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
if (!token) {
|
|
setError('Authentication token is missing. Please log in.');
|
|
return;
|
|
}
|
|
try {
|
|
const endpoint = assignDismissAction === 'assign' ? 'assign' : 'dismiss';
|
|
const response = await authAwareFetch(`/systems/${selectedSystemId}/${endpoint}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ client_id: selectedBotClientId }),
|
|
token,
|
|
}, logoutUser);
|
|
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) {
|
|
if (err.message === 'Unauthorized: Session expired or invalid token.') {
|
|
setLoading(false); // already handled by authAwareFetch
|
|
return;
|
|
}
|
|
setError(`Network error during ${assignDismissAction} bot.`);
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
// Only show loading if actively fetching or waiting for token
|
|
if (loading && token) return <p>Loading bots and Discord IDs...</p>;
|
|
// Show error if there's an error
|
|
if (error) return <p className="text-red-500 whitespace-pre-line">{error}</p>;
|
|
// If not loading and no token, it implies no action can be taken or a login is needed
|
|
if (!loading && !token) return <p className="text-orange-500">Please log in to view and manage bots.</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>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{bots.length > 0 ? (
|
|
bots.map((active_bot) => {
|
|
return (
|
|
<TableRow key={active_bot.client_id}>
|
|
<TableCell className="font-medium">
|
|
<Link href={`/nodes/${active_bot.client_id}`} className="text-blue-600 hover:underline">
|
|
{active_bot.nickname} ({active_bot.client_id})
|
|
</Link>
|
|
</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" disabled={!token}>Add New Discord ID</Button>
|
|
<Button onClick={() => setIsAssignDismissDialogOpen(true)} className="mb-4 ml-2" disabled={!token}>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);
|
|
}}
|
|
disabled={!token}>
|
|
Edit
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={() => handleDeleteId(dId._id)} disabled={!token}>
|
|
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} disabled={!token}>
|
|
{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((active_bot) => (
|
|
<SelectItem key={active_bot.client_id} value={active_bot.client_id}>{active_bot.client_id}</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 || !token}>Perform Action</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default BotsManagement; |