diff --git a/messages/en-US.json b/messages/en-US.json index ba5e6f7b..6b0c01cb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1161,6 +1161,8 @@ "blueprintInfo": "Blueprint Information", "blueprintNameDescription": "This is the display name for the blueprint.", "blueprintContentsDescription": "Define the YAML content describing your infrastructure", + "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", + "blueprintErrorCreate": "Error creating blueprint", "searchBlueprintProgress": "Search blueprints...", "source": "Source", "contents": "Contents", diff --git a/package-lock.json b/package-lock.json index f4a112c0..cc62d193 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,6 +101,7 @@ "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", + "yaml": "^2.8.1", "yargs": "18.0.0", "zod": "3.25.76", "zod-validation-error": "3.5.2" diff --git a/package.json b/package.json index 88a7bb67..536505e3 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "winston": "3.18.3", "winston-daily-rotate-file": "5.0.0", "ws": "8.18.3", + "yaml": "^2.8.1", "yargs": "18.0.0", "zod": "3.25.76", "zod-validation-error": "3.5.2" diff --git a/server/routers/blueprints/createAndApplyBlueprint.ts b/server/routers/blueprints/createAndApplyBlueprint.ts new file mode 100644 index 00000000..642559bf --- /dev/null +++ b/server/routers/blueprints/createAndApplyBlueprint.ts @@ -0,0 +1,133 @@ +import { OpenAPITags, registry } from "@server/openApi"; +import z from "zod"; +import { applyBlueprint as applyBlueprintFunc } from "@server/lib/blueprints/applyBlueprint"; +import { NextFunction, Request, Response } from "express"; +import logger from "@server/logger"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromZodError } from "zod-validation-error"; +import response from "@server/lib/response"; +import { type Blueprint, blueprints, db, loginPage } from "@server/db"; +import { parse as parseYaml } from "yaml"; + +const applyBlueprintSchema = z + .object({ + name: z.string().min(1).max(255), + contents: z + .string() + .min(1) + .superRefine((contents, ctx) => { + try { + parseYaml(contents); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }) + }) + .strict(); + +const applyBlueprintParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export type CreateBlueprintResponse = Blueprint; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/blueprint", + description: + "Create and Apply a base64 encoded blueprint to an organization", + tags: [OpenAPITags.Org], + request: { + params: applyBlueprintParamsSchema, + body: { + content: { + "application/json": { + schema: applyBlueprintSchema + } + } + } + }, + responses: {} +}); + +export async function createAndApplyBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = applyBlueprintParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = applyBlueprintSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedBody.error) + ) + ); + } + + const { contents, name } = parsedBody.data; + + logger.debug(`Received blueprint: ${contents}`); + + // try { + // // then parse the json + // const blueprintParsed = parseYaml(contents); + + // // Update the blueprint in the database + // await applyBlueprintFunc(orgId, blueprintParsed); + + // await db.transaction(async (trx) => { + // const newBlueprint = await trx + // .insert(blueprints) + // .values({ + // orgId, + // name, + // contents + // // createdAt + // }) + // .returning(); + // }); + // } catch (error) { + // logger.error(`Failed to update database from config: ${error}`); + + // return next( + // createHttpError( + // HttpCode.BAD_REQUEST, + // `Failed to update database from config: ${error}` + // ) + // ); + // } + + return response(res, { + data: null, + success: true, + error: false, + message: "Blueprint applied successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/blueprints/index.ts b/server/routers/blueprints/index.ts index 7182d5f9..24634365 100644 --- a/server/routers/blueprints/index.ts +++ b/server/routers/blueprints/index.ts @@ -1 +1,2 @@ -export * from "./listBluePrints"; +export * from "./listBlueprints"; +export * from "./createAndApplyBlueprint"; diff --git a/server/routers/blueprints/listBluePrints.ts b/server/routers/blueprints/listBluePrints.ts index e25f0563..c021e1e6 100644 --- a/server/routers/blueprints/listBluePrints.ts +++ b/server/routers/blueprints/listBluePrints.ts @@ -40,7 +40,8 @@ async function queryBlueprints(orgId: string, limit: number, offset: number) { name: blueprints.name, source: blueprints.source, succeeded: blueprints.succeeded, - orgId: blueprints.orgId + orgId: blueprints.orgId, + createdAt: blueprints.createdAt }) .from(blueprints) .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) @@ -51,9 +52,10 @@ async function queryBlueprints(orgId: string, limit: number, offset: number) { type BlueprintData = Omit< Awaited>[number], - "source" + "source" | "createdAt" > & { - source: "API" | "WEB" | "CLI"; + source: "API" | "UI" | "NEWT"; + createdAt: Date; }; export type ListBlueprintsResponse = { @@ -116,7 +118,10 @@ export async function listBlueprints( return response(res, { data: { - blueprints: blueprintsList as BlueprintData[], + blueprints: blueprintsList.map((b) => ({ + ...b, + createdAt: new Date(b.createdAt * 1000) + })) as BlueprintData[], pagination: { total: count, limit, diff --git a/server/routers/external.ts b/server/routers/external.ts index a5ef3ba3..fb18db72 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -818,6 +818,14 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listBlueprints), blueprints.listBlueprints ); + +authenticated.put( + "/org/:orgId/blueprints", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.applyBlueprint), + blueprints.createAndApplyBlueprint +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/src/app/[orgId]/settings/blueprints/create/page.tsx b/src/app/[orgId]/settings/blueprints/create/page.tsx index 387e470a..3d78289f 100644 --- a/src/app/[orgId]/settings/blueprints/create/page.tsx +++ b/src/app/[orgId]/settings/blueprints/create/page.tsx @@ -37,7 +37,7 @@ export default async function CreateBlueprintPage( /> - + ); } diff --git a/src/components/CreateBlueprintForm.tsx b/src/components/CreateBlueprintForm.tsx index de8516c2..376b8424 100644 --- a/src/components/CreateBlueprintForm.tsx +++ b/src/components/CreateBlueprintForm.tsx @@ -26,19 +26,43 @@ import { useActionState, useTransition } from "react"; import Editor, { useMonaco } from "@monaco-editor/react"; import { cn } from "@app/lib/cn"; import { Button } from "./ui/button"; +import { wait } from "@app/lib/wait"; +import { parse as parseYaml } from "yaml"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import type { CreateBlueprintResponse } from "@server/routers/blueprints"; +import { toast } from "@app/hooks/useToast"; -export type CreateBlueprintFormProps = {}; +export type CreateBlueprintFormProps = { + orgId: string; +}; -export default function CreateBlueprintForm({}: CreateBlueprintFormProps) { +export default function CreateBlueprintForm({ + orgId +}: CreateBlueprintFormProps) { const t = useTranslations(); - + const { env } = useEnvContext(); + const api = createApiClient({ env }); const [, formAction, isSubmitting] = useActionState(onSubmit, null); - const baseForm = useForm({ + const form = useForm({ resolver: zodResolver( z.object({ name: z.string().min(1).max(255), - contents: z.string() + contents: z + .string() + .min(1) + .superRefine((contents, ctx) => { + try { + parseYaml(contents); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }) }) ), defaultValues: { @@ -47,7 +71,7 @@ export default function CreateBlueprintForm({}: CreateBlueprintFormProps) { resource-nice-id-uno: name: this is my resource protocol: http - full-domain: duce.test.example.com + full-domain: never-gonna-give-you-up.example.com host-header: example.com tls-server-name: example.com ` @@ -55,124 +79,100 @@ export default function CreateBlueprintForm({}: CreateBlueprintFormProps) { }); async function onSubmit() { - // setCreateLoading(true); - // const baseData = baseForm.getValues(); - // const isHttp = baseData.http; - // try { - // const payload = { - // name: baseData.name, - // http: baseData.http, - // }; - // let sanitizedSubdomain: string | undefined; - // if (isHttp) { - // const httpData = httpForm.getValues(); - // sanitizedSubdomain = httpData.subdomain - // ? finalizeSubdomainSanitize(httpData.subdomain) - // : undefined; - // Object.assign(payload, { - // subdomain: sanitizedSubdomain - // ? toASCII(sanitizedSubdomain) - // : undefined, - // domainId: httpData.domainId, - // protocol: "tcp" - // }); - // } else { - // const tcpUdpData = tcpUdpForm.getValues(); - // Object.assign(payload, { - // protocol: tcpUdpData.protocol, - // proxyPort: tcpUdpData.proxyPort - // // enableProxy: tcpUdpData.enableProxy - // }); - // } - // const res = await api - // .put< - // AxiosResponse - // >(`/org/${orgId}/resource/`, payload) - // .catch((e) => { - // toast({ - // variant: "destructive", - // title: t("resourceErrorCreate"), - // description: formatAxiosError( - // e, - // t("resourceErrorCreateDescription") - // ) - // }); - // }); - // if (res && res.status === 201) { - // const id = res.data.data.resourceId; - // const niceId = res.data.data.niceId; - // setNiceId(niceId); - // // Create targets if any exist - // if (targets.length > 0) { - // try { - // for (const target of targets) { - // const data: any = { - // ip: target.ip, - // port: target.port, - // method: target.method, - // enabled: target.enabled, - // siteId: target.siteId, - // hcEnabled: target.hcEnabled, - // hcPath: target.hcPath || null, - // hcMethod: target.hcMethod || null, - // hcInterval: target.hcInterval || null, - // hcTimeout: target.hcTimeout || null, - // hcHeaders: target.hcHeaders || null, - // hcScheme: target.hcScheme || null, - // hcHostname: target.hcHostname || null, - // hcPort: target.hcPort || null, - // hcFollowRedirects: - // target.hcFollowRedirects || null, - // hcStatus: target.hcStatus || null - // }; - // // Only include path-related fields for HTTP resources - // if (isHttp) { - // data.path = target.path; - // data.pathMatchType = target.pathMatchType; - // data.rewritePath = target.rewritePath; - // data.rewritePathType = target.rewritePathType; - // data.priority = target.priority; - // } - // await api.put(`/resource/${id}/target`, data); - // } - // } catch (targetError) { - // console.error("Error creating targets:", targetError); - // toast({ - // variant: "destructive", - // title: t("targetErrorCreate"), - // description: formatAxiosError( - // targetError, - // t("targetErrorCreateDescription") - // ) - // }); - // } - // } - // if (isHttp) { - // router.push(`/${orgId}/settings/resources/${niceId}`); - // } else { - // const tcpUdpData = tcpUdpForm.getValues(); - // // Only show config snippets if enableProxy is explicitly true - // // if (tcpUdpData.enableProxy === true) { - // setShowSnippets(true); - // router.refresh(); - // // } else { - // // // If enableProxy is false or undefined, go directly to resource page - // // router.push(`/${orgId}/settings/resources/${id}`); - // // } - // } - // } - // } catch (e) { - // console.error(t("resourceErrorCreateMessage"), e); - // toast({ - // variant: "destructive", - // title: t("resourceErrorCreate"), - // description: t("resourceErrorCreateMessageDescription") - // }); - // } - // setCreateLoading(false); + const isValid = await form.trigger(); + + if (!isValid) return; + + const payload = form.getValues(); + console.log({ + isValid, + payload + // json: parse(data.contents) + }); + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/blueprint/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: t("resourceErrorCreate"), + description: formatAxiosError( + e, + t("blueprintErrorCreateDescription") + ) + }); + }); + + if (res && res.status === 201) { + toast({ + variant: "default", + title: "Success" + }); + // const id = res.data.data.resourceId; + // const niceId = res.data.data.niceId; + // setNiceId(niceId); + // // Create targets if any exist + // if (targets.length > 0) { + // try { + // for (const target of targets) { + // const data: any = { + // ip: target.ip, + // port: target.port, + // method: target.method, + // enabled: target.enabled, + // siteId: target.siteId, + // hcEnabled: target.hcEnabled, + // hcPath: target.hcPath || null, + // hcMethod: target.hcMethod || null, + // hcInterval: target.hcInterval || null, + // hcTimeout: target.hcTimeout || null, + // hcHeaders: target.hcHeaders || null, + // hcScheme: target.hcScheme || null, + // hcHostname: target.hcHostname || null, + // hcPort: target.hcPort || null, + // hcFollowRedirects: target.hcFollowRedirects || null, + // hcStatus: target.hcStatus || null + // }; + // // Only include path-related fields for HTTP resources + // if (isHttp) { + // data.path = target.path; + // data.pathMatchType = target.pathMatchType; + // data.rewritePath = target.rewritePath; + // data.rewritePathType = target.rewritePathType; + // data.priority = target.priority; + // } + // await api.put(`/resource/${id}/target`, data); + // } + // } catch (targetError) { + // console.error("Error creating targets:", targetError); + // toast({ + // variant: "destructive", + // title: t("targetErrorCreate"), + // description: formatAxiosError( + // targetError, + // t("targetErrorCreateDescription") + // ) + // }); + // } + // } + // if (isHttp) { + // router.push(`/${orgId}/settings/resources/${niceId}`); + // } else { + // const tcpUdpData = tcpUdpForm.getValues(); + // // Only show config snippets if enableProxy is explicitly true + // // if (tcpUdpData.enableProxy === true) { + // setShowSnippets(true); + // router.refresh(); + // // } else { + // // // If enableProxy is false or undefined, go directly to resource page + // // router.push(`/${orgId}/settings/resources/${id}`); + // // } + // } + } } return ( -
+ @@ -184,18 +184,55 @@ export default function CreateBlueprintForm({}: CreateBlueprintFormProps) { ( {t("name")} + + {t("blueprintNameDescription")} + + + )} + /> + + ( + + + {t("contents")} + - {t("blueprintNameDescription")} + {t( + "blueprintContentsDescription" + )} + +
+ +
+
+
)} /> @@ -203,58 +240,9 @@ export default function CreateBlueprintForm({}: CreateBlueprintFormProps) {
- - - - {t("contents")} - - - {t("blueprintContentsDescription")} - - - -
- setChangedContents(value ?? "")} - /> - -