added domain tab in app

This commit is contained in:
stefan.meyer
2024-10-25 09:05:07 +00:00
parent d6445997d7
commit 6eb6ea55ac
20 changed files with 358 additions and 66 deletions

View File

@@ -0,0 +1,38 @@
/*
Warnings:
- The primary key for the `AppDomain` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `AppVolume` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The required column `id` was added to the `AppDomain` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
- The required column `id` was added to the `AppVolume` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_AppDomain" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostname" TEXT NOT NULL,
"port" INTEGER NOT NULL,
"useSsl" BOOLEAN NOT NULL DEFAULT true,
"appId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "AppDomain_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_AppDomain" ("appId", "createdAt", "hostname", "port", "updatedAt", "useSsl") SELECT "appId", "createdAt", "hostname", "port", "updatedAt", "useSsl" FROM "AppDomain";
DROP TABLE "AppDomain";
ALTER TABLE "new_AppDomain" RENAME TO "AppDomain";
CREATE TABLE "new_AppVolume" (
"id" TEXT NOT NULL PRIMARY KEY,
"containerMountPath" TEXT 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", "updatedAt") SELECT "appId", "containerMountPath", "createdAt", "updatedAt" FROM "AppVolume";
DROP TABLE "AppVolume";
ALTER TABLE "new_AppVolume" RENAME TO "AppVolume";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[hostname]` on the table `AppDomain` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "AppDomain_hostname_key" ON "AppDomain"("hostname");

View File

@@ -152,7 +152,8 @@ model App {
}
model AppDomain {
hostname String
id String @id @default(uuid())
hostname String @unique
port Int
useSsl Boolean @default(true)
appId String
@@ -160,17 +161,14 @@ model AppDomain {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([hostname, appId])
}
model AppVolume {
id String @id @default(uuid())
containerMountPath String
appId String
app App @relation(fields: [appId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([containerMountPath, appId])
}

View File

@@ -1,17 +1,28 @@
'use server'
import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/model/env-edit.model";
import { appDomainEditZodModel } from "@/model/domain-edit.model";
import { SuccessActionResult } from "@/model/server-action-error-return.model";
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction } from "@/server/utils/action-wrapper.utils";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({
appId: z.string(),
id: z.string().nullish()
}));
export const saveEnvVariables = async (prevState: any, inputData: AppEnvVariablesModel, appId: string) =>
saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => {
export const saveDomain = async (prevState: any, inputData: z.infer<typeof actionAppDomainEditZodModel>) =>
saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => {
await getAuthUserSession();
const existingApp = await appService.getById(appId);
await appService.save({
...existingApp,
await appService.saveDomain({
...validatedData,
id: appId,
id: validatedData.id ?? undefined
});
});
export const deleteDomain = async (domainId: string) =>
simpleAction(async () => {
await getAuthUserSession();
await appService.deleteDomainById(domainId);
return new SuccessActionResult(undefined, 'Successfully deleted domain');
});
});

View File

@@ -0,0 +1,117 @@
'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 { AppDomain } from "@prisma/client"
import { AppDomainEditModel, appDomainEditZodModel } from "@/model/domain-edit.model"
import { ServerActionResult } from "@/model/server-action-error-return.model"
import { saveDomain } from "./actions"
import { toast } from "sonner"
import CheckboxFormField from "@/components/custom/checkbox-form-field"
export default function DialogEditDialog({ children, domain, appId }: { children: React.ReactNode; domain?: AppDomain; appId: string; }) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const form = useForm<AppDomainEditModel>({
resolver: zodResolver(appDomainEditZodModel),
defaultValues: {
...domain,
useSsl: domain?.useSsl === false ? false : true
}
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>, payload: AppDomainEditModel) =>
saveDomain(state, {
...payload,
appId,
id: domain?.id
}), FormUtils.getInitialFormState<typeof appDomainEditZodModel>());
useEffect(() => {
if (state.status === 'success') {
form.reset();
toast.success('Domain saved successfully');
setIsOpen(false);
}
FormUtils.mapValidationErrorsToForm<typeof appDomainEditZodModel>(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 Domain</DialogTitle>
<DialogDescription>
Configure your custom domain for this application. Note that the domain must be pointing to the server IP address.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
<div className="space-y-4">
<FormField
control={form.control}
name="hostname"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>App Port</FormLabel>
<FormControl>
<Input type="number" placeholder="ex. 80" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<CheckboxFormField form={form} name="useSsl" label="use HTTPS" />
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
</form>
</Form >
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -7,7 +7,6 @@ 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 { saveEnvVariables } from "./actions";
import { useFormState } from "react-dom";
import { ServerActionResult } from "@/model/server-action-error-return.model";
import { Input } from "@/components/ui/input";
@@ -22,7 +21,10 @@ 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 } from "lucide-react";
import { CheckIcon, CrossIcon, DeleteIcon, EditIcon, TrashIcon, XIcon } from "lucide-react";
import DialogEditDialog from "./domain-edit.-overlay";
import { Toast } from "@/lib/toast.utils";
import { deleteDomain } from "./actions";
export default function DomainsList({ app }: {
@@ -42,7 +44,7 @@ export default function DomainsList({ app }: {
<TableHead>Name</TableHead>
<TableHead>Port</TableHead>
<TableHead>SSL</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-[100px]">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -50,15 +52,24 @@ export default function DomainsList({ app }: {
<TableRow key={domain.hostname}>
<TableCell className="font-medium">{domain.hostname}</TableCell>
<TableCell className="font-medium">{domain.port}</TableCell>
<TableCell className="font-medium">{domain.useSsl ? <CheckIcon /> : <CrossIcon />}</TableCell>
<TableCell className="font-medium"><Button variant="ghost">Edit</Button></TableCell>
<TableCell className="font-medium">{domain.useSsl ? <CheckIcon /> : <XIcon />}</TableCell>
<TableCell className="font-medium flex gap-2">
<DialogEditDialog appId={app.id} domain={domain}>
<Button variant="ghost"><EditIcon /></Button>
</DialogEditDialog>
<Button variant="ghost" onClick={() => Toast.fromAction(() => deleteDomain(domain.id))}>
<TrashIcon />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
<CardFooter>
<Button>Add Domain</Button>
<DialogEditDialog appId={app.id}>
<Button>Add Domain</Button>
</DialogEditDialog>
</CardFooter>
</Card >

View File

@@ -38,7 +38,6 @@ export default function EnvEdit({ app }: {
FormUtils.mapValidationErrorsToForm<typeof appEnvVariablesZodModel>(state, form);
}, [state]);
const sourceTypeField = form.watch();
return <>
<Card>
<CardHeader>

View File

@@ -1,4 +1,17 @@
import { z } from "zod";
import { z, ZodObject, ZodRawShape } from "zod";
export class ZodUtils {
static getFieldNamesAndTypes<TObjectType extends ZodRawShape>(schema: ZodObject<TObjectType>): Map<string, string> {
const shape = schema.shape;
const fieldMap = new Map<string, string>();
for (const [key, value] of Object.entries(shape)) {
fieldMap.set(key, value._def.typeName);
}
return fieldMap;
}
}
export const stringToNumber = z.union([z.string(), z.number()])
.transform((val) => {
@@ -58,22 +71,37 @@ export const stringToDate = z.union([z.string(), z.date()])
message: 'Der Eingegebene Wert muss ein Datum sein.',
});
export const stringToOptionalBoolean = z.preprocess((val) => {
if (val === null || val === undefined) {
return null;
}
if (typeof val === 'string') {
if (val === 'true') {
return true;
}
if (val === 'false') {
return false;
}
return null;
}
return val;
}, z.boolean().nullish());
/*z.union([z.string(), z.number(), z.null(), z.undefined()])
.transform((val) => {
if (val === null || val === undefined) {
return null;
export const stringToBoolean = z.preprocess((val) => {
if (val === null || val === undefined) {
return false;
}
if (typeof val === 'string') {
if (val === 'true') {
return true;
}
if (typeof val === 'string') {
const parsed = parseFloat(val);
if (isNaN(parsed)) {
return null;
}
return parsed;
if (val === 'false') {
return false;
}
return false;
}
if (typeof val === 'boolean') {
return val;
})
.refine((val) => val === null || typeof val === 'number', {
message: 'Der Eingegebene Wert muss eine Zahl oder leer sein.',
});
*/
}
return false; // default false
}, z.boolean());

View File

@@ -0,0 +1,10 @@
import { stringToBoolean, stringToNumber } from "@/lib/zod.utils";
import { z } from "zod";
export const appDomainEditZodModel = z.object({
hostname: z.string().trim().min(1),
useSsl: stringToBoolean,
port: stringToNumber,
})
export type AppDomainEditModel = z.infer<typeof appDomainEditZodModel>;

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteUser, RelatedUserModel } from "./index"
export const AccountModel = z.object({

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppVolume, RelatedAppVolumeModel } from "./index"
export const AppModel = z.object({

View File

@@ -1,8 +1,9 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteApp, RelatedAppModel } from "./index"
export const AppDomainModel = z.object({
id: z.string(),
hostname: z.string(),
port: z.number().int(),
useSsl: z.boolean(),

View File

@@ -1,8 +1,9 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteApp, RelatedAppModel } from "./index"
export const AppVolumeModel = z.object({
id: z.string(),
containerMountPath: z.string(),
appId: z.string(),
createdAt: z.date(),

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteUser, RelatedUserModel } from "./index"
export const AuthenticatorModel = z.object({

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteApp, RelatedAppModel } from "./index"
export const ProjectModel = z.object({

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteUser, RelatedUserModel } from "./index"
export const SessionModel = z.object({

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
import { CompleteAccount, RelatedAccountModel, CompleteSession, RelatedSessionModel, CompleteAuthenticator, RelatedAuthenticatorModel } from "./index"
export const UserModel = z.object({

View File

@@ -1,5 +1,5 @@
import * as z from "zod"
import * as imports from "../../../prisma/null"
export const VerificationTokenModel = z.object({
identifier: z.string(),

View File

@@ -1,9 +1,10 @@
import { revalidateTag, unstable_cache } from "next/cache";
import dataAccess from "../adapter/db.client";
import { Tags } from "../utils/cache-tag-generator.utils";
import { App, Prisma, Project } from "@prisma/client";
import { App, AppDomain, 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";
class AppService {
@@ -18,6 +19,7 @@ class AppService {
}
});
revalidateTag(Tags.apps(existingItem.projectId));
revalidateTag(Tags.app(existingItem.id));
}
async getAllAppsByProjectID(projectId: string) {
@@ -31,8 +33,8 @@ class AppService {
})(projectId as string);
}
async getExtendedById(id: string): Promise<AppExtendedModel> {
return dataAccess.client.app.findFirstOrThrow({
async getExtendedById(appId: string): Promise<AppExtendedModel> {
return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({
where: {
id
}, include: {
@@ -40,35 +42,99 @@ class AppService {
appDomains: true,
appVolumes: true,
}
});
}),
[Tags.app(appId)], {
tags: [Tags.app(appId)]
})(appId);
}
async getById(id: string) {
return dataAccess.client.app.findFirstOrThrow({
async getById(appId: string) {
return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({
where: {
id
}
});
}),
[Tags.app(appId)], {
tags: [Tags.app(appId)]
})(appId);
}
async save(item: Prisma.AppUncheckedCreateInput | Prisma.AppUncheckedUpdateInput) {
let savedItem: Prisma.Prisma__AppClient<App, never, DefaultArgs>;
if (item.id) {
savedItem = dataAccess.client.app.update({
where: {
id: item.id as string
},
data: item
});
} else {
savedItem = dataAccess.client.app.create({
data: item as Prisma.AppUncheckedCreateInput
});
try {
if (item.id) {
savedItem = dataAccess.client.app.update({
where: {
id: item.id as string
},
data: item
});
} else {
savedItem = dataAccess.client.app.create({
data: item as Prisma.AppUncheckedCreateInput
});
}
} finally {
revalidateTag(Tags.apps(item.projectId as string));
revalidateTag(Tags.app(item.id as string));
}
revalidateTag(Tags.apps(item.projectId as string));
return savedItem;
}
async saveDomain(domainToBeSaved: Prisma.AppDomainUncheckedCreateInput | Prisma.AppDomainUncheckedUpdateInput) {
let savedItem: AppDomain;
const existingApp = await this.getExtendedById(domainToBeSaved.appId as string);
const existingDomainWithSameHostname = await dataAccess.client.appDomain.findFirst({
where: {
hostname: domainToBeSaved.hostname as string,
}
});
if (domainToBeSaved.id && domainToBeSaved.id !== existingDomainWithSameHostname?.id) {
throw new ServiceException("Hostname is already in use by this or another app.");
}
try {
if (domainToBeSaved.id) {
savedItem = await dataAccess.client.appDomain.update({
where: {
id: domainToBeSaved.id as string
},
data: domainToBeSaved
});
} else {
savedItem = await dataAccess.client.appDomain.create({
data: domainToBeSaved as Prisma.AppDomainUncheckedCreateInput
});
}
} finally {
revalidateTag(Tags.apps(existingApp.projectId as string));
revalidateTag(Tags.app(existingApp.id as string));
}
return savedItem;
}
async deleteDomainById(id: string) {
const existingDomain = await dataAccess.client.appDomain.findFirst({
where: {
id
}, include: {
app: true
}
});
if (!existingDomain) {
return;
}
try {
await dataAccess.client.appDomain.delete({
where: {
id
}
});
} finally {
revalidateTag(Tags.app(existingDomain.appId));
revalidateTag(Tags.apps(existingDomain.app.projectId));
}
}
}
const appService = new AppService();

View File

@@ -11,4 +11,8 @@ export class Tags {
static apps(projectId: string) {
return `apps-${projectId}`;
}
static app(appId: string) {
return `app-${appId}`;
}
}