mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Merge pull request #366 from Portabase/fix/storage-encryption
fix: storage-encryption
This commit is contained in:
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.22.2
|
||||
version: 1.22.4
|
||||
date-released: '2026-03-02'
|
||||
|
||||
+22
-31
@@ -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}
|
||||
|
||||
@@ -12,12 +12,20 @@ import {logger} from "@/lib/logger";
|
||||
import {isUUID} from "@/utils/text";
|
||||
import {StorageInput} from "@/features/storages/types";
|
||||
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
||||
import {getMasterServerKeyContent} from "@/features/agents/actions/keys.action";
|
||||
import {encryptStorages, isAgentVersionAtLeast, MIN_AGENT_VERSION_STORAGE_ENC} from "@/utils/status-crypto";
|
||||
|
||||
const log = logger.child({module: "api/agent/status/helpers"});
|
||||
|
||||
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) {
|
||||
const databasesResponse = [];
|
||||
|
||||
const masterKeyResult = await getMasterServerKeyContent();
|
||||
const masterKey = Buffer.isBuffer(masterKeyResult) ? masterKeyResult : null;
|
||||
if (!masterKey) {
|
||||
log.error({name: "handleDatabases"}, "Master key unavailable; storages will be sent in plaintext");
|
||||
}
|
||||
|
||||
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null, backupSize: number | null) => ({
|
||||
generatedId: database.agentDatabaseId,
|
||||
dbms: database.dbms,
|
||||
@@ -89,7 +97,9 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null));
|
||||
const entry = formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null, null);
|
||||
applyStorageEncryption(entry, body.version, masterKey, agent.id);
|
||||
databasesResponse.push(entry);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -209,7 +219,9 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
}
|
||||
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
|
||||
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize));
|
||||
const entry = formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta, backupSize);
|
||||
applyStorageEncryption(entry, body.version, masterKey, agent.id);
|
||||
databasesResponse.push(entry);
|
||||
}
|
||||
}
|
||||
return databasesResponse;
|
||||
@@ -277,3 +289,38 @@ async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatab
|
||||
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||
}
|
||||
|
||||
function applyStorageEncryption(
|
||||
entry: Record<string, any>,
|
||||
version: string | undefined,
|
||||
masterKey: Buffer | null,
|
||||
agentId: string,
|
||||
): void {
|
||||
if (!masterKey) return;
|
||||
if (!Array.isArray(entry.storages) || entry.storages.length === 0) return;
|
||||
if (!isAgentVersionAtLeast(version, MIN_AGENT_VERSION_STORAGE_ENC)) {
|
||||
log.warn(
|
||||
{
|
||||
name: "applyStorageEncryption",
|
||||
agentId,
|
||||
agentVersion: version ?? "unknown",
|
||||
requiredVersion: MIN_AGENT_VERSION_STORAGE_ENC,
|
||||
},
|
||||
`\n============================================================\n` +
|
||||
` ⚠️ OUTDATED AGENT — STORAGE CREDENTIALS SENT UNENCRYPTED\n` +
|
||||
` Agent ${agentId} reports v${version ?? "unknown"} (< required v${MIN_AGENT_VERSION_STORAGE_ENC}).\n` +
|
||||
` Update this agent to v${MIN_AGENT_VERSION_STORAGE_ENC}+ to encrypt storage credentials in transit.\n` +
|
||||
`============================================================`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ciphertext = encryptStorages(entry.storages, masterKey);
|
||||
entry.storages_ciphertext = ciphertext;
|
||||
entry.storages_encrypted = true;
|
||||
entry.storages = [];
|
||||
} catch (err) {
|
||||
log.error({error: err, name: "applyStorageEncryption"}, "Storage encryption failed; sending plaintext");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.22.2",
|
||||
"version": "1.22.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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}/>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -12,6 +12,7 @@ export const AdvancedCronSelect = ({
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
onValidityChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -20,11 +21,21 @@ export const AdvancedCronSelect = ({
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onValidityChange?: (valid: boolean) => void;
|
||||
}) => {
|
||||
const [isAdvanced, setIsAdvanced] = useState(false);
|
||||
const [customValue, setCustomValue] = useState(defaultValue || value);
|
||||
const [customValue, setCustomValue] = useState(value || defaultValue);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCustomValue(value || defaultValue);
|
||||
}, [value, defaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const valid = !isAdvanced || isValidCronPart(type, customValue);
|
||||
onValidityChange?.(valid);
|
||||
}, [isAdvanced, customValue, type, onValidityChange]);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (customValue.trim() === "") {
|
||||
setIsAdvanced(false);
|
||||
@@ -46,7 +57,7 @@ export const AdvancedCronSelect = ({
|
||||
// @ts-ignore
|
||||
id={id}
|
||||
className="col-span-4"
|
||||
value={defaultValue}
|
||||
value={value}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === "advanced") {
|
||||
setIsAdvanced(true);
|
||||
|
||||
@@ -2,7 +2,7 @@ import {AdvancedCronSelect} from "@/features/database/components/cron-advanced-s
|
||||
import {updateDatabaseBackupPolicyAction} from "@/features/database/actions/cron.action";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {useCallback, useState} from "react";
|
||||
import {toast} from "sonner";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
@@ -14,10 +14,17 @@ export type CronInputProps = {
|
||||
};
|
||||
|
||||
export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
||||
const [cron, setCron] = useState<string>(database.backupPolicy ?? "* * * * *");
|
||||
const [cron, setCron] = useState<string>(database.backupPolicy ?? "0 0 * * *");
|
||||
const [fieldValidity, setFieldValidity] = useState<Record<string, boolean>>({});
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const setFieldValid = useCallback((id: string) => (valid: boolean) => {
|
||||
setFieldValidity((prev) => (prev[id] === valid ? prev : {...prev, [id]: valid}));
|
||||
}, []);
|
||||
|
||||
const hasInvalidField = Object.values(fieldValidity).some((valid) => !valid);
|
||||
|
||||
const updateBackupPolicy = useMutation({
|
||||
mutationFn: (value: string) => updateDatabaseBackupPolicyAction({databaseId: database.id, backupPolicy: value}),
|
||||
onSuccess: () => {
|
||||
@@ -48,38 +55,42 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
||||
<AdvancedCronSelect
|
||||
id="minute"
|
||||
label="Minute"
|
||||
options={Array.from({length: 60}, (_, i) => String(i).padStart(2, "0"))}
|
||||
options={Array.from({length: 60}, (_, i) => String(i))}
|
||||
type="minute"
|
||||
value={cron.split(" ")[0]}
|
||||
defaultValue={cron.split(" ")[0]}
|
||||
onValueChange={(value) => handleChangeCron("minute", value)}
|
||||
onValidityChange={setFieldValid("minute")}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="hour"
|
||||
label="Hour"
|
||||
options={Array.from({length: 24}, (_, i) => String(i).padStart(2, "0"))}
|
||||
options={Array.from({length: 24}, (_, i) => String(i))}
|
||||
type="hour"
|
||||
value={cron.split(" ")[1]}
|
||||
defaultValue={cron.split(" ")[1]}
|
||||
onValueChange={(value) => handleChangeCron("hour", value)}
|
||||
onValidityChange={setFieldValid("hour")}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="day-of-month"
|
||||
label="Day of Month"
|
||||
options={Array.from({length: 31}, (_, i) => String(i + 1).padStart(2, "0"))}
|
||||
options={Array.from({length: 31}, (_, i) => String(i + 1))}
|
||||
type="day-of-month"
|
||||
value={cron.split(" ")[2]}
|
||||
defaultValue={cron.split(" ")[2]}
|
||||
onValueChange={(value) => handleChangeCron("day-of-month", value)}
|
||||
onValidityChange={setFieldValid("day-of-month")}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="month"
|
||||
label="Month"
|
||||
options={["01", "02", "03", "04", "05", "06", "07", "08", "09", "10", "11", "12"]}
|
||||
options={Array.from({length: 12}, (_, i) => String(i + 1))}
|
||||
type="month"
|
||||
value={cron.split(" ")[3]}
|
||||
defaultValue={cron.split(" ")[3]}
|
||||
onValueChange={(value) => handleChangeCron("month", value)}
|
||||
onValidityChange={setFieldValid("month")}
|
||||
/>
|
||||
<AdvancedCronSelect
|
||||
id="day-of-week"
|
||||
@@ -89,6 +100,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
||||
value={cron.split(" ")[4]}
|
||||
defaultValue={cron.split(" ")[4]}
|
||||
onValueChange={(value) => handleChangeCron("day-of-week", value)}
|
||||
onValidityChange={setFieldValid("day-of-week")}
|
||||
/>
|
||||
<Separator/>
|
||||
<div className="grid gap-2">
|
||||
@@ -113,6 +125,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => {
|
||||
onClick={async () => {
|
||||
await handleUpdateCron(cron);
|
||||
}}
|
||||
disabled={hasInvalidField || updateBackupPolicy.isPending}
|
||||
>
|
||||
Save cron
|
||||
</Button>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+46
-9
@@ -1,10 +1,47 @@
|
||||
const cronBounds: Record<string, [number, number]> = {
|
||||
minute: [0, 59],
|
||||
hour: [0, 23],
|
||||
"day-of-month": [1, 31],
|
||||
month: [1, 12],
|
||||
"day-of-week": [0, 6],
|
||||
};
|
||||
|
||||
const isNumberInRange = (value: string, min: number, max: number): boolean => {
|
||||
if (!/^\d+$/.test(value)) return false;
|
||||
const n = Number(value);
|
||||
return n >= min && n <= max;
|
||||
};
|
||||
|
||||
const isRangeOrNumber = (value: string, min: number, max: number): boolean => {
|
||||
const range = value.match(/^(\d+)-(\d+)$/);
|
||||
if (range) {
|
||||
const start = Number(range[1]);
|
||||
const end = Number(range[2]);
|
||||
return isNumberInRange(range[1], min, max) && isNumberInRange(range[2], min, max) && start <= end;
|
||||
}
|
||||
return isNumberInRange(value, min, max);
|
||||
};
|
||||
|
||||
export const isValidCronPart = (type: string, value: string): boolean => {
|
||||
const regexMap: Record<string, RegExp> = {
|
||||
minute: /^(\*|([0-5]?\d)|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
|
||||
hour: /^(\*|([01]?\d|2[0-3])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
|
||||
"day-of-month": /^(\*|([1-9]|[12]\d|3[01])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
|
||||
month: /^(\*|([1-9]|1[0-2])|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
|
||||
"day-of-week": /^(\*|[0-6]|(\d+(,\d+)*|(\d+-\d+)|(\*\/\d+)))$/,
|
||||
};
|
||||
return regexMap[type]?.test(value) ?? false;
|
||||
};
|
||||
const bounds = cronBounds[type];
|
||||
if (!bounds) return false;
|
||||
const [min, max] = bounds;
|
||||
|
||||
return value.split(",").every((rawPart) => {
|
||||
const part = rawPart.trim();
|
||||
if (part === "") return false;
|
||||
|
||||
const step = part.match(/^(.+)\/(\d+)$/);
|
||||
if (step) {
|
||||
const base = step[1];
|
||||
const stepValue = Number(step[2]);
|
||||
if (!/^\d+$/.test(step[2]) || stepValue < 1 || stepValue > max) return false;
|
||||
if (base === "*") return true;
|
||||
return isRangeOrNumber(base, min, max);
|
||||
}
|
||||
|
||||
if (part === "*") return true;
|
||||
|
||||
return isRangeOrNumber(part, min, max);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
/**
|
||||
* Minimum agent version (its own CARGO_PKG_VERSION) that understands the
|
||||
* encrypted storages envelope. Older agents receive legacy plaintext.
|
||||
*/
|
||||
export const MIN_AGENT_VERSION_STORAGE_ENC = "1.17.0";
|
||||
|
||||
/**
|
||||
* Encrypt the storages payload with AES-256-GCM using the raw 32-byte master
|
||||
* key. Wire layout: base64( iv(12) ‖ ciphertext ‖ authTag(16) ).
|
||||
*/
|
||||
export function encryptStorages(storages: unknown, masterKey: Buffer): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", masterKey, iv);
|
||||
const plaintext = Buffer.from(JSON.stringify(storages), "utf-8");
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, ciphertext, tag]).toString("base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Numeric major.minor.patch comparison. Returns false when either version is
|
||||
* missing or cannot be parsed as three non-negative integers (fail safe).
|
||||
*/
|
||||
export function isAgentVersionAtLeast(
|
||||
reported: string | undefined,
|
||||
min: string,
|
||||
): boolean {
|
||||
const parse = (v: string): [number, number, number] | null => {
|
||||
const core = v.trim().split(/[-+]/)[0];
|
||||
const parts = core.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
if (!parts.every((p) => /^\d+$/.test(p))) return null;
|
||||
const nums = parts.map((p) => Number(p));
|
||||
if (nums.some((n) => !Number.isInteger(n) || n < 0)) return null;
|
||||
return [nums[0], nums[1], nums[2]];
|
||||
};
|
||||
|
||||
if (!reported) return false;
|
||||
const a = parse(reported);
|
||||
const b = parse(min);
|
||||
if (!a || !b) return false;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true;
|
||||
if (a[i] < b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Reference in New Issue
Block a user