diff --git a/src/components/wrappers/common/table/data-table.tsx b/src/components/wrappers/common/table/data-table.tsx index c87568c1..ad83010f 100644 --- a/src/components/wrappers/common/table/data-table.tsx +++ b/src/components/wrappers/common/table/data-table.tsx @@ -148,7 +148,8 @@ export function DataTable({ table.getRowModel().rows.map((row) => ( + data-state={row.getIsSelected() && "selected"} + > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/src/components/wrappers/common/table/filters.tsx b/src/components/wrappers/common/table/filters.tsx new file mode 100644 index 00000000..ea0928d2 --- /dev/null +++ b/src/components/wrappers/common/table/filters.tsx @@ -0,0 +1,66 @@ +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; +import {Toggle} from "@/components/ui/toggle"; +import {CheckIcon, Filter, RefreshCcw} from "lucide-react"; +import {Badge} from "@/components/ui/badge"; + +export type FilterItem = { + label: string, + value: string +} + +type FiltersDropdownProps = { + items: FilterItem[]; + selectedItems: FilterItem[]; + onSelect: (item: FilterItem) => void; + clearFilters: () => void; +} + +export const FiltersDropdown = ({items, selectedItems, onSelect, clearFilters}: FiltersDropdownProps) => { + + return ( + + + +
+ + + + {selectedItems.length > 0 && ( + + {selectedItems.length} + + )} +
+
+ + {items.map((item, index) => { + const isSelected = selectedItems.some(f => f.value === item.value); + return ( + { + e.preventDefault(); + onSelect(item); + }} + > + {item.label} + {isSelected && } + + ) + })} + { + e.preventDefault(); + clearFilters(); + }} + > + + Clear filters + + +
+ ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx b/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx new file mode 100644 index 00000000..657d334f --- /dev/null +++ b/src/components/wrappers/dashboard/projects/database/database-backup-list.tsx @@ -0,0 +1,146 @@ +"use client" +import {backupColumns} from "@/features/dashboard/backup/columns"; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; +import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; +import {MoreHorizontal, Trash2} from "lucide-react"; +import {FilterItem, FiltersDropdown} from "@/components/wrappers/common/table/filters"; +import {DataTable} from "@/components/wrappers/common/table/data-table"; +import {useMemo, useState} from "react"; +import {Backup, DatabaseWith} from "@/db/schema/07_database"; +import {Setting} from "@/db/schema/01_setting"; +import {useMutation} from "@tanstack/react-query"; +import {deleteBackupAction} from "@/features/dashboard/restore/restore.action"; +import {toast} from "sonner"; +import {useRouter} from "next/navigation"; + + +type DatabaseBackupListProps = { + isAlreadyRestore: boolean; + settings: Setting; + database: DatabaseWith; + backups: Backup[]; +} + + +export const DatabaseBackupList = (props: DatabaseBackupListProps) => { + + const items = [ + {label: "Deleted", value: "deleted"}, + {label: "Available", value: "available"}, + ] + + const [selectedFilters, setSelectedFilters] = useState([items[1]]); + const router = useRouter(); + + const filteredBackups = useMemo(() => { + if (!props.backups) return []; + + return props.backups.filter(backup => { + + // --- Status Filter --- + if (selectedFilters.length > 0) { + const selectedValues = selectedFilters.map(f => f.value); + const status = backup.deletedAt != null ? "deleted" : "available"; + if (!selectedValues.includes(status)) return false; + } + + return true; + }); + }, [props.backups, selectedFilters]); + + + const handleSelectFilter = (item: FilterItem) => { + setSelectedFilters(prev => + prev.some(f => f.value === item.value) + ? prev.filter(f => f.value !== item.value) + : [...prev, item] + ); + }; + + const clearFilters = () => setSelectedFilters([]); + + + const mutationDeleteBackups = useMutation({ + mutationFn: async (backups: Backup[]) => { + const results = await Promise.all( + backups.map(async (backup) => { + if (backup.deletedAt == null) { + const backupDeleted = await deleteBackupAction({ + backupId: backup.id, + databaseId: backup.databaseId, + file: backup.file!, + projectSlug: props.database?.project?.slug! + }); + return { + success: backupDeleted?.data?.success, + message: backupDeleted?.data?.success + ? backupDeleted?.data?.actionSuccess?.message + // @ts-ignore + : restoration?.data?.actionError.message, + }; + } + return { + success: true, + message: `Already deleted this backup (ref: ${backup.id}).`, + } + }) + ); + results.forEach((result) => { + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + }); + router.refresh(); + }, + }); + + + return ( + ( +
+
+ + + { + + }} + disabled={rows.length === 0 || mutationDeleteBackups.isPending} + icon={} + isPending={mutationDeleteBackups.isPending} + size="sm" + /> + + + { + console.log("Deleting rows:", rows) + await mutationDeleteBackups.mutateAsync(rows) + }} + className="text-red-600 focus:text-red-700" + > + + Delete Selected + + + + +
+
+ )} + /> + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/projects/database/database-restore-list.tsx b/src/components/wrappers/dashboard/projects/database/database-restore-list.tsx new file mode 100644 index 00000000..a9378dfa --- /dev/null +++ b/src/components/wrappers/dashboard/projects/database/database-restore-list.tsx @@ -0,0 +1,87 @@ +"use client" +import {DataTable} from "@/components/wrappers/common/table/data-table"; +import {restoreColumns} from "@/features/dashboard/restore/columns"; +import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu"; +import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading"; +import {MoreHorizontal, Trash2} from "lucide-react"; +import {Restoration} from "@/db/schema/07_database"; +import {useMutation} from "@tanstack/react-query"; +import {deleteRestoreAction} from "@/features/dashboard/restore/restore.action"; +import {toast} from "sonner"; +import {useRouter} from "next/navigation"; + + +type DatabaseRestoreListProps = { + isAlreadyRestore: boolean; + restorations: Restoration[]; +} + +export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => { + const router = useRouter(); + + 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 ( + ( + + + { + }} + disabled={rows.length === 0 || mutationDeleteRestorations.isPending} + icon={} + isPending={mutationDeleteRestorations.isPending} + size="sm" + /> + + + { + await mutationDeleteRestorations.mutateAsync(rows) + }} + disabled={props.isAlreadyRestore} + className="text-red-600 focus:text-red-700" + > + + Delete Selected + + + + )} + /> + ) +} \ No newline at end of file diff --git a/src/components/wrappers/dashboard/projects/database/database-tabs.tsx b/src/components/wrappers/dashboard/projects/database/database-tabs.tsx index 42a7168f..f623d8d7 100644 --- a/src/components/wrappers/dashboard/projects/database/database-tabs.tsx +++ b/src/components/wrappers/dashboard/projects/database/database-tabs.tsx @@ -4,17 +4,10 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"; import {useEffect, useState} from "react"; import {useRouter, useSearchParams} from "next/navigation"; import {eventUpdate} from "@/types/events"; -import {backupColumns} from "@/features/dashboard/backup/columns"; -import {restoreColumns} from "@/features/dashboard/restore/columns"; -import {DataTable} from "@/components/wrappers/common/table/data-table"; import {Backup, Database, DatabaseWith, Restoration} from "@/db/schema/07_database"; import {Setting} from "@/db/schema/01_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"; +import {DatabaseBackupList} from "@/components/wrappers/dashboard/projects/database/database-backup-list"; +import {DatabaseRestoreList} from "@/components/wrappers/dashboard/projects/database/database-restore-list"; export type DatabaseTabsProps = { settings: Setting @@ -44,7 +37,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => { }; }, []); - useEffect(() => { const newTab = searchParams.get("tab") ?? "backup"; setTab(newTab); @@ -54,65 +46,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => { 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, - file: backup.file!, - projectSlug: props.database?.project?.slug! - }); - 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 ( @@ -120,83 +53,17 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => { Restoration - ( - - - { - - }} - disabled={rows.length === 0 || mutationDeleteBackups.isPending} - icon={} - isPending={mutationDeleteBackups.isPending} - size="sm" - /> - - - {/* {*/} - {/* console.log("Deleting rows:", rows)*/} - {/* }}*/} - {/*>*/} - {/* */} - {/* Download Selected*/} - {/**/} - {/**/} - { - console.log("Deleting rows:", rows) - await mutationDeleteBackups.mutateAsync(rows) - }} - className="text-red-600 focus:text-red-700" - > - - Delete Selected - - - - )} + - ( - - - { - }} - disabled={rows.length === 0 || mutationDeleteRestorations.isPending} - icon={} - isPending={mutationDeleteRestorations.isPending} - size="sm" - /> - - - { - await mutationDeleteRestorations.mutateAsync(rows) - }} - disabled={props.isAlreadyRestore} - className="text-red-600 focus:text-red-700" - > - - Delete Selected - - - - )} + diff --git a/src/features/dashboard/backup/columns.tsx b/src/features/dashboard/backup/columns.tsx index 8fa466ce..dae1463d 100644 --- a/src/features/dashboard/backup/columns.tsx +++ b/src/features/dashboard/backup/columns.tsx @@ -28,23 +28,43 @@ import {Setting} from "@/db/schema/01_setting"; import {SafeActionResult} from "next-safe-action"; import {ZodString} from "zod"; import {ServerActionResult} from "@/types/action-type"; +import {cn} from "@/lib/utils"; +import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; export function backupColumns(isAlreadyRestore: boolean, settings: Setting, database: DatabaseWith): ColumnDef[] { return [ - + { + id: "availability", + cell: ({row}) => { + const colorStatus = row.original.deletedAt != null ? "bg-red-400 border-red-600" : "bg-green-400 border-green-600"; + return ( + + + +
+ + {row.original.deletedAt != null && ( + +

{formatFrenchDate(row.getValue("deletedAt"))}

+
+ )} + + + ) + }, + }, { accessorKey: "id", header: "Reference", }, - { - accessorKey: "deletedAt", - header: "Deleted At", - cell: ({row}) => { - - return row.original.deletedAt ? formatFrenchDate(row.getValue("deletedAt")) : "" - }, - }, + // { + // accessorKey: "deletedAt", + // header: "Deleted At", + // cell: ({row}) => { + // return row.original.deletedAt ? formatFrenchDate(row.getValue("deletedAt")) : "-" + // }, + // }, { accessorKey: "createdAt", header: "Created At", diff --git a/src/features/dashboard/restore/restore.action.ts b/src/features/dashboard/restore/restore.action.ts index 7f6c6453..f4ffac86 100644 --- a/src/features/dashboard/restore/restore.action.ts +++ b/src/features/dashboard/restore/restore.action.ts @@ -15,6 +15,7 @@ import { uploadS3Private } from "@/features/upload/private/upload.action"; import {env} from "@/env.mjs"; +import {withUpdatedAt} from "@/db/utils"; export const deleteRestoreAction = userAction .schema( @@ -63,6 +64,7 @@ export const deleteBackupAction = userAction .action(async ({parsedInput}): Promise> => { try { + const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); if (!settings) { return { @@ -76,6 +78,12 @@ export const deleteBackupAction = userAction }; } + await db + .update(drizzleDb.schemas.backup) + .set(withUpdatedAt({ + deletedAt: new Date(), + })) + .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) let success: boolean, message: string; @@ -98,36 +106,79 @@ export const deleteBackupAction = userAction }; } - await db - .delete(drizzleDb.schemas.backup) - .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) - .execute(); - - const backupExists = await db - .select() - .from(drizzleDb.schemas.backup) - .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) - .execute(); + return { + success: true, + actionSuccess: { + message: `Backup deleted successfully (ref: ${parsedInput.backupId}).`, + }, + }; - if (backupExists.length === 0) { - return { - success: true, - actionSuccess: { - message: "Backup deleted successfully.", - }, - }; - } else { - return { - success: false, - actionError: { - message: "Backup not found or already deleted.", - status: 404, - cause: "Backup could not be deleted (from database or remote storage).", - messageParams: {message: "Error deleting the backup"}, - }, - }; - } + // const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); + // if (!settings) { + // return { + // success: false, + // actionError: { + // message: "No settings found.", + // status: 404, + // cause: "No settings found.", + // messageParams: {message: "Error deleting the backup"}, + // }, + // }; + // } + // + // + // let success: boolean, message: string; + // + // const result = + // settings.storage === "local" + // ? await deleteLocalPrivate(parsedInput.file) + // : await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!); + // + // ({success, message} = result); + // + // if (!success) { + // return { + // success: false, + // actionError: { + // message: message, + // status: 404, + // cause: "Unable to delete backup from storage", + // messageParams: {message: "Error deleting the backup"}, + // }, + // }; + // } + // + // await db + // .delete(drizzleDb.schemas.backup) + // .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) + // .execute(); + // + // const backupExists = await db + // .select() + // .from(drizzleDb.schemas.backup) + // .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId))) + // .execute(); + // + // + // if (backupExists.length === 0) { + // return { + // success: true, + // actionSuccess: { + // message: "Backup deleted successfully.", + // }, + // }; + // } else { + // return { + // success: false, + // actionError: { + // message: "Backup not found or already deleted.", + // status: 404, + // cause: "Backup could not be deleted (from database or remote storage).", + // messageParams: {message: "Error deleting the backup"}, + // }, + // }; + // } } catch (error) { return { success: false, diff --git a/src/lib/tasks/database/index.ts b/src/lib/tasks/database/index.ts index 84fa5392..7e3310a9 100644 --- a/src/lib/tasks/database/index.ts +++ b/src/lib/tasks/database/index.ts @@ -5,6 +5,7 @@ import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf"; import {retentionPolicy} from "@/db/schema/07_database"; import {eq, isNull} from "drizzle-orm"; import * as drizzleDb from "@/db"; +import {eventEmitter} from "../../../../app/api/events/route"; export const retentionCleanTask = async () => { @@ -54,4 +55,5 @@ export async function enforceRetention( }); break; } + eventEmitter.emit('modification', {update: true}); } diff --git a/src/lib/tasks/database/retention-days.ts b/src/lib/tasks/database/retention-days.ts index 78fee581..a189fce9 100644 --- a/src/lib/tasks/database/retention-days.ts +++ b/src/lib/tasks/database/retention-days.ts @@ -1,5 +1,5 @@ import {db} from "@/db"; -import {eq, lt, and, desc} from "drizzle-orm"; +import {eq, lt, and, desc, isNull} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; @@ -9,7 +9,8 @@ export async function enforceRetentionDays(databaseId: string, days: number) { const expiredBackups = await db.query.backup.findMany({ where: and( eq(drizzleDb.schemas.backup.databaseId, databaseId), - lt(drizzleDb.schemas.backup.createdAt, cutoff) + lt(drizzleDb.schemas.backup.createdAt, cutoff), + isNull(drizzleDb.schemas.backup.deletedAt) ), with: { database: { diff --git a/src/lib/tasks/database/retention-gsf.ts b/src/lib/tasks/database/retention-gsf.ts index e7ce5450..96204d26 100644 --- a/src/lib/tasks/database/retention-gsf.ts +++ b/src/lib/tasks/database/retention-gsf.ts @@ -1,6 +1,6 @@ import {db} from "@/db"; import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns"; -import {eq, desc} from "drizzle-orm"; +import {eq, desc, isNull, and} from "drizzle-orm"; import * as drizzleDb from "@/db"; import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete"; @@ -11,7 +11,7 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: { yearly: number; }) { const backups = await db.query.backup.findMany({ - where: eq(drizzleDb.schemas.backup.databaseId, databaseId), + where: and(eq(drizzleDb.schemas.backup.databaseId, databaseId), isNull(drizzleDb.schemas.backup.deletedAt)), orderBy: desc(drizzleDb.schemas.backup.createdAt), with: { database: {