blueprint details page

This commit is contained in:
Fred KISSIE
2025-10-28 00:14:27 +01:00
parent a05ee2483b
commit 7ce6fadb3d
19 changed files with 482 additions and 66 deletions

View File

@@ -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",

View File

@@ -119,6 +119,7 @@ export enum ActionsEnum {
// blueprints
listBlueprints = "listBlueprints",
getBlueprint = "getBlueprint",
applyBlueprint = "applyBlueprint"
}

View File

@@ -5,6 +5,7 @@ export const registry = new OpenAPIRegistry();
export enum OpenAPITags {
Site = "Site",
Org = "Organization",
Blueprint = "Blueprint",
Resource = "Resource",
Role = "Role",
User = "User",

View File

@@ -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: {

View File

@@ -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<any> {
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<GetBlueprintResponse>(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")
);
}
}

View File

@@ -1,2 +1,3 @@
export * from "./listBlueprints";
export * from "./createAndApplyBlueprint";
export * from "./getBlueprint";

View File

@@ -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<ListBlueprintsResponse>(res, {
data: {
blueprints: blueprintsList.map((b) => ({
...b,
createdAt: new Date(b.createdAt * 1000)
})) as BlueprintData[],
blueprints:
blueprintsList as ListBlueprintsResponse["blueprints"],
pagination: {
total: count,
limit,

View File

@@ -2,7 +2,6 @@ import type { Blueprint } from "@server/db";
export type BlueprintSource = "API" | "UI" | "NEWT";
export type BlueprintData = Omit<Blueprint, "source" | "createdAt"> & {
export type BlueprintData = Omit<Blueprint, "source"> & {
source: BlueprintSource;
createdAt: Date;
};

View File

@@ -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);

View File

@@ -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<AxiosResponse<GetBlueprintResponse>>(
`/org/${params.orgId}/blueprint/${params.blueprintId}`,
await authCookieHeader()
);
blueprint = res.data.data;
} catch (e) {
console.error(e);
notFound();
}
const t = await getTranslations();
return (
<>
<div className="flex flex-col gap-2 items-start">
<Button variant="link" asChild className="gap-1 px-0">
<Link href={`/${params.orgId}/settings/blueprints`}>
<ArrowLeft className="size-4 flex-none" />
{t("blueprintGoBack")}
</Link>
</Button>
<SettingsSectionTitle
title={t("blueprintDetails")}
description={t("blueprintDetailsDescription")}
/>
</div>
<BlueprintDetailsForm blueprint={blueprint} />
</>
);
}

View File

@@ -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<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);

View File

@@ -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<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
const res = await internal.get<AxiosResponse<ListDomainsResponse>>(
`/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<AxiosResponse<GetOrgResponse>>(
`/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 (

View File

@@ -0,0 +1,17 @@
import { getTranslations } from "next-intl/server";
export default async function NotFound() {
const t = await getTranslations();
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>
<p className="text-neutral-500 dark:text-neutral-700 mb-8">
{t("pageNotFoundDescription")}
</p>
</div>
);
}

View File

@@ -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 (
<Form {...form}>
<div className="flex flex-col gap-6">
<Alert>
<AlertDescription>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
{t("appliedAt")}
</InfoSectionTitle>
<InfoSectionContent>
<time
className="text-muted-foreground"
dateTime={blueprint.createdAt.toString()}
>
{new Date(
blueprint.createdAt * 1000
).toLocaleString()}
</time>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("status")}
</InfoSectionTitle>
<InfoSectionContent>
{blueprint.succeeded ? (
<Badge variant="green">
{t("success")}
</Badge>
) : (
<Badge variant="red">
{t("failed", {
fallback: "Failed"
})}
</Badge>
)}
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("message")}
</InfoSectionTitle>
<InfoSectionContent>
<p className="text-muted-foreground">
{blueprint.message}
</p>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("source")}
</InfoSectionTitle>
<InfoSectionContent>
{blueprint.source === "API" && (
<Badge
variant="secondary"
className="-mx-2"
>
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
</Badge>
)}
{blueprint.source === "NEWT" && (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
</Badge>
)}
{blueprint.source === "UI" && (
<Badge
variant="secondary"
className="-mx-1 rounded-sm py-1"
>
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
</Badge>
)}{" "}
</InfoSectionContent>
</InfoSection>
</InfoSections>
</AlertDescription>
</Alert>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("blueprintInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<FormDescription>
{t("blueprintNameDescription")}
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
},
readOnly: true
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
</div>
</Form>
);
}

View File

@@ -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()}
</time>
);
}
@@ -179,11 +165,11 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<Button variant="outline" className="items-center" asChild>
<Link href={`#`}>
<Link
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
>
View details{" "}
<ArrowRight className="size-4 flex-none" />
</Link>

View File

@@ -3,7 +3,6 @@ import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
@@ -22,11 +21,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { useForm } from "react-hook-form";
import { Input } from "./ui/input";
import { useActionState, useTransition } from "react";
import Editor, { useMonaco } from "@monaco-editor/react";
import { useActionState } from "react";
import Editor 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";

View File

@@ -1,5 +1,7 @@
"use client";
import { cn } from "@app/lib/cn";
export function InfoSections({
children,
cols
@@ -9,25 +11,44 @@ export function InfoSections({
}) {
return (
<div
className={`grid md:grid-cols-${cols || 1} md:gap-4 gap-2 md:items-start grid-cols-1`}
className={`grid md:grid-cols-[var(--columns)] md:gap-4 gap-2 md:items-start grid-cols-1`}
style={{
// @ts-expect-error dynamic props don't work with tailwind, but we can set the
// value of a CSS variable at runtime and tailwind will just reuse that value
"--columns": `repeat(${cols || 1}, minmax(0, 1fr))`
}}
>
{children}
</div>
);
}
export function InfoSection({ children }: { children: React.ReactNode }) {
return <div className="space-y-1">{children}</div>;
export function InfoSection({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("space-y-1", className)}>{children}</div>;
}
export function InfoSectionTitle({ children }: { children: React.ReactNode }) {
return <div className="font-semibold">{children}</div>;
export function InfoSectionTitle({
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={cn("font-semibold", className)}>{children}</div>;
}
export function InfoSectionContent({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="break-words">{children}</div>;
return <div className={cn("break-words", className)}>{children}</div>;
}

View File

@@ -11,7 +11,6 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import { useTranslations } from "next-intl";
import { build } from "@server/build";
import CertificateStatus from "@app/components/private/CertificateStatus";
import { toUnicode } from "punycode";
import { useEnvContext } from "@app/hooks/useEnvContext";

View File

@@ -0,0 +1,12 @@
import type { GetOrgResponse } from "@server/routers/org";
import type { AxiosResponse } from "axios";
import { cache } from "react";
import { authCookieHeader } from "./cookies";
import { internal } from ".";
export const getCachedOrg = cache(async (orgId: string) =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
await authCookieHeader()
)
);