feat/add volume backup model and related services for edit of backups schedules in ui

This commit is contained in:
biersoeckli
2025-01-02 15:18:32 +00:00
parent 072d3c734b
commit b871112dac
19 changed files with 487 additions and 26 deletions
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "VolumeBackup" (
"id" TEXT NOT NULL PRIMARY KEY,
"volumeId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"cron" TEXT NOT NULL,
"retention" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "VolumeBackup_volumeId_fkey" FOREIGN KEY ("volumeId") REFERENCES "AppVolume" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "VolumeBackup_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "S3Target" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+25 -10
View File
@@ -186,12 +186,13 @@ model AppDomain {
}
model AppVolume {
id String @id @default(uuid())
id String @id @default(uuid())
containerMountPath String
size Int
accessMode String @default("rwo")
accessMode String @default("rwo")
appId String
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
volumeBackups VolumeBackup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -221,13 +222,27 @@ model Parameter {
}
model S3Target {
id String @id @default(uuid())
name String
bucketName String
endpoint String
region String
accessKeyId String
secretKey String
id String @id @default(uuid())
name String
bucketName String
endpoint String
region String
accessKeyId String
secretKey String
volumeBackups VolumeBackup[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model VolumeBackup {
id String @id @default(uuid())
volumeId String
volume AppVolume @relation(fields: [volumeId], references: [id], onDelete: Cascade)
targetId String
target S3Target @relation(fields: [targetId], references: [id], onDelete: Cascade)
cron String
retention Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+12 -6
View File
@@ -5,28 +5,30 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import GeneralAppRateLimits from "./general/app-rate-limits";
import GeneralAppSource from "./general/app-source";
import EnvEdit from "./environment/env-edit";
import { App } from "@prisma/client";
import { S3Target } from "@prisma/client";
import DomainsList from "./domains/domains";
import StorageList from "./volumes/storages";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { BuildJobModel } from "@/shared/model/build-job";
import BuildsTab from "./overview/deployments";
import Logs from "./overview/logs";
import MonitoringTab from "./overview/monitoring-app";
import InternalHostnames from "./domains/ports-and-internal-hostnames";
import TerminalStreamed from "./overview/terminal-streamed";
import { useEffect } from "react";
import { useBreadcrumbs } from "@/frontend/states/zustand.states";
import FileMount from "./volumes/file-mount";
import WebhookDeploymentInfo from "./overview/webhook-deployment";
import DbCredentials from "./credentials/db-crendentials";
import VolumeBackupList from "./volumes/volume-backup";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
export default function AppTabs({
app,
tabName
tabName,
s3Targets,
volumeBackups
}: {
app: AppExtendedModel;
tabName: string;
s3Targets: S3Target[],
volumeBackups: VolumeBackupExtendedModel[]
}) {
const router = useRouter();
@@ -67,6 +69,10 @@ export default function AppTabs({
<TabsContent value="storage" className="space-y-4">
<StorageList app={app} />
<FileMount app={app} />
<VolumeBackupList
app={app}
s3Targets={s3Targets}
volumeBackups={volumeBackups} />
</TabsContent>
</Tabs>
)
+12 -2
View File
@@ -2,6 +2,8 @@ import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import appService from "@/server/services/app.service";
import AppTabs from "./app-tabs";
import AppBreadcrumbs from "./app-breadcrumbs";
import s3TargetService from "@/server/services/s3-target.service";
import volumeBackupService from "@/server/services/volume-backup.service";
export default async function AppPage({
searchParams,
@@ -15,10 +17,18 @@ export default async function AppPage({
if (!appId) {
return <p>Could not find app with id {appId}</p>
}
const app = await appService.getExtendedById(appId);
const [app, s3Targets, volumeBackups] = await Promise.all([
appService.getExtendedById(appId),
s3TargetService.getAll(),
volumeBackupService.getForApp(appId)
]);
return (<>
<AppTabs app={app} tabName={searchParams?.tabName ?? 'overview'} />
<AppTabs
volumeBackups={volumeBackups}
s3Targets={s3Targets}
app={app}
tabName={searchParams?.tabName ?? 'overview'} />
<AppBreadcrumbs app={app} />
</>
)
+19 -1
View File
@@ -8,6 +8,8 @@ import { z } from "zod";
import { ServiceException } from "@/shared/model/service.exception.model";
import pvcService from "@/server/services/pvc.service";
import { fileMountEditZodModel } from "@/shared/model/file-mount-edit.model";
import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model";
import volumeBackupService from "@/server/services/volume-backup.service";
const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({
appId: z.string(),
@@ -60,7 +62,6 @@ const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({
export const saveFileMount = async (prevState: any, inputData: z.infer<typeof actionAppFileMountEditZodModel>) =>
saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => {
await getAuthUserSession();
const existingApp = await appService.getExtendedById(validatedData.appId);
await appService.saveFileMount({
...validatedData,
id: validatedData.id ?? undefined,
@@ -72,4 +73,21 @@ export const deleteFileMount = async (fileMountId: string) =>
await getAuthUserSession();
await appService.deleteFileMountById(fileMountId);
return new SuccessActionResult(undefined, 'Successfully deleted volume');
});
export const saveBackupVolume = async (prevState: any, inputData: VolumeBackupEditModel) =>
saveFormAction(inputData, volumeBackupEditZodModel, async (validatedData) => {
await getAuthUserSession();
await volumeBackupService.save({
...validatedData,
id: validatedData.id ?? undefined,
});
return new SuccessActionResult();
});
export const deleteBackupVolume = async (backupVolumeId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await volumeBackupService.deleteById(backupVolumeId);
return new SuccessActionResult(undefined, 'Successfully deleted backup schedule');
});
@@ -0,0 +1,156 @@
'use client'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { useFormState } from 'react-dom'
import { useEffect, useState } from "react";
import { FormUtils } from "@/frontend/utils/form.utilts";
import { SubmitButton } from "@/components/custom/submit-button";
import { AppVolume, S3Target, VolumeBackup } from "@prisma/client"
import { ServerActionResult } from "@/shared/model/server-action-error-return.model"
import { saveBackupVolume } from "./actions"
import { toast } from "sonner"
import { VolumeBackupEditModel, volumeBackupEditZodModel } from "@/shared/model/backup-volume-edit.model"
import SelectFormField from "@/components/custom/select-form-field"
import Link from "next/link"
export default function VolumeBackupEditDialog({
children,
volumeBackup,
s3Targets,
volumes
}: {
children: React.ReactNode;
volumeBackup?: VolumeBackup;
s3Targets: S3Target[];
volumes: AppVolume[];
}) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const form = useForm<VolumeBackupEditModel>({
resolver: zodResolver(volumeBackupEditZodModel),
defaultValues: {
...volumeBackup,
retention: volumeBackup?.retention || 5,
targetId: volumeBackup?.targetId || (s3Targets.length === 1 ? s3Targets[0].id : undefined),
volumeId: volumeBackup?.volumeId || (volumes.length === 1 ? volumes[0].id : undefined),
}
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
payload: VolumeBackupEditModel) =>
saveBackupVolume(state, {
...payload
}), FormUtils.getInitialFormState<typeof volumeBackupEditZodModel>());
useEffect(() => {
if (state.status === 'success') {
form.reset();
toast.success('Volume Backup saved successfully', {
description: "From now on the volume will be backed up according to the new settings.",
});
setIsOpen(false);
}
FormUtils.mapValidationErrorsToForm<typeof volumeBackupEditZodModel>(state, form);
}, [state]);
useEffect(() => {
form.reset(volumeBackup);
}, [volumeBackup]);
return (
<>
<div onClick={() => setIsOpen(true)}>
{children}
</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Backup Configuration</DialogTitle>
<DialogDescription>
Configure your custom volume for this container.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<div className="space-y-4">
<FormField
control={form.control}
name="cron"
render={({ field }) => (
<FormItem>
<FormLabel>Cron expression</FormLabel>
<FormControl>
<Input placeholder="5 4 * * *" {...field} />
</FormControl>
<FormDescription>
To learn more about cron expressions, visit <a href="https://crontab.guru/" target="_blank" className="underline">crontab.guru</a>.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="retention"
render={({ field }) => (
<FormItem>
<FormLabel>Retention</FormLabel>
<FormControl>
<Input type="number" placeholder="5" {...field} />
</FormControl>
<FormDescription>
The number of backups to keep.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SelectFormField
form={form}
name="volumeId"
label="Volume to backup"
values={volumes.map((volume) =>
[volume.id, `${volume.containerMountPath}`])}
/>
<SelectFormField
form={form}
name="targetId"
label="Backup Location"
formDescription={<>
S3 Storage Locations can be configured <span className="underline"><Link href="/settings/s3-targets">here</Link></span>.
</>}
values={s3Targets.map((target) =>
[target.id, `${target.name}`])}
/>
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
</form>
</Form >
</DialogContent>
</Dialog>
</>
)
}
@@ -0,0 +1,86 @@
'use client';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { AppExtendedModel } from "@/shared/model/app-extended.model";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { EditIcon, TrashIcon } from "lucide-react";
import { Toast } from "@/frontend/utils/toast.utils";
import { deleteBackupVolume, deleteVolume } from "./actions";
import { useConfirmDialog } from "@/frontend/states/zustand.states";
import { S3Target, VolumeBackup } from "@prisma/client";
import React from "react";
import { formatDateTime } from "@/frontend/utils/format.utils";
import VolumeBackupEditDialog from "./volume-backup-edit-overlay";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
export default function VolumeBackupList({
app,
volumeBackups,
s3Targets
}: {
app: AppExtendedModel,
s3Targets: S3Target[],
volumeBackups: VolumeBackupExtendedModel[]
}) {
const { openConfirmDialog: openDialog } = useConfirmDialog();
const asyncDeleteBackupVolume = async (volumeId: string) => {
const confirm = await openDialog({
title: "Delete Backup Schedule",
description: "Are you sure you want to remove this Backup Schdeule? All backups created by this schedule will still be available.",
okButton: "Delete Backup Schedule"
});
if (confirm) {
await Toast.fromAction(() => deleteBackupVolume(volumeId));
}
};
return <>
<Card>
<CardHeader>
<CardTitle>Backup Schedules</CardTitle>
<CardDescription>Configure backup schedules for your volumes. Backups can be stored in a S3 bucket.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableCaption>{volumeBackups.length} Backup Rules</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Cron Expression</TableHead>
<TableHead>Retention</TableHead>
<TableHead>Backup Location</TableHead>
<TableHead>Created At</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{volumeBackups.map(volumeBackup => (
<TableRow key={volumeBackup.id}>
<TableCell className="font-medium">{volumeBackup.cron}</TableCell>
<TableCell className="font-medium">{volumeBackup.retention}</TableCell>
<TableCell className="font-medium">{volumeBackup.target.name}</TableCell>
<TableCell className="font-medium">{formatDateTime(volumeBackup.createdAt)}</TableCell>
<TableCell className="font-medium flex gap-2">
<VolumeBackupEditDialog volumeBackup={volumeBackup}
s3Targets={s3Targets} volumes={app.appVolumes}>
<Button variant="ghost"><EditIcon /></Button>
</VolumeBackupEditDialog>
<Button variant="ghost" onClick={() => asyncDeleteBackupVolume(volumeBackup.id)}>
<TrashIcon />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<VolumeBackupEditDialog s3Targets={s3Targets} volumes={app.appVolumes}>
<Button>Add Backup Schedule</Button>
</VolumeBackupEditDialog>
</CardFooter>
</Card >
</>;
}
@@ -49,7 +49,7 @@ export default function QuickStackRegistrySettings({
<CardTitle>Registry Storage Location</CardTitle>
<CardDescription>
After a build the Docker Image is stored on the server by default. This can take up a lot of disk space.
If your want to store all Docker Images from the registry in a external S3 Storage you can configure it here.
If your want to store all Docker Images from the registry in an external S3 Storage you can configure it here.
</CardDescription>
</CardHeader>
<Form {...form}>
+1
View File
@@ -88,6 +88,7 @@ class AppService {
appPorts: true,
appFileMounts: true,
};
if (cached) {
return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({
where: {
-1
View File
@@ -56,7 +56,6 @@ class S3TargetService {
data: item as Prisma.S3TargetUncheckedCreateInput
});
}
await namespaceService.createNamespaceIfNotExists(savedItem.id);
} finally {
revalidateTag(Tags.s3Targets());
}
@@ -0,0 +1,89 @@
import { revalidateTag, unstable_cache } from "next/cache";
import dataAccess from "../adapter/db.client";
import { Tags } from "../utils/cache-tag-generator.utils";
import { Prisma, VolumeBackup } from "@prisma/client";
import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model";
class VolumeBackupService {
async getAll(): Promise<VolumeBackupExtendedModel[]> {
return await unstable_cache(() => dataAccess.client.volumeBackup.findMany({
orderBy: {
cron: 'asc'
},
include: {
target: true
}
}),
[Tags.volumeBackups()], {
tags: [Tags.volumeBackups()]
})();
}
async getForApp(appId: string): Promise<VolumeBackupExtendedModel[]> {
return await unstable_cache(() => dataAccess.client.volumeBackup.findMany({
where: {
volume: {
appId
}
},
include: {
target: true
},
orderBy: {
cron: 'asc'
}
}),
[Tags.volumeBackups()], {
tags: [Tags.volumeBackups()]
})();
}
async getById(id: string) {
return dataAccess.client.volumeBackup.findFirstOrThrow({
where: {
id
}
});
}
async save(item: Prisma.VolumeBackupUncheckedCreateInput | Prisma.VolumeBackupUncheckedUpdateInput) {
let savedItem: VolumeBackup;
try {
if (item.id) {
savedItem = await dataAccess.client.volumeBackup.update({
where: {
id: item.id as string
},
data: item,
});
} else {
savedItem = await dataAccess.client.volumeBackup.create({
data: item as Prisma.VolumeBackupUncheckedCreateInput,
});
}
} finally {
revalidateTag(Tags.volumeBackups());
}
return savedItem;
}
async deleteById(id: string) {
const existingItem = await this.getById(id);
if (!existingItem) {
return;
}
try {
await dataAccess.client.volumeBackup.delete({
where: {
id
}
});
} finally {
revalidateTag(Tags.volumeBackups());
}
}
}
const volumeBackupService = new VolumeBackupService();
export default volumeBackupService;
@@ -12,6 +12,10 @@ export class Tags {
return `targets`;
}
static volumeBackups() {
return `volume-backups`;
}
static apps(projectId: string) {
return `apps-${projectId}`;
}
+3 -4
View File
@@ -1,13 +1,12 @@
import { z } from "zod";
import { AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, RelatedAppModel } from "./generated-zod";
import { AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, VolumeBackupModel } from "./generated-zod";
export const AppExtendedZodModel= z.lazy(() => AppModel.extend({
project: ProjectModel,
appDomains: AppDomainModel.array(),
appVolumes: AppVolumeModel.array(),
appPorts: AppPortModel.array(),
appFileMounts: AppFileMountModel.array(),
appVolumes: AppVolumeModel.array(),
}))
export type AppExtendedModel = z.infer<typeof AppExtendedZodModel>;
export type AppExtendedModel = z.infer<typeof AppExtendedZodModel>;
@@ -0,0 +1,13 @@
import { stringToNumber } from "@/shared/utils/zod.utils";
import { z } from "zod";
export const volumeBackupEditZodModel = z.object({
id: z.string().nullable(),
volumeId: z.string(),
targetId: z.string(),
cron: z.string().trim().regex(/^ *(\*|[0-5]?\d) *(\*|[01]?\d) *(\*|[0-2]?\d) *(\*|[0-6]?\d) *(\*|[0-6]?\d) *$/),
//cron: z.string().trim().min(1),
retention: stringToNumber,
});
export type VolumeBackupEditModel = z.infer<typeof volumeBackupEditZodModel>;
+3 -1
View File
@@ -1,6 +1,6 @@
import * as z from "zod"
import { CompleteApp, RelatedAppModel } from "./index"
import { CompleteApp, RelatedAppModel, CompleteVolumeBackup, RelatedVolumeBackupModel } from "./index"
export const AppVolumeModel = z.object({
id: z.string(),
@@ -14,6 +14,7 @@ export const AppVolumeModel = z.object({
export interface CompleteAppVolume extends z.infer<typeof AppVolumeModel> {
app: CompleteApp
volumeBackups: CompleteVolumeBackup[]
}
/**
@@ -23,4 +24,5 @@ export interface CompleteAppVolume extends z.infer<typeof AppVolumeModel> {
*/
export const RelatedAppVolumeModel: z.ZodSchema<CompleteAppVolume> = z.lazy(() => AppVolumeModel.extend({
app: RelatedAppModel,
volumeBackups: RelatedVolumeBackupModel.array(),
}))
+1
View File
@@ -11,3 +11,4 @@ export * from "./appvolume"
export * from "./appfilemount"
export * from "./parameter"
export * from "./s3target"
export * from "./volumebackup"
@@ -1,5 +1,6 @@
import * as z from "zod"
import { CompleteVolumeBackup, RelatedVolumeBackupModel } from "./index"
export const S3TargetModel = z.object({
id: z.string(),
@@ -12,3 +13,16 @@ export const S3TargetModel = z.object({
createdAt: z.date(),
updatedAt: z.date(),
})
export interface CompleteS3Target extends z.infer<typeof S3TargetModel> {
volumeBackups: CompleteVolumeBackup[]
}
/**
* RelatedS3TargetModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedS3TargetModel: z.ZodSchema<CompleteS3Target> = z.lazy(() => S3TargetModel.extend({
volumeBackups: RelatedVolumeBackupModel.array(),
}))
@@ -0,0 +1,28 @@
import * as z from "zod"
import { CompleteAppVolume, RelatedAppVolumeModel, CompleteS3Target, RelatedS3TargetModel } from "./index"
export const VolumeBackupModel = z.object({
id: z.string(),
volumeId: z.string(),
targetId: z.string(),
cron: z.string(),
retention: z.number().int(),
createdAt: z.date(),
updatedAt: z.date(),
})
export interface CompleteVolumeBackup extends z.infer<typeof VolumeBackupModel> {
volume: CompleteAppVolume
target: CompleteS3Target
}
/**
* RelatedVolumeBackupModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const RelatedVolumeBackupModel: z.ZodSchema<CompleteVolumeBackup> = z.lazy(() => VolumeBackupModel.extend({
volume: RelatedAppVolumeModel,
target: RelatedS3TargetModel,
}))
@@ -0,0 +1,8 @@
import { z } from "zod";
import { S3TargetModel, VolumeBackupModel } from "./generated-zod";
export const volumeBackupExtendedZodModel = z.lazy(() => VolumeBackupModel.extend({
target: S3TargetModel
}))
export type VolumeBackupExtendedModel = z.infer<typeof volumeBackupExtendedZodModel>;