Clone any database to a local Docker PostgreSQL container with schema and/or data transfer via pg_dump. Supports three modes: schema only, full clone, and sample data. Includes container lifecycle management (start/stop/remove) in the Admin panel, progress tracking with collapsible process log, and automatic connection creation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
useDatabaseInfo,
|
|
useRoles,
|
|
useDropDatabase,
|
|
useDropRole,
|
|
} from "@/hooks/use-management";
|
|
import { useAppStore } from "@/stores/app-store";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { CreateDatabaseDialog } from "./CreateDatabaseDialog";
|
|
import { CreateRoleDialog } from "./CreateRoleDialog";
|
|
import { AlterRoleDialog } from "./AlterRoleDialog";
|
|
import { toast } from "sonner";
|
|
import {
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
HardDrive,
|
|
Users,
|
|
Activity,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { DockerContainersList } from "@/components/docker/DockerContainersList";
|
|
import type { Tab, RoleInfo } from "@/types";
|
|
|
|
export function AdminPanel() {
|
|
const { activeConnectionId, currentDatabase, readOnlyMap, addTab } = useAppStore();
|
|
|
|
if (!activeConnectionId) {
|
|
return (
|
|
<div className="p-4 text-sm text-muted-foreground">
|
|
Connect to a database to manage it.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isReadOnly = readOnlyMap[activeConnectionId] ?? true;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-y-auto">
|
|
<DatabasesSection
|
|
connectionId={activeConnectionId}
|
|
currentDatabase={currentDatabase}
|
|
isReadOnly={isReadOnly}
|
|
/>
|
|
<RolesSection
|
|
connectionId={activeConnectionId}
|
|
isReadOnly={isReadOnly}
|
|
onOpenRoleManager={() => {
|
|
const tab: Tab = {
|
|
id: crypto.randomUUID(),
|
|
type: "roles",
|
|
title: "Roles & Users",
|
|
connectionId: activeConnectionId,
|
|
database: currentDatabase ?? undefined,
|
|
};
|
|
addTab(tab);
|
|
}}
|
|
/>
|
|
<SessionsSection
|
|
connectionId={activeConnectionId}
|
|
onOpenSessions={() => {
|
|
const tab: Tab = {
|
|
id: crypto.randomUUID(),
|
|
type: "sessions",
|
|
title: "Active Sessions",
|
|
connectionId: activeConnectionId,
|
|
database: currentDatabase ?? undefined,
|
|
};
|
|
addTab(tab);
|
|
}}
|
|
/>
|
|
<DockerContainersList />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DatabasesSection({
|
|
connectionId,
|
|
currentDatabase,
|
|
isReadOnly,
|
|
}: {
|
|
connectionId: string;
|
|
currentDatabase: string | null;
|
|
isReadOnly: boolean;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(true);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const { data: databases, isLoading } = useDatabaseInfo(connectionId);
|
|
const dropMutation = useDropDatabase();
|
|
|
|
const handleDrop = (name: string) => {
|
|
if (name === currentDatabase) {
|
|
toast.error("Cannot drop the active database");
|
|
return;
|
|
}
|
|
if (!confirm(`Drop database "${name}"? This will terminate all connections and cannot be undone.`)) {
|
|
return;
|
|
}
|
|
dropMutation.mutate(
|
|
{ connectionId, name },
|
|
{
|
|
onSuccess: () => toast.success(`Database "${name}" dropped`),
|
|
onError: (err) => toast.error("Failed to drop database", { description: String(err) }),
|
|
}
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="border-b">
|
|
<div
|
|
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
|
)}
|
|
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-semibold flex-1">Databases</span>
|
|
{!isReadOnly && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setCreateOpen(true);
|
|
}}
|
|
title="Create Database"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="pb-1">
|
|
{isLoading && (
|
|
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
|
|
</div>
|
|
)}
|
|
{databases?.map((db) => (
|
|
<div
|
|
key={db.name}
|
|
className={`group flex items-center gap-2 px-6 py-1 text-xs hover:bg-accent/50 ${
|
|
db.name === currentDatabase ? "text-primary" : ""
|
|
}`}
|
|
>
|
|
<span className="truncate flex-1 font-medium">{db.name}</span>
|
|
<span className="text-[10px] text-muted-foreground shrink-0">{db.size}</span>
|
|
{db.name === currentDatabase && (
|
|
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
|
|
active
|
|
</Badge>
|
|
)}
|
|
{!isReadOnly && db.name !== currentDatabase && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive shrink-0"
|
|
onClick={() => handleDrop(db.name)}
|
|
title="Drop"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<CreateDatabaseDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
connectionId={connectionId}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RolesSection({
|
|
connectionId,
|
|
isReadOnly,
|
|
onOpenRoleManager,
|
|
}: {
|
|
connectionId: string;
|
|
isReadOnly: boolean;
|
|
onOpenRoleManager: () => void;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(true);
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [alterRole, setAlterRole] = useState<RoleInfo | null>(null);
|
|
const { data: roles, isLoading } = useRoles(connectionId);
|
|
const dropMutation = useDropRole();
|
|
|
|
const handleDrop = (name: string) => {
|
|
if (!confirm(`Drop role "${name}"? This cannot be undone.`)) return;
|
|
dropMutation.mutate(
|
|
{ connectionId, name },
|
|
{
|
|
onSuccess: () => toast.success(`Role "${name}" dropped`),
|
|
onError: (err) => toast.error("Failed to drop role", { description: String(err) }),
|
|
}
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="border-b">
|
|
<div
|
|
className="flex items-center gap-1 px-3 py-2 cursor-pointer select-none hover:bg-accent/50"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{expanded ? (
|
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
|
)}
|
|
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-semibold flex-1">Roles</span>
|
|
<div className="flex gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1 text-[10px]"
|
|
onClick={onOpenRoleManager}
|
|
title="Open Role Manager"
|
|
>
|
|
Manager
|
|
</Button>
|
|
{!isReadOnly && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
onClick={() => setCreateOpen(true)}
|
|
title="Create Role"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="pb-1">
|
|
{isLoading && (
|
|
<div className="px-3 py-2 text-xs text-muted-foreground flex items-center gap-1">
|
|
<Loader2 className="h-3 w-3 animate-spin" /> Loading...
|
|
</div>
|
|
)}
|
|
{roles?.map((role) => (
|
|
<div
|
|
key={role.name}
|
|
className="group flex items-center gap-1.5 px-6 py-1 text-xs hover:bg-accent/50"
|
|
>
|
|
<span className="truncate flex-1 font-medium">{role.name}</span>
|
|
<div className="flex gap-0.5 shrink-0">
|
|
{role.can_login && (
|
|
<Badge variant="secondary" className="text-[9px] px-1 py-0">
|
|
LOGIN
|
|
</Badge>
|
|
)}
|
|
{role.is_superuser && (
|
|
<Badge variant="default" className="text-[9px] px-1 py-0 bg-amber-600 hover:bg-amber-600">
|
|
SUPER
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
|
|
{!isReadOnly && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0"
|
|
onClick={() => setAlterRole(role)}
|
|
title="Edit"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 text-destructive hover:text-destructive"
|
|
onClick={() => handleDrop(role.name)}
|
|
title="Drop"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<CreateRoleDialog
|
|
open={createOpen}
|
|
onOpenChange={setCreateOpen}
|
|
connectionId={connectionId}
|
|
/>
|
|
<AlterRoleDialog
|
|
open={!!alterRole}
|
|
onOpenChange={(open) => !open && setAlterRole(null)}
|
|
connectionId={connectionId}
|
|
role={alterRole}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SessionsSection({
|
|
connectionId,
|
|
onOpenSessions,
|
|
}: {
|
|
connectionId: string;
|
|
onOpenSessions: () => void;
|
|
}) {
|
|
return (
|
|
<div className="border-b">
|
|
<div className="flex items-center gap-1 px-3 py-2">
|
|
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-semibold flex-1">Sessions</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 px-1 text-[10px]"
|
|
onClick={onOpenSessions}
|
|
title="View active sessions"
|
|
>
|
|
View Sessions
|
|
</Button>
|
|
</div>
|
|
<div className="px-6 pb-2 text-xs text-muted-foreground">
|
|
Monitor active database connections and running queries.
|
|
{/* The connectionId is used by parent to open the sessions tab */}
|
|
<span className="hidden">{connectionId}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|