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
@@ -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 ">
|
||||
|
||||
Reference in New Issue
Block a user