fix: data-loading (#364)

* chore: remove dead comment in database page

* fix(data-table): manualPagination support

* fix(hooks): useServerDataTable for server-side pagination

* fix(database):  paginated backup and restoration list actions

* fix(database): server-paginate backup list

* fix(database): server-paginate restoration list

* fix(database): switch tabs via History API instead of router.push

* fix(logs): load job logs on demand when the modal opens

* perf(database): slim metadata poll and drop eager backup/log loading

* fix(database): invalidate backups/restorations list keys on mutations for instant refresh

* fix(logs): disable log button when row has no logs

* fix(logs): fetch logs on button click, open modal already populated

* fix: loading log modal

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Charles GTE
2026-07-07 19:39:25 +02:00
committed by GitHub
co-authored by charles-gauthereau
parent 141abbae9d
commit 47d78943d8
20 changed files with 511 additions and 166 deletions
@@ -2,7 +2,7 @@ import { PageParams } from "@/types/next";
import { notFound, redirect } from "next/navigation";
import { Page } from "@/features/layout/components/page";
import { db } from "@/db";
import { eq, and, inArray } from "drizzle-orm";
import { eq, and, inArray, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { getOrganizationProjectDatabases } from "@/db/services/project";
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
@@ -49,38 +49,22 @@ export default async function RoutePage(
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: {
logs: true,
},
orderBy: (r, { desc }) => [desc(r.createdAt)],
});
//const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const availableBackups = backups.filter((b) => !b.deletedAt).length;
const availableBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
.from(drizzleDb.schemas.backup)
.where(
and(
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
isNull(drizzleDb.schemas.backup.deletedAt),
),
)
.then((rows) => rows.length);
const successfulBackups = await db
.select({ count: drizzleDb.schemas.backup.id })
@@ -93,6 +77,17 @@ export default async function RoutePage(
)
.then((rows) => rows.length);
const isAlreadyRestore = await db
.select({ count: drizzleDb.schemas.restoration.id })
.from(drizzleDb.schemas.restoration)
.where(
and(
eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
eq(drizzleDb.schemas.restoration.status, "waiting"),
),
)
.then((rows) => rows.length > 0);
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
@@ -109,8 +104,6 @@ export default async function RoutePage(
const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
//const isMember = activeMember?.role === "member";
return (
<Page>
<LogsModalProvider>
@@ -121,8 +114,6 @@ export default async function RoutePage(
database={dbItem}
databaseHealthLogs={databaseHealthLogs}
isAlreadyRestore={isAlreadyRestore}
restorations={restorations}
backups={backups}
totalBackups={totalBackups}
availableBackups={availableBackups}
successRate={successRate}
+38 -11
View File
@@ -9,6 +9,8 @@ import {
SortingState,
ColumnFiltersState,
getFilteredRowModel,
PaginationState,
OnChangeFn,
} from "@tanstack/react-table";
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table";
@@ -42,6 +44,13 @@ interface DataTableProps<TData, TValue> {
};
highlightRow?: (row: TData) => boolean;
selectedActions?: (rows: TData[]) => ReactNode;
manualPagination?: boolean;
rowCount?: number;
paginationState?: { pageIndex: number; pageSize: number };
onPaginationChange?: OnChangeFn<PaginationState>;
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
isFetching?: boolean;
}
@@ -56,10 +65,19 @@ export function DataTable<TData, TValue>({
emptyButton,
highlightRow,
selectedActions,
manualPagination = false,
rowCount,
paginationState,
onPaginationChange,
sorting: controlledSorting,
onSortingChange: controlledOnSortingChange,
isFetching = false,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const sorting = controlledSorting ?? internalSorting;
const setSorting = controlledOnSortingChange ?? setInternalSorting;
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
@@ -96,7 +114,7 @@ export function DataTable<TData, TValue>({
data,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
@@ -106,10 +124,15 @@ export function DataTable<TData, TValue>({
autoResetPageIndex: false,
autoResetExpanded: false,
enableRowSelection: true,
manualPagination,
manualSorting: manualPagination,
rowCount: manualPagination ? rowCount : undefined,
onPaginationChange: manualPagination ? onPaginationChange : undefined,
state: {
sorting,
columnFilters,
rowSelection,
...(manualPagination && paginationState ? { pagination: paginationState } : {}),
},
});
const router = useRouter();
@@ -194,18 +217,22 @@ export function DataTable<TData, TValue>({
selected.
</div>
)}
{enablePagination && table.getFilteredRowModel().rows.length >= 1 && (
{enablePagination && (manualPagination ? (rowCount ?? 0) > 0 : table.getFilteredRowModel().rows.length >= 1) && (
<TablePagination
table={table}
maxVisiblePages={paginationOptions?.pageVisible}
pageSizeOptions={(() => {
const rowCount = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCount);
const nextSize = allSizes.find((size) => size > rowCount);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()}
pageSizeOptions={
manualPagination
? paginationOptions.pageSize
: (() => {
const rowCountLocal = table.getFilteredRowModel().rows.length;
const allSizes = paginationOptions.pageSize.sort((a, b) => a - b);
const validSizes = allSizes.filter((size) => size <= rowCountLocal);
const nextSize = allSizes.find((size) => size > rowCountLocal);
if (nextSize) validSizes.push(nextSize);
return validSizes;
})()
}
className={paginationOptions.className}
/>
)}
+2
View File
@@ -142,10 +142,12 @@ export type BackupWith = Backup & {
restorations?: Restoration[] | null;
storages?: BackupStorage[] | null;
logs?: JobLog[] | null;
hasLogs?: boolean;
};
export type RestorationWith = Restoration & {
logs?: JobLog[] | null;
hasLogs?: boolean;
};
@@ -2,9 +2,8 @@
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {db} from "@/db";
import {and, eq, isNull} from "drizzle-orm";
import {and, count, eq, inArray, isNull} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
import {getOrganizationChannels} from "@/db/services/notification-channel";
import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import {getHealthLast12hLogs} from "@/db/services/healthcheck";
@@ -31,33 +30,50 @@ export const getDatabaseDataAction = userAction
}
});
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
},
orderBy: (b, {desc}) => [desc(b.createdAt)],
}) as BackupWith[];
const [totalRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(eq(drizzleDb.schemas.backup.databaseId, databaseId));
const totalBackups = totalRow?.count ?? 0;
const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, databaseId),
with: {
logs: true
},
orderBy: (r, {desc}) => [desc(r.createdAt)],
}) as RestorationWith[];
const [availableRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
isNull(drizzleDb.schemas.backup.deletedAt),
));
const availableBackups = availableRow?.count ?? 0;
const [successRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
eq(drizzleDb.schemas.backup.status, "success"),
));
const successfulBackups = successRow?.count ?? 0;
const totalBackups = backups.length;
const availableBackups = backups.filter(b => !b.deletedAt).length;
const successfulBackups = backups.filter(b => b.status === "success").length;
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
const [activeBackupRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.backup)
.where(and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
));
const isAlreadyBackup = (activeBackupRow?.count ?? 0) > 0;
const [activeRestoreRow] = await db
.select({count: count()})
.from(drizzleDb.schemas.restoration)
.where(and(
eq(drizzleDb.schemas.restoration.databaseId, databaseId),
eq(drizzleDb.schemas.restoration.status, "waiting"),
));
const isAlreadyRestore = (activeRestoreRow?.count ?? 0) > 0;
// @ts-ignore
let activeOrganizationChannels = [];
// @ts-ignore
@@ -71,11 +87,8 @@ export const getDatabaseDataAction = userAction
activeOrganizationStorageChannels = organizationStorageChannels.filter(channel => channel.enabled);
}
return {
database,
backups,
restorations,
// @ts-ignore
activeOrganizationChannels,
// @ts-ignore
@@ -85,6 +98,8 @@ export const getDatabaseDataAction = userAction
availableBackups,
successRate
},
health: database ? await getHealthLast12hLogs({ id: database.id }) : []
isAlreadyRestore,
isAlreadyBackup,
health: database ? await getHealthLast12hLogs({id: database.id}) : []
};
});
});
@@ -0,0 +1,120 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { db } from "@/db";
import { and, count, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { BackupWith, RestorationWith } from "@/db/schema/07_database";
import {
FetchBackupsSchema,
FetchRestorationsSchema,
} from "@/features/database/actions/backup-list.schema";
export const fetchBackupsAction = userAction
.schema(FetchBackupsSchema)
.action(async ({ parsedInput }) => {
const { databaseId, page, pageSize, filter } = parsedInput;
const offset = (page - 1) * pageSize;
const deletedCondition =
filter === "available"
? isNull(drizzleDb.schemas.backup.deletedAt)
: filter === "deleted"
? isNotNull(drizzleDb.schemas.backup.deletedAt)
: undefined;
const where = and(
eq(drizzleDb.schemas.backup.databaseId, databaseId),
deletedCondition,
);
const [totalResult] = await db
.select({ count: count() })
.from(drizzleDb.schemas.backup)
.where(where);
const data = (await db.query.backup.findMany({
where,
with: {
restorations: true,
storages: {
with: {
storageChannel: true,
},
},
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
limit: pageSize,
offset,
})) as BackupWith[];
const backupIds = data.map((b) => b.id);
const backupsWithLogs = backupIds.length
? await db
.select({ backupId: drizzleDb.schemas.jobLog.backupId })
.from(drizzleDb.schemas.jobLog)
.where(inArray(drizzleDb.schemas.jobLog.backupId, backupIds))
.groupBy(drizzleDb.schemas.jobLog.backupId)
: [];
const loggedBackupIds = new Set(backupsWithLogs.map((l) => l.backupId));
for (const b of data) {
b.hasLogs = loggedBackupIds.has(b.id);
}
const total = totalResult?.count ?? 0;
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
};
});
export const fetchRestorationsAction = userAction
.schema(FetchRestorationsSchema)
.action(async ({ parsedInput }) => {
const { databaseId, page, pageSize } = parsedInput;
const offset = (page - 1) * pageSize;
const where = eq(drizzleDb.schemas.restoration.databaseId, databaseId);
const [totalResult] = await db
.select({ count: count() })
.from(drizzleDb.schemas.restoration)
.where(where);
const data = (await db.query.restoration.findMany({
where,
orderBy: (r, { desc }) => [desc(r.createdAt)],
limit: pageSize,
offset,
})) as RestorationWith[];
const restorationIds = data.map((r) => r.id);
const restorationsWithLogs = restorationIds.length
? await db
.select({ restorationId: drizzleDb.schemas.jobLog.restorationId })
.from(drizzleDb.schemas.jobLog)
.where(inArray(drizzleDb.schemas.jobLog.restorationId, restorationIds))
.groupBy(drizzleDb.schemas.jobLog.restorationId)
: [];
const loggedRestorationIds = new Set(
restorationsWithLogs.map((l) => l.restorationId),
);
for (const r of data) {
r.hasLogs = loggedRestorationIds.has(r.id);
}
const total = totalResult?.count ?? 0;
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
},
};
});
@@ -0,0 +1,18 @@
import { z } from "zod";
export const FetchBackupsSchema = z.object({
databaseId: z.string(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
filter: z.enum(["available", "deleted"]).optional(),
sorting: z.any().optional(),
});
export type FetchBackupsSchema = z.infer<typeof FetchBackupsSchema>;
export const FetchRestorationsSchema = z.object({
databaseId: z.string(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(20),
sorting: z.any().optional(),
});
export type FetchRestorationsSchema = z.infer<typeof FetchRestorationsSchema>;
@@ -101,6 +101,12 @@ export const BackupActionsForm = ({
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["restorations", backup.databaseId],
});
router.refresh();
if (action === "download") {
const url = inner.value;
@@ -121,6 +127,9 @@ export const BackupActionsForm = ({
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", backup.databaseId],
});
router.refresh();
closeModal();
} else {
@@ -144,6 +153,9 @@ export const BackupActionsForm = ({
queryClient.invalidateQueries({
queryKey: ["database-data", backup.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", backup.databaseId],
});
router.refresh();
closeModal();
} else {
@@ -29,6 +29,9 @@ export const BackupButton = (props: BackupButtonProps) => {
queryClient.invalidateQueries({
queryKey: ["database-data", props.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["backups", props.databaseId],
});
router.refresh();
} else {
toast.error(backup?.serverError || "Failed to create backup.");
@@ -112,8 +112,7 @@ export function backupColumns(
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
return <LogsModalTrigger backupId={row.original.id} hasLogs={row.original.hasLogs}/>;
},
},
{
@@ -13,23 +13,22 @@ import {toast} from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
import {deleteBackupAction} from "@/features/database/actions/backup-actions.action";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useServerDataTable} from "@/hooks/use-server-data-table";
import {fetchBackupsAction} from "@/features/database/actions/backup-list.action";
import type {FetchBackupsSchema} from "@/features/database/actions/backup-list.schema";
type DatabaseBackupListProps = {
isAlreadyRestore: boolean;
settings: Setting;
database: DatabaseWith;
backups: BackupWith[];
activeMember: MemberWithUser
activeMember: MemberWithUser;
}
export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
const items = [
{label: "Deleted", value: "deleted"},
{label: "Available", value: "available"},
]
];
const [selectedFilters, setSelectedFilters] = useState<FilterItem[]>([items[1]]);
const [isActionsOpen, setIsActionsOpen] = useState(false);
@@ -39,20 +38,19 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
return backupColumns(props.isAlreadyRestore, props.settings, props.database, props.activeMember);
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
const filteredBackups = useMemo(() => {
if (!props.backups) return [];
return props.backups.filter(backup => {
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 filter = useMemo<"available" | "deleted" | undefined>(() => {
const values = selectedFilters.map((f) => f.value);
if (values.length === 1) return values[0] as "available" | "deleted";
return undefined;
}, [selectedFilters]);
const {data, tableProps, isFetching, isLoading} = useServerDataTable<BackupWith, FetchBackupsSchema>({
fetchAction: fetchBackupsAction,
queryKey: ["backups", props.database.id],
extraParams: {databaseId: props.database.id, filter},
initialPageSize: 20,
refetchInterval: 4000,
});
const handleSelectFilter = (item: FilterItem) => {
setSelectedFilters(prev =>
@@ -64,29 +62,26 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
const clearFilters = () => setSelectedFilters([]);
const mutationDeleteBackups = useMutation({
mutationFn: async (backups: Backup[]) => {
const results = await Promise.all(
backups.map(async (backup) => {
if (backup.deletedAt == null || backup.status == "ongoing") {
const backupDeleted = await deleteBackupAction({
databaseId: backup.databaseId,
backupId: backup.id,
})
});
return {
success: backupDeleted?.data?.success,
message: backupDeleted?.data?.success
? backupDeleted?.data?.actionSuccess?.message
// @ts-ignore
: restoration?.data?.actionError.message,
: "Failed to delete backup.",
};
}
return {
success: true,
message: `Already deleted this backup (ref: ${backup.id}).`,
}
};
})
);
results.forEach((result) => {
@@ -96,7 +91,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
toast.error(result.message);
}
});
queryClient.invalidateQueries({queryKey: ["database-data", props.database.id]});
queryClient.invalidateQueries({queryKey: ["backups", props.database.id]});
},
});
@@ -106,8 +101,10 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
<DataTable
enableSelect={!isMember}
columns={columns}
data={filteredBackups}
data={data}
enablePagination
isFetching={isFetching || isLoading}
{...tableProps}
selectedActions={(rows) => (
<>
<div className="flex justify-start md:justify-between gap-3 md:gap-0 items-center w-full ml-0">
@@ -156,9 +153,8 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
/>
</div>
</div>
</>
)}
/>
)
}
);
};
@@ -2,7 +2,7 @@
import {DatabaseBackupActionsModal} from "@/features/database/components/backup-actions-modal";
import {DatabaseTabs} from "@/features/database/components/database-tabs";
import {Setting} from "@/db/schema/01_setting";
import {BackupWith, DatabaseWith, RestorationWith} from "@/db/schema/07_database";
import {DatabaseWith} from "@/db/schema/07_database";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useBackupModal} from "@/features/database/components/backup-modal-context";
import {DatabaseKpi} from "@/features/database/components/database-kpi";
@@ -27,8 +27,6 @@ import {LogsModal} from "@/features/logs/components/logs-modal";
export type DatabaseContentProps = {
settings: Setting;
backups: BackupWith[];
restorations: RestorationWith[];
isAlreadyRestore: boolean;
database: DatabaseWith;
activeMember: MemberWithUser;
@@ -53,14 +51,11 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
return result?.data;
},
initialData: {
// TODO : to be patched
// @ts-ignore
database: {
...props.database,
project: props.database.project ?? null,
},
backups: props.backups,
restorations: props.restorations,
activeOrganizationChannels: props.activeOrganizationChannels,
activeOrganizationStorageChannels:
props.activeOrganizationStorageChannels,
@@ -69,16 +64,16 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
availableBackups: props.availableBackups,
successRate: props.successRate,
},
isAlreadyRestore: props.isAlreadyRestore,
isAlreadyBackup: false,
health: props.databaseHealthLogs
},
staleTime: 0,
gcTime: 0,
refetchInterval: 1000,
refetchInterval: 4000,
});
const database = data?.database ?? props.database;
const backups = data?.backups ?? props.backups;
const restorations = data?.restorations ?? props.restorations;
const activeOrganizationChannels =
data?.activeOrganizationChannels ?? props.activeOrganizationChannels;
const activeOrganizationStorageChannels =
@@ -90,10 +85,8 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
successRate: props.successRate,
};
const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const isAlreadyBackup = backups.some(
(b) => b.status === "waiting" || b.status === "ongoing",
);
const isAlreadyRestore = data?.isAlreadyRestore ?? props.isAlreadyRestore;
const isAlreadyBackup = data?.isAlreadyBackup ?? false;
const isMember = props.activeMember.role === "member";
@@ -166,8 +159,6 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
settings={props.settings}
database={database}
isAlreadyRestore={isAlreadyRestore}
backups={backups}
restorations={restorations}
/>
</PageContent>
</>
@@ -4,18 +4,19 @@ import {restoreColumns} from "@/features/database/components/restore-columns";
import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {ButtonWithLoading} from "@/components/common/button-with-loading";
import {MoreHorizontal, Trash2} from "lucide-react";
import {Restoration} from "@/db/schema/07_database";
import {Restoration, RestorationWith} from "@/db/schema/07_database";
import {useMutation, useQueryClient} from "@tanstack/react-query";
import {deleteRestoreAction} from "@/features/database/actions/restore.action";
import {toast} from "sonner";
import {MemberWithUser} from "@/db/schema/03_organization";
import {useMemo, useState} from "react";
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
import {useServerDataTable} from "@/hooks/use-server-data-table";
import {fetchRestorationsAction} from "@/features/database/actions/backup-list.action";
import type {FetchRestorationsSchema} from "@/features/database/actions/backup-list.schema";
type DatabaseRestoreListProps = {
isAlreadyRestore: boolean;
restorations: Restoration[];
activeMember: MemberWithUser;
databaseId: string;
}
@@ -28,6 +29,14 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
return restoreColumns(props.isAlreadyRestore, props.activeMember);
}, [props.isAlreadyRestore, props.activeMember.id, props.activeMember.role]);
const {data, tableProps, isFetching, isLoading} = useServerDataTable<RestorationWith, FetchRestorationsSchema>({
fetchAction: fetchRestorationsAction,
queryKey: ["restorations", props.databaseId],
extraParams: {databaseId: props.databaseId},
initialPageSize: 20,
refetchInterval: 4000,
});
const mutationDeleteRestorations = useMutation({
mutationFn: async (restorations: Restoration[]) => {
const results = await Promise.all(
@@ -42,8 +51,6 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
// @ts-ignore
: restorationDeleted?.data?.actionError.message,
};
})
);
results.forEach((result) => {
@@ -53,18 +60,19 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
toast.error(result.message);
}
});
queryClient.invalidateQueries({queryKey: ["database-data", props.databaseId]});
queryClient.invalidateQueries({queryKey: ["restorations", props.databaseId]});
},
});
const isMember = props.activeMember.role === "member";
return (
<DataTable
enableSelect={!isMember}
columns={columns}
data={props.restorations}
data={data}
enablePagination
isFetching={isFetching || isLoading}
{...tableProps}
selectedActions={(rows) => (
<>
{!isMember && (
@@ -84,7 +92,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
>Actions</ButtonWithLoading>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<ButtonWithConfirm
<ButtonWithConfirm
onConfirm={() => {
mutationDeleteRestorations.mutate(rows)
setIsActionsOpen(false);
@@ -110,5 +118,5 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
</>
)}
/>
)
}
);
};
@@ -2,8 +2,8 @@
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {useEffect, useState} from "react";
import {useRouter, useSearchParams} from "next/navigation";
import {BackupWith, DatabaseWith, Restoration} from "@/db/schema/07_database";
import {useSearchParams} from "next/navigation";
import {DatabaseWith} from "@/db/schema/07_database";
import {Setting} from "@/db/schema/01_setting";
import {DatabaseBackupList} from "@/features/database/components/database-backup-list";
import {DatabaseRestoreList} from "@/features/database/components/database-restore-list";
@@ -11,8 +11,6 @@ import {MemberWithUser} from "@/db/schema/03_organization";
export type DatabaseTabsProps = {
settings: Setting,
backups: BackupWith[],
restorations: Restoration[],
isAlreadyRestore: boolean,
database: DatabaseWith,
activeMember: MemberWithUser
@@ -21,24 +19,27 @@ export type DatabaseTabsProps = {
export const backupOnly = ["redis", "valkey"];
export const DatabaseTabs = (props: DatabaseTabsProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "backup");
useEffect(() => {
const newTab = searchParams.get("tab") ?? "backup";
// eslint-disable-next-line react-hooks/set-state-in-effect
setTab(newTab);
}, [searchParams]);
const onPopState = () => {
const params = new URLSearchParams(window.location.search);
setTab(params.get("tab") ?? "backup");
};
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const handleChangeTab = (value: string) => {
router.push(`?tab=${value}`);
setTab(value);
const params = new URLSearchParams(window.location.search);
params.set("tab", value);
window.history.pushState(null, "", `?${params.toString()}`);
};
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type)
const isBackupOnly = backupOnly.some((type) => props.database.dbms === type);
return (
<>
@@ -47,7 +48,6 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
isAlreadyRestore={props.isAlreadyRestore}
settings={props.settings}
database={props.database}
backups={props.backups}
activeMember={props.activeMember}
/>
:
@@ -61,22 +61,18 @@ export const DatabaseTabs = (props: DatabaseTabsProps) => {
isAlreadyRestore={props.isAlreadyRestore}
settings={props.settings}
database={props.database}
backups={props.backups}
activeMember={props.activeMember}
/>
</TabsContent>
<TabsContent className="h-full justify-between" value="restore">
<DatabaseRestoreList
isAlreadyRestore={props.isAlreadyRestore}
restorations={props.restorations}
activeMember={props.activeMember}
databaseId={props.database.id}
/>
</TabsContent>
</Tabs>
}
</>
);
};
@@ -41,6 +41,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
toast.success(inner.actionSuccess?.message);
onSuccessAction?.()
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
queryClient.invalidateQueries({queryKey: ["backups", database.id]});
router.refresh();
} else {
toast.error(inner?.actionError?.message);
@@ -50,6 +51,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
toast.error("An error occurred while upload in the backup");
} finally {
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
queryClient.invalidateQueries({queryKey: ["backups", database.id]});
setIsProcessing(false);
}
},
@@ -62,8 +62,7 @@ export function restoreColumns(
accessorKey: "logs",
header: "Logs",
cell: ({row}) => {
const logs = row.original.logs ?? [];
return <LogsModalTrigger logs={logs}/>;
return <LogsModalTrigger restorationId={row.original.id} hasLogs={row.original.hasLogs}/>;
},
},
{
@@ -86,6 +85,9 @@ export function restoreColumns(
queryClient.invalidateQueries({
queryKey: ["database-data", rowData.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["restorations", rowData.databaseId],
});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
@@ -105,6 +107,9 @@ export function restoreColumns(
queryClient.invalidateQueries({
queryKey: ["database-data", rowData.databaseId],
});
queryClient.invalidateQueries({
queryKey: ["restorations", rowData.databaseId],
});
} else {
// @ts-ignore
toast.error(restoration.data.actionError.message);
@@ -0,0 +1,32 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { JobLog } from "@/db/schema/17_job-log";
export const fetchJobLogsAction = userAction
.schema(
z.object({
backupId: z.string().optional(),
restorationId: z.string().optional(),
}),
)
.action(async ({ parsedInput }): Promise<JobLog[]> => {
const { backupId, restorationId } = parsedInput;
if (backupId) {
return (await db.query.jobLog.findMany({
where: eq(drizzleDb.schemas.jobLog.backupId, backupId),
orderBy: (l, { asc }) => [asc(l.loggedAt)],
})) as JobLog[];
}
if (restorationId) {
return (await db.query.jobLog.findMany({
where: eq(drizzleDb.schemas.jobLog.restorationId, restorationId),
orderBy: (l, { asc }) => [asc(l.loggedAt)],
})) as JobLog[];
}
return [];
});
@@ -17,7 +17,7 @@ export const LogsModalProvider = ({children}: { children: ReactNode }) => {
const [logs, setLogs] = useState<JobLog[]>([]);
const openModal = (newLogs: JobLog[]) => {
setLogs(newLogs);
setLogs(newLogs ?? []);
setOpen(true);
};
@@ -1,19 +1,35 @@
import {FileText} from "lucide-react";
import {JobLog} from "@/db/schema/17_job-log";
import {FileText, Loader2} from "lucide-react";
import {useState} from "react";
import {toast} from "sonner";
import {Button} from "@/components/ui/button";
import {useLogsModal} from "@/features/logs/components/logs-modal-context";
import {fetchJobLogsAction} from "@/features/logs/actions/job-logs.action";
export type LogsModalTriggerProps = {
logs: JobLog[]
backupId?: string;
restorationId?: string;
hasLogs?: boolean;
}
export const LogsModalTrigger = ({logs}: LogsModalTriggerProps) => {
export const LogsModalTrigger = ({backupId, restorationId, hasLogs}: LogsModalTriggerProps) => {
const {openModal} = useLogsModal();
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
const result = await fetchJobLogsAction({backupId, restorationId});
openModal(result?.data ?? []);
} catch {
toast.error("Failed to load logs.");
} finally {
setIsLoading(false);
}
};
return (
<Button disabled={logs.length == 0} variant="outline" size="sm" onClick={()=> {
openModal(logs);
}}>
<FileText />
<Button disabled={!hasLogs || isLoading} variant="outline" size="sm" onClick={handleClick}>
{isLoading ? <Loader2 className="animate-spin"/> : <FileText/>}
</Button>
)
}
);
};
+11 -5
View File
@@ -29,11 +29,17 @@ export const LogsModal = () => {
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
{logs.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No logs available
</div>
) : (
<div>
{logs.map((entry: JobLog) => (
<LogRow key={entry.id} entry={entry} />
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
+106
View File
@@ -0,0 +1,106 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { PaginationState, SortingState } from "@tanstack/react-table";
import { useEffect, useState } from "react";
interface PaginatedResult<TData> {
data: TData[];
meta: {
total: number;
page: number;
pageSize: number;
totalPages: number;
[key: string]: any;
};
}
interface UseServerDataTableProps<TData, TParams> {
fetchAction: any;
queryKey: any[];
extraParams?: Partial<TParams>;
initialPageSize?: number;
initialSorting?: SortingState;
enabled?: boolean;
refetchInterval?: number | false;
}
const EMPTY_ARRAY: any[] = [];
export function useServerDataTable<TData, TParams>(
props: UseServerDataTableProps<TData, TParams>,
) {
const {
fetchAction,
queryKey,
extraParams,
initialPageSize = 20,
initialSorting = [],
enabled = true,
refetchInterval = false,
} = props;
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: initialPageSize,
});
const [sorting, setSorting] = useState<SortingState>(initialSorting);
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, [sorting]);
useEffect(() => {
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}, [JSON.stringify(extraParams)]);
const query = useQuery({
queryKey: [
...queryKey,
pagination.pageIndex,
pagination.pageSize,
sorting,
JSON.stringify(extraParams),
],
queryFn: async () => {
const response = await fetchAction({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sorting,
...extraParams,
} as any);
const result = response?.data as PaginatedResult<TData> | undefined;
if (!result || !Array.isArray(result.data)) {
throw new Error("Failed to fetch paginated data");
}
return result;
},
placeholderData: keepPreviousData,
enabled,
gcTime: 2 * 60 * 1000,
staleTime: 30 * 1000,
refetchInterval,
refetchOnWindowFocus: true,
retry: 1,
});
return {
data: (query.data?.data as TData[]) || EMPTY_ARRAY,
rowCount: query.data?.meta?.total || 0,
pagination,
onPaginationChange: setPagination,
sorting,
onSortingChange: setSorting,
tableProps: {
manualPagination: true as const,
rowCount: query.data?.meta?.total || 0,
paginationState: pagination,
onPaginationChange: setPagination,
sorting,
onSortingChange: setSorting,
},
meta: query.data?.meta,
isLoading: query.isPending,
isFetching: query.isFetching,
refetch: query.refetch,
};
}