From ddad271a55930231d597f17bfc3573b15101a8f5 Mon Sep 17 00:00:00 2001 From: Derock Date: Sun, 4 Feb 2024 19:51:15 -0500 Subject: [PATCH] wip: more domain stuf --- package.json | 1 + pnpm-lock.yaml | 8 + .../[serviceId]/domains/DomainsList.tsx | 122 +++++++---- .../domains/_components/DomainEntry.tsx | 184 ++++++++++------ src/components/ui/card.tsx | 38 ++-- .../api/routers/projects/service/index.ts | 101 +-------- .../api/routers/projects/service/update.ts | 202 ++++++++++++++++++ src/server/db/schema.ts | 14 +- src/server/utils/zod.ts | 7 + 9 files changed, 463 insertions(+), 214 deletions(-) create mode 100644 src/server/api/routers/projects/service/update.ts diff --git a/package.json b/package.json index 5b21936..bd52ab1 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "trpc-openapi": "^1.2.0", "ts-permissions": "^1.0.0", "ua-parser-js": "^1.0.37", + "uuidv7": "^0.6.3", "winston": "^3.11.0", "ws": "^8.16.0", "zod": "^3.22.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aaa8adf..50405b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -173,6 +173,9 @@ dependencies: ua-parser-js: specifier: ^1.0.37 version: 1.0.37 + uuidv7: + specifier: ^0.6.3 + version: 0.6.3 winston: specifier: ^3.11.0 version: 3.11.0 @@ -8257,6 +8260,11 @@ packages: /util-deprecate@1.0.2: 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: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx index 933ab92..5a39880 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/DomainsList.tsx @@ -1,7 +1,9 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; import { PlusIcon } from "lucide-react"; import { useFieldArray } from "react-hook-form"; +import { uuidv7 } from "uuidv7"; import { z } from "zod"; import { Button } from "~/components/ui/button"; import { Form } from "~/components/ui/form"; @@ -10,11 +12,14 @@ import { FormUnsavedChangesIndicator, useForm, } from "~/hooks/forms"; +import { api } from "~/trpc/react"; +import { useService } from "../_hooks/service"; import DomainEntry from "./_components/DomainEntry"; const formValidator = z.object({ domains: z.array( z.object({ + domainId: z.string(), domain: z .string() .regex( @@ -23,73 +28,108 @@ const formValidator = z.object({ ), internalPort: z.coerce.number().int().min(1).max(65535).default(8080), - https: z.boolean().default(true), - forceSSL: z.boolean().default(true), + https: z.coerce.boolean().default(true), + forceSSL: z.coerce.boolean().default(true), }), ), }); -export default function DomainsList( - { - // service, - }: { - // service: RouterOutputs["projects"]["services"]["get"]; - }, -) { +export default function DomainsList() { + const service = useService(); + const updateDomain = api.projects.services.updateDomain.useMutation(); + const form = useForm(formValidator, { defaultValues: { domains: [], }, }); + const domainsForm = useFieldArray({ control: form.control, 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 (
{ - 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" >

Domains

- {domainsForm.fields.map((field, index) => ( - - ))} + + {domainsForm.fields.map((field, index) => ( + + ))} -
- - - + - -
+ + + + +
); diff --git a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx index c4ac040..ed61055 100644 --- a/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx +++ b/src/app/(dashboard)/project/[projectId]/service/[serviceId]/domains/_components/DomainEntry.tsx @@ -1,7 +1,8 @@ "use client"; +import { AnimatePresence, motion } from "framer-motion"; 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 { Button } from "~/components/ui/button"; import { Card } from "~/components/ui/card"; @@ -9,78 +10,139 @@ import { Switch } from "~/components/ui/switch"; import { SimpleFormField } from "~/hooks/forms"; type FieldData = { + domainId: string; domain: string; internalPort: number; https: boolean; forceSSL: boolean; }; -export default function DomainEntry({ - form, - domains, - field, - index, -}: { - form: UseFormReturn< - { - domains: FieldData[]; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any, - undefined - >; +const DomainEntry = forwardRef< + HTMLDivElement, + { + form: UseFormReturn< + { + domains: FieldData[]; + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + undefined + >; - domains: UseFieldArrayReturn< - { - domains: FieldData[]; - }, - "domains", - "id" - >; + domains: UseFieldArrayReturn< + { + domains: FieldData[]; + }, + "domains", + "id" + >; - field: FieldData; - index: number; -}) { - const isOpen = useState(false); + field: FieldData; + index: number; + } +>(({ form, domains, field, index }, ref) => { + const [isOpen, setIsOpen] = useState(false); return ( - - ( -
- + + +

+ Rendering {field.domainId ?? "undefined???"} at index {index} +

+ +
+ ( +
+ +
+ )} + /> + + + + + + + +
+
- )} - /> +
- + + {isOpen && ( + + ( +
+ +
+ )} + /> - + - - -
-
-
+ +
+ )} + + + ); -} +}); + +DomainEntry.displayName = "DomainEntry"; +export default DomainEntry; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 6360160..94a1188 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -4,17 +4,23 @@ import { cn } from "~/utils/utils"; const Card = React.forwardRef< HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( -
-)); + React.HTMLAttributes & { + as?: React.ElementType; + } +>(({ className, as, ...props }, ref) => { + const Component = as ?? "div"; + + return ( + + ); +}); Card.displayName = "Card"; const CardHeader = React.forwardRef< @@ -47,7 +53,7 @@ const CardDescription = React.forwardRef< >(({ className, ...props }, ref) => (

)); @@ -75,9 +81,9 @@ CardFooter.displayName = "CardFooter"; export { Card, - CardHeader, - CardFooter, - CardTitle, - CardDescription, CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, }; diff --git a/src/server/api/routers/projects/service/index.ts b/src/server/api/routers/projects/service/index.ts index 32477a8..5bb9172 100644 --- a/src/server/api/routers/projects/service/index.ts +++ b/src/server/api/routers/projects/service/index.ts @@ -1,23 +1,26 @@ import assert from "assert"; import { randomBytes } from "crypto"; import { eq } from "drizzle-orm"; -import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; import { env } from "~/env"; import { projectMiddleware } from "~/server/api/middleware/project"; import { serviceMiddleware } from "~/server/api/middleware/service"; import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc"; import { service } from "~/server/db/schema"; -import { - DOCKER_DEPLOY_MODE_MAP, - DockerDeployMode, - ServiceSource, -} from "~/server/db/types"; -import { zDockerDuration, zDockerImage, zDockerName } from "~/server/utils/zod"; +import { DOCKER_DEPLOY_MODE_MAP, ServiceSource } from "~/server/db/types"; +import { zDockerName } from "~/server/utils/zod"; import { getServiceContainers } from "./containers"; +import { + deleteServiceDomainsProcedure, + updateServiceDomainsProcedure, + updateServiceProcedure, +} from "./update"; export const serviceRouter = createTRPCRouter({ containers: getServiceContainers, + update: updateServiceProcedure, + updateDomain: updateServiceDomainsProcedure, + deleteDomain: deleteServiceDomainsProcedure, get: authenticatedProcedure .meta({ @@ -46,94 +49,10 @@ export const serviceRouter = createTRPCRouter({ return { ...fullServiceData, - zeroDowntime: fullServiceData.zeroDowntime === 1, 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(git@|https:\/\/)([\w\.@]+)(\/|:))(?P[\w,\-,\_]+)\/(?P[\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 & { - 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 .meta({ openapi: { diff --git a/src/server/api/routers/projects/service/update.ts b/src/server/api/routers/projects/service/update.ts new file mode 100644 index 0000000..735b3c4 --- /dev/null +++ b/src/server/api/routers/projects/service/update.ts @@ -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(git@|https:\/\/)([\w\.@]+)(\/|:))(?P[\w,\-,\_]+)\/(?P[\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 & { + 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; + }); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index c7c1656..c1a6087 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -169,12 +169,14 @@ export const service = sqliteTable( .$type() .default(DockerDeployMode.Replicated) .notNull(), - zeroDowntime: integer("zero_downtime").default(0).notNull(), + zeroDowntime: integer("zero_downtime", { mode: "boolean" }) + .default(false) + .notNull(), // deployment usage limits max_cpu: real("max_cpu").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: integer("restart") @@ -187,7 +189,9 @@ export const service = sqliteTable( restartMaxAttempts: integer("restart_max_attempts"), // healthcheck - healthcheckEnabled: integer("healthcheck_enabled").default(0).notNull(), + healthcheckEnabled: integer("healthcheck_enabled", { mode: "boolean" }) + .default(false) + .notNull(), healthcheckCommand: text("healthcheck_command"), healthcheckInterval: text("healthcheck_interval").default("30s").notNull(), healthcheckTimeout: text("healthcheck_timeout").default("30s").notNull(), @@ -244,8 +248,8 @@ export const serviceDomain = sqliteTable("service_domain", { domain: text("domain").notNull(), internalPort: integer("internal_port").notNull(), - https: integer("https").default(0).notNull(), - forceSSL: integer("force_ssl").default(0).notNull(), + https: integer("https", { mode: "boolean" }).default(false).notNull(), + forceSSL: integer("force_ssl", { mode: "boolean" }).default(false).notNull(), }); export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({ diff --git a/src/server/utils/zod.ts b/src/server/utils/zod.ts index f59cb56..ead25fe 100644 --- a/src/server/utils/zod.ts +++ b/src/server/utils/zod.ts @@ -18,3 +18,10 @@ export const zDockerDuration = z .regex(/(?:[\d.]+h)?(?:[\d.]+m)?(?:[\d.]+s)?/, { 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)" }, + );