feat: create project overhaul, slight fixes to other stuff

This commit is contained in:
Derock 2024-04-14 20:55:35 -04:00
parent e52597c349
commit b54a710835
No known key found for this signature in database
14 changed files with 476 additions and 232 deletions

View file

@ -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",

View file

@ -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

View file

@ -27,6 +27,7 @@ export default function LoginForm() {
onSuccess: (data) => {
toast.success("Successfully logged in!", { id: toastLoading });
router.push("/#");
router.refresh();
},
trpc: {

View file

@ -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&apos;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>
);
}

View file

@ -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
/>
</>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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&apos;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>
);
}

View file

@ -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"];

View file

@ -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>

View file

@ -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>

View file

@ -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>
)}

View file

@ -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,
},
},