diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx index 1ef510e368..aa28aebd5a 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/deployment-network-view.tsx @@ -1,11 +1,14 @@ "use client"; import { trpc } from "@/lib/trpc/client"; -import { useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDeployment } from "../layout-provider"; import { + COLLAPSE_THRESHOLD, + DEFAULT_NODE_HEIGHT, + DEFAULT_NODE_WIDTH, type DeploymentNode, InfiniteCanvas, - InstanceNode, + type InstanceNode as InstanceNodeType, InternalDevTreeGenerator, LiveIndicator, NodeDetailsPanel, @@ -21,6 +24,7 @@ import { isSentinelNode, isSkeletonNode, } from "./unkey-flow"; +import { InstanceNode } from "./unkey-flow/components/nodes/instance-node"; interface DeploymentNetworkViewProps { showProjectDetails?: boolean; @@ -34,6 +38,8 @@ export function DeploymentNetworkView({ const { deployment } = useDeployment(); const [generatedTree, setGeneratedTree] = useState(null); const [selectedNode, setSelectedNode] = useState(null); + const [collapsedSentinelIds, setCollapsedSentinelIds] = useState>(new Set()); + const hasAutoCollapsed = useRef(false); const { data: defaultTree, isLoading } = trpc.deploy.network.get.useQuery( { @@ -45,6 +51,140 @@ export function DeploymentNetworkView({ const currentTree = generatedTree ?? defaultTree ?? SKELETON_TREE; const isShowingSkeleton = isLoading && !generatedTree; + const toggleSentinel = useCallback((id: string) => { + setCollapsedSentinelIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + useEffect(() => { + if (hasAutoCollapsed.current || !defaultTree) { + return; + } + hasAutoCollapsed.current = true; + const toCollapse = new Set(); + if (isOriginNode(defaultTree)) { + for (const child of defaultTree.children ?? []) { + if ( + isSentinelNode(child) && + (child.children ?? []).filter(isInstanceNode).length > COLLAPSE_THRESHOLD + ) { + toCollapse.add(child.id); + } + } + } + if (toCollapse.size > 0) { + setCollapsedSentinelIds(toCollapse); + } + }, [defaultTree]); + + const { visibleTree, sentinelChildrenMap } = useMemo(() => { + const map = new Map(); + + function collapse(node: DeploymentNode): DeploymentNode { + if (isOriginNode(node)) { + return { ...node, children: node.children?.map((c) => collapse(c)) }; + } + if (isSentinelNode(node)) { + const instanceChildren = (node.children ?? []).filter(isInstanceNode); + map.set(node.id, instanceChildren); + + return collapsedSentinelIds.has(node.id) && instanceChildren.length > COLLAPSE_THRESHOLD + ? { ...node, children: instanceChildren.slice(0, 1) } + : node; + } + return node; + } + + return { visibleTree: collapse(currentTree), sentinelChildrenMap: map }; + }, [currentTree, collapsedSentinelIds]); + + const renderDeploymentNode = useCallback( + (node: DeploymentNode, parent?: DeploymentNode): React.ReactNode => { + if (isSkeletonNode(node)) { + return ; + } + + if (isOriginNode(node)) { + return ; + } + + if (isSentinelNode(node)) { + return ( + toggleSentinel(node.id)} + /> + ); + } + + if (isInstanceNode(node)) { + if (!parent || !isSentinelNode(parent)) { + throw new Error("Instance node requires parent sentinel"); + } + if (collapsedSentinelIds.has(parent.id)) { + const instances = sentinelChildrenMap.get(parent.id) ?? []; + const totalLayers = instances.length; + const step = 10; + const frontOffset = (totalLayers - 1) * step; + // pointer-events-none: stacked instances are not individually interactive + // users must expand the sentinel first via its toggle button + return ( +
+ {instances + .slice(1) + .reverse() + .map((inst, i) => ( +
+ +
+ ))} +
+ +
+
+ ); + } + return ( + + ); + } + + // This will yell at you if you don't handle a node type + const _exhaustive: never = node; + return _exhaustive; + }, + [deployment.id, collapsedSentinelIds, toggleSentinel, isShowingSkeleton, sentinelChildrenMap], + ); + return ( setSelectedNode(node)} - renderNode={(node, parent) => renderDeploymentNode(node, parent, deployment.id)} + renderNode={renderDeploymentNode} renderConnection={(path, parent, child) => ( )} @@ -78,35 +218,3 @@ export function DeploymentNetworkView({ ); } - -// renderDeployment function does not narrow types without type guards. -function renderDeploymentNode( - node: DeploymentNode, - parent?: DeploymentNode, - deploymentId?: string, -): React.ReactNode { - if (isSkeletonNode(node)) { - return ; - } - - if (isOriginNode(node)) { - return ; - } - - if (isSentinelNode(node)) { - return ; - } - - if (isInstanceNode(node)) { - if (!parent || !isSentinelNode(parent)) { - throw new Error("Instance node requires parent sentinel"); - } - return ( - - ); - } - - // This will yell at you if you don't handle a node type - const _exhaustive: never = node; - return _exhaustive; -} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/index.ts b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/index.ts index ddbb1decd8..c2244b1e91 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/index.ts +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/index.ts @@ -7,9 +7,13 @@ export * from "./skeleton-node/skeleton-node"; export * from "./origin-node"; export { + COLLAPSE_THRESHOLD, + DEFAULT_NODE_HEIGHT, + DEFAULT_NODE_WIDTH, isOriginNode, isSentinelNode, isInstanceNode, isSkeletonNode, type DeploymentNode, + type InstanceNode, } from "./types"; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/instance-node.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/instance-node.tsx index dfa1681d75..41af47ee2e 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/instance-node.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/instance-node.tsx @@ -9,9 +9,10 @@ type InstanceNodeProps = { node: InstanceNodeType; flagCode: SentinelNodeType["metadata"]["flagCode"]; deploymentId?: string; + stacked?: boolean; }; -export function InstanceNode({ node, flagCode, deploymentId }: InstanceNodeProps) { +export function InstanceNode({ node, flagCode, deploymentId, stacked }: InstanceNodeProps) { const { cpu, memory, health } = node.metadata; const { data: rps } = trpc.deploy.network.getInstanceRps.useQuery( @@ -19,13 +20,13 @@ export function InstanceNode({ node, flagCode, deploymentId }: InstanceNodeProps instanceId: node.id, }, { - enabled: Boolean(deploymentId), + enabled: Boolean(deploymentId) && !stacked, refetchInterval: 5000, }, ); return ( - + +
; -export function NodeWrapper({ health, children }: NodeWrapperProps) { +export function NodeWrapper({ health, children, showBanner = true }: NodeWrapperProps) { const isDisabled = health === "disabled"; const { ring, glow } = getHealthStyles(health); @@ -27,7 +28,7 @@ export function NodeWrapper({ health, children }: NodeWrapperProps) { ), )} > - + {showBanner && }
void; }; -export function SentinelNode({ node, deploymentId }: SentinelNodeProps) { - const { flagCode, cpu, memory, health, replicas } = node.metadata; +export function SentinelNode({ + node, + deploymentId, + isCollapsed, + onToggleCollapse, +}: SentinelNodeProps) { + const { flagCode, cpu, memory, health, replicas, instances } = node.metadata; const { data: rps } = trpc.deploy.network.getSentinelRps.useQuery( { @@ -31,24 +39,42 @@ export function SentinelNode({ node, deploymentId }: SentinelNodeProps) { : `${replicas} available ${replicas === 1 ? "replica" : "replicas"}`; return ( - - - - - } - title={node.label} - subtitle={replicaText} - health={health} - /> - - +
+ + + + + } + title={node.label} + subtitle={replicaText} + health={health} + /> + + + + {instances > COLLAPSE_THRESHOLD && onToggleCollapse && ( + + )} +
); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/skeleton-node/skeleton-node.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/skeleton-node/skeleton-node.tsx index 5648ae6276..a81c336369 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/skeleton-node/skeleton-node.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/network/unkey-flow/components/nodes/skeleton-node/skeleton-node.tsx @@ -1,9 +1,12 @@ -import { DEFAULT_NODE_WIDTH } from "../types"; +import { DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH } from "../types"; export const SkeletonNode = () => { return ( -
-
+
+
{/* Header */}
= { } as const; const DEFAULT_NODE_WIDTH = 230; +const DEFAULT_NODE_HEIGHT = 70; +const COLLAPSE_THRESHOLD = 3; type NodeSize = { width: number; height: number }; /** * Since our nodes are custom-made, we can optimize layout through static heights and widths. @@ -88,9 +90,9 @@ type NodeSize = { width: number; height: number }; */ const NODE_SIZES: Record = { origin: { width: 70, height: 20 }, - sentinel: { width: DEFAULT_NODE_WIDTH, height: 70 }, - instance: { width: DEFAULT_NODE_WIDTH, height: 70 }, - skeleton: { width: DEFAULT_NODE_WIDTH, height: 70 }, + sentinel: { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT }, + instance: { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT }, + skeleton: { width: DEFAULT_NODE_WIDTH, height: DEFAULT_NODE_HEIGHT }, } as const; export type { @@ -109,6 +111,8 @@ export { isSentinelNode, isInstanceNode, isSkeletonNode, + COLLAPSE_THRESHOLD, + DEFAULT_NODE_HEIGHT, DEFAULT_NODE_WIDTH, REGION_INFO, NODE_SIZES,