diff --git a/CITATION.cff b/CITATION.cff index 5c561183..c10562db 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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' diff --git a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx index 658c7186..02cb0e46 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -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 ( @@ -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} diff --git a/app/api/agent/[agentId]/status/helpers.ts b/app/api/agent/[agentId]/status/helpers.ts index 9326ed65..d7478050 100644 --- a/app/api/agent/[agentId]/status/helpers.ts +++ b/app/api/agent/[agentId]/status/helpers.ts @@ -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 0 ? filteredChannels : defaultStorageChannel; } +function applyStorageEncryption( + entry: Record, + 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"); + } +} + diff --git a/package.json b/package.json index d2260a8d..f94c9d1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "portabase", - "version": "1.22.2", + "version": "1.22.4", "private": true, "scripts": { "dev": "next dev --turbopack -p 8887", diff --git a/src/components/common/data-table.tsx b/src/components/common/data-table.tsx index 61e1bcc0..76a6a4a2 100644 --- a/src/components/common/data-table.tsx +++ b/src/components/common/data-table.tsx @@ -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 { }; highlightRow?: (row: TData) => boolean; selectedActions?: (rows: TData[]) => ReactNode; + manualPagination?: boolean; + rowCount?: number; + paginationState?: { pageIndex: number; pageSize: number }; + onPaginationChange?: OnChangeFn; + sorting?: SortingState; + onSortingChange?: OnChangeFn; + isFetching?: boolean; } @@ -56,10 +65,19 @@ export function DataTable({ emptyButton, highlightRow, selectedActions, + manualPagination = false, + rowCount, + paginationState, + onPaginationChange, + sorting: controlledSorting, + onSortingChange: controlledOnSortingChange, + isFetching = false, }: DataTableProps) { - const [sorting, setSorting] = useState([]); + const [internalSorting, setInternalSorting] = useState([]); + const sorting = controlledSorting ?? internalSorting; + const setSorting = controlledOnSortingChange ?? setInternalSorting; const [columnFilters, setColumnFilters] = useState([]); const [rowSelection, setRowSelection] = useState({}); @@ -96,7 +114,7 @@ export function DataTable({ data, columns: finalColumns, getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), + getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(), onSortingChange: setSorting, getSortedRowModel: getSortedRowModel(), onColumnFiltersChange: setColumnFilters, @@ -106,10 +124,15 @@ export function DataTable({ 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({ selected. )} - {enablePagination && table.getFilteredRowModel().rows.length >= 1 && ( + {enablePagination && (manualPagination ? (rowCount ?? 0) > 0 : table.getFilteredRowModel().rows.length >= 1) && ( { - 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} /> )} diff --git a/src/db/schema/07_database.ts b/src/db/schema/07_database.ts index d1330c2e..256e9598 100644 --- a/src/db/schema/07_database.ts +++ b/src/db/schema/07_database.ts @@ -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; }; diff --git a/src/features/database/actions/backup-get-data.action.ts b/src/features/database/actions/backup-get-data.action.ts index edd527f8..65273aba 100644 --- a/src/features/database/actions/backup-get-data.action.ts +++ b/src/features/database/actions/backup-get-data.action.ts @@ -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}) : [] }; - }); \ No newline at end of file + }); diff --git a/src/features/database/actions/backup-list.action.ts b/src/features/database/actions/backup-list.action.ts new file mode 100644 index 00000000..bec1eb4a --- /dev/null +++ b/src/features/database/actions/backup-list.action.ts @@ -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), + }, + }; + }); diff --git a/src/features/database/actions/backup-list.schema.ts b/src/features/database/actions/backup-list.schema.ts new file mode 100644 index 00000000..eb49fc0e --- /dev/null +++ b/src/features/database/actions/backup-list.schema.ts @@ -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; + +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; diff --git a/src/features/database/components/backup-actions-form.tsx b/src/features/database/components/backup-actions-form.tsx index 8dbb64e8..f56f192d 100644 --- a/src/features/database/components/backup-actions-form.tsx +++ b/src/features/database/components/backup-actions-form.tsx @@ -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 { diff --git a/src/features/database/components/backup-button.tsx b/src/features/database/components/backup-button.tsx index cc8a44bd..37ca1534 100644 --- a/src/features/database/components/backup-button.tsx +++ b/src/features/database/components/backup-button.tsx @@ -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."); diff --git a/src/features/database/components/backup-columns.tsx b/src/features/database/components/backup-columns.tsx index e6ce4f37..b0c5b2ed 100644 --- a/src/features/database/components/backup-columns.tsx +++ b/src/features/database/components/backup-columns.tsx @@ -112,8 +112,7 @@ export function backupColumns( accessorKey: "logs", header: "Logs", cell: ({row}) => { - const logs = row.original.logs ?? []; - return ; + return ; }, }, { diff --git a/src/features/database/components/cron-advanced-select.tsx b/src/features/database/components/cron-advanced-select.tsx index 5236f191..490deca4 100644 --- a/src/features/database/components/cron-advanced-select.tsx +++ b/src/features/database/components/cron-advanced-select.tsx @@ -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(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); diff --git a/src/features/database/components/cron-input.tsx b/src/features/database/components/cron-input.tsx index 52f3bd73..2ed0ea02 100644 --- a/src/features/database/components/cron-input.tsx +++ b/src/features/database/components/cron-input.tsx @@ -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(database.backupPolicy ?? "* * * * *"); + const [cron, setCron] = useState(database.backupPolicy ?? "0 0 * * *"); + const [fieldValidity, setFieldValidity] = useState>({}); 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) => { 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")} /> 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")} /> 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")} /> String(i + 1))} type="month" value={cron.split(" ")[3]} defaultValue={cron.split(" ")[3]} onValueChange={(value) => handleChangeCron("month", value)} + onValidityChange={setFieldValid("month")} /> { value={cron.split(" ")[4]} defaultValue={cron.split(" ")[4]} onValueChange={(value) => handleChangeCron("day-of-week", value)} + onValidityChange={setFieldValid("day-of-week")} />
@@ -113,6 +125,7 @@ export const CronInput = ({database, onSuccess}: CronInputProps) => { onClick={async () => { await handleUpdateCron(cron); }} + disabled={hasInvalidField || updateBackupPolicy.isPending} > Save cron diff --git a/src/features/database/components/database-backup-list.tsx b/src/features/database/components/database-backup-list.tsx index fb6fe8ec..43152eef 100644 --- a/src/features/database/components/database-backup-list.tsx +++ b/src/features/database/components/database-backup-list.tsx @@ -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([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({ + 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) => { ( <>
@@ -156,9 +153,8 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => { />
- )} /> - ) -} \ No newline at end of file + ); +}; diff --git a/src/features/database/components/database-content.tsx b/src/features/database/components/database-content.tsx index aa2b368c..9983dc0d 100644 --- a/src/features/database/components/database-content.tsx +++ b/src/features/database/components/database-content.tsx @@ -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} /> diff --git a/src/features/database/components/database-restore-list.tsx b/src/features/database/components/database-restore-list.tsx index 649ed1a0..31e6be33 100644 --- a/src/features/database/components/database-restore-list.tsx +++ b/src/features/database/components/database-restore-list.tsx @@ -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({ + 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 ( ( <> {!isMember && ( @@ -84,7 +92,7 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => { >Actions - { mutationDeleteRestorations.mutate(rows) setIsActionsOpen(false); @@ -110,5 +118,5 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => { )} /> - ) -} \ No newline at end of file + ); +}; diff --git a/src/features/database/components/database-tabs.tsx b/src/features/database/components/database-tabs.tsx index a1b91e2e..a7a5ea39 100644 --- a/src/features/database/components/database-tabs.tsx +++ b/src/features/database/components/database-tabs.tsx @@ -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(() => 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} /> } - - ); }; diff --git a/src/features/database/components/import-upload-zone.tsx b/src/features/database/components/import-upload-zone.tsx index 2d7d2d43..d9fa4663 100644 --- a/src/features/database/components/import-upload-zone.tsx +++ b/src/features/database/components/import-upload-zone.tsx @@ -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); } }, diff --git a/src/features/database/components/restore-columns.tsx b/src/features/database/components/restore-columns.tsx index 00f63d79..698cf795 100644 --- a/src/features/database/components/restore-columns.tsx +++ b/src/features/database/components/restore-columns.tsx @@ -62,8 +62,7 @@ export function restoreColumns( accessorKey: "logs", header: "Logs", cell: ({row}) => { - const logs = row.original.logs ?? []; - return ; + return ; }, }, { @@ -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); diff --git a/src/features/logs/actions/job-logs.action.ts b/src/features/logs/actions/job-logs.action.ts new file mode 100644 index 00000000..a74abbca --- /dev/null +++ b/src/features/logs/actions/job-logs.action.ts @@ -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 => { + 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 []; + }); diff --git a/src/features/logs/components/logs-modal-context.tsx b/src/features/logs/components/logs-modal-context.tsx index 08a2171c..c9e2d0fd 100644 --- a/src/features/logs/components/logs-modal-context.tsx +++ b/src/features/logs/components/logs-modal-context.tsx @@ -17,7 +17,7 @@ export const LogsModalProvider = ({children}: { children: ReactNode }) => { const [logs, setLogs] = useState([]); const openModal = (newLogs: JobLog[]) => { - setLogs(newLogs); + setLogs(newLogs ?? []); setOpen(true); }; diff --git a/src/features/logs/components/logs-modal-trigger.tsx b/src/features/logs/components/logs-modal-trigger.tsx index c32f3f16..629c5f40 100644 --- a/src/features/logs/components/logs-modal-trigger.tsx +++ b/src/features/logs/components/logs-modal-trigger.tsx @@ -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 ( - - ) -} \ No newline at end of file + ); +}; diff --git a/src/features/logs/components/logs-modal.tsx b/src/features/logs/components/logs-modal.tsx index d43c1d6d..00f4b3a8 100644 --- a/src/features/logs/components/logs-modal.tsx +++ b/src/features/logs/components/logs-modal.tsx @@ -29,11 +29,17 @@ export const LogsModal = () => {
-
- {logs.map((entry: JobLog) => ( - - ))} -
+ {logs.length === 0 ? ( +
+ No logs available +
+ ) : ( +
+ {logs.map((entry: JobLog) => ( + + ))} +
+ )}
diff --git a/src/hooks/use-server-data-table.ts b/src/hooks/use-server-data-table.ts new file mode 100644 index 00000000..d2d55ac0 --- /dev/null +++ b/src/hooks/use-server-data-table.ts @@ -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 { + data: TData[]; + meta: { + total: number; + page: number; + pageSize: number; + totalPages: number; + [key: string]: any; + }; +} + +interface UseServerDataTableProps { + fetchAction: any; + queryKey: any[]; + extraParams?: Partial; + initialPageSize?: number; + initialSorting?: SortingState; + enabled?: boolean; + refetchInterval?: number | false; +} + +const EMPTY_ARRAY: any[] = []; + +export function useServerDataTable( + props: UseServerDataTableProps, +) { + const { + fetchAction, + queryKey, + extraParams, + initialPageSize = 20, + initialSorting = [], + enabled = true, + refetchInterval = false, + } = props; + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: initialPageSize, + }); + const [sorting, setSorting] = useState(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 | 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, + }; +} diff --git a/src/utils/cron.ts b/src/utils/cron.ts index 7baf9c41..35e12a77 100644 --- a/src/utils/cron.ts +++ b/src/utils/cron.ts @@ -1,10 +1,47 @@ +const cronBounds: Record = { + 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 = { - 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; -}; \ No newline at end of file + 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); + }); +}; diff --git a/src/utils/status-crypto.ts b/src/utils/status-crypto.ts new file mode 100644 index 00000000..063db6f5 --- /dev/null +++ b/src/utils/status-crypto.ts @@ -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; +}