diff --git a/frontend/src/app/actor-builds-list.tsx b/frontend/src/app/actor-builds-list.tsx index 1b90d6abd9..ce71f53f52 100644 --- a/frontend/src/app/actor-builds-list.tsx +++ b/frontend/src/app/actor-builds-list.tsx @@ -2,10 +2,10 @@ import * as allIcons from "@rivet-gg/icons"; import { faActorsBorderless, Icon, type IconProp } from "@rivet-gg/icons"; import { useInfiniteQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; -import { Fragment, useMemo } from "react"; +import { Fragment } from "react"; import { match } from "ts-pattern"; import { Button, cn, Skeleton } from "@/components"; -import { useDataProvider } from "@/components/actors"; +import { useEngineCompatDataProvider } from "@/components/actors"; import { VisibilitySensor } from "@/components/visibility-sensor"; import { RECORDS_PER_PAGE } from "./data-providers/default-data-provider"; @@ -53,7 +53,7 @@ function getActorIcon(iconValue: string | null) { export function ActorBuildsList() { const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = - useInfiniteQuery(useDataProvider().buildsQueryOptions()); + useInfiniteQuery(useEngineCompatDataProvider().buildsQueryOptions()); const navigate = useNavigate(); @@ -88,7 +88,7 @@ export function ActorBuildsList() { variant={"ghost"} size="sm" onClick={() => { - navigate({ + return navigate({ to: match(__APP_TYPE__) .with("engine", () => "/ns/$namespace") .with( diff --git a/frontend/src/app/context-switcher.tsx b/frontend/src/app/context-switcher.tsx index 80493be899..4de5fd614e 100644 --- a/frontend/src/app/context-switcher.tsx +++ b/frontend/src/app/context-switcher.tsx @@ -27,7 +27,11 @@ import { PopoverTrigger, Skeleton, } from "@/components"; -import { useCloudDataProvider } from "@/components/actors"; +import { + useCloudDataProvider, + useEngineCompatDataProvider, + useEngineDataProvider, +} from "@/components/actors"; import { SafeHover } from "@/components/safe-hover"; import { VisibilitySensor } from "@/components/visibility-sensor"; import { LazyBillingPlanBadge } from "./billing/billing-plan-badge"; @@ -42,7 +46,9 @@ export function ContextSwitcher({ inline }: { inline?: boolean }) { return ( ); } @@ -51,15 +57,20 @@ function ContextSwitcherInner({ organization, inline, }: { - organization: string; + organization?: string; inline?: boolean; }) { const [isOpen, setIsOpen] = useState(false); - usePrefetchInfiniteQuery({ - ...useCloudDataProvider().projectsQueryOptions({ - organization, - }), - }); + + if (__APP_TYPE__ === "cloud") { + // biome-ignore lint/correctness/useHookAtTopLevel: guaranteed by build condition + usePrefetchInfiniteQuery({ + // biome-ignore lint/correctness/useHookAtTopLevel: guaranteed by build condition + ...useCloudDataProvider().projectsQueryOptions({ + organization: organization!, + }), + }); + } return ( @@ -92,6 +103,7 @@ const useContextSwitcherMatch = (): organization: string; } | { organization: string; project: string } + | { namespace: string } | false => { const match = useMatchRoute(); @@ -113,6 +125,15 @@ const useContextSwitcherMatch = (): return matchProject; } + const matchEngineNamespace = match({ + to: "/ns/$namespace", + fuzzy: true, + }); + + if (matchEngineNamespace) { + return matchEngineNamespace; + } + return false; }; @@ -156,10 +177,15 @@ function Breadcrumbs({ inline }: { inline?: boolean }) { } if (match && "project" in match) { + return ; + } + + if (match && "namespace" in match) { return ( - <> - - + ); } @@ -213,6 +239,27 @@ function NamespaceBreadcrumb({ ); } +function EngineNamespaceBreadcrumb({ + namespace, + className, +}: { + namespace: string; + className?: string; +}) { + const { isLoading, data } = useQuery( + useEngineCompatDataProvider().namespaceQueryOptions(namespace), + ); + if (isLoading) { + return ; + } + + return ( + + {data?.displayName || "Unknown Namespace"} + + ); +} + function Content({ onClose }: { onClose?: () => void }) { const params = useParams({ strict: false, diff --git a/frontend/src/app/data-providers/default-data-provider.tsx b/frontend/src/app/data-providers/default-data-provider.tsx index 8d0e3ff43d..0261ea3fe7 100644 --- a/frontend/src/app/data-providers/default-data-provider.tsx +++ b/frontend/src/app/data-providers/default-data-provider.tsx @@ -96,13 +96,12 @@ const defaultContext = { return undefined; }, select: (data) => { + // Flatten the paginated responses into a single list of builds return data.pages.flatMap((page) => - Array.from( - Object.entries(page.names).map(([id, name]) => ({ - id, - name, - })), - ), + Object.entries(page.names).map(([id, name]) => ({ + id, + name, + })), ); }, }); diff --git a/frontend/src/app/data-providers/engine-data-provider.tsx b/frontend/src/app/data-providers/engine-data-provider.tsx index 93a601cc93..3b8189c3ec 100644 --- a/frontend/src/app/data-providers/engine-data-provider.tsx +++ b/frontend/src/app/data-providers/engine-data-provider.tsx @@ -96,6 +96,18 @@ export const createGlobalContext = (opts: { }, }); }, + namespaceQueryOptions(name: string | undefined) { + return queryOptions({ + queryKey: ["namespace", name] as any, + enabled: !!name, + queryFn: async () => { + const data = await client.namespaces.list({ + name, + }); + return data.namespaces[0]; + }, + }); + }, createNamespaceMutationOptions(opts: { onSuccess?: (data: Namespace) => void; }) { diff --git a/frontend/src/app/serverless-connection-check.tsx b/frontend/src/app/serverless-connection-check.tsx index d961d4f666..4e2358eda0 100644 --- a/frontend/src/app/serverless-connection-check.tsx +++ b/frontend/src/app/serverless-connection-check.tsx @@ -17,6 +17,16 @@ import * as z4 from "zod/v4"; import { cn, Uptime } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; +const IPV4_REGEX = /^(\d{1,3}\.){3}\d{1,3}$/; +const IPV6_REGEX = /^\[[\da-fA-F:]+\]$/; + +function isValidHost(hostname: string): boolean { + if (hostname === "localhost") return true; + if (IPV4_REGEX.test(hostname)) return true; + if (IPV6_REGEX.test(hostname)) return true; + return z4.regexes.domain.test(hostname); +} + export const endpointSchema = z .string() .refine((val) => { @@ -25,7 +35,7 @@ export const endpointSchema = z try { const url = new URL(urlStr); if (!/^https?:$/.test(url.protocol)) return false; - return z4.regexes.domain.test(url.hostname); + return isValidHost(url.hostname); } catch { return false; } diff --git a/frontend/src/components/actors/workflow/workflow-to-xyflow.ts b/frontend/src/components/actors/workflow/workflow-to-xyflow.ts index 9a3020d2af..7653f73fff 100644 --- a/frontend/src/components/actors/workflow/workflow-to-xyflow.ts +++ b/frontend/src/components/actors/workflow/workflow-to-xyflow.ts @@ -1,17 +1,4 @@ import type { Edge, Node } from "@xyflow/react"; -import { - LOOP_HEADER_HEIGHT, - LOOP_PADDING_BOTTOM, - LOOP_PADDING_X, - NODE_HEIGHT, - NODE_WIDTH, - TERMINATION_NODE_SIZE, - type BranchGroupNodeData, - type LoopGroupNodeData, - type TerminationNodeData, - type WorkflowNodeData, - formatDuration, -} from "./xyflow-nodes"; import type { EntryKindType, EntryStatus, @@ -27,6 +14,19 @@ import type { StepEntry, WorkflowHistory, } from "./workflow-types"; +import { + type BranchGroupNodeData, + formatDuration, + LOOP_HEADER_HEIGHT, + LOOP_PADDING_BOTTOM, + LOOP_PADDING_X, + type LoopGroupNodeData, + NODE_HEIGHT, + NODE_WIDTH, + TERMINATION_NODE_SIZE, + type TerminationNodeData, + type WorkflowNodeData, +} from "./xyflow-nodes"; // ─── Constants ─────────────────────────────────────────────── @@ -40,7 +40,11 @@ type XYNode = Node; type XYLoopGroupNode = Node; type XYBranchGroupNode = Node; type XYTerminationNode = Node; -type AnyXYNode = XYNode | XYLoopGroupNode | XYBranchGroupNode | XYTerminationNode; +type AnyXYNode = + | XYNode + | XYLoopGroupNode + | XYBranchGroupNode + | XYTerminationNode; export interface LayoutResult { nodes: AnyXYNode[]; @@ -100,10 +104,19 @@ function getEntrySummary(type: ExtendedEntryType, data: unknown): string { /** Extract common node properties from a HistoryItem. */ function itemToNodeData(item: HistoryItem) { - const { startedAt, completedAt, kind, status: rawStatus, retryCount, error } = item.entry; - const duration = startedAt && completedAt ? completedAt - startedAt : undefined; + const { + startedAt, + completedAt, + kind, + status: rawStatus, + retryCount, + error, + } = item.entry; + const duration = + startedAt && completedAt ? completedAt - startedAt : undefined; const status: EntryStatus = - rawStatus || (completedAt ? "completed" : startedAt ? "running" : "pending"); + rawStatus || + (completedAt ? "completed" : startedAt ? "running" : "completed"); return { name: getDisplayName(item.key), summary: getEntrySummary(kind.type, kind.data), @@ -144,7 +157,10 @@ function makeNode( id: string, x: number, y: number, - data: Omit & { + data: Omit< + WorkflowNodeData, + "label" | "summary" | "entryType" | "status" + > & { label?: string; summary?: string; entryType: EntryKindType | "input" | "output"; @@ -222,7 +238,9 @@ export function workflowHistoryToXYFlow( function connectTo(targetId: string, targetStartedAt?: number) { if (prevNodeId) { const gap = - prevCompletedAt && targetStartedAt && targetStartedAt > prevCompletedAt + prevCompletedAt && + targetStartedAt && + targetStartedAt > prevCompletedAt ? formatDuration(targetStartedAt - prevCompletedAt) : undefined; edges.push({ @@ -232,8 +250,14 @@ export function workflowHistoryToXYFlow( ...(gap && { label: gap, style: { stroke: "hsl(var(--muted-foreground))" }, - labelStyle: { fill: "hsl(var(--muted-foreground))", fontSize: 10 }, - labelBgStyle: { fill: "hsl(var(--background))", fillOpacity: 0.8 }, + labelStyle: { + fill: "hsl(var(--muted-foreground))", + fontSize: 10, + }, + labelBgStyle: { + fill: "hsl(var(--background))", + fillOpacity: 0.8, + }, }), }); } @@ -248,7 +272,11 @@ export function workflowHistoryToXYFlow( } /** Place a sequential node, connect it, and advance the cursor. */ - function addSequentialNode(id: string, data: Parameters[3], startedAt?: number) { + function addSequentialNode( + id: string, + data: Parameters[3], + startedAt?: number, + ) { nodes.push(makeNode(id, 0, currentY, data)); connectTo(id, startedAt); prevNodeId = id; @@ -271,7 +299,11 @@ export function workflowHistoryToXYFlow( nodes.push(makeChildNode(id, parentId, y, { ...d, label: d.name })); if (lastId) { - edges.push({ id: `e-${lastId}-${id}`, source: lastId, target: id }); + edges.push({ + id: `e-${lastId}-${id}`, + source: lastId, + target: id, + }); } lastId = id; y += NODE_HEIGHT + NODE_GAP_Y; @@ -301,7 +333,9 @@ export function workflowHistoryToXYFlow( if (entryType === "loop") { const loopId = `loop-${item.entry.id}`; - const children = collectLoopChildren(nestedByParent.get(item.key) ?? []); + const children = collectLoopChildren( + nestedByParent.get(item.key) ?? [], + ); const height = groupHeight(children.length); nodes.push({ @@ -315,7 +349,11 @@ export function workflowHistoryToXYFlow( connectTo(loopId, d.startedAt); - const { lastChildId } = addChildChain(children, loopId, LOOP_HEADER_HEIGHT); + const { lastChildId } = addChildChain( + children, + loopId, + LOOP_HEADER_HEIGHT, + ); currentY += height + NODE_GAP_Y; prevNodeId = lastChildId ?? loopId; @@ -333,7 +371,9 @@ export function workflowHistoryToXYFlow( const TERMINATION_GAP = 24; const branches = branchNames.map((name) => { - const branchItems = nested.filter((ni) => ni.key.includes(`/${name}/`)); + const branchItems = nested.filter((ni) => + ni.key.includes(`/${name}/`), + ); sortByLocation(branchItems); const status = branchData.branches[name].status; const isFailed = status === "failed" || status === "cancelled"; @@ -348,7 +388,8 @@ export function workflowHistoryToXYFlow( const maxHeight = Math.max(...branches.map((b) => b.height)); const totalWidth = - branches.length * GROUP_WIDTH + (branches.length - 1) * BRANCH_GAP_X; + branches.length * GROUP_WIDTH + + (branches.length - 1) * BRANCH_GAP_X; const startX = -totalWidth / 2 + GROUP_WIDTH / 2 - LOOP_PADDING_X; const branchStartY = currentY; const branchGroupIds: string[] = []; @@ -395,13 +436,17 @@ export function workflowHistoryToXYFlow( const branchX = startX + i * (GROUP_WIDTH + BRANCH_GAP_X); const groupId = `branchgroup-${item.entry.id}-${branch.name}`; const termId = `term-${item.entry.id}-${branch.name}`; - const termX = branchX + GROUP_WIDTH / 2 - TERMINATION_NODE_SIZE / 2; + const termX = + branchX + GROUP_WIDTH / 2 - TERMINATION_NODE_SIZE / 2; nodes.push({ id: termId, type: "termination", position: { x: termX, y: termY }, - measured: { width: TERMINATION_NODE_SIZE, height: TERMINATION_NODE_SIZE }, + measured: { + width: TERMINATION_NODE_SIZE, + height: TERMINATION_NODE_SIZE, + }, data: {}, } as XYTerminationNode); @@ -427,14 +472,16 @@ export function workflowHistoryToXYFlow( if (history.output !== undefined && history.state === "completed") { const id = "meta-output"; - nodes.push(makeNode(id, 0, currentY, { - label: "Output", - summary: getEntrySummary("output", { value: history.output }), - entryType: "output", - status: "completed", - nodeKey: "output", - rawData: { value: history.output }, - })); + nodes.push( + makeNode(id, 0, currentY, { + label: "Output", + summary: getEntrySummary("output", { value: history.output }), + entryType: "output", + status: "completed", + nodeKey: "output", + rawData: { value: history.output }, + }), + ); connectTo(id); } diff --git a/frontend/src/components/actors/workflow/workflow-visualizer.tsx b/frontend/src/components/actors/workflow/workflow-visualizer.tsx index 34f118d0a0..4ca3c21ac3 100644 --- a/frontend/src/components/actors/workflow/workflow-visualizer.tsx +++ b/frontend/src/components/actors/workflow/workflow-visualizer.tsx @@ -79,10 +79,14 @@ export function WorkflowVisualizer({ nodeTypes={workflowNodeTypes} fitView panOnScroll - panOnDrag={[1, 2]} + panOnDrag edgesFocusable={false} + panActivationKeyCode={null} onNodeClick={onNodeClick} onPaneClick={onPaneClick} + nodesDraggable={false} + nodesConnectable={false} + edgesReconnectable={false} proOptions={{ hideAttribution: true }} > ) { className="text-[9px] font-medium" style={{ color: isFailed ? "#ef4444" : "#f59e0b" }} > - x{data.retryCount} + x{data.retryCount + 1} )}