mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Patching a bug when delete an organization, add refetch.
This commit is contained in:
+3
@@ -12,6 +12,7 @@ import {eq, and, inArray} from "drizzle-orm";
|
|||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {getOrganizationProjectDatabases} from "@/lib/services";
|
import {getOrganizationProjectDatabases} from "@/lib/services";
|
||||||
import {getOrganization} from "@/lib/auth/auth";
|
import {getOrganization} from "@/lib/auth/auth";
|
||||||
|
import {RetentionPolicySheet} from "@/components/wrappers/dashboard/database/retention-policy/retention-policy-sheet";
|
||||||
|
|
||||||
export default async function RoutePage(props: PageParams<{
|
export default async function RoutePage(props: PageParams<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -89,9 +90,11 @@ export default async function RoutePage(props: PageParams<{
|
|||||||
{dbItem.name}
|
{dbItem.name}
|
||||||
<EditButton/>
|
<EditButton/>
|
||||||
<CronButton database={dbItem}/>
|
<CronButton database={dbItem}/>
|
||||||
|
<RetentionPolicySheet/>
|
||||||
</PageTitle>
|
</PageTitle>
|
||||||
<PageActions className="justify-between">
|
<PageActions className="justify-between">
|
||||||
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
<BackupButton disable={isAlreadyBackup} databaseId={databaseId}/>
|
||||||
|
|
||||||
</PageActions>
|
</PageActions>
|
||||||
</div>
|
</div>
|
||||||
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
<PageDescription className="mt-5 sm:mt-0">{dbItem.description}</PageDescription>
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import {Card, CardContent} from "@/components/ui/card";
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
import {
|
||||||
import { Input } from "@/components/ui/input";
|
FormControl,
|
||||||
import { Form } from "@/components/ui/form";
|
FormDescription,
|
||||||
import { Button } from "@/components/ui/button";
|
FormField,
|
||||||
import { useMutation } from "@tanstack/react-query";
|
FormItem,
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useZodForm
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {Input} from "@/components/ui/input";
|
||||||
|
import {Form} from "@/components/ui/form";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {TooltipProvider} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
import { EmailFormSchema, EmailFormType } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
import {
|
||||||
import { PasswordInput } from "@/components/wrappers/auth/password-input/password-input";
|
EmailFormSchema,
|
||||||
import { updateEmailSettingsAction } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
EmailFormType
|
||||||
import { toast } from "sonner";
|
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||||
import { useRouter } from "next/navigation";
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
|
import {
|
||||||
|
updateEmailSettingsAction
|
||||||
|
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
||||||
|
import {toast} from "sonner";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
|
||||||
export type EmailFormProps = {
|
export type EmailFormProps = {
|
||||||
defaultValues?: EmailFormType;
|
defaultValues?: EmailFormType;
|
||||||
@@ -27,7 +40,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (values: EmailFormType) => {
|
mutationFn: async (values: EmailFormType) => {
|
||||||
const updateEmailSettings = await updateEmailSettingsAction({ name: "system", data: values });
|
const updateEmailSettings = await updateEmailSettingsAction({name: "system", data: values});
|
||||||
const data = updateEmailSettings?.data?.data;
|
const data = updateEmailSettings?.data?.data;
|
||||||
if (updateEmailSettings?.serverError || !data) {
|
if (updateEmailSettings?.serverError || !data) {
|
||||||
toast.error(updateEmailSettings?.serverError);
|
toast.error(updateEmailSettings?.serverError);
|
||||||
@@ -53,14 +66,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="smtpFrom"
|
name="smtpFrom"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>From Email *</FormLabel>
|
<FormLabel>From Email *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"exemple@portabase.com"} {...field} />
|
<Input placeholder={"exemple@portabase.com"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"The email from where the email will be send"}</FormDescription>
|
<FormDescription>{"The email from where the email will be send"}</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -68,14 +81,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="smtpHost"
|
name="smtpHost"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server Host *</FormLabel>
|
<FormLabel>Server Host *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"ssl0.ovh.net"} {...field} />
|
<Input placeholder={"ssl0.ovh.net"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"Your email server host"}</FormDescription>
|
<FormDescription>{"Your email server host"}</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -83,14 +96,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="smtpPort"
|
name="smtpPort"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server Port *</FormLabel>
|
<FormLabel>Server Port *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"465"} {...field} />
|
<Input placeholder={"465"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"Your email server port (send)"}</FormDescription>
|
<FormDescription>{"Your email server port (send)"}</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -99,7 +112,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="smtpPassword"
|
name="smtpPassword"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -107,7 +120,7 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"Your email server password"}</FormDescription>
|
<FormDescription>{"Your email server password"}</FormDescription>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -115,14 +128,14 @@ export const EmailForm = (props: EmailFormProps) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="smtpUser"
|
name="smtpUser"
|
||||||
defaultValue=""
|
defaultValue=""
|
||||||
render={({ field }) => (
|
render={({field}) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>User Email *</FormLabel>
|
<FormLabel>User Email *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"exemple@portabase.com"} {...field} />
|
<Input placeholder={"exemple@portabase.com"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"The email server user"}</FormDescription>
|
<FormDescription>{"The email server user"}</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage/>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
|
|||||||
import { render } from "@react-email/render";
|
import { render } from "@react-email/render";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {Setting} from "@/db/schema/00_setting";
|
import {Setting} from "@/db/schema/00_setting";
|
||||||
|
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||||
|
|
||||||
export type SettingsEmailTabProps = {
|
export type SettingsEmailTabProps = {
|
||||||
settings: Setting;
|
settings: Setting;
|
||||||
@@ -53,7 +54,7 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings : null} />
|
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-1
@@ -12,6 +12,7 @@ import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||||
|
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||||
|
|
||||||
export type S3FormProps = {
|
export type S3FormProps = {
|
||||||
defaultValues?: S3FormType;
|
defaultValues?: S3FormType;
|
||||||
@@ -86,7 +87,7 @@ export const StorageS3Form = (props: S3FormProps) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Secret Key *</FormLabel>
|
<FormLabel>Secret Key *</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder={"The secret key token"} {...field} />
|
<PasswordInput placeholder={"The secret key token"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{"Add your secret key"}</FormDescription>
|
<FormDescription>{"Add your secret key"}</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
+321
@@ -0,0 +1,321 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
|
import { Clock, Database, Save, Settings, Calendar, RotateCcw } from "lucide-react"
|
||||||
|
import {toast} from "sonner";
|
||||||
|
|
||||||
|
type RetentionPolicyType = "count" | "days" | "gfs"
|
||||||
|
|
||||||
|
interface GFSSettings {
|
||||||
|
daily: number
|
||||||
|
weekly: number
|
||||||
|
monthly: number
|
||||||
|
yearly: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RetentionSettings {
|
||||||
|
type: RetentionPolicyType
|
||||||
|
count: number
|
||||||
|
days: number
|
||||||
|
gfs: GFSSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupRetentionSettings() {
|
||||||
|
const [settings, setSettings] = useState<RetentionSettings>({
|
||||||
|
type: "gfs",
|
||||||
|
count: 7,
|
||||||
|
days: 30,
|
||||||
|
gfs: {
|
||||||
|
daily: 7,
|
||||||
|
weekly: 4,
|
||||||
|
monthly: 12,
|
||||||
|
yearly: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
|
toast.success("Your backup retention settings have been saved successfully.")
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to save retention settings")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateTotalFiles = () => {
|
||||||
|
if (settings.type === "gfs") {
|
||||||
|
return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly
|
||||||
|
}
|
||||||
|
return settings.type === "count" ? settings.count : Math.ceil(settings.days / 1) // Assuming daily backups
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStorageEstimate = () => {
|
||||||
|
const totalFiles = calculateTotalFiles()
|
||||||
|
if (totalFiles <= 10) return "Low"
|
||||||
|
if (totalFiles <= 30) return "Medium"
|
||||||
|
return "High"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-balance">
|
||||||
|
<Database className="h-5 w-5" />
|
||||||
|
Backup Retention Policy
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-pretty">
|
||||||
|
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, or
|
||||||
|
enterprise GFS rotation strategies.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Label className="text-sm font-medium">Retention Policy Type</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={settings.type}
|
||||||
|
onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({ ...prev, type: value }))}
|
||||||
|
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" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="count" className="font-medium cursor-pointer">
|
||||||
|
Keep last N backups
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Simple count-based retention (e.g., keep last 10 backups)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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">
|
||||||
|
<Label htmlFor="days" className="font-medium cursor-pointer">
|
||||||
|
Keep backups for X days
|
||||||
|
</Label>
|
||||||
|
<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">
|
||||||
|
<Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2">
|
||||||
|
GFS Rotation
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Recommended
|
||||||
|
</Badge>
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Grandfather-Father-Son rotation for enterprise/critical systems
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{settings.type === "count" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label className="font-medium">Count-Based Configuration</Label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="backup-count">Number of backups to keep</Label>
|
||||||
|
<Input
|
||||||
|
id="backup-count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={settings.count}
|
||||||
|
onChange={(e) => setSettings((prev) => ({ ...prev, count: Number.parseInt(e.target.value) || 1 }))}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Older backups beyond this count will be automatically deleted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.type === "days" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label className="font-medium">Time-Based Configuration</Label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retention-days">Retention period (days)</Label>
|
||||||
|
<Input
|
||||||
|
id="retention-days"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="3650"
|
||||||
|
value={settings.days}
|
||||||
|
onChange={(e) => setSettings((prev) => ({ ...prev, days: Number.parseInt(e.target.value) || 1 }))}
|
||||||
|
className="w-32"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Backups older than {settings.days} days will be automatically deleted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{settings.type === "gfs" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RotateCcw className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label className="font-medium">GFS Rotation Configuration</Label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="daily-backups">Daily backups</Label>
|
||||||
|
<Input
|
||||||
|
id="daily-backups"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
value={settings.gfs.daily}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gfs: { ...prev.gfs, daily: Number.parseInt(e.target.value) || 1 },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Keep last N daily backups</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="weekly-backups">Weekly backups</Label>
|
||||||
|
<Input
|
||||||
|
id="weekly-backups"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="52"
|
||||||
|
value={settings.gfs.weekly}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gfs: { ...prev.gfs, weekly: Number.parseInt(e.target.value) || 0 },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Keep N weekly backups</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="monthly-backups">Monthly backups</Label>
|
||||||
|
<Input
|
||||||
|
id="monthly-backups"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
value={settings.gfs.monthly}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gfs: { ...prev.gfs, monthly: Number.parseInt(e.target.value) || 0 },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Keep N monthly backups</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="yearly-backups">Yearly backups</Label>
|
||||||
|
<Input
|
||||||
|
id="yearly-backups"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="50"
|
||||||
|
value={settings.gfs.yearly}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
gfs: { ...prev.gfs, yearly: Number.parseInt(e.target.value) || 0 },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Keep N yearly backups</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/50 p-3 rounded-lg">
|
||||||
|
<p className="text-sm font-medium mb-1">GFS Strategy Benefits:</p>
|
||||||
|
<ul className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<li>• Balanced storage usage with long-term retention</li>
|
||||||
|
<li>• Automatic promotion of backups (daily → weekly → monthly → yearly)</li>
|
||||||
|
<li>• Industry standard for enterprise backup systems</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 space-y-3 bg-card">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">Storage Impact Summary</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
getStorageEstimate() === "Low"
|
||||||
|
? "default"
|
||||||
|
: getStorageEstimate() === "Medium"
|
||||||
|
? "secondary"
|
||||||
|
: "destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getStorageEstimate()} 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()} backup files</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Policy type:</span>
|
||||||
|
<p className="font-medium capitalize">
|
||||||
|
{settings.type === "gfs" ? "GFS Rotation" : settings.type === "count" ? "Count-based" : "Time-based"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.type === "gfs" && (
|
||||||
|
<div className="text-xs text-muted-foreground bg-accent/10 p-2 rounded border border-accent/20">
|
||||||
|
<strong>Note:</strong> With GFS rotation, you'll have approximately {calculateTotalFiles()} files per
|
||||||
|
database per year, providing excellent long-term retention with optimized storage usage.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} disabled={isLoading} className="w-full">
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
{isLoading ? "Saving Policy..." : "Save Retention Policy"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet";
|
||||||
|
import {Button} from "@/components/ui/button";
|
||||||
|
import {DatabaseZap} from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
export const RetentionPolicySheet = () => {
|
||||||
|
return (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<DatabaseZap/>
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className="w-[400px] sm:w-[540px]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Are you absolutely sure?</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
This action cannot be undone. This will permanently delete your account
|
||||||
|
and remove your data from our servers.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TYPE "public"."retention_policy_type" AS ENUM('count', 'days', 'gfs');--> statement-breakpoint
|
||||||
|
CREATE TABLE "retention_policies" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"database_id" uuid NOT NULL,
|
||||||
|
"type" "retention_policy_type" NOT NULL,
|
||||||
|
"count" integer DEFAULT 7,
|
||||||
|
"days" integer DEFAULT 30,
|
||||||
|
"gfs_daily" integer DEFAULT 7,
|
||||||
|
"gfs_weekly" integer DEFAULT 4,
|
||||||
|
"gfs_monthly" integer DEFAULT 12,
|
||||||
|
"gfs_yearly" integer DEFAULT 3,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "retention_policies" ADD CONSTRAINT "retention_policies_database_id_databases_id_fk" FOREIGN KEY ("database_id") REFERENCES "public"."databases"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
|||||||
"when": 1753720983182,
|
"when": 1753720983182,
|
||||||
"tag": "0002_pink_groot",
|
"tag": "0002_pink_groot",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1756321312852,
|
||||||
|
"tag": "0003_absent_maestro",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ export const database = pgTable("databases", {
|
|||||||
|
|
||||||
projectId: uuid("project_id")
|
projectId: uuid("project_id")
|
||||||
.references(() => project.id),
|
.references(() => project.id),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const backup = pgTable(
|
export const backup = pgTable(
|
||||||
@@ -41,6 +42,23 @@ export const backup = pgTable(
|
|||||||
// (table) => [uniqueIndex("database_id_status_unique").on(table.databaseId, table.status)]
|
// (table) => [uniqueIndex("database_id_status_unique").on(table.databaseId, table.status)]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const retentionPolicyType = pgEnum("retention_policy_type", ["count", "days", "gfs"]);
|
||||||
|
|
||||||
|
export const retentionPolicy = pgTable("retention_policies", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
databaseId: uuid("database_id").notNull().references(() => database.id, {onDelete: "cascade"}),
|
||||||
|
type: retentionPolicyType("type").notNull(),
|
||||||
|
count: integer("count").default(7), // for "count"
|
||||||
|
days: integer("days").default(30), // for "days"
|
||||||
|
gfsDaily: integer("gfs_daily").default(7),
|
||||||
|
gfsWeekly: integer("gfs_weekly").default(4),
|
||||||
|
gfsMonthly: integer("gfs_monthly").default(12),
|
||||||
|
gfsYearly: integer("gfs_yearly").default(3),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at"),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
export const restoration = pgTable("restorations", {
|
export const restoration = pgTable("restorations", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
status: statusEnum("status").default("waiting").notNull(),
|
status: statusEnum("status").default("waiting").notNull(),
|
||||||
@@ -53,6 +71,7 @@ export const restoration = pgTable("restorations", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const databaseRelations = relations(database, ({one, many}) => ({
|
export const databaseRelations = relations(database, ({one, many}) => ({
|
||||||
|
retentionPolicy: one(retentionPolicy),
|
||||||
agent: one(agent, {fields: [database.agentId], references: [agent.id]}),
|
agent: one(agent, {fields: [database.agentId], references: [agent.id]}),
|
||||||
project: one(project, {fields: [database.projectId], references: [project.id]}),
|
project: one(project, {fields: [database.projectId], references: [project.id]}),
|
||||||
backups: many(backup),
|
backups: many(backup),
|
||||||
@@ -78,10 +97,16 @@ export type Backup = z.infer<typeof backupSchema>;
|
|||||||
export const restorationSchema = createSelectSchema(restoration);
|
export const restorationSchema = createSelectSchema(restoration);
|
||||||
export type Restoration = z.infer<typeof restorationSchema>;
|
export type Restoration = z.infer<typeof restorationSchema>;
|
||||||
|
|
||||||
|
export const retentionPolicySchema = createSelectSchema(retentionPolicy);
|
||||||
|
export type RetentionPolicy = z.infer<typeof retentionPolicySchema>;
|
||||||
|
|
||||||
|
|
||||||
export type DatabaseWith = Database & {
|
export type DatabaseWith = Database & {
|
||||||
agent?: Agent | null;
|
agent?: Agent | null;
|
||||||
project?: Project | null;
|
project?: Project | null;
|
||||||
backups?: Backup[] | null;
|
backups?: Backup[] | null;
|
||||||
restorations?: Restoration[] | null;
|
restorations?: Restoration[] | null;
|
||||||
|
retentionPolicy?: RetentionPolicy | null;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {db} from "@/db";
|
||||||
|
import {enforceRetentionCount} from "@/lib/tasks/database/retention-count";
|
||||||
|
import {enforceRetentionDays} from "@/lib/tasks/database/retention-days";
|
||||||
|
import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
|
||||||
|
import {retentionPolicy} from "@/db/schema/06_database";
|
||||||
|
|
||||||
|
|
||||||
|
export const retentionCleanTask = async () => {
|
||||||
|
try {
|
||||||
|
const databases = await db.query.database.findMany({
|
||||||
|
with: {
|
||||||
|
retentionPolicy: true,
|
||||||
|
backups: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const db of databases) {
|
||||||
|
if (!db.retentionPolicy) continue; // no policy = skip
|
||||||
|
|
||||||
|
await enforceRetention(db.id, db.retentionPolicy);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Retention cleanup failed:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enforceRetention(
|
||||||
|
databaseId: string,
|
||||||
|
policy: typeof retentionPolicy.$inferSelect
|
||||||
|
) {
|
||||||
|
switch (policy.type) {
|
||||||
|
case "count":
|
||||||
|
await enforceRetentionCount(databaseId, policy.count ?? 7);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "days":
|
||||||
|
await enforceRetentionDays(databaseId, policy.days ?? 30);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "gfs":
|
||||||
|
await enforceRetentionGFS(databaseId, {
|
||||||
|
daily: policy.gfsDaily ?? 7,
|
||||||
|
weekly: policy.gfsWeekly ?? 4,
|
||||||
|
monthly: policy.gfsMonthly ?? 12,
|
||||||
|
yearly: policy.gfsYearly ?? 3,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import {desc, eq} from "drizzle-orm";
|
||||||
|
|
||||||
|
export async function enforceRetentionCount(databaseId: string, count: number) {
|
||||||
|
const backups = await db.query.backup.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toDelete = backups.slice(count); // keep first `count`, delete rest
|
||||||
|
|
||||||
|
for (const b of toDelete) {
|
||||||
|
await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
|
||||||
|
// TODO: delete backup file from storage
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {db} from "@/db";
|
||||||
|
import {eq, lt} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
|
export async function enforceRetentionDays(databaseId: string, days: number) {
|
||||||
|
const cutoff = new Date(Date.now() - days * 86400000); // days → ms
|
||||||
|
|
||||||
|
await db.delete(drizzleDb.schemas.backup).where(
|
||||||
|
eq(drizzleDb.schemas.backup.databaseId, databaseId) &&
|
||||||
|
lt(drizzleDb.schemas.backup.createdAt, cutoff)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: files should also be deleted from storage
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {db} from "@/db";
|
||||||
|
import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns";
|
||||||
|
import {eq, desc} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
|
export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
|
||||||
|
daily: number;
|
||||||
|
weekly: number;
|
||||||
|
monthly: number;
|
||||||
|
yearly: number;
|
||||||
|
}) {
|
||||||
|
const backups = await db.query.backup.findMany({
|
||||||
|
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
|
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const toKeep: Set<string> = new Set();
|
||||||
|
|
||||||
|
// DAILY
|
||||||
|
backups.forEach((b) => {
|
||||||
|
if (b.createdAt >= subDays(now, gfsSettings.daily)) toKeep.add(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WEEKLY
|
||||||
|
const weekStartDates = Array.from({length: gfsSettings.weekly}, (_, i) => startOfWeek(subWeeks(now, i)));
|
||||||
|
weekStartDates.forEach((weekStart) => {
|
||||||
|
const backupOfWeek = backups.find(
|
||||||
|
(b) => b.createdAt >= weekStart && b.createdAt < subWeeks(weekStart, -1)
|
||||||
|
);
|
||||||
|
if (backupOfWeek) toKeep.add(backupOfWeek.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// MONTHLY
|
||||||
|
const monthStartDates = Array.from({length: gfsSettings.monthly}, (_, i) => startOfMonth(subMonths(now, i)));
|
||||||
|
monthStartDates.forEach((monthStart) => {
|
||||||
|
const backupOfMonth = backups.find(
|
||||||
|
(b) => b.createdAt >= monthStart && b.createdAt < subMonths(monthStart, -1)
|
||||||
|
);
|
||||||
|
if (backupOfMonth) toKeep.add(backupOfMonth.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// YEARLY
|
||||||
|
const yearStartDates = Array.from({length: gfsSettings.yearly}, (_, i) => startOfYear(subYears(now, i)));
|
||||||
|
yearStartDates.forEach((yearStart) => {
|
||||||
|
const backupOfYear = backups.find(
|
||||||
|
(b) => b.createdAt >= yearStart && b.createdAt < subYears(yearStart, -1)
|
||||||
|
);
|
||||||
|
if (backupOfYear) toKeep.add(backupOfYear.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete backups not in `toKeep`
|
||||||
|
for (const b of backups) {
|
||||||
|
if (!toKeep.has(b.id)) {
|
||||||
|
await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
|
||||||
|
// TODO: delete backup file from storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import cron from "node-cron";
|
import cron from "node-cron";
|
||||||
import {retentionCleanTask} from "@/lib/tasks/project";
|
import {retentionCleanTask} from "@/lib/tasks/database";
|
||||||
|
|
||||||
|
|
||||||
export const retentionJob = cron.schedule("* * * * *", async () => {
|
export const retentionJob = cron.schedule("* * * * *", async () => {
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import {db} from "@/db";
|
|
||||||
import * as drizzleDb from "@/db";
|
|
||||||
|
|
||||||
export const retentionCleanTask = async () => {
|
|
||||||
try {
|
|
||||||
const projects = await db.query.project.findMany();
|
|
||||||
console.log(projects);
|
|
||||||
|
|
||||||
} catch (e: any) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user