Compare commits

...
1 Commits
Author SHA1 Message Date
Eduard Gert 9e2e38764e Add control center (#494)
* Add control center

* Update rdp doc link
2025-10-09 11:26:21 +02:00
33 changed files with 3857 additions and 15 deletions

No files matched your search

+1
View File
@@ -26,6 +26,7 @@ The dashboard makes it possible to:
- NextJS
- ReactJS
- Tailwind CSS
- [React Flow](https://reactflow.dev/) for the Control Center
- Auth0
- Nginx
- Docker
+4
View File
@@ -7,6 +7,10 @@ server {
root /usr/share/nginx/html;
default_type application/wasm;
}
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
root /usr/share/nginx/html;
default_type application/wasm;
}
location / {
try_files $uri $uri.html $uri/ =404;
+815 -9
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@axa-fr/react-oidc": "^7.22.18",
"@dagrejs/dagre": "^1.1.5",
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -35,6 +36,7 @@
"@tanstack/match-sorter-utils": "^8.8.4",
"@tanstack/react-table": "^8.10.7",
"@types/crypto-js": "^4.2.2",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.14.200",
"@types/node": "20.10.6",
"@types/react": "^18",
@@ -42,6 +44,7 @@
"@types/react-window": "^1.8.8",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
@@ -49,8 +52,10 @@
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"crypto-js": "^4.2.0",
"d3": "^7.9.0",
"date-fns": "^2.30.0",
"dayjs": "^1.11.10",
"elkjs": "^0.10.0",
"eslint": "^8",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
@@ -60,7 +65,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lucide-react": "^0.481.0",
"lucide-react": "^0.539.0",
"next": "^14.2.28",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -0,0 +1,8 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Control Center - ${globalMetaTitle}`,
};
export default BlankLayout;
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -167,4 +167,10 @@ p {
.xterm-viewport {
@apply m-0 p-0 box-border;
}
}
/* Control Center */
.react-flow__node-groupNode .selected{
@apply border-netbird;
}
+22
View File
@@ -0,0 +1,22 @@
import { iconProperties, IconProps } from "@/assets/icons/IconProperties";
export default function ControlCenterIcon(props: IconProps) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 24 24"
{...iconProperties(props)}
>
<path d="M5 3a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm0 12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2H5Zm12 0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2h-2Zm0-12a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2h2a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2h-2Z" />
<path
fillRule="evenodd"
d="M10 6.5a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1ZM10 18a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm-4-4a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Zm12 0a1 1 0 0 1-1-1v-2a1 1 0 1 1 2 0v2a1 1 0 0 1-1 1Z"
clipRule="evenodd"
/>
</svg>
);
}
+20
View File
@@ -1,9 +1,12 @@
"use client";
import { ScrollArea } from "@components/ScrollArea";
import { SmallBadge } from "@components/ui/SmallBadge";
import { cn } from "@utils/helpers";
import * as React from "react";
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import ControlCenterIcon from "@/assets/icons/ControlCenterIcon";
import DNSIcon from "@/assets/icons/DNSIcon";
import DocsIcon from "@/assets/icons/DocsIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
@@ -67,6 +70,23 @@ export default function Navigation({
>
<div>
<SidebarItemGroup>
<SidebarItem
icon={<ControlCenterIcon size={16} />}
label={
<div className={"flex items-center gap-2"}>
Control Center
<SmallBadge
text={"Beta"}
variant={"sky"}
className={"text-[8px] leading-none py-[3px] px-[5px]"}
textClassName={"top-0"}
/>
</div>
}
href={"/control-center"}
visible={permission.policies.read}
/>
<SidebarItem
icon={<PeerIcon />}
label="Peers"
@@ -0,0 +1,48 @@
import { SegmentedTabs } from "@components/SegmentedTabs";
import { FolderGit2, MonitorSmartphoneIcon, NetworkIcon } from "lucide-react";
import * as React from "react";
export enum FlowView {
NETWORKS = "networks",
GROUPS = "groups",
PEERS = "peers",
}
type Props = {
value?: FlowView;
onChange?: (value: FlowView) => void;
};
export const FlowSelector = ({ value, onChange }: Props) => {
return (
<SegmentedTabs value={value} onChange={(v) => onChange?.(v as FlowView)}>
<SegmentedTabs.List
className={
"border-b rounded-b-lg text-sm font-medium bg-nb-gray-930 p-1"
}
>
<SegmentedTabs.Trigger
value={FlowView.PEERS}
className={"text-xs px-3 py-1"}
>
<MonitorSmartphoneIcon size={12} />
Peers
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.GROUPS}
className={"text-xs px-3 py-1"}
>
<FolderGit2 size={12} />
Groups
</SegmentedTabs.Trigger>
<SegmentedTabs.Trigger
value={FlowView.NETWORKS}
className={"text-xs px-3 py-[0.45rem]"}
>
<NetworkIcon size={12} />
Networks
</SegmentedTabs.Trigger>
</SegmentedTabs.List>
</SegmentedTabs>
);
};
@@ -0,0 +1,48 @@
import Button from "@components/Button";
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import * as React from "react";
import { useMemo } from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { Network, NetworkRouter } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
type Props = {
network: Network;
};
export const NetworkRoutingPeerCount = ({ network }: Props) => {
const { data: routers, isLoading: isRoutersLoading } =
useFetchApi<NetworkRouter[]>("/networks/routers");
const { data: peers, isLoading: isPeersLoading } =
useFetchApi<Peer[]>("/peers");
const routingPeerStatusColor = useMemo(() => {
if (!network) return "bg-nb-gray-500";
const routerCount = network.routers?.length || 0;
if (routerCount === 0) return "bg-nb-gray-500";
if (routerCount === 1) return "bg-yellow-400";
if (routerCount > 1) return "bg-green-400";
return "bg-nb-gray-500";
}, [network]);
const networkRouters = useMemo(() => {
if (!network || !peers) return [];
const routerIds = network?.routers?.map((r) => r) || [];
return routers?.filter((r) => routerIds.includes(r.id)) || [];
}, [network, peers, routers]);
return (
<Button
variant={"secondary"}
size={"xs"}
className={"!bg-nb-gray-930 !text-nb-gray-300 cursor-default"}
>
<CircleIcon
size={8}
className={cn("shrink-0 block", routingPeerStatusColor)}
/>
{network.routers?.length || 0} Routing Peer(s)
</Button>
);
};
@@ -0,0 +1,125 @@
import { Edge, useInternalNode } from "@xyflow/react";
import React from "react";
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
type AnimatedLineProps = Edge<
{
label?: string;
color?: string;
},
"animated-line"
>;
function AnimatedLine({ id, source, target, data }: AnimatedLineProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) return null;
const { sx, sy, tx, ty } = getEdgeParams(sourceNode, targetNode);
const labelX = (sx + tx) / 2;
const labelY = (sy + ty) / 2;
let angle = Math.atan2(ty - sy, tx - sx) * (180 / Math.PI);
if (angle < -90 || angle > 90) {
angle += 180;
}
const label = data?.label || "";
const hasLabel = label?.length > 0;
const fontSize = 12;
const paddingX = hasLabel ? 2 : 0;
const paddingY = hasLabel ? 2 : 0;
const gapWidth = hasLabel ? 4 : 0;
const labelTextWidth = label.length * 7;
const labelWidth = gapWidth + labelTextWidth + paddingX * 2;
const labelHeight = fontSize + paddingY * 2;
const dx = tx - sx;
const dy = ty - sy;
const length = Math.sqrt(dx * dx + dy * dy);
const gap = labelWidth / 2;
const nx = dx / length;
const ny = dy / length;
const preLabelX = labelX - nx * gap;
const preLabelY = labelY - ny * gap;
const postLabelX = labelX + nx * gap;
const postLabelY = labelY + ny * gap;
const color = data?.color || "#0e9f6e";
return (
<>
<line
x1={sx}
y1={sy}
x2={preLabelX}
y2={preLabelY}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
<line
x1={postLabelX}
y1={postLabelY}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={2}
strokeDasharray="5, 5"
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</line>
{label && hasLabel && (
<foreignObject
x={labelX - labelWidth / 2}
y={labelY - labelHeight / 2}
width={labelWidth}
height={labelHeight}
style={{ overflow: "visible" }}
>
<div
style={{
width: labelWidth,
height: labelHeight,
fontSize,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: `${paddingY}px ${paddingX}px`,
transform: `rotate(${angle}deg)`,
transformOrigin: "center center",
boxSizing: "border-box",
background: "none",
}}
className={
"flex items-center justify-center gap-1 select-none pointer-events-none z-10 text-green-50"
}
>
<div className={"whitespace-nowrap"}>{label}</div>
</div>
</foreignObject>
)}
</>
);
}
export default AnimatedLine;
@@ -0,0 +1,70 @@
import { BaseEdge, type EdgeProps, getSmoothStepPath } from "@xyflow/react";
import React from "react";
export function BidirectionalEdges({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
}: EdgeProps) {
const [forwardPath] = getSmoothStepPath({
sourceX: sourceX - 5,
sourceY: sourceY - 5,
sourcePosition,
targetX: targetX + 15,
targetY: targetY - 5,
targetPosition,
});
const [backwardPath] = getSmoothStepPath({
sourceX: targetX + 5,
sourceY: targetY + 5,
sourcePosition: targetPosition,
targetX: sourceX - 15,
targetY: sourceY + 5,
targetPosition: sourcePosition,
});
return (
<>
<BaseEdge
id={`${id}-forward`}
path={forwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
<BaseEdge
id={`${id}-backward`}
path={backwardPath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
</>
);
}
@@ -0,0 +1,92 @@
import {
BaseEdge,
type EdgeProps,
getSimpleBezierPath,
getSmoothStepPath,
getStraightPath,
} from "@xyflow/react";
import React from "react";
type Props = {
data: {
enabled: boolean;
type: "smoothstep" | "straight" | "bezier";
};
} & EdgeProps;
export function DirectionIn({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
}: Props) {
const { enabled, type = "straight" } = data;
const getPath = () => {
switch (type) {
case "straight":
return getStraightPath({
sourceX,
sourceY,
targetX,
targetY,
});
case "bezier":
return getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
case "smoothstep":
return getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
default:
return getSmoothStepPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
}
};
const [edgePath] = getPath();
return (
<BaseEdge
id={id}
path={edgePath}
style={{
opacity: enabled ? 1 : 0.6,
strokeWidth: 2,
stroke: enabled ? "#0e9f6e" : "#787878",
strokeDasharray: "5, 5",
}}
>
{enabled && (
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
)}
</BaseEdge>
);
}
@@ -0,0 +1,53 @@
import {
BaseEdge,
EdgeProps,
getBezierPath,
useInternalNode,
} from "@xyflow/react";
import React from "react";
import { getEdgeParams } from "@/modules/control-center/utils/edge-helper";
function FloatingEdge({ id, source, target, markerEnd, style }: EdgeProps) {
const sourceNode = useInternalNode(source);
const targetNode = useInternalNode(target);
if (!sourceNode || !targetNode) {
return null;
}
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
sourceNode,
targetNode,
);
const [edgePath] = getBezierPath({
sourceX: sx,
sourceY: sy,
sourcePosition: sourcePos,
targetPosition: targetPos,
targetX: tx,
targetY: ty,
});
return (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 2,
stroke: "#0e9f6e",
strokeDasharray: "5, 5",
}}
>
<animate
attributeName="stroke-dashoffset"
from="20"
to="0"
dur="0.5s"
repeatCount="indefinite"
/>
</BaseEdge>
);
}
export default FloatingEdge;
@@ -0,0 +1,45 @@
import { BaseEdge, type EdgeProps, getSimpleBezierPath } from "@xyflow/react";
import React from "react";
import { useSourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type Props = {
data: {
enabled: boolean;
};
} & EdgeProps;
export function SimpleConnection({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
source,
}: Props) {
const [edgePath] = getSimpleBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const enabled = useSourceGroupEnabled(source);
return (
<BaseEdge
id={id}
path={edgePath}
style={{
strokeWidth: 1.5,
stroke: "#595959",
strokeDasharray: "0, 0",
opacity: enabled ? 1 : 0.6,
}}
></BaseEdge>
);
}
@@ -0,0 +1,111 @@
import TruncatedText from "@components/ui/TruncatedText";
import { getOperatingSystem } from "@hooks/useOperatingSystem";
import { cn } from "@utils/helpers";
import { GlobeIcon, NetworkIcon, WorkflowIcon } from "lucide-react";
import * as React from "react";
import RoundedFlag from "@/assets/countries/RoundedFlag";
import { NetworkResource } from "@/interfaces/Network";
import { OperatingSystem } from "@/interfaces/OperatingSystem";
import type { Peer } from "@/interfaces/Peer";
import { OSLogo } from "@/modules/peers/PeerOSCell";
type DeviceCardProps = {
device?: Peer;
resource?: NetworkResource;
className?: string;
};
export const DeviceCard = ({
device,
resource,
className,
}: DeviceCardProps) => {
if (!device && !resource) return;
return (
<div
className={cn(
"flex shrink-0 items-center gap-2.5 text-nb-gray-300 text-left py-1 pl-3 pr-4 rounded-md group/machine my-0 w-[200px]",
className,
)}
>
<div
className={cn(
"flex items-center justify-center rounded-md h-9 w-9 shrink-0 bg-nb-gray-850 transition-all",
"group-hover:bg-nb-gray-800 relative",
)}
>
{device && <PeerOSIcon os={device.os} />}
{resource?.type && <ResourceIcon type={resource.type} />}
{device?.country_code && (
<div className={"absolute -bottom-[4px] -right-[4px]"}>
<div
className={cn(
"flex items-center justify-center rounded-full border-[3px] shrink-0",
"border-nb-gray-940",
)}
>
<RoundedFlag country={device?.country_code} size={10} />
</div>
</div>
)}
</div>
<div className={"flex flex-col gap-0 justify-center mt-2 leading-tight"}>
<span
className={
"mb-1.5 font-normal text-[0.85rem] text-nb-gray-100 flex items-center gap-2"
}
>
<TruncatedText
text={device?.name || resource?.name || "Unknown"}
maxWidth={"150px"}
hideTooltip={true}
/>
</span>
<span
className={
"text-sm font-normal text-nb-gray-400 -top-[0.3rem] relative"
}
>
{device?.ip || resource?.address}
</span>
</div>
</div>
);
};
const PeerOSIcon = ({ os }: { os: string }) => {
const osType = getOperatingSystem(os);
return (
<div
className={cn(
"flex items-center justify-center grayscale brightness-[100%] contrast-[40%]",
"w-4 h-4 shrink-0",
osType === OperatingSystem.WINDOWS && "p-[2.5px]",
osType === OperatingSystem.APPLE && "p-[2.7px]",
osType === OperatingSystem.FREEBSD && "p-[1.5px]",
)}
>
<OSLogo os={os} />
</div>
);
};
const ResourceIcon = ({
type,
size = 15,
}: {
type: "domain" | "host" | "subnet";
size?: number;
}) => {
switch (type) {
case "domain":
return <GlobeIcon size={size} />;
case "subnet":
return <NetworkIcon size={size} />;
case "host":
return <WorkflowIcon size={size} />;
default:
return <WorkflowIcon size={size} />;
}
};
@@ -0,0 +1,80 @@
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import { useMemo } from "react";
import { Group } from "@/interfaces/Group";
type GroupNodeProps = Node<
{
group: Group;
enabled: boolean;
hoverable?: boolean;
onClick?: (g: Group) => void;
},
"groupNode"
>;
export const GroupNode = ({ data, id }: GroupNodeProps) => {
const { enabled = true, group, hoverable = true, onClick } = data;
const countLabel = useMemo(() => {
const peerCount = group?.peers_count || 0;
const resourceCount = group?.resources_count || 0;
if (resourceCount === 0) {
return `${peerCount} Peer(s)`;
}
if (peerCount === 0) {
return `${resourceCount} Resource(s)`;
}
return `${peerCount} Peer(s), ${resourceCount} Resource(s)`;
}, [group?.peers_count, group?.resources_count]);
return (
<div
className={cn(
"cc-group-node bg-nb-gray-940 border border-nb-gray-800 rounded-lg overflow-hidden transition-all",
!enabled && "opacity-60",
hoverable && "hover:bg-nb-gray-930 cursor-pointer",
)}
onClick={() => onClick?.(group)}
>
<div
className={
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-3 pr-5 py-3 font-normal"
}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div
className={
"h-9 w-9 bg-nb-gray-850 rounded-md flex items-center justify-center shrink-0"
}
>
<GroupBadgeIcon id={group?.id} issued={group?.issued} size={14} />
</div>
<div>
<div className={" text-nb-gray-200 font-normal whitespace-nowrap"}>
{group.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap text-xs"}>
{countLabel}
</div>
</div>
</div>
</div>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};
@@ -0,0 +1,102 @@
import useFetchApi from "@utils/api";
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import { NetworkIcon } from "lucide-react";
import * as React from "react";
import CircleIcon from "@/assets/icons/CircleIcon";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { Network, NetworkResource } from "@/interfaces/Network";
type NetworkNodeType = {
network: Network;
};
type NetworkNodeProps = Node<NetworkNodeType, "networkNode">;
export const NetworkNode = ({ data }: NetworkNodeProps) => {
const { data: networkResources, isLoading: isLoadingResources } = useFetchApi<
NetworkResource[]
>("/networks/resources");
const n = data.network as Network;
const resourceIds = n?.resources || [];
const routingPeers = n?.routers || [];
const resources =
networkResources?.filter((r) => resourceIds.includes(r?.id || "")) || [];
return (
<div
className={cn(
"bg-nb-gray-940 border border-nb-gray-800 rounded-2xl overflow-hidden group hover:bg-nb-gray-935 transition-all cursor-pointer",
)}
>
<div
className={cn(
"flex w-full items-center justify-between text-nb-gray-300 gap-2 text-sm pl-6 pr-6 py-3.5 font-normal bg-nb-gray-935 border-b border-nb-gray-800 group-hover:bg-nb-gray-930 transition-all",
resources?.length === 0 && "border-b-0",
)}
>
<div className={"flex items-center gap-3 font-normal text-sm"}>
<div>
<div
className={
" text-nb-gray-100 font-medium whitespace-nowrap flex items-center gap-2"
}
>
<NetworkIcon size={12} />
{n?.name}
</div>
<div className={"text-nb-gray-400 whitespace-nowrap mt-0.5"}>
{resources?.length || 0} Resources
</div>
</div>
</div>
<div className={"flex items-center gap-2 text-xs"}>
<CircleIcon
size={8}
className={cn(
"shrink-0 block",
routingPeers?.length === 0 && "bg-nb-gray-500",
routingPeers?.length === 1 && "bg-yellow-400",
routingPeers?.length > 1 && "bg-green-400",
)}
/>
{routingPeers?.length || 0} Routing Peer(s)
</div>
</div>
{resources && resources.length > 0 && (
<div className={"p-2 flex flex-col gap-4 relative"}>
<div className={"grid grid-cols-2 relative z-0"}>
{resources?.slice(0, 6).map((r) => {
return <DeviceCard resource={r} key={r.id} />;
})}
</div>
<div
className={cn(
"absolute w-full h-full bg-gradient-to-b from-transparent via-nb-gray-940/20 to-nb-gray-940 z-10 left-0 top-0 pointer-events-none",
resources?.length > 6 ? "opacity-100" : "opacity-0",
)}
></div>
</div>
)}
<Handle
type="source"
position={Position.Right}
id={"sr"}
style={{
opacity: 0,
}}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
style={{
opacity: 0,
}}
/>
</div>
);
};
@@ -0,0 +1,44 @@
import { cn } from "@utils/helpers";
import { Handle, type Node, Position } from "@xyflow/react";
import * as React from "react";
import type { Peer } from "@/interfaces/Peer";
import { DeviceCard } from "@/modules/control-center/nodes/DeviceCard";
import { useAnySourceGroupEnabled } from "@/modules/control-center/utils/helpers";
type PeerNodeProps = Node<
{
peer: Peer;
enabled?: boolean;
},
"peerNode"
>;
export const PeerNode = ({ data, id }: PeerNodeProps) => {
const { peer, enabled } = data;
const isEnabled = useAnySourceGroupEnabled(id);
return (
<div
className={
"border-0 border-nb-gray-800 rounded-lg overflow-hidden transition-all"
}
>
<DeviceCard
device={peer}
className={cn("p-0", !isEnabled && "opacity-60")}
/>
<Handle
type="source"
position={Position.Right}
id={"sr"}
className={"opacity-0"}
/>
<Handle
type="target"
position={Position.Left}
id={"tl"}
className={"opacity-0"}
/>
</div>
);
};
Loaded 20 of 33 files, more files were not shown because too many files have changed in this diff. Show more