wip: more domain stuf
This commit is contained in:
parent
fbbf1772a1
commit
ddad271a55
|
@ -73,6 +73,7 @@
|
||||||
"trpc-openapi": "^1.2.0",
|
"trpc-openapi": "^1.2.0",
|
||||||
"ts-permissions": "^1.0.0",
|
"ts-permissions": "^1.0.0",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
|
"uuidv7": "^0.6.3",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"ws": "^8.16.0",
|
"ws": "^8.16.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|
|
@ -173,6 +173,9 @@ dependencies:
|
||||||
ua-parser-js:
|
ua-parser-js:
|
||||||
specifier: ^1.0.37
|
specifier: ^1.0.37
|
||||||
version: 1.0.37
|
version: 1.0.37
|
||||||
|
uuidv7:
|
||||||
|
specifier: ^0.6.3
|
||||||
|
version: 0.6.3
|
||||||
winston:
|
winston:
|
||||||
specifier: ^3.11.0
|
specifier: ^3.11.0
|
||||||
version: 3.11.0
|
version: 3.11.0
|
||||||
|
@ -8257,6 +8260,11 @@ packages:
|
||||||
/util-deprecate@1.0.2:
|
/util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
/uuidv7@0.6.3:
|
||||||
|
resolution: {integrity: sha512-zV3eW2NlXTsun/aJ7AixxZjH/byQcH/r3J99MI0dDEkU2cJIBJxhEWUHDTpOaLPRNhebPZoeHuykYREkI9HafA==}
|
||||||
|
hasBin: true
|
||||||
|
dev: false
|
||||||
|
|
||||||
/validate-npm-package-license@3.0.4:
|
/validate-npm-package-license@3.0.4:
|
||||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { PlusIcon } from "lucide-react";
|
||||||
import { useFieldArray } from "react-hook-form";
|
import { useFieldArray } from "react-hook-form";
|
||||||
|
import { uuidv7 } from "uuidv7";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Form } from "~/components/ui/form";
|
import { Form } from "~/components/ui/form";
|
||||||
|
@ -10,11 +12,14 @@ import {
|
||||||
FormUnsavedChangesIndicator,
|
FormUnsavedChangesIndicator,
|
||||||
useForm,
|
useForm,
|
||||||
} from "~/hooks/forms";
|
} from "~/hooks/forms";
|
||||||
|
import { api } from "~/trpc/react";
|
||||||
|
import { useService } from "../_hooks/service";
|
||||||
import DomainEntry from "./_components/DomainEntry";
|
import DomainEntry from "./_components/DomainEntry";
|
||||||
|
|
||||||
const formValidator = z.object({
|
const formValidator = z.object({
|
||||||
domains: z.array(
|
domains: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
domainId: z.string(),
|
||||||
domain: z
|
domain: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
|
@ -23,73 +28,108 @@ const formValidator = z.object({
|
||||||
),
|
),
|
||||||
|
|
||||||
internalPort: z.coerce.number().int().min(1).max(65535).default(8080),
|
internalPort: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||||
https: z.boolean().default(true),
|
https: z.coerce.boolean().default(true),
|
||||||
forceSSL: z.boolean().default(true),
|
forceSSL: z.coerce.boolean().default(true),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function DomainsList(
|
export default function DomainsList() {
|
||||||
{
|
const service = useService();
|
||||||
// service,
|
const updateDomain = api.projects.services.updateDomain.useMutation();
|
||||||
}: {
|
|
||||||
// service: RouterOutputs["projects"]["services"]["get"];
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const form = useForm(formValidator, {
|
const form = useForm(formValidator, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
domains: [],
|
domains: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const domainsForm = useFieldArray({
|
const domainsForm = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: "domains",
|
name: "domains",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// console.log("setting domains", service.data?.domains ?? []);
|
||||||
|
// form.setValue("domains", service.data?.domains ?? []);
|
||||||
|
// }, [service.data?.domains]);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"Rendering fields with ids: ",
|
||||||
|
domainsForm.fields.map((field) => field.id),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(async (data) => {
|
onSubmit={form.handleSubmit(async (data) => {
|
||||||
console.log(data);
|
// await Promise.all(
|
||||||
|
// data.domains.map((domain) => {
|
||||||
|
// if (service.data === undefined) return;
|
||||||
|
|
||||||
|
// return updateDomain.mutateAsync({
|
||||||
|
// projectId: service.data.projectId,
|
||||||
|
// serviceId: service.data.id,
|
||||||
|
// domain: domain.domain,
|
||||||
|
// forceSSL: domain.forceSSL,
|
||||||
|
// https: domain.https,
|
||||||
|
// internalPort: domain.internalPort,
|
||||||
|
// });
|
||||||
|
// }),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// refetch service
|
||||||
|
await service.refetch();
|
||||||
})}
|
})}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<h1 className="col-span-2">Domains</h1>
|
<h1 className="col-span-2">Domains</h1>
|
||||||
|
|
||||||
{domainsForm.fields.map((field, index) => (
|
<AnimatePresence mode="sync">
|
||||||
<DomainEntry
|
{domainsForm.fields.map((field, index) => (
|
||||||
form={form}
|
<DomainEntry
|
||||||
domains={domainsForm}
|
form={form}
|
||||||
field={field}
|
domains={domainsForm}
|
||||||
index={index}
|
field={field}
|
||||||
key={field.id}
|
index={index}
|
||||||
/>
|
key={field.id}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="flex flex-row flex-wrap items-center gap-4">
|
<motion.div
|
||||||
<FormSubmit
|
className="flex flex-row flex-wrap items-center gap-4"
|
||||||
form={form}
|
layout
|
||||||
className="col-span-2"
|
key={service.data?.id}
|
||||||
hideUnsavedChangesIndicator
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
icon={PlusIcon}
|
|
||||||
onClick={() =>
|
|
||||||
domainsForm.append({
|
|
||||||
domain: "",
|
|
||||||
forceSSL: false,
|
|
||||||
https: false,
|
|
||||||
internalPort: 8080,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Add Domain
|
<FormSubmit
|
||||||
</Button>
|
form={form}
|
||||||
|
className="col-span-2"
|
||||||
|
hideUnsavedChangesIndicator
|
||||||
|
/>
|
||||||
|
|
||||||
<FormUnsavedChangesIndicator form={form} />
|
<Button
|
||||||
</div>
|
variant="secondary"
|
||||||
|
icon={PlusIcon}
|
||||||
|
onClick={() => {
|
||||||
|
const domain = {
|
||||||
|
domainId: new Date().toISOString(),
|
||||||
|
domain: uuidv7().split("-").at(-1) + ".example.com",
|
||||||
|
forceSSL: false,
|
||||||
|
https: false,
|
||||||
|
internalPort: 8080,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("add domain: ", domain.domainId);
|
||||||
|
|
||||||
|
domainsForm.append(domain);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Domain
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<FormUnsavedChangesIndicator form={form} />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { ArrowRight, CogIcon, TrashIcon } from "lucide-react";
|
import { ArrowRight, CogIcon, TrashIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { forwardRef, useState } from "react";
|
||||||
import { type UseFieldArrayReturn, type UseFormReturn } from "react-hook-form";
|
import { type UseFieldArrayReturn, type UseFormReturn } from "react-hook-form";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
|
@ -9,78 +10,139 @@ import { Switch } from "~/components/ui/switch";
|
||||||
import { SimpleFormField } from "~/hooks/forms";
|
import { SimpleFormField } from "~/hooks/forms";
|
||||||
|
|
||||||
type FieldData = {
|
type FieldData = {
|
||||||
|
domainId: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
internalPort: number;
|
internalPort: number;
|
||||||
https: boolean;
|
https: boolean;
|
||||||
forceSSL: boolean;
|
forceSSL: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DomainEntry({
|
const DomainEntry = forwardRef<
|
||||||
form,
|
HTMLDivElement,
|
||||||
domains,
|
{
|
||||||
field,
|
form: UseFormReturn<
|
||||||
index,
|
{
|
||||||
}: {
|
domains: FieldData[];
|
||||||
form: UseFormReturn<
|
},
|
||||||
{
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
domains: FieldData[];
|
any,
|
||||||
},
|
undefined
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
>;
|
||||||
any,
|
|
||||||
undefined
|
|
||||||
>;
|
|
||||||
|
|
||||||
domains: UseFieldArrayReturn<
|
domains: UseFieldArrayReturn<
|
||||||
{
|
{
|
||||||
domains: FieldData[];
|
domains: FieldData[];
|
||||||
},
|
},
|
||||||
"domains",
|
"domains",
|
||||||
"id"
|
"id"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
field: FieldData;
|
field: FieldData;
|
||||||
index: number;
|
index: number;
|
||||||
}) {
|
}
|
||||||
const isOpen = useState(false);
|
>(({ form, domains, field, index }, ref) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-row gap-4 p-4">
|
<motion.div
|
||||||
<SimpleFormField
|
ref={ref}
|
||||||
control={form.control}
|
layout
|
||||||
name={`domains.${index}.forceSSL`}
|
initial={{ opacity: 0, scale: 0.5 }}
|
||||||
friendlyName="HTTPS"
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
render={({ field }) => (
|
exit={{ opacity: 0, scale: 0.5 }}
|
||||||
<div className="pt-2">
|
transition={{
|
||||||
<Switch {...field} className="mx-auto block" />
|
duration: 0.4,
|
||||||
|
type: "spring",
|
||||||
|
}}
|
||||||
|
key={field.domainId}
|
||||||
|
>
|
||||||
|
<Card className="p-4">
|
||||||
|
<h1>
|
||||||
|
Rendering {field.domainId ?? "undefined???"} at index {index}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<SimpleFormField
|
||||||
|
control={form.control}
|
||||||
|
name={`domains.${index}.forceSSL`}
|
||||||
|
friendlyName="HTTPS"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Switch {...field} className="mx-auto block" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleFormField
|
||||||
|
control={form.control}
|
||||||
|
name={`domains.${index}.domain`}
|
||||||
|
friendlyName="Domain"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ArrowRight className="mt-9 flex-shrink-0" />
|
||||||
|
|
||||||
|
<SimpleFormField
|
||||||
|
control={form.control}
|
||||||
|
name={`domains.${index}.internalPort`}
|
||||||
|
friendlyName="Internal Port"
|
||||||
|
className="w-60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 pt-8">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={CogIcon}
|
||||||
|
className="mr-2"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
icon={TrashIcon}
|
||||||
|
onClick={() => {
|
||||||
|
domains.remove(index);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<SimpleFormField
|
<AnimatePresence>
|
||||||
control={form.control}
|
{isOpen && (
|
||||||
name={`domains.${index}.domain`}
|
<motion.div className="flex flex-col gap-4">
|
||||||
friendlyName="Domain"
|
<SimpleFormField
|
||||||
className="flex-1"
|
control={form.control}
|
||||||
/>
|
name={`domains.${index}.https`}
|
||||||
|
friendlyName="HTTPS"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Switch {...field} className="mx-auto block" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ArrowRight className="mt-9 flex-shrink-0" />
|
<SimpleFormField
|
||||||
|
control={form.control}
|
||||||
|
name={`domains.${index}.domain`}
|
||||||
|
friendlyName="Domain"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
<SimpleFormField
|
<SimpleFormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`domains.${index}.internalPort`}
|
name={`domains.${index}.internalPort`}
|
||||||
friendlyName="Internal Port"
|
friendlyName="Internal Port"
|
||||||
className="w-60"
|
className="w-60"
|
||||||
/>
|
/>
|
||||||
|
</motion.div>
|
||||||
<div className="flex flex-row gap-2 pt-8">
|
)}
|
||||||
<Button variant="secondary" icon={CogIcon} className="mr-2" />
|
</AnimatePresence>
|
||||||
|
</Card>
|
||||||
<Button
|
</motion.div>
|
||||||
variant="destructive"
|
|
||||||
icon={TrashIcon}
|
|
||||||
onClick={() => domains.remove(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
DomainEntry.displayName = "DomainEntry";
|
||||||
|
export default DomainEntry;
|
||||||
|
|
|
@ -4,17 +4,23 @@ import { cn } from "~/utils/utils";
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
>(({ className, ...props }, ref) => (
|
as?: React.ElementType;
|
||||||
<div
|
}
|
||||||
ref={ref}
|
>(({ className, as, ...props }, ref) => {
|
||||||
className={cn(
|
const Component = as ?? "div";
|
||||||
"bg-card text-card-foreground rounded-xl border shadow",
|
|
||||||
className,
|
return (
|
||||||
)}
|
<Component
|
||||||
{...props}
|
ref={ref}
|
||||||
/>
|
className={cn(
|
||||||
));
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
Card.displayName = "Card";
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<
|
||||||
|
@ -47,7 +53,7 @@ const CardDescription = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<p
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
@ -75,9 +81,9 @@ CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
|
||||||
CardFooter,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,23 +1,26 @@
|
||||||
import assert from "assert";
|
import assert from "assert";
|
||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "~/env";
|
import { env } from "~/env";
|
||||||
import { projectMiddleware } from "~/server/api/middleware/project";
|
import { projectMiddleware } from "~/server/api/middleware/project";
|
||||||
import { serviceMiddleware } from "~/server/api/middleware/service";
|
import { serviceMiddleware } from "~/server/api/middleware/service";
|
||||||
import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc";
|
import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc";
|
||||||
import { service } from "~/server/db/schema";
|
import { service } from "~/server/db/schema";
|
||||||
import {
|
import { DOCKER_DEPLOY_MODE_MAP, ServiceSource } from "~/server/db/types";
|
||||||
DOCKER_DEPLOY_MODE_MAP,
|
import { zDockerName } from "~/server/utils/zod";
|
||||||
DockerDeployMode,
|
|
||||||
ServiceSource,
|
|
||||||
} from "~/server/db/types";
|
|
||||||
import { zDockerDuration, zDockerImage, zDockerName } from "~/server/utils/zod";
|
|
||||||
import { getServiceContainers } from "./containers";
|
import { getServiceContainers } from "./containers";
|
||||||
|
import {
|
||||||
|
deleteServiceDomainsProcedure,
|
||||||
|
updateServiceDomainsProcedure,
|
||||||
|
updateServiceProcedure,
|
||||||
|
} from "./update";
|
||||||
|
|
||||||
export const serviceRouter = createTRPCRouter({
|
export const serviceRouter = createTRPCRouter({
|
||||||
containers: getServiceContainers,
|
containers: getServiceContainers,
|
||||||
|
update: updateServiceProcedure,
|
||||||
|
updateDomain: updateServiceDomainsProcedure,
|
||||||
|
deleteDomain: deleteServiceDomainsProcedure,
|
||||||
|
|
||||||
get: authenticatedProcedure
|
get: authenticatedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
|
@ -46,94 +49,10 @@ export const serviceRouter = createTRPCRouter({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...fullServiceData,
|
...fullServiceData,
|
||||||
zeroDowntime: fullServiceData.zeroDowntime === 1,
|
|
||||||
deployMode: DOCKER_DEPLOY_MODE_MAP[fullServiceData.deployMode],
|
deployMode: DOCKER_DEPLOY_MODE_MAP[fullServiceData.deployMode],
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: authenticatedProcedure
|
|
||||||
.meta({
|
|
||||||
openapi: {
|
|
||||||
method: "PATCH",
|
|
||||||
path: "/api/projects/:projectId/services/:serviceId",
|
|
||||||
summary: "Update service",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
// sometimes i forget how powerful zod is
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
projectId: z.string(),
|
|
||||||
serviceId: z.string(),
|
|
||||||
})
|
|
||||||
.merge(
|
|
||||||
createInsertSchema(service, {
|
|
||||||
dockerImage: zDockerImage,
|
|
||||||
gitUrl: (schema) =>
|
|
||||||
schema.gitUrl.regex(
|
|
||||||
// https://www.debuggex.com/r/fFggA8Uc4YYKjl34 from https://stackoverflow.com/a/22312124
|
|
||||||
// /(?P<host>(git@|https:\/\/)([\w\.@]+)(\/|:))(?P<owner>[\w,\-,\_]+)\/(?P<repo>[\w,\-,\_]+)(.git){0,1}((\/){0,1})/,
|
|
||||||
/(git@|https:\/\/)([\w\.@]+)(\/|:)([\w,\-,\_]+)\/([\w,\-,\_]+)(.git){0,1}(\/{0,1})/,
|
|
||||||
{
|
|
||||||
message: "Must be a valid git url. (Regex failed)",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
deployMode: z
|
|
||||||
.nativeEnum(DockerDeployMode)
|
|
||||||
// or "replicated" | "global" and transform into 1 or 2
|
|
||||||
.or(
|
|
||||||
z
|
|
||||||
.enum(["replicated", "global"])
|
|
||||||
.transform((val) =>
|
|
||||||
val === "replicated"
|
|
||||||
? DockerDeployMode.Replicated
|
|
||||||
: DockerDeployMode.Global,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
zeroDowntime: z.boolean().transform((val) => (val ? 1 : 0)),
|
|
||||||
restartDelay: zDockerDuration,
|
|
||||||
healthcheckEnabled: z.boolean().transform((val) => (val ? 1 : 0)),
|
|
||||||
healthcheckInterval: zDockerDuration,
|
|
||||||
healthcheckTimeout: zDockerDuration,
|
|
||||||
healthcheckStartPeriod: zDockerDuration,
|
|
||||||
loggingMaxSize: (schema) =>
|
|
||||||
schema.loggingMaxSize.regex(/^\d+[kmg]$/, {
|
|
||||||
message: "Must be an integer plus a modifier (k, m, or g).",
|
|
||||||
}),
|
|
||||||
loggingMaxFiles: (schema) => schema.loggingMaxFiles.positive(),
|
|
||||||
})
|
|
||||||
.omit({
|
|
||||||
id: true,
|
|
||||||
projectId: true,
|
|
||||||
name: true,
|
|
||||||
})
|
|
||||||
.partial(),
|
|
||||||
)
|
|
||||||
.strict(),
|
|
||||||
)
|
|
||||||
.use(projectMiddleware)
|
|
||||||
.use(serviceMiddleware)
|
|
||||||
.mutation(async ({ ctx, input }) => {
|
|
||||||
// gotta keep TS happy, can't delete properties from input directly
|
|
||||||
const queryUpdate: Omit<typeof input, "projectId" | "serviceId"> & {
|
|
||||||
projectId?: string;
|
|
||||||
serviceId?: string;
|
|
||||||
id?: string;
|
|
||||||
} = structuredClone(input);
|
|
||||||
|
|
||||||
delete queryUpdate.projectId;
|
|
||||||
delete queryUpdate.serviceId;
|
|
||||||
delete queryUpdate.id;
|
|
||||||
|
|
||||||
await ctx.db
|
|
||||||
.update(service)
|
|
||||||
.set(queryUpdate)
|
|
||||||
.where(eq(service.id, ctx.service.id))
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
|
|
||||||
create: authenticatedProcedure
|
create: authenticatedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
openapi: {
|
openapi: {
|
||||||
|
|
202
src/server/api/routers/projects/service/update.ts
Normal file
202
src/server/api/routers/projects/service/update.ts
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { projectMiddleware } from "~/server/api/middleware/project";
|
||||||
|
import { serviceMiddleware } from "~/server/api/middleware/service";
|
||||||
|
import { authenticatedProcedure } from "~/server/api/trpc";
|
||||||
|
import { service, serviceDomain } from "~/server/db/schema";
|
||||||
|
import { DockerDeployMode } from "~/server/db/types";
|
||||||
|
import { zDockerDuration, zDockerImage, zDomain } from "~/server/utils/zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles updating basic details of a service
|
||||||
|
*/
|
||||||
|
export const updateServiceProcedure = authenticatedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
method: "PATCH",
|
||||||
|
path: "/api/projects/:projectId/services/:serviceId",
|
||||||
|
summary: "Update service",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
// sometimes i forget how powerful zod is
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
serviceId: z.string(),
|
||||||
|
})
|
||||||
|
.merge(
|
||||||
|
createInsertSchema(service, {
|
||||||
|
dockerImage: zDockerImage,
|
||||||
|
gitUrl: (schema) =>
|
||||||
|
schema.gitUrl.regex(
|
||||||
|
// https://www.debuggex.com/r/fFggA8Uc4YYKjl34 from https://stackoverflow.com/a/22312124
|
||||||
|
// /(?P<host>(git@|https:\/\/)([\w\.@]+)(\/|:))(?P<owner>[\w,\-,\_]+)\/(?P<repo>[\w,\-,\_]+)(.git){0,1}((\/){0,1})/,
|
||||||
|
/(git@|https:\/\/)([\w\.@]+)(\/|:)([\w,\-,\_]+)\/([\w,\-,\_]+)(.git){0,1}(\/{0,1})/,
|
||||||
|
{
|
||||||
|
message: "Must be a valid git url. (Regex failed)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
deployMode: z
|
||||||
|
.nativeEnum(DockerDeployMode)
|
||||||
|
// or "replicated" | "global" and transform into 1 or 2
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.enum(["replicated", "global"])
|
||||||
|
.transform((val) =>
|
||||||
|
val === "replicated"
|
||||||
|
? DockerDeployMode.Replicated
|
||||||
|
: DockerDeployMode.Global,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
zeroDowntime: z.boolean(),
|
||||||
|
restartDelay: zDockerDuration,
|
||||||
|
healthcheckEnabled: z.boolean(),
|
||||||
|
healthcheckInterval: zDockerDuration,
|
||||||
|
healthcheckTimeout: zDockerDuration,
|
||||||
|
healthcheckStartPeriod: zDockerDuration,
|
||||||
|
loggingMaxSize: (schema) =>
|
||||||
|
schema.loggingMaxSize.regex(/^\d+[kmg]$/, {
|
||||||
|
message: "Must be an integer plus a modifier (k, m, or g).",
|
||||||
|
}),
|
||||||
|
loggingMaxFiles: (schema) => schema.loggingMaxFiles.positive(),
|
||||||
|
})
|
||||||
|
.omit({
|
||||||
|
id: true,
|
||||||
|
projectId: true,
|
||||||
|
name: true,
|
||||||
|
})
|
||||||
|
.partial(),
|
||||||
|
)
|
||||||
|
.strict(),
|
||||||
|
)
|
||||||
|
.use(projectMiddleware)
|
||||||
|
.use(serviceMiddleware)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// gotta keep TS happy, can't delete properties from input directly
|
||||||
|
const queryUpdate: Omit<typeof input, "projectId" | "serviceId"> & {
|
||||||
|
projectId?: string;
|
||||||
|
serviceId?: string;
|
||||||
|
id?: string;
|
||||||
|
} = structuredClone(input);
|
||||||
|
|
||||||
|
delete queryUpdate.projectId;
|
||||||
|
delete queryUpdate.serviceId;
|
||||||
|
delete queryUpdate.id;
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(service)
|
||||||
|
.set(queryUpdate)
|
||||||
|
.where(eq(service.id, ctx.service.id))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateServiceDomainsProcedure = authenticatedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
method: "PUT",
|
||||||
|
path: "/api/projects/:projectId/services/:serviceId/domains/:domainId",
|
||||||
|
summary: "Update or create a service domains",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
// meta
|
||||||
|
projectId: z.string(),
|
||||||
|
serviceId: z.string(),
|
||||||
|
domainId: z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
// details
|
||||||
|
domain: zDomain,
|
||||||
|
internalPort: z.coerce.number().int().min(1).max(65535),
|
||||||
|
https: z.boolean(),
|
||||||
|
forceSSL: z.boolean().default(true),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const baseQuery = ctx.db
|
||||||
|
.insert(serviceDomain)
|
||||||
|
.values({
|
||||||
|
id: input.domainId,
|
||||||
|
serviceId: input.serviceId,
|
||||||
|
domain: input.domain,
|
||||||
|
internalPort: input.internalPort,
|
||||||
|
https: input.https,
|
||||||
|
forceSSL: input.forceSSL,
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: serviceDomain.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// track if we inserted or updated
|
||||||
|
let updated = false;
|
||||||
|
let domainId = input.domainId;
|
||||||
|
|
||||||
|
if (input.domainId) {
|
||||||
|
const result = await baseQuery
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
set: {
|
||||||
|
internalPort: input.internalPort,
|
||||||
|
https: input.https,
|
||||||
|
forceSSL: input.forceSSL,
|
||||||
|
domain: input.domain,
|
||||||
|
},
|
||||||
|
target: serviceDomain.id,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (result[0]) {
|
||||||
|
updated = true;
|
||||||
|
domainId = result[0].id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await baseQuery.execute();
|
||||||
|
|
||||||
|
if (result[0]) {
|
||||||
|
updated = false;
|
||||||
|
domainId = result[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updated,
|
||||||
|
domainId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a service domain
|
||||||
|
*/
|
||||||
|
export const deleteServiceDomainsProcedure = authenticatedProcedure
|
||||||
|
.meta({
|
||||||
|
openapi: {
|
||||||
|
method: "DELETE",
|
||||||
|
path: "/api/projects/:projectId/services/:serviceId/domains/:domainId",
|
||||||
|
summary: "Delete a service domain",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
serviceId: z.string(),
|
||||||
|
domainId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(projectMiddleware)
|
||||||
|
.use(serviceMiddleware)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
await ctx.db
|
||||||
|
.delete(serviceDomain)
|
||||||
|
.where(eq(serviceDomain.id, input.domainId))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
|
@ -169,12 +169,14 @@ export const service = sqliteTable(
|
||||||
.$type<DockerDeployMode>()
|
.$type<DockerDeployMode>()
|
||||||
.default(DockerDeployMode.Replicated)
|
.default(DockerDeployMode.Replicated)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
zeroDowntime: integer("zero_downtime").default(0).notNull(),
|
zeroDowntime: integer("zero_downtime", { mode: "boolean" })
|
||||||
|
.default(false)
|
||||||
|
.notNull(),
|
||||||
|
|
||||||
// deployment usage limits
|
// deployment usage limits
|
||||||
max_cpu: real("max_cpu").default(0).notNull(),
|
max_cpu: real("max_cpu").default(0).notNull(),
|
||||||
max_memory: text("max_memory").default("0").notNull(),
|
max_memory: text("max_memory").default("0").notNull(),
|
||||||
max_pids: integer("max_pids").default(0).notNull(),
|
max_pids: integer("max_pids", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
|
||||||
// restart policy
|
// restart policy
|
||||||
restart: integer("restart")
|
restart: integer("restart")
|
||||||
|
@ -187,7 +189,9 @@ export const service = sqliteTable(
|
||||||
restartMaxAttempts: integer("restart_max_attempts"),
|
restartMaxAttempts: integer("restart_max_attempts"),
|
||||||
|
|
||||||
// healthcheck
|
// healthcheck
|
||||||
healthcheckEnabled: integer("healthcheck_enabled").default(0).notNull(),
|
healthcheckEnabled: integer("healthcheck_enabled", { mode: "boolean" })
|
||||||
|
.default(false)
|
||||||
|
.notNull(),
|
||||||
healthcheckCommand: text("healthcheck_command"),
|
healthcheckCommand: text("healthcheck_command"),
|
||||||
healthcheckInterval: text("healthcheck_interval").default("30s").notNull(),
|
healthcheckInterval: text("healthcheck_interval").default("30s").notNull(),
|
||||||
healthcheckTimeout: text("healthcheck_timeout").default("30s").notNull(),
|
healthcheckTimeout: text("healthcheck_timeout").default("30s").notNull(),
|
||||||
|
@ -244,8 +248,8 @@ export const serviceDomain = sqliteTable("service_domain", {
|
||||||
|
|
||||||
domain: text("domain").notNull(),
|
domain: text("domain").notNull(),
|
||||||
internalPort: integer("internal_port").notNull(),
|
internalPort: integer("internal_port").notNull(),
|
||||||
https: integer("https").default(0).notNull(),
|
https: integer("https", { mode: "boolean" }).default(false).notNull(),
|
||||||
forceSSL: integer("force_ssl").default(0).notNull(),
|
forceSSL: integer("force_ssl", { mode: "boolean" }).default(false).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({
|
export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({
|
||||||
|
|
|
@ -18,3 +18,10 @@ export const zDockerDuration = z
|
||||||
.regex(/(?:[\d.]+h)?(?:[\d.]+m)?(?:[\d.]+s)?/, {
|
.regex(/(?:[\d.]+h)?(?:[\d.]+m)?(?:[\d.]+s)?/, {
|
||||||
message: "Must be a valid duration. (Regex failed)",
|
message: "Must be a valid duration. (Regex failed)",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zDomain = z
|
||||||
|
.string()
|
||||||
|
.regex(
|
||||||
|
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/,
|
||||||
|
{ message: "Invalid domain name. (Regex)" },
|
||||||
|
);
|
||||||
|
|
Loading…
Reference in a new issue