mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
co-authored by
charles-gauthereau
parent
ab55ab349a
commit
ff28a80d7c
@@ -0,0 +1,65 @@
|
|||||||
|
import {PageParams} from "@/types/next";
|
||||||
|
import {Page, PageContent, PageHeader, PageTitle} from "@/features/layout/page";
|
||||||
|
import {notFound} from "next/navigation";
|
||||||
|
import {getOrganization} from "@/lib/auth/auth";
|
||||||
|
import {Metadata} from "next";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {MigrationTool} from "@/components/wrappers/dashboard/organization/migration/migration-tool";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Projects",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RoutePage(props: PageParams<{}>) {
|
||||||
|
|
||||||
|
const organization = await getOrganization({});
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await db.query.project.findMany({
|
||||||
|
where: (project, {eq, and, not}) =>
|
||||||
|
and(
|
||||||
|
eq(project.organizationId, organization.id),
|
||||||
|
not(eq(project.isArchived, true))
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
organization: true,
|
||||||
|
databases: {
|
||||||
|
where: (database, { isNull, not, inArray, and }) =>
|
||||||
|
and(
|
||||||
|
isNull(database.deletedAt),
|
||||||
|
not(inArray(database.dbms, ["valkey", "redis"]))
|
||||||
|
),
|
||||||
|
|
||||||
|
with: {
|
||||||
|
backups: {
|
||||||
|
where: (backup, { isNull, eq, and }) =>
|
||||||
|
and(
|
||||||
|
isNull(backup.deletedAt),
|
||||||
|
eq(backup.status, "success")
|
||||||
|
),
|
||||||
|
orderBy: (backup, {desc}) => [desc(backup.createdAt)],
|
||||||
|
limit: 15,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<PageHeader className="flex flex-col items-start justify-between mb-6">
|
||||||
|
<PageTitle className="mb-2">
|
||||||
|
Database Migration
|
||||||
|
</PageTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Import backups from a source project into your target database
|
||||||
|
</p>
|
||||||
|
</PageHeader>
|
||||||
|
<PageContent>
|
||||||
|
<MigrationTool projects={projects}/>
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
ChartArea,
|
ChartArea,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen
|
Building, UserRoundCog, Mail, PackageOpen, Logs, Megaphone, Blocks, Warehouse, BookOpen, Hammer,
|
||||||
|
ChevronsLeftRightEllipsis
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
|
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
|
||||||
import {authClient} from "@/lib/auth/auth-client";
|
import {authClient} from "@/lib/auth/auth-client";
|
||||||
@@ -32,7 +33,17 @@ export const SidebarMenuCustomMain = () => {
|
|||||||
const groupContent: SidebarGroupItem["group_content"] = [
|
const groupContent: SidebarGroupItem["group_content"] = [
|
||||||
{title: "Projects", url: "/projects", icon: Layers, details: true, type: "item"},
|
{title: "Projects", url: "/projects", icon: Layers, details: true, type: "item"},
|
||||||
{title: "Statistics", url: "/statistics", icon: ChartArea, 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,
|
details: true,
|
||||||
type: "collapse",
|
type: "collapse",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item" },
|
{title: "Channels", url: "/notifications/channels", icon: Blocks, type: "item"},
|
||||||
{ title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item" },
|
{title: "Activity Logs", url: "/notifications/logs", icon: Logs, type: "item"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -80,7 +91,7 @@ export const SidebarMenuCustomMain = () => {
|
|||||||
details: true,
|
details: true,
|
||||||
type: "collapse",
|
type: "collapse",
|
||||||
submenu: [
|
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",
|
type: "collapse",
|
||||||
submenu: [
|
submenu: [
|
||||||
{title: "Users", url: "/admin/users", icon: Users, type: "item"},
|
{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(
|
items.push(
|
||||||
{
|
{
|
||||||
label: "Resources",
|
label: "Resources",
|
||||||
type: "list",
|
type: "list",
|
||||||
group_content: [
|
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 {BackupButton} from "@/components/wrappers/dashboard/backup/backup-button/backup-button";
|
||||||
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
|
import {HealthModal} from "@/components/wrappers/dashboard/database/health/health-modal";
|
||||||
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||||
|
import {Badge} from "@/components/ui/badge";
|
||||||
|
|
||||||
export type DatabaseContentProps = {
|
export type DatabaseContentProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -99,8 +100,13 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
|||||||
<>
|
<>
|
||||||
<div className="justify-between gap-2 sm:flex">
|
<div className="justify-between gap-2 sm:flex">
|
||||||
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
|
<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)}
|
{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>
|
</div>
|
||||||
{!isMember && (
|
{!isMember && (
|
||||||
<div className="flex items-center gap-2 md:justify-between w-full ">
|
<div className="flex items-center gap-2 md:justify-between w-full ">
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "backups" ADD COLUMN "migrated" boolean DEFAULT false;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -372,6 +372,13 @@
|
|||||||
"when": 1775803959723,
|
"when": 1775803959723,
|
||||||
"tag": "0052_cute_punisher",
|
"tag": "0052_cute_punisher",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 53,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777204042208,
|
||||||
|
"tag": "0053_lyrical_union_jack",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ import {relations} from "drizzle-orm";
|
|||||||
import {Organization, organization} from "./03_organization";
|
import {Organization, organization} from "./03_organization";
|
||||||
import {createSelectSchema} from "drizzle-zod";
|
import {createSelectSchema} from "drizzle-zod";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {Database, database} from "./07_database";
|
import {Database, database, DatabaseWith} from "./07_database";
|
||||||
import {timestamps} from "@/db/schema/00_common";
|
import {timestamps} from "@/db/schema/00_common";
|
||||||
|
|
||||||
export const project = pgTable("projects", {
|
export const project = pgTable("projects", {
|
||||||
@@ -32,3 +32,9 @@ export type ProjectWith = Project & {
|
|||||||
databases: Database[];
|
databases: Database[];
|
||||||
organization: Organization;
|
organization: Organization;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectWithDatabasesAndBackups = Project & {
|
||||||
|
databases: DatabaseWith[];
|
||||||
|
organization: Organization;
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export const backup = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => database.id, {onDelete: "cascade"}),
|
.references(() => database.id, {onDelete: "cascade"}),
|
||||||
imported: boolean('imported').default(false),
|
imported: boolean('imported').default(false),
|
||||||
|
migrated: boolean('migrated').default(false),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function backupColumns(
|
|||||||
cell: ({row}) => {
|
cell: ({row}) => {
|
||||||
const reference = row.original.id
|
const reference = row.original.id
|
||||||
const isImported = row.original.imported
|
const isImported = row.original.imported
|
||||||
|
const isMigrated = row.original.migrated
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span>{reference}</span>
|
<span>{reference}</span>
|
||||||
@@ -68,6 +69,11 @@ export function backupColumns(
|
|||||||
Imported
|
Imported
|
||||||
</BadgeC>
|
</BadgeC>
|
||||||
)}
|
)}
|
||||||
|
{isMigrated && (
|
||||||
|
<BadgeC variant="outline" className="bg-blue-400/10 border-blue-600/50 text-blue-600">
|
||||||
|
Migrated
|
||||||
|
</BadgeC>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
"use server"
|
"use server"
|
||||||
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from '../../types';
|
import {
|
||||||
|
StorageCopyInput,
|
||||||
|
StorageDeleteInput,
|
||||||
|
StorageGetInput,
|
||||||
|
StorageMetaData,
|
||||||
|
StorageResult,
|
||||||
|
StorageUploadInput
|
||||||
|
} from '../../types';
|
||||||
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
|
import {GoogleDriveConfig} from "@/features/storages/providers/google-drive/types";
|
||||||
import {
|
import {
|
||||||
ensureFolderPath,
|
ensureFolderPath,
|
||||||
@@ -145,3 +152,70 @@ export async function pingGoogleDrive(config: GoogleDriveConfig): Promise<Storag
|
|||||||
return {success: false, provider: "google-drive", response: err.message};
|
return {success: false, provider: "google-drive", response: err.message};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function copyGoogleDrive(
|
||||||
|
config: GoogleDriveConfig,
|
||||||
|
input: {
|
||||||
|
data: StorageCopyInput,
|
||||||
|
metadata?: StorageMetaData;
|
||||||
|
},
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const client = await getGoogleDriveClient(config);
|
||||||
|
|
||||||
|
const sourceFileId = await resolveFilePath(
|
||||||
|
client,
|
||||||
|
input.data.from,
|
||||||
|
config.folderId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourceFileId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "google-drive",
|
||||||
|
error: "Source file not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = input.data.to;
|
||||||
|
const parts = fullPath.split("/").filter(Boolean);
|
||||||
|
const fileName = parts.pop()!;
|
||||||
|
const folderPath = parts.join("/");
|
||||||
|
|
||||||
|
const folderId = folderPath
|
||||||
|
? await ensureFolderPath(client, folderPath, config.folderId)
|
||||||
|
: config.folderId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copied = await client.files.copy({
|
||||||
|
fileId: sourceFileId,
|
||||||
|
requestBody: {
|
||||||
|
name: fileName,
|
||||||
|
parents: [folderId],
|
||||||
|
},
|
||||||
|
fields: "id",
|
||||||
|
supportsAllDrives: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newFileId = copied.data.id;
|
||||||
|
|
||||||
|
if (!newFileId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "google-drive",
|
||||||
|
error: "Copy failed (no file id returned)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "google-drive",
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "google-drive",
|
||||||
|
error: err.message || "Copy failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@ import {
|
|||||||
StorageResult,
|
StorageResult,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
import {uploadLocal, getLocal, deleteLocal, pingLocal} from './local';
|
import {uploadLocal, getLocal, deleteLocal, pingLocal, copyLocal} from './local';
|
||||||
import {deleteS3, getS3, pingS3, uploadS3} from "@/features/storages/providers/s3";
|
import {copyS3, deleteS3, getS3, pingS3, uploadS3} from "@/features/storages/providers/s3";
|
||||||
import {
|
import {
|
||||||
|
copyGoogleDrive,
|
||||||
deleteGoogleDrive,
|
deleteGoogleDrive,
|
||||||
getGoogleDrive,
|
getGoogleDrive,
|
||||||
pingGoogleDrive,
|
pingGoogleDrive,
|
||||||
@@ -18,6 +19,7 @@ type ProviderHandler = {
|
|||||||
get: (config: any, input: StorageInput & { action: 'get' }) => Promise<StorageResult>;
|
get: (config: any, input: StorageInput & { action: 'get' }) => Promise<StorageResult>;
|
||||||
delete: (config: any, input: StorageInput & { action: 'delete' }) => Promise<StorageResult>;
|
delete: (config: any, input: StorageInput & { action: 'delete' }) => Promise<StorageResult>;
|
||||||
ping: (config: any, input: { action: 'ping' }) => Promise<StorageResult>;
|
ping: (config: any, input: { action: 'ping' }) => Promise<StorageResult>;
|
||||||
|
copy: (config: any, input: StorageInput & { action: 'copy' }) => Promise<StorageResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
||||||
@@ -26,18 +28,21 @@ const handlers: Record<StorageProviderKind, ProviderHandler> = {
|
|||||||
get: getLocal,
|
get: getLocal,
|
||||||
delete: deleteLocal,
|
delete: deleteLocal,
|
||||||
ping: pingLocal,
|
ping: pingLocal,
|
||||||
|
copy: copyLocal,
|
||||||
},
|
},
|
||||||
s3: {
|
s3: {
|
||||||
upload: uploadS3,
|
upload: uploadS3,
|
||||||
get: getS3,
|
get: getS3,
|
||||||
delete: deleteS3,
|
delete: deleteS3,
|
||||||
ping: pingS3,
|
ping: pingS3,
|
||||||
|
copy: copyS3
|
||||||
},
|
},
|
||||||
"google-drive": {
|
"google-drive": {
|
||||||
upload: uploadGoogleDrive,
|
upload: uploadGoogleDrive,
|
||||||
get: getGoogleDrive,
|
get: getGoogleDrive,
|
||||||
delete: deleteGoogleDrive,
|
delete: deleteGoogleDrive,
|
||||||
ping: pingGoogleDrive,
|
ping: pingGoogleDrive,
|
||||||
|
copy: copyGoogleDrive,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { mkdir, unlink } from "fs/promises";
|
import { mkdir, unlink } from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import {
|
import {
|
||||||
|
StorageCopyInput,
|
||||||
StorageDeleteInput,
|
StorageDeleteInput,
|
||||||
StorageGetInput,
|
StorageGetInput,
|
||||||
StorageMetaData,
|
StorageMetaData,
|
||||||
@@ -159,3 +160,58 @@ export async function pingLocal(config: {
|
|||||||
response: "Local storage OK",
|
response: "Local storage OK",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function copyLocal(
|
||||||
|
config: { baseDir?: string },
|
||||||
|
input: {
|
||||||
|
data: StorageCopyInput,
|
||||||
|
metadata?: StorageMetaData;
|
||||||
|
},
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const base = config.baseDir
|
||||||
|
? path.join(process.cwd(), config.baseDir ?? "")
|
||||||
|
: BASE_DIR;
|
||||||
|
|
||||||
|
const sourcePath = path.join(base, input.data.from);
|
||||||
|
const destinationPath = path.join(base, input.data.to);
|
||||||
|
|
||||||
|
const dir = path.dirname(destinationPath);
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
|
if (!fs.existsSync(sourcePath)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "local",
|
||||||
|
error: "Source file not found",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const readStream = fs.createReadStream(sourcePath);
|
||||||
|
const writeStream = fs.createWriteStream(destinationPath);
|
||||||
|
|
||||||
|
readStream.on("error", reject);
|
||||||
|
writeStream.on("error", reject);
|
||||||
|
writeStream.on("finish", resolve);
|
||||||
|
|
||||||
|
readStream.pipe(writeStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "local",
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
try {
|
||||||
|
await unlink(destinationPath);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "local",
|
||||||
|
error: err.message || "Copy failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import * as Minio from "minio";
|
import * as Minio from "minio";
|
||||||
import {StorageDeleteInput, StorageGetInput, StorageMetaData, StorageResult, StorageUploadInput} from "../types";
|
import {
|
||||||
|
StorageCopyInput,
|
||||||
|
StorageDeleteInput,
|
||||||
|
StorageGetInput,
|
||||||
|
StorageMetaData,
|
||||||
|
StorageResult,
|
||||||
|
StorageUploadInput
|
||||||
|
} from "../types";
|
||||||
import {Readable} from "node:stream";
|
import {Readable} from "node:stream";
|
||||||
|
|
||||||
type S3Config = {
|
type S3Config = {
|
||||||
@@ -132,3 +139,36 @@ export async function pingS3(config: S3Config): Promise<StorageResult> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function copyS3(
|
||||||
|
config: S3Config,
|
||||||
|
input: {
|
||||||
|
data: StorageCopyInput,
|
||||||
|
},
|
||||||
|
): Promise<StorageResult> {
|
||||||
|
const client = await getS3Client(config);
|
||||||
|
await ensureBucket(config);
|
||||||
|
|
||||||
|
const sourceKey = `${BASE_DIR}${input.data.from}`;
|
||||||
|
const destinationKey = `${BASE_DIR}${input.data.to}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.copyObject(
|
||||||
|
config.bucketName,
|
||||||
|
destinationKey,
|
||||||
|
`/${config.bucketName}/${sourceKey}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
provider: "s3",
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
provider: "s3",
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,11 +38,18 @@ export interface StorageDeleteInput {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface StorageCopyInput {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type StorageInput =
|
export type StorageInput =
|
||||||
| { action: 'upload'; data: StorageUploadInput, metadata?: StorageMetaData }
|
| { action: 'upload'; data: StorageUploadInput, metadata?: StorageMetaData }
|
||||||
| { action: 'get'; data: StorageGetInput, metadata: StorageMetaData }
|
| { action: 'get'; data: StorageGetInput, metadata: StorageMetaData }
|
||||||
| { action: 'delete'; data: StorageDeleteInput, metadata?: StorageMetaData }
|
| { action: 'delete'; data: StorageDeleteInput, metadata?: StorageMetaData }
|
||||||
| { action: 'ping'; };
|
| { action: 'ping'; }
|
||||||
|
| { action: 'copy'; data: StorageCopyInput, metadata?: StorageMetaData };
|
||||||
|
|
||||||
export interface StorageResult {
|
export interface StorageResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|||||||
@@ -47,3 +47,11 @@ export function formatDayOnly(date: Date) {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTodayISODate() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user