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 (
);
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 && (
+
+ (
+
+
+
+ )}
+ />
-
+
-
-
-
-
-
- domains.remove(index)}
- />
-
-
+
+
+ )}
+
+
+
);
-}
+});
+
+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)" },
+ );