Compare commits

...

8 Commits

Author SHA1 Message Date
Logan Cusano
e64984b7b8 Include the system ID when starting the bot
Some checks failed
Lint Pull Request / lint (push) Successful in 1m0s
release-image / release-image (push) Has been cancelled
2025-07-06 19:35:00 -04:00
Logan Cusano
20187a06f0 Update invite link
All checks were successful
Lint Pull Request / lint (push) Successful in 30s
release-image / release-image (push) Successful in 10m39s
2025-06-29 22:49:41 -04:00
Logan Cusano
464f37adda Implement active client on status
Some checks failed
Lint Pull Request / lint (push) Successful in 20s
release-image / release-image (push) Has been cancelled
2025-06-29 22:42:20 -04:00
Logan Cusano
a1fda6fe87 Add invite button for the bot tokens
All checks were successful
Lint Pull Request / lint (push) Successful in 23s
release-image / release-image (push) Successful in 10m15s
2025-06-29 22:13:47 -04:00
Logan Cusano
aaa362ec0e Update metadata
All checks were successful
Lint Pull Request / lint (push) Successful in 22s
release-image / release-image (push) Successful in 10m16s
2025-06-29 22:00:34 -04:00
Logan Cusano
3b62590df3 Implement Fixed Node Management
All checks were successful
Lint Pull Request / lint (push) Successful in 23s
release-image / release-image (push) Successful in 11m52s
2025-06-29 19:38:45 -04:00
Logan Cusano
8373fdd685 Implement nickname for table
All checks were successful
Lint Pull Request / lint (push) Successful in 22s
release-image / release-image (push) Successful in 10m26s
2025-06-29 03:46:43 -04:00
6a74cd55a0 Merge pull request 'Refactored the header' (#2) from refactoring-header into main
All checks were successful
Lint Pull Request / lint (push) Successful in 20s
release-image / release-image (push) Successful in 10m23s
Reviewed-on: #2
2025-05-28 23:20:07 -04:00
4 changed files with 204 additions and 158 deletions

View File

@@ -19,8 +19,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Discord Radio Bot Manager",
description: "Manage the fleet of DRB nodes.",
};
export default function RootLayout({

View File

@@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '
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 } from '@/types';
import { DiscordId, System, ErrorResponse, ActiveClient } from '@/types';
import { authAwareFetch } from '@/utils/AuthAwareFetch';
interface BotsManagementProps {
@@ -20,7 +20,7 @@ interface BotsManagementProps {
}
const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) => {
const [bots, setBots] = useState<string[]>([]);
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
@@ -61,7 +61,7 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
]);
if (nodesRes.ok) {
const botsData: any = await nodesRes.json();
const botsData: ActiveClient[] = await nodesRes.json();
setBots(botsData);
} else {
let errorMsg = `Failed to fetch bots (${nodesRes.status})`;
@@ -155,7 +155,7 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
if (response.ok) {
fetchData();
setIsAddIdDialogOpen(false);
setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '' });
setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '', fixed_node: '' });
setEditingId(null);
} else {
let errorMsg = `Failed to ${editingId ? 'update' : 'add'} Discord ID (${response.status})`;
@@ -166,10 +166,10 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
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);
}
@@ -195,10 +195,10 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
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);
}
@@ -207,8 +207,8 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
const handleAssignDismiss = async (): Promise<void> => {
setError('');
if (!selectedBotClientId || !selectedSystemId) {
setError("Bot Client ID and System must be selected.");
return;
setError("Bot Client ID and System must be selected.");
return;
}
if (!token) {
setError('Authentication token is missing. Please log in.');
@@ -236,10 +236,10 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
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);
}
@@ -270,14 +270,14 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
</TableHeader>
<TableBody>
{bots.length > 0 ? (
bots.map((botClientId) => {
bots.map((active_bot) => {
return (
<TableRow key={botClientId}>
<TableRow key={active_bot.client_id}>
<TableCell className="font-medium">
<Link href={`/nodes/${botClientId}`} className="text-blue-600 hover:underline">
{botClientId}
<Link href={`/nodes/${active_bot.client_id}`} className="text-blue-600 hover:underline">
{active_bot.nickname} ({active_bot.client_id})
</Link>
</TableCell>
</TableCell>
</TableRow>
);
})
@@ -292,7 +292,7 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
<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={() => { setEditingId(null); setNewIdData({ discord_id: '', name: '', token: '', active: false, guild_ids: '', fixed_node: '' }); 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>
@@ -303,6 +303,7 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
<TableHead>Token (Partial)</TableHead>
<TableHead>Active</TableHead>
<TableHead>Guilds</TableHead>
<TableHead>Fixed Node ID</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
@@ -315,7 +316,14 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
<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>{dId?.fixed_node ? dId?.fixed_node : "None"}</TableCell>
<TableCell>
<a target="_blank" rel="noopener noreferrer" href={`https://discord.com/oauth2/authorize?client_id=${dId?.discord_id}&permissions=3146752&response_type=code&integration_type=0&scope=bot+rpc.voice.read+rpc.voice.write+rpc+voice+guilds.channels.read+guilds+guilds.join+gdm.join+guilds.members.read+connections`}>
<Button variant="outline" size="sm" className="mr-2"
disabled={!dId?.discord_id}>
Invite
</Button>
</a>
<Button variant="outline" size="sm" className="mr-2"
onClick={() => {
setEditingId(dId);
@@ -325,6 +333,7 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
token: dId.token,
active: dId.active,
guild_ids: dId.guild_ids?.join(', '),
fixed_node: dId.fixed_node,
});
setIsAddIdDialogOpen(true);
}}
@@ -346,120 +355,129 @@ const BotsManagement: React.FC<BotsManagementProps> = ({ token, logoutUser }) =>
</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"
/>
<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="guild_ids" className="text-right">Fixed Node</Label>
<Input
id="fixed_node"
value={newIdData?.fixed_node}
onChange={(e) => setNewIdData({ ...newIdData, fixed_node: 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>
<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>
<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((botId) => (
<SelectItem key={botId} value={botId}>{botId}</SelectItem>
))}
</SelectContent>
</Select>
<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>
<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>
<DialogFooter>
<Button onClick={handleAssignDismiss} disabled={!selectedBotClientId || !selectedSystemId || !token}>Perform Action</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);

View File

@@ -3,7 +3,7 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { ErrorResponse, NodeStatusResponse, System } from '@/types';
import { ActiveClient, 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';
@@ -37,6 +37,9 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
const [isLeaveDiscordDialogOpen, setIsLeaveDiscordDialogOpen] = useState<boolean>(false);
const [leaveGuildId, setLeaveGuildId] = useState<string>('');
// Active Client States
const [activeClient, setActiveClient] = useState<ActiveClient | null>(null);
const fetchClientStatus = async () => {
if (!token) {
@@ -51,6 +54,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
if (response.ok) {
const data: NodeStatusResponse = await response.json();
console.log(data)
setActiveClient(data.active_client);
setCurrentClientDiscordStatus(data.status.discord_status);
setCurrentClientOp25Status(data.status.op25_status)
} else {
@@ -58,10 +62,10 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
setCurrentClientOp25Status('Failed to fetch status');
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setCurrentClientDiscordStatus('Error fetching status');
setCurrentClientOp25Status('Error fetching status');
} finally {
@@ -92,14 +96,14 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
setError(errorMsg);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError('Failed to fetch systems. Check server connection or console.');
console.error(err);
} finally {
setLoading(false);
setLoading(false);
}
};
@@ -109,8 +113,8 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
fetchClientStatus();
fetchSystems();
} else {
setLoading(false);
setError('Please log in to manage this client.');
setLoading(false);
setError('Please log in to manage this client.');
}
}, [clientId, token, logoutUser]); // Added logoutUser to dependencies to avoid lint warnings
@@ -146,14 +150,15 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
"system_id": selectedSystem
});
} else if (action === "join") {
if (!payloadData?.server_id || !payloadData?.channel_id) {
if (!payloadData?.server_id || !payloadData?.channel_id || !selectedSystem) {
setError("Please provide both Discord Server ID and Channel ID.");
setLoading(false);
return;
}
httpOptions.body = JSON.stringify({
"guild_id": payloadData.server_id,
"channel_id": payloadData.channel_id
"channel_id": payloadData.channel_id,
"system_id": selectedSystem
});
} else if (action === "leave") {
if (!payloadData?.guild_id) {
@@ -181,10 +186,10 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
setError(`Failed to ${action} client "${clientId}": ${errorData.message || (typeof errorData.detail === 'string' ? errorData.detail : JSON.stringify(errorData.detail)) || response.statusText}`);
}
} catch (err: any) {
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
if (err.message === 'Unauthorized: Session expired or invalid token.') {
setLoading(false); // already handled by authAwareFetch
return;
}
setError(`Network error during ${action} client: ${err.message}`);
console.error(err);
} finally {
@@ -215,7 +220,7 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
return (
<Card className="w-full max-w-md mx-auto mt-8">
<CardHeader>
<CardTitle>Manage Client: {clientId}</CardTitle>
<CardTitle>Manage Client: {activeClient?.nickname} - {clientId}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && <p className="text-red-500 text-sm">{error}</p>}
@@ -331,9 +336,24 @@ const IndividualClientPage: React.FC<IndividualClientPageProps> = ({ clientId, t
placeholder="Enter Discord Channel ID"
/>
</div>
<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={handleConfirmJoin} disabled={!discordServerId || !discordChannelId || loading || !token}>
<Button onClick={handleConfirmJoin} disabled={!discordServerId || !discordChannelId || !selectedSystem || loading || !token}>
{loading ? 'Joining...' : 'Confirm Join'}
</Button>
<Button variant="outline" onClick={() => setIsJoinDiscordDialogOpen(false)}>

View File

@@ -16,6 +16,7 @@ export enum DemodTypes {
token: string;
active: boolean;
guild_ids: string[];
fixed_node?: string;
}
export interface System {
@@ -48,10 +49,17 @@ export enum DemodTypes {
}
export interface NodeStatusResponse {
active_client: ActiveClient;
status: {
op25_status: string;
discord_status: string;
}
};
}
export interface ActiveClient {
active_token: DiscordId;
nickname: string;
client_id: string;
}
// Auth Context Types