321 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|