Add Volumes Page

This commit is contained in:
stefan.meyer
2024-10-28 17:56:24 +00:00
parent 54a1a0fff2
commit 9b3bad0861
10 changed files with 311 additions and 3 deletions

View File

@@ -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;

View File

@@ -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])

View File

@@ -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({
<TabsContent value="domains" className="space-y-4">
<DomainsList app={app} />
</TabsContent>
<TabsContent value="storage">storage</TabsContent>
<TabsContent value="storage" className="space-y-4">
<StorageList app={app} />
</TabsContent>
</Tabs>
)
}

View File

@@ -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";

View File

@@ -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<typeof actionAppVolumeEditZodModel>) =>
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');
});

View File

@@ -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<boolean>(false);
const form = useForm<AppVolumeEditModel>({
resolver: zodResolver(appVolumeEditZodModel),
defaultValues: {
...volume,
}
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppVolumeEditModel) =>
saveVolume(state, {
...payload,
appId,
id: volume?.id
}), FormUtils.getInitialFormState<typeof appVolumeEditZodModel>());
useEffect(() => {
if (state.status === 'success') {
form.reset();
toast.success('Volume saved successfully');
setIsOpen(false);
}
FormUtils.mapValidationErrorsToForm<typeof appVolumeEditZodModel>(state, form);
}, [state]);
return (
<>
<div onClick={() => setIsOpen(true)}>
{children}
</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Volume</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="containerMountPath"
render={({ field }) => (
<FormItem>
<FormLabel>Mount Path Container</FormLabel>
<FormControl>
<Input placeholder="ex. /data" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="size"
render={({ field }) => (
<FormItem>
<FormLabel>Size in GB</FormLabel>
<FormControl>
<Input type="number" placeholder="ex. 20" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
</form>
</Form >
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -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 <>
<Card>
<CardHeader>
<CardTitle>Storage</CardTitle>
<CardDescription>Add one or more volumes to your application.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableCaption>{app.appVolumes.length} Storage</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Mount Path</TableHead>
<TableHead>Size in GB</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{app.appVolumes.map(volume => (
<TableRow key={volume.containerMountPath}>
<TableCell className="font-medium">{volume.containerMountPath}</TableCell>
<TableCell className="font-medium">{volume.size}</TableCell>
<TableCell className="font-medium flex gap-2">
<DialogEditDialog appId={app.id} volume={volume}>
<Button variant="ghost"><EditIcon /></Button>
</DialogEditDialog>
<Button variant="ghost" onClick={() => Toast.fromAction(() => deleteVolume(volume.id))}>
<TrashIcon />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<DialogEditDialog appId={app.id}>
<Button>Add Volume</Button>
</DialogEditDialog>
</CardFooter>
</Card >
</>;
}

View File

@@ -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<typeof appVolumeEditZodModel>;

View File

@@ -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();