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",
|
"chalk": "^5.3.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
"common-tags": "^1.8.2",
|
||||||
"cookie": "^0.6.0",
|
"cookie": "^0.6.0",
|
||||||
"date-fns": "^3.0.6",
|
"date-fns": "^3.0.6",
|
||||||
"docker-cli-js": "^2.10.0",
|
"docker-cli-js": "^2.10.0",
|
||||||
|
@ -79,6 +80,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
|
"@types/common-tags": "^1.8.4",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/dockerode": "^3.3.23",
|
"@types/dockerode": "^3.3.23",
|
||||||
"@types/eslint": "^8.56.1",
|
"@types/eslint": "^8.56.1",
|
||||||
|
|
|
@ -86,6 +86,9 @@ dependencies:
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
|
common-tags:
|
||||||
|
specifier: ^1.8.2
|
||||||
|
version: 1.8.2
|
||||||
cookie:
|
cookie:
|
||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
|
@ -187,6 +190,9 @@ devDependencies:
|
||||||
'@types/better-sqlite3':
|
'@types/better-sqlite3':
|
||||||
specifier: ^7.6.8
|
specifier: ^7.6.8
|
||||||
version: 7.6.8
|
version: 7.6.8
|
||||||
|
'@types/common-tags':
|
||||||
|
specifier: ^1.8.4
|
||||||
|
version: 1.8.4
|
||||||
'@types/cookie':
|
'@types/cookie':
|
||||||
specifier: ^0.6.0
|
specifier: ^0.6.0
|
||||||
version: 0.6.0
|
version: 0.6.0
|
||||||
|
@ -2713,6 +2719,10 @@ packages:
|
||||||
'@types/node': 20.10.6
|
'@types/node': 20.10.6
|
||||||
dev: false
|
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:
|
/@types/connect@3.4.38:
|
||||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -3849,6 +3859,11 @@ packages:
|
||||||
engines: {node: ^12.20.0 || >=14}
|
engines: {node: ^12.20.0 || >=14}
|
||||||
dev: true
|
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:
|
/component-emitter@1.3.1:
|
||||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
|
@ -27,6 +27,7 @@ export default function LoginForm() {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast.success("Successfully logged in!", { id: toastLoading });
|
toast.success("Successfully logged in!", { id: toastLoading });
|
||||||
router.push("/#");
|
router.push("/#");
|
||||||
|
router.refresh();
|
||||||
},
|
},
|
||||||
|
|
||||||
trpc: {
|
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";
|
} from "~/components/ui/dropdown-menu";
|
||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { type RouterOutputs } from "~/trpc/shared";
|
import { type RouterOutputs } from "~/trpc/shared";
|
||||||
import { CreateProjectButton } from "./CreateProject";
|
import { CreateProjectButton } from "./CreateProject/CreateProject";
|
||||||
import { Project } from "./Project";
|
import { Project } from "./Project";
|
||||||
|
|
||||||
type Projects = RouterOutputs["projects"]["list"];
|
type Projects = RouterOutputs["projects"]["list"];
|
||||||
|
|
|
@ -103,7 +103,7 @@ export function ProjectLayout(props: {
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{project.data.services.length == 0 && (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -61,7 +61,7 @@ export default function DeploymentSettings({
|
||||||
})}
|
})}
|
||||||
className="grid grid-cols-2 gap-4"
|
className="grid grid-cols-2 gap-4"
|
||||||
>
|
>
|
||||||
<h1 className="col-span-2">Deployment</h1>
|
<h1 className="col-span-2 text-lg">Deployment</h1>
|
||||||
|
|
||||||
<SimpleFormField
|
<SimpleFormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
@ -139,6 +139,15 @@ export default function DeploymentSettings({
|
||||||
render={({ field }) => <Switch {...field} className="!my-4 block" />}
|
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" />
|
<FormSubmit form={form} className="col-span-2" />
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -52,9 +52,9 @@ export default function ContainersPage({
|
||||||
|
|
||||||
{containers.data && containers.data.latest.length === 0 && (
|
{containers.data && containers.data.latest.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center align-middle">
|
<TableCell colSpan={8} className="h-24 text-center align-middle">
|
||||||
No containers running. Try hitting the Deploy Changes button to
|
No containers running. Try hitting the{" "}
|
||||||
deploy the service.
|
<strong>Deploy Changes</strong> button to deploy the service.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -62,7 +62,7 @@ export async function buildDockerStackFile(
|
||||||
limits: {
|
limits: {
|
||||||
cpus: service.max_cpu?.toString() ?? undefined,
|
cpus: service.max_cpu?.toString() ?? undefined,
|
||||||
memory: service.max_memory ?? undefined,
|
memory: service.max_memory ?? undefined,
|
||||||
pids: service.max_pids ?? undefined,
|
// pids: service.max_pids ?? undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue