diff --git a/package.json b/package.json index 6bab3da..27fd938 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e691dc..005cd8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(auth)/login/Login.tsx b/src/app/(auth)/login/Login.tsx index 6db0d4a..e9b5ceb 100644 --- a/src/app/(auth)/login/Login.tsx +++ b/src/app/(auth)/login/Login.tsx @@ -27,6 +27,7 @@ export default function LoginForm() { onSuccess: (data) => { toast.success("Successfully logged in!", { id: toastLoading }); router.push("/#"); + router.refresh(); }, trpc: { diff --git a/src/app/(dashboard)/_projects/CreateProject.tsx b/src/app/(dashboard)/_projects/CreateProject.tsx deleted file mode 100644 index 065065c..0000000 --- a/src/app/(dashboard)/_projects/CreateProject.tsx +++ /dev/null @@ -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 ( - - - - - - - - Create a Project - - 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. - - - -
{ - create.mutate({ - friendlyName: data.name, - internalName: data.internalName, - }); - })} - className="max-w-full space-y-4" - id="create-project-form" - > - - { - 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 }); - } - }} - /> - - - - - - - - - Template - Docker Compose - Blank Project - - - -
-

Templates

-
-
- - - -
- {/* 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 */} - - -
- - - Loading the editor...}> - form.setFieldValue("composeFile", value)} - /> - -
- - -
-
- - - - -
-
- ); -} diff --git a/src/app/(dashboard)/_projects/CreateProject/1_BasicDetails.tsx b/src/app/(dashboard)/_projects/CreateProject/1_BasicDetails.tsx new file mode 100644 index 0000000..982ef74 --- /dev/null +++ b/src/app/(dashboard)/_projects/CreateProject/1_BasicDetails.tsx @@ -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>(); + + 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 ( + <> + + + + + ); +} diff --git a/src/app/(dashboard)/_projects/CreateProject/2_SelectType.tsx b/src/app/(dashboard)/_projects/CreateProject/2_SelectType.tsx new file mode 100644 index 0000000..f5c38e5 --- /dev/null +++ b/src/app/(dashboard)/_projects/CreateProject/2_SelectType.tsx @@ -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>(); + const active = form.watch("type"); + + const setStep = (step: CreateProjectStep, type: CreateProjectType) => () => { + form.setValue("type", type); + form.setValue("step", step); + }; + + return ( +
+
+
+

Template

+

+ Quickly deploy a whole stack from one of the built-in templates. +

+
+ + {active === CreateProjectType.Template ? ( + + ) : ( + + )} +
+ +
+
+

Docker Compose

+

+ Import services from an existing docker-compose.yml file. +

+
+ + {active === CreateProjectType.Compose ? ( + + ) : ( + + )} +
+ +
+
+

Blank Project

+

+ Start from scratch and add services on your own. +

+
+ + {active === CreateProjectType.Blank ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/_projects/CreateProject/3a_FromTemplate.tsx b/src/app/(dashboard)/_projects/CreateProject/3a_FromTemplate.tsx new file mode 100644 index 0000000..e69de29 diff --git a/src/app/(dashboard)/_projects/CreateProject/3b_FromCompose.tsx b/src/app/(dashboard)/_projects/CreateProject/3b_FromCompose.tsx new file mode 100644 index 0000000..427053c --- /dev/null +++ b/src/app/(dashboard)/_projects/CreateProject/3b_FromCompose.tsx @@ -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>(); + + 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 ( +
+ +
+ {/* 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 */} + + +
+ + + Loading the editor...
}> + form.setValue("composeFile", data)} + /> + + + ); +} diff --git a/src/app/(dashboard)/_projects/CreateProject/CreateProject.tsx b/src/app/(dashboard)/_projects/CreateProject/CreateProject.tsx new file mode 100644 index 0000000..2450982 --- /dev/null +++ b/src/app/(dashboard)/_projects/CreateProject/CreateProject.tsx @@ -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 ( + + + + + + + + Create a Project + + 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. + + + +
+ { + 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" + > + + + {step === CreateProjectStep.BasicDetails && ( + + )} + + {step === CreateProjectStep.ChooseType && ( + + )} + + {step === CreateProjectStep.FromCompose && ( + + )} + + +
+ + + + {type !== undefined && ( + <> + + + + + + + )} + +
+
+ ); +} diff --git a/src/app/(dashboard)/_projects/ProjectList.tsx b/src/app/(dashboard)/_projects/ProjectList.tsx index 780de29..827b89f 100644 --- a/src/app/(dashboard)/_projects/ProjectList.tsx +++ b/src/app/(dashboard)/_projects/ProjectList.tsx @@ -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"]; diff --git a/src/app/(dashboard)/project/[projectId]/ProjectLayout.tsx b/src/app/(dashboard)/project/[projectId]/ProjectLayout.tsx index 5ef25e1..9377f12 100644 --- a/src/app/(dashboard)/project/[projectId]/ProjectLayout.tsx +++ b/src/app/(dashboard)/project/[projectId]/ProjectLayout.tsx @@ -103,7 +103,7 @@ export function ProjectLayout(props: { ))} {project.data.services.length == 0 && ( - No services found. + No services. )} diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/advanced/DeploymentSettings.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/advanced/DeploymentSettings.tsx index 608a776..9eb3645 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/advanced/DeploymentSettings.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/advanced/DeploymentSettings.tsx @@ -61,7 +61,7 @@ export default function DeploymentSettings({ })} className="grid grid-cols-2 gap-4" > -

Deployment

+

Deployment

} /> + {/*

Resource Limits

+ + */} + diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/containers/ContainersList.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/containers/ContainersList.tsx index 15b4ff9..1748c08 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/containers/ContainersList.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/containers/ContainersList.tsx @@ -52,9 +52,9 @@ export default function ContainersPage({ {containers.data && containers.data.latest.length === 0 && ( - - No containers running. Try hitting the Deploy Changes button to - deploy the service. + + No containers running. Try hitting the{" "} + Deploy Changes button to deploy the service. )} diff --git a/src/server/docker/stack.ts b/src/server/docker/stack.ts index 7b13851..c79330b 100644 --- a/src/server/docker/stack.ts +++ b/src/server/docker/stack.ts @@ -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, }, },