wip: service stuff
This commit is contained in:
parent
cb5d196e5a
commit
37184a2394
|
@ -16,6 +16,7 @@
|
||||||
"fetch-compose-types": "curl -s https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json | json2ts > src/server/docker/compose.d.ts"
|
"fetch-compose-types": "curl -s https://raw.githubusercontent.com/compose-spec/compose-spec/master/schema/compose-spec.json | json2ts > src/server/docker/compose.d.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
"@mantine/form": "^7.3.2",
|
"@mantine/form": "^7.3.2",
|
||||||
"@nicktomlin/codemirror-lang-yaml-lite": "^0.0.3",
|
"@nicktomlin/codemirror-lang-yaml-lite": "^0.0.3",
|
||||||
"@prisma/migrate": "^5.7.0",
|
"@prisma/migrate": "^5.7.0",
|
||||||
|
@ -54,6 +55,7 @@
|
||||||
"node-os-utils": "^1.3.7",
|
"node-os-utils": "^1.3.7",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
|
"react-hook-form": "^7.49.2",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^4.12.0",
|
||||||
"react-simple-code-editor": "^0.13.1",
|
"react-simple-code-editor": "^0.13.1",
|
||||||
"recharts": "^2.10.3",
|
"recharts": "^2.10.3",
|
||||||
|
|
|
@ -5,6 +5,9 @@ settings:
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@hookform/resolvers':
|
||||||
|
specifier: ^3.3.2
|
||||||
|
version: 3.3.2(react-hook-form@7.49.2)
|
||||||
'@mantine/form':
|
'@mantine/form':
|
||||||
specifier: ^7.3.2
|
specifier: ^7.3.2
|
||||||
version: 7.3.2(react@18.2.0)
|
version: 7.3.2(react@18.2.0)
|
||||||
|
@ -119,6 +122,9 @@ dependencies:
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: 18.2.0
|
specifier: 18.2.0
|
||||||
version: 18.2.0(react@18.2.0)
|
version: 18.2.0(react@18.2.0)
|
||||||
|
react-hook-form:
|
||||||
|
specifier: ^7.49.2
|
||||||
|
version: 7.49.2(react@18.2.0)
|
||||||
react-icons:
|
react-icons:
|
||||||
specifier: ^4.12.0
|
specifier: ^4.12.0
|
||||||
version: 4.12.0(react@18.2.0)
|
version: 4.12.0(react@18.2.0)
|
||||||
|
@ -1359,6 +1365,14 @@ packages:
|
||||||
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
|
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@hookform/resolvers@3.3.2(react-hook-form@7.49.2):
|
||||||
|
resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==}
|
||||||
|
peerDependencies:
|
||||||
|
react-hook-form: ^7.0.0
|
||||||
|
dependencies:
|
||||||
|
react-hook-form: 7.49.2(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@humanwhocodes/config-array@0.11.13:
|
/@humanwhocodes/config-array@0.11.13:
|
||||||
resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
|
resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
|
@ -6888,6 +6902,15 @@ packages:
|
||||||
scheduler: 0.23.0
|
scheduler: 0.23.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-hook-form@7.49.2(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==}
|
||||||
|
engines: {node: '>=18', pnpm: '8'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18
|
||||||
|
dependencies:
|
||||||
|
react: 18.2.0
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-icons@4.12.0(react@18.2.0):
|
/react-icons@4.12.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==}
|
resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
|
@ -1,3 +1,115 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
export function CreateService() {}
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getQueryKey } from "@trpc/react-query";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "~/components/ui/form";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Required } from "~/components/ui/required";
|
||||||
|
import { useForm } from "~/hooks/forms";
|
||||||
|
import { zDockerName } from "~/server/utils/zod";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useProject } from "../_context/ProjectContext";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: zDockerName,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function CreateService() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const project = useProject();
|
||||||
|
|
||||||
|
const mutate = api.projects.services.create.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: getQueryKey(
|
||||||
|
api.projects.get,
|
||||||
|
{ projectId: project.id },
|
||||||
|
"query",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm(formSchema);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" icon={Plus}>
|
||||||
|
New Service
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Service</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new service for {project.friendlyName}. You will be able to
|
||||||
|
configure this service before deploying it in the next step.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit((data) =>
|
||||||
|
mutate.mutate({ name: data.name, projectId: project.id }),
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
Service Name <Required />
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="my-service" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
This is the name of the service as it will appear in the
|
||||||
|
Docker Compose file. It must be a valid Docker name.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="float-right"
|
||||||
|
isLoading={mutate.isPending}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
110
src/app/(dashboard)/project/[id]/_components/ProjectLayout.tsx
Normal file
110
src/app/(dashboard)/project/[id]/_components/ProjectLayout.tsx
Normal file
|
@ -0,0 +1,110 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Home, Settings2, UploadCloud } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { type RouterOutputs } from "~/trpc/shared";
|
||||||
|
import { ProjectContextProvider } from "../_context/ProjectContext";
|
||||||
|
import { CreateService } from "./CreateService";
|
||||||
|
|
||||||
|
export function ProjectLayout(props: {
|
||||||
|
project: RouterOutputs["projects"]["get"];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const params = useParams();
|
||||||
|
const projectPath = `/project/${params.id as string}`;
|
||||||
|
|
||||||
|
const project = api.projects.get.useQuery(
|
||||||
|
{ projectId: props.project.id },
|
||||||
|
{
|
||||||
|
initialData: props.project,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const healthy =
|
||||||
|
project.data.services.filter(
|
||||||
|
(s) =>
|
||||||
|
s.stats?.ServiceStatus?.RunningTasks ==
|
||||||
|
s.stats?.ServiceStatus?.DesiredTasks,
|
||||||
|
).length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectContextProvider data={project.data}>
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{project.data.friendlyName}{" "}
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
({project.data.internalName})
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<h2 className="text-muted-foreground">
|
||||||
|
{/* green dot = healthy, yellow = partial, red = all off */}
|
||||||
|
<span
|
||||||
|
className={`mr-1 inline-block h-3 w-3 rounded-full bg-${
|
||||||
|
healthy == project.data.services.length
|
||||||
|
? "green"
|
||||||
|
: healthy > 0
|
||||||
|
? "yellow"
|
||||||
|
: "red"
|
||||||
|
}-500`}
|
||||||
|
/>
|
||||||
|
{healthy}/{project.data.services.length} Healthy Services
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-row flex-wrap gap-4">
|
||||||
|
{/* home */}
|
||||||
|
<Link href={projectPath}>
|
||||||
|
<Button variant="outline" icon={Home}>
|
||||||
|
Home
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* deploy changes */}
|
||||||
|
<Button variant="outline" icon={UploadCloud}>
|
||||||
|
Deploy Changes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* new */}
|
||||||
|
<CreateService />
|
||||||
|
|
||||||
|
{/* settings */}
|
||||||
|
<Button variant="outline" icon={Settings2}>
|
||||||
|
<Link href={`${projectPath}/settings`}>Settings</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-4 border-b-2 border-b-border py-8">
|
||||||
|
{/* services */}
|
||||||
|
<div className="flex flex-grow flex-row gap-2 overflow-x-auto">
|
||||||
|
{project.data.services.map((service) => (
|
||||||
|
<Link
|
||||||
|
key={service.id}
|
||||||
|
href={`${projectPath}/service/${service.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={`mr-1 inline-block h-3 w-3 rounded-full bg-${
|
||||||
|
service.stats?.ServiceStatus?.RunningTasks ==
|
||||||
|
service.stats?.ServiceStatus?.DesiredTasks
|
||||||
|
? "green"
|
||||||
|
: service.stats?.ServiceStatus?.RunningTasks ?? 0 > 0
|
||||||
|
? "yellow"
|
||||||
|
: "red"
|
||||||
|
}-500`}
|
||||||
|
/>
|
||||||
|
<span>{service.name}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{project.data.services.length == 0 && (
|
||||||
|
<span className="text-muted-foreground">No services found.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
</ProjectContextProvider>
|
||||||
|
);
|
||||||
|
}
|
25
src/app/(dashboard)/project/[id]/_context/ProjectContext.tsx
Normal file
25
src/app/(dashboard)/project/[id]/_context/ProjectContext.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { type RouterOutputs } from "~/trpc/shared";
|
||||||
|
|
||||||
|
type ProjectContextType = RouterOutputs["projects"]["get"];
|
||||||
|
|
||||||
|
const ProjectContext = createContext<ProjectContextType>(
|
||||||
|
{} as unknown as ProjectContextType,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function ProjectContextProvider(props: {
|
||||||
|
data: ProjectContextType;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ProjectContext.Provider value={props.data}>
|
||||||
|
{props.children}
|
||||||
|
</ProjectContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProject = () => {
|
||||||
|
return useContext(ProjectContext);
|
||||||
|
};
|
|
@ -1,94 +1,11 @@
|
||||||
import { Home, Plus, Settings2, UploadCloud } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Button } from "~/components/ui/button";
|
|
||||||
import { api } from "~/trpc/server";
|
import { api } from "~/trpc/server";
|
||||||
|
import { ProjectLayout } from "./_components/ProjectLayout";
|
||||||
|
|
||||||
export default async function ProjectPage(props: {
|
export default async function ProjectPage(props: {
|
||||||
params: { id: string };
|
params: { id: string };
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const project = await api.projects.get.query({ projectId: props.params.id });
|
const project = await api.projects.get.query({ projectId: props.params.id });
|
||||||
const healthy = project.services.filter(
|
|
||||||
(s) =>
|
|
||||||
s.stats?.ServiceStatus?.RunningTasks ==
|
|
||||||
s.stats?.ServiceStatus?.DesiredTasks,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
return (
|
return <ProjectLayout project={project}>{props.children}</ProjectLayout>;
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold">
|
|
||||||
{project.friendlyName}{" "}
|
|
||||||
<span className="text-sm font-normal text-muted-foreground">
|
|
||||||
({project.internalName})
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<h2 className="text-muted-foreground">
|
|
||||||
{/* green dot = healthy, yellow = partial, red = all off */}
|
|
||||||
<span
|
|
||||||
className={`mr-1 inline-block h-3 w-3 rounded-full bg-${
|
|
||||||
healthy == project.services.length
|
|
||||||
? "green"
|
|
||||||
: healthy > 0
|
|
||||||
? "yellow"
|
|
||||||
: "red"
|
|
||||||
}-500`}
|
|
||||||
/>
|
|
||||||
{healthy}/{project.services.length} Healthy Services
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-row flex-wrap gap-4">
|
|
||||||
{/* home */}
|
|
||||||
<Button variant="outline" icon={Home} asChild>
|
|
||||||
<Link href={`/project/${props.params.id}/home`}>Home</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* deploy changes */}
|
|
||||||
<Button variant="outline" icon={UploadCloud}>
|
|
||||||
Deploy Changes
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* new */}
|
|
||||||
<Button variant="outline" icon={Plus}>
|
|
||||||
New Service
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* settings */}
|
|
||||||
<Button variant="outline" icon={Settings2}>
|
|
||||||
<Link href={`/project/${props.params.id}/settings`}>Settings</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-4 border-b-2 border-b-border py-8">
|
|
||||||
{/* services */}
|
|
||||||
<div className="flex flex-grow flex-row gap-2 overflow-x-auto">
|
|
||||||
{project.services.map((service) => (
|
|
||||||
<Link
|
|
||||||
key={service.id}
|
|
||||||
href={`/project/${props.params.id}/service/${service.id}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center gap-1">
|
|
||||||
<div
|
|
||||||
className={`mr-1 inline-block h-3 w-3 rounded-full bg-${
|
|
||||||
service.stats?.ServiceStatus?.RunningTasks ==
|
|
||||||
service.stats?.ServiceStatus?.DesiredTasks
|
|
||||||
? "green"
|
|
||||||
: service.stats?.ServiceStatus?.RunningTasks ?? 0 > 0
|
|
||||||
? "yellow"
|
|
||||||
: "red"
|
|
||||||
}-500`}
|
|
||||||
/>
|
|
||||||
<span>{service.name}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{project.services.length == 0 && (
|
|
||||||
<span className="text-muted-foreground">No services found.</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
177
src/components/ui/form.tsx
Normal file
177
src/components/ui/form.tsx
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import type * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { Label } from "src/components/ui/label";
|
||||||
|
import { cn } from "~/utils/utils";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormItem.displayName = "FormItem";
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormLabel.displayName = "FormLabel";
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormControl.displayName = "FormControl";
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormDescription.displayName = "FormDescription";
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message) : children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormMessage.displayName = "FormMessage";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
useFormField,
|
||||||
|
};
|
|
@ -1 +1,3 @@
|
||||||
export const Required = () => <span className="px-1 text-red-500">*</span>;
|
export const Required = () => (
|
||||||
|
<span className="px-[0.125rem] text-red-500">*</span>
|
||||||
|
);
|
||||||
|
|
|
@ -23,6 +23,11 @@ export const env = createEnv({
|
||||||
.transform((str) => parseInt(str)),
|
.transform((str) => parseInt(str)),
|
||||||
|
|
||||||
STORAGE_PATH: z.string().default("/var/lib/hostforge"),
|
STORAGE_PATH: z.string().default("/var/lib/hostforge"),
|
||||||
|
|
||||||
|
REDEPLOY_SECRET_BYTES: z
|
||||||
|
.string()
|
||||||
|
.default("32")
|
||||||
|
.transform((str) => parseInt(str)),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
22
src/hooks/forms.tsx
Normal file
22
src/hooks/forms.tsx
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm as useFormHook, type UseFormProps } from "react-hook-form";
|
||||||
|
import { type z } from "zod";
|
||||||
|
|
||||||
|
// type UseFormData<TFieldValues, TContext> = UseFormProps<TFieldValues
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction on top of react-hook-form so you dont have to repeat boilerplate
|
||||||
|
* Automatically z.infer's the schema and sets the resolver
|
||||||
|
*
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function useForm<TSchema extends z.Schema<any>, TContext = any>(
|
||||||
|
schema: TSchema,
|
||||||
|
props: UseFormProps<z.infer<TSchema>, TContext> = {},
|
||||||
|
) {
|
||||||
|
return useFormHook<z.infer<TSchema>>({
|
||||||
|
...props,
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
}
|
44
src/server/api/middleware/project.ts
Normal file
44
src/server/api/middleware/project.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { TRPCError, experimental_standaloneMiddleware } from "@trpc/server";
|
||||||
|
import { eq, or } from "drizzle-orm";
|
||||||
|
import { type db } from "~/server/db";
|
||||||
|
import { projects } from "~/server/db/schema";
|
||||||
|
|
||||||
|
export const projectMiddleware = experimental_standaloneMiddleware<{
|
||||||
|
ctx: { db: typeof db };
|
||||||
|
input: { projectId: string };
|
||||||
|
}>().create(async ({ ctx, input, next }) => {
|
||||||
|
if (typeof input.projectId != "string") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Expected a project ID or internal name.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [project] = await ctx.db
|
||||||
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
friendlyName: projects.friendlyName,
|
||||||
|
internalName: projects.internalName,
|
||||||
|
createdAt: projects.createdAt,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(projects.id, input.projectId),
|
||||||
|
eq(projects.internalName, input.projectId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!project)
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Project not found or insufficient permissions.",
|
||||||
|
});
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
project: project,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
|
@ -4,8 +4,11 @@ import { z } from "zod";
|
||||||
import { projects, service } from "~/server/db/schema";
|
import { projects, service } from "~/server/db/schema";
|
||||||
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
|
import { authenticatedProcedure, createTRPCRouter } from "../../trpc";
|
||||||
import { getProject } from "./project";
|
import { getProject } from "./project";
|
||||||
|
import { serviceRouter } from "./service";
|
||||||
|
|
||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
|
services: serviceRouter,
|
||||||
|
|
||||||
list: authenticatedProcedure
|
list: authenticatedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { service } from "~/server/db/schema";
|
import { service } from "~/server/db/schema";
|
||||||
import { projectAuthenticatedProcedure } from "../../trpc";
|
import { projectMiddleware } from "../../middleware/project";
|
||||||
|
import { authenticatedProcedure } from "../../trpc";
|
||||||
|
|
||||||
export const getProject = projectAuthenticatedProcedure
|
export const getProject = authenticatedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -11,6 +12,8 @@ export const getProject = projectAuthenticatedProcedure
|
||||||
summary: "Get project",
|
summary: "Get project",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
.input(z.object({ projectId: z.string() }))
|
||||||
|
.use(projectMiddleware)
|
||||||
.output(z.unknown())
|
.output(z.unknown())
|
||||||
.query(async ({ ctx }) => {
|
.query(async ({ ctx }) => {
|
||||||
const projServices = await ctx.db
|
const projServices = await ctx.db
|
||||||
|
|
50
src/server/api/routers/projects/service/index.ts
Normal file
50
src/server/api/routers/projects/service/index.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import assert from "assert";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { projectMiddleware } from "~/server/api/middleware/project";
|
||||||
|
import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc";
|
||||||
|
import { service } from "~/server/db/schema";
|
||||||
|
import { ServiceSource } from "~/server/db/types";
|
||||||
|
import { zDockerName } from "~/server/utils/zod";
|
||||||
|
|
||||||
|
export const serviceRouter = createTRPCRouter({
|
||||||
|
create: authenticatedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/projects/:projectId/services",
|
||||||
|
summary: "Create service",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
name: zDockerName,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.string({ description: "Service ID" }))
|
||||||
|
.use(projectMiddleware)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const [data] = await ctx.db
|
||||||
|
.insert(service)
|
||||||
|
.values({
|
||||||
|
name: input.name,
|
||||||
|
projectId: ctx.project.id,
|
||||||
|
redeploySecret: randomBytes(env.REDEPLOY_SECRET_BYTES).toString(
|
||||||
|
"hex",
|
||||||
|
),
|
||||||
|
source: ServiceSource.Docker,
|
||||||
|
|
||||||
|
dockerImage: "traefik/whoami",
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: service.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
assert(data?.id);
|
||||||
|
|
||||||
|
return data.id;
|
||||||
|
}),
|
||||||
|
});
|
|
@ -9,14 +9,12 @@
|
||||||
import { TRPCError, initTRPC } from "@trpc/server";
|
import { TRPCError, initTRPC } from "@trpc/server";
|
||||||
import cookie from "cookie";
|
import cookie from "cookie";
|
||||||
import type Dockerode from "dockerode";
|
import type Dockerode from "dockerode";
|
||||||
import { eq, or } from "drizzle-orm";
|
|
||||||
import { type IncomingMessage, type ServerResponse } from "http";
|
import { type IncomingMessage, type ServerResponse } from "http";
|
||||||
import ipaddr from "ipaddr.js";
|
import ipaddr from "ipaddr.js";
|
||||||
import superjson from "superjson";
|
import superjson from "superjson";
|
||||||
import { ZodError, z } from "zod";
|
import { ZodError } from "zod";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { Session } from "../auth/Session";
|
import { Session } from "../auth/Session";
|
||||||
import { projects } from "../db/schema";
|
|
||||||
import { getDockerInstance } from "../docker";
|
import { getDockerInstance } from "../docker";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
// import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi";
|
// import { OpenApiMeta, generateOpenApiDocument } from "trpc-openapi";
|
||||||
|
@ -196,50 +194,50 @@ export const authenticatedProcedure = t.procedure.use(
|
||||||
* Abstracts away finding a project by ID or internal name, and returns the project object.
|
* Abstracts away finding a project by ID or internal name, and returns the project object.
|
||||||
* REMINDER: if you override `input`, you must include `projectId` in the new input object.
|
* REMINDER: if you override `input`, you must include `projectId` in the new input object.
|
||||||
*/
|
*/
|
||||||
export const projectAuthenticatedProcedure = authenticatedProcedure
|
// export const projectAuthenticatedProcedure = authenticatedProcedure
|
||||||
.use(
|
// .use(
|
||||||
t.middleware(async ({ ctx, input, next }) => {
|
// t.middleware(async ({ ctx, input, next }) => {
|
||||||
if (
|
// if (
|
||||||
!input ||
|
// !input ||
|
||||||
typeof input != "object" ||
|
// typeof input != "object" ||
|
||||||
!("projectId" in input) ||
|
// !("projectId" in input) ||
|
||||||
typeof input.projectId != "string"
|
// typeof input.projectId != "string"
|
||||||
) {
|
// ) {
|
||||||
console.log(input);
|
// console.log(input);
|
||||||
|
|
||||||
throw new TRPCError({
|
// throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
// code: "NOT_FOUND",
|
||||||
message: "Expected a project ID or internal name.",
|
// message: "Expected a project ID or internal name.",
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
const [project] = await ctx.db
|
// const [project] = await ctx.db
|
||||||
.select({
|
// .select({
|
||||||
id: projects.id,
|
// id: projects.id,
|
||||||
friendlyName: projects.friendlyName,
|
// friendlyName: projects.friendlyName,
|
||||||
internalName: projects.internalName,
|
// internalName: projects.internalName,
|
||||||
createdAt: projects.createdAt,
|
// createdAt: projects.createdAt,
|
||||||
})
|
// })
|
||||||
.from(projects)
|
// .from(projects)
|
||||||
.where(
|
// .where(
|
||||||
or(
|
// or(
|
||||||
eq(projects.id, input.projectId),
|
// eq(projects.id, input.projectId),
|
||||||
eq(projects.internalName, input.projectId),
|
// eq(projects.internalName, input.projectId),
|
||||||
),
|
// ),
|
||||||
)
|
// )
|
||||||
.limit(1);
|
// .limit(1);
|
||||||
|
|
||||||
if (!project)
|
// if (!project)
|
||||||
throw new TRPCError({
|
// throw new TRPCError({
|
||||||
code: "NOT_FOUND",
|
// code: "NOT_FOUND",
|
||||||
message: "Project not found or insufficient permissions.",
|
// message: "Project not found or insufficient permissions.",
|
||||||
});
|
// });
|
||||||
|
|
||||||
return next({
|
// return next({
|
||||||
ctx: {
|
// ctx: {
|
||||||
project: project,
|
// project: project,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}),
|
// }),
|
||||||
)
|
// )
|
||||||
.input(z.object({ projectId: z.string() }));
|
// .input(z.object({ projectId: z.string() }));
|
||||||
|
|
5
src/server/utils/zod.ts
Normal file
5
src/server/utils/zod.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export const zDockerName = z.string().regex(/^[a-z0-9-]+$/, {
|
||||||
|
message: "Must be lowercase alphanumeric with dashes.",
|
||||||
|
});
|
Loading…
Reference in a new issue