mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Some fix on the s3 and local methods for the files saving, we have to improve testing. Working on table actions.
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
import {NextResponse} from "next/server";
|
import {NextResponse} from "next/server";
|
||||||
import {Body} from "./route";
|
import {Body} from "./route";
|
||||||
import {isUuidv4} from "@/utils/verify-uuid";
|
import {isUuidv4} from "@/utils/verify-uuid";
|
||||||
import {getFileUrlPresignedLocal} from "@/features/upload/private/upload.action";
|
import {getFileUrlPresignedLocal, getFileUrlPreSignedS3Action} from "@/features/upload/private/upload.action";
|
||||||
import {Agent} from "@/db/schema/07_agent";
|
import {Agent} from "@/db/schema/07_agent";
|
||||||
import {Database} from "@/db/schema/06_database";
|
import {Database} from "@/db/schema/06_database";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db as dbClient} from "@/db";
|
import {db as dbClient} from "@/db";
|
||||||
import {and, eq} from "drizzle-orm";
|
import {and, eq} from "drizzle-orm";
|
||||||
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {SafeActionResult} from "next-safe-action";
|
||||||
|
import {ZodString} from "zod";
|
||||||
|
|
||||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date) {
|
||||||
const databasesResponse = [];
|
const databasesResponse = [];
|
||||||
@@ -35,7 +38,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
|
|
||||||
let backupAction: boolean = false
|
let backupAction: boolean = false
|
||||||
let restoreAction: boolean = false
|
let restoreAction: boolean = false
|
||||||
let UrlBackup: string = ""
|
let urlBackup: string = ""
|
||||||
|
|
||||||
if (!existingDatabase) {
|
if (!existingDatabase) {
|
||||||
if (!isUuidv4(db.generatedId)) {
|
if (!isUuidv4(db.generatedId)) {
|
||||||
@@ -58,12 +61,11 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (databaseCreated) {
|
if (databaseCreated) {
|
||||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction,restoreAction, UrlBackup));
|
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const [databaseUpdated] = await dbClient
|
const [databaseUpdated] = await dbClient
|
||||||
.update(drizzleDb.schemas.database)
|
.update(drizzleDb.schemas.database)
|
||||||
.set({lastContact: lastContact})
|
.set({lastContact: lastContact})
|
||||||
@@ -71,7 +73,6 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const backup = await dbClient.query.backup.findFirst({
|
const backup = await dbClient.query.backup.findFirst({
|
||||||
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
where: and(eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.backup.status, "waiting"))
|
||||||
})
|
})
|
||||||
@@ -97,23 +98,71 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
|||||||
|
|
||||||
const backupToRestore = await dbClient.query.backup.findFirst({
|
const backupToRestore = await dbClient.query.backup.findFirst({
|
||||||
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
where: eq(drizzleDb.schemas.backup.id, restoration.backupId),
|
||||||
|
with: {
|
||||||
|
database: {
|
||||||
|
with: {
|
||||||
|
project: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [settings] = await dbClient.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
|
if (!settings) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{error: "Unable to find settings"},
|
||||||
|
{status: 500}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const fileName = backupToRestore?.file
|
const fileName = backupToRestore?.file
|
||||||
UrlBackup = await getFileUrlPresignedLocal(fileName ?? "")
|
|
||||||
|
|
||||||
|
let data: SafeActionResult<string, ZodString, readonly [], {
|
||||||
|
_errors?: string[] | undefined;
|
||||||
|
}, readonly [], ServerActionResult<string>, object> | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
if (settings.storage == "local") {
|
||||||
|
data = await getFileUrlPresignedLocal(fileName!)
|
||||||
|
} else if (settings.storage == "s3") {
|
||||||
|
|
||||||
|
data = await getFileUrlPreSignedS3Action(`backups/${backupToRestore?.database.project?.slug}/${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (data?.data?.success) {
|
||||||
|
urlBackup = data.data.value ?? "";
|
||||||
|
} else {
|
||||||
|
await dbClient
|
||||||
|
.update(drizzleDb.schemas.restoration)
|
||||||
|
.set({status: "failed"})
|
||||||
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const errorMessage = data?.data?.actionError?.message || "Failed to get presigned URL";
|
||||||
|
console.error("Restoration failed: ", errorMessage);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Restoration crashed unexpectedly:", err);
|
||||||
|
await dbClient
|
||||||
|
.update(drizzleDb.schemas.restoration)
|
||||||
|
.set({status: "failed"})
|
||||||
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
await dbClient
|
await dbClient
|
||||||
.update(drizzleDb.schemas.restoration)
|
.update(drizzleDb.schemas.restoration)
|
||||||
.set({status: "ongoing"})
|
.set({status: "ongoing"})
|
||||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup));
|
||||||
|
|
||||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, UrlBackup));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return databasesResponse;
|
return databasesResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,157 +1,3 @@
|
|||||||
// "use client";
|
|
||||||
//
|
|
||||||
// import {
|
|
||||||
// ColumnDef,
|
|
||||||
// flexRender,
|
|
||||||
// getCoreRowModel,
|
|
||||||
// useReactTable,
|
|
||||||
// getPaginationRowModel,
|
|
||||||
// getSortedRowModel,
|
|
||||||
// SortingState,
|
|
||||||
// ColumnFiltersState,
|
|
||||||
// getFilteredRowModel,
|
|
||||||
// } from "@tanstack/react-table";
|
|
||||||
//
|
|
||||||
// import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
|
||||||
// import { Input } from "@/components/ui/input";
|
|
||||||
//
|
|
||||||
// import { useState } from "react";
|
|
||||||
// import { TablePagination } from "./table-pagination";
|
|
||||||
// import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
//
|
|
||||||
// interface DataTableProps<TData, TValue> {
|
|
||||||
// columns: ColumnDef<TData, TValue>[];
|
|
||||||
// data: TData[];
|
|
||||||
// enableFilter?: boolean;
|
|
||||||
// enableSelect?: boolean;
|
|
||||||
// enablePagination?: boolean;
|
|
||||||
// paginationOptions?: {
|
|
||||||
// pageSize: number[];
|
|
||||||
// pageVisible: number;
|
|
||||||
// className?: string;
|
|
||||||
// };
|
|
||||||
// filterOptions?: {
|
|
||||||
// title?: string;
|
|
||||||
// key: string;
|
|
||||||
// };
|
|
||||||
// emptyText?: string;
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// export function DataTable<TData, TValue>({
|
|
||||||
// columns,
|
|
||||||
// data,
|
|
||||||
// enableFilter = false,
|
|
||||||
// enablePagination = true,
|
|
||||||
// enableSelect = true,
|
|
||||||
// paginationOptions = { pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3 },
|
|
||||||
// filterOptions = { key: "id", title: "Filter by ID" },
|
|
||||||
// emptyText = "No data.",
|
|
||||||
// }: DataTableProps<TData, TValue>) {
|
|
||||||
// const [sorting, setSorting] = useState<SortingState>([]);
|
|
||||||
// const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
|
||||||
// const [rowSelection, setRowSelection] = useState({});
|
|
||||||
//
|
|
||||||
// if (enableSelect && data.length > 0) {
|
|
||||||
// const selectColumnExists = columns.some((column) => column.id === "select");
|
|
||||||
//
|
|
||||||
// if (!selectColumnExists) {
|
|
||||||
// columns.unshift({
|
|
||||||
// id: "select",
|
|
||||||
// header: ({ table }) => (
|
|
||||||
// <Checkbox
|
|
||||||
// checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
|
||||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
|
||||||
// aria-label="Select all"
|
|
||||||
// />
|
|
||||||
// ),
|
|
||||||
// cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onCheckedChange={(value) => row.toggleSelected(!!value)} aria-label="Select row" />,
|
|
||||||
// enableSorting: false,
|
|
||||||
// enableHiding: false,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const table = useReactTable({
|
|
||||||
// data,
|
|
||||||
// columns,
|
|
||||||
// getCoreRowModel: getCoreRowModel(),
|
|
||||||
// getPaginationRowModel: getPaginationRowModel(),
|
|
||||||
// onSortingChange: setSorting,
|
|
||||||
// getSortedRowModel: getSortedRowModel(),
|
|
||||||
// onColumnFiltersChange: setColumnFilters,
|
|
||||||
// getFilteredRowModel: getFilteredRowModel(),
|
|
||||||
// onRowSelectionChange: setRowSelection,
|
|
||||||
// state: {
|
|
||||||
// sorting,
|
|
||||||
// columnFilters,
|
|
||||||
// rowSelection,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// return (
|
|
||||||
// <div className="h-full">
|
|
||||||
// {enableFilter && (
|
|
||||||
// <div className="flex items-center py-4">
|
|
||||||
// <Input
|
|
||||||
// placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
|
||||||
// value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
|
||||||
// onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
|
||||||
// className="max-w-sm"
|
|
||||||
// />
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// <div className="rounded-md border w-full">
|
|
||||||
// <Table className="w-full">
|
|
||||||
// <TableHeader>
|
|
||||||
// {table.getHeaderGroups().map((headerGroup) => (
|
|
||||||
// <TableRow key={headerGroup.id}>
|
|
||||||
// {headerGroup.headers.map((header) => {
|
|
||||||
// return (
|
|
||||||
// <TableHead key={header.id}>
|
|
||||||
// {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
|
||||||
// </TableHead>
|
|
||||||
// );
|
|
||||||
// })}
|
|
||||||
// </TableRow>
|
|
||||||
// ))}
|
|
||||||
// </TableHeader>
|
|
||||||
// <TableBody>
|
|
||||||
// {table.getRowModel().rows?.length ? (
|
|
||||||
// table.getRowModel().rows.map((row) => (
|
|
||||||
// <TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
|
||||||
// {row.getVisibleCells().map((cell) => (
|
|
||||||
// <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
|
||||||
// ))}
|
|
||||||
// </TableRow>
|
|
||||||
// ))
|
|
||||||
// ) : (
|
|
||||||
// <TableRow>
|
|
||||||
// <TableCell colSpan={columns.length} className="h-24 text-center">
|
|
||||||
// {emptyText}
|
|
||||||
// </TableCell>
|
|
||||||
// </TableRow>
|
|
||||||
// )}
|
|
||||||
// </TableBody>
|
|
||||||
// </Table>
|
|
||||||
// </div>
|
|
||||||
// <div className="flex items-center justify-end space-x-2 py-4 mt-6">
|
|
||||||
// {enableSelect && (
|
|
||||||
// <div className="flex-1 text-sm text-muted-foreground">
|
|
||||||
// {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) selected.
|
|
||||||
// </div>
|
|
||||||
// )}
|
|
||||||
// {enablePagination && (
|
|
||||||
// <TablePagination
|
|
||||||
// table={table}
|
|
||||||
// maxVisiblePages={paginationOptions?.pageVisible}
|
|
||||||
// pageSizeOptions={paginationOptions.pageSize}
|
|
||||||
// className={paginationOptions.className}
|
|
||||||
// />
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
"use client"
|
"use client"
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
@@ -168,7 +14,7 @@ import {
|
|||||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
|
||||||
import {Input} from "@/components/ui/input";
|
import {Input} from "@/components/ui/input";
|
||||||
|
|
||||||
import {useState} from "react";
|
import {ReactNode, useEffect, useState} from "react";
|
||||||
import {TablePagination} from "./table-pagination";
|
import {TablePagination} from "./table-pagination";
|
||||||
import {Checkbox} from "@/components/ui/checkbox";
|
import {Checkbox} from "@/components/ui/checkbox";
|
||||||
import {Button} from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
@@ -195,6 +41,7 @@ interface DataTableProps<TData, TValue> {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
highlightRow?: (row: TData) => boolean;
|
highlightRow?: (row: TData) => boolean;
|
||||||
|
selectedActions?: (rows: TData[]) => ReactNode;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +54,18 @@ export function DataTable<TData, TValue>({
|
|||||||
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
|
paginationOptions = {pageSize: [10, 20, 30, 40, 50, 100], pageVisible: 3},
|
||||||
filterOptions = {key: "id", title: "Filter by ID"},
|
filterOptions = {key: "id", title: "Filter by ID"},
|
||||||
emptyButton,
|
emptyButton,
|
||||||
highlightRow
|
highlightRow,
|
||||||
|
selectedActions,
|
||||||
|
|
||||||
|
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState({});
|
const [rowSelection, setRowSelection] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRowSelection({});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
if (enableSelect && data.length > 0) {
|
if (enableSelect && data.length > 0) {
|
||||||
const selectColumnExists = columns.some((column) => column.id === "select");
|
const selectColumnExists = columns.some((column) => column.id === "select");
|
||||||
@@ -257,14 +110,20 @@ export function DataTable<TData, TValue>({
|
|||||||
<div
|
<div
|
||||||
className="flex flex-col h-full"
|
className="flex flex-col h-full"
|
||||||
>
|
>
|
||||||
{enableFilter && (
|
{enableFilter || selectedActions && (
|
||||||
<div className="flex items-center py-4">
|
<div className="flex items-center py-4">
|
||||||
|
{enableFilter && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
placeholder={`${filterOptions.title ?? `Filter by ${filterOptions.key}`}`}
|
||||||
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
value={(table.getColumn(filterOptions.key)?.getFilterValue() as string) ?? ""}
|
||||||
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
onChange={(event) => table.getColumn(filterOptions.key)?.setFilterValue(event.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
{/*{selectedActions && table.getSelectedRowModel().rows.length > 0 && (*/}
|
||||||
|
{selectedActions && (
|
||||||
|
selectedActions(table.getSelectedRowModel().rows.map(row => row.original))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col justify-between h-full">
|
<div className="flex flex-col justify-between h-full">
|
||||||
@@ -277,6 +136,7 @@ export function DataTable<TData, TValue>({
|
|||||||
return (
|
return (
|
||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -342,10 +202,7 @@ export function DataTable<TData, TValue>({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||||
import { useEffect } from "react";
|
import {useEffect, useState} from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import {useRouter, useSearchParams} from "next/navigation";
|
||||||
import {eventUpdate} from "@/types/events";
|
import {eventUpdate} from "@/types/events";
|
||||||
import {backupColumns} from "@/features/dashboard/backup/columns";
|
import {backupColumns} from "@/features/dashboard/backup/columns";
|
||||||
import {restoreColumns} from "@/features/dashboard/restore/columns";
|
import {restoreColumns} from "@/features/dashboard/restore/columns";
|
||||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||||
import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/06_database";
|
import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/06_database";
|
||||||
import {Setting} from "@/db/schema/00_setting";
|
import {Setting} from "@/db/schema/00_setting";
|
||||||
|
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
|
||||||
|
import {MoreHorizontal, Trash2} from "lucide-react";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {deleteBackupAction, deleteRestoreAction} from "@/features/dashboard/restore/restore.action";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||||
|
|
||||||
export type DatabaseTabsProps = {
|
export type DatabaseTabsProps = {
|
||||||
settings: Setting
|
settings: Setting
|
||||||
@@ -20,9 +26,12 @@ export type DatabaseTabsProps = {
|
|||||||
|
|
||||||
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const eventSource = new EventSource("/api/events");
|
const eventSource = new EventSource("/api/events");
|
||||||
|
|
||||||
eventSource.addEventListener("modification", (event) => {
|
eventSource.addEventListener("modification", (event) => {
|
||||||
const data: eventUpdate = JSON.parse(event.data);
|
const data: eventUpdate = JSON.parse(event.data);
|
||||||
if (data.update) {
|
if (data.update) {
|
||||||
@@ -35,18 +44,158 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newTab = searchParams.get("tab") ?? "backup";
|
||||||
|
setTab(newTab);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const handleChangeTab = (value: string) => {
|
||||||
|
router.push(`?tab=${value}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const mutationDeleteBackups = useMutation({
|
||||||
|
mutationFn: async (backups: Backup[]) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
backups.map(async (backup) => {
|
||||||
|
const backupDeleted = await deleteBackupAction({
|
||||||
|
backupId: backup.id,
|
||||||
|
databaseId: backup.databaseId,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: backupDeleted?.data?.success,
|
||||||
|
message: backupDeleted?.data?.success
|
||||||
|
? backupDeleted?.data?.actionSuccess?.message
|
||||||
|
// @ts-ignore
|
||||||
|
: restoration?.data?.actionError.message,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutationDeleteRestorations = useMutation({
|
||||||
|
mutationFn: async (restorations: Restoration[]) => {
|
||||||
|
const results = await Promise.all(
|
||||||
|
restorations.map(async (restoration) => {
|
||||||
|
const restorationDeleted = await deleteRestoreAction({
|
||||||
|
restorationId: restoration.id,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: restorationDeleted?.data?.success,
|
||||||
|
message: restorationDeleted?.data?.success
|
||||||
|
? restorationDeleted?.data?.actionSuccess?.message
|
||||||
|
// @ts-ignore
|
||||||
|
: restorationDeleted?.data?.actionError.message,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
results.forEach((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message);
|
||||||
|
} else {
|
||||||
|
toast.error(result.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs className="flex flex-col flex-1" defaultValue="backup">
|
<Tabs className="flex flex-col flex-1" value={tab} onValueChange={handleChangeTab}>
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="backup">Backup</TabsTrigger>
|
<TabsTrigger value="backup">Backup</TabsTrigger>
|
||||||
<TabsTrigger value="restore">Restoration</TabsTrigger>
|
<TabsTrigger value="restore">Restoration</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent className="h-full justify-between" value="backup">
|
<TabsContent className="h-full justify-between" value="backup">
|
||||||
<DataTable columns={backupColumns(props.isAlreadyRestore, props.settings, props.database)} data={props.backups} enablePagination />
|
<DataTable
|
||||||
|
columns={backupColumns(props.isAlreadyRestore, props.settings, props.database)}
|
||||||
|
data={props.backups}
|
||||||
|
enablePagination
|
||||||
|
selectedActions={(rows) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<ButtonWithLoading
|
||||||
|
variant="outline"
|
||||||
|
text="Actions"
|
||||||
|
onClick={() => {
|
||||||
|
|
||||||
|
}}
|
||||||
|
disabled={rows.length === 0 || mutationDeleteBackups.isPending}
|
||||||
|
icon={<MoreHorizontal/>}
|
||||||
|
isPending={mutationDeleteBackups.isPending}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
{/*<DropdownMenuItem*/}
|
||||||
|
{/* onClick={() => {*/}
|
||||||
|
{/* console.log("Deleting rows:", rows)*/}
|
||||||
|
{/* }}*/}
|
||||||
|
{/*>*/}
|
||||||
|
{/* <Download className="w-4 h-4 mr-2"/>*/}
|
||||||
|
{/* Download Selected*/}
|
||||||
|
{/*</DropdownMenuItem>*/}
|
||||||
|
{/*<Separator/>*/}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
console.log("Deleting rows:", rows)
|
||||||
|
await mutationDeleteBackups.mutateAsync(rows)
|
||||||
|
}}
|
||||||
|
className="text-red-600 focus:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2"/>
|
||||||
|
Delete Selected
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent className="h-full justify-between" value="restore">
|
<TabsContent className="h-full justify-between" value="restore">
|
||||||
<DataTable columns={restoreColumns(props.isAlreadyRestore)} data={props.restorations} enablePagination />
|
<DataTable
|
||||||
|
columns={restoreColumns(props.isAlreadyRestore)}
|
||||||
|
data={props.restorations}
|
||||||
|
enablePagination
|
||||||
|
selectedActions={(rows) => (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<ButtonWithLoading
|
||||||
|
variant="outline"
|
||||||
|
text="Actions"
|
||||||
|
onClick={() => {
|
||||||
|
}}
|
||||||
|
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
||||||
|
icon={<MoreHorizontal/>}
|
||||||
|
isPending={mutationDeleteRestorations.isPending}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
await mutationDeleteRestorations.mutateAsync(rows)
|
||||||
|
}}
|
||||||
|
disabled={props.isAlreadyRestore}
|
||||||
|
className="text-red-600 focus:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2"/>
|
||||||
|
Delete Selected
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {Download, MoreHorizontal, Trash2} from "lucide-react";
|
|||||||
import {ReloadIcon} from "@radix-ui/react-icons";
|
import {ReloadIcon} from "@radix-ui/react-icons";
|
||||||
import {
|
import {
|
||||||
getFileUrlPresignedLocal,
|
getFileUrlPresignedLocal,
|
||||||
getFileUrlPresignedS3,
|
|
||||||
getFileUrlPreSignedS3Action
|
getFileUrlPreSignedS3Action
|
||||||
} from "@/features/upload/private/upload.action";
|
} from "@/features/upload/private/upload.action";
|
||||||
import {useMutation} from "@tanstack/react-query";
|
import {useMutation} from "@tanstack/react-query";
|
||||||
@@ -26,6 +25,9 @@ import {Backup, DatabaseWith} from "@/db/schema/06_database";
|
|||||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||||
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
|
import {TooltipCustom} from "@/components/wrappers/common/tooltip-custom";
|
||||||
import {Setting} from "@/db/schema/00_setting";
|
import {Setting} from "@/db/schema/00_setting";
|
||||||
|
import {SafeActionResult} from "next-safe-action";
|
||||||
|
import {ZodString} from "zod";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
|
||||||
|
|
||||||
export function backupColumns(isAlreadyRestore: boolean, settings: Setting, database: DatabaseWith): ColumnDef<Backup>[] {
|
export function backupColumns(isAlreadyRestore: boolean, settings: Setting, database: DatabaseWith): ColumnDef<Backup>[] {
|
||||||
@@ -103,11 +105,18 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (fileName: string) => {
|
const handleDownload = async (fileName: string) => {
|
||||||
|
|
||||||
let url: string = "";
|
let url: string = "";
|
||||||
|
let data: SafeActionResult<string, ZodString, readonly [], {
|
||||||
|
_errors?: string[] | undefined;
|
||||||
|
}, readonly [], ServerActionResult<string>, object> | undefined
|
||||||
|
|
||||||
if (settings.storage == "local") {
|
if (settings.storage == "local") {
|
||||||
url = await getFileUrlPresignedLocal(fileName);
|
data = await getFileUrlPresignedLocal(fileName!)
|
||||||
} else if (settings.storage == "s3") {
|
} else if (settings.storage == "s3") {
|
||||||
const data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`)
|
data = await getFileUrlPreSignedS3Action(`backups/${database.project?.slug}/${fileName}`);
|
||||||
|
}
|
||||||
|
console.log(data)
|
||||||
if (data?.data?.success) {
|
if (data?.data?.success) {
|
||||||
url = data.data.value ?? "";
|
url = data.data.value ?? "";
|
||||||
} else {
|
} else {
|
||||||
@@ -115,7 +124,7 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data
|
|||||||
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
|
const errorMessage = data?.data?.actionError?.message || "Failed to get file!";
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
window.open(url, "_self");
|
window.open(url, "_self");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {getServerUrl} from "@/utils/get-server-url";
|
|||||||
import {createPresignedUrlToDownload, saveFileInBucket} from "@/utils/s3-file-management";
|
import {createPresignedUrlToDownload, saveFileInBucket} from "@/utils/s3-file-management";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import {env} from "@/env.mjs";
|
import {env} from "@/env.mjs";
|
||||||
import {userAction} from "@/safe-actions";
|
import {action, userAction} from "@/safe-actions";
|
||||||
import {z} from "zod";
|
import {z} from "zod";
|
||||||
import {ServerActionResult} from "@/types/action-type";
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
import {Backup} from "@/db/schema/06_database";
|
import {Backup} from "@/db/schema/06_database";
|
||||||
@@ -54,25 +54,25 @@ export async function uploadS3Private(fileName: string, buffer: any, bucketName:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export async function getFileUrlPresignedLocal(fileName: string) {
|
// export async function getFileUrlPresignedLocal(fileName: string) {
|
||||||
try {
|
// try {
|
||||||
const filePath = path.join(privateLocalDir, fileName);
|
// const filePath = path.join(privateLocalDir, fileName);
|
||||||
await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
|
// await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
|
||||||
|
//
|
||||||
if (!fs.existsSync(filePath)) {
|
// if (!fs.existsSync(filePath)) {
|
||||||
console.error("File not found at:", filePath);
|
// console.error("File not found at:", filePath);
|
||||||
return `File not found at: ${filePath}`;
|
// return `File not found at: ${filePath}`;
|
||||||
}
|
// }
|
||||||
const crypto = require("crypto");
|
// const crypto = require("crypto");
|
||||||
const baseUrl = getServerUrl();
|
// const baseUrl = getServerUrl();
|
||||||
|
//
|
||||||
const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
|
// const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
|
||||||
const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex");
|
// const token = crypto.createHash("sha256").update(`${fileName}${expiresAt}`).digest("hex");
|
||||||
return `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`;
|
// return `${baseUrl}/api/files/${fileName}?token=${token}&expires=${expiresAt}`;
|
||||||
} catch (error) {
|
// } catch (error) {
|
||||||
throw error;
|
// throw error;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
export async function getFileUrlPresignedS3(fileName: string) {
|
export async function getFileUrlPresignedS3(fileName: string) {
|
||||||
try {
|
try {
|
||||||
@@ -86,26 +86,37 @@ export async function getFileUrlPresignedS3(fileName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const getFileUrlPreSignedS3Action = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
|
export const getFileUrlPresignedLocal = action
|
||||||
|
.schema(z.string())
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
|
||||||
try {
|
try {
|
||||||
const url = await createPresignedUrlToDownload({
|
const filePath = path.join(privateLocalDir, parsedInput);
|
||||||
bucketName: env.S3_BUCKET_NAME!,
|
await mkdir(path.join(process.cwd(), privateLocalDir), {recursive: true});
|
||||||
fileName: parsedInput,
|
|
||||||
});
|
if (!fs.existsSync(filePath)) {
|
||||||
|
console.error("File not found at:", filePath);
|
||||||
|
throw new Error(`File not found at: ${filePath}`);
|
||||||
|
}
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const baseUrl = getServerUrl();
|
||||||
|
|
||||||
|
const expiresAt = Date.now() + 60 * 1000; // expires in 1 minute
|
||||||
|
const token = crypto.createHash("sha256").update(`${parsedInput}${expiresAt}`).digest("hex");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
value: url,
|
value: `${baseUrl}/api/files/${parsedInput}?token=${token}&expires=${expiresAt}`,
|
||||||
actionSuccess: {
|
actionSuccess: {
|
||||||
message: "Successfully get url",
|
message: "Successfully retrieved presigned URL Local",
|
||||||
messageParams: {fileName: parsedInput},
|
messageParams: {fileName: parsedInput},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating backup:", error);
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
actionError: {
|
actionError: {
|
||||||
message: "Failed to create url pre signed s3.",
|
message: "Failed to generate presigned URL",
|
||||||
status: 500,
|
status: 500,
|
||||||
cause: error instanceof Error ? error.message : "Unknown error",
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
messageParams: {fileName: parsedInput},
|
messageParams: {fileName: parsedInput},
|
||||||
@@ -115,4 +126,45 @@ export const getFileUrlPreSignedS3Action = userAction.schema(z.string()).action(
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export const getFileUrlPreSignedS3Action = action
|
||||||
|
.schema(z.string())
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<string>> => {
|
||||||
|
try {
|
||||||
|
const data = await createPresignedUrlToDownload({
|
||||||
|
bucketName: env.S3_BUCKET_NAME!,
|
||||||
|
fileName: parsedInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: data.url,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Successfully retrieved presigned URL",
|
||||||
|
messageParams: {fileName: parsedInput},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const isNotFound = error instanceof Error && error.message.includes("File does not exist");
|
||||||
|
|
||||||
|
const logContext = {
|
||||||
|
file: parsedInput,
|
||||||
|
reason: error instanceof Error ? error.message : "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error("Presigned URL generation failed:", logContext);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: isNotFound
|
||||||
|
? "File not found in S3 bucket"
|
||||||
|
: "Failed to generate presigned URL",
|
||||||
|
status: isNotFound ? 404 : 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
messageParams: {fileName: parsedInput},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -108,15 +108,35 @@ export async function saveFileInBucket({bucketName, fileName, file}: {
|
|||||||
* @param fileName name of the file
|
* @param fileName name of the file
|
||||||
* @returns true if file exists, false if not
|
* @returns true if file exists, false if not
|
||||||
*/
|
*/
|
||||||
export async function checkFileExistsInBucket({bucketName, fileName}: { bucketName: string; fileName: string }) {
|
// export async function checkFileExistsInBucket({bucketName, fileName}: { bucketName: string; fileName: string }) {
|
||||||
const s3Client = await getS3Client();
|
// const s3Client = await getS3Client();
|
||||||
|
//
|
||||||
|
// try {
|
||||||
|
// await s3Client.statObject(bucketName, fileName);
|
||||||
|
// } catch (error) {
|
||||||
|
// return false;
|
||||||
|
// }
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
export async function checkFileExistsInBucket({
|
||||||
|
bucketName,
|
||||||
|
fileName,
|
||||||
|
}: {
|
||||||
|
bucketName: string;
|
||||||
|
fileName: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const s3 = await getS3Client();
|
||||||
try {
|
try {
|
||||||
await s3Client.statObject(bucketName, fileName);
|
const stat = await s3.statObject(bucketName, fileName);
|
||||||
} catch (error) {
|
return !!stat;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'NoSuchKey' || error.message?.includes('not found')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Instead of throwing, return false to prevent crashes
|
||||||
|
// console.error("Unexpected S3 statObject error:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,19 +206,34 @@ export async function createPublicBucket({bucketName}: { bucketName: string }) {
|
|||||||
export async function createPresignedUrlToDownload({
|
export async function createPresignedUrlToDownload({
|
||||||
bucketName,
|
bucketName,
|
||||||
fileName,
|
fileName,
|
||||||
expiry = 60 * 60, // 1 hour
|
expiry = 60 * 60,
|
||||||
}: {
|
}: {
|
||||||
bucketName: string;
|
bucketName: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
expiry?: number;
|
expiry?: number;
|
||||||
}) {
|
}) {
|
||||||
|
try {
|
||||||
const s3Client = await getS3Client();
|
const s3Client = await getS3Client();
|
||||||
|
|
||||||
// Optionally: ensure file exists
|
console.debug("Checking if file exists in bucket:", {bucketName, fileName});
|
||||||
|
|
||||||
const fileExists = await checkFileExistsInBucket({bucketName, fileName});
|
const fileExists = await checkFileExistsInBucket({bucketName, fileName});
|
||||||
|
|
||||||
if (!fileExists) {
|
if (!fileExists) {
|
||||||
|
console.warn("File does not exist:", {bucketName, fileName});
|
||||||
throw new Error("File does not exist in the bucket.");
|
throw new Error("File does not exist in the bucket.");
|
||||||
}
|
}
|
||||||
|
const presignedUrl = await s3Client.presignedGetObject(bucketName, fileName, expiry);
|
||||||
|
console.debug("Generated pre signed URL:", presignedUrl);
|
||||||
|
|
||||||
return await s3Client.presignedGetObject(bucketName, fileName, expiry);
|
return {url: presignedUrl};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error in createPreSignedUrlToDownload:", {
|
||||||
|
bucketName,
|
||||||
|
fileName,
|
||||||
|
errorMessage: err?.message,
|
||||||
|
// stack: err?.stack,
|
||||||
|
});
|
||||||
|
throw {error: err.message ?? "Unknown error"};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user