Add panel feature updates across API, daemon, and web
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user