Working on retention policy and cron system.

This commit is contained in:
charlesgauthereau
2025-09-19 09:58:54 +02:00
parent bce6c9d55f
commit b95e145a8b
9 changed files with 259 additions and 232 deletions
@@ -30,7 +30,6 @@ export default async function RoutePage(props: PageParams<{
notFound(); notFound();
} }
const databasesProject = await getOrganizationProjectDatabases({ const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug, organizationSlug: organization.slug,
projectId: projectId projectId: projectId
@@ -64,18 +63,20 @@ export default async function RoutePage(props: PageParams<{
const isAlreadyBackup = backups.some((b) => b.status === "waiting"); const isAlreadyBackup = backups.some((b) => b.status === "waiting");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const [totalBackups, successfulBackups] = await Promise.all([ const totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
db .from(drizzleDb.schemas.backup)
.select({count: drizzleDb.schemas.backup.id}) .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.from(drizzleDb.schemas.backup) .then(rows => rows.length);
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length), const availableBackups = backups.filter(b => !b.deletedAt).length;
db
.select({count: drizzleDb.schemas.backup.id}) const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id})
.from(drizzleDb.schemas.backup) .from(drizzleDb.schemas.backup)
.where(and(eq(drizzleDb.schemas.backup.databaseId, dbItem.id), eq(drizzleDb.schemas.backup.status, "success"))) .where(and(
.then((rows) => rows.length), 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); 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<{
<div className="justify-between gap-2 sm:flex"> <div className="justify-between gap-2 sm:flex">
<PageTitle className="flex items-center"> <PageTitle className="flex items-center">
{capitalizeFirstLetter(dbItem.name)} {capitalizeFirstLetter(dbItem.name)}
<EditButton/> <EditButton/>
<RetentionPolicySheet database={dbItem}/> <RetentionPolicySheet database={dbItem}/>
<CronButton database={dbItem}/> <CronButton database={dbItem}/>
</PageTitle> </PageTitle>
<PageActions className="justify-between"> <PageActions className="justify-between">
@@ -101,7 +102,8 @@ export default async function RoutePage(props: PageParams<{
</div> </div>
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription> <PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
<PageContent className="flex flex-col w-full h-full"> <PageContent className="flex flex-col w-full h-full">
<DatabaseKpi successRate={successRate} database={dbItem} totalBackups={totalBackups}/> <DatabaseKpi successRate={successRate} database={dbItem} availableBackups={availableBackups}
totalBackups={totalBackups}/>
<DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore} <DatabaseTabs settings={settings} database={dbItem} isAlreadyRestore={isAlreadyRestore}
backups={backups} backups={backups}
restorations={restorations}/> restorations={restorations}/>
+1 -1
View File
@@ -54,7 +54,7 @@ services:
s3: s3:
container_name: s3-portabase-dev container_name: s3-portabase-dev
image: docker.io/bitnami/minio:latest image: minio/minio:latest
ports: ports:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
@@ -1,9 +1,9 @@
"use server"; "use server";
import { userAction } from "@/safe-actions"; import {userAction} from "@/safe-actions";
import { z } from "zod"; import {z} from "zod";
import { db } from "@/db"; import {db} from "@/db";
import { eq } from "drizzle-orm"; import {eq} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
export const updateDatabaseBackupPolicyAction = userAction export const updateDatabaseBackupPolicyAction = userAction
@@ -13,7 +13,7 @@ export const updateDatabaseBackupPolicyAction = userAction
backupPolicy: z.string(), backupPolicy: z.string(),
}) })
) )
.action(async ({ parsedInput }) => { .action(async ({parsedInput}) => {
const cronPolicy = parsedInput.backupPolicy === "" ? null : parsedInput.backupPolicy; const cronPolicy = parsedInput.backupPolicy === "" ? null : parsedInput.backupPolicy;
const [updated] = await db const [updated] = await db
@@ -25,6 +25,11 @@ export const updateDatabaseBackupPolicyAction = userAction
.returning() .returning()
.execute(); .execute();
if (cronPolicy == null) {
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, parsedInput.databaseId)).execute();
}
return { return {
data: updated, data: updated,
}; };
@@ -9,11 +9,10 @@ import {
FormMessage, FormMessage,
useZodForm useZodForm
} from "@/components/ui/form"; } 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 {RetentionSettings, RetentionSettingsSchema} from "./backup-retention-settings.schema";
import {useMutation} from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import {updateOrCreateBackupRetentionPolicyAction} from "./backup-retention-settings.action"; 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 {toast} from "sonner";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"; 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 {Button} from "@/components/ui/button";
import {Calendar, Save} from "lucide-react"; import {Calendar, Save} from "lucide-react";
export type BackupRetentionSettingsFormProps = { export type BackupRetentionSettingsFormProps = {
defaultValues?: RetentionPolicy; defaultValues?: RetentionPolicy;
database: DatabaseWith; database: DatabaseWith;
}; };
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => { export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
const router = useRouter(); 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({ const form = useZodForm({
schema: RetentionSettingsSchema, schema: RetentionSettingsSchema,
defaultValues: { defaultValues: defaultValuesFormatted,
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,
},
},
}); });
const mutation = useMutation({ const mutation = useMutation({
@@ -56,7 +55,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
}), }),
onSuccess: () => { onSuccess: () => {
toast.success("Retention policy updated successfully."); toast.success("Retention policy updated successfully.");
router.refresh() router.refresh();
}, },
onError: () => { onError: () => {
toast.error("An error occurred while updating retention policy."); toast.error("An error occurred while updating retention policy.");
@@ -64,10 +63,15 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
}); });
const calculateTotalFiles = (values: RetentionSettings) => { const calculateTotalFiles = (values: RetentionSettings) => {
if (values.type === "gfs") { if (values.type === "gfs" && values.gfs) {
return values.gfs.daily + values.gfs.weekly + values.gfs.monthly + values.gfs.yearly; 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) => { const getStorageEstimate = (totalFiles: number) => {
@@ -77,7 +81,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
}; };
return ( return (
<div className=" flex flex-col gap-3 py-0"> <div className="flex flex-col gap-3 py-0">
<div className="px-3"> <div className="px-3">
<Form <Form
form={form} form={form}
@@ -94,61 +98,69 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
<FormLabel>Retention Policy Type</FormLabel> <FormLabel>Retention Policy Type</FormLabel>
<FormControl> <FormControl>
<RadioGroup <RadioGroup
value={field.value} value={field.value ?? ""}
onValueChange={field.onChange} onValueChange={field.onChange}
className="grid grid-cols-1 gap-4" className="grid grid-cols-1 gap-4"
> >
<div {[
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors"> {
<RadioGroupItem value="count" id="count"/> id: "count",
<div className="flex-1"> label: "Keep last N backups",
<FormLabel htmlFor="count" className="font-medium cursor-pointer"> desc: "Simple count-based retention (e.g., keep last 10 backups)",
Keep last N backups },
</FormLabel> {
<p className="text-sm text-muted-foreground"> id: "days",
Simple count-based retention (e.g., keep last 10 backups) label: "Keep backups for X days",
</p> desc: "Time-based retention (e.g., keep backups for 30 days)",
</div> },
</div> {
id: "gfs",
label: "GFS Rotation",
desc: "Grandfather-Father-Son rotation for enterprise/critical systems",
badge: "Recommended",
},
].map((opt) => (
<FormLabel
key={opt.id}
htmlFor={opt.id}
className={`flex items-center space-x-3 rounded-lg border p-4 transition-colors cursor-pointer ${
field.value === opt.id
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<RadioGroupItem value={opt.id} id={opt.id}/>
<div className="flex-1">
<span className="font-medium flex items-center gap-2">
{opt.label}
{opt.badge && (
<Badge variant="secondary" className="text-xs">
{opt.badge}
</Badge>
)}
</span>
<p className="text-sm text-muted-foreground">{opt.desc}</p>
</div>
<div {database.retentionPolicy?.type === opt.id && (
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="days" id="days"/>
<div className="flex-1">
<FormLabel htmlFor="days" className="font-medium cursor-pointer">
Keep backups for X days
</FormLabel>
<p className="text-sm text-muted-foreground">
Time-based retention (e.g., keep backups for 30 days)
</p>
</div>
</div>
<div
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors bg-accent/5">
<RadioGroupItem value="gfs" id="gfs"/>
<div className="flex-1">
<FormLabel htmlFor="gfs"
className="font-medium cursor-pointer flex items-center gap-2">
GFS Rotation
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
Recommended Actual
</Badge> </Badge>
</FormLabel> )}
<p className="text-sm text-muted-foreground"> </FormLabel>
Grandfather-Father-Son rotation for enterprise/critical systems ))}
</p>
</div>
</div>
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
<FormMessage/>
</FormItem> </FormItem>
)} )}
/> />
<Separator/> {form.watch("type") && (
<Separator/>
)}
{/* Count config */}
{form.watch("type") === "count" && ( {form.watch("type") === "count" && (
<FormField <FormField
control={form.control} control={form.control}
@@ -157,7 +169,14 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
<FormItem> <FormItem>
<FormLabel>Number of backups to keep</FormLabel> <FormLabel>Number of backups to keep</FormLabel>
<FormControl> <FormControl>
<Input type="number" min={1} max={100} className="w-32" {...field} /> <Input
type="number"
min={1}
max={100}
className="w-32"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Older backups beyond this count will be automatically deleted. 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" && ( {form.watch("type") === "days" && (
<FormField <FormField
control={form.control} control={form.control}
@@ -177,7 +195,14 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
<FormItem> <FormItem>
<FormLabel>Retention period (days)</FormLabel> <FormLabel>Retention period (days)</FormLabel>
<FormControl> <FormControl>
<Input type="number" min={1} max={3650} className="w-32" {...field} /> <Input
type="number"
min={1}
max={3650}
className="w-32"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Backups older than {field.value} days will be automatically deleted. 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" && ( {form.watch("type") === "gfs" && (
<div className="space-y-4"> <div className="space-y-4">
<FormField {["daily", "weekly", "monthly", "yearly"].map((key) => (
control={form.control} <FormField
name="gfs.daily" key={key}
render={({field}) => ( control={form.control}
<FormItem> name={`gfs.${key}` as const}
<FormLabel>Daily backups</FormLabel> render={({field}) => (
<FormControl> <FormItem>
<Input type="number" min={1} max={31} {...field} /> <FormLabel>
</FormControl> {key.charAt(0).toUpperCase() + key.slice(1)} backups
<FormDescription>Keep last N daily backups</FormDescription> </FormLabel>
<FormMessage/> <FormControl>
</FormItem> <Input
)} type="number"
/> min={0}
<FormField max={key === "yearly" ? 50 : key === "monthly" ? 120 : key === "weekly" ? 52 : 31}
control={form.control} {...field}
name="gfs.weekly" onChange={(e) => field.onChange(e.target.valueAsNumber)}
render={({field}) => ( />
<FormItem> </FormControl>
<FormLabel>Weekly backups</FormLabel> <FormDescription>
<FormControl> Keep N {key} backups
<Input type="number" min={0} max={52} {...field} /> </FormDescription>
</FormControl> <FormMessage/>
<FormDescription>Keep N weekly backups</FormDescription> </FormItem>
<FormMessage/> )}
</FormItem> />
)} ))}
/>
<FormField
control={form.control}
name="gfs.monthly"
render={({field}) => (
<FormItem>
<FormLabel>Monthly backups</FormLabel>
<FormControl>
<Input type="number" min={0} max={120} {...field} />
</FormControl>
<FormDescription>Keep N monthly backups</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="gfs.yearly"
render={({field}) => (
<FormItem>
<FormLabel>Yearly backups</FormLabel>
<FormControl>
<Input type="number" min={0} max={50} {...field} />
</FormControl>
<FormDescription>Keep N yearly backups</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
</div> </div>
)} )}
<Separator/> {form.watch("type") && (
<>
{/* Summary */} <Separator/>
<div className="rounded-lg border p-4 space-y-3 bg-card"> <div className="rounded-lg border p-4 space-y-3 bg-card">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground"/> <Calendar className="h-4 w-4 text-muted-foreground"/>
<span className="font-medium">Storage Impact Summary</span> <span className="font-medium">Storage Impact Summary</span>
</div>
{(() => {
const totalFiles = calculateTotalFiles(form.getValues());
const estimate = getStorageEstimate(totalFiles);
return (
<Badge
variant={
estimate === "Low"
? "default"
: estimate === "Medium"
? "secondary"
: "destructive"
}
>
{estimate} Usage
</Badge>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Estimated files per database:</span>
<p className="font-medium">
{calculateTotalFiles(form.getValues())} backup files
</p>
</div>
<div>
<span className="text-muted-foreground">Policy type:</span>
<p className="font-medium capitalize">
{form.watch("type") === "gfs"
? "GFS Rotation"
: form.watch("type") === "count"
? "Count-based"
: "Time-based"}
</p>
</div>
</div>
</div> </div>
{(() => { <Button type="submit" disabled={mutation.isPending} className="w-full">
const totalFiles = calculateTotalFiles(form.getValues()); <Save className="h-4 w-4 mr-2"/>
const estimate = getStorageEstimate(totalFiles); {mutation.isPending ? "Saving Policy..." : "Save Retention Policy"}
return ( </Button>
<Badge </>
variant={ )}
estimate === "Low"
? "default"
: estimate === "Medium"
? "secondary"
: "destructive"
}
>
{estimate} Usage
</Badge>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Estimated files per database:</span>
<p className="font-medium">{calculateTotalFiles(form.getValues())} backup files</p>
</div>
<div>
<span className="text-muted-foreground">Policy type:</span>
<p className="font-medium capitalize">
{form.watch("type") === "gfs"
? "GFS Rotation"
: form.watch("type") === "count"
? "Count-based"
: "Time-based"}
</p>
</div>
</div>
</div>
<Button type="submit" disabled={mutation.isPending} className="w-full">
<Save className="h-4 w-4 mr-2"/>
{mutation.isPending ? "Saving Policy..." : "Save Retention Policy"}
</Button>
</Form> </Form>
</div> </div>
</div> </div>
); );
} };
@@ -4,15 +4,17 @@ import {userAction} from "@/safe-actions"
import {z} from "zod" import {z} from "zod"
import {db} from "@/db" import {db} from "@/db"
import {eq} from "drizzle-orm" import {eq} from "drizzle-orm"
import {retentionSettingsSchema} from "@/components/wrappers/dashboard/database/retention-policy/schema";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {
RetentionSettingsSchema
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.schema";
export const updateOrCreateBackupRetentionPolicyAction = userAction export const updateOrCreateBackupRetentionPolicyAction = userAction
.schema( .schema(
z.object({ z.object({
databaseId: z.string(), databaseId: z.string(),
settings: retentionSettingsSchema, settings: RetentionSettingsSchema,
}) })
) )
.action(async ({parsedInput}) => { .action(async ({parsedInput}) => {
@@ -44,11 +46,12 @@ export const updateOrCreateBackupRetentionPolicyAction = userAction
.returning() .returning()
} else { } else {
// Insert new policy // Insert new policy
updated = await db updated = await db
.insert(drizzleDb.schemas.retentionPolicy) .insert(drizzleDb.schemas.retentionPolicy)
.values({ .values({
databaseId, databaseId,
type: settings.type, type: settings.type ?? "gfs",
count: settings.count, count: settings.count,
days: settings.days, days: settings.days,
gfsDaily: settings.gfs.daily, gfsDaily: settings.gfs.daily,
@@ -58,7 +61,6 @@ export const updateOrCreateBackupRetentionPolicyAction = userAction
}) })
.returning() .returning()
} }
return { return {
data: updated[0], data: updated[0],
} }
@@ -8,7 +8,7 @@ const GFSSettingsSchema = z.object({
}); });
export const RetentionSettingsSchema = 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), count: z.number().min(1).max(100),
days: z.number().min(1).max(3650), days: z.number().min(1).max(3650),
gfs: GFSSettingsSchema, gfs: GFSSettingsSchema,
@@ -1,9 +1,6 @@
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet"; import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet";
import {Button} from "@/components/ui/button"; import {Button} from "@/components/ui/button";
import {Database, DatabaseZap} from "lucide-react"; 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 {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database";
import { import {
BackupRetentionSettingsForm BackupRetentionSettingsForm
@@ -13,7 +10,7 @@ type RetentionPolicySheetProps = {
database: DbSchema database: DbSchema
} }
export const RetentionPolicySheet = (props: RetentionPolicySheetProps) => { export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
return ( return (
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
@@ -35,10 +32,17 @@ export const RetentionPolicySheet = (props: RetentionPolicySheetProps) => {
enterprise GFS rotation strategies. enterprise GFS rotation strategies.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
{database.backupPolicy !== null ?
<BackupRetentionSettingsForm database={props.database} defaultValues={props.database.retentionPolicy as RetentionPolicy}/> <BackupRetentionSettingsForm database={database}
defaultValues={database.retentionPolicy as RetentionPolicy}/>
{/*<BackupRetentionSettings database={props.database}/>*/} :
<div
className="flex flex-col items-center justify-center text-center py-12 gap-4 border rounded-lg">
<p className="text-muted-foreground">
No backup policy configured yet. Please configure one !
</p>
</div>
}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )
@@ -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<typeof retentionSettingsSchema>
@@ -1,29 +1,63 @@
"use client"; "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 {formatDateLastContact} from "@/utils/date-formatting";
import {Database} from "@/db/schema/07_database"; import {Database} from "@/db/schema/07_database";
import {DatabaseBackup, Server, CheckCircle, Clock} from "lucide-react";
export type DatabaseKpiPro = { export type DatabaseKpiPro = {
successRate: any; successRate: any;
database: Database; database: Database;
totalBackups: number; totalBackups: number;
availableBackups: number;
}; };
export const DatabaseKpi = (props: DatabaseKpiPro) => { export const DatabaseKpi = (props: DatabaseKpiPro) => {
return ( return (
<div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6"> <div className="flex flex-col sm:flex-row sm:justify-between gap-8 mb-6">
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">Backups</CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardContent>{props.totalBackups}</CardContent> <CardTitle className="text-sm font-medium">Available Backups</CardTitle>
<DatabaseBackup className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{props.availableBackups}</div>
<p className="text-xs text-muted-foreground">Backups currently available</p>
</CardContent>
</Card> </Card>
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">Success rate</CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardContent>{props.successRate ? `${props.successRate} %` : "Unavailable for now."}</CardContent> <CardTitle className="text-sm font-medium">Total Backups</CardTitle>
<Server className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{props.totalBackups}</div>
<p className="text-xs text-muted-foreground">Total backups recorded</p>
</CardContent>
</Card> </Card>
<Card className="w-full sm:w-auto flex-1"> <Card className="w-full sm:w-auto flex-1">
<CardHeader className="font-bold text-xl">Last contact</CardHeader> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardContent>{formatDateLastContact(props.database.lastContact)}</CardContent> <CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{props.successRate ? `${props.successRate.toFixed(0)} %` : "Unavailable"}
</div>
<p className="text-xs text-muted-foreground">Backup success rate</p>
</CardContent>
</Card>
<Card className="w-full sm:w-auto flex-1">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Contact</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground"/>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatDateLastContact(props.database.lastContact)}
</div>
<p className="text-xs text-muted-foreground">Time of last backup contact</p>
</CardContent>
</Card> </Card>
</div> </div>
); );