Files
source-gamepanel/apps/web/src/pages/server/databases.tsx
T

321 lines
12 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useParams } from 'react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, ExternalLink, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { ApiError, api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface ManagedDatabase {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
createdAt: string;
updatedAt: string;
}
function extractApiMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError && error.data && typeof error.data === 'object') {
const maybeMessage = (error.data as { message?: unknown }).message;
if (typeof maybeMessage === 'string' && maybeMessage.trim()) {
return maybeMessage;
}
}
return fallback;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-1">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{label}</p>
<div className="rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs">
{value}
</div>
</div>
);
}
export function DatabasesPage() {
const { orgId, serverId } = useParams();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [createName, setCreateName] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [editingDatabase, setEditingDatabase] = useState<ManagedDatabase | null>(null);
const [editName, setEditName] = useState('');
const [editPassword, setEditPassword] = useState('');
const { data, isLoading } = useQuery({
queryKey: ['server-databases', orgId, serverId],
queryFn: () =>
api.get<{ data: ManagedDatabase[] }>(
`/organizations/${orgId}/servers/${serverId}/databases`,
),
});
useEffect(() => {
if (!editingDatabase) return;
setEditName(editingDatabase.name);
setEditPassword('');
}, [editingDatabase]);
const databases = data?.data ?? [];
const resetCreateForm = () => {
setCreateName('');
setCreatePassword('');
};
const createMutation = useMutation({
mutationFn: (body: { name: string; password?: string }) =>
api.post<ManagedDatabase>(`/organizations/${orgId}/servers/${serverId}/databases`, body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
setCreateOpen(false);
resetCreateForm();
toast.success('Database created');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to create database'));
},
});
const updateMutation = useMutation({
mutationFn: (body: { name?: string; password?: string }) =>
api.patch<ManagedDatabase>(
`/organizations/${orgId}/servers/${serverId}/databases/${editingDatabase!.id}`,
body,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
setEditingDatabase(null);
setEditPassword('');
toast.success('Database updated');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to update database'));
},
});
const deleteMutation = useMutation({
mutationFn: (databaseId: string) =>
api.delete(`/organizations/${orgId}/servers/${serverId}/databases/${databaseId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['server-databases', orgId, serverId] });
toast.success('Database deleted');
},
onError: (error) => {
toast.error(extractApiMessage(error, 'Failed to delete database'));
},
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Databases</h2>
<p className="text-sm text-muted-foreground">
Unlimited MySQL databases for this server, with password rotation and phpMyAdmin links.
</p>
</div>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetCreateForm();
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4" /> Create Database
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create MySQL Database</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
createMutation.mutate({
name: createName,
password: createPassword.trim() || undefined,
});
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
placeholder="LuckPerms"
required
/>
</div>
<div className="space-y-2">
<Label>Password (Optional)</Label>
<Input
value={createPassword}
onChange={(event) => setCreatePassword(event.target.value)}
minLength={8}
placeholder="Leave empty to auto-generate"
/>
<p className="text-xs text-muted-foreground">
If left empty, the panel generates a strong password automatically.
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<Dialog
open={Boolean(editingDatabase)}
onOpenChange={(open) => {
if (!open) {
setEditingDatabase(null);
setEditPassword('');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Database</DialogTitle>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
updateMutation.mutate({
name: editName !== editingDatabase?.name ? editName : undefined,
password: editPassword.trim() || undefined,
});
}}
>
<div className="space-y-2">
<Label>Label</Label>
<Input
value={editName}
onChange={(event) => setEditName(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>New Password (Optional)</Label>
<Input
value={editPassword}
onChange={(event) => setEditPassword(event.target.value)}
minLength={8}
placeholder="Leave empty to keep the current password"
/>
<p className="text-xs text-muted-foreground">
Entering a value rotates the MySQL user password immediately.
</p>
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : databases.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
No databases yet. Create one for plugins, web panels, or server-side data.
</CardContent>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{databases.map((database) => (
<Card key={database.id}>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-primary" />
<CardTitle className="text-base">{database.name}</CardTitle>
</div>
<p className="text-xs text-muted-foreground">
Created {new Date(database.createdAt).toLocaleString()}
</p>
</div>
<div className="flex gap-2">
{database.phpMyAdminUrl ? (
<Button asChild size="sm" variant="outline">
<a href={database.phpMyAdminUrl} rel="noreferrer" target="_blank">
<ExternalLink className="h-4 w-4" /> phpMyAdmin
</a>
</Button>
) : null}
<Button
size="sm"
variant="outline"
onClick={() => setEditingDatabase(database)}
>
<RefreshCw className="h-4 w-4" /> Edit
</Button>
<Button
size="sm"
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => {
const confirmed = window.confirm(
`Delete "${database.name}" and permanently drop ${database.databaseName}?`,
);
if (!confirmed) return;
deleteMutation.mutate(database.id);
}}
>
<Trash2 className="h-4 w-4" /> Delete
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<InfoRow label="Host" value={database.host} />
<InfoRow label="Port" value={String(database.port)} />
<InfoRow label="Database" value={database.databaseName} />
<InfoRow label="Username" value={database.username} />
</div>
<InfoRow label="Password" value={database.password} />
<InfoRow
label="Connection URI"
value={`mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}`}
/>
{!database.phpMyAdminUrl ? (
<p className="text-xs text-muted-foreground">
phpMyAdmin link is not configured. Set `managed_mysql.phpmyadmin_url` in the daemon config for this node.
</p>
) : null}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}