From 7ce6fadb3dff867393a35b203e03e90c2582e57c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 28 Oct 2025 00:14:27 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20blueprint=20details=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 3 + server/auth/actions.ts | 1 + server/openApi.ts | 1 + .../blueprints/createAndApplyBlueprint.ts | 2 +- server/routers/blueprints/getBlueprint.ts | 110 +++++++++ server/routers/blueprints/index.ts | 1 + server/routers/blueprints/listBluePrints.ts | 9 +- server/routers/blueprints/types.ts | 3 +- server/routers/external.ts | 7 + .../blueprints/[blueprintId]/page.tsx | 66 ++++++ src/app/[orgId]/settings/blueprints/page.tsx | 12 +- src/app/[orgId]/settings/domains/page.tsx | 23 +- src/app/[orgId]/settings/not-found.tsx | 17 ++ src/components/BlueprintDetailsForm.tsx | 211 ++++++++++++++++++ src/components/BlueprintsTable.tsx | 28 +-- src/components/CreateBlueprintForm.tsx | 6 +- src/components/InfoSection.tsx | 35 ++- src/components/ResourceInfoBox.tsx | 1 - src/lib/api/getCachedOrg.ts | 12 + 19 files changed, 482 insertions(+), 66 deletions(-) create mode 100644 server/routers/blueprints/getBlueprint.ts create mode 100644 src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx create mode 100644 src/app/[orgId]/settings/not-found.tsx create mode 100644 src/components/BlueprintDetailsForm.tsx create mode 100644 src/lib/api/getCachedOrg.ts diff --git a/messages/en-US.json b/messages/en-US.json index e2b7b60f..b79f0a22 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1158,7 +1158,10 @@ "blueprintGoBack": "Back to blueprints", "blueprintCreate": "Create blueprint", "blueprintCreateDescription2": "Follow the steps below to create and apply a new blueprint", + "blueprintDetails": "Blueprint details", + "blueprintDetailsDescription": "See the blueprint run details", "blueprintInfo": "Blueprint Information", + "message": "Message", "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", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 132eec7b..50ebd964 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -119,6 +119,7 @@ export enum ActionsEnum { // blueprints listBlueprints = "listBlueprints", + getBlueprint = "getBlueprint", applyBlueprint = "applyBlueprint" } diff --git a/server/openApi.ts b/server/openApi.ts index 32cdb67b..46b58371 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -5,6 +5,7 @@ export const registry = new OpenAPIRegistry(); export enum OpenAPITags { Site = "Site", Org = "Organization", + Blueprint = "Blueprint", Resource = "Resource", Role = "Role", User = "User", diff --git a/server/routers/blueprints/createAndApplyBlueprint.ts b/server/routers/blueprints/createAndApplyBlueprint.ts index a2c133ab..55867fce 100644 --- a/server/routers/blueprints/createAndApplyBlueprint.ts +++ b/server/routers/blueprints/createAndApplyBlueprint.ts @@ -44,7 +44,7 @@ registry.registerPath({ path: "/org/{orgId}/blueprint", description: "Create and Apply a base64 encoded blueprint to an organization", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], request: { params: applyBlueprintParamsSchema, body: { diff --git a/server/routers/blueprints/getBlueprint.ts b/server/routers/blueprints/getBlueprint.ts new file mode 100644 index 00000000..3d3f7366 --- /dev/null +++ b/server/routers/blueprints/getBlueprint.ts @@ -0,0 +1,110 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { blueprints, orgs } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import stoi from "@server/lib/stoi"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { BlueprintData } from "./types"; + +const getBlueprintSchema = z + .object({ + blueprintId: z + .string() + .transform(stoi) + .pipe(z.number().int().positive()), + orgId: z.string() + }) + .strict(); + +async function query(blueprintId: number, orgId: string) { + // Get the client + const [blueprint] = await db + .select({ + blueprintId: blueprints.blueprintId, + name: blueprints.name, + source: blueprints.source, + succeeded: blueprints.succeeded, + orgId: blueprints.orgId, + createdAt: blueprints.createdAt, + message: blueprints.message, + contents: blueprints.contents + }) + .from(blueprints) + .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) + .where( + and( + eq(blueprints.blueprintId, blueprintId), + eq(blueprints.orgId, orgId) + ) + ) + .limit(1); + + if (!blueprint) { + return null; + } + + return blueprint; +} + +export type GetBlueprintResponse = BlueprintData; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/blueprint/{blueprintId}", + description: "Get a blueprint by its blueprint ID.", + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + request: { + params: getBlueprintSchema + }, + responses: {} +}); + +export async function getBlueprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getBlueprintSchema.safeParse(req.params); + if (!parsedParams.success) { + logger.error( + `Error parsing params: ${fromError(parsedParams.error).toString()}` + ); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, blueprintId } = parsedParams.data; + + const blueprint = await query(blueprintId, orgId); + + if (!blueprint) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + return response(res, { + data: blueprint as BlueprintData, + success: true, + error: false, + message: "Client retrieved successfully", + status: HttpCode.OK + }); + } 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 24634365..f182b02f 100644 --- a/server/routers/blueprints/index.ts +++ b/server/routers/blueprints/index.ts @@ -1,2 +1,3 @@ export * from "./listBlueprints"; export * from "./createAndApplyBlueprint"; +export * from "./getBlueprint"; diff --git a/server/routers/blueprints/listBluePrints.ts b/server/routers/blueprints/listBluePrints.ts index 7e248a75..1b621e2b 100644 --- a/server/routers/blueprints/listBluePrints.ts +++ b/server/routers/blueprints/listBluePrints.ts @@ -46,6 +46,7 @@ async function queryBlueprints(orgId: string, limit: number, offset: number) { }) .from(blueprints) .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) + .where(eq(blueprints.orgId, orgId)) .limit(limit) .offset(offset); return res; @@ -70,7 +71,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/blueprints", description: "List all blueprints for a organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Org, OpenAPITags.Blueprint], request: { params: z.object({ orgId: z.string() @@ -121,10 +122,8 @@ export async function listBlueprints( return response(res, { data: { - blueprints: blueprintsList.map((b) => ({ - ...b, - createdAt: new Date(b.createdAt * 1000) - })) as BlueprintData[], + blueprints: + blueprintsList as ListBlueprintsResponse["blueprints"], pagination: { total: count, limit, diff --git a/server/routers/blueprints/types.ts b/server/routers/blueprints/types.ts index a47cbc74..52d61300 100644 --- a/server/routers/blueprints/types.ts +++ b/server/routers/blueprints/types.ts @@ -2,7 +2,6 @@ import type { Blueprint } from "@server/db"; export type BlueprintSource = "API" | "UI" | "NEWT"; -export type BlueprintData = Omit & { +export type BlueprintData = Omit & { source: BlueprintSource; - createdAt: Date; }; diff --git a/server/routers/external.ts b/server/routers/external.ts index 1c87a11c..d23de443 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -826,6 +826,13 @@ authenticated.put( blueprints.createAndApplyBlueprint ); +authenticated.get( + "/org/:orgId/blueprint/:blueprintId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getBlueprint), + blueprints.getBlueprint +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); diff --git a/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx new file mode 100644 index 00000000..e4417467 --- /dev/null +++ b/src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx @@ -0,0 +1,66 @@ +import BlueprintDetailsForm from "@app/components/BlueprintDetailsForm"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import { GetBlueprintResponse } from "@server/routers/blueprints"; +import { AxiosResponse } from "axios"; +import { ArrowLeft } from "lucide-react"; +import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; + +type BluePrintsPageProps = { + params: Promise<{ orgId: string; blueprintId: string }>; +}; + +export const metadata: Metadata = { + title: "Blueprint Detail" +}; + +export default async function BluePrintDetailPage(props: BluePrintsPageProps) { + const params = await props.params; + let org = null; + try { + const res = await getCachedOrg(params.orgId); + org = res.data.data; + } catch { + redirect(`/${params.orgId}`); + } + + let blueprint = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/blueprint/${params.blueprintId}`, + await authCookieHeader() + ); + + blueprint = res.data.data; + } catch (e) { + console.error(e); + notFound(); + } + + const t = await getTranslations(); + + return ( + <> +
+ + +
+ + + + ); +} diff --git a/src/app/[orgId]/settings/blueprints/page.tsx b/src/app/[orgId]/settings/blueprints/page.tsx index 58d5e8ed..f71e8375 100644 --- a/src/app/[orgId]/settings/blueprints/page.tsx +++ b/src/app/[orgId]/settings/blueprints/page.tsx @@ -4,18 +4,16 @@ import BlueprintsTable, { import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import { ListBlueprintsResponse } from "@server/routers/blueprints"; -import { GetOrgResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; -import { cache } from "react"; type BluePrintsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; }; export const metadata: Metadata = { @@ -39,13 +37,7 @@ export default async function BluePrintsPage(props: BluePrintsPageProps) { let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}`); diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index cb587d92..cf684ebd 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -9,7 +9,8 @@ import { GetOrgResponse } from "@server/routers/org"; import { redirect } from "next/navigation"; import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; -import { toUnicode } from 'punycode'; +import { toUnicode } from "punycode"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type Props = { params: Promise<{ orgId: string }>; @@ -20,15 +21,16 @@ export default async function DomainsPage(props: Props) { let domains: DomainRow[] = []; try { - const res = await internal.get< - AxiosResponse - >(`/org/${params.orgId}/domains`, await authCookieHeader()); + const res = await internal.get>( + `/org/${params.orgId}/domains`, + await authCookieHeader() + ); const rawDomains = res.data.data.domains as DomainRow[]; domains = rawDomains.map((domain) => ({ ...domain, - baseDomain: toUnicode(domain.baseDomain), + baseDomain: toUnicode(domain.baseDomain) })); } catch (e) { console.error(e); @@ -36,21 +38,12 @@ export default async function DomainsPage(props: Props) { let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}`); } - if (!org) { - } - const t = await getTranslations(); return ( diff --git a/src/app/[orgId]/settings/not-found.tsx b/src/app/[orgId]/settings/not-found.tsx new file mode 100644 index 00000000..d3ca37cc --- /dev/null +++ b/src/app/[orgId]/settings/not-found.tsx @@ -0,0 +1,17 @@ +import { getTranslations } from "next-intl/server"; + +export default async function NotFound() { + const t = await getTranslations(); + + return ( +
+

404

+

+ {t("pageNotFound")} +

+

+ {t("pageNotFoundDescription")} +

+
+ ); +} diff --git a/src/components/BlueprintDetailsForm.tsx b/src/components/BlueprintDetailsForm.tsx new file mode 100644 index 00000000..a46af0db --- /dev/null +++ b/src/components/BlueprintDetailsForm.tsx @@ -0,0 +1,211 @@ +"use client"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { useTranslations } from "next-intl"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { useForm } from "react-hook-form"; +import { Input } from "./ui/input"; +import Editor from "@monaco-editor/react"; +import { cn } from "@app/lib/cn"; +import type { GetBlueprintResponse } from "@server/routers/blueprints"; +import { Alert, AlertDescription } from "./ui/alert"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "./InfoSection"; +import { Badge } from "./ui/badge"; +import { Globe, Terminal, Webhook } from "lucide-react"; + +export type CreateBlueprintFormProps = { + blueprint: GetBlueprintResponse; +}; + +export default function BlueprintDetailsForm({ + blueprint +}: CreateBlueprintFormProps) { + const t = useTranslations(); + + const form = useForm({ + disabled: true, + defaultValues: { + name: blueprint.name, + contents: blueprint.contents + } + }); + + return ( +
+
+ + + + + + {t("appliedAt")} + + + + + + + + {t("status")} + + + {blueprint.succeeded ? ( + + {t("success")} + + ) : ( + + {t("failed", { + fallback: "Failed" + })} + + )} + + + + + {t("message")} + + +

+ {blueprint.message} +

+
+
+ + + {t("source")} + + + {blueprint.source === "API" && ( + + + API + + + + )} + {blueprint.source === "NEWT" && ( + + + Newt CLI + + + + )} + {blueprint.source === "UI" && ( + + + Dashboard{" "} + + + + )}{" "} + + +
+
+
+ + + + + {t("blueprintInfo")} + + + + + ( + + {t("name")} + + {t("blueprintNameDescription")} + + + + + + + )} + /> + + ( + + + {t("contents")} + + + {t( + "blueprintContentsDescription" + )} + + +
+ +
+
+ +
+ )} + /> +
+
+
+
+
+
+ ); +} diff --git a/src/components/BlueprintsTable.tsx b/src/components/BlueprintsTable.tsx index f33bcdd0..d7a7732b 100644 --- a/src/components/BlueprintsTable.tsx +++ b/src/components/BlueprintsTable.tsx @@ -1,35 +1,19 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; -import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, Globe, - LucideIcon, - MoreHorizontal, Terminal, Webhook } from "lucide-react"; -import { useState, useTransition } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTransition } from "react"; import { Badge } from "@app/components/ui/badge"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import CreateDomainForm from "@app/components/CreateDomainForm"; -import { useToast } from "@app/hooks/useToast"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { DataTable } from "./ui/data-table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "./ui/dropdown-menu"; import Link from "next/link"; import { ListBlueprintsResponse } from "@server/routers/blueprints"; @@ -68,7 +52,9 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) { className="text-muted-foreground" dateTime={row.original.createdAt.toString()} > - {new Date(row.original.createdAt).toLocaleString()} + {new Date( + row.original.createdAt * 1000 + ).toLocaleString()} ); } @@ -179,11 +165,11 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) { ); }, cell: ({ row }) => { - const domain = row.original; - return (