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}
)}