mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 05:29:23 -06:00
feat/added first version of app templates
This commit is contained in:
@@ -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();
|
||||
|
||||
55
src/app/project/choose-template-dialog.tsx
Normal file
55
src/app/project/choose-template-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
124
src/app/project/create-template-app-setup-dialog.tsx
Normal file
124
src/app/project/create-template-app-setup-dialog.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
71
src/server/services/app-template.service.ts
Normal file
71
src/server/services/app-template.service.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
48
src/shared/model/app-template.model.ts
Normal file
48
src/shared/model/app-template.model.ts
Normal 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>;
|
||||
@@ -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>;
|
||||
6
src/shared/templates/all.templates.ts
Normal file
6
src/shared/templates/all.templates.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { AppTemplateModel } from "../model/app-template.model";
|
||||
import { mariadbAppTemplate } from "./mariadb.template";
|
||||
|
||||
export const allTemplates: AppTemplateModel[] = [
|
||||
mariadbAppTemplate
|
||||
];
|
||||
49
src/shared/templates/mariadb.template.ts
Normal file
49
src/shared/templates/mariadb.template.ts
Normal 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,
|
||||
}]
|
||||
}]
|
||||
}
|
||||
Reference in New Issue
Block a user