[TOOL-3337] Dashboard: Engine cloud - DO NOT MERGE (#6639)

Co-authored-by: Prithvish Baidya <[email protected]>
Co-authored-by: Joaquim Verges <[email protected]>
This commit is contained in:
Manan Tank
2025-05-08 06:53:10 +12:00
committed by GitHub
co-authored by Prithvish Baidya Joaquim Verges
parent ec50f8cc3c
commit c81fed3617
193 changed files with 11515 additions and 269 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@thirdweb-dev/vault-sdk": patch
---
Introducing vault sdk
+2 -1
View File
@@ -30,4 +30,5 @@ packages/*/typedoc/*
storybook-static
.aider*
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
.cursor
+5 -1
View File
@@ -104,4 +104,8 @@ ANALYTICS_SERVICE_URL=""
NEXT_PUBLIC_NEBULA_URL=""
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
STRIPE_SECRET_KEY=""
STRIPE_SECRET_KEY=""
# required for server wallet management
NEXT_PUBLIC_THIRDWEB_VAULT_URL=""
NEXT_PUBLIC_ENGINE_CLOUD_URL=""
+1
View File
@@ -12,6 +12,7 @@
"ignoreDependencies": [
"@storybook/blocks",
"@thirdweb-dev/service-utils",
"@thirdweb-dev/vault-sdk",
"@types/color",
"fast-xml-parser"
]
+2
View File
@@ -45,11 +45,13 @@
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tooltip": "1.2.3",
"@scalar/api-reference-react": "^0.6.19",
"@sentry/nextjs": "9.13.0",
"@shazow/whatsabi": "0.21.0",
"@tanstack/react-query": "5.74.4",
"@tanstack/react-table": "^8.21.3",
"@thirdweb-dev/service-utils": "workspace:*",
"@thirdweb-dev/vault-sdk": "workspace:*",
"@vercel/functions": "2.0.0",
"@vercel/og": "^0.6.8",
"abitype": "1.0.8",
+5 -1
View File
@@ -1,7 +1,7 @@
"use server";
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
import { API_SERVER_URL } from "../constants/env";
import { API_SERVER_URL, THIRDWEB_ENGINE_CLOUD_URL } from "../constants/env";
type ProxyActionParams = {
pathname: string;
@@ -79,6 +79,10 @@ export async function apiServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(API_SERVER_URL, params);
}
export async function engineCloudProxy<T>(params: ProxyActionParams) {
return proxy<T>(THIRDWEB_ENGINE_CLOUD_URL, params);
}
export async function payServerProxy<T>(params: ProxyActionParams) {
return proxy<T>(
process.env.NEXT_PUBLIC_PAY_URL
@@ -95,7 +95,7 @@ export function FullWidthSidebarLayout(props: {
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
/>
<main className="z-0 flex min-w-0 grow flex-col max-sm:w-full">
<main className="flex min-w-0 grow flex-col max-sm:w-full">
{children}
</main>
<AppFooter containerClassName="max-w-7xl" />
@@ -129,7 +129,9 @@ export const SelectWithSearch = React.forwardRef<
selectedOption && "text-foreground",
)}
>
{selectedOption?.label || placeholder}
{renderOption && selectedOption
? renderOption(selectedOption)
: selectedOption?.label || placeholder}
</span>
<ChevronDownIcon className="size-4 cursor-pointer text-muted-foreground" />
</div>
@@ -22,7 +22,7 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline text-semibold",
pink: "border border-nebula-pink-foreground !text-nebula-pink-foreground bg-[hsl(var(--nebula-pink-foreground)/5%)] hover:bg-nebula-pink-foreground/10 dark:!text-foreground dark:bg-nebula-pink-foreground/10 dark:hover:bg-nebula-pink-foreground/20",
upsell:
"bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:from-purple-600 hover:to-pink-600 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
"bg-green-600 text-white hover:bg-green-700 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200",
},
size: {
default: "h-10 px-4 py-2",
+3 -3
View File
@@ -10,7 +10,7 @@ import { Button } from "./button";
import { ToolTipLabel } from "./tooltip";
export type TabLink = {
name: string;
name: React.ReactNode;
href: string;
isActive: boolean;
isDisabled?: boolean;
@@ -43,7 +43,7 @@ export function TabLinks(props: {
return (
<Button
asChild
key={tab.name}
key={tab.href}
disabled={tab.isDisabled}
variant="ghost"
>
@@ -206,7 +206,7 @@ function useUnderline<El extends HTMLElement>() {
export function TabPathLinks(props: {
links: {
name: string;
name: React.ReactNode;
path: string;
exactMatch?: boolean;
isDisabled?: boolean;
+6
View File
@@ -9,6 +9,12 @@ export const NEXT_PUBLIC_NEBULA_APP_CLIENT_ID =
export const NEBULA_APP_SECRET_KEY = process.env.NEBULA_APP_SECRET_KEY || "";
export const THIRDWEB_VAULT_URL =
process.env.NEXT_PUBLIC_THIRDWEB_VAULT_URL || "";
export const THIRDWEB_ENGINE_CLOUD_URL =
process.env.NEXT_PUBLIC_ENGINE_CLOUD_URL || "";
export const THIRDWEB_API_SECRET = process.env.API_SERVER_SECRET || "";
export const IPFS_GATEWAY_URL =
+55
View File
@@ -0,0 +1,55 @@
import { differenceInCalendarDays } from "date-fns";
import {
type DurationId,
type Range,
getLastNDaysRange,
} from "../../components/analytics/date-range-selector";
export function normalizeTime(date: Date) {
const newDate = new Date(date);
newDate.setHours(1, 0, 0, 0);
return newDate;
}
export function normalizeTimeISOString(date: Date) {
return normalizeTime(date).toISOString();
}
export function getFiltersFromSearchParams(params: {
from: string | undefined | string[];
to: string | undefined | string[];
interval: string | undefined | string[];
defaultRange: DurationId;
}) {
const fromStr = params.from;
const toStr = params.to;
const defaultRange = getLastNDaysRange(params.defaultRange);
const range: Range =
fromStr && toStr && typeof fromStr === "string" && typeof toStr === "string"
? {
from: normalizeTime(new Date(fromStr)),
to: normalizeTime(new Date(toStr)),
type: "custom",
}
: {
from: normalizeTime(defaultRange.from),
to: normalizeTime(defaultRange.to),
type: defaultRange.type,
};
const defaultInterval =
differenceInCalendarDays(range.to, range.from) > 30
? "week"
: ("day" as const);
return {
range,
interval:
params.interval === "day"
? ("day" as const)
: params.interval === "week"
? ("week" as const)
: defaultInterval,
};
}
@@ -7,12 +7,12 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { ResultItem } from "app/(app)/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/metrics/components/StatusCodes";
import type { EngineBackendWalletType } from "lib/engine";
import { useState } from "react";
import { useActiveAccount } from "thirdweb/react";
import invariant from "tiny-invariant";
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/(team)/~/engine/(instance)/[engineId]/overview/components/transactions-table";
import type { ResultItem } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/metrics/components/StatusCodes";
import type { EngineStatus } from "../../../app/(app)/team/[team_slug]/[project_slug]/engine/dedicated/(instance)/[engineId]/overview/components/transactions-table";
import { engineKeys } from "../cache-keys";
// Engine instances
@@ -1641,7 +1641,11 @@ interface EngineResourceMetrics {
};
}
export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
export function useEngineSystemMetrics(
engineId: string,
teamIdOrSlug: string,
projectSlug: string,
) {
const [enabled, setEnabled] = useState(true);
return useQuery({
@@ -1649,7 +1653,7 @@ export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
queryFn: async () => {
const res = await apiServerProxy({
method: "GET",
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/metrics`,
pathname: `/v1/teams/${teamIdOrSlug}/${projectSlug}/engine/dedicated/${engineId}/metrics`,
});
if (!res.ok) {
@@ -74,10 +74,6 @@ export default async function TeamLayout(props: {
path: `/team/${params.team_slug}/~/analytics`,
name: "Analytics",
},
{
path: `/team/${params.team_slug}/~/engine`,
name: "Engines",
},
{
path: `/team/${params.team_slug}/~/ecosystem`,
name: "Ecosystems",
@@ -1,46 +0,0 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { ImportEngineLink } from "./_components";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
}>;
children: React.ReactNode;
}) {
const params = await props.params;
const linkPrefix = `/team/${params.team_slug}/~/engine`;
const sidebarLinks: SidebarLink[] = [
{
label: "Engine Instances",
href: `${linkPrefix}`,
exactMatch: true,
},
{
label: "Import Engine",
href: `${linkPrefix}/import`,
},
];
return (
<div className="flex grow flex-col">
{/* header */}
<header className="border-border border-b py-10">
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<h1 className="font-semibold text-3xl tracking-tight">Engines</h1>
<div className="flex items-center gap-3">
<ImportEngineLink
label="Import Engine"
engineLinkPrefix={linkPrefix}
/>
</div>
</div>
</header>
{/* sidebar layout */}
<SidebarLayout sidebarLinks={sidebarLinks}>
{props.children}
</SidebarLayout>
</div>
);
}
@@ -3,7 +3,7 @@ import { getTeamBySlug } from "@/api/team";
import { getTeamSubscriptions } from "@/api/team-subscription";
import { fetchRPCUsage } from "@/api/usage/rpc";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { normalizeTimeISOString } from "lib/time";
import { normalizeTimeISOString } from "@/lib/time";
import { redirect } from "next/navigation";
import { getValidAccount } from "../../../../../account/settings/getAccount";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
@@ -234,7 +234,7 @@ function ProductsSection(props: {
title: "Engine",
description:
"Scale your application with a backend server to read, write, and deploy contracts at production-grade.",
href: `/team/${props.teamSlug}/~/engine`,
href: `/team/${props.teamSlug}/${props.projectSlug}/engine`,
icon: EngineIcon,
trackingLabel: "engine",
},
@@ -8,9 +8,11 @@ import {
WalletIcon,
} from "lucide-react";
import { ContractIcon } from "../../../../(dashboard)/(chain)/components/server/icons/ContractIcon";
import { EngineIcon } from "../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
import { InsightIcon } from "../../../../(dashboard)/(chain)/components/server/icons/InsightIcon";
import { PayIcon } from "../../../../(dashboard)/(chain)/components/server/icons/PayIcon";
import { SmartAccountIcon } from "../../../../(dashboard)/(chain)/components/server/icons/SmartAccountIcon";
import { Badge } from "../../../../../../@/components/ui/badge";
import { NebulaIcon } from "../../../../../nebula-app/(app)/icons/NebulaIcon";
export function ProjectSidebarLayout(props: {
@@ -60,10 +62,14 @@ export function ProjectSidebarLayout(props: {
tracking: tracking("contracts"),
},
{
href: `${layoutPath}/nebula`,
label: "Nebula",
icon: NebulaIcon,
tracking: tracking("nebula"),
href: `${layoutPath}/engine`,
label: (
<span className="flex items-center gap-2">
Engine <Badge>New</Badge>
</span>
),
icon: EngineIcon,
tracking: tracking("engine"),
},
{
href: `${layoutPath}/insight`,
@@ -71,6 +77,12 @@ export function ProjectSidebarLayout(props: {
icon: InsightIcon,
tracking: tracking("insight"),
},
{
href: `${layoutPath}/nebula`,
label: "Nebula",
icon: NebulaIcon,
tracking: tracking("nebula"),
},
]}
footerSidebarLinks={[
{
@@ -75,7 +75,7 @@ export default async function Page(props: {
});
return (
<div>
<div className="flex grow flex-col">
<AccountAbstractionSummary
teamId={project.teamId}
projectId={project.id}
@@ -0,0 +1,43 @@
import type { Project } from "@/api/projects";
import { ResponsiveSearchParamsProvider } from "responsive-rsc";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { TransactionAnalyticsFilter } from "./filter";
import { TransactionsChartCard } from "./tx-chart/tx-chart";
import { TransactionsTable } from "./tx-table/tx-table";
export function TransactionsAnalyticsPageContent(props: {
searchParams: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
project: Project;
showAnalytics: boolean;
wallets?: Wallet[];
teamSlug: string;
}) {
return (
<ResponsiveSearchParamsProvider value={props.searchParams}>
<div className="flex grow flex-col gap-6">
{props.showAnalytics && (
<>
<div className="flex justify-end">
<TransactionAnalyticsFilter />
</div>
<TransactionsChartCard
searchParams={props.searchParams}
project={props.project}
wallets={props.wallets ?? []}
teamSlug={props.teamSlug}
/>
</>
)}
<TransactionsTable
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
</div>
</ResponsiveSearchParamsProvider>
);
}
@@ -0,0 +1,51 @@
"use client";
import { normalizeTimeISOString } from "@/lib/time";
import {
useResponsiveSearchParams,
useSetResponsiveSearchParams,
} from "responsive-rsc";
import { DateRangeSelector } from "../../../../../../../../components/analytics/date-range-selector";
import { IntervalSelector } from "../../../../../../../../components/analytics/interval-selector";
import { getTxAnalyticsFiltersFromSearchParams } from "../lib/utils";
export function TransactionAnalyticsFilter() {
const responsiveSearchParams = useResponsiveSearchParams();
const setResponsiveSearchParams = useSetResponsiveSearchParams();
const { range, interval } = getTxAnalyticsFiltersFromSearchParams({
from: responsiveSearchParams.from,
to: responsiveSearchParams.to,
interval: responsiveSearchParams.interval,
});
return (
<div className="no-scrollbar flex items-center gap-3 max-sm:overflow-auto">
<DateRangeSelector
range={range}
popoverAlign="end"
setRange={(newRange) => {
setResponsiveSearchParams((v) => {
return {
...v,
from: normalizeTimeISOString(newRange.from),
to: normalizeTimeISOString(newRange.to),
};
});
}}
/>
<IntervalSelector
intervalType={interval}
setIntervalType={(newInterval) => {
setResponsiveSearchParams((v) => {
return {
...v,
interval: newInterval,
};
});
}}
/>
</div>
);
}
@@ -0,0 +1,134 @@
"use client";
import type { Project } from "@/api/projects";
import { type Step, StepsCard } from "components/dashboard/StepsCard";
import Link from "next/link";
import { useMemo, useState } from "react";
import { Button } from "../../../../../../../../@/components/ui/button";
import CreateServerWallet from "../server-wallets/components/create-server-wallet.client";
import type { Wallet } from "../server-wallets/wallet-table/types";
import CreateVaultAccountButton from "../vault/components/create-vault-account.client";
import { SendTestTransaction } from "./send-test-tx.client";
interface Props {
managementAccessToken: string | undefined;
project: Project;
wallets: Wallet[];
hasTransactions: boolean;
teamSlug: string;
testTxWithWallet?: string | undefined;
}
export const EngineChecklist: React.FC<Props> = (props) => {
const [userAccessToken, setUserAccessToken] = useState<string | undefined>();
const finalSteps = useMemo(() => {
const steps: Step[] = [];
steps.push({
title: "Create a Vault Admin Account",
description:
"Vault is thirdweb's key management system. It allows you to create secure server wallets and manage access tokens.",
children: (
<CreateVaultAccountStep
project={props.project}
teamSlug={props.teamSlug}
onUserAccessTokenCreated={(token) => setUserAccessToken(token)}
/>
),
completed: !!props.managementAccessToken,
showCompletedChildren: false,
});
steps.push({
title: "Create a Server Wallet",
description:
"Server wallets are smart wallets, they don't require any gas funds to send transactions.",
children: (
<CreateServerWalletStep
project={props.project}
teamSlug={props.teamSlug}
managementAccessToken={props.managementAccessToken}
/>
),
completed: props.wallets.length > 0,
showIncompleteChildren: false,
showCompletedChildren: false,
});
steps.push({
title: "Send a Test Transaction",
description:
"Engine handles gas fees, and is designed for scale, speed and security. Send a test transaction to see it in action",
children: (
<SendTestTransaction
wallets={props.wallets}
project={props.project}
userAccessToken={userAccessToken}
teamSlug={props.teamSlug}
/>
),
completed: props.hasTransactions,
showIncompleteChildren: false,
showCompletedChildren: false,
});
return steps;
}, [
props.managementAccessToken,
props.project,
props.wallets,
props.hasTransactions,
userAccessToken,
props.teamSlug,
]);
if (props.testTxWithWallet) {
return (
<SendTestTransaction
wallets={props.wallets}
project={props.project}
userAccessToken={userAccessToken}
walletId={props.testTxWithWallet}
teamSlug={props.teamSlug}
/>
);
}
if (finalSteps.length === 1) {
return null;
}
return (
<StepsCard title="Setup Your Engine" steps={finalSteps} delay={1000} />
);
};
function CreateVaultAccountStep(props: {
project: Project;
teamSlug: string;
onUserAccessTokenCreated: (userAccessToken: string) => void;
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateVaultAccountButton
project={props.project}
onUserAccessTokenCreated={props.onUserAccessTokenCreated}
/>
<Link href="https://portal.thirdweb.com/engine/vault" target="_blank">
<Button variant="outline">Learn more about Vault</Button>
</Link>
</div>
);
}
function CreateServerWalletStep(props: {
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
}) {
return (
<div className="mt-4 flex flex-row gap-4">
<CreateServerWallet
project={props.project}
teamSlug={props.teamSlug}
managementAccessToken={props.managementAccessToken}
/>
</div>
);
}
@@ -0,0 +1,299 @@
"use client";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import { SingleNetworkSelector } from "@/components/blocks/NetworkSelectors";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2Icon, LockIcon } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
arbitrumSepolia,
baseSepolia,
optimismSepolia,
sepolia,
} from "thirdweb/chains";
import * as z from "zod";
import { CopyTextButton } from "../../../../../../../../@/components/ui/CopyTextButton";
import { useTrack } from "../../../../../../../../hooks/analytics/useTrack";
import type { Wallet } from "../server-wallets/wallet-table/types";
import { SmartAccountCell } from "../server-wallets/wallet-table/wallet-table-ui.client";
const formSchema = z.object({
accessToken: z.string().min(1, "Access token is required"),
walletIndex: z.string(),
chainId: z.number(),
});
type FormValues = z.infer<typeof formSchema>;
export function SendTestTransaction(props: {
wallets?: Wallet[];
project: Project;
teamSlug: string;
userAccessToken?: string;
expanded?: boolean;
walletId?: string;
}) {
const thirdwebClient = useThirdwebClient();
const queryClient = useQueryClient();
const [hasSentTx, setHasSentTx] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
accessToken: props.userAccessToken ?? "",
walletIndex:
props.wallets && props.walletId
? props.wallets
.findIndex((w) => w.id === props.walletId)
?.toString()
.replace("-1", "0")
: "0",
chainId: 84532,
},
});
const selectedWalletIndex = Number(form.watch("walletIndex"));
const selectedWallet = props.wallets?.[selectedWalletIndex];
const sendDummyTxMutation = useMutation({
mutationFn: async (args: {
walletAddress: string;
accessToken: string;
chainId: number;
}) => {
trackEvent({
category: "engine-cloud",
action: "send_test_tx",
});
const response = await engineCloudProxy({
pathname: "/v1/write/transaction",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-team-id": props.project.teamId,
"x-client-id": props.project.publishableKey,
"x-vault-access-token": args.accessToken,
},
body: JSON.stringify({
executionOptions: {
from: args.walletAddress,
chainId: args.chainId.toString(),
},
params: [
{
to: args.walletAddress,
value: "0",
},
],
}),
});
if (!response.ok) {
const errorMsg = response.error ?? "Failed to send transaction";
throw new Error(errorMsg);
}
return response.data;
},
onSuccess: () => {
toast.success("Test transaction sent successfully!");
},
onError: (error) => {
toast.error(error.message);
},
});
const isLoading = sendDummyTxMutation.isPending;
// Early return in render phase
if (!props.wallets || props.wallets.length === 0 || !selectedWallet) {
return null;
}
const onSubmit = async (data: FormValues) => {
await sendDummyTxMutation.mutateAsync({
walletAddress: selectedWallet.address,
accessToken: data.accessToken,
chainId: data.chainId,
});
queryClient.invalidateQueries({
queryKey: ["transactions", props.project.id],
});
setHasSentTx(true);
};
return (
<div className="mt-3 w-full rounded-md border bg-background p-6">
<form onSubmit={form.handleSubmit(onSubmit)}>
{props.walletId && (
<h3 className="mb-4 font-medium text-lg">Send a test transaction</h3>
)}
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" />
{props.userAccessToken
? "Copy your Vault access token, you'll need it for every HTTP call to Engine."
: "Every wallet action requires your Vault access token."}
</p>
<div className="h-4" />
{/* Responsive container */}
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex-grow">
<div className="flex flex-col gap-2">
<p className="text-sm">Vault Access Token</p>
{props.userAccessToken ? (
<div className="flex flex-col gap-2 ">
<CopyTextButton
copyIconPosition="right"
textToCopy={props.userAccessToken}
textToShow={props.userAccessToken}
tooltip="Copy Vault Access Token"
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
This is a project-wide access token to access your server
wallets. You can create more access tokens using your admin
key, with granular scopes and permissions.
</p>
</div>
) : (
<Input
id="access-token"
type={props.userAccessToken ? "text" : "password"}
placeholder="vt_act_1234....ABCD"
{...form.register("accessToken")}
disabled={isLoading}
className="text-xs"
/>
)}
</div>
</div>
</div>
<div className="h-4" />
{/* Wallet Selector */}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-2">
<div className="flex flex-1 flex-col gap-2">
<p className="text-sm">Server Wallet</p>
<Select
value={form.watch("walletIndex")}
onValueChange={(value) => form.setValue("walletIndex", value)}
>
<SelectTrigger className="w-full">
<SelectValue>
<div className="flex items-center gap-2">
<SmartAccountCell wallet={selectedWallet} />
<span className="text-muted-foreground text-sm">
{selectedWallet.metadata.label}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{props.wallets.map((wallet, index) => (
<SelectItem key={wallet.address} value={index.toString()}>
<div className="flex items-center gap-2">
<SmartAccountCell wallet={wallet} />
<span className="text-muted-foreground text-sm">
{wallet.metadata.label}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-1 flex-col gap-2">
<p className="text-sm">Network</p>
<SingleNetworkSelector
className="bg-background"
chainIds={[
baseSepolia.id,
optimismSepolia.id,
arbitrumSepolia.id,
sepolia.id,
1301,
1946,
1993,
919,
6342,
10143,
44787,
80002,
80069,
98985,
128123,
167009,
168587773,
37714555429,
1952959480,
97,
296,
1740,
4202,
10200,
]}
client={thirdwebClient}
chainId={form.watch("chainId")}
onChange={(chainId) => {
form.setValue("chainId", chainId);
}}
/>
</div>
</div>
</div>
<div className="h-6" />
<div className="flex flex-col gap-2 md:flex-row md:justify-end md:gap-2">
<Button
type="submit"
variant={hasSentTx ? "secondary" : "primary"}
disabled={isLoading || !form.formState.isValid}
className="w-full min-w-[200px] md:w-auto"
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>{hasSentTx ? "Send More Transactions" : "Send Transaction"}</>
)}
</Button>
{hasSentTx && (
<Button
variant="primary"
className="w-full md:w-auto"
onClick={() => {
if (props.walletId) {
router.replace(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud`,
);
} else {
router.refresh();
}
}}
>
{props.walletId ? "Close" : "Complete Setup"}
</Button>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,96 @@
import { StatCard } from "components/analytics/stat"; // Assuming correct path
import { ActivityIcon, CoinsIcon } from "lucide-react";
import { toEther } from "thirdweb/utils";
import type { TransactionSummaryData } from "../lib/analytics";
// Renders the UI based on fetched data or pending state
function TransactionAnalyticsSummaryUI(props: {
data: TransactionSummaryData | undefined;
isPending: boolean;
}) {
// Formatter function specifically for the StatCard prop
// Typed to accept number for StatCard's prop type, but receives the string via `as any`
const parseTotalGasCost = (valueFromCard: string): number => {
// At runtime, valueFromCard is the string passed via `as any` if data exists,
// or potentially the fallback number (like 0) if data doesn't exist.
// We prioritize the actual string from props.data if available.
const weiString = props.data?.totalGasCostWei ?? "0";
// Check if the effective value is zero
if (weiString === "0") {
return 0;
}
try {
// Convert the definitive wei string to BigInt
const weiBigInt = BigInt(weiString);
// Use the imported toEther function
return Number.parseFloat(toEther(weiBigInt));
} catch (e) {
// Catch potential errors during BigInt conversion or formatting
console.error("Error formatting wei value:", weiString, e);
// Check if the value passed from card was actually the fallback number
if (typeof valueFromCard === "number" && valueFromCard === 0) {
return 0; // If fallback 0 was passed, display 0
}
}
return 0;
};
// NOTE: props.data?.totalGasUnitsUsed is fetched but not currently displayed.
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<StatCard
label="Total Transactions"
// Value is a number, standard formatter works
value={props.data?.totalCount}
icon={ActivityIcon}
isPending={props.isPending}
/>
<StatCard
label="Total Gas Spent (includes testnet)"
// If pending, value doesn't matter much.
// If not pending, pass the wei string `as any` if data exists, otherwise pass 0.
// Passing 0 ensures StatCard receives a number if data is missing post-loading.
value={
props.isPending
? undefined
: parseTotalGasCost(props.data?.totalGasCostWei ?? "0")
}
formatter={(v: number) => `${v.toFixed(10)} ETH`}
icon={CoinsIcon}
// Pass the formatter that handles the type juggling
isPending={props.isPending}
/>
{/*
// Example of how totalGasUnitsUsed could be added later:
<StatCard
label="Total Gas Units Used"
value={(props.isPending ? undefined : (props.data?.totalGasUnitsUsed ?? 0)) as any} // Pass string as any
icon={SomeOtherIcon}
isPending={props.isPending}
formatter={(value: number) => { // Formatter receives string via `as any`
const unitString = props.data?.totalGasUnitsUsed ?? '0';
if (unitString === '0') return '0';
try {
return new Intl.NumberFormat("en-US").format(BigInt(unitString));
} catch {
return "Error";
}
}}
/>
*/}
</div>
);
}
export function TransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
initialData: TransactionSummaryData | undefined;
}) {
return (
<TransactionAnalyticsSummaryUI data={props.initialData} isPending={false} />
);
}
@@ -0,0 +1,206 @@
"use client";
import type { Project } from "@/api/projects";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { Button } from "@/components/ui/button";
import type { ChartConfig } from "@/components/ui/chart";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { formatDate } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { formatTickerNumber } from "lib/format-utils";
import Link from "next/link";
import { useMemo } from "react";
import type { TransactionStats } from "types/analytics";
import type { Wallet } from "../../server-wallets/wallet-table/types";
type ChartData = Record<string, number> & {
time: string;
};
// TODO: write a story for this component when its finalized
export function TransactionsChartCardUI(props: {
userOpStats: TransactionStats[];
isPending: boolean;
project: Project;
wallets: Wallet[];
teamSlug: string;
}) {
const { userOpStats } = props;
const topChainsToShow = 10;
const chainsStore = useAllChainsData();
// TODO - update this if we need to change it
const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const chainIdToVolumeMap: Map<string, number> = new Map();
// for each stat, add it in _chartDataMap
for (const stat of userOpStats) {
const chartData = _chartDataMap.get(stat.date);
const { chainId } = stat;
const chain = chainsStore.idToChain.get(Number(chainId));
const chainName = chain?.name || chainId.toString() || "Unknown";
// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: stat.date,
[chainName]: stat.count,
} as ChartData);
} else {
chartData[chainName] = (chartData[chainName] || 0) + stat.count;
}
chainIdToVolumeMap.set(
chainName,
stat.count + (chainIdToVolumeMap.get(chainName) || 0),
);
}
const chainsSorted = Array.from(chainIdToVolumeMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);
const chainsToShow = chainsSorted.slice(0, topChainsToShow);
const chainsToTagAsOthers = chainsSorted.slice(topChainsToShow);
// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const chainId in data) {
if (chainsToTagAsOthers.includes(chainId)) {
data.others = (data.others || 0) + (data[chainId] || 0);
delete data[chainId];
}
}
}
chainsToShow.forEach((chainName, i) => {
_chartConfig[chainName] = {
label: chainName,
color: `hsl(var(--chart-${(i % 10) + 1}))`,
};
});
// Add Other
chainsToShow.push("others");
_chartConfig.others = {
label: "Others",
color: "hsl(var(--muted-foreground))",
};
return {
chartData: Array.from(_chartDataMap.values()),
chartConfig: _chartConfig,
};
}, [userOpStats, chainsStore]);
const uniqueChainIds = Object.keys(chartConfig);
const disableActions =
props.isPending ||
chartData.length === 0 ||
chartData.every((data) => data.transactions === 0);
return (
<ThirdwebBarChart
customHeader={
<div className="relative px-6 pt-6">
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
Daily Transactions
</h3>
<p className="text-muted-foreground text-sm">
Amount of daily transactions by chain.
</p>
<div className="top-6 right-6 mb-8 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
<ExportToCSVButton
className="bg-background"
fileName="Sponsored Transactions"
disabled={disableActions}
getData={async () => {
const header = ["Date", ...uniqueChainIds];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
</div>
}
config={chartConfig}
data={chartData}
isPending={props.isPending}
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
showLegend
hideLabel={false}
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return formatDate(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
toolTipValueFormatter={(value) => formatTickerNumber(Number(value))}
emptyChartState={
<EmptyChartContent
teamSlug={props.teamSlug}
project={props.project}
wallets={props.wallets}
/>
}
/>
);
}
// TODO - update the title and doc links
function EmptyChartContent(props: {
project: Project;
teamSlug: string;
wallets: Wallet[];
}) {
const router = useDashboardRouter();
return (
<div className="flex flex-col items-center justify-center px-4">
{props.wallets.length === 0 ? (
<>
<span className="mb-6 text-center text-lg">
Engine requires a{" "}
<Link
href="https://portal.thirdweb.com/vault"
target="_blank"
className="underline"
>
Vault admin account
</Link>
. Create one to get started.
</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<Button
variant="primary"
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
);
}}
>
Create Vault Admin Account
</Button>
</div>
</>
) : (
<p className="flex items-center gap-2 rounded-full border bg-background px-3.5 py-1.5 text-sm">
<span className="!pointer-events-auto relative flex size-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
Waiting for transactions...
</p>
)}
</div>
);
}
@@ -0,0 +1,73 @@
import type { Project } from "@/api/projects";
import { ResponsiveSuspense } from "responsive-rsc";
import { getTransactionsChart } from "../../lib/analytics";
import { getTxAnalyticsFiltersFromSearchParams } from "../../lib/utils";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionsChartCardUI } from "./tx-chart-ui";
async function AsyncTransactionsChartCard(props: {
from: string;
to: string;
interval: "day" | "week";
project: Project;
wallets: Wallet[];
teamSlug: string;
}) {
const data = await getTransactionsChart({
clientId: props.project.publishableKey,
teamId: props.project.teamId,
from: props.from,
to: props.to,
interval: props.interval,
});
return (
<TransactionsChartCardUI
isPending={false}
userOpStats={data}
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
);
}
export function TransactionsChartCard(props: {
searchParams: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
};
project: Project;
wallets: Wallet[];
teamSlug: string;
}) {
const { range, interval } = getTxAnalyticsFiltersFromSearchParams(
props.searchParams,
);
return (
<ResponsiveSuspense
// TODO - change this if this component does not end up using these params
searchParamsUsed={["from", "to", "interval"]}
fallback={
<TransactionsChartCardUI
isPending={true}
userOpStats={[]}
project={props.project}
wallets={[]}
teamSlug={props.teamSlug}
/>
}
>
<AsyncTransactionsChartCard
from={range.from.toISOString()}
to={range.to.toISOString()}
interval={interval}
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
</ResponsiveSuspense>
);
}
@@ -0,0 +1,414 @@
"use client";
import type { Project } from "@/api/projects";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { PaginationButtons } from "@/components/pagination-buttons";
import { CopyAddressButton } from "@/components/ui/CopyAddressButton";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { formatDistanceToNowStrict } from "date-fns";
import { format } from "date-fns/format";
import { useAllChainsData } from "hooks/chains/allChains";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ChainIconClient } from "../../../../../../../../../components/icons/ChainIcon";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import type {
Transaction,
TransactionStatus,
TransactionsResponse,
} from "./types";
// TODO - add Status selector dropdown here
export function TransactionsTableUI(props: {
getData: (params: { page: number }) => Promise<TransactionsResponse>;
project: Project;
teamSlug: string;
wallets?: Wallet[];
}) {
const router = useDashboardRouter();
const [autoUpdate, setAutoUpdate] = useState(true);
const [status, setStatus] = useState<TransactionStatus | undefined>(
undefined,
);
const [page, setPage] = useState(1);
const pageSize = 10;
const transactionsQuery = useQuery({
queryKey: ["transactions", props.project.id, page],
queryFn: () => props.getData({ page }),
refetchInterval: autoUpdate ? 4_000 : false,
placeholderData: keepPreviousData,
enabled: !!props.wallets && props.wallets.length > 0,
});
const transactions = transactionsQuery.data?.transactions ?? [];
const totalCount = transactionsQuery.data?.pagination.totalCount ?? 0;
const totalPages = Math.ceil(totalCount / pageSize);
const showPagination = totalCount > pageSize;
const showSkeleton =
(transactionsQuery.isPlaceholderData && transactionsQuery.isFetching) ||
(transactionsQuery.isLoading && !transactionsQuery.isPlaceholderData);
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col gap-4 rounded-lg rounded-b-none px-6 py-6 lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Transaction History
</h2>
<p className="text-muted-foreground text-sm">
Transactions sent from server wallets
</p>
</div>
<div className="flex items-center justify-end gap-5 border-border border-t pt-4 lg:border-none lg:pt-0">
<div className="flex shrink-0 items-center gap-2">
<Label htmlFor="auto-update">Auto Update</Label>
<Switch
checked={autoUpdate}
onCheckedChange={(v) => setAutoUpdate(!!v)}
id="auto-update"
/>
</div>
<StatusSelector
status={status}
setStatus={(v) => {
setStatus(v);
// reset page
setPage(1);
}}
/>
</div>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Queue ID</TableHead>
<TableHead>Chain</TableHead>
<TableHead>Status</TableHead>
<TableHead>From</TableHead>
<TableHead>Tx Hash</TableHead>
<TableHead>Queued</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{showSkeleton ? (
<>
{new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<SkeletonRow key={i} />
))}
</>
) : (
<>
{transactions.map((tx) => (
<TableRow
key={`${tx.id}${tx.chainId}`}
className="cursor-pointer hover:bg-accent/50"
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/tx/${tx.id}`,
);
}}
>
{/* Queue ID */}
<TableCell className="font-medium">
<CopyAddressButton
address={tx.id ? tx.id : ""}
copyIconPosition="left"
variant="ghost"
className="text-muted-foreground"
/>
</TableCell>
{/* Chain Id */}
<TableCell>
<TxChainCell chainId={tx.chainId || undefined} />
</TableCell>
{/* Status */}
<TableCell>
<TxStatusCell transaction={tx} />
</TableCell>
{/* From Address */}
<TableCell>
{tx.from ? <WalletAddress address={tx.from} /> : "N/A"}
</TableCell>
{/* Tx Hash */}
<TableCell>
<TxHashCell transaction={tx} />
</TableCell>
{/* Queued At */}
<TableCell>
<TxQueuedAtCell transaction={tx} />
</TableCell>
</TableRow>
))}
</>
)}
</TableBody>
</Table>
{!showSkeleton && transactions.length === 0 && (
<div className="flex min-h-[200px] items-center justify-center">
No transactions found
</div>
)}
</TableContainer>
{showPagination && (
<div className="py-6">
<PaginationButtons
activePage={page}
onPageClick={setPage}
totalPages={totalPages}
/>
</div>
)}
</div>
);
}
export const statusDetails = {
QUEUED: {
name: "Queued",
type: "warning",
},
SUBMITTED: {
name: "Submitted",
type: "warning",
},
CONFIRMED: {
name: "Confirmed",
type: "success",
},
REVERTED: {
name: "Reverted",
type: "destructive",
},
FAILED: {
name: "Failed",
type: "destructive",
},
} as const;
function StatusSelector(props: {
status: TransactionStatus | undefined;
setStatus: (value: TransactionStatus | undefined) => void;
}) {
const statuses = Object.keys(statusDetails) as TransactionStatus[];
return (
<Select
value={props.status || "all"}
onValueChange={(v) => {
if (v === "all") {
props.setStatus(undefined);
} else {
props.setStatus(v as TransactionStatus);
}
}}
>
<SelectTrigger className="min-w-[160px]">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All Transactions</SelectItem>
{statuses.map((item) => {
return (
<SelectItem key={item} value={item}>
{statusDetails[item].name}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
function SkeletonRow() {
return (
<TableRow className="h-[73px]">
<TableCell>
<Skeleton className="h-6 w-[152px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[155px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[58px] rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[145px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[160px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[108px]" />
</TableCell>
</TableRow>
);
}
function TxChainCell(props: { chainId: string | undefined }) {
const { chainId } = props;
const { idToChain } = useAllChainsData();
const thirdwebClient = useThirdwebClient();
if (!chainId) {
return "N/A";
}
const chain = idToChain.get(Number.parseInt(chainId));
if (!chain) {
return `Chain ID: ${chainId}`;
}
return (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-5"
client={thirdwebClient}
src={chain.icon?.url}
/>
<div className="max-w-[150px] truncate">
{chain.name ?? `Chain ID: ${chainId}`}
</div>
</div>
);
}
function TxStatusCell(props: { transaction: Transaction }) {
const { transaction } = props;
const { errorMessage } = transaction;
const minedAt = transaction.confirmedAt;
const status =
(transaction.executionResult?.status as TransactionStatus) ?? null;
const onchainStatus =
transaction.executionResult &&
"onchainStatus" in transaction.executionResult
? transaction.executionResult.onchainStatus
: null;
if (!status) {
return null;
}
const tooltip =
onchainStatus !== "REVERTED"
? errorMessage
: status === "CONFIRMED" && minedAt
? `Completed ${format(new Date(minedAt), "PP pp")}`
: undefined;
return (
<ToolTipLabel hoverable label={tooltip}>
<Badge variant={statusDetails[status].type} className="gap-2">
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
);
}
function TxHashCell(props: { transaction: Transaction }) {
const { idToChain } = useAllChainsData();
const { chainId, transactionHash } = props.transaction;
if (!transactionHash) {
return "N/A";
}
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
const shortHash = `${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`;
if (!explorer) {
return (
<CopyTextButton
textToCopy={transactionHash}
copyIconPosition="left"
textToShow={shortHash}
variant="ghost"
tooltip="Copy transaction hash"
className="font-mono text-muted-foreground text-sm"
/>
);
}
return (
<Button
variant="ghost"
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
{shortHash}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
);
}
function TxQueuedAtCell(props: { transaction: Transaction }) {
const value = props.transaction.createdAt;
if (!value) {
return;
}
const date = new Date(value);
return (
<ToolTipLabel label={format(date, "PP pp z")}>
<p>{formatDistanceToNowStrict(date, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
@@ -0,0 +1,57 @@
"use client";
import { engineCloudProxy } from "@/actions/proxies";
import type { Project } from "@/api/projects";
import type { Wallet } from "../../server-wallets/wallet-table/types";
import { TransactionsTableUI } from "./tx-table-ui";
import type { TransactionsResponse } from "./types";
export function TransactionsTable(props: {
project: Project;
wallets?: Wallet[];
teamSlug: string;
}) {
return (
<TransactionsTableUI
getData={async ({ page }) => {
return await getTransactions({
project: props.project,
page,
});
}}
project={props.project}
wallets={props.wallets}
teamSlug={props.teamSlug}
/>
);
}
async function getTransactions({
project,
page,
}: {
project: Project;
page: number;
}) {
const transactions = await engineCloudProxy<{ result: TransactionsResponse }>(
{
pathname: "/v1/transactions/search",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-team-id": project.teamId,
"x-client-id": project.publishableKey,
},
body: JSON.stringify({
page,
limit: 20,
}),
},
);
if (!transactions.ok) {
throw new Error(transactions.error);
}
return transactions.data.result;
}
@@ -0,0 +1,93 @@
import type { Address, Hex } from "thirdweb";
// Revert data type (referenced but not defined in the provided code)
type RevertDataSerialized = {
revertReason?: string;
decodedError?: {
name: string;
signature: string;
args: string[];
};
};
// Transaction parameters
type TransactionParamsSerialized = {
to: Address;
data: Hex;
value: string;
};
// Execution parameters
type ExecutionParams4337Serialized = {
type: "AA";
entrypointAddress: string;
smartAccountAddress: string;
signerAddress: string;
};
type ExecutionParamsSerialized = ExecutionParams4337Serialized;
// Execution result
type ExecutionResult4337Serialized =
| {
status: "QUEUED";
}
| {
status: "SUBMITTED";
monitoringStatus: "WILL_MONITOR" | "CANNOT_MONITOR";
userOpHash: string;
}
| ({
status: "CONFIRMED";
userOpHash: Hex;
transactionHash: Hex;
actualGasCost: string;
actualGasUsed: string;
nonce: string;
} & (
| {
onchainStatus: "SUCCESS";
}
| {
onchainStatus: "REVERTED";
revertData?: RevertDataSerialized;
}
));
type ExecutionResultSerialized = ExecutionResult4337Serialized;
// Main Transaction type from database
export type Transaction = {
id: string;
batchIndex: number;
chainId: string;
from: Address | null;
transactionParams: TransactionParamsSerialized[];
transactionHash: Hex | null;
confirmedAt: Date | null;
confirmedAtBlockNumber: string | null;
enrichedData: unknown[];
executionParams: ExecutionParamsSerialized;
executionResult: ExecutionResultSerialized | null;
createdAt: Date;
errorMessage: string | null;
cancelledAt: Date | null;
};
export type TransactionStatus =
| "QUEUED"
| "SUBMITTED"
| "CONFIRMED"
| "REVERTED"
| "FAILED";
type Pagination = {
totalCount: number;
page: number;
limit: number;
};
export type TransactionsResponse = {
transactions: Transaction[];
pagination: Pagination;
};
@@ -0,0 +1,24 @@
"use client";
import { ApiReferenceReact } from "@scalar/api-reference-react";
import "@scalar/api-reference-react/style.css";
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
export function Scalar() {
return (
<div className="flex flex-col gap-4">
<h2 className="font-bold text-2xl tracking-tight">Full API Reference</h2>
<ApiReferenceReact
configuration={{
url: `${THIRDWEB_ENGINE_CLOUD_URL}/openapi`,
hideModels: true,
servers: [
{
url: THIRDWEB_ENGINE_CLOUD_URL,
description: "Engine Cloud",
},
],
}}
/>
</div>
);
}
@@ -0,0 +1,11 @@
import { TryItOut } from "../server-wallets/components/try-it-out";
import { Scalar } from "./components/scalar";
export default async function TransactionsExplorerPage() {
return (
<div className="flex flex-col gap-4">
<TryItOut />
<Scalar />
</div>
);
}
@@ -0,0 +1,101 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { TabPathLinks } from "@/components/ui/tabs";
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
import Link from "next/link";
import { EngineIcon } from "../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
export default async function Page(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
children: React.ReactNode;
}) {
const { team_slug, project_slug } = await props.params;
return (
<TransactionsLayout projectSlug={project_slug} teamSlug={team_slug}>
{props.children}
</TransactionsLayout>
);
}
function TransactionsLayout(props: {
projectSlug: string;
teamSlug: string;
children: React.ReactNode;
}) {
const engineBaseSlug = `/team/${props.teamSlug}/${props.projectSlug}/engine`;
const engineLayoutSlug = `${engineBaseSlug}/cloud`;
return (
<div className="flex grow flex-col">
{/* top */}
<div className="pt-4 lg:pt-6">
{/* header */}
<div className="container flex max-w-7xl flex-col gap-4">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-col">
<h1 className="mb-0.5 flex items-center gap-2 font-semibold text-3xl tracking-tight">
Engine{" "}
<Badge
variant="success"
className="mt-0.5 flex items-center gap-2 text-sm"
>
<EngineIcon className="size-4" /> Cloud
</Badge>
<Badge
variant="outline"
className="mt-0.5 flex items-center gap-2 text-sm"
>
Beta
</Badge>
</h1>
<div className="flex items-center gap-2">
<Link
href={`${THIRDWEB_ENGINE_CLOUD_URL}/reference`} // TODO: change this
target="_blank"
className="max-w-full truncate py-1 text-muted-foreground"
>
{THIRDWEB_ENGINE_CLOUD_URL}
</Link>
</div>
</div>
<Link href={`${engineBaseSlug}/dedicated`}>
<Button variant="outline">View dedicated engines</Button>
</Link>
</div>
</div>
<div className="h-4" />
{/* Nav */}
<TabPathLinks
scrollableClassName="container max-w-7xl"
links={[
{
name: "Transactions",
path: `${engineLayoutSlug}`,
exactMatch: true,
},
{
name: "API Explorer",
path: `${engineLayoutSlug}/explorer`,
},
{
name: "Server Wallets",
path: `${engineLayoutSlug}/server-wallets`,
},
{
name: "Vault",
path: `${engineLayoutSlug}/vault`,
},
]}
/>
</div>
<div className="h-6" />
<div className="container flex max-w-7xl grow flex-col gap-6">
<div>{props.children}</div>
</div>
<div className="h-20" />
</div>
);
}
@@ -0,0 +1,202 @@
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
import type { TransactionStats } from "../../../../../../../../types/analytics";
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
import type {
Transaction,
TransactionsResponse,
} from "../analytics/tx-table/types";
// Define the structure of the data we expect back from our fetch function
export type TransactionSummaryData = {
totalCount: number;
totalGasCostWei: string;
totalGasUnitsUsed: string; // Keep fetched data structure
};
// Define the structure of the API response
type AnalyticsSummaryApiResponse = {
result: {
summary: {
totalCount: number;
totalGasCostWei: string;
totalGasUnitsUsed: string;
};
metadata: {
startDate?: string;
endDate?: string;
};
};
};
// Fetches data from the /analytics-summary endpoint
export async function getTransactionAnalyticsSummary(props: {
teamId: string;
clientId: string;
}): Promise<TransactionSummaryData> {
const authToken = await getAuthToken();
const body = {};
const defaultData: TransactionSummaryData = {
totalCount: 0,
totalGasCostWei: "0",
totalGasUnitsUsed: "0",
};
try {
const response = await fetch(
`${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/analytics-summary`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-team-id": props.teamId,
"x-client-id": props.clientId,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 400) {
console.error("Unauthorized fetching transaction summary");
return defaultData;
}
const errorText = await response.text();
throw new Error(
`Error fetching transaction summary: ${response.status} ${response.statusText} - ${errorText}`,
);
}
const data = (await response.json()) as AnalyticsSummaryApiResponse;
return {
totalCount: data.result.summary.totalCount ?? 0,
totalGasCostWei: data.result.summary.totalGasCostWei ?? "0",
totalGasUnitsUsed: data.result.summary.totalGasUnitsUsed ?? "0",
};
} catch (error) {
console.error("Failed to fetch transaction summary:", error);
return defaultData;
}
}
export async function getTransactionsChart({
teamId,
clientId,
from,
to,
interval,
}: {
teamId: string;
clientId: string;
from: string;
to: string;
interval: "day" | "week";
}): Promise<TransactionStats[]> {
const authToken = await getAuthToken();
const filters = {
startDate: from,
endDate: to,
resolution: interval,
};
const response = await fetch(
`${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/analytics`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-team-id": teamId,
"x-client-id": clientId,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(filters),
},
);
if (!response.ok) {
if (response.status === 401 || response.status === 400) {
return [];
}
// TODO - need to handle this error state, like we do with the connect charts
throw new Error(
`Error fetching transactions chart data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
);
}
type TransactionsChartResponse = {
result: {
analytics: Array<{
timeBucket: string;
chainId: string;
count: number;
}>;
metadata: {
resolution: string;
startDate: string;
endDate: string;
};
};
};
const data = (await response.json()) as TransactionsChartResponse;
return data.result.analytics.map((stat) => ({
date: stat.timeBucket,
chainId: Number(stat.chainId),
count: stat.count,
}));
}
export async function getSingleTransaction({
teamId,
clientId,
transactionId,
}: {
teamId: string;
clientId: string;
transactionId: string;
}): Promise<Transaction | undefined> {
const authToken = await getAuthToken();
const filters = {
filters: [
{
field: "id",
values: [transactionId],
operation: "OR",
},
],
};
const response = await fetch(
`${THIRDWEB_ENGINE_CLOUD_URL}/v1/transactions/search`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-team-id": teamId,
"x-client-id": clientId,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(filters),
},
);
if (!response.ok) {
if (response.status === 401) {
return undefined;
}
// TODO - need to handle this error state, like we do with the connect charts
throw new Error(
`Error fetching single transaction data: ${response.status} ${response.statusText} - ${await response.text().catch(() => "Unknown error")}`,
);
}
const data = (await response.json()).result as TransactionsResponse;
return data.transactions[0];
}
@@ -0,0 +1,14 @@
import { getFiltersFromSearchParams } from "@/lib/time";
export function getTxAnalyticsFiltersFromSearchParams(params: {
from?: string | undefined | string[];
to?: string | undefined | string[];
interval?: string | undefined | string[];
}) {
return getFiltersFromSearchParams({
from: params.from,
to: params.to,
interval: params.interval,
defaultRange: "last-30",
});
}
@@ -0,0 +1,317 @@
"use client";
import type { Project } from "@/api/projects";
import { THIRDWEB_VAULT_URL } from "@/constants/env";
import {
type VaultClient,
createAccessToken,
createVaultClient,
} from "@thirdweb-dev/vault-sdk";
import { updateProjectClient } from "../../../../../../../../@3rdweb-sdk/react/hooks/useApi";
const SERVER_WALLET_ACCESS_TOKEN_PURPOSE =
"Access Token for All Server Wallets";
export const SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE =
"Management Token for Dashboard";
let vc: VaultClient | null = null;
export async function initVaultClient() {
if (vc) {
return vc;
}
vc = await createVaultClient({
baseUrl: THIRDWEB_VAULT_URL,
});
return vc;
}
export async function createWalletAccessToken(props: {
project: Project;
adminKey: string;
vaultClient: VaultClient;
}) {
return createAccessToken({
client: props.vaultClient,
request: {
options: {
expiresAt: new Date(
Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000,
).toISOString(), // 100 years from now
policies: [
{
type: "eoa:read",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:create",
requiredMetadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:signMessage",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:signTransaction",
payloadPatterns: {},
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:signTypedData",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:signStructuredMessage",
structuredPatterns: {
useropV06: {},
useropV07: {},
},
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
],
metadata: {
projectId: props.project.id,
teamId: props.project.teamId,
purpose: SERVER_WALLET_ACCESS_TOKEN_PURPOSE,
},
},
auth: {
adminKey: props.adminKey,
},
},
});
}
export async function createManagementAccessToken(props: {
project: Project;
adminKey: string;
rotationCode: string;
vaultClient: VaultClient;
}) {
const res = await createAccessToken({
client: props.vaultClient,
request: {
options: {
expiresAt: new Date(
Date.now() + 60 * 60 * 1000 * 24 * 365 * 1000,
).toISOString(), // 100 years from now
policies: [
{
type: "eoa:read",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "eoa:create",
requiredMetadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
{
key: "type",
rule: {
pattern: "server-wallet",
},
},
],
},
{
type: "accessToken:read",
metadataPatterns: [
{
key: "projectId",
rule: {
pattern: props.project.id,
},
},
{
key: "teamId",
rule: {
pattern: props.project.teamId,
},
},
],
revealSensitive: false,
},
],
metadata: {
projectId: props.project.id,
teamId: props.project.teamId,
purpose: SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE,
},
},
auth: {
adminKey: props.adminKey,
},
},
});
if (res.success) {
const data = res.data;
// store the management access token in the project
await updateProjectClient(
{
projectId: props.project.id,
teamId: props.project.teamId,
},
{
services: [
...props.project.services,
{
name: "engineCloud",
managementAccessToken: data.accessToken,
maskedAdminKey: maskSecret(props.adminKey),
rotationCode: props.rotationCode,
actions: [],
},
],
},
);
}
return res;
}
export function maskSecret(secret: string) {
return `${secret.substring(0, 11)}...${secret.substring(secret.length - 5)}`;
}
@@ -0,0 +1,107 @@
import { getProject } from "@/api/projects";
import { THIRDWEB_VAULT_URL } from "@/constants/env";
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { notFound, redirect } from "next/navigation";
import { getAuthToken } from "../../../../../api/lib/getAuthToken";
import { TransactionsAnalyticsPageContent } from "./analytics/analytics-page";
import { EngineChecklist } from "./analytics/ftux.client";
import { TransactionAnalyticsSummary } from "./analytics/summary";
import {
type TransactionSummaryData,
getTransactionAnalyticsSummary,
} from "./lib/analytics";
import type { Wallet } from "./server-wallets/wallet-table/types";
export default async function TransactionsAnalyticsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
from?: string | string[] | undefined;
to?: string | string[] | undefined;
interval?: string | string[] | undefined;
testTxWithWallet?: string | string[] | undefined;
}>;
}) {
const [params, searchParams, authToken] = await Promise.all([
props.params,
props.searchParams,
getAuthToken(),
]);
if (!authToken) {
notFound();
}
const [vaultClient, project] = await Promise.all([
createVaultClient({
baseUrl: THIRDWEB_VAULT_URL,
}).catch(() => undefined),
getProject(params.team_slug, params.project_slug),
]);
if (!project) {
redirect(`/team/${params.team_slug}`);
}
if (!vaultClient) {
return <div>Error: Failed to connect to Vault</div>;
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {},
},
})
: { data: { items: [] }, error: null, success: true };
const wallets = eoas.data?.items as Wallet[] | undefined;
let initialData: TransactionSummaryData | undefined;
if (wallets && wallets.length > 0) {
const summary = await getTransactionAnalyticsSummary({
teamId: project.teamId,
clientId: project.publishableKey,
}).catch(() => undefined);
initialData = summary;
}
const hasTransactions = initialData ? initialData.totalCount > 0 : false;
return (
<div className="flex grow flex-col">
<EngineChecklist
teamSlug={params.team_slug}
managementAccessToken={managementAccessToken ?? undefined}
hasTransactions={hasTransactions}
project={project}
wallets={wallets ?? []}
testTxWithWallet={searchParams.testTxWithWallet as string | undefined}
/>
{hasTransactions && !searchParams.testTxWithWallet && (
<TransactionAnalyticsSummary
initialData={initialData}
teamId={project.teamId}
clientId={project.publishableKey}
/>
)}
<div className="h-10" />
<TransactionsAnalyticsPageContent
teamSlug={params.team_slug}
searchParams={searchParams}
project={project}
showAnalytics={hasTransactions && !searchParams.testTxWithWallet}
wallets={wallets}
/>
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
import type { Project } from "@/api/projects";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { useMutation } from "@tanstack/react-query";
import { createEoa } from "@thirdweb-dev/vault-sdk";
import { Loader2Icon, WalletIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { engineCloudProxy } from "../../../../../../../../../@/actions/proxies";
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
import { initVaultClient } from "../../lib/vault.client";
export default function CreateServerWallet(props: {
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
}) {
const router = useDashboardRouter();
const [label, setLabel] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const trackEvent = useTrack();
const createEoaMutation = useMutation({
mutationFn: async ({
managementAccessToken,
label,
}: {
managementAccessToken: string;
label: string;
}) => {
trackEvent({
category: "engine-cloud",
action: "create_server_wallet",
});
const vaultClient = await initVaultClient();
const eoa = await createEoa({
request: {
options: {
metadata: {
projectId: props.project.id,
teamId: props.project.teamId,
type: "server-wallet",
label,
},
},
auth: {
accessToken: managementAccessToken,
},
},
client: vaultClient,
});
if (!eoa.success) {
throw new Error("Failed to create eoa");
}
// no need to await this, it's not blocking
engineCloudProxy({
pathname: "/cache/smart-account",
method: "POST",
headers: {
"Content-Type": "application/json",
"x-team-id": props.project.teamId,
"x-client-id": props.project.publishableKey,
},
body: JSON.stringify({
signerAddress: eoa.data.address,
}),
}).catch((err) => {
console.warn("failed to cache server wallet", err);
});
router.refresh();
setModalOpen(false);
return eoa;
},
onError: (error) => {
toast.error(error.message);
},
});
const handleCreateServerWallet = async () => {
if (!props.managementAccessToken) {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
);
} else {
await createEoaMutation.mutateAsync({
managementAccessToken: props.managementAccessToken,
label,
});
}
};
const isLoading = createEoaMutation.isPending;
return (
<>
<Button
variant={"primary"}
onClick={() =>
props.managementAccessToken
? setModalOpen(true)
: router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud/vault`,
)
}
className="flex flex-row items-center gap-2"
>
{isLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<WalletIcon className="size-4" />
)}
{props.managementAccessToken
? isLoading
? "Creating..."
: "Create Server Wallet"
: "Get Started"}
</Button>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle>Create server wallet</DialogTitle>
<DialogDescription>
Enter a label for your server wallet.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 px-6">
<div>
<Input
placeholder="Wallet label (optional)"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full"
/>
</div>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
variant="outline"
onClick={() => setModalOpen(false)}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleCreateServerWallet}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Creating...
</>
) : (
"Create"
)}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,412 @@
"use client";
import { CodeClient } from "@/components/ui/code/code.client";
import { TabButtons } from "@/components/ui/tabs";
import { THIRDWEB_ENGINE_CLOUD_URL } from "@/constants/env";
import { CircleAlertIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import {
Alert,
AlertDescription,
AlertTitle,
} from "../../../../../../../../../@/components/ui/alert";
export function TryItOut() {
const [activeTab, setActiveTab] = useState<string>("sdk");
return (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card p-6">
<div className="flex flex-row items-center gap-4">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Usage from your backend
</h2>
<p className="text-muted-foreground text-sm">
Send transactions from your server wallets using the thirdweb SDK
or the HTTP API directly.
</p>
</div>
</div>
</div>
<div>
<TabButtons
tabs={[
{
name: "thirdweb SDK",
onClick: () => setActiveTab("sdk"),
isActive: activeTab === "sdk",
},
{
name: "Curl",
onClick: () => setActiveTab("curl"),
isActive: activeTab === "curl",
},
{
name: "JavaScript",
onClick: () => setActiveTab("js"),
isActive: activeTab === "js",
},
{
name: "Python",
onClick: () => setActiveTab("python"),
isActive: activeTab === "python",
},
{
name: "Go",
onClick: () => setActiveTab("go"),
isActive: activeTab === "go",
},
{
name: "C#",
onClick: () => setActiveTab("csharp"),
isActive: activeTab === "csharp",
},
]}
/>
<div className="h-4" />
{activeTab === "sdk" && (
<div className="flex flex-col gap-4">
<Alert variant="info" className="bg-background">
<CircleAlertIcon className="size-5" />
<AlertTitle>Using the thirdweb SDK on the backend</AlertTitle>
<AlertDescription className="leading-loose">
<p className="text-foreground text-sm">
You can use the full TypeScript thirdweb SDK in your backend,
allowing you to use:{" "}
<ul className="ml-2 list-disc py-2 pl-4">
<li>
The full catalog of{" "}
<Link
href="https://portal.thirdweb.com/references/typescript/v5/functions#extensions"
className="text-blue-500"
target="_blank"
>
extension functions
</Link>
</li>
<li>
The{" "}
<Link
href="https://portal.thirdweb.com/references/typescript/v5/prepareContractCall"
className="text-blue-500"
target="_blank"
>
prepareContractCall
</Link>{" "}
function to encode your transactions
</li>
<li>
The full{" "}
<Link
href="https://portal.thirdweb.com/references/typescript/v5/Account"
className="text-blue-500"
target="_blank"
>
account
</Link>{" "}
interface, predefined chains, and more
</li>
</ul>
The SDK handles encoding your transactions, signing them to
Engine and polling for status.
</p>
</AlertDescription>
</Alert>
<h3 className="font-semibold text-lg tracking-tight">
Installation
</h3>
<CodeClient
lang="shell"
code={"npm install thirdweb"}
className="bg-background"
/>
<h3 className="font-semibold text-lg tracking-tight">
Usage example: Minting a ERC1155 NFT to a user
</h3>
<CodeClient
lang="ts"
code={sdkExample()}
className="bg-background"
/>
</div>
)}
{activeTab === "curl" && (
<CodeClient
lang="bash"
code={curlExample()}
className="bg-background"
/>
)}
{activeTab === "js" && (
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
A lightweight, type safe wrapper package of the Engine HTTP API is
available on{" "}
<Link
href="https://www.npmjs.com/package/@thirdweb-dev/engine"
className="text-blue-500"
target="_blank"
>
NPM
</Link>
.
</p>
<CodeClient
lang="js"
code={jsExample()}
className="bg-background"
/>
</div>
)}
{activeTab === "python" && (
<CodeClient
lang="python"
code={pythonExample()}
className="bg-background"
/>
)}
{activeTab === "go" && (
<CodeClient lang="go" code={goExample()} className="bg-background" />
)}
{activeTab === "csharp" && (
<CodeClient
lang="csharp"
code={csharpExample()}
className="bg-background"
/>
)}
</div>
</div>
);
}
const sdkExample = () => `\
import { createThirdwebClient, sendTransaction, getContract, Engine } from "thirdweb";
import { baseSepolia } from "thirdweb/chains";
import { claimTo } from "thirdweb/extensions/1155";
// Create a thirdweb client
const client = createThirdwebClient({
secretKey: "<your-project-secret-key>",
});
// Create a server wallet
const serverWallet = Engine.serverWallet({
client,
address: "<your-server-wallet-address>",
vaultAccessToken: "<your-vault-access-token>",
});
// Prepare the transaction
const transaction = claimTo({
contract: getContract({
client,
address: "0x...", // Address of the ERC1155 token contract
chain: baseSepolia, // Chain of the ERC1155 token contract
}),
to: "0x...", // The address of the user to mint to
tokenId: 0n, // The token ID of the NFT to mint
quantity: 1n, // The quantity of NFTs to mint
});
// Enqueue the transaction via Engine
const { transactionId } = await serverWallet.enqueueTransaction({
transaction,
});
// Get the execution status of the transaction at any point in time
const executionResult = await Engine.getTransactionStatus({
client,
transactionId,
});
// Utility function to poll for the transaction to be submitted onchain
const txHash = await Engine.waitForTransactionHash({
client,
transactionId,
});
console.log("Transaction hash:", result.transactionHash);
`;
const curlExample = () => `\
curl -X POST "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract" \\
-H "Content-Type: application/json" \\
-H "x-secret-key: <your-project-secret-key>" \\
-H "x-vault-access-token: <your-vault-access-token>" \\
-d '{
"executionOptions": {
"from": "<your-server-wallet-address>",
"chainId": "84532"
},
"params": [
{
"contractAddress": "0x...",
"method": "function mintTo(address to, uint256 amount)",
"params": ["0x...", "100"]
}
]
}'`;
const jsExample = () => `\
const response = await fetch(
"${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-secret-key": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
},
body: JSON.stringify({
"executionOptions": {
"from": "<your-server-wallet-address>",
"chainId": "84532"
},
"params": [
{
"contractAddress": "0x...",
"method": "function mintTo(address to, uint256 amount)",
"params": ["0x...", "100"]
}
]
})
}
);`;
const pythonExample = () => `\
import requests
import json
url = "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract"
headers = {
"Content-Type": "application/json",
"x-secret-key": "<your-project-secret-key>",
"x-vault-access-token": "<your-vault-access-token>"
}
payload = {
"executionOptions": {
"from": "<your-server-wallet-address>",
"chainId": "84532"
},
"params": [
{
"contractAddress": "0x...",
"method": "function mintTo(address to, uint256 amount)",
"params": ["0x...", "100"]
}
]
}
response = requests.post(url, headers=headers, json=payload)
result = response.json()`;
const goExample = () => `\
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
url := "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract"
// Create the request payload
type Param struct {
ContractAddress string \`json:"contractAddress"\`
Method string \`json:"method"\`
Params []string \`json:"params"\`
}
type RequestBody struct {
ExecutionOptions struct {
From string \`json:"from"\`
ChainId string \`json:"chainId"\`
} \`json:"executionOptions"\`
Params []Param \`json:"params"\`
}
requestBody := RequestBody{
Params: []Param{
{
ContractAddress: "0x...",
Method: "function mintTo(address to, uint256 amount)",
Params: []string{"0x...", "100"},
},
},
}
requestBody.ExecutionOptions.From = "<your-server-wallet-address>"
requestBody.ExecutionOptions.ChainId = "84532"
jsonData, _ := json.Marshal(requestBody)
// Create the HTTP request
req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-secret-key", "<your-project-secret-key>")
req.Header.Set("x-vault-access-token", "<your-vault-access-token>")
// Send the request
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
fmt.Println("Error:", err)
return
}
defer resp.Body.Close()
// Process the response
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Println("Response:", result)
}`;
const csharpExample = () => `\
using System;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
var url = "${THIRDWEB_ENGINE_CLOUD_URL}/v1/write/contract";
var requestData = new
{
executionOptions = new
{
from = "<your-server-wallet-address>",
chainId = "84532"
},
@params = new[]
{
new
{
contractAddress = "0x...",
method = "function mintTo(address to, uint256 amount)",
@params = new[] { "0x...", "100" }
}
}
};
var json = JsonSerializer.Serialize(requestData);
var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Add("x-secret-key", "<your-project-secret-key>");
httpClient.DefaultRequestHeaders.Add("x-vault-access-token", "<your-vault-access-token>");
var response = await httpClient.PostAsync(url, content);
var responseContent = await response.Content.ReadAsStringAsync();
Console.WriteLine(responseContent);
}
}`;
@@ -0,0 +1,61 @@
import { getProject } from "@/api/projects";
import { createVaultClient, listEoas } from "@thirdweb-dev/vault-sdk";
import { notFound } from "next/navigation";
import { THIRDWEB_VAULT_URL } from "../../../../../../../../@/constants/env";
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
import type { Wallet } from "./wallet-table/types";
import { ServerWalletsTable } from "./wallet-table/wallet-table";
export default async function TransactionsServerWalletsPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const vaultClient = await createVaultClient({
baseUrl: THIRDWEB_VAULT_URL,
});
const { team_slug, project_slug } = await props.params;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
]);
if (!project || !authToken) {
notFound();
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const managementAccessToken =
projectEngineCloudService?.managementAccessToken;
const eoas = managementAccessToken
? await listEoas({
client: vaultClient,
request: {
auth: {
accessToken: managementAccessToken,
},
options: {},
},
})
: { data: { items: [] }, error: null, success: true };
return (
<>
{eoas.error ? (
<div>Error: {eoas.error.message}</div>
) : (
<div className="flex flex-col gap-8">
<ServerWalletsTable
wallets={eoas.data.items as Wallet[]}
project={project}
teamSlug={team_slug}
managementAccessToken={managementAccessToken ?? undefined}
/>
</div>
)}
</>
);
}
@@ -0,0 +1,11 @@
export type Wallet = {
id: string;
address: string;
metadata: {
type: string;
projectId: string;
label?: string;
};
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,191 @@
"use client";
import type { Project } from "@/api/projects";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { getThirdwebClient } from "@/constants/thirdweb.server";
import { useQuery } from "@tanstack/react-query";
import { formatDistanceToNowStrict } from "date-fns";
import { format } from "date-fns/format";
import { SendIcon } from "lucide-react";
import { useState } from "react";
import {
DEFAULT_ACCOUNT_FACTORY_V0_7,
predictSmartAccountAddress,
} from "thirdweb/wallets/smart";
import { Button } from "../../../../../../../../../@/components/ui/button";
import { useDashboardRouter } from "../../../../../../../../../@/lib/DashboardRouter";
import { useV5DashboardChain } from "../../../../../../../../../lib/v5-adapter";
import CreateServerWallet from "../components/create-server-wallet.client";
import type { Wallet } from "./types";
export function ServerWalletsTableUI({
wallets,
project,
teamSlug,
managementAccessToken,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
}) {
const [showSigners, setShowSigners] = useState(false);
return (
<div className="overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-row items-center gap-4 px-6 py-6">
<div className="flex flex-1 flex-col gap-4 rounded-lg rounded-b-none lg:flex-row lg:justify-between">
<div>
<h2 className="font-semibold text-xl tracking-tight">
Server Wallets
</h2>
<p className="text-muted-foreground text-sm">
Create and manage server wallets for you project
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<label htmlFor="show-signers" className="cursor-pointer text-sm">
Show Signer Addresses
</label>
<Switch
id="show-signers"
checked={showSigners}
onCheckedChange={() => setShowSigners(!showSigners)}
/>
</div>
<CreateServerWallet
project={project}
teamSlug={teamSlug}
managementAccessToken={managementAccessToken}
/>
</div>
<TableContainer className="rounded-none border-x-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Label</TableHead>
<TableHead className="text-right">Created At</TableHead>
<TableHead className="text-right">Updated At</TableHead>
<TableHead className="text-right">Send test tx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wallets.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center">
No server wallets found
</TableCell>
</TableRow>
) : (
wallets.map((wallet) => (
<TableRow key={wallet.id} className="hover:bg-accent/50">
<TableCell>
{showSigners ? (
<WalletAddress address={wallet.address} />
) : (
<SmartAccountCell wallet={wallet} />
)}
</TableCell>
<TableCell>{wallet.metadata.label || "none"}</TableCell>
<TableCell className="text-right">
<WalletDateCell date={wallet.createdAt} />
</TableCell>
<TableCell className="text-right">
<WalletDateCell date={wallet.updatedAt} />
</TableCell>
<TableCell className="flex justify-end">
<SendTestTransaction
wallet={wallet}
project={project}
teamSlug={teamSlug}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
export function SmartAccountCell({ wallet }: { wallet: Wallet }) {
const chainId = 1; // TODO: add chain switcher for balance + smart account address
const chain = useV5DashboardChain(chainId);
const smartAccountAddressQuery = useQuery({
queryKey: ["smart-account-address", wallet.address],
queryFn: async () => {
const smartAccountAddress = await predictSmartAccountAddress({
client: getThirdwebClient(undefined),
adminAddress: wallet.address,
chain,
factoryAddress: DEFAULT_ACCOUNT_FACTORY_V0_7,
});
return smartAccountAddress;
},
});
return (
<div>
{smartAccountAddressQuery.data ? (
<div className="flex items-center gap-2">
<WalletAddress address={smartAccountAddressQuery.data} />
<Badge variant={"default"}>Smart Account</Badge>
</div>
) : (
<Skeleton className="h-4 w-20" />
)}
</div>
);
}
function WalletDateCell({ date }: { date: string }) {
if (!date) {
return "N/A";
}
const dateObj = new Date(date);
return (
<ToolTipLabel label={format(dateObj, "PP pp z")}>
<p>{formatDistanceToNowStrict(dateObj, { addSuffix: true })}</p>
</ToolTipLabel>
);
}
function SendTestTransaction(props: {
wallet: Wallet;
teamSlug: string;
project: Project;
}) {
const router = useDashboardRouter();
return (
<Button
variant="secondary"
size="sm"
onClick={() => {
router.push(
`/team/${props.teamSlug}/${props.project.slug}/engine/cloud?testTxWithWallet=${props.wallet.id}`,
);
}}
>
<SendIcon className="size-4" />
</Button>
);
}
@@ -0,0 +1,24 @@
import type { Project } from "@/api/projects";
import type { Wallet } from "./types";
import { ServerWalletsTableUI } from "./wallet-table-ui.client";
export function ServerWalletsTable({
wallets,
project,
teamSlug,
managementAccessToken,
}: {
wallets: Wallet[];
project: Project;
teamSlug: string;
managementAccessToken: string | undefined;
}) {
return (
<ServerWalletsTableUI
wallets={wallets}
project={project}
teamSlug={teamSlug}
managementAccessToken={managementAccessToken}
/>
);
}
@@ -0,0 +1,25 @@
import { ChevronLeftIcon } from "lucide-react";
import Link from "next/link";
export default function TransactionLayout({
children,
params,
}: {
children: React.ReactNode;
params: { team_slug: string; project_slug: string };
}) {
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Link
href={`/team/${params.team_slug}/${params.project_slug}/engine/cloud`}
className="flex items-center gap-1 text-muted-foreground text-sm hover:text-foreground"
>
<ChevronLeftIcon className="size-4" />
Back to Transactions
</Link>
</div>
<div className="flex-1">{children}</div>
</div>
);
}
@@ -0,0 +1,38 @@
import { getProject } from "@/api/projects";
import { notFound, redirect } from "next/navigation";
import { getSingleTransaction } from "../../lib/analytics";
import { TransactionDetailsUI } from "./transaction-details-ui";
export default async function TransactionPage({
params,
}: {
params: Promise<{ team_slug: string; project_slug: string; id: string }>;
}) {
const { team_slug, project_slug, id } = await params;
const project = await getProject(team_slug, project_slug);
if (!project) {
redirect(`/team/${team_slug}`);
}
const transactionData = await getSingleTransaction({
teamId: project.teamId,
clientId: project.publishableKey,
transactionId: id,
});
if (!transactionData) {
notFound();
}
return (
<div className="space-y-6 p-2">
<TransactionDetailsUI
transaction={transactionData}
teamSlug={team_slug}
project={project}
/>
</div>
);
}
@@ -0,0 +1,334 @@
"use client";
import type { Project } from "@/api/projects";
import { WalletAddress } from "@/components/blocks/wallet-address";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CodeClient } from "@/components/ui/code/code.client";
import { ToolTipLabel } from "@/components/ui/tooltip";
import { useThirdwebClient } from "@/constants/thirdweb.client";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useAllChainsData } from "hooks/chains/allChains";
import { ExternalLinkIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { toEther } from "thirdweb";
import { ChainIconClient } from "../../../../../../../../../components/icons/ChainIcon";
import { statusDetails } from "../../analytics/tx-table/tx-table-ui";
import type { Transaction } from "../../analytics/tx-table/types";
export function TransactionDetailsUI({
transaction,
}: {
transaction: Transaction;
teamSlug: string;
project: Project;
}) {
const thirdwebClient = useThirdwebClient();
const { idToChain } = useAllChainsData();
// Extract relevant data from transaction
const {
id,
chainId,
from,
transactionHash,
confirmedAt,
createdAt,
errorMessage,
executionParams,
executionResult,
} = transaction;
const status = executionResult?.status as keyof typeof statusDetails;
const chain = chainId ? idToChain.get(Number.parseInt(chainId)) : undefined;
const explorer = chain?.explorers?.[0];
// Calculate time difference between creation and confirmation
const confirmationTime =
confirmedAt && createdAt
? new Date(confirmedAt).getTime() - new Date(createdAt).getTime()
: null;
// Determine sender and signer addresses
const senderAddress = executionParams?.smartAccountAddress || from || "";
const signerAddress = executionParams?.signerAddress || from || "";
// Gas information
const gasUsed =
executionResult && "actualGasUsed" in executionResult
? `${executionResult.actualGasUsed}`
: "N/A";
const gasCost =
executionResult && "actualGasCost" in executionResult
? `${toEther(BigInt(executionResult.actualGasCost || "0"))} ${chain?.nativeCurrency.symbol || ""}`
: "N/A";
return (
<>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h1 className="font-semibold text-2xl tracking-tight">
Transaction Details
</h1>
</div>
<div className="flex flex-col gap-6">
{/* Transaction Info Card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Transaction Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Status
</div>
<div className="md:w-2/3">
{status && (
<ToolTipLabel
hoverable
label={
errorMessage ||
(status === "CONFIRMED" && confirmedAt
? `Completed ${format(new Date(confirmedAt), "PP pp")}`
: undefined)
}
>
<Badge
variant={statusDetails[status].type}
className="gap-2"
>
{statusDetails[status].name}
{errorMessage && <InfoIcon className="size-3" />}
</Badge>
</ToolTipLabel>
)}
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Transaction ID
</div>
<div className="md:w-2/3">
<CopyTextButton
textToCopy={id}
copyIconPosition="left"
textToShow={`${id.slice(0, 8)}...${id.slice(-6)}`}
variant="ghost"
tooltip="Copy transaction ID"
className="font-mono text-muted-foreground text-sm"
/>
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Transaction Hash
</div>
<div className="md:w-2/3">
{transactionHash ? (
<div>
{explorer ? (
<Button
variant="ghost"
asChild
className="-translate-x-2 gap-2 font-mono"
size="sm"
>
<Link
href={`${explorer.url}/tx/${transactionHash}`}
target="_blank"
onClick={(e) => {
e.stopPropagation();
}}
>
{`${transactionHash.slice(0, 8)}...${transactionHash.slice(-6)}`}{" "}
<ExternalLinkIcon className="size-4 text-muted-foreground" />
</Link>
</Button>
) : (
<CopyTextButton
textToCopy={transactionHash}
copyIconPosition="left"
textToShow={`${transactionHash.slice(0, 6)}...${transactionHash.slice(-4)}`}
variant="ghost"
tooltip="Copy transaction hash"
className="font-mono text-muted-foreground text-sm"
/>
)}
</div>
) : (
<div className="text-sm">Not available yet</div>
)}
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Network
</div>
<div className="md:w-2/3">
{chain ? (
<div className="flex items-center gap-2">
<ChainIconClient
className="size-4"
client={thirdwebClient}
src={chain.icon?.url}
/>
<span className="text-sm">{chain.name}</span>
</div>
) : (
<div>Chain ID: {chainId || "Unknown"}</div>
)}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Sender Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Sender Address
</div>
<div className="md:w-2/3">
<WalletAddress address={senderAddress} />
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Signer Address
</div>
<div className="md:w-2/3">
<WalletAddress address={signerAddress} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Transaction Parameters</CardTitle>
</CardHeader>
<CardContent>
{transaction.transactionParams &&
transaction.transactionParams.length > 0 ? (
<CodeClient
code={JSON.stringify(transaction.transactionParams, null, 2)}
lang="json"
/>
) : (
<p className="text-muted-foreground text-sm">
No transaction parameters available
</p>
)}
</CardContent>
</Card>
{errorMessage && (
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive text-lg">
Error Details
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
{errorMessage}
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-lg">Timing Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Created At
</div>
<div className="md:w-2/3">
{createdAt ? (
<p className="text-sm">
{formatDistanceToNowStrict(new Date(createdAt), {
addSuffix: true,
})}{" "}
({format(new Date(createdAt), "PP pp z")})
</p>
) : (
"N/A"
)}
</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Confirmed At
</div>
<div className="text-sm md:w-2/3">
{confirmedAt ? (
<p>
{formatDistanceToNowStrict(new Date(confirmedAt), {
addSuffix: true,
})}{" "}
({format(new Date(confirmedAt), "PP pp z")})
</p>
) : (
"Pending"
)}
</div>
</div>
{confirmationTime && (
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Confirmation Time
</div>
<div className="text-sm md:w-2/3">
{Math.floor(confirmationTime / 1000)} seconds
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Gas Information</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Gas Used
</div>
<div className="text-sm md:w-2/3">{gasUsed}</div>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Gas Cost
</div>
<div className="text-sm md:w-2/3">{gasCost}</div>
</div>
{transaction.confirmedAtBlockNumber && (
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0">
<div className="w-full text-muted-foreground text-sm md:w-1/3">
Block Number
</div>
<div className="text-sm md:w-2/3">
{transaction.confirmedAtBlockNumber}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</>
);
}
@@ -0,0 +1,285 @@
"use client";
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import { createServiceAccount } from "@thirdweb-dev/vault-sdk";
import { CheckIcon, DownloadIcon, Loader2Icon, LockIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
import {
createManagementAccessToken,
createWalletAccessToken,
initVaultClient,
maskSecret,
} from "../../lib/vault.client";
export default function CreateVaultAccountButton(props: {
project: Project;
onUserAccessTokenCreated?: (userAccessToken: string) => void;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const initialiseProjectWithVaultMutation = useMutation({
mutationFn: async () => {
setModalOpen(true);
trackEvent({
category: "engine-cloud",
action: "create_vault_account",
});
const vaultClient = await initVaultClient();
const serviceAccount = await createServiceAccount({
client: vaultClient,
request: {
options: {
metadata: {
projectId: props.project.id,
teamId: props.project.teamId,
purpose: "Thirdweb Project Server Wallet Service Account",
},
},
},
});
if (!serviceAccount.success) {
throw new Error("Failed to create service account");
}
const managementAccessTokenPromise = createManagementAccessToken({
project: props.project,
adminKey: serviceAccount.data.adminKey,
rotationCode: serviceAccount.data.rotationCode,
vaultClient,
});
const userAccesTokenPromise = createWalletAccessToken({
project: props.project,
adminKey: serviceAccount.data.adminKey,
vaultClient,
});
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
userAccesTokenPromise,
managementAccessTokenPromise,
]);
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
throw new Error("Failed to create access token");
}
props.onUserAccessTokenCreated?.(userAccessTokenRes.data.accessToken);
return {
serviceAccount: serviceAccount.data,
userAccessToken: userAccessTokenRes.data,
};
},
onError: (error) => {
toast.error(error.message);
setModalOpen(false);
},
});
const handleCreateServerWallet = async () => {
await initialiseProjectWithVaultMutation.mutateAsync();
};
const handleDownloadKeys = () => {
if (!initialiseProjectWithVaultMutation.data) {
return;
}
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${initialiseProjectWithVaultMutation.data.serviceAccount.adminKey}\n\nVault Access Token:\n${initialiseProjectWithVaultMutation.data.userAccessToken.accessToken}\n`;
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = `${props.project.name}-vault-keys.txt`;
link.href = url;
link.download = filename;
document.body.appendChild(link); // Required for Firefox
link.click();
// Clean up
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Keys downloaded as ${filename}`);
setKeysDownloaded(true);
};
const handleCloseModal = () => {
if (!keysConfirmed) {
return;
}
setModalOpen(false);
setKeysConfirmed(false);
setKeysDownloaded(false);
initialiseProjectWithVaultMutation.reset();
// invalidate the page to force a reload
router.refresh();
};
const isLoading = initialiseProjectWithVaultMutation.isPending;
return (
<>
<Button
variant={"primary"}
onClick={handleCreateServerWallet}
disabled={isLoading}
className="flex flex-row items-center gap-2"
>
{isLoading ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<LockIcon className="size-4" />
)}
{"Create Vault Admin Account"}
</Button>
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
<DialogContent
className="overflow-hidden p-0"
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
>
{initialiseProjectWithVaultMutation.isPending ? (
<>
<DialogHeader className="p-6">
<DialogTitle>Generating your Vault management keys</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center gap-4 p-10">
<Spinner className="size-8" />
<p className="text-muted-foreground text-xs">
This may take a few seconds.
</p>
</div>
</>
) : initialiseProjectWithVaultMutation.data ? (
<div>
<DialogHeader className="p-6">
<DialogTitle>Save your Vault Admin Key</DialogTitle>
<p className="text-muted-foreground text-sm">
You'll need this key to create server wallets and access
tokens.
</p>
</DialogHeader>
<div className="space-y-6 p-6 pt-0">
<div className="space-y-4">
<div>
<div className="flex flex-col gap-2">
<CopyTextButton
textToCopy={
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data.serviceAccount
.adminKey,
)}
copyIconPosition="right"
tooltip="Copy Admin Key"
/>
<p className="text-muted-foreground text-xs">
Download this key to your local machine or a password
manager.
</p>
</div>
</div>
{/* <div>
<h3 className="mb-2 font-medium text-sm">
Vault Access Token
</h3>
<div className="flex flex-col gap-2 ">
<CopyTextButton
textToCopy={
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
initialiseProjectWithVaultMutation.data
.userAccessToken.accessToken,
)}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This access token is used to sign transactions and
messages from your backend. Can be revoked and recreated
with your admin key.
</p>
</div>
</div> */}
</div>
<Alert variant="destructive">
<AlertTitle>Secure your admin key</AlertTitle>
<AlertDescription>
These keys will not be displayed again. Store them securely
as they provide access to your server wallets.
</AlertDescription>
<div className="h-4" />
<div className="flex items-center gap-2">
<Button
variant="link"
onClick={handleDownloadKeys}
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
>
<DownloadIcon className="size-4" />
{keysDownloaded ? "Key Downloaded" : "Download Admin Key"}
</Button>
{keysDownloaded && (
<span className="text-success-text text-xs">
<CheckIcon className="size-4" />
</span>
)}
</div>
<div className="h-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={keysConfirmed}
onCheckedChange={(v) => setKeysConfirmed(!!v)}
/>
I confirm that I've securely stored my admin key
</CheckboxWithLabel>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
onClick={handleCloseModal}
disabled={!keysConfirmed}
variant={"primary"}
>
Close
</Button>
</div>
</div>
) : null}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,104 @@
import type { Project } from "@/api/projects";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { InfoIcon } from "lucide-react";
import Link from "next/link";
import CreateVaultAccountButton from "./create-vault-account.client";
import ListAccessTokens from "./list-access-tokens.client";
import RotateAdminKeyButton from "./rotate-admin-key.client";
export function KeyManagement({
maskedAdminKey,
project,
}: { maskedAdminKey?: string; project: Project }) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col px-6 pt-6">
<h2 className="font-semibold text-xl tracking-tight">Vault</h2>
<p className="text-muted-foreground text-sm">
Secure, non-custodial key management system for your server wallets.{" "}
<Link
href="https://portal.thirdweb.com/vault"
className="underline"
>
Learn more.
</Link>
</p>
</div>
{!maskedAdminKey ? (
<CreateVaultAccountAlert project={project} />
) : (
<div />
)}
</div>
{maskedAdminKey ? (
<>
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col p-6">
<h2 className="font-semibold text-xl tracking-tight">
Admin Key
</h2>
<p className="text-muted-foreground text-sm">
This key is used to create new server wallets and access tokens.
<br /> We do not store this key. If you lose it, you can rotate
it to create a new one. Doing so will invalidate all existing
access tokens.
</p>
<div className="h-4" />
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex min-w-[300px] flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground text-sm">
{maskedAdminKey}
</p>
</div>
<RotateAdminKeyButton project={project} />
</div>
</div>
</div>
<ListAccessTokens project={project} />
</>
) : null}
</div>
);
}
async function CreateVaultAccountAlert(props: {
project: Project;
}) {
return (
<div className="flex flex-col gap-6">
<div className="px-6">
<Alert variant="info" className="bg-background">
<AlertTitle className="flex items-center gap-2">
<InfoIcon className="h-4 w-4" />
What is Vault?
</AlertTitle>
<AlertDescription className="text-primary-foreground text-sm">
Vault is thirdweb's non-custodial key management system for your
server wallets that allows you to:
<ul className="list-disc py-2 pl-4">
<li>Create multiple server wallets.</li>
<li>Create Vault access tokens.</li>
<li>Sign transactions using a Vault access token.</li>
</ul>
Your keys are stored in a hardware enclave, and all requests are
end-to-end encrypted.{" "}
<Link
href="https://portal.thirdweb.com/vault"
className="underline"
>
Learn more about Vault security model.
</Link>
<div className="h-2" />
Creating server wallets and access tokens requires a Vault admin
account. Create one below to get started.
</AlertDescription>
<div className="h-4" />
</Alert>
</div>
<div className="flex flex-row justify-end gap-4 border-border border-t px-6 pt-4 pb-4">
<CreateVaultAccountButton project={props.project} />
</div>
</div>
);
}
@@ -0,0 +1,361 @@
"use client";
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import {} from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {} from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { listAccessTokens, revokeAccessToken } from "@thirdweb-dev/vault-sdk";
import { Loader2Icon, LockIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { toDateTimeLocal } from "utils/date-utils";
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
import {
SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE,
createWalletAccessToken,
initVaultClient,
} from "../../lib/vault.client";
export default function ListAccessTokens(props: {
project: Project;
}) {
const [modalOpen, setModalOpen] = useState(false);
const [typedAdminKey, setTypedAdminKey] = useState("");
const [adminKey, setAdminKey] = useState("");
const [deletingTokenId, setDeletingTokenId] = useState<string | null>(null);
const queryClient = useQueryClient();
const trackEvent = useTrack();
// TODO allow passing permissions to the access token
const createAccessTokenMutation = useMutation({
mutationFn: async (args: { adminKey: string }) => {
const vaultClient = await initVaultClient();
trackEvent({
category: "engine-cloud",
action: "create_access_token",
});
const userAccessTokenRes = await createWalletAccessToken({
project: props.project,
adminKey: args.adminKey,
vaultClient,
});
if (!userAccessTokenRes.success) {
throw new Error(
`Failed to create access token: ${userAccessTokenRes.error.message}`,
);
}
return {
userAccessToken: userAccessTokenRes.data,
};
},
onError: (error) => {
toast.error(error.message);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["list-access-tokens"],
});
},
});
const revokeAccessTokenMutation = useMutation({
mutationFn: async (args: { adminKey: string; accessTokenId: string }) => {
setDeletingTokenId(args.accessTokenId);
const vaultClient = await initVaultClient();
trackEvent({
category: "engine-cloud",
action: "revoke_access_token",
});
const revokeAccessTokenRes = await revokeAccessToken({
client: vaultClient,
request: {
options: {
id: args.accessTokenId,
},
auth: {
adminKey: args.adminKey,
},
},
});
if (!revokeAccessTokenRes.success) {
throw new Error(
`Failed to revoke access token: ${revokeAccessTokenRes.error.message}`,
);
}
return {
success: true,
};
},
onError: (error) => {
toast.error(error.message);
setDeletingTokenId(null);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["list-access-tokens"],
});
setDeletingTokenId(null);
},
});
const managementAccessToken = props.project.services.find(
(s) => s.name === "engineCloud",
)?.managementAccessToken;
const listAccessTokensQuery = useQuery({
queryKey: [
"list-access-tokens",
maskSecret(managementAccessToken || ""),
maskSecret(adminKey),
],
queryFn: async () => {
if (!managementAccessToken) {
throw new Error("Management access token not found");
}
const vaultClient = await initVaultClient();
const listResult = await listAccessTokens({
client: vaultClient,
request: {
auth: adminKey
? {
adminKey,
}
: {
accessToken: managementAccessToken,
},
options: {},
},
});
if (!listResult.success) {
throw new Error(
`Failed to list access tokens: ${listResult.error.message}`,
);
}
return {
accessTokens: listResult.data.items
.filter(
(t) =>
t.metadata?.purpose?.toString() !==
SERVER_WALLET_MANAGEMENT_ACCESS_TOKEN_PURPOSE,
)
.filter((t) => !t.revokedAt && !t.isRotated),
};
// Return stub data for now
},
enabled: !!managementAccessToken,
});
const handleCloseModal = () => {
setModalOpen(false);
};
return (
<div className="flex flex-col gap-6 overflow-hidden rounded-lg border border-border bg-card">
<div className="flex flex-col px-6 pt-6">
<div className="flex flex-row items-center justify-between">
<div className="flex flex-col">
<h2 className="font-semibold text-xl tracking-tight">
Access Tokens
</h2>
<p className="text-muted-foreground text-sm">
Access tokens let you sign transactions using any of your server
wallets.
</p>
</div>
<Button
onClick={() => {
if (!adminKey) {
setModalOpen(true);
} else {
setAdminKey("");
setTypedAdminKey("");
queryClient.invalidateQueries({
queryKey: ["list-access-tokens"],
});
}
}}
variant={"primary"}
className="flex flex-row items-center gap-2"
>
<LockIcon className="h-4 w-4" />{" "}
{adminKey ? "Lock Vault" : "Unlock Vault"}
</Button>
</div>
<div className="h-4" />
{listAccessTokensQuery.isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 p-10">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : listAccessTokensQuery.error ? (
<div className="flex flex-col gap-4">
<p className="text-destructive-text text-sm">
Failed to list access tokens. Check your admin key and try again.
</p>
</div>
) : listAccessTokensQuery.data ? (
<div>
<div className="space-y-6 pt-0">
<div className="space-y-4">
<div>
<div className="flex flex-col gap-2">
{listAccessTokensQuery.data.accessTokens.map((token) => (
<div key={token.id} className="flex gap-2">
<div className="flex max-w-full flex-1 flex-col justify-between gap-4 rounded-lg border border-border bg-background p-4 text-xs">
<h4 className="font-bold">
{token.metadata?.purpose || "Unnamed Access Token"}
</h4>
<div className="flex flex-row items-center gap-2">
{token.accessToken.includes("**") ? (
<div className="flex flex-grow flex-row items-center gap-2 rounded-lg border border-border bg-background px-4 py-3 font-mono text-sm">
<p className="text-muted-foreground text-sm">
{token.accessToken}{" "}
<span className="text-muted-foreground text-xs">
(unlock vault to reveal the full token)
</span>
</p>
</div>
) : (
<CopyTextButton
textToCopy={token.accessToken}
className="!h-auto min-w-0 flex-grow justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={token.accessToken}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
)}
<Button
variant="destructive"
size="sm"
className="px-3 py-3"
onClick={() =>
revokeAccessTokenMutation.mutate({
adminKey,
accessTokenId: token.id,
})
}
disabled={
deletingTokenId !== null ||
revokeAccessTokenMutation.isPending ||
!adminKey
}
>
{deletingTokenId === token.id ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<Trash2Icon className="size-4" />
)}
</Button>
</div>
{/* TODO (cloud): show policies and let you edit them */}
<p className="text-muted-foreground text-xs">
Created on:{" "}
{toDateTimeLocal(new Date(token.createdAt))}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
No access tokens found.
</p>
</div>
)}
<div className="flex justify-end gap-3 bg-card py-4">
<Button
onClick={() => createAccessTokenMutation.mutate({ adminKey })}
disabled={createAccessTokenMutation.isPending || !adminKey}
variant={"primary"}
>
{createAccessTokenMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Creating new access token...
</>
) : (
"Create new access token"
)}
</Button>
</div>
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
<DialogContent className="overflow-hidden p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle>Unlock Vault</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 px-6">
<p className="flex items-center gap-2 text-sm text-warning-text">
<LockIcon className="h-4 w-4" /> This action requires your
Vault admin key.
</p>
<Input
type="password"
placeholder="sa_adm_ABCD_1234..."
value={typedAdminKey}
onChange={(e) => setTypedAdminKey(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setAdminKey(typedAdminKey);
}
}}
/>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
variant={"outline"}
onClick={() => {
setAdminKey("");
setTypedAdminKey("");
setModalOpen(false);
}}
>
Cancel
</Button>
<Button
variant={"primary"}
onClick={() => {
setAdminKey(typedAdminKey);
handleCloseModal();
}}
disabled={!typedAdminKey || listAccessTokensQuery.isLoading}
>
Unlock Vault
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</div>
);
}
function maskSecret(secret: string) {
return `${secret.substring(0, 10)}...${secret.substring(secret.length - 10)}`;
}
@@ -0,0 +1,327 @@
"use client";
import type { Project } from "@/api/projects";
import { CopyTextButton } from "@/components/ui/CopyTextButton";
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useDashboardRouter } from "@/lib/DashboardRouter";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
import { rotateServiceAccount } from "@thirdweb-dev/vault-sdk";
import {
CheckIcon,
CircleAlertIcon,
DownloadIcon,
Loader2Icon,
RefreshCcwIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useTrack } from "../../../../../../../../../hooks/analytics/useTrack";
import {
createManagementAccessToken,
createWalletAccessToken,
initVaultClient,
maskSecret,
} from "../../lib/vault.client";
export default function RotateAdminKeyButton(props: { project: Project }) {
const [modalOpen, setModalOpen] = useState(false);
const [keysConfirmed, setKeysConfirmed] = useState(false);
const [keysDownloaded, setKeysDownloaded] = useState(false);
const router = useDashboardRouter();
const trackEvent = useTrack();
const rotateAdminKeyMutation = useMutation({
mutationFn: async () => {
trackEvent({
category: "engine-cloud",
action: "rotate_admin_key",
});
const vaultClient = await initVaultClient();
const rotationCode = props.project.services.find(
(service) => service.name === "engineCloud",
)?.rotationCode;
if (!rotationCode) {
throw new Error("Rotation code not found");
}
const rotateServiceAccountRes = await rotateServiceAccount({
client: vaultClient,
request: {
auth: {
rotationCode,
},
},
});
if (rotateServiceAccountRes.error) {
throw new Error(rotateServiceAccountRes.error.message);
}
// need to recreate the management access token with the new admin key
const managementAccessTokenPromise = createManagementAccessToken({
project: props.project,
adminKey: rotateServiceAccountRes.data.newAdminKey,
rotationCode: rotateServiceAccountRes.data.newRotationCode,
vaultClient,
});
const userAccesTokenPromise = createWalletAccessToken({
project: props.project,
adminKey: rotateServiceAccountRes.data.newAdminKey,
vaultClient,
});
const [userAccessTokenRes, managementAccessTokenRes] = await Promise.all([
userAccesTokenPromise,
managementAccessTokenPromise,
]);
if (!managementAccessTokenRes.success || !userAccessTokenRes.success) {
throw new Error("Failed to create access token");
}
return {
success: true,
adminKey: rotateServiceAccountRes.data.newAdminKey,
userAccessToken: userAccessTokenRes.data,
};
},
onError: (error) => {
toast.error(error.message);
},
});
const handleDownloadKeys = () => {
if (!rotateAdminKeyMutation.data) {
return;
}
const fileContent = `Project:\n${props.project.name} (${props.project.publishableKey})\n\nVault Admin Key:\n${rotateAdminKeyMutation.data.adminKey}\n\nVault Access Token:\n${rotateAdminKeyMutation.data.userAccessToken.accessToken}\n`;
const blob = new Blob([fileContent], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = `${props.project.name}-vault-keys-rotated.txt`;
link.href = url;
link.download = filename;
document.body.appendChild(link); // Required for Firefox
link.click();
// Clean up
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(`Keys downloaded as ${filename}`);
setKeysDownloaded(true);
};
const handleCloseModal = () => {
if (!keysConfirmed) {
return;
}
setModalOpen(false);
setKeysConfirmed(false);
setKeysDownloaded(false);
// invalidate the page to force a reload
rotateAdminKeyMutation.reset();
router.refresh();
};
const isLoading = rotateAdminKeyMutation.isPending;
return (
<>
<Button
variant="outline"
onClick={() => setModalOpen(true)}
disabled={isLoading}
className="h-auto gap-2 rounded-lg bg-background px-4 py-3"
>
{isLoading && <Loader2Icon className="size-4 animate-spin" />}
{!isLoading && <RefreshCcwIcon className="size-4" />}
Rotate Admin Key
</Button>
<Dialog open={modalOpen} onOpenChange={handleCloseModal} modal={true}>
<DialogContent
className="overflow-hidden p-0"
dialogCloseClassName={cn(!keysConfirmed && "hidden")}
>
{rotateAdminKeyMutation.isPending ? (
<>
<DialogHeader className="p-6">
<DialogTitle>Generating new keys...</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center gap-4 p-10">
<Spinner className="size-8" />
<p className="text-muted-foreground text-xs">
This may take a few seconds.
</p>
</div>
</>
) : rotateAdminKeyMutation.data ? (
<div>
<DialogHeader className="p-6">
<DialogTitle>New Vault Keys</DialogTitle>
</DialogHeader>
<div className="space-y-6 p-6 pt-0">
<div className="space-y-4">
<div>
<h3 className="mb-2 font-medium text-sm">
New Vault Admin Key
</h3>
<div className="flex flex-col gap-2">
<CopyTextButton
textToCopy={rotateAdminKeyMutation.data.adminKey}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
rotateAdminKeyMutation.data.adminKey,
)}
copyIconPosition="right"
tooltip="Copy Admin Key"
/>
<p className="text-muted-foreground text-xs">
This key is used to create or revoke your access tokens.
</p>
</div>
</div>
<div>
<h3 className="mb-2 font-medium text-sm">
New Vault Access Token
</h3>
<div className="flex flex-col gap-2 ">
<CopyTextButton
textToCopy={
rotateAdminKeyMutation.data.userAccessToken
.accessToken
}
className="!h-auto w-full justify-between bg-background px-3 py-3 font-mono text-xs"
textToShow={maskSecret(
rotateAdminKeyMutation.data.userAccessToken
.accessToken,
)}
copyIconPosition="right"
tooltip="Copy Vault Access Token"
/>
<p className="text-muted-foreground text-xs">
This access token is used to sign transactions and
messages from your backend. Can be revoked and recreated
with your admin key.
</p>
</div>
</div>
</div>
<Alert variant="destructive">
<AlertTitle>Secure your keys</AlertTitle>
<AlertDescription>
These keys will not be displayed again. Store them securely
as they provide access to your server wallets.
</AlertDescription>
<div className="h-4" />
<div className="flex items-center gap-2">
<Button
variant="link"
onClick={handleDownloadKeys}
className="flex h-auto items-center gap-2 p-0 text-sm text-success-text"
>
<DownloadIcon className="size-4" />
{keysDownloaded ? "Keys Downloaded" : "Download Keys"}
</Button>
{keysDownloaded && (
<span className="text-success-text text-xs">
<CheckIcon className="size-4" />
</span>
)}
</div>
<div className="h-4" />
<CheckboxWithLabel className="text-foreground">
<Checkbox
checked={keysConfirmed}
onCheckedChange={(v) => setKeysConfirmed(!!v)}
/>
I confirm that I've securely stored these keys
</CheckboxWithLabel>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
onClick={handleCloseModal}
disabled={!keysConfirmed}
variant="primary"
>
Close
</Button>
</div>
</div>
) : (
<>
<DialogHeader className="px-6 pt-6">
<DialogTitle>Rotate your Vault admin key</DialogTitle>
<DialogDescription>
This action will generate a new Vault admin key and rotation
code.{" "}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 px-6">
<p className="text-md text-primary-foreground">
Revoke your current keys and generates new ones.
</p>
<Alert variant="destructive">
<CircleAlertIcon className="size-5" />
<AlertTitle>Important</AlertTitle>
<AlertDescription>
This action will invalidate your current admin key and all
existing access tokens. You will need to update your
backend to use these new access tokens.
</AlertDescription>
</Alert>
</div>
<div className="flex justify-end gap-3 border-t bg-card px-6 py-4">
<Button
variant="outline"
onClick={() => {
setModalOpen(false);
}}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => rotateAdminKeyMutation.mutate()}
disabled={rotateAdminKeyMutation.isPending}
>
{rotateAdminKeyMutation.isPending ? (
<>
<Loader2Icon className="mr-2 size-4 animate-spin" />
Rotating...
</>
) : (
"Rotate Admin Key"
)}
</Button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,33 @@
import { getProject } from "@/api/projects";
import { notFound } from "next/navigation";
import { getAuthToken } from "../../../../../../api/lib/getAuthToken";
import { KeyManagement } from "./components/key-management";
export default async function VaultPage(props: {
params: Promise<{ team_slug: string; project_slug: string }>;
}) {
const { team_slug, project_slug } = await props.params;
const [authToken, project] = await Promise.all([
getAuthToken(),
getProject(team_slug, project_slug),
]);
if (!project || !authToken) {
notFound();
}
const projectEngineCloudService = project.services.find(
(service) => service.name === "engineCloud",
);
const maskedAdminKey = projectEngineCloudService?.maskedAdminKey;
return (
<div className="flex flex-col gap-8">
<KeyManagement
maskedAdminKey={maskedAdminKey ?? undefined}
project={project}
/>
</div>
);
}
@@ -6,6 +6,7 @@ const meta = {
component: EngineFooterCard,
args: {
team_slug: "demo-team",
project_slug: "demo-project",
},
decorators: [
(Story) => (
@@ -27,8 +27,8 @@ export function ImportEngineLink(props: {
);
}
function EngineInfoSection(props: { team_slug: string }) {
const engineLinkPrefix = `/team/${props.team_slug}/~/engine`;
function EngineInfoSection(props: { team_slug: string; project_slug: string }) {
const engineLinkPrefix = `/team/${props.team_slug}/${props.project_slug}/engine/dedicated`;
return (
<div className="">
@@ -84,7 +84,7 @@ function CloudHostedEngineSection(props: {
return (
<div className="flex flex-col">
<h3 className="mb-0.5 font-semibold text-lg tracking-tight">
Get Cloud Hosted Engine
Get Managed Engine
</h3>
{props.teamPlan !== "pro" ? (
@@ -98,7 +98,7 @@ function CloudHostedEngineSection(props: {
<UnderlineLink href="/pricing" target="_blank">
Scale
</UnderlineLink>{" "}
to get a cloud hosted Engine
to get a managed Engine instance
</p>
<div className="h-5" />
@@ -131,7 +131,7 @@ function CloudHostedEngineSection(props: {
) : (
<div>
<p className="mb-4 text-muted-foreground text-sm">
Contact us to get a cloud hosted engine for your team
Contact us to get a managed engine for your team
</p>
<Button variant="outline" size="sm" asChild className="gap-2">
<Link href={PRO_CONTACT_US_URL} target="_blank">
@@ -148,6 +148,7 @@ function CloudHostedEngineSection(props: {
export function EngineFooterCard(props: {
teamPlan: Team["billingPlan"];
team_slug: string;
project_slug: string;
}) {
return (
<div className="relative rounded-lg border p-6">
@@ -161,7 +162,10 @@ export function EngineFooterCard(props: {
</>
)}
<EngineInfoSection team_slug={props.team_slug} />
<EngineInfoSection
team_slug={props.team_slug}
project_slug={props.project_slug}
/>
</div>
);
}
@@ -55,6 +55,7 @@ async function importEngine({
export function EngineImportCard(props: {
prefillImportUrl: string | undefined;
teamSlug: string;
projectSlug: string;
}) {
const router = useDashboardRouter();
@@ -63,7 +64,9 @@ export function EngineImportCard(props: {
prefillImportUrl={props.prefillImportUrl}
importEngine={async (params) => {
await importEngine({ ...params, teamIdOrSlug: props.teamSlug });
router.push(`/team/${props.teamSlug}/~/engine`);
router.push(
`/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`,
);
}}
/>
);
@@ -1,7 +1,7 @@
import { EngineImportCard } from "./EngineImportPage";
export default async function Page(props: {
params: Promise<{ team_slug: string }>;
params: Promise<{ team_slug: string; project_slug: string }>;
searchParams: Promise<{
importUrl?: string | string[];
}>;
@@ -21,6 +21,7 @@ export default async function Page(props: {
<EngineImportCard
prefillImportUrl={importUrl ? decodeURIComponent(importUrl) : undefined}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
/>
</div>
);
@@ -0,0 +1,111 @@
import type { SidebarLink } from "@/components/blocks/Sidebar";
import { SidebarLayout } from "@/components/blocks/SidebarLayout";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DatabaseIcon } from "lucide-react";
import Link from "next/link";
import { EngineIcon } from "../../../../../../(dashboard)/(chain)/components/server/icons/EngineIcon";
import { ImportEngineLink } from "./_components";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
children: React.ReactNode;
}) {
const params = await props.params;
const linkPrefix = `/team/${params.team_slug}/${params.project_slug}/engine/dedicated`;
const sidebarLinks: SidebarLink[] = [
{
label: "Engine Instances",
href: `${linkPrefix}`,
exactMatch: true,
},
{
label: "Import Engine",
href: `${linkPrefix}/import`,
},
];
return (
<div className="container flex max-w-7xl grow flex-col">
{/* header */}
<header className="border-border border-b py-10">
<div className="container flex flex-col justify-between gap-4 lg:flex-row lg:items-center">
<div className="flex flex-col gap-2">
<h1 className="flex items-center gap-2 font-semibold text-3xl tracking-tight">
Engine{" "}
<Badge
variant="warning"
className="flex items-center gap-2 text-sm"
>
<DatabaseIcon className="size-4" /> Dedicated
</Badge>
</h1>
<p className="flex flex-col text-muted-foreground text-sm">
Manage your deployed Engine instances
</p>
</div>
<div className="flex items-center gap-3">
<ImportEngineLink
label="Import Engine"
engineLinkPrefix={linkPrefix}
/>
</div>
</div>
<div className="h-6" />
<div className="container">
<EngineLegacyBannerUI
teamSlug={params.team_slug}
projectSlug={params.project_slug}
/>
</div>
</header>
{/* sidebar layout */}
<SidebarLayout sidebarLinks={sidebarLinks}>
{props.children}
</SidebarLayout>
</div>
);
}
function EngineLegacyBannerUI(props: {
teamSlug: string;
projectSlug: string;
}) {
return (
<Alert variant="info">
<EngineIcon className="size-5" />
<AlertTitle>Engine Cloud (Beta)</AlertTitle>
<AlertDescription>
<div className="h-2" />
<p className="text-primary-foreground text-sm">
Try Engine Cloud (Beta) - now included for free in every thirdweb
project.
</p>
<div className="h-2" />
<ul className="list-disc pl-4 text-muted-foreground text-sm">
<li>No recurring monthly cost, pay-per-request model</li>
<li>Powered by Vault: our new TEE based key management system</li>
<li>Improved performance and simplified transaction API</li>
</ul>
<div className="h-4" />
<div className="flex justify-end gap-2">
<Link href={"https://portal.thirdweb.com/engine/v3"} target="_blank">
<Button variant="outline">Learn More</Button>
</Link>
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/cloud`}
>
<Button variant={"primary"} className="flex items-center gap-2">
<EngineIcon className="size-4" /> Try Engine Cloud
</Button>
</Link>
</div>
</AlertDescription>
</Alert>
);
}
@@ -83,6 +83,7 @@ type RemovedEngineFromDashboard = (
export function EngineInstancesTable(props: {
teamSlug: string;
projectSlug: string;
instances: EngineInstance[];
engineLinkPrefix: string;
teamPlan: Team["billingPlan"];
@@ -95,6 +96,7 @@ export function EngineInstancesTable(props: {
instances={props.instances}
engineLinkPrefix={props.engineLinkPrefix}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
deleteCloudHostedEngine={async (params) => {
await deleteCloudHostedEngine(params);
router.refresh();
@@ -119,6 +121,7 @@ export function EngineInstancesTableUI(props: {
removeEngineFromDashboard: RemovedEngineFromDashboard;
teamPlan: Team["billingPlan"];
teamSlug: string;
projectSlug: string;
}) {
return (
<div className="flex grow flex-col">
@@ -127,14 +130,19 @@ export function EngineInstancesTableUI(props: {
</h2>
{props.instances.length === 0 ? (
<EmptyEngineState teamPlan={props.teamPlan} teamSlug={props.teamSlug} />
<EmptyEngineState
teamPlan={props.teamPlan}
teamSlug={props.teamSlug}
projectSlug={props.projectSlug}
/>
) : (
<TableContainer>
<Table>
<TableHeader>
<TableRow>
<TableHead>Engine Instance</TableHead>
<TableHead>Actions</TableHead>
<TableHead className="w-full">Engine Instance</TableHead>
<TableHead className="w-[100px]">Version</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -177,7 +185,7 @@ function EngineInstanceRow(props: {
>
<TableCell>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<div className="flex-grow space-y-0.5">
<InstanceNameLink
instance={instance}
engineLinkPrefix={engineLinkPrefix}
@@ -191,6 +199,9 @@ function EngineInstanceRow(props: {
)}
</div>
</TableCell>
<TableCell>
<Badge variant="warning">v2</Badge>
</TableCell>
<TableCell className="relative z-10 w-[50px]">
<EngineActionsDropdown
instance={instance}
@@ -806,6 +817,7 @@ function DeleteEngineSubscriptionModalContent(props: {
function EmptyEngineState(props: {
teamPlan: Team["billingPlan"];
teamSlug: string;
projectSlug: string;
}) {
const [selectedTab, setSelectedTab] = useState<
"self-hosted" | "cloud-hosted"
@@ -834,7 +846,7 @@ function EmptyEngineState(props: {
className={cn(selectedTab === "cloud-hosted" && "bg-accent")}
onClick={() => setSelectedTab("cloud-hosted")}
>
Cloud-hosted
Managed
</Button>
<Button
@@ -866,7 +878,9 @@ function EmptyEngineState(props: {
size="sm"
asChild
>
<Link href={`/team/${props.teamSlug}/~/engine/import`}>
<Link
href={`/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated/import`}
>
Import self-hosted Engine
<ArrowRightIcon size={16} />
</Link>
@@ -878,21 +892,20 @@ function EmptyEngineState(props: {
{selectedTab === "cloud-hosted" && (
<div className="flex flex-col text-center">
<h3 className="mb-0.5 font-semibold text-base">
Cloud-Hosted Engine
Managed Engine
</h3>
<p className="text-muted-foreground text-sm">
{props.teamPlan !== "pro" ? (
<>
Upgrade your team plan to Accelerate or Scale to get a
fully managed cloud-hosted engine instance.{" "}
fully managed engine instance.{" "}
<UnderlineLink href="/pricing" target="_blank">
View pricing
</UnderlineLink>
</>
) : (
<>
Contact us to add a cloud-hosted engine instance to your
team
Contact us to add a managed engine instance to your team
</>
)}
</p>
@@ -5,22 +5,28 @@ import { EngineInstancesTable } from "./engine-instances-table";
export const EngineInstancesList = (props: {
team_slug: string;
project_slug: string;
instances: EngineInstance[];
teamPlan: Team["billingPlan"];
}) => {
const engineLinkPrefix = `/team/${props.team_slug}/~/engine`;
const engineLinkPrefix = `/team/${props.team_slug}/${props.project_slug}/engine/dedicated`;
return (
<div className="flex grow flex-col">
<EngineInstancesTable
teamSlug={props.team_slug}
projectSlug={props.project_slug}
instances={props.instances}
engineLinkPrefix={engineLinkPrefix}
teamPlan={props.teamPlan}
/>
<div className="h-40" />
<EngineFooterCard teamPlan={props.teamPlan} team_slug={props.team_slug} />
<EngineFooterCard
teamPlan={props.teamPlan}
team_slug={props.team_slug}
project_slug={props.project_slug}
/>
</div>
);
};
@@ -8,6 +8,7 @@ import { EngineInstancesList } from "./overview/engine-list";
export default async function Page(props: {
params: Promise<{
team_slug: string;
project_slug: string;
}>;
searchParams: Promise<{
importUrl?: string;
@@ -20,7 +21,7 @@ export default async function Page(props: {
if (searchParams.importUrl) {
redirect(
`/team/${params.team_slug}/~/engine/import?importUrl=${searchParams.importUrl}`,
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated/import?importUrl=${searchParams.importUrl}`,
);
}
@@ -30,7 +31,9 @@ export default async function Page(props: {
]);
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/engine`);
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated`,
);
}
if (!team) {
@@ -45,6 +48,7 @@ export default async function Page(props: {
return (
<EngineInstancesList
team_slug={params.team_slug}
project_slug={params.project_slug}
instances={res.data || []}
teamPlan={team.billingPlan}
/>
@@ -51,9 +51,10 @@ const sidebarLinkMeta: Array<{ pathId: string; label: string }> = [
export function EngineSidebarLayout(props: {
engineId: string;
teamSlug: string;
projectSlug: string;
children: React.ReactNode;
}) {
const rootPath = `/team/${props.teamSlug}/~/engine`;
const rootPath = `/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`;
const links: SidebarLink[] = sidebarLinkMeta.map((linkMeta) => {
return {
@@ -1,9 +1,9 @@
"use client";
import { GenericLoadingPage } from "@/components/blocks/skeletons/GenericLoadingPage";
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
import { useQuery } from "@tanstack/react-query";
import type React from "react";
import GenericLoadingPage from "../../../../ecosystem/loading";
import { getEngineAccessPermission } from "../../../_utils/getEngineAccessPermission";
import { EngineErrorPage } from "./EngineErrorPage";
@@ -13,10 +13,11 @@ export function EnsureEnginePermission(props: {
authToken: string;
children: React.ReactNode;
teamSlug: string;
projectSlug: string;
instance: EngineInstance;
}) {
const { instance } = props;
const rootPath = `/team/${props.teamSlug}/~/engine`;
const rootPath = `/team/${props.teamSlug}/${props.projectSlug}/engine/dedicated`;
const permissionQuery = useQuery({
queryKey: [
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return <EngineAdmins instanceUrl={instance.url} authToken={authToken} />;
@@ -239,7 +239,7 @@ function CreateAlertButton(props: {
<ToolTipLabel
label={
props.alertRules.length === 0
? "This feature is only available for cloud-hosted Engines running v2.0.10 or later."
? "This feature is only available for managed Engines running v2.0.10 or later."
: undefined
}
>
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
@@ -9,12 +9,14 @@ import { EngineSystem } from "./system";
interface EngineConfigurationProps {
instance: EngineInstance;
teamSlug: string;
projectSlug: string;
authToken: string;
}
export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
instance,
teamSlug,
projectSlug,
authToken,
}) => {
return (
@@ -22,6 +24,7 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
<EngineWalletConfig
instance={instance}
teamSlug={teamSlug}
projectSlug={projectSlug}
authToken={authToken}
/>
<EngineCorsConfig instanceUrl={instance.url} authToken={authToken} />
@@ -29,7 +32,11 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
instanceUrl={instance.url}
authToken={authToken}
/>
<EngineSystem instance={instance} teamIdOrSlug={teamSlug} />
<EngineSystem
instance={instance}
teamIdOrSlug={teamSlug}
projectSlug={projectSlug}
/>
</div>
);
};
@@ -23,12 +23,14 @@ import { LocalConfig } from "./local-config";
interface EngineWalletConfigProps {
instance: EngineInstance;
teamSlug: string;
projectSlug: string;
authToken: string;
}
export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
instance,
teamSlug,
projectSlug,
authToken,
}) => {
const { data: walletConfig } = useEngineWalletConfig({
@@ -72,7 +74,7 @@ export const EngineWalletConfig: React.FC<EngineWalletConfigProps> = ({
<p className="text-muted-foreground">
Create backend wallets on the{" "}
<Link
href={`/team/${teamSlug}/~/engine/${instance.id}`}
href={`/team/${teamSlug}/${projectSlug}/engine/dedicated/${instance.id}`}
className="text-link-foreground hover:text-foreground"
>
Overview
@@ -7,14 +7,20 @@ import {
interface EngineSystemProps {
instance: EngineInstance;
teamIdOrSlug: string;
projectSlug: string;
}
export const EngineSystem: React.FC<EngineSystemProps> = ({
instance,
teamIdOrSlug,
projectSlug,
}) => {
const healthQuery = useEngineSystemHealth(instance.url);
const metricsQuery = useEngineSystemMetrics(instance.id, teamIdOrSlug);
const metricsQuery = useEngineSystemMetrics(
instance.id,
teamIdOrSlug,
projectSlug,
);
if (!healthQuery.data) {
return null;
}
@@ -7,12 +7,14 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
<EngineConfiguration
instance={instance}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
authToken={authToken}
/>
);
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return (
@@ -7,6 +7,7 @@ export default async function Page(props: EngineInstancePageProps) {
const { instance, authToken } = await engineInstancePageHandler({
engineId: params.engineId,
teamSlug: params.team_slug,
projectSlug: params.project_slug,
});
return <EngineExplorer instanceUrl={instance.url} authToken={authToken} />;
@@ -20,6 +20,7 @@ import { EngineVersionBadge } from "./_components/version";
export default async function Layout(props: {
params: Promise<{
team_slug: string;
project_slug: string;
engineId: string;
}>;
children: React.ReactNode;
@@ -29,7 +30,9 @@ export default async function Layout(props: {
const authToken = await getAuthToken();
if (!authToken) {
loginRedirect(`/team/${params.team_slug}/~/engine/${params.engineId}`);
loginRedirect(
`/team/${params.team_slug}/${params.project_slug}/engine/dedicated/${params.engineId}`,
);
}
const instance = await getEngineInstance({
@@ -39,13 +42,14 @@ export default async function Layout(props: {
accountId: account.id,
});
const engineRootLayoutPath = `/team/${params.team_slug}/~/engine`;
const engineRootLayoutPath = `/team/${params.team_slug}/${params.project_slug}/engine/dedicated`;
if (!instance) {
return (
<EngineSidebarLayout
engineId={params.engineId}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
>
<EngineErrorPage rootPath={engineRootLayoutPath}>
Engine Instance Not Found
@@ -66,9 +70,11 @@ export default async function Layout(props: {
<EngineSidebarLayout
engineId={params.engineId}
teamSlug={params.team_slug}
projectSlug={params.project_slug}
>
<EnsureEnginePermission
teamSlug={params.team_slug}
projectSlug={params.project_slug}
accountId={account.id}
authToken={authToken}
engineId={params.engineId}

Some files were not shown because too many files have changed in this diff Show More