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,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,
Expand All @@ -21,6 +24,7 @@ import {
isSentinelNode,
isSkeletonNode,
} from "./unkey-flow";
import { InstanceNode } from "./unkey-flow/components/nodes/instance-node";

interface DeploymentNetworkViewProps {
showProjectDetails?: boolean;
Expand All @@ -34,6 +38,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 +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<string>();
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<string, InstanceNodeType[]>();

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 <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
.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,46 +207,14 @@ 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} />
)}
/>
</InfiniteCanvas>
);
}

// 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
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
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
import { RegionFlag } from "@/app/(app)/[workspaceSlug]/projects/[projectId]/components/region-flag";
import { trpc } from "@/lib/trpc/client";
import { ChevronDown, ChevronUp, Layers3 } from "@unkey/icons";
import { InfoTooltip } from "@unkey/ui";
import { CardFooter } from "./components/card-footer";
import { CardHeader } from "./components/card-header";
import { NodeWrapper } from "./node-wrapper/node-wrapper";
import { REGION_INFO, type SentinelNode as SentinelNodeType } from "./types";
import { COLLAPSE_THRESHOLD, REGION_INFO, type SentinelNode as SentinelNodeType } from "./types";

type SentinelNodeProps = {
node: SentinelNodeType;
deploymentId?: string;
isCollapsed?: boolean;
onToggleCollapse?: () => 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(
{
Expand All @@ -31,24 +39,42 @@ export function SentinelNode({ node, deploymentId }: SentinelNodeProps) {
: `${replicas} available ${replicas === 1 ? "replica" : "replicas"}`;

return (
<NodeWrapper health={health}>
<CardHeader
type="sentinel"
icon={
<InfoTooltip
content={`AWS region ${node.label} (${regionInfo.location})`}
variant="primary"
className="px-2.5 py-1 rounded-[10px] bg-white dark:bg-blackA-12 text-xs z-30"
position={{ align: "center", side: "top", sideOffset: 5 }}
>
<RegionFlag flagCode={flagCode} size="md" shape="rounded" />
</InfoTooltip>
}
title={node.label}
subtitle={replicaText}
health={health}
/>
<CardFooter type="sentinel" rps={rps} cpu={cpu} memory={memory} />
</NodeWrapper>
<div className="relative flex flex-col items-end">
<NodeWrapper health={health}>
<CardHeader
type="sentinel"
icon={
<InfoTooltip
content={`AWS region ${node.label} (${regionInfo.location})`}
variant="primary"
className="px-2.5 py-1 rounded-[10px] bg-white dark:bg-blackA-12 text-xs z-30"
position={{ align: "center", side: "top", sideOffset: 5 }}
>
<RegionFlag flagCode={flagCode} size="md" shape="rounded" />
</InfoTooltip>
}
title={node.label}
subtitle={replicaText}
health={health}
/>
<CardFooter type="sentinel" rps={rps} cpu={cpu} memory={memory} />
</NodeWrapper>

{instances > COLLAPSE_THRESHOLD && onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="mt-2 flex items-center gap-1.5 border border-grayA-4 bg-grayA-2 hover:bg-grayA-3 pl-2 pr-2.5 py-1 rounded-full text-xs text-gray-11 font-medium transition-colors shadow-sm"
type="button"
>
<Layers3 iconSize="sm-regular" className="text-gray-9" />
<span className="w-7 text-center">{isCollapsed ? "Show" : "Hide"}</span> instances
{isCollapsed ? (
<ChevronDown iconSize="sm-regular" className="text-gray-9" />
) : (
<ChevronUp iconSize="sm-regular" className="text-gray-9" />
)}
</button>
)}
</div>
);
}
Loading
Loading