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