diff --git a/prisma/migrations/20241028143028_migration/migration.sql b/prisma/migrations/20241028143028_migration/migration.sql new file mode 100644 index 0000000..6901f27 --- /dev/null +++ b/prisma/migrations/20241028143028_migration/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - Added the required column `size` to the `AppVolume` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AppVolume" ( + "id" TEXT NOT NULL PRIMARY KEY, + "containerMountPath" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppVolume_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_AppVolume" ("appId", "containerMountPath", "createdAt", "id", "updatedAt") SELECT "appId", "containerMountPath", "createdAt", "id", "updatedAt" FROM "AppVolume"; +DROP TABLE "AppVolume"; +ALTER TABLE "new_AppVolume" RENAME TO "AppVolume"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2eb2a0e..38e79be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,6 +167,7 @@ model AppDomain { model AppVolume { id String @id @default(uuid()) containerMountPath String + size Int appId String app App @relation(fields: [appId], references: [id]) diff --git a/src/app/project/app/[tabName]/app-tabs.tsx b/src/app/project/app/[tabName]/app-tabs.tsx index 0f84fe4..1fea22a 100644 --- a/src/app/project/app/[tabName]/app-tabs.tsx +++ b/src/app/project/app/[tabName]/app-tabs.tsx @@ -7,6 +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 { AppExtendedModel } from "@/model/app-extended.model"; export default function AppTabs({ @@ -42,7 +43,9 @@ export default function AppTabs({ - storage + + + ) } diff --git a/src/app/project/app/[tabName]/domains/domain-edit.-overlay.tsx b/src/app/project/app/[tabName]/domains/domain-edit-overlay.tsx similarity index 100% rename from src/app/project/app/[tabName]/domains/domain-edit.-overlay.tsx rename to src/app/project/app/[tabName]/domains/domain-edit-overlay.tsx diff --git a/src/app/project/app/[tabName]/domains/domains.tsx b/src/app/project/app/[tabName]/domains/domains.tsx index 7f3c351..96d84f1 100644 --- a/src/app/project/app/[tabName]/domains/domains.tsx +++ b/src/app/project/app/[tabName]/domains/domains.tsx @@ -22,7 +22,7 @@ import { AppExtendedModel } from "@/model/app-extended.model"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { CheckIcon, CrossIcon, DeleteIcon, EditIcon, TrashIcon, XIcon } from "lucide-react"; -import DialogEditDialog from "./domain-edit.-overlay"; +import DialogEditDialog from "./domain-edit-overlay"; import { Toast } from "@/lib/toast.utils"; import { deleteDomain } from "./actions"; diff --git a/src/app/project/app/[tabName]/storage/actions.ts b/src/app/project/app/[tabName]/storage/actions.ts new file mode 100644 index 0000000..fe341d8 --- /dev/null +++ b/src/app/project/app/[tabName]/storage/actions.ts @@ -0,0 +1,28 @@ +'use server' + +import { appVolumeEditZodModel } from "@/model/volume-edit.model"; +import { SuccessActionResult } from "@/model/server-action-error-return.model"; +import appService from "@/server/services/app.service"; +import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { z } from "zod"; + +const actionAppVolumeEditZodModel = appVolumeEditZodModel.merge(z.object({ + appId: z.string(), + id: z.string().nullish() +})); + +export const saveVolume = async (prevState: any, inputData: z.infer) => + saveFormAction(inputData, actionAppVolumeEditZodModel, async (validatedData) => { + await getAuthUserSession(); + await appService.saveVolume({ + ...validatedData, + id: validatedData.id ?? undefined + }); + }); + + export const deleteVolume = async (volumeID: string) => + simpleAction(async () => { + await getAuthUserSession(); + await appService.deleteVolumeById(volumeID); + return new SuccessActionResult(undefined, 'Successfully deleted volume'); + }); diff --git a/src/app/project/app/[tabName]/storage/storage-edit-overlay.tsx b/src/app/project/app/[tabName]/storage/storage-edit-overlay.tsx new file mode 100644 index 0000000..2c3fb93 --- /dev/null +++ b/src/app/project/app/[tabName]/storage/storage-edit-overlay.tsx @@ -0,0 +1,113 @@ +'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 "@/lib/form.utilts"; +import { SubmitButton } from "@/components/custom/submit-button"; +import { AppVolume } from "@prisma/client" +import { AppVolumeEditModel, appVolumeEditZodModel } from "@/model/volume-edit.model" +import { ServerActionResult } from "@/model/server-action-error-return.model" +import { saveVolume } from "./actions" +import { toast } from "sonner" + + + +export default function DialogEditDialog({ children, volume, appId }: { children: React.ReactNode; volume?: AppVolume; appId: string; }) { + + const [isOpen, setIsOpen] = useState(false); + + + const form = useForm({ + resolver: zodResolver(appVolumeEditZodModel), + defaultValues: { + ...volume, + } + }); + + const [state, formAction] = useFormState((state: ServerActionResult, payload: AppVolumeEditModel) => + saveVolume(state, { + ...payload, + appId, + id: volume?.id + }), FormUtils.getInitialFormState()); + + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Volume saved successfully'); + setIsOpen(false); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [state]); + + + return ( + <> +
setIsOpen(true)}> + {children} +
+ setIsOpen(false)}> + + + Edit Volume + + Configure your custom volume for this container. + + +
+ form.handleSubmit((data) => { + return formAction(data); + })()}> +
+ ( + + Mount Path Container + + + + + + )} + /> + + ( + + Size in GB + + + + + + )} + /> +

{state.message}

+ Save +
+
+ +
+
+ + ) + + + +} \ No newline at end of file diff --git a/src/app/project/app/[tabName]/storage/storages.tsx b/src/app/project/app/[tabName]/storage/storages.tsx new file mode 100644 index 0000000..5c2ed23 --- /dev/null +++ b/src/app/project/app/[tabName]/storage/storages.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { SubmitButton } from "@/components/custom/submit-button"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { FormUtils } from "@/lib/form.utilts"; +import { AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/model/app-source-info.model"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useFormState } from "react-dom"; +import { ServerActionResult } from "@/model/server-action-error-return.model"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Label } from "@/components/ui/label"; +import { AppRateLimitsModel, appRateLimitsZodModel } from "@/model/app-rate-limits.model"; +import { App } from "@prisma/client"; +import { useEffect } from "react"; +import { toast } from "sonner"; +import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/model/env-edit.model"; +import { Textarea } from "@/components/ui/textarea"; +import { AppExtendedModel } from "@/model/app-extended.model"; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { CheckIcon, CrossIcon, DeleteIcon, EditIcon, TrashIcon, XIcon } from "lucide-react"; +import DialogEditDialog from "./storage-edit-overlay"; +import { Toast } from "@/lib/toast.utils"; +import { deleteVolume } from "./actions"; + + +export default function StorageList({ app }: { + app: AppExtendedModel +}) { + return <> + + + Storage + Add one or more volumes to your application. + + + + {app.appVolumes.length} Storage + + + Mount Path + Size in GB + Action + + + + {app.appVolumes.map(volume => ( + + {volume.containerMountPath} + {volume.size} + + + + + + + + ))} + +
+
+ + + + + +
+ + ; +} \ No newline at end of file diff --git a/src/model/volume-edit.model.ts b/src/model/volume-edit.model.ts new file mode 100644 index 0000000..a147fef --- /dev/null +++ b/src/model/volume-edit.model.ts @@ -0,0 +1,9 @@ +import { stringToNumber } from "@/lib/zod.utils"; +import { z } from "zod"; + +export const appVolumeEditZodModel = z.object({ + containerMountPath: z.string().trim().min(1), + size: stringToNumber, +}) + +export type AppVolumeEditModel = z.infer; \ No newline at end of file diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index c85fa81..bfc33ef 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -1,7 +1,7 @@ import { revalidateTag, unstable_cache } from "next/cache"; import dataAccess from "../adapter/db.client"; import { Tags } from "../utils/cache-tag-generator.utils"; -import { App, AppDomain, Prisma } from "@prisma/client"; +import { App, AppDomain, AppVolume, Prisma } from "@prisma/client"; import { DefaultArgs } from "@prisma/client/runtime/library"; import { AppExtendedModel } from "@/model/app-extended.model"; import { ServiceException } from "@/model/service.exception.model"; @@ -153,6 +153,62 @@ class AppService { revalidateTag(Tags.apps(existingDomain.app.projectId)); } } + + async saveVolume(volumeToBeSaved: Prisma.AppVolumeUncheckedCreateInput | Prisma.AppVolumeUncheckedUpdateInput) { + let savedItem: AppVolume; + const existingApp = await this.getExtendedById(volumeToBeSaved.appId as string); + const existingAppWithSameVolumeMountPath = await dataAccess.client.appVolume.findFirst({ + where: { + appId: volumeToBeSaved.appId as string, + containerMountPath: volumeToBeSaved.containerMountPath as string, + } + }); + if (volumeToBeSaved.appId == existingAppWithSameVolumeMountPath?.appId && volumeToBeSaved.id !== existingAppWithSameVolumeMountPath?.id) { + throw new ServiceException("Volume mount path is already in use from another volume within the same app."); + } + try { + if (volumeToBeSaved.id) { + savedItem = await dataAccess.client.appVolume.update({ + where: { + id: volumeToBeSaved.id as string + }, + data: volumeToBeSaved + }); + } else { + savedItem = await dataAccess.client.appVolume.create({ + data: volumeToBeSaved as Prisma.AppVolumeUncheckedCreateInput + }); + } + + } finally { + revalidateTag(Tags.apps(existingApp.projectId as string)); + revalidateTag(Tags.app(existingApp.id as string)); + } + return savedItem; + } + + async deleteVolumeById(id: string) { + const existingVolume = await dataAccess.client.appVolume.findFirst({ + where: { + id + }, include: { + app: true + } + }); + if (!existingVolume) { + return; + } + try { + await dataAccess.client.appVolume.delete({ + where: { + id + } + }); + } finally { + revalidateTag(Tags.app(existingVolume.appId)); + revalidateTag(Tags.apps(existingVolume.app.projectId)); + } + } } const appService = new AppService();