Merge pull request #1733 from Fredkiss3/feat-blueprint-ui-on-dashboard

feat: blueprint ui on dashboard
This commit is contained in:
Owen Schwartz
2025-10-29 20:45:31 -07:00
committed by GitHub
45 changed files with 1695 additions and 142 deletions

View File

@@ -0,0 +1,208 @@
"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 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>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("parsedContents")}
</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

@@ -0,0 +1,206 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowRight,
ArrowUpDown,
Globe,
Terminal,
Webhook
} from "lucide-react";
import { useTransition } from "react";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { DataTable } from "./ui/data-table";
import Link from "next/link";
import { ListBlueprintsResponse } from "@server/routers/blueprints";
export type BlueprintRow = ListBlueprintsResponse["blueprints"][number];
type Props = {
blueprints: BlueprintRow[];
orgId: string;
};
export default function BlueprintsTable({ blueprints, orgId }: Props) {
const t = useTranslations();
const [isRefreshing, startTransition] = useTransition();
const router = useRouter();
const columns: ColumnDef<BlueprintRow>[] = [
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("appliedAt")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<time
className="text-muted-foreground"
dateTime={row.original.createdAt.toString()}
>
{new Date(
row.original.createdAt * 1000
).toLocaleString()}
</time>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
}
},
{
accessorKey: "source",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("source")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
switch (originalRow.source) {
case "API": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
API
<Webhook className="size-4 flex-none" />
</span>
</Badge>
);
}
case "NEWT": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Newt CLI
<Terminal className="size-4 flex-none" />
</span>
</Badge>
);
}
case "UI": {
return (
<Badge variant="secondary">
<span className="inline-flex items-center gap-1 ">
Dashboard{" "}
<Globe className="size-4 flex-none" />
</span>
</Badge>
);
}
}
}
},
{
accessorKey: "succeeded",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { succeeded } = row.original;
if (succeeded) {
return <Badge variant="green">{t("success")}</Badge>;
} else {
return (
<Badge variant="red">
{t("failed", { fallback: "Failed" })}
</Badge>
);
}
}
},
{
id: "actions",
header: () => {
return null;
},
cell: ({ row }) => {
return (
<div className="flex justify-end">
<Button
variant="outline"
className="items-center"
asChild
>
<Link
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
>
View details{" "}
<ArrowRight className="size-4 flex-none" />
</Link>
</Button>
</div>
);
}
}
];
return (
<DataTable
columns={columns}
data={blueprints}
persistPageSize="blueprint-table"
title={t("blueprints")}
searchPlaceholder={t("searchBlueprintProgress")}
searchColumn="name"
onAdd={() => {
router.push(`/${orgId}/settings/blueprints/create`);
}}
addButtonText={t("blueprintAdd")}
onRefresh={() => {
startTransition(() => router.refresh());
}}
isRefreshing={isRefreshing}
defaultSort={{
id: "createdAt",
desc: true
}}
/>
);
}

View File

@@ -0,0 +1,194 @@
"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 { zodResolver } from "@hookform/resolvers/zod";
import z from "zod";
import { useForm } from "react-hook-form";
import { Input } from "./ui/input";
import { useActionState } from "react";
import Editor from "@monaco-editor/react";
import { cn } from "@app/lib/cn";
import { Button } from "./ui/button";
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";
import { useRouter } from "next/navigation";
export type CreateBlueprintFormProps = {
orgId: string;
};
export default function CreateBlueprintForm({
orgId
}: CreateBlueprintFormProps) {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [, formAction, isSubmitting] = useActionState(onSubmit, null);
const router = useRouter();
const form = useForm({
resolver: zodResolver(
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"}`
});
}
})
})
),
defaultValues: {
name: "",
contents: `# Example blueprint
# proxy-resources:
# resource-nice-id-uno:
# name: this is my resource
# protocol: http
# full-domain: never-gonna-give-you-up.example.com
# targets:
# - site: lively-yosemite-toad
# hostname: localhost
# method: http
# port: 8000
`
}
});
async function onSubmit() {
const isValid = await form.trigger();
if (!isValid) return;
const res = await api
.put<AxiosResponse<CreateBlueprintResponse>>(
`/org/${orgId}/blueprint/`,
{
name: form.getValues("name"),
blueprint: form.getValues("contents")
}
)
.catch((e) => {
toast({
variant: "destructive",
title: t("resourceErrorCreate"),
description: formatAxiosError(
e,
t("blueprintErrorCreateDescription")
)
});
});
if (res && res.status === 201) {
const createdBlueprint = res.data.data;
toast({
variant: "warning",
title: createdBlueprint.succeeded ? "Success" : "Warning",
description: createdBlueprint.message
});
router.push(`/${orgId}/settings/blueprints`);
}
}
return (
<Form {...form}>
<form action={formAction} id="base-resource-form">
<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>
<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
}
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<div className="flex justify-end space-x-2 mt-8">
<Button type="submit" loading={isSubmitting}>
{t("actionApplyBlueprint")}
</Button>
</div>
</SettingsContainer>
</form>
</Form>
);
}

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

@@ -1,9 +1,15 @@
import { cn } from "@app/lib/cn";
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-6">{children}</div>;
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
return (
<div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">
{children}
</div>
);
}
export function SettingsSectionHeader({
@@ -15,11 +21,15 @@ export function SettingsSectionHeader({
}
export function SettingsSectionForm({
children
children,
className
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className="max-w-xl space-y-4">{children}</div>;
return (
<div className={cn("max-w-xl space-y-4", className)}>{children}</div>
);
}
export function SettingsSectionTitle({
@@ -55,7 +65,11 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
return (
<div className="flex justify-end space-x-2 mt-auto pt-6">
{children}
</div>
);
}
export function SettingsSectionGrid({

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import * as NProgress from "nprogress";
import NextTopLoader from "nextjs-toploader";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export function TopLoader() {
return (
<>
<NextTopLoader showSpinner={false} />
<FinishingLoader />
</>
);
}
function FinishingLoader() {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
React.useEffect(() => {
NProgress.done();
}, [pathname, router, searchParams]);
React.useEffect(() => {
const linkClickListener = (ev: MouseEvent) => {
const element = ev.target as HTMLElement;
const closestlink = element.closest("a");
const isOpenToNewTabClick =
ev.ctrlKey ||
ev.shiftKey ||
ev.metaKey || // apple
(ev.button && ev.button == 1); // middle click, >IE9 + everyone else
if (closestlink && isOpenToNewTabClick) {
NProgress.done();
}
};
window.addEventListener("click", linkClickListener);
return () => window.removeEventListener("click", linkClickListener);
}, []);
return null;
}

View File

@@ -71,8 +71,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={loading || props.disabled} // Disable button when loading
{...props}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{props.children}
{asChild ? (
props.children
) : (
<>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{props.children}
</>
)}
</Comp>
);
}

View File

@@ -91,7 +91,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View File

@@ -31,7 +31,8 @@ const toastVariants = cva(
variant: {
default: "border bg-card text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground"
"destructive group border-destructive bg-destructive text-white dark:text-destructive-foreground",
warning: "group border-amber-600 bg-amber-600 text-white"
}
},
defaultVariants: {