Files
drb-frontend/src/components/BotsManagement.tsx
Logan Cusano 8373fdd685
All checks were successful
Lint Pull Request / lint (push) Successful in 22s
release-image / release-image (push) Successful in 10m26s
Implement nickname for table
2025-06-29 03:46:43 -04:00

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;