feat: create project overhaul, slight fixes to other stuff
This commit is contained in:
parent
e52597c349
commit
b54a710835
|
@ -44,6 +44,7 @@
|
|||
"chalk": "^5.3.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"common-tags": "^1.8.2",
|
||||
"cookie": "^0.6.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"docker-cli-js": "^2.10.0",
|
||||
|
@ -79,6 +80,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/common-tags": "^1.8.4",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/dockerode": "^3.3.23",
|
||||
"@types/eslint": "^8.56.1",
|
||||
|
|
|
@ -86,6 +86,9 @@ dependencies:
|
|||
clsx:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
common-tags:
|
||||
specifier: ^1.8.2
|
||||
version: 1.8.2
|
||||
cookie:
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
|
@ -187,6 +190,9 @@ devDependencies:
|
|||
'@types/better-sqlite3':
|
||||
specifier: ^7.6.8
|
||||
version: 7.6.8
|
||||
'@types/common-tags':
|
||||
specifier: ^1.8.4
|
||||
version: 1.8.4
|
||||
'@types/cookie':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
|
@ -2713,6 +2719,10 @@ packages:
|
|||
'@types/node': 20.10.6
|
||||
dev: false
|
||||
|
||||
/@types/common-tags@1.8.4:
|
||||
resolution: {integrity: sha512-S+1hLDJPjWNDhcGxsxEbepzaxWqURP/o+3cP4aa2w7yBXgdcmKGQtZzP8JbyfOd0m+33nh+8+kvxYE2UJtBDkg==}
|
||||
dev: true
|
||||
|
||||
/@types/connect@3.4.38:
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
dependencies:
|
||||
|
@ -3849,6 +3859,11 @@ packages:
|
|||
engines: {node: ^12.20.0 || >=14}
|
||||
dev: true
|
||||
|
||||
/common-tags@1.8.2:
|
||||
resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
dev: false
|
||||
|
||||
/component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
dev: true
|
||||
|
|
|
@ -27,6 +27,7 @@ export default function LoginForm() {
|
|||
onSuccess: (data) => {
|
||||
toast.success("Successfully logged in!", { id: toastLoading });
|
||||
router.push("/#");
|
||||
router.refresh();
|
||||
},
|
||||
|
||||
trpc: {
|
||||
|
|
|
@ -1,225 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { Suspense, useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { FormInputGroup } from "~/components/FormInput";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
// kinda large dependency, but syntax highlighting is nice
|
||||
const Editor = React.lazy(() => import("./YAMLEditor"));
|
||||
|
||||
enum ProjectType {
|
||||
Template = "template",
|
||||
Compose = "compose",
|
||||
Blank = "blank",
|
||||
}
|
||||
|
||||
export function CreateProjectButton() {
|
||||
const create = api.projects.create.useMutation({
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: "",
|
||||
internalName: "",
|
||||
type: ProjectType.Template,
|
||||
composeURL: "",
|
||||
composeFile:
|
||||
"version: '3.8'\n\nservices:\n# Everything written here will be transformed into a HostForge service.\n# Please note that some options are not supported by Docker Swarm and not all options are supported by HostForge.",
|
||||
},
|
||||
|
||||
validate: {
|
||||
composeFile: (value, others) => {
|
||||
if (others.type === ProjectType.Compose && !value) {
|
||||
return "A Docker Compose file is required";
|
||||
}
|
||||
},
|
||||
|
||||
name: (value) => !value && "A project name is required",
|
||||
|
||||
internalName: (value) => {
|
||||
if (!value) {
|
||||
return "An internal project name is required";
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9\-]+$/g.test(value)) {
|
||||
return "Internal project names can only contain letters, numbers, and dashes";
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fetchComposeFile = useQuery({
|
||||
queryKey: ["fetchComposeFile", form.values.composeURL],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(form.values.composeURL);
|
||||
return await response.text();
|
||||
},
|
||||
enabled: false,
|
||||
// cacheTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchComposeFile.isError) {
|
||||
toast.error("Failed to fetch Docker Compose file");
|
||||
} else if (fetchComposeFile.isSuccess) {
|
||||
form.setFieldValue("composeFile", fetchComposeFile.data);
|
||||
toast.success("Fetched Docker Compose file");
|
||||
}
|
||||
}, [fetchComposeFile, form]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Create Project</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="overflow-auto sm:max-w-4xl [&>*]:max-w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each project has it's own private internal network and is
|
||||
isolated from other projects. You can either create a blank project
|
||||
or choose from a template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
onSubmit={form.onSubmit((data) => {
|
||||
create.mutate({
|
||||
friendlyName: data.name,
|
||||
internalName: data.internalName,
|
||||
});
|
||||
})}
|
||||
className="max-w-full space-y-4"
|
||||
id="create-project-form"
|
||||
>
|
||||
<FormInputGroup
|
||||
label="Project Name"
|
||||
description="The name of your project"
|
||||
required
|
||||
form={form}
|
||||
formKey="name"
|
||||
>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="My Project"
|
||||
className="w-full"
|
||||
{...form.getInputProps("name")}
|
||||
onChange={(e) => {
|
||||
form.setFieldValue("name", e.target.value);
|
||||
|
||||
if (!form.isDirty("internalName")) {
|
||||
form.setFieldValue(
|
||||
"internalName",
|
||||
e.target.value.replace(/[^a-zA-Z\d:]/g, "-").toLowerCase(),
|
||||
);
|
||||
|
||||
form.setDirty({ internalName: false });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormInputGroup>
|
||||
|
||||
<FormInputGroup
|
||||
label="Internal Project Name"
|
||||
description="The name of your project, used internally for networking and DNS"
|
||||
required
|
||||
form={form}
|
||||
formKey="internalName"
|
||||
>
|
||||
<Input
|
||||
id="project-internal-name"
|
||||
placeholder="my-project"
|
||||
className="w-full"
|
||||
{...form.getInputProps("internalName")}
|
||||
/>
|
||||
</FormInputGroup>
|
||||
|
||||
<Tabs defaultValue="templates" className="w-full max-w-full">
|
||||
<TabsList className="mx-auto flex w-fit flex-wrap items-center text-center">
|
||||
<TabsTrigger value="templates">Template</TabsTrigger>
|
||||
<TabsTrigger value="compose">Docker Compose</TabsTrigger>
|
||||
<TabsTrigger value="blank">Blank Project</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<div className="rounded-md bg-background p-4">
|
||||
<p>Templates</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="compose" className="space-y-2">
|
||||
<Label htmlFor="compose-url">Docker Compose URL</Label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Maybe we should add the ability to fetch URLs protected by CORS by routing through the server
|
||||
Currently sites like pastebin.com will not work due to strict CORS policies
|
||||
|
||||
If we do this, make sure the user cannot access private URLs and proper input validation is done */}
|
||||
<Input
|
||||
id="compose-url"
|
||||
placeholder="https://example.com/docker-compose.yml"
|
||||
className="flex-grow"
|
||||
type="url"
|
||||
{...form.getInputProps("composeURL")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={fetchComposeFile.isSuccess ? "success" : "outline"}
|
||||
onClick={() => fetchComposeFile.refetch()}
|
||||
isLoading={fetchComposeFile.isFetching}
|
||||
disabled={
|
||||
fetchComposeFile.isFetching || !form.values.composeURL
|
||||
}
|
||||
>
|
||||
{fetchComposeFile.isSuccess ? "Fetched!" : "Fetch"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Label htmlFor="compose-file" className="mt-2">
|
||||
Docker Compose File
|
||||
</Label>
|
||||
<Suspense fallback={<div>Loading the editor...</div>}>
|
||||
<Editor
|
||||
value={form.values.composeFile}
|
||||
onChange={(value) => form.setFieldValue("composeFile", value)}
|
||||
/>
|
||||
</Suspense>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="blank"></TabsContent>
|
||||
</Tabs>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-project-form"
|
||||
isLoading={create.isPending}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { type z } from "zod";
|
||||
import { SimpleFormField } from "~/hooks/forms";
|
||||
import { type ProjectCreationValidator } from "./CreateProject";
|
||||
|
||||
export default function CreateProjectBasicDetails() {
|
||||
const form = useFormContext<z.infer<typeof ProjectCreationValidator>>();
|
||||
|
||||
const name = form.watch("name") ?? "";
|
||||
const internalNameState = form.getFieldState("internalName");
|
||||
|
||||
useEffect(() => {
|
||||
if (!internalNameState.isDirty) {
|
||||
form.setValue(
|
||||
"internalName",
|
||||
name.toLowerCase().replace(/[^a-z0-9]/g, "-"),
|
||||
);
|
||||
}
|
||||
}, [name, internalNameState.isDirty, form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SimpleFormField
|
||||
name="name"
|
||||
control={form.control}
|
||||
friendlyName="Project Name"
|
||||
description="The name of your project."
|
||||
required
|
||||
/>
|
||||
|
||||
<SimpleFormField
|
||||
name="internalName"
|
||||
control={form.control}
|
||||
friendlyName="Internal Name"
|
||||
description="The name of your project, used internally for networking and DNS"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
96
src/app/(dashboard)/_projects/CreateProject/2_SelectType.tsx
Normal file
96
src/app/(dashboard)/_projects/CreateProject/2_SelectType.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { Check, ChevronRight } from "lucide-react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { type z } from "zod";
|
||||
import {
|
||||
CreateProjectStep,
|
||||
CreateProjectType,
|
||||
type ProjectCreationValidator,
|
||||
} from "./CreateProject";
|
||||
|
||||
export default function CreateProjectSelectType() {
|
||||
const form = useFormContext<z.infer<typeof ProjectCreationValidator>>();
|
||||
const active = form.watch("type");
|
||||
|
||||
const setStep = (step: CreateProjectStep, type: CreateProjectType) => () => {
|
||||
form.setValue("type", type);
|
||||
form.setValue("step", step);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div
|
||||
className={
|
||||
"flex cursor-pointer flex-row rounded-md border border-border p-4 align-middle transition-colors hover:bg-accent" +
|
||||
(active === CreateProjectType.Template ? " bg-accent" : "")
|
||||
}
|
||||
onClick={setStep(
|
||||
CreateProjectStep.FromTemplate,
|
||||
CreateProjectType.Template,
|
||||
)}
|
||||
>
|
||||
<div className="flex-grow">
|
||||
<h1 className="font-bold">Template</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Quickly deploy a whole stack from one of the built-in templates.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{active === CreateProjectType.Template ? (
|
||||
<Check className="my-auto" />
|
||||
) : (
|
||||
<ChevronRight className="my-auto" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"flex cursor-pointer flex-row rounded-md border border-border p-4 align-middle transition-colors hover:bg-accent" +
|
||||
(active === CreateProjectType.Compose ? " bg-accent" : "")
|
||||
}
|
||||
onClick={setStep(
|
||||
CreateProjectStep.FromCompose,
|
||||
CreateProjectType.Compose,
|
||||
)}
|
||||
>
|
||||
<div className="flex-grow">
|
||||
<h1 className="font-bold">Docker Compose</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Import services from an existing docker-compose.yml file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{active === CreateProjectType.Compose ? (
|
||||
<Check className="my-auto" />
|
||||
) : (
|
||||
<ChevronRight className="my-auto" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
"flex cursor-pointer flex-row rounded-md border border-border p-4 align-middle transition-colors hover:bg-accent" +
|
||||
(active === CreateProjectType.Blank ? " bg-accent" : "")
|
||||
}
|
||||
onClick={setStep(
|
||||
CreateProjectStep.BasicDetails,
|
||||
CreateProjectType.Blank,
|
||||
)}
|
||||
>
|
||||
<div className="flex-grow">
|
||||
<h1 className="font-bold">Blank Project</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Start from scratch and add services on your own.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{active === CreateProjectType.Blank ? (
|
||||
<Check className="my-auto" />
|
||||
) : (
|
||||
<ChevronRight className="my-auto" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { Suspense, useEffect } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { type z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { type ProjectCreationValidator } from "./CreateProject";
|
||||
|
||||
// kinda large dependency, but syntax highlighting is nice
|
||||
const Editor = React.lazy(() => import("../YAMLEditor"));
|
||||
|
||||
export default function CreateProjectFromCompose() {
|
||||
const form = useFormContext<z.infer<typeof ProjectCreationValidator>>();
|
||||
|
||||
const fetchComposeFile = useQuery({
|
||||
queryKey: ["fetchComposeFile", form.getValues().composeURL],
|
||||
queryFn: async () => {
|
||||
const fileURL = form.getValues().composeFile;
|
||||
|
||||
if (!fileURL) {
|
||||
throw new Error("Invalid URL specified");
|
||||
}
|
||||
|
||||
return await fetch(fileURL).then((res) => res.text());
|
||||
},
|
||||
enabled: false,
|
||||
// cacheTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchComposeFile.isError) {
|
||||
toast.error("Failed to fetch Docker Compose file");
|
||||
} else if (fetchComposeFile.isSuccess) {
|
||||
form.setValue("composeFile", fetchComposeFile.data);
|
||||
toast.success("Fetched Docker Compose file");
|
||||
}
|
||||
}, [fetchComposeFile, form]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="compose-url">Docker Compose URL</Label>
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Maybe we should add the ability to fetch URLs protected by CORS by routing through the server
|
||||
Currently sites like pastebin.com will not work due to strict CORS policies
|
||||
|
||||
If we do this, make sure the user cannot access private URLs/IPs and proper input validation is done */}
|
||||
<Input
|
||||
id="compose-url"
|
||||
placeholder="https://example.com/docker-compose.yml"
|
||||
className="flex-grow"
|
||||
type="url"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant={fetchComposeFile.isSuccess ? "success" : "outline"}
|
||||
onClick={() => fetchComposeFile.refetch()}
|
||||
isLoading={fetchComposeFile.isFetching}
|
||||
disabled={
|
||||
fetchComposeFile.isFetching || !form.getValues().composeFile
|
||||
}
|
||||
>
|
||||
{fetchComposeFile.isSuccess ? "Fetched!" : "Fetch"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Label htmlFor="compose-file" className="mt-2">
|
||||
Docker Compose File
|
||||
</Label>
|
||||
<Suspense fallback={<div>Loading the editor...</div>}>
|
||||
<Editor
|
||||
value={form.getValues().composeFile}
|
||||
onChange={(data) => form.setValue("composeFile", data)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
221
src/app/(dashboard)/_projects/CreateProject/CreateProject.tsx
Normal file
221
src/app/(dashboard)/_projects/CreateProject/CreateProject.tsx
Normal file
|
@ -0,0 +1,221 @@
|
|||
"use client";
|
||||
|
||||
import { stripIndents } from "common-tags";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Form } from "~/components/ui/form";
|
||||
import { useForm } from "~/hooks/forms";
|
||||
import { zDockerName } from "~/server/utils/zod";
|
||||
import { api } from "~/trpc/react";
|
||||
import CreateProjectBasicDetails from "./1_BasicDetails";
|
||||
import CreateProjectSelectType from "./2_SelectType";
|
||||
import CreateProjectFromCompose from "./3b_FromCompose";
|
||||
|
||||
export enum CreateProjectType {
|
||||
Template = "template",
|
||||
Compose = "compose",
|
||||
Blank = "blank",
|
||||
}
|
||||
|
||||
export enum CreateProjectStep {
|
||||
BasicDetails,
|
||||
ChooseType,
|
||||
|
||||
FromTemplate,
|
||||
FromCompose,
|
||||
FromBlank,
|
||||
}
|
||||
|
||||
export const ProjectCreationValidator = z.object({
|
||||
step: z.nativeEnum(CreateProjectStep), // internally used
|
||||
|
||||
type: z.nativeEnum(CreateProjectType),
|
||||
internalName: zDockerName,
|
||||
name: z.string(),
|
||||
|
||||
// compose
|
||||
composeURL: z.string().url().optional(),
|
||||
composeFile: z.string(),
|
||||
|
||||
// template
|
||||
template: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Flow
|
||||
* 1. Choose Type
|
||||
* |-> From Template -> Choose Template Page -> Basic Details
|
||||
* |-> From Compose -> Enter URL -> Fetch -> Basic Details
|
||||
* \-> From Blank -> Basic Details
|
||||
*
|
||||
* 2. Basic Details -> Submit
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export function CreateProjectButton() {
|
||||
const create = api.projects.create.useMutation({
|
||||
onError(error) {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm(ProjectCreationValidator, {
|
||||
defaultValues: {
|
||||
step: CreateProjectStep.ChooseType,
|
||||
|
||||
composeFile: stripIndents`
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Everything written here will be transformed into a HostForge service.
|
||||
# Please note that some options are not supported by Docker Swarm and not all options are supported by HostForge.`,
|
||||
},
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const [step, type] = form.watch(["step", "type"]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">Create Project</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="overflow-auto sm:max-w-4xl [&>*]:max-w-full">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Each project has it's own private internal network and is
|
||||
isolated from other projects. You can either create a blank project
|
||||
or choose from a template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
const projectId = await create.mutateAsync({
|
||||
friendlyName: data.name,
|
||||
internalName: data.internalName,
|
||||
});
|
||||
|
||||
toast.success("Project created!");
|
||||
router.push(`/projects/${projectId}`);
|
||||
})}
|
||||
className="max-w-full space-y-4"
|
||||
id="create-project-form"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={step}
|
||||
className="space-y-2"
|
||||
initial="initialState"
|
||||
animate="animateState"
|
||||
exit="exitState"
|
||||
transition={{
|
||||
type: "tween",
|
||||
duration: 0.2,
|
||||
}}
|
||||
variants={{
|
||||
initialState: {
|
||||
x: "10%",
|
||||
opacity: 0,
|
||||
},
|
||||
animateState: {
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exitState: {
|
||||
x: "-10%",
|
||||
opacity: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{step === CreateProjectStep.BasicDetails && (
|
||||
<CreateProjectBasicDetails />
|
||||
)}
|
||||
|
||||
{step === CreateProjectStep.ChooseType && (
|
||||
<CreateProjectSelectType />
|
||||
)}
|
||||
|
||||
{step === CreateProjectStep.FromCompose && (
|
||||
<CreateProjectFromCompose />
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter>
|
||||
{type !== undefined && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={step === CreateProjectStep.ChooseType}
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"step",
|
||||
step === CreateProjectStep.BasicDetails
|
||||
? type === CreateProjectType.Template
|
||||
? CreateProjectStep.FromTemplate
|
||||
: type === CreateProjectType.Compose
|
||||
? CreateProjectStep.FromCompose
|
||||
: CreateProjectStep.ChooseType
|
||||
: CreateProjectStep.ChooseType,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
step === CreateProjectStep.BasicDetails || type === undefined
|
||||
}
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"step",
|
||||
step === CreateProjectStep.ChooseType
|
||||
? CreateProjectStep.BasicDetails
|
||||
: step === CreateProjectStep.FromTemplate
|
||||
? CreateProjectStep.ChooseType
|
||||
: CreateProjectStep.BasicDetails,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-project-form"
|
||||
isLoading={create.isPending}
|
||||
disabled={
|
||||
step != CreateProjectStep.BasicDetails || create.isPending
|
||||
}
|
||||
>
|
||||
Create Project
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -14,7 +14,7 @@ import {
|
|||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
import { type RouterOutputs } from "~/trpc/shared";
|
||||
import { CreateProjectButton } from "./CreateProject";
|
||||
import { CreateProjectButton } from "./CreateProject/CreateProject";
|
||||
import { Project } from "./Project";
|
||||
|
||||
type Projects = RouterOutputs["projects"]["list"];
|
||||
|
|
|
@ -103,7 +103,7 @@ export function ProjectLayout(props: {
|
|||
))}
|
||||
|
||||
{project.data.services.length == 0 && (
|
||||
<span className="text-muted-foreground">No services found.</span>
|
||||
<span className="my-auto text-muted-foreground">No services.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -61,7 +61,7 @@ export default function DeploymentSettings({
|
|||
})}
|
||||
className="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<h1 className="col-span-2">Deployment</h1>
|
||||
<h1 className="col-span-2 text-lg">Deployment</h1>
|
||||
|
||||
<SimpleFormField
|
||||
control={form.control}
|
||||
|
@ -139,6 +139,15 @@ export default function DeploymentSettings({
|
|||
render={({ field }) => <Switch {...field} className="!my-4 block" />}
|
||||
/>
|
||||
|
||||
{/* <h1 className="col-span-2 text-lg">Resource Limits</h1>
|
||||
|
||||
<SimpleFormField
|
||||
control={form.control}
|
||||
name="max_memory"
|
||||
friendlyName="Memory Limit"
|
||||
description="The maximum amount of memory that this service can use. Example: 512M, 4G"
|
||||
/> */}
|
||||
|
||||
<FormSubmit form={form} className="col-span-2" />
|
||||
</form>
|
||||
</Form>
|
||||
|
|
|
@ -52,9 +52,9 @@ export default function ContainersPage({
|
|||
|
||||
{containers.data && containers.data.latest.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center align-middle">
|
||||
No containers running. Try hitting the Deploy Changes button to
|
||||
deploy the service.
|
||||
<TableCell colSpan={8} className="h-24 text-center align-middle">
|
||||
No containers running. Try hitting the{" "}
|
||||
<strong>Deploy Changes</strong> button to deploy the service.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
|
|
@ -62,7 +62,7 @@ export async function buildDockerStackFile(
|
|||
limits: {
|
||||
cpus: service.max_cpu?.toString() ?? undefined,
|
||||
memory: service.max_memory ?? undefined,
|
||||
pids: service.max_pids ?? undefined,
|
||||
// pids: service.max_pids ?? undefined,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
Loading…
Reference in a new issue