mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Working on retention policy and cron system.
This commit is contained in:
+4
-4
@@ -89,13 +89,13 @@ 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/>
|
||||||
<CronButton database={dbItem}/>
|
<RetentionPolicySheet database={dbItem}/>
|
||||||
<RetentionPolicySheet/>
|
<CronButton database={dbItem}/>
|
||||||
|
|
||||||
</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>
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"use server"
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
|
||||||
|
export const updateOrCreateBackupRetentionPolicyAction = userAction
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
databaseId: z.string(),
|
||||||
|
settings: retentionSettingsSchema,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({parsedInput}) => {
|
||||||
|
const {databaseId, settings} = parsedInput
|
||||||
|
|
||||||
|
// Check if a retention policy already exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(drizzleDb.schemas.retentionPolicy)
|
||||||
|
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
let updated
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing policy
|
||||||
|
updated = await db
|
||||||
|
.update(drizzleDb.schemas.retentionPolicy)
|
||||||
|
.set({
|
||||||
|
type: settings.type,
|
||||||
|
count: settings.count,
|
||||||
|
days: settings.days,
|
||||||
|
gfsDaily: settings.gfs.daily,
|
||||||
|
gfsWeekly: settings.gfs.weekly,
|
||||||
|
gfsMonthly: settings.gfs.monthly,
|
||||||
|
gfsYearly: settings.gfs.yearly,
|
||||||
|
})
|
||||||
|
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
|
||||||
|
.returning()
|
||||||
|
} else {
|
||||||
|
// Insert new policy
|
||||||
|
updated = await db
|
||||||
|
.insert(drizzleDb.schemas.retentionPolicy)
|
||||||
|
.values({
|
||||||
|
databaseId,
|
||||||
|
type: settings.type,
|
||||||
|
count: settings.count,
|
||||||
|
days: settings.days,
|
||||||
|
gfsDaily: settings.gfs.daily,
|
||||||
|
gfsWeekly: settings.gfs.weekly,
|
||||||
|
gfsMonthly: settings.gfs.monthly,
|
||||||
|
gfsYearly: settings.gfs.yearly,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: updated[0],
|
||||||
|
}
|
||||||
|
})
|
||||||
+78
-48
@@ -1,15 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import {useState} from "react"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import {Label} from "@/components/ui/label"
|
||||||
import { Label } from "@/components/ui/label"
|
import {Button} from "@/components/ui/button"
|
||||||
import { Button } from "@/components/ui/button"
|
import {Badge} from "@/components/ui/badge"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import {Input} from "@/components/ui/input"
|
||||||
import { Input } from "@/components/ui/input"
|
import {Separator} from "@/components/ui/separator"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import {Clock, Database, Save, Settings, Calendar, RotateCcw} from "lucide-react"
|
||||||
import { Clock, Database, Save, Settings, Calendar, RotateCcw } from "lucide-react"
|
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import {useRouter} from "next/navigation";
|
||||||
|
import {
|
||||||
|
updateOrCreateBackupRetentionPolicyAction
|
||||||
|
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings.action";
|
||||||
|
import {Database as DbSchema} from "@/db/schema/07_database";
|
||||||
|
|
||||||
type RetentionPolicyType = "count" | "days" | "gfs"
|
type RetentionPolicyType = "count" | "days" | "gfs"
|
||||||
|
|
||||||
@@ -20,14 +25,19 @@ interface GFSSettings {
|
|||||||
yearly: number
|
yearly: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RetentionSettings {
|
export interface RetentionSettings {
|
||||||
type: RetentionPolicyType
|
type: RetentionPolicyType
|
||||||
count: number
|
count: number
|
||||||
days: number
|
days: number
|
||||||
gfs: GFSSettings
|
gfs: GFSSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackupRetentionSettings() {
|
type BackupRetentionSettingsProps = {
|
||||||
|
database: DbSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function BackupRetentionSettings({database}: BackupRetentionSettingsProps) {
|
||||||
const [settings, setSettings] = useState<RetentionSettings>({
|
const [settings, setSettings] = useState<RetentionSettings>({
|
||||||
type: "gfs",
|
type: "gfs",
|
||||||
count: 7,
|
count: 7,
|
||||||
@@ -39,20 +49,25 @@ export function BackupRetentionSettings() {
|
|||||||
yearly: 3,
|
yearly: 3,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const router = useRouter()
|
||||||
|
|
||||||
const handleSave = async () => {
|
const updateRetentionPolicy = useMutation({
|
||||||
setIsLoading(true)
|
mutationFn: async (payload: RetentionSettings) => await updateOrCreateBackupRetentionPolicyAction({
|
||||||
try {
|
databaseId: database.id,
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
settings: payload
|
||||||
toast.success("Your backup retention settings have been saved successfully.")
|
}),
|
||||||
} catch (error) {
|
onSuccess: () => {
|
||||||
toast.error("Failed to save retention settings")
|
toast.success("Retention policy updated successfully.")
|
||||||
} finally {
|
router.refresh()
|
||||||
setIsLoading(false)
|
},
|
||||||
}
|
onError: () => {
|
||||||
|
toast.error("An error occurred while updating retention policy.")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updateRetentionPolicy.mutate(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateTotalFiles = () => {
|
const calculateTotalFiles = () => {
|
||||||
if (settings.type === "gfs") {
|
if (settings.type === "gfs") {
|
||||||
return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly
|
return settings.gfs.daily + settings.gfs.weekly + settings.gfs.monthly + settings.gfs.yearly
|
||||||
@@ -76,11 +91,15 @@ export function BackupRetentionSettings() {
|
|||||||
<Label className="text-sm font-medium">Retention Policy Type</Label>
|
<Label className="text-sm font-medium">Retention Policy Type</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={settings.type}
|
value={settings.type}
|
||||||
onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({ ...prev, type: value }))}
|
onValueChange={(value: RetentionPolicyType) => setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: value
|
||||||
|
}))}
|
||||||
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">
|
<div
|
||||||
<RadioGroupItem value="count" id="count" />
|
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">
|
<div className="flex-1">
|
||||||
<Label htmlFor="count" className="font-medium cursor-pointer">
|
<Label htmlFor="count" className="font-medium cursor-pointer">
|
||||||
Keep last N backups
|
Keep last N backups
|
||||||
@@ -91,18 +110,21 @@ export function BackupRetentionSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors">
|
<div
|
||||||
<RadioGroupItem value="days" id="days" />
|
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">
|
<div className="flex-1">
|
||||||
<Label htmlFor="days" className="font-medium cursor-pointer">
|
<Label htmlFor="days" className="font-medium cursor-pointer">
|
||||||
Keep backups for X days
|
Keep backups for X days
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-sm text-muted-foreground">Time-based retention (e.g., keep backups for 30 days)</p>
|
<p className="text-sm text-muted-foreground">Time-based retention (e.g., keep
|
||||||
|
backups for 30 days)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-muted/50 transition-colors bg-accent/5">
|
<div
|
||||||
<RadioGroupItem value="gfs" id="gfs" />
|
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">
|
<div className="flex-1">
|
||||||
<Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2">
|
<Label htmlFor="gfs" className="font-medium cursor-pointer flex items-center gap-2">
|
||||||
GFS Rotation
|
GFS Rotation
|
||||||
@@ -118,12 +140,12 @@ export function BackupRetentionSettings() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator/>
|
||||||
|
|
||||||
{settings.type === "count" && (
|
{settings.type === "count" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground"/>
|
||||||
<Label className="font-medium">Count-Based Configuration</Label>
|
<Label className="font-medium">Count-Based Configuration</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -134,7 +156,10 @@ export function BackupRetentionSettings() {
|
|||||||
min="1"
|
min="1"
|
||||||
max="100"
|
max="100"
|
||||||
value={settings.count}
|
value={settings.count}
|
||||||
onChange={(e) => setSettings((prev) => ({ ...prev, count: Number.parseInt(e.target.value) || 1 }))}
|
onChange={(e) => setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
count: Number.parseInt(e.target.value) || 1
|
||||||
|
}))}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -147,7 +172,7 @@ export function BackupRetentionSettings() {
|
|||||||
{settings.type === "days" && (
|
{settings.type === "days" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
<Clock className="h-4 w-4 text-muted-foreground"/>
|
||||||
<Label className="font-medium">Time-Based Configuration</Label>
|
<Label className="font-medium">Time-Based Configuration</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -158,7 +183,10 @@ export function BackupRetentionSettings() {
|
|||||||
min="1"
|
min="1"
|
||||||
max="3650"
|
max="3650"
|
||||||
value={settings.days}
|
value={settings.days}
|
||||||
onChange={(e) => setSettings((prev) => ({ ...prev, days: Number.parseInt(e.target.value) || 1 }))}
|
onChange={(e) => setSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
days: Number.parseInt(e.target.value) || 1
|
||||||
|
}))}
|
||||||
className="w-32"
|
className="w-32"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -171,7 +199,7 @@ export function BackupRetentionSettings() {
|
|||||||
{settings.type === "gfs" && (
|
{settings.type === "gfs" && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RotateCcw className="h-4 w-4 text-muted-foreground" />
|
<RotateCcw className="h-4 w-4 text-muted-foreground"/>
|
||||||
<Label className="font-medium">GFS Rotation Configuration</Label>
|
<Label className="font-medium">GFS Rotation Configuration</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -186,7 +214,7 @@ export function BackupRetentionSettings() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) => ({
|
setSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
gfs: { ...prev.gfs, daily: Number.parseInt(e.target.value) || 1 },
|
gfs: {...prev.gfs, daily: Number.parseInt(e.target.value) || 1},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -204,7 +232,7 @@ export function BackupRetentionSettings() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) => ({
|
setSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
gfs: { ...prev.gfs, weekly: Number.parseInt(e.target.value) || 0 },
|
gfs: {...prev.gfs, weekly: Number.parseInt(e.target.value) || 0},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -222,7 +250,7 @@ export function BackupRetentionSettings() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) => ({
|
setSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
gfs: { ...prev.gfs, monthly: Number.parseInt(e.target.value) || 0 },
|
gfs: {...prev.gfs, monthly: Number.parseInt(e.target.value) || 0},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -240,7 +268,7 @@ export function BackupRetentionSettings() {
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setSettings((prev) => ({
|
setSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
gfs: { ...prev.gfs, yearly: Number.parseInt(e.target.value) || 0 },
|
gfs: {...prev.gfs, yearly: Number.parseInt(e.target.value) || 0},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -259,12 +287,12 @@ export function BackupRetentionSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator />
|
<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>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -294,16 +322,18 @@ export function BackupRetentionSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings.type === "gfs" && (
|
{settings.type === "gfs" && (
|
||||||
<div className="text-xs text-muted-foreground bg-accent/10 p-2 rounded border border-accent/20">
|
<div
|
||||||
<strong>Note:</strong> With GFS rotation, you'll have approximately {calculateTotalFiles()} files per
|
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.
|
database per year, providing excellent long-term retention with optimized storage usage.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleSave} disabled={isLoading} className="w-full">
|
<Button onClick={handleSave} disabled={updateRetentionPolicy.isPending} className="w-full">
|
||||||
<Save className="h-4 w-4 mr-2" />
|
<Save className="h-4 w-4 mr-2"/>
|
||||||
{isLoading ? "Saving Policy..." : "Save Retention Policy"}
|
{updateRetentionPolicy.isPending ? "Saving Policy..." : "Save Retention Policy"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-4
@@ -4,9 +4,13 @@ import {Database, DatabaseZap} from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
BackupRetentionSettings
|
BackupRetentionSettings
|
||||||
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings";
|
} from "@/components/wrappers/dashboard/database/retention-policy/backup-retention-settings";
|
||||||
|
import {Database as DbSchema} from "@/db/schema/07_database";
|
||||||
|
|
||||||
|
type RetentionPolicySheetProps = {
|
||||||
|
database: DbSchema
|
||||||
|
}
|
||||||
|
|
||||||
export const RetentionPolicySheet = () => {
|
export const RetentionPolicySheet = (props: RetentionPolicySheetProps) => {
|
||||||
return (
|
return (
|
||||||
<Sheet>
|
<Sheet>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
@@ -19,15 +23,16 @@ export const RetentionPolicySheet = () => {
|
|||||||
>
|
>
|
||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle className="flex items-center gap-2 text-balance">
|
<SheetTitle className="flex items-center gap-2 text-balance">
|
||||||
<Database className="h-5 w-5" />
|
<Database className="h-5 w-5"/>
|
||||||
Backup Retention Policy
|
Backup Retention Policy
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="text-pretty">
|
<SheetDescription className="text-pretty">
|
||||||
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, or
|
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based,
|
||||||
|
or
|
||||||
enterprise GFS rotation strategies.
|
enterprise GFS rotation strategies.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<BackupRetentionSettings/>
|
<BackupRetentionSettings database={props.database}/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// --- 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>
|
||||||
@@ -69,7 +69,10 @@ export const restoration = pgTable("restorations", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const databaseRelations = relations(database, ({one, many}) => ({
|
export const databaseRelations = relations(database, ({one, many}) => ({
|
||||||
retentionPolicy: one(retentionPolicy),
|
retentionPolicy: one(retentionPolicy, {
|
||||||
|
fields: [database.id],
|
||||||
|
references: [retentionPolicy.databaseId],
|
||||||
|
}),
|
||||||
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),
|
||||||
@@ -86,6 +89,14 @@ export const restorationRelations = relations(restoration, ({one}) => ({
|
|||||||
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
|
database: one(database, {fields: [restoration.databaseId], references: [database.id]}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
export const retentionPolicyRelations = relations(retentionPolicy, ({one}) => ({
|
||||||
|
database: one(database, {
|
||||||
|
fields: [retentionPolicy.databaseId],
|
||||||
|
references: [database.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const databaseSchema = createSelectSchema(database);
|
export const databaseSchema = createSelectSchema(database);
|
||||||
export type Database = z.infer<typeof databaseSchema>;
|
export type Database = z.infer<typeof databaseSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export function backupColumns(isAlreadyRestore: boolean, settings: Setting, data
|
|||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
header: "Reference",
|
header: "Reference",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "deletedAt",
|
||||||
|
header: "Deleted At",
|
||||||
|
cell: ({row}) => {
|
||||||
|
|
||||||
|
return row.original.deletedAt ? formatFrenchDate(row.getValue("deletedAt")) : ""
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
header: "Created At",
|
header: "Created At",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {enforceRetentionCount} from "@/lib/tasks/database/retention-count";
|
|||||||
import {enforceRetentionDays} from "@/lib/tasks/database/retention-days";
|
import {enforceRetentionDays} from "@/lib/tasks/database/retention-days";
|
||||||
import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
|
import {enforceRetentionGFS} from "@/lib/tasks/database/retention-gsf";
|
||||||
import {retentionPolicy} from "@/db/schema/07_database";
|
import {retentionPolicy} from "@/db/schema/07_database";
|
||||||
|
import {eq, isNull} from "drizzle-orm";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
|
||||||
|
|
||||||
export const retentionCleanTask = async () => {
|
export const retentionCleanTask = async () => {
|
||||||
@@ -10,13 +12,16 @@ export const retentionCleanTask = async () => {
|
|||||||
const databases = await db.query.database.findMany({
|
const databases = await db.query.database.findMany({
|
||||||
with: {
|
with: {
|
||||||
retentionPolicy: true,
|
retentionPolicy: true,
|
||||||
backups: true,
|
backups: {
|
||||||
|
where: isNull(drizzleDb.schemas.backup.deletedAt),
|
||||||
|
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log(databases);
|
||||||
|
|
||||||
for (const db of databases) {
|
for (const db of databases) {
|
||||||
if (!db.retentionPolicy) continue; // no policy = skip
|
if (!db.retentionPolicy) continue; // no policy = skip
|
||||||
|
|
||||||
await enforceRetention(db.id, db.retentionPolicy);
|
await enforceRetention(db.id, db.retentionPolicy);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -29,6 +34,8 @@ export async function enforceRetention(
|
|||||||
databaseId: string,
|
databaseId: string,
|
||||||
policy: typeof retentionPolicy.$inferSelect
|
policy: typeof retentionPolicy.$inferSelect
|
||||||
) {
|
) {
|
||||||
|
console.log(`EnforceRetention: ${policy}`);
|
||||||
|
|
||||||
switch (policy.type) {
|
switch (policy.type) {
|
||||||
case "count":
|
case "count":
|
||||||
await enforceRetentionCount(databaseId, policy.count ?? 7);
|
await enforceRetentionCount(databaseId, policy.count ?? 7);
|
||||||
|
|||||||
@@ -1,17 +1,43 @@
|
|||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {desc, eq} from "drizzle-orm";
|
import {desc, eq} from "drizzle-orm";
|
||||||
|
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
|
||||||
|
|
||||||
export async function enforceRetentionCount(databaseId: string, count: number) {
|
export async function enforceRetentionCount(databaseId: string, count: number) {
|
||||||
const backups = await db.query.backup.findMany({
|
const backups = await db.query.backup.findMany({
|
||||||
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
||||||
|
with:{
|
||||||
|
database: {
|
||||||
|
with: {
|
||||||
|
project: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const toDelete = backups.slice(count); // keep first `count`, delete rest
|
const toDelete = backups.slice(count); // keep first `count`, delete rest
|
||||||
|
|
||||||
for (const b of toDelete) {
|
for (const b of toDelete) {
|
||||||
await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
|
// await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
|
||||||
// TODO: delete backup file from storage
|
|
||||||
|
const deletion = await deleteBackupCronAction({
|
||||||
|
backupId: b.id,
|
||||||
|
databaseId: b.databaseId,
|
||||||
|
file: b.file!,
|
||||||
|
projectSlug: b.database.project?.slug!
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (deletion.data.success) {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(deletion.data.actionSuccess.message);
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(deletion.data.actionError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,45 @@
|
|||||||
import {db} from "@/db";
|
import {db} from "@/db";
|
||||||
import {eq, lt} from "drizzle-orm";
|
import {eq, lt, and, desc} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
|
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
|
||||||
|
|
||||||
export async function enforceRetentionDays(databaseId: string, days: number) {
|
export async function enforceRetentionDays(databaseId: string, days: number) {
|
||||||
const cutoff = new Date(Date.now() - days * 86400000); // days → ms
|
const cutoff = new Date(Date.now() - days * 86400000);
|
||||||
|
|
||||||
await db.delete(drizzleDb.schemas.backup).where(
|
const expiredBackups = await db.query.backup.findMany({
|
||||||
eq(drizzleDb.schemas.backup.databaseId, databaseId) &&
|
where: and(
|
||||||
lt(drizzleDb.schemas.backup.createdAt, cutoff)
|
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
);
|
lt(drizzleDb.schemas.backup.createdAt, cutoff)
|
||||||
|
),
|
||||||
|
with: {
|
||||||
|
database: {
|
||||||
|
with: {
|
||||||
|
project: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
for (const backup of expiredBackups) {
|
||||||
|
|
||||||
|
const deletion = await deleteBackupCronAction({
|
||||||
|
backupId: backup.id,
|
||||||
|
databaseId: backup.databaseId,
|
||||||
|
file: backup.file!,
|
||||||
|
projectSlug: backup.database.project?.slug!
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (deletion.data.success) {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(deletion.data.actionSuccess.message);
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(deletion.data.actionError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// Note: files should also be deleted from storage
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {db} from "@/db";
|
|||||||
import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns";
|
import {subDays, subWeeks, subMonths, subYears, startOfWeek, startOfMonth, startOfYear} from "date-fns";
|
||||||
import {eq, desc} from "drizzle-orm";
|
import {eq, desc} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
|
import {deleteBackupCronAction} from "@/lib/tasks/database/utils/delete";
|
||||||
|
|
||||||
export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
|
export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
|
||||||
daily: number;
|
daily: number;
|
||||||
@@ -12,6 +13,13 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
|
|||||||
const backups = await db.query.backup.findMany({
|
const backups = await db.query.backup.findMany({
|
||||||
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||||
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
||||||
|
with: {
|
||||||
|
database: {
|
||||||
|
with: {
|
||||||
|
project: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -52,8 +60,26 @@ export async function enforceRetentionGFS(databaseId: string, gfsSettings: {
|
|||||||
// Delete backups not in `toKeep`
|
// Delete backups not in `toKeep`
|
||||||
for (const b of backups) {
|
for (const b of backups) {
|
||||||
if (!toKeep.has(b.id)) {
|
if (!toKeep.has(b.id)) {
|
||||||
await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
|
// await db.delete(drizzleDb.schemas.backup).where(eq(drizzleDb.schemas.backup.id, b.id));
|
||||||
// TODO: delete backup file from storage
|
|
||||||
|
|
||||||
|
const deletion = await deleteBackupCronAction({
|
||||||
|
backupId: b.id,
|
||||||
|
databaseId: b.databaseId,
|
||||||
|
file: b.file!,
|
||||||
|
projectSlug: b.database.project?.slug!
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (deletion.data.success) {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(deletion.data.actionSuccess.message);
|
||||||
|
} else {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(deletion.data.actionError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"use server"
|
||||||
|
import {action, userAction} from "@/safe-actions";
|
||||||
|
import {z} from "zod";
|
||||||
|
import {ServerActionResult} from "@/types/action-type";
|
||||||
|
import {Backup} from "@/db/schema/07_database";
|
||||||
|
import {db} from "@/db";
|
||||||
|
import * as drizzleDb from "@/db";
|
||||||
|
import {and, eq} from "drizzle-orm";
|
||||||
|
import {deleteFileS3Private, deleteLocalPrivate} from "@/features/upload/private/upload.action";
|
||||||
|
import {env} from "@/env.mjs";
|
||||||
|
|
||||||
|
|
||||||
|
export const deleteBackupCronAction = action
|
||||||
|
.schema(
|
||||||
|
z.object({
|
||||||
|
backupId: z.string(),
|
||||||
|
databaseId: z.string(),
|
||||||
|
projectSlug: z.string(),
|
||||||
|
file: z.string(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.action(async ({parsedInput}): Promise<ServerActionResult<Backup>> => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||||
|
if (!settings) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "No settings found.",
|
||||||
|
status: 404,
|
||||||
|
cause: "No settings found.",
|
||||||
|
messageParams: {message: "Error deleting the backup"},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(drizzleDb.schemas.backup)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
let success: boolean, message: string;
|
||||||
|
|
||||||
|
const result =
|
||||||
|
settings.storage === "local"
|
||||||
|
? await deleteLocalPrivate(parsedInput.file)
|
||||||
|
: await deleteFileS3Private(`${parsedInput.projectSlug}/${parsedInput.file}`, env.S3_BUCKET_NAME!);
|
||||||
|
|
||||||
|
({success, message} = result);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: message,
|
||||||
|
status: 404,
|
||||||
|
cause: "Unable to delete backup from storage",
|
||||||
|
messageParams: {message: "Error deleting the backup"},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// await db
|
||||||
|
// .delete(drizzleDb.schemas.backup)
|
||||||
|
// .where(and(eq(drizzleDb.schemas.backup.id, parsedInput.backupId), eq(drizzleDb.schemas.backup.databaseId, parsedInput.databaseId)))
|
||||||
|
// .execute();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
actionSuccess: {
|
||||||
|
message: "Backup deleted successfully.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
actionError: {
|
||||||
|
message: "Failed to delete backup.",
|
||||||
|
status: 500,
|
||||||
|
cause: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
messageParams: {message: "Error deleting the backup"},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2,7 +2,6 @@ import {env} from "@/env.mjs";
|
|||||||
import {db, makeMigration} from "@/db";
|
import {db, makeMigration} from "@/db";
|
||||||
import {eq} from "drizzle-orm";
|
import {eq} from "drizzle-orm";
|
||||||
import * as drizzleDb from "@/db";
|
import * as drizzleDb from "@/db";
|
||||||
import cron from "node-cron";
|
|
||||||
import {retentionJob} from "@/lib/tasks";
|
import {retentionJob} from "@/lib/tasks";
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user