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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@mantine/form": "^7.3.2",
|
||||
"@nicktomlin/codemirror-lang-yaml-lite": "^0.0.3",
|
||||
"@prisma/migrate": "^5.7.0",
|
||||
|
@ -54,6 +55,7 @@
|
|||
"node-os-utils": "^1.3.7",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-simple-code-editor": "^0.13.1",
|
||||
"recharts": "^2.10.3",
|
||||
|
|
|
@ -5,6 +5,9 @@ settings:
|
|||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^3.3.2
|
||||
version: 3.3.2(react-hook-form@7.49.2)
|
||||
'@mantine/form':
|
||||
specifier: ^7.3.2
|
||||
version: 7.3.2(react@18.2.0)
|
||||
|
@ -119,6 +122,9 @@ dependencies:
|
|||
react-dom:
|
||||
specifier: 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:
|
||||
specifier: ^4.12.0
|
||||
version: 4.12.0(react@18.2.0)
|
||||
|
@ -1359,6 +1365,14 @@ packages:
|
|||
resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
|
@ -6888,6 +6902,15 @@ packages:
|
|||
scheduler: 0.23.0
|
||||
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):
|
||||
resolution: {integrity: sha512-IBaDuHiShdZqmfc/TwHu6+d6k2ltNCf3AszxNmjJc1KUfXdEeRJOKyNvLmAHaarhzGmTSVygNdyu8/opXv2gaw==}
|
||||
peerDependencies:
|
||||
|
|
|
@ -1,3 +1,115 @@
|
|||
"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 { ProjectLayout } from "./_components/ProjectLayout";
|
||||
|
||||
export default async function ProjectPage(props: {
|
||||
params: { id: string };
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
return <ProjectLayout project={project}>{props.children}</ProjectLayout>;
|
||||
}
|
||||
|
|
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)),
|
||||
|
||||
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 { authenticatedProcedure, createTRPCRouter } from "../../trpc";
|
||||
import { getProject } from "./project";
|
||||
import { serviceRouter } from "./service";
|
||||
|
||||
export const projectRouter = createTRPCRouter({
|
||||
services: serviceRouter,
|
||||
|
||||
list: authenticatedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
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({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
|
@ -11,6 +12,8 @@ export const getProject = projectAuthenticatedProcedure
|
|||
summary: "Get project",
|
||||
},
|
||||
})
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.use(projectMiddleware)
|
||||
.output(z.unknown())
|
||||
.query(async ({ ctx }) => {
|
||||
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 cookie from "cookie";
|
||||
import type Dockerode from "dockerode";
|
||||
import { eq, or } from "drizzle-orm";
|
||||
import { type IncomingMessage, type ServerResponse } from "http";
|
||||
import ipaddr from "ipaddr.js";
|
||||
import superjson from "superjson";
|
||||
import { ZodError, z } from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import { db } from "~/server/db";
|
||||
import { Session } from "../auth/Session";
|
||||
import { projects } from "../db/schema";
|
||||
import { getDockerInstance } from "../docker";
|
||||
import logger from "../utils/logger";
|
||||
// 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.
|
||||
* REMINDER: if you override `input`, you must include `projectId` in the new input object.
|
||||
*/
|
||||
export const projectAuthenticatedProcedure = authenticatedProcedure
|
||||
.use(
|
||||
t.middleware(async ({ ctx, input, next }) => {
|
||||
if (
|
||||
!input ||
|
||||
typeof input != "object" ||
|
||||
!("projectId" in input) ||
|
||||
typeof input.projectId != "string"
|
||||
) {
|
||||
console.log(input);
|
||||
// export const projectAuthenticatedProcedure = authenticatedProcedure
|
||||
// .use(
|
||||
// t.middleware(async ({ ctx, input, next }) => {
|
||||
// if (
|
||||
// !input ||
|
||||
// typeof input != "object" ||
|
||||
// !("projectId" in input) ||
|
||||
// typeof input.projectId != "string"
|
||||
// ) {
|
||||
// console.log(input);
|
||||
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Expected a project ID or internal name.",
|
||||
});
|
||||
}
|
||||
// throw new TRPCError({
|
||||
// code: "NOT_FOUND",
|
||||
// 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);
|
||||
// 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.",
|
||||
});
|
||||
// if (!project)
|
||||
// throw new TRPCError({
|
||||
// code: "NOT_FOUND",
|
||||
// message: "Project not found or insufficient permissions.",
|
||||
// });
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
project: project,
|
||||
},
|
||||
});
|
||||
}),
|
||||
)
|
||||
.input(z.object({ projectId: z.string() }));
|
||||
// return next({
|
||||
// ctx: {
|
||||
// project: project,
|
||||
// },
|
||||
// });
|
||||
// }),
|
||||
// )
|
||||
// .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