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 f7dfe10b..1251ee60 100644 --- a/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx +++ b/app/(customer)/dashboard/(organization)/projects/[projectId]/database/[databaseId]/page.tsx @@ -30,7 +30,6 @@ export default async function RoutePage(props: PageParams<{ notFound(); } - const databasesProject = await getOrganizationProjectDatabases({ organizationSlug: organization.slug, projectId: projectId @@ -64,18 +63,20 @@ export default async function RoutePage(props: PageParams<{ const isAlreadyBackup = backups.some((b) => b.status === "waiting"); const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); - const [totalBackups, successfulBackups] = await Promise.all([ - db - .select({count: drizzleDb.schemas.backup.id}) - .from(drizzleDb.schemas.backup) - .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) - .then((rows) => rows.length), - db - .select({count: drizzleDb.schemas.backup.id}) - .from(drizzleDb.schemas.backup) - .where(and(eq(drizzleDb.schemas.backup.databaseId, dbItem.id), eq(drizzleDb.schemas.backup.status, "success"))) - .then((rows) => rows.length), - ]); + 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 successfulBackups = await db.select({count: drizzleDb.schemas.backup.id}) + .from(drizzleDb.schemas.backup) + .where(and( + eq(drizzleDb.schemas.backup.databaseId, dbItem.id), + eq(drizzleDb.schemas.backup.status, "success") + )) + .then(rows => rows.length); const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); @@ -90,9 +91,9 @@ export default async function RoutePage(props: PageParams<{
{capitalizeFirstLetter(dbItem.name)} - - - + + + @@ -101,7 +102,8 @@ export default async function RoutePage(props: PageParams<{
{dbItem.description} - + diff --git a/docker-compose.yml b/docker-compose.yml index c6321d9d..06e7f03f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -54,7 +54,7 @@ services: s3: container_name: s3-portabase-dev - image: docker.io/bitnami/minio:latest + image: minio/minio:latest ports: - "9000:9000" - "9001:9001" diff --git a/src/components/wrappers/dashboard/database/cron-button/cron.action.ts b/src/components/wrappers/dashboard/database/cron-button/cron.action.ts index f2c6accb..6c6a1ca7 100644 --- a/src/components/wrappers/dashboard/database/cron-button/cron.action.ts +++ b/src/components/wrappers/dashboard/database/cron-button/cron.action.ts @@ -1,9 +1,9 @@ "use server"; -import { userAction } from "@/safe-actions"; -import { z } from "zod"; -import { db } from "@/db"; -import { eq } from "drizzle-orm"; +import {userAction} from "@/safe-actions"; +import {z} from "zod"; +import {db} from "@/db"; +import {eq} from "drizzle-orm"; import * as drizzleDb from "@/db"; export const updateDatabaseBackupPolicyAction = userAction @@ -13,7 +13,7 @@ export const updateDatabaseBackupPolicyAction = userAction backupPolicy: z.string(), }) ) - .action(async ({ parsedInput }) => { + .action(async ({parsedInput}) => { const cronPolicy = parsedInput.backupPolicy === "" ? null : parsedInput.backupPolicy; const [updated] = await db @@ -25,6 +25,11 @@ export const updateDatabaseBackupPolicyAction = userAction .returning() .execute(); + if (cronPolicy == null) { + await db.delete(drizzleDb.schemas.retentionPolicy) + .where(eq(drizzleDb.schemas.retentionPolicy.databaseId, parsedInput.databaseId)).execute(); + } + return { data: updated, }; diff --git a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings-form.tsx b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings-form.tsx index 99f52676..f596db7b 100644 --- a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings-form.tsx +++ b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings-form.tsx @@ -9,11 +9,10 @@ import { FormMessage, useZodForm } from "@/components/ui/form"; -import {DatabaseType} from "@/components/wrappers/dashboard/database/database-form/form-database.schema"; import {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema"; import {useMutation} from "@tanstack/react-query"; import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action"; -import {Database, DatabaseWith, RetentionPolicy} from "@/db/schema/07_database"; +import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database"; import {toast} from "sonner"; import {useRouter} from "next/navigation"; import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"; @@ -23,29 +22,29 @@ import {Input} from "@/components/ui/input"; import {Button} from "@/components/ui/button"; import {Calendar, Save} from "lucide-react"; - export type BackupRetentionSettingsFormProps = { defaultValues?: RetentionPolicy; database: DatabaseWith; }; - export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => { const router = useRouter(); + const defaultValuesFormatted: RetentionSettings = { + type: defaultValues?.type, + count: defaultValues?.count ?? 7, + days: defaultValues?.days ?? 30, + gfs: { + daily: defaultValues?.gfsDaily ?? 7, + weekly: defaultValues?.gfsWeekly ?? 4, + monthly: defaultValues?.gfsMonthly ?? 12, + yearly: defaultValues?.gfsYearly ?? 3, + }, + }; + const form = useZodForm({ schema: RetentionSettingsSchema, - defaultValues: { - type: defaultValues?.type ?? "gfs", - count: defaultValues?.count ?? 7, - days: defaultValues?.days ?? 30, - gfs: { - daily: defaultValues?.gfsDaily ?? 7, - weekly: defaultValues?.gfsWeekly ?? 4, - monthly: defaultValues?.gfsMonthly ?? 12, - yearly: defaultValues?.gfsYearly ?? 3, - }, - }, + defaultValues: defaultValuesFormatted, }); const mutation = useMutation({ @@ -56,7 +55,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet }), onSuccess: () => { toast.success("Retention policy updated successfully."); - router.refresh() + router.refresh(); }, onError: () => { toast.error("An error occurred while updating retention policy."); @@ -64,10 +63,15 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet }); const calculateTotalFiles = (values: RetentionSettings) => { - if (values.type === "gfs") { - return values.gfs.daily + values.gfs.weekly + values.gfs.monthly + values.gfs.yearly; + if (values.type === "gfs" && values.gfs) { + return ( + (values.gfs.daily ?? 0) + + (values.gfs.weekly ?? 0) + + (values.gfs.monthly ?? 0) + + (values.gfs.yearly ?? 0) + ); } - return values.type === "count" ? values.count : values.days; + return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0; }; const getStorageEstimate = (totalFiles: number) => { @@ -77,7 +81,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet }; return ( -
+
Retention Policy Type -
- -
- - Keep last N backups - -

- Simple count-based retention (e.g., keep last 10 backups) -

-
-
+ {[ + { + id: "count", + label: "Keep last N backups", + desc: "Simple count-based retention (e.g., keep last 10 backups)", + }, + { + id: "days", + label: "Keep backups for X days", + desc: "Time-based retention (e.g., keep backups for 30 days)", + }, + { + id: "gfs", + label: "GFS Rotation", + desc: "Grandfather-Father-Son rotation for enterprise/critical systems", + badge: "Recommended", + }, + ].map((opt) => ( + + +
+ + {opt.label} + {opt.badge && ( + + {opt.badge} + + )} + +

{opt.desc}

+
-
- -
- - Keep backups for X days - -

- Time-based retention (e.g., keep backups for 30 days) -

-
-
- -
- -
- - GFS Rotation + {database.retentionPolicy?.type === opt.id && ( - Recommended + Actual - -

- Grandfather-Father-Son rotation for enterprise/critical systems -

-
-
+ )} +
+ ))}
+ )} /> - + {form.watch("type") && ( + + )} + - {/* Count config */} {form.watch("type") === "count" && ( Number of backups to keep - + field.onChange(e.target.valueAsNumber)} + /> Older backups beyond this count will be automatically deleted. @@ -168,7 +187,6 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet /> )} - {/* Days config */} {form.watch("type") === "days" && ( Retention period (days) - + field.onChange(e.target.valueAsNumber)} + /> Backups older than {field.value} days will be automatically deleted. @@ -188,119 +213,92 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet /> )} - {/* GFS config */} {form.watch("type") === "gfs" && (
- ( - - Daily backups - - - - Keep last N daily backups - - - )} - /> - ( - - Weekly backups - - - - Keep N weekly backups - - - )} - /> - ( - - Monthly backups - - - - Keep N monthly backups - - - )} - /> - ( - - Yearly backups - - - - Keep N yearly backups - - - )} - /> + {["daily", "weekly", "monthly", "yearly"].map((key) => ( + ( + + + {key.charAt(0).toUpperCase() + key.slice(1)} backups + + + field.onChange(e.target.valueAsNumber)} + /> + + + Keep N {key} backups + + + + )} + /> + ))}
)} - - - {/* Summary */} -
-
-
- - Storage Impact Summary + {form.watch("type") && ( + <> + +
+
+
+ + Storage Impact Summary +
+ {(() => { + const totalFiles = calculateTotalFiles(form.getValues()); + const estimate = getStorageEstimate(totalFiles); + return ( + + {estimate} Usage + + ); + })()} +
+
+
+ Estimated files per database: +

+ {calculateTotalFiles(form.getValues())} backup files +

+
+
+ Policy type: +

+ {form.watch("type") === "gfs" + ? "GFS Rotation" + : form.watch("type") === "count" + ? "Count-based" + : "Time-based"} +

+
+
- {(() => { - const totalFiles = calculateTotalFiles(form.getValues()); - const estimate = getStorageEstimate(totalFiles); - return ( - - {estimate} Usage - - ); - })()} -
-
-
- Estimated files per database: -

{calculateTotalFiles(form.getValues())} backup files

-
-
- Policy type: -

- {form.watch("type") === "gfs" - ? "GFS Rotation" - : form.watch("type") === "count" - ? "Count-based" - : "Time-based"} -

-
-
-
- - + + + )}
); -} \ No newline at end of file +}; diff --git a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx index e0212d5f..cdb37139 100644 --- a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx +++ b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action.tsx @@ -4,15 +4,17 @@ import {userAction} from "@/safe-actions" import {z} from "zod" import {db} from "@/db" import {eq} from "drizzle-orm" -import {retentionSettingsSchema} from "@/components/wrappers/dashboard/database/retention-policy/schema"; import * as drizzleDb from "@/db"; +import { + RetentionSettingsSchema +} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema"; export const updateOrCreateBackupRetentionPolicyAction = userAction .schema( z.object({ databaseId: z.string(), - settings: retentionSettingsSchema, + settings: RetentionSettingsSchema, }) ) .action(async ({parsedInput}) => { @@ -44,11 +46,12 @@ export const updateOrCreateBackupRetentionPolicyAction = userAction .returning() } else { // Insert new policy + updated = await db .insert(drizzleDb.schemas.retentionPolicy) .values({ databaseId, - type: settings.type, + type: settings.type ?? "gfs", count: settings.count, days: settings.days, gfsDaily: settings.gfs.daily, @@ -58,7 +61,6 @@ export const updateOrCreateBackupRetentionPolicyAction = userAction }) .returning() } - return { data: updated[0], } diff --git a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema.ts b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema.ts index 150eeeb1..b394d049 100644 --- a/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema.ts +++ b/src/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema.ts @@ -8,7 +8,7 @@ const GFSSettingsSchema = z.object({ }); export const RetentionSettingsSchema = z.object({ - type: z.enum(["count", "days", "gfs"]), + type: z.enum(["count", "days", "gfs"]).optional(), count: z.number().min(1).max(100), days: z.number().min(1).max(3650), gfs: GFSSettingsSchema, diff --git a/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx b/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx index 2f534657..1d050eb7 100644 --- a/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx +++ b/src/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet.tsx @@ -1,9 +1,6 @@ import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet"; import {Button} from "@/components/ui/button"; import {Database, DatabaseZap} from "lucide-react"; -import { - BackupRetentionSettings -} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings"; import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database"; import { BackupRetentionSettingsForm @@ -13,7 +10,7 @@ type RetentionPolicySheetProps = { database: DbSchema } -export const RetentionPolicySheet = (props: RetentionPolicySheetProps) => { +export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => { return ( @@ -35,10 +32,17 @@ export const RetentionPolicySheet = (props: RetentionPolicySheetProps) => { enterprise GFS rotation strategies. - - - - {/**/} + {database.backupPolicy !== null ? + + : +
+

+ No backup policy configured yet. Please configure one ! +

+
+ }
) diff --git a/src/components/wrappers/dashboard/database/retention-policy/schema.ts b/src/components/wrappers/dashboard/database/retention-policy/schema.ts deleted file mode 100644 index 032bf973..00000000 --- a/src/components/wrappers/dashboard/database/retention-policy/schema.ts +++ /dev/null @@ -1,18 +0,0 @@ -// --- Zod Schema --- -import {z} from "zod"; - -export const gfsSettingsSchema = z.object({ - daily: z.number().min(1).max(31), - weekly: z.number().min(0).max(52), - monthly: z.number().min(0).max(120), - yearly: z.number().min(0).max(50), -}) - -export const retentionSettingsSchema = z.object({ - type: z.enum(["count", "days", "gfs"]), - count: z.number().min(1).max(100), - days: z.number().min(1).max(3650), - gfs: gfsSettingsSchema, -}) - -export type RetentionSettings = z.infer diff --git a/src/components/wrappers/dashboard/projects/database/database-kpi.tsx b/src/components/wrappers/dashboard/projects/database/database-kpi.tsx index a0f0236f..2a3c4ab9 100644 --- a/src/components/wrappers/dashboard/projects/database/database-kpi.tsx +++ b/src/components/wrappers/dashboard/projects/database/database-kpi.tsx @@ -1,29 +1,63 @@ "use client"; -import {Card, CardContent, CardHeader} from "@/components/ui/card"; +import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"; import {formatDateLastContact} from "@/utils/date-formatting"; import {Database} from "@/db/schema/07_database"; +import {DatabaseBackup, Server, CheckCircle, Clock} from "lucide-react"; export type DatabaseKpiPro = { successRate: any; database: Database; totalBackups: number; + availableBackups: number; }; export const DatabaseKpi = (props: DatabaseKpiPro) => { return (
- Backups - {props.totalBackups} + + Available Backups + + + +
{props.availableBackups}
+

Backups currently available

+
- Success rate - {props.successRate ? `${props.successRate} %` : "Unavailable for now."} + + Total Backups + + + +
{props.totalBackups}
+

Total backups recorded

+
- Last contact - {formatDateLastContact(props.database.lastContact)} + + Success Rate + + + +
+ {props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"} +
+

Backup success rate

+
+
+ + + Last Contact + + + +
+ {formatDateLastContact(props.database.lastContact)} +
+

Time of last backup contact

+
);