diff --git a/prisma/migrations/20241223140802_migration/migration.sql b/prisma/migrations/20241223140802_migration/migration.sql new file mode 100644 index 0000000..cdfa5ee --- /dev/null +++ b/prisma/migrations/20241223140802_migration/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "AppFileMount" ( + "id" TEXT NOT NULL PRIMARY KEY, + "containerMountPath" TEXT NOT NULL, + "content" TEXT NOT NULL, + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppFileMount_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "AppFileMount_appId_containerMountPath_key" ON "AppFileMount"("appId", "containerMountPath"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 54ab3ba..e463ebb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -147,9 +147,10 @@ model App { cpuReservation Int? cpuLimit Int? - appDomains AppDomain[] - appVolumes AppVolume[] - appPorts AppPort[] + appDomains AppDomain[] + appPorts AppPort[] + appVolumes AppVolume[] + appFileMounts AppFileMount[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -194,6 +195,19 @@ model AppVolume { @@unique([appId, containerMountPath]) } +model AppFileMount { + id String @id @default(uuid()) + containerMountPath String + content String + appId String + app App @relation(fields: [appId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([appId, containerMountPath]) +} + model Parameter { name String @id value String diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index 12c4992..96f12ab 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -7,7 +7,7 @@ import GeneralAppSource from "./general/app-source"; import EnvEdit from "./environment/env-edit"; import { App } from "@prisma/client"; import DomainsList from "./domains/domains"; -import StorageList from "./storage/storages"; +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"; @@ -17,6 +17,7 @@ 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"; export default function AppTabs({ app, @@ -58,6 +59,7 @@ export default function AppTabs({ + ) diff --git a/src/app/project/app/[appId]/storage/actions.ts b/src/app/project/app/[appId]/volumes/actions.ts similarity index 68% rename from src/app/project/app/[appId]/storage/actions.ts rename to src/app/project/app/[appId]/volumes/actions.ts index 838434b..38e988b 100644 --- a/src/app/project/app/[appId]/storage/actions.ts +++ b/src/app/project/app/[appId]/volumes/actions.ts @@ -7,6 +7,7 @@ import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils 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"; const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ appId: z.string(), @@ -31,10 +32,10 @@ export const saveVolume = async (prevState: any, inputData: z.infer +export const deleteVolume = async (volumeId: string) => simpleAction(async () => { await getAuthUserSession(); - await appService.deleteVolumeById(volumeID); + await appService.deleteVolumeById(volumeId); return new SuccessActionResult(undefined, 'Successfully deleted volume'); }); @@ -42,7 +43,7 @@ export const getPvcUsage = async (appId: string, projectId: string) => simpleAction(async () => { await getAuthUserSession(); return pvcService.getPvcUsageFromApp(appId, projectId); - }) as Promise>; + }) as Promise>; export const downloadPvcData = async (volumeId: string) => simpleAction(async () => { @@ -50,3 +51,25 @@ export const downloadPvcData = async (volumeId: string) => const fileNameOfDownloadedFile = await pvcService.downloadPvcData(volumeId); return new SuccessActionResult(fileNameOfDownloadedFile, 'Successfully zipped volume data'); // returns the download path on the server }) as Promise>; + +const actionAppFileMountEditZodModel = fileMountEditZodModel.merge(z.object({ + appId: z.string(), + id: z.string().nullish() +})); + +export const saveFileMount = async (prevState: any, inputData: z.infer) => + saveFormAction(inputData, actionAppFileMountEditZodModel, async (validatedData) => { + await getAuthUserSession(); + const existingApp = await appService.getExtendedById(validatedData.appId); + await appService.saveFileMount({ + ...validatedData, + id: validatedData.id ?? undefined, + }); + }); + +export const deleteFileMount = async (fileMountId: string) => + simpleAction(async () => { + await getAuthUserSession(); + await appService.deleteFileMountById(fileMountId); + return new SuccessActionResult(undefined, 'Successfully deleted volume'); + }); \ No newline at end of file diff --git a/src/app/project/app/[appId]/volumes/file-mount-edit-dialog.tsx b/src/app/project/app/[appId]/volumes/file-mount-edit-dialog.tsx new file mode 100644 index 0000000..d69e9d9 --- /dev/null +++ b/src/app/project/app/[appId]/volumes/file-mount-edit-dialog.tsx @@ -0,0 +1,124 @@ +'use client' + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { + Form, + FormControl, + 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 { AppFileMount } from "@prisma/client" +import { ServerActionResult } from "@/shared/model/server-action-error-return.model" +import { saveFileMount } from "./actions" +import { toast } from "sonner" +import { AppExtendedModel } from "@/shared/model/app-extended.model" +import { FileMountEditModel, fileMountEditZodModel } from "@/shared/model/file-mount-edit.model" +import { Textarea } from "@/components/ui/textarea" + +const accessModes = [ + { label: "ReadWriteOnce", value: "ReadWriteOnce" }, + { label: "ReadWriteMany", value: "ReadWriteMany" }, +] as const + +export default function FileMountEditDialog({ children, fileMount, app }: { children: React.ReactNode; fileMount?: AppFileMount; app: AppExtendedModel; }) { + + const [isOpen, setIsOpen] = useState(false); + + + const form = useForm({ + resolver: zodResolver(fileMountEditZodModel), + defaultValues: { + ...fileMount, + } + }); + + const [state, formAction] = useFormState((state: ServerActionResult, payload: FileMountEditModel) => + saveFileMount(state, { + ...payload, + appId: app.id, + id: fileMount?.id + }), FormUtils.getInitialFormState()); + + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('File Mount saved successfully', { + description: "Click \"deploy\" to apply the changes to your app.", + }); + setIsOpen(false); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [state]); + + useEffect(() => { + form.reset(fileMount); + }, [fileMount]); + + return ( + <> +
setIsOpen(true)}> + {children} +
+ setIsOpen(false)}> + + + Edit File Mount + + Configure your custom file mount. The content of the file mount will be available in the container at the specified mount path. + + +
+ form.handleSubmit((data) => { + return formAction(data); + })()}> +
+ ( + + Mount Path Container + + + + + + )} + /> + + ( + + File Content + +