mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-01-02 01:30:38 -06:00
Add Volumes Page
This commit is contained in:
23
prisma/migrations/20241028143028_migration/migration.sql
Normal file
23
prisma/migrations/20241028143028_migration/migration.sql
Normal 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;
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
28
src/app/project/app/[tabName]/storage/actions.ts
Normal file
28
src/app/project/app/[tabName]/storage/actions.ts
Normal 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');
|
||||
});
|
||||
113
src/app/project/app/[tabName]/storage/storage-edit-overlay.tsx
Normal file
113
src/app/project/app/[tabName]/storage/storage-edit-overlay.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
75
src/app/project/app/[tabName]/storage/storages.tsx
Normal file
75
src/app/project/app/[tabName]/storage/storages.tsx
Normal 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 >
|
||||
|
||||
</>;
|
||||
}
|
||||
9
src/model/volume-edit.model.ts
Normal file
9
src/model/volume-edit.model.ts
Normal 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>;
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user