feat/added first version of app templates

This commit is contained in:
biersoeckli
2024-12-21 17:36:18 +00:00
parent 0b7fc5fb27
commit fea1318d6b
11 changed files with 372 additions and 6 deletions

View File

@@ -4,6 +4,8 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m
import appService from "@/server/services/app.service";
import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils";
import { z } from "zod";
import appTemplateService from "@/server/services/app-template.service";
import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model";
const createAppSchema = z.object({
appName: z.string().min(1)
@@ -22,6 +24,13 @@ export const createApp = async (appName: string, projectId: string, appId?: stri
return new SuccessActionResult(returnData, "App created successfully.");
});
export const createAppFromTemplate = async(prevState: any, inputData: AppTemplateModel, projectId: string) =>
saveFormAction(inputData, appTemplateZodModel, async (validatedData) => {
await getAuthUserSession();
await appTemplateService.createAppFromTemplate(projectId, validatedData);
return new SuccessActionResult(undefined, "App created successfully.");
});
export const deleteApp = async (appId: string) =>
simpleAction(async () => {
await getAuthUserSession();

View File

@@ -0,0 +1,55 @@
'use client'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { useState } from "react";
import { AppTemplateModel } from "@/shared/model/app-template.model"
import { allTemplates } from "@/shared/templates/all.templates"
import CreateTemplateAppSetupDialog from "./create-template-app-setup-dialog"
export default function ChooseTemplateDialog({
projectId,
children
}: {
projectId: string;
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [chosenAppTemplate, setChosenAppTemplate] = useState<AppTemplateModel | undefined>(undefined);
return (
<>
<CreateTemplateAppSetupDialog appTemplate={chosenAppTemplate} projectId={projectId}
dialogClosed={() => setChosenAppTemplate(undefined)} />
<div onClick={() => setIsOpen(true)}>{children}</div>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => setIsOpen(false)}>
<DialogContent className="sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle>Create App from Template</DialogTitle>
<DialogDescription>
Choose a Template you want to deploy.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{allTemplates.map((template) => (
<div key={template.name}
className="bg-white rounded-md p-4 border border-gray-200 text-center hover:bg-slate-50 active:bg-slate-100 transition-all cursor-pointer"
onClick={() => {
setIsOpen(false);
setChosenAppTemplate(template);
}} >
<h3 className="text-lg font-semibold py-5">{template.name}</h3>
</div>
))}
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -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 { ServerActionResult } from "@/shared/model/server-action-error-return.model"
import { toast } from "sonner"
import { AppTemplateModel, appTemplateZodModel } from "@/shared/model/app-template.model"
import { createAppFromTemplate } from "./actions"
export default function CreateTemplateAppSetupDialog({
appTemplate,
projectId,
dialogClosed
}: {
appTemplate?: AppTemplateModel;
projectId: string;
dialogClosed?: () => void;
}) {
const [isOpen, setIsOpen] = useState<boolean>(false);
const form = useForm<AppTemplateModel>({
resolver: zodResolver(appTemplateZodModel),
defaultValues: appTemplate
});
const [state, formAction] = useFormState((state: ServerActionResult<any, any>,
payload: AppTemplateModel) => createAppFromTemplate(state, payload, projectId!),
FormUtils.getInitialFormState<typeof appTemplateZodModel>());
useEffect(() => {
if (state.status === 'success') {
form.reset();
toast.success('App created successfully');
setIsOpen(false);
}
FormUtils.mapValidationErrorsToForm<typeof appTemplateZodModel>(state, form);
}, [state]);
const values = form.watch();
useEffect(() => {
setIsOpen(!!appTemplate && !!projectId);
form.reset(appTemplate);
}, [appTemplate, projectId]);
return (
<>
<Dialog open={!!isOpen} onOpenChange={(isOpened) => {
setIsOpen(isOpened);
if (!isOpened && dialogClosed) {
dialogClosed();
}
}}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create App "{appTemplate?.name}"</DialogTitle>
<DialogDescription>
Insert your values for the template.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form action={(e) => form.handleSubmit((data) => {
return formAction(data);
})()}>
{appTemplate?.templates.map((t, templateIndex) =>
<div className="space-y-4">
<FormField
control={form.control}
name={`templates[${templateIndex}].appModel.name` as any}
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{t.inputSettings.map((input, settingsIndex) => (
<FormField
control={form.control}
name={`templates[${templateIndex}].inputSettings[${settingsIndex}].value` as any}
render={({ field }) => (
<FormItem>
<FormLabel>{input.label}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
<p className="text-red-500">{state.message}</p>
<SubmitButton>Save</SubmitButton>
</div>
)}
</form>
</Form >
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -19,6 +19,7 @@ import {
import PageTitle from "@/components/custom/page-title";
import ProjectBreadcrumbs from "./project-breadcrumbs";
import { Plus } from "lucide-react";
import ChooseTemplateDialog from "./choose-template-dialog";
export default async function AppsPage({
@@ -39,6 +40,7 @@ export default async function AppsPage({
<PageTitle
title="Apps"
subtitle={`All Apps for Project "${project.name}"`}>
<ChooseTemplateDialog projectId={projectId}><Button variant="secondary"><Plus /> Create App from Template</Button></ChooseTemplateDialog>
<EditAppDialog projectId={projectId}><Button><Plus /> Create App</Button></EditAppDialog>
</PageTitle>
<AppTable app={data} projectId={project.id} />

View File

@@ -0,0 +1,71 @@
import { AppTemplateContentModel, AppTemplateInputSettingsModel, AppTemplateModel } from "@/shared/model/app-template.model";
import { ServiceException } from "@/shared/model/service.exception.model";
import appService from "./app.service";
import { allTemplates } from "@/shared/templates/all.templates";
class AppTemplateService {
async createAppFromTemplate(projectId: string, template: AppTemplateModel) {
if (!allTemplates.find(x => x.name === template.name)) {
throw new ServiceException(`Template with name '${template.name}' not found.`);
}
for (const tmpl of template.templates) {
await this.createAppFromTemplateContent(projectId, tmpl, tmpl.inputSettings);
}
}
private async createAppFromTemplateContent(projectId: string, template: AppTemplateContentModel, inputValues: AppTemplateInputSettingsModel[]) {
const mappedApp = this.mapTemplateInputValuesToApp(template, inputValues);
const createdApp = await appService.save({
...mappedApp,
projectId
});
const savedDomains = await Promise.all(template.appDomains.map(async x => {
return await appService.saveDomain({
...x,
appId: createdApp.id
});
}));
const savedVolumes = await Promise.all(template.appVolumes.map(async x => {
return await appService.saveVolume({
...x,
appId: createdApp.id
});
}));
const savedPorts = await Promise.all(template.appPorts.map(async x => {
return await appService.savePort({
...x,
appId: createdApp.id
});
}));
return createdApp.id;
}
mapTemplateInputValuesToApp(appTemplate: AppTemplateContentModel,
inputValues: AppTemplateInputSettingsModel[]) {
const app = { ...appTemplate.appModel };
const envVariables = inputValues.filter(x => x.isEnvVar);
const otherConfigValues = inputValues.filter(x => !x.isEnvVar);
for (const envVariable of envVariables) {
app.envVars += `${envVariable.key}=${envVariable.value}\n`;
}
for (const configValue of otherConfigValues) {
(app as any)[configValue.key] = configValue.value;
}
return app;
}
}
const appTermplateService = new AppTemplateService();
export default appTermplateService;

View File

@@ -39,13 +39,12 @@ class BuildService {
if (!forceBuild && latestSuccessfulBuld?.gitCommit && latestRemoteGitHash &&
latestSuccessfulBuld?.gitCommit === latestRemoteGitHash) {
await dlog(deploymentId, `Latest build is already up to date with git repository, using container from last build.`);
console.log(`Last build is already up to date with data in git repo for app ${app.id}`);
if (await registryService.doesImageExist(app.id, 'latest')) {
await dlog(deploymentId, `Latest build is already up to date with git repository, using container from last build.`);
return [latestSuccessfulBuld.name, latestRemoteGitHash, Promise.resolve()];
} else {
await dlog(deploymentId, `Docker Image for last build not found in internal registry, starting new build.`);
await dlog(deploymentId, `Docker Image for last build not found in internal registry, creating new build.`);
}
}
return await this.createAndStartBuildJob(deploymentId, app, latestRemoteGitHash);

View File

@@ -1,7 +1,9 @@
import { z } from "zod";
export const appSourceTypeZodModel = z.enum(["GIT", "CONTAINER"]);
export const appSourceInfoInputZodModel = z.object({
sourceType: z.enum(["GIT", "CONTAINER"]),
sourceType: appSourceTypeZodModel,
containerImageSource: z.string().nullish(),
gitUrl: z.string().trim().nullish(),

View File

@@ -0,0 +1,48 @@
import { z } from "zod";
import { AppDomainModel, AppModel, AppPortModel, AppVolumeModel, RelatedAppDomainModel, RelatedAppPortModel, RelatedAppVolumeModel } from "./generated-zod";
import { appSourceTypeZodModel } from "./app-source-info.model";
import { appVolumeTypeZodModel } from "./volume-edit.model";
const appModelWithRelations = z.lazy(() => AppModel.extend({
projectId: z.undefined(),
dockerfilePath: z.undefined(),
sourceType: appSourceTypeZodModel,
id: z.undefined(),
createdAt: z.undefined(),
updatedAt: z.undefined(),
}));
export const appTemplateInputSettingsZodModel = z.object({
key: z.string(),
label: z.string(),
value: z.any(),
isEnvVar: z.boolean(),
});
export type AppTemplateInputSettingsModel = z.infer<typeof appTemplateInputSettingsZodModel>;
export const appTemplateContentZodModel = z.object({
inputSettings: appTemplateInputSettingsZodModel.array(),
appModel: appModelWithRelations,
appDomains: AppDomainModel.array(),
appVolumes: AppVolumeModel.extend({
accessMode: appVolumeTypeZodModel,
id: z.undefined(),
appId: z.undefined(),
createdAt: z.undefined(),
updatedAt: z.undefined(),
}).array(),
appPorts: AppPortModel.extend({
id: z.undefined(),
appId: z.undefined(),
createdAt: z.undefined(),
updatedAt: z.undefined(),
}).array(),
});
export type AppTemplateContentModel = z.infer<typeof appTemplateContentZodModel>;
export const appTemplateZodModel = z.object({
name: z.string(),
templates: appTemplateContentZodModel.array(),
});
export type AppTemplateModel = z.infer<typeof appTemplateZodModel>;

View File

@@ -1,11 +1,12 @@
import { stringToNumber } from "@/shared/utils/zod.utils";
import { access } from "fs";
import { z } from "zod";
export const appVolumeTypeZodModel = z.enum(["ReadWriteOnce", "ReadWriteMany"]);
export const appVolumeEditZodModel = z.object({
containerMountPath: z.string().trim().min(1),
size: stringToNumber,
accessMode: z.string().min(1).nullish(),
accessMode: appVolumeTypeZodModel.nullish().or(z.string().nullish()),
})
export type AppVolumeEditModel = z.infer<typeof appVolumeEditZodModel>;

View File

@@ -0,0 +1,6 @@
import { AppTemplateModel } from "../model/app-template.model";
import { mariadbAppTemplate } from "./mariadb.template";
export const allTemplates: AppTemplateModel[] = [
mariadbAppTemplate
];

View File

@@ -0,0 +1,49 @@
import { AppTemplateModel } from "../model/app-template.model";
export const mariadbAppTemplate: AppTemplateModel = {
name: "MariaDB",
templates: [{
inputSettings: [
{
key: "MYSQL_ROOT_PASSWORD",
label: "Root Password",
value: "mariadb",
isEnvVar: true,
},
{
key: "MYSQL_USER",
label: "User",
value: "mariadb",
isEnvVar: true,
},
{
key: "MYSQL_PASSWORD",
label: "Password",
value: "mariadb",
isEnvVar: true,
},
{
key: "MYSQL_DATABASE",
label: "Database",
value: "defaultdb",
isEnvVar: true,
},
],
appModel: {
name: "MariaDb",
sourceType: 'CONTAINER',
containerImageSource: "mariadb:latest",
replicas: 1,
envVars: ``,
},
appDomains: [],
appVolumes: [{
size: 500,
containerMountPath: '/var/lib/mysql',
accessMode: 'ReadWriteOnce'
}],
appPorts: [{
port: 3306,
}]
}]
}