Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
"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,
OriginNode,
type OriginNode as OriginNodeType,
ProjectDetails,
SKELETON_TREE,
SentinelNode,
Expand All @@ -21,6 +24,8 @@ import {
isSentinelNode,
isSkeletonNode,
} from "./unkey-flow";
import { InstanceNode } from "./unkey-flow/components/nodes/instance-node";
import { OriginNode } from "./unkey-flow/components/nodes/origin-node";

interface DeploymentNetworkViewProps {
showProjectDetails?: boolean;
Expand All @@ -34,6 +39,8 @@ export function DeploymentNetworkView({
const { deployment } = useDeployment();
const [generatedTree, setGeneratedTree] = useState<DeploymentNode | null>(null);
const [selectedNode, setSelectedNode] = useState<DeploymentNode | null>(null);
const [collapsedSentinelIds, setCollapsedSentinelIds] = useState<Set<string>>(new Set());
const hasAutoCollapsed = useRef(false);

const { data: defaultTree, isLoading } = trpc.deploy.network.get.useQuery(
{
Expand All @@ -45,6 +52,137 @@ 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);
next[next.has(id) ? "delete" : "add"](id);
return next;
});
}, []);

// Triggers auto collapse for sentinels with more than 3 children
useEffect(() => {
if (hasAutoCollapsed.current || !defaultTree) {
return;
}
hasAutoCollapsed.current = true;

const toCollapse = computeAutoCollapsedSentinels(defaultTree);
if (toCollapse.size > 0) {
setCollapsedSentinelIds(toCollapse);
}
}, [defaultTree]);

const { visibleTree, sentinelChildrenMap } = useMemo(() => {
const map = new Map<string, InstanceNodeType[]>();

const originNode = currentTree as OriginNodeType;
const newChildren = (originNode.children ?? []).map((sentinel) => {
// Required for narrowing down the type. There is no way children of originNode not being SentinelNode
if (!isSentinelNode(sentinel)) {
return sentinel;
}

const instanceChildren = sentinel.children ?? [];
map.set(sentinel.id, instanceChildren);

const isCollapsed = collapsedSentinelIds.has(sentinel.id);
const exceedsThreshold = instanceChildren.length > COLLAPSE_THRESHOLD;

if (isCollapsed && exceedsThreshold) {
return { ...sentinel, children: instanceChildren.slice(0, 1) };
}

return sentinel;
});

return {
visibleTree: { ...originNode, children: newChildren },
sentinelChildrenMap: map,
};
}, [currentTree, collapsedSentinelIds]);

const renderDeploymentNode = useCallback(
(node: DeploymentNode, parent?: DeploymentNode): React.ReactNode => {
if (isSkeletonNode(node)) {
return <SkeletonNode />;
}

if (isOriginNode(node)) {
return <OriginNode node={node} />;
}

if (isSentinelNode(node)) {
return (
<SentinelNode
node={node}
deploymentId={deployment.id}
isCollapsed={collapsedSentinelIds.has(node.id)}
onToggleCollapse={isShowingSkeleton ? undefined : () => 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 (
<div
className="relative pointer-events-none"
style={{
height: frontOffset + DEFAULT_NODE_HEIGHT,
width: frontOffset + DEFAULT_NODE_WIDTH,
}}
>
{instances
// We render the first child below as the first card thats why we drop the first one here
.slice(1)
.reverse()
.map((inst, i) => (
<div key={inst.id} className="absolute" style={{ top: i * step, left: i * step }}>
<InstanceNode
node={inst}
flagCode={parent.metadata.flagCode}
deploymentId={deployment.id}
stacked
/>
</div>
))}
<div className="absolute" style={{ top: frontOffset, left: frontOffset }}>
<InstanceNode
node={node}
flagCode={parent.metadata.flagCode}
deploymentId={deployment.id}
stacked
/>
</div>
</div>
);
}
return (
<InstanceNode
node={node}
flagCode={parent.metadata.flagCode}
deploymentId={deployment.id}
/>
);
}

// 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 (
<InfiniteCanvas
defaultZoom={0.85}
Expand All @@ -67,10 +205,10 @@ export function DeploymentNetworkView({
}
>
<TreeLayout
data={currentTree}
data={visibleTree}
nodeSpacing={{ x: 75, y: 100 }}
onNodeClick={isShowingSkeleton ? undefined : (node) => setSelectedNode(node)}
renderNode={(node, parent) => renderDeploymentNode(node, parent, deployment.id)}
renderNode={renderDeploymentNode}
renderConnection={(path, parent, child) => (
<TreeConnectionLine key={`${parent.id}-${child.id}`} path={path} />
)}
Expand All @@ -79,34 +217,13 @@ 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 <SkeletonNode />;
}

if (isOriginNode(node)) {
return <OriginNode node={node} />;
}

if (isSentinelNode(node)) {
return <SentinelNode node={node} deploymentId={deploymentId} />;
}

if (isInstanceNode(node)) {
if (!parent || !isSentinelNode(parent)) {
throw new Error("Instance node requires parent sentinel");
}
return (
<InstanceNode node={node} flagCode={parent.metadata.flagCode} deploymentId={deploymentId} />
);
}

// This will yell at you if you don't handle a node type
const _exhaustive: never = node;
return _exhaustive;
function computeAutoCollapsedSentinels(tree: OriginNodeType): Set<string> {
const ids = (tree.children ?? [])
// This is purely needed for proper type inference
.filter(isSentinelNode)
// If instance nodes exceeds threshold then we mark that sentinel as collapsed
.filter((s) => (s.children ?? []).filter(isInstanceNode).length > COLLAPSE_THRESHOLD)
.map((s) => s.id);

return new Set(ids);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ 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,
type OriginNode,
} from "./types";
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,24 @@ 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(
{
instanceId: node.id,
},
{
enabled: Boolean(deploymentId),
enabled: Boolean(deploymentId) && !stacked,
refetchInterval: 5000,
},
);

return (
<NodeWrapper health={health}>
<NodeWrapper health={health} showBanner={!stacked}>
<CardHeader
type="instance"
icon={
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { cn } from "@unkey/ui/src/lib/utils";
import { type HealthStatus, STATUS_CONFIG } from "../status/status-config";
import { DEFAULT_NODE_WIDTH } from "../types";

type HealthBannerProps = {
healthStatus: HealthStatus;
Expand All @@ -17,7 +16,7 @@ export function HealthBanner({ healthStatus }: HealthBannerProps) {
const Icon = config.icon;

return (
<div className={`mx-auto w-[${DEFAULT_NODE_WIDTH}px] -m-[20px]`}>
<div className="w-full -mt-[20px] -mb-[20px]">
<div
className={cn(
"h-12 border rounded-t-[14px]",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { HealthBanner } from "./health-banner";

type NodeWrapperProps = PropsWithChildren<{
health: HealthStatus;
showBanner?: boolean;
}>;

export function NodeWrapper({ health, children }: NodeWrapperProps) {
export function NodeWrapper({ health, children, showBanner = true }: NodeWrapperProps) {
const isDisabled = health === "disabled";
const { ring, glow } = getHealthStyles(health);

Expand All @@ -27,7 +28,7 @@ export function NodeWrapper({ health, children }: NodeWrapperProps) {
),
)}
>
<HealthBanner healthStatus={health} />
{showBanner && <HealthBanner healthStatus={health} />}
<div
className={cn(
"w-[282px] h-[100px] border border-grayA-4 rounded-[14px] flex flex-col bg-white dark:bg-black shadow-[0_2px_8px_-2px_rgba(0,0,0,0.1)]",
Expand Down
Loading