feat: migration-tool (#269)

* feat: working on migration backup tool

* feat: add migration sequences

* fix: responsive for migration tool

* fix: truncate database name in migration

* fix: filter on databases success for migration tool

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Charles GTE
2026-04-29 22:39:37 +02:00
committed by GitHub
co-authored by charles-gauthereau
parent ab55ab349a
commit ff28a80d7c
20 changed files with 4133 additions and 14 deletions
@@ -7,7 +7,8 @@ import {
Layers,
ChartArea,
ShieldHalf,
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen, Hammer,
ChevronsLeftRightEllipsis
} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient} from "@/lib/auth/auth-client";
@@ -32,7 +33,17 @@ export const SidebarMenuCustomMain = () => {
const groupContent: SidebarGroupItem["group_content"] = [
{title: "Projects", url: "/projects", icon: Layers, details: true, type: "item"},
{title: "Statistics", url: "/statistics", icon: ChartArea, type: "item"},
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"}
{title: "Settings", url: "/settings", icon: Settings, details: true, type: "item"},
{
title: "Tools",
url: "/tools",
icon: Hammer,
details: true,
type: "collapse",
submenu: [
{title: "Migration", url: "/migration", icon: ChevronsLeftRightEllipsis, type: "item"},
]
}
];
@@ -69,8 +80,8 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item" },
{ title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" },
{title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item"},
{title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item"},
],
},
{
@@ -80,7 +91,7 @@ export const SidebarMenuCustomMain = () => {
details: true,
type: "collapse",
submenu: [
{ title: "Channels", url: "/storages/channels", icon: Blocks, type: "item" },
{title: "Channels", url: "/storages/channels", icon: Blocks, type: "item"},
],
},
{
@@ -91,7 +102,13 @@ export const SidebarMenuCustomMain = () => {
type: "collapse",
submenu: [
{title: "Users", url: "/admin/users", icon: Users, type: "item"},
{title: "Organizations", url: "/admin/organizations", icon: Building, type: "item", details: true},
{
title: "Organizations",
url: "/admin/organizations",
icon: Building,
type: "item",
details: true
},
],
},
{
@@ -105,7 +122,7 @@ export const SidebarMenuCustomMain = () => {
}
items.push(
{
{
label: "Resources",
type: "list",
group_content: [
@@ -0,0 +1,272 @@
"use client"
import {
ArrowRight,
CheckCircle2,
Circle,
Loader2,
Play,
Server,
FolderOpen,
HardDrive,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
interface MigrationFlowProps {
sourceProject: ProjectWith | null
sourceDatabase: DatabaseWith | null
selectedBackups: Backup[]
targetProject: ProjectWith | null
targetDatabase: DatabaseWith | null
status: MigrationStatus
onStartMigration: () => void
canStart: boolean
}
export type MigrationStatus = "idle" | "migrating" | "completed" | "error"
export function MigrationFlow({
sourceProject,
sourceDatabase,
selectedBackups,
targetProject,
targetDatabase,
status,
onStartMigration,
canStart,
}: MigrationFlowProps) {
const steps = [
{
id: "source-project",
label: "Source Project",
description: "Choose source project",
completed: sourceProject !== null,
active: status === "idle" && !sourceProject,
},
{
id: "source-database",
label: "Source Database",
description: "Select database",
completed: sourceDatabase !== null,
active: status === "idle" && sourceProject !== null && !sourceDatabase,
},
{
id: "backups",
label: "Select Backups",
description: "Choose backups to migrate",
completed: selectedBackups.length > 0,
active: status === "idle" && sourceDatabase !== null && selectedBackups.length === 0,
},
{
id: "target-project",
label: "Target Project",
description: "Choose destination project",
completed: targetProject !== null,
active: status === "idle" && selectedBackups.length > 0 && !targetProject,
},
{
id: "target-database",
label: "Target Database",
description: "Select destination database",
completed: targetDatabase !== null,
active: status === "idle" && targetProject !== null && !targetDatabase,
},
{
id: "migrate",
label: "Migrate Data",
description: "Transfer to target",
completed: status === "completed",
active: status === "migrating",
},
]
return (
<div className="flex h-full flex-col rounded-xl border border-border bg-card">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
<ArrowRight className="h-5 w-5 text-primary"/>
</div>
<div>
<h2 className="font-semibold text-foreground">Migration Flow</h2>
<p className="text-sm text-muted-foreground">
{status === "idle" && "Configure your migration"}
{status === "migrating" && "Migration in progress..."}
{status === "completed" && "Migration completed!"}
</p>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">
<div className="mb-6 flex flex-col gap-3">
{steps.map((step, index) => (
<div key={step.id} className="flex items-start gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full border-2 transition-all",
step.completed
? "border-emerald-500 bg-emerald-500 text-white"
: step.active
? "border-primary bg-primary/10 text-primary"
: "border-muted-foreground/30 bg-muted text-muted-foreground"
)}
>
{step.completed ? (
<CheckCircle2 className="h-4 w-4"/>
) : step.active && status === "migrating" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin"/>
) : (
<Circle className="h-3.5 w-3.5"/>
)}
</div>
{index < steps.length - 1 && (
<div
className={cn(
"mt-1 h-6 w-0.5",
step.completed ? "bg-emerald-500" : "bg-border"
)}
/>
)}
</div>
<div className="pt-0.5">
<p
className={cn(
"text-sm font-medium",
step.completed || step.active
? "text-foreground"
: "text-muted-foreground"
)}
>
{step.label}
</p>
<p className="text-xs text-muted-foreground">{step.description}</p>
</div>
</div>
))}
</div>
{(sourceProject || targetProject) && (
<div className="mb-6 rounded-lg border border-border bg-muted/30 p-4">
<h3 className="mb-3 text-sm font-medium text-foreground">
Migration Summary
</h3>
<div className="flex flex-col gap-2.5 text-sm">
{/* Source */}
{sourceProject && (
<div className="flex items-start gap-2">
<FolderOpen className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0">
<span className="text-muted-foreground">From: </span>
<span className="font-medium text-foreground">
{sourceProject.name}
</span>
{sourceDatabase && (
<span className="text-muted-foreground">
{" "}/ {sourceDatabase.name}
</span>
)}
</div>
</div>
)}
{selectedBackups.length > 0 && (
<div className="flex items-center gap-2">
<HardDrive className="h-4 w-4 text-muted-foreground"/>
<span className="text-muted-foreground">Backups:</span>
<span className="font-medium text-foreground">
{selectedBackups.length}
</span>
</div>
)}
{targetProject && (
<div className="flex items-start gap-2">
<Server className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
<div className="min-w-0">
<span className="text-muted-foreground">To: </span>
<span className="font-medium text-foreground">
{targetProject.name}
</span>
{targetDatabase && (
<span className="text-muted-foreground">
{" "}/ {targetDatabase.name}
</span>
)}
</div>
</div>
)}
</div>
</div>
)}
{status === "completed" && (
<div className="mb-6 rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-emerald-600"/>
<span className="font-medium text-emerald-600">
Migration completed successfully!
</span>
</div>
</div>
)}
</div>
<div className="border-t border-border p-5">
<ButtonWithLoading
className={cn(
"flex w-full items-center justify-center gap-2 rounded-lg px-4 py-3 font-medium transition-all",
canStart
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "cursor-not-allowed bg-muted text-muted-foreground"
)}
onClick={onStartMigration}
disabled={!canStart}
isPending={status === "migrating"}
size="lg"
type="button"
>
{status === "migrating" ? (
<>
Migrating...
</>
) : status === "completed" ? (
<>
<CheckCircle2 className="h-4 w-4"/>
Completed
</>
) : (
<>
<Play className="h-4 w-4"/>
Start Migration
</>
)}
</ButtonWithLoading>
{!canStart && status === "idle" && (
<p className="mt-2 text-center text-xs text-muted-foreground">
{!sourceProject
? "Select a source project"
: !sourceDatabase
? "Select a source database"
: selectedBackups.length === 0
? "Select at least one backup"
: !targetProject
? "Select a target project"
: !targetDatabase
? "Select a target database"
: "Ready to start"}
</p>
)}
</div>
</div>
)
}
@@ -0,0 +1,172 @@
"use client"
import {useState, useMemo} from "react"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project"
import {SourcePanel} from "@/components/wrappers/dashboard/organization/migration/source-panel"
import {Backup, DatabaseWith} from "@/db/schema/07_database"
import {MigrationFlow, MigrationStatus} from "@/components/wrappers/dashboard/organization/migration/migration-flow"
import {TargetPanel} from "@/components/wrappers/dashboard/organization/migration/target-panel"
import {useMutation} from "@tanstack/react-query";
import {toast} from "sonner";
import {migrationAction} from "@/components/wrappers/dashboard/organization/migration/migration.action";
import {useRouter} from "next/navigation";
interface MigrationToolProps {
projects: ProjectWith[]
}
export const MigrationTool = ({projects}: MigrationToolProps) => {
const [sourceProject, setSourceProject] = useState<ProjectWith | null>(null)
const [sourceDatabase, setSourceDatabase] = useState<DatabaseWith | null>(null)
const [selectedBackups, setSelectedBackups] = useState<Backup[]>([])
const router = useRouter()
const [targetProject, setTargetProject] = useState<ProjectWith | null>(null)
const [targetDatabase, setTargetDatabase] = useState<DatabaseWith | null>(null)
const [migrationStatus, setMigrationStatus] = useState<MigrationStatus>("idle")
const [_migrationProgress, setMigrationProgress] = useState(0)
const sourceDbKind = sourceDatabase?.dbms
const isTargetEnabled = useMemo(() => {
return migrationStatus === "idle" &&
sourceDatabase !== null &&
selectedBackups.length > 0
}, [sourceDatabase, migrationStatus, selectedBackups])
const filteredProjects = useMemo(() => {
if (!sourceDbKind) return []
return projects.map((project) => ({
...project,
databases: project.databases.filter((db) => {
const sameKind = db.dbms === sourceDbKind
const notSource = db.id !== sourceDatabase?.id
return sameKind && notSource
}),
}))
}, [projects, sourceDbKind, sourceDatabase])
const handleSelectSourceProject = (project: ProjectWith | null) => {
setSourceProject(project)
setSourceDatabase(null)
setSelectedBackups([])
setTargetProject(null)
setTargetDatabase(null)
}
const handleSelectSourceDatabase = (database: DatabaseWith | null) => {
setSourceDatabase(database)
setSelectedBackups([])
if (targetDatabase && database?.dbms !== targetDatabase.dbms) {
setTargetProject(null)
setTargetDatabase(null)
}
if (targetDatabase && database?.id === targetDatabase.id) {
setTargetProject(null)
setTargetDatabase(null)
}
}
const handleSelectBackup = (backup: Backup) => {
setSelectedBackups((prev) => {
const isSelected = prev.some((b) => b.id === backup.id)
if (isSelected) {
return prev.filter((b) => b.id !== backup.id)
}
return [...prev, backup]
})
}
const handleSelectTargetProject = (project: ProjectWith | null) => {
setTargetProject(project)
setTargetDatabase(null)
}
const handleSelectTargetDatabase = (database: DatabaseWith | null) => {
setTargetDatabase(database)
}
const mutation = useMutation({
mutationFn: async () => {
if (selectedBackups.length === 0 || !targetDatabase) return
setMigrationStatus("migrating")
const result = await migrationAction({
targetDatabaseId: targetDatabase?.id,
backupIds: selectedBackups.map((backup) => backup.id)
})
const inner = result?.data;
console.log(inner)
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
handleReset()
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
},
});
const handleReset = () => {
setMigrationStatus("idle")
setMigrationProgress(0)
setSelectedBackups([])
setSourceProject(null)
setSourceDatabase(null)
setTargetProject(null)
setTargetDatabase(null)
}
const canStartMigration =
selectedBackups.length > 0 &&
targetDatabase !== null &&
migrationStatus === "idle"
return (
<div className="h-full">
<div className="flex flex-col md:grid md:grid-cols-12 gap-6 h-full">
<div className="col-span-4">
<SourcePanel
projects={projects}
selectedProject={sourceProject}
selectedDatabase={sourceDatabase}
selectedBackups={selectedBackups}
onSelectProject={handleSelectSourceProject}
onSelectDatabase={handleSelectSourceDatabase}
onSelectBackup={handleSelectBackup}
disabled={migrationStatus !== "idle"}
/>
</div>
<div className="col-span-4">
<MigrationFlow
sourceProject={sourceProject}
sourceDatabase={sourceDatabase}
selectedBackups={selectedBackups}
targetProject={targetProject}
targetDatabase={targetDatabase}
status={migrationStatus}
onStartMigration={() => mutation.mutateAsync()}
canStart={canStartMigration}
/>
</div>
<div className="col-span-4 min-h-36">
<TargetPanel
projects={filteredProjects}
selectedProject={targetProject}
selectedDatabase={targetDatabase}
onSelectProject={handleSelectTargetProject}
onSelectDatabase={handleSelectTargetDatabase}
disabled={!isTargetEnabled}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {ServerActionResult} from "@/types/action-type";
import {z} from "zod";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq, inArray} from "drizzle-orm";
import {dispatchStorage} from "@/features/storages/dispatch";
import {v4 as uuidv4} from "uuid";
import {getTodayISODate} from "@/utils/date-formatting";
import {withUpdatedAt} from "@/db/utils";
export const migrationAction = userAction.schema(
z.object({
targetDatabaseId: z.string(),
backupIds: z.array(z.string()),
})
).action(async ({ parsedInput }): Promise<ServerActionResult<{}>> => {
const { targetDatabaseId, backupIds } = parsedInput;
let hasError = false;
try {
const targetDatabase = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!targetDatabase) {
return {
success: false,
actionError: {
message: "Unable to find target database",
status: 404,
},
};
}
const backups = await db.query.backup.findMany({
where: inArray(drizzleDb.schemas.backup.id, backupIds),
with: {
database: true,
storages: true
}
});
if (backups.length !== backupIds.length) {
return {
success: false,
actionError: {
message: "Some backups were not found",
status: 400,
},
};
}
await Promise.all(
backups.map(async (backup) => {
const [migratedBackup] = await db
.insert(drizzleDb.schemas.backup)
.values({
status: "ongoing",
databaseId: targetDatabaseId,
fileSize: backup.fileSize,
migrated: true,
})
.returning();
const storageResults = await Promise.allSettled(
backup.storages.map(async (storage) => {
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: migratedBackup.id,
storageChannelId: storage.storageChannelId,
status: "pending",
})
.returning();
const fileName = `${uuidv4()}.tar.gz`;
const pathTo = `backups/${getTodayISODate()}/${fileName}`;
try {
const result = await dispatchStorage(
{
action: "copy",
data: {
from: storage.path ?? "",
to: pathTo,
},
metadata: {
storageId: storage.storageChannelId,
fileKind: "backups",
},
},
undefined,
storage.storageChannelId
);
if (!result.success) {
hasError = true;
throw new Error("dispatchStorage failed");
}
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
status: "success",
path: pathTo,
size: backup.fileSize,
})
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
return { success: true };
} catch (error) {
hasError = true;
await db
.update(drizzleDb.schemas.backupStorage)
.set(
withUpdatedAt({
status: "failed",
})
)
.where(eq(drizzleDb.schemas.backupStorage.id, backupStorage.id));
return { success: false };
}
})
);
const failed = storageResults.some(
r => r.status === "rejected" || (r.status === "fulfilled" && !r.value.success)
);
if (failed) hasError = true;
await db
.update(drizzleDb.schemas.backup)
.set(
withUpdatedAt({
status: failed ? "failed" : "success",
})
)
.where(eq(drizzleDb.schemas.backup.id, migratedBackup.id));
})
);
if (hasError) {
return {
success: false,
actionError: {
message: "Migration completed with errors",
status: 500,
},
};
}
return {
success: true,
value: {},
actionSuccess: {
message: "Backups successfully migrated.",
messageParams: {},
},
};
} catch (error) {
return {
success: false,
actionError: {
message: "Migration failed unexpectedly",
status: 500,
cause: error instanceof Error ? error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,309 @@
"use client"
import {
Database,
HardDrive,
Calendar,
Check,
ChevronLeft,
FolderOpen,
Server,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {Backup, DatabaseWith} from "@/db/schema/07_database";
import {ScrollArea} from "@/components/ui/scroll-area";
import {formatLocalizedDate} from "@/utils/date-formatting";
import {formatBytes, truncateWords} from "@/utils/text";
type ViewState = "projects" | "databases" | "backups"
interface SourcePanelProps {
projects: ProjectWith[]
selectedProject: ProjectWith | null
selectedDatabase: DatabaseWith | null
selectedBackups: Backup[]
onSelectProject: (project: ProjectWith | null) => void
onSelectDatabase: (database: DatabaseWith | null) => void
onSelectBackup: (backup: Backup) => void
disabled?: boolean
}
export function SourcePanel({
projects,
selectedProject,
selectedDatabase,
selectedBackups,
onSelectProject,
onSelectDatabase,
onSelectBackup,
disabled,
}: SourcePanelProps) {
const currentView: ViewState = selectedDatabase
? "backups"
: selectedProject
? "databases"
: "projects"
const handleBack = () => {
if (currentView === "backups") {
onSelectDatabase(null)
} else if (currentView === "databases") {
onSelectProject(null)
}
}
const getHeaderInfo = () => {
if (currentView === "backups" && selectedDatabase) {
return {
title: selectedDatabase.name,
subtitle: selectedDatabase.backups?.length? `${selectedBackups.length} of ${selectedDatabase.backups.length} backups selected` : "",
icon: <Database className="h-5 w-5 text-primary"/>,
}
}
if (currentView === "databases" && selectedProject) {
return {
title: selectedProject.name,
subtitle: `${selectedProject.databases.length} database${selectedProject.databases.length !== 1 ? "s" : ""} available`,
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
return {
title: "Source",
subtitle: "Select a project to start",
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
const headerInfo = getHeaderInfo()
return (
<div className="rounded-xl border border-border bg-card h-full flex flex-col overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
{currentView !== "projects" ? (
<button
onClick={handleBack}
disabled={disabled}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5 text-secondary-foreground"/>
</button>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
{headerInfo.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold text-foreground">
{headerInfo.title}
</h2>
<p className="text-sm text-muted-foreground">{headerInfo.subtitle}</p>
</div>
</div>
{currentView !== "projects" && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<button
onClick={() => {
onSelectDatabase(null)
onSelectProject(null)
}}
disabled={disabled}
className="hover:text-foreground disabled:opacity-50"
>
Projects
</button>
{selectedProject && (
<>
<span>/</span>
<button
onClick={() => onSelectDatabase(null)}
disabled={disabled}
className={cn(
"truncate hover:text-foreground disabled:opacity-50",
currentView === "databases" && "text-foreground"
)}
>
{selectedProject.name}
</button>
</>
)}
{selectedDatabase && (
<>
<span>/</span>
<span className="truncate text-foreground">
{selectedDatabase.name}
</span>
</>
)}
</div>
)}
</div>
<ScrollArea
// Do not remove the radix scroll
className="w-full flex-1 [&>[data-radix-scroll-area-viewport]]:max-h-[calc(100vh-320px)]"
>
<div className="p-4">
{currentView === "projects" && (
<div className="flex flex-col gap-2">
{projects.length === 0 ? (
<div
className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border p-6 text-center">
<p className="text-sm text-muted-foreground">
No projects available
</p>
</div>
) : (
projects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(project.name, 6)}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{project.databases.length} database
{project.databases.length !== 1 ? "s" : ""}
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))
)}
</div>
)}
{currentView === "databases" && selectedProject && (
<div className="flex flex-col gap-2">
{selectedProject.databases.length === 0 ? (
<div className="py-8 text-center">
<Database className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No databases in this project
</p>
</div>
) : (
selectedProject.databases.map((database) => (
<button
key={database.id}
onClick={() => onSelectDatabase(database)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Database className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(database.name, 6)}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{database.dbms}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{database?.backups?.length} backup
{database?.backups?.length !== 1 ? "s" : ""} available
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))
)}
</div>
)}
{currentView === "backups" && selectedDatabase && (
<div className="flex flex-col gap-2">
{selectedDatabase?.backups?.length === 0 ? (
<div className="py-8 text-center">
<HardDrive className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No backups available for this database
</p>
</div>
) : (
selectedDatabase?.backups?.map((backup) => {
const isSelected = selectedBackups.some((b) => b.id === backup.id)
return (
<button
key={backup.id}
onClick={() => onSelectBackup(backup)}
disabled={disabled}
className={cn(
"group relative w-full rounded-lg border p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div
className={cn(
"absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30 bg-transparent"
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground"/>
)}
</div>
<div className="mb-3 pr-8">
<h3 className="font-medium text-foreground">{backup.id}</h3>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<HardDrive className="h-3.5 w-3.5"/>
<span>{formatBytes(backup.fileSize)}</span>
</div>
<div className="flex items-center gap-1.5 text-muted-foreground">
<Calendar className="h-3.5 w-3.5"/>
<span>{ formatLocalizedDate(backup.createdAt)}</span>
</div>
</div>
</button>
)
})
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
@@ -0,0 +1,214 @@
"use client"
import {
Database,
Check,
ChevronLeft,
FolderOpen,
Server,
} from "lucide-react"
import {cn} from "@/lib/utils"
import {ProjectWithDatabasesAndBackups as ProjectWith} from "@/db/schema/06_project";
import {DatabaseWith} from "@/db/schema/07_database";
import {ScrollArea} from "@/components/ui/scroll-area";
import {truncateWords} from "@/utils/text";
type ViewState = "projects" | "databases"
interface TargetPanelProps {
projects: ProjectWith[]
selectedProject: ProjectWith | null
selectedDatabase: DatabaseWith | null
onSelectProject: (project: ProjectWith | null) => void
onSelectDatabase: (database: DatabaseWith | null) => void
disabled?: boolean
}
export function TargetPanel({
projects,
selectedProject,
selectedDatabase,
onSelectProject,
onSelectDatabase,
disabled,
}: TargetPanelProps) {
const currentView: ViewState = selectedProject ? "databases" : "projects"
const handleBack = () => {
onSelectDatabase(null)
onSelectProject(null)
}
const getHeaderInfo = () => {
if (selectedProject) {
return {
title: selectedProject.name,
subtitle: selectedDatabase
? selectedDatabase.name
: "Select a target database",
icon: <FolderOpen className="h-5 w-5 text-primary"/>,
}
}
return {
title: "Target",
subtitle: "Select destination project",
icon: <Server className="h-5 w-5 text-primary"/>,
}
}
const headerInfo = getHeaderInfo()
return (
<div className="rounded-xl border border-border bg-card h-full flex flex-col overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
{currentView !== "projects" ? (
<button
onClick={handleBack}
disabled={disabled}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-secondary transition-colors hover:bg-secondary/80 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronLeft className="h-5 w-5 text-secondary-foreground"/>
</button>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10">
{headerInfo.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="truncate font-semibold text-foreground">
{headerInfo.title}
</h2>
<p className="text-sm text-muted-foreground">{headerInfo.subtitle}</p>
</div>
</div>
{currentView !== "projects" && (
<div className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<button
onClick={handleBack}
disabled={disabled}
className="hover:text-foreground disabled:opacity-50"
>
Projects
</button>
{selectedProject && (
<>
<span>/</span>
<span className="truncate text-foreground">
{selectedProject.name}
</span>
</>
)}
</div>
)}
</div>
<ScrollArea
// Do not remove the radix scroll
className="w-full flex-1 [&>[data-radix-scroll-area-viewport]]:max-h-[calc(100vh-320px)]"
>
<div className="p-4">
{currentView === "projects" && (
<div className="flex flex-col gap-2">
{projects.map((project) => (
<button
key={project.id}
onClick={() => onSelectProject(project)}
disabled={disabled}
className={cn(
"group w-full rounded-lg border border-border bg-card p-4 text-left transition-all",
"hover:border-primary/50 hover:bg-accent/50",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
disabled && "cursor-not-allowed opacity-50"
)}
>
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Server className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{truncateWords(project.name, 6)}
</h3>
<div className="mt-1 flex items-center gap-2">
</div>
<p className="mt-2 text-sm text-muted-foreground">
{project.databases.length} database
{project.databases.length !== 1 ? "s" : ""}
</p>
</div>
<ChevronLeft
className="h-5 w-5 rotate-180 text-muted-foreground transition-transform group-hover:translate-x-1"/>
</div>
</button>
))}
</div>
)}
{currentView === "databases" && selectedProject && (
<div className="flex flex-col gap-2">
{selectedProject.databases.length === 0 ? (
<div className="py-8 text-center">
<Database className="mx-auto h-10 w-10 text-muted-foreground/50"/>
<p className="mt-2 text-sm text-muted-foreground">
No databases in this project
</p>
</div>
) : (
selectedProject.databases.map((database) => {
const isSelected = selectedDatabase?.id === database.id
return (
<button
key={database.id}
onClick={() => onSelectDatabase(database)}
disabled={disabled}
className={cn(
"group relative w-full rounded-lg border p-4 text-left transition-all",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card",
(disabled) && "cursor-not-allowed opacity-50"
)}
>
<div
className={cn(
"absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full border-2 transition-all",
isSelected
? "border-primary bg-primary"
: "border-muted-foreground/30 bg-transparent"
)}
>
{isSelected && (
<Check className="h-3 w-3 text-primary-foreground"/>
)}
</div>
<div className="flex items-start gap-3 pr-8">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
<Database className="h-5 w-5 text-muted-foreground"/>
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-medium text-foreground">
{database.name}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{database.dbms}
</p>
</div>
</div>
</button>
)
})
)}
</div>
)}
</div>
</ScrollArea>
</div>
)
}
@@ -22,6 +22,7 @@ import {ImportModal} from "@/components/wrappers/dashboard/database/import/impor
import {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
import {Badge} from "@/components/ui/badge";
export type DatabaseContentProps = {
settings: Setting;
@@ -99,8 +100,13 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
<>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
<div className="flex min-w-full md:min-w-fit justify-between gap-2 items-center ">
{capitalizeFirstLetter(database.name)}
<div className=" flex items-center justify-center">
<Badge variant="outline" className="bg-orange-400/10 border-orange-600/50 text-orange-600">
{database.dbms}
</Badge>
</div>
</div>
{!isMember && (
<div className="flex items-center gap-2 md:justify-between w-full ">