feat: add ProjectOverview and ProjectNetworkGraph components for enhanced app visualization

This commit is contained in:
biersoeckli
2025-12-18 16:21:30 +00:00
parent 3f3117762b
commit 0125c9d7dc
6 changed files with 376 additions and 6 deletions

View File

@@ -42,6 +42,7 @@
"@radix-ui/react-tooltip": "^1.1.4",
"@tanstack/react-table": "^8.20.5",
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.10.0",
"bcrypt": "^5.1.1",
"bufferutil": "^4.0.9",
"class-variance-authority": "^0.7.1",

View File

@@ -3,7 +3,7 @@
import { getAuthUserSession } from "@/server/utils/action-wrapper.utils";
import projectService from "@/server/services/project.service";
import AppTable from "./apps-table";
import ProjectOverview from "./project-overview";
import appService from "@/server/services/app.service";
import PageTitle from "@/components/custom/page-title";
import ProjectBreadcrumbs from "./project-breadcrumbs";
@@ -36,7 +36,7 @@ export default async function AppsPage({
{UserGroupUtils.sessionCanCreateNewAppsForProject(session, params.projectId) &&
<CreateProjectActions projectId={projectId} />}
</PageTitle>
<AppTable session={session} app={relevantApps} projectId={project.id} />
<ProjectOverview session={session} apps={relevantApps} projectId={project.id} />
<ProjectBreadcrumbs project={project} />
</div>
)

View File

@@ -0,0 +1,227 @@
'use client';
import React, { useMemo } from 'react';
import { ReactFlow, Background, Controls, Node, Edge, MarkerType, Handle, Position } from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { App, AppDomain, AppPort } from '@prisma/client';
import { Globe, Network, Lock, Cloud, Shield, ArrowDown } from 'lucide-react';
import PodStatusIndicator from '@/components/custom/pod-status-indicator';
import { useRouter } from 'next/navigation';
interface AppWithRelations extends App {
appPorts: AppPort[];
appDomains: AppDomain[];
}
interface ProjectNetworkGraphProps {
apps: AppWithRelations[];
}
const PolicyIcon = ({ policy, type, ports }: { policy: string, type: 'ingress' | 'egress', ports: string }) => {
let Icon = Globe;
let color = type === 'egress' ? 'text-blue-500' : 'text-green-500';
let title = policy;
switch (policy) {
case 'ALLOW_ALL':
Icon = Globe;
color = 'text-green-500';
break;
case 'NAMESPACE_ONLY':
Icon = Network;
color = 'text-blue-500';
break;
case 'DENY_ALL':
Icon = Lock;
color = 'text-red-500';
break;
case 'INTERNET_ONLY':
Icon = Cloud;
color = 'text-orange-500';
break;
default:
Icon = Shield;
color = 'text-gray-500';
}
return (
<div className='flex items-center gap-2'>
{/*<div className={`p-1 bg-white rounded-full border shadow-sm ${color}`} title={`${type}: ${title}`}>
<div className=' flex gap-1 items-center'>
<ArrowDown size={13} className='text-gray-500' />
</div>
</div>*/}
<div className={`p-1 bg-white rounded-full border shadow-sm ${color}`} title={`${type}: ${title}`}>
<div className=' flex gap-1 items-center'>
<Icon size={16} />
</div>
</div>
{ports && type === 'ingress' && <div className={`p-1 px-2 bg-white rounded-full border shadow-sm text-xs text-gray-500`} title={`${type}: ${title}`}>
{ports}
</div>}
</div>
);
};
const AppNode = ({ data }: { data: any }) => {
return (
<div className="relative bg-white border border-slate-300 rounded-md p-4 min-w-[150px] shadow-sm text-center cursor-pointer hover:border-slate-400 transition-colors">
<Handle type="target" position={Position.Top} className="!bg-transparent !border-0" />
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<PolicyIcon policy={data.ingressPolicy} ports={data.ports} type="ingress" />
</div>
<div className="font-semibold text-sm mt-2 mb-2 flex gap-3 items-center justify-center">
<PodStatusIndicator appId={data.appId} /> <p>{data.label}</p>
</div>
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 z-10">
<PolicyIcon policy={data.egressPolicy} ports={data.ports} type="egress" />
</div>
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-0" />
</div>
);
};
const nodeTypes = {
appNode: AppNode,
};
const Legend = () => {
return (
<div className="mt-4 p-4 border rounded-md bg-slate-50 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-1 text-xs uppercase text-slate-500 pb-2">Node Layout</h4>
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 border border-slate-300 rounded bg-white relative mx-1">
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-slate-200 rounded-full border border-slate-300"></div>
</div>
<span>Top Icon: <strong>Ingress Policy</strong> (Incoming traffic)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 border border-slate-300 rounded bg-white relative mx-1">
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-slate-200 rounded-full border border-slate-300"></div>
</div>
<span>Bottom Icon: <strong>Egress Policy</strong> (Outgoing traffic)</span>
</div>
</div>
<div>
<h4 className="font-medium mb-1 text-xs uppercase text-slate-500 pb-2">Network Policy Types</h4>
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2">
<Globe size={16} className="text-green-500" />
<span>Allow All</span>
</div>
<div className="flex items-center gap-2">
<Network size={16} className="text-blue-500" />
<span>Project Only</span>
</div>
<div className="flex items-center gap-2">
<Cloud size={16} className="text-orange-500" />
<span>Internet Only</span>
</div>
<div className="flex items-center gap-2">
<Lock size={16} className="text-red-500" />
<span>Deny All</span>
</div>
</div>
</div>
</div>
</div>
);
};
export default function ProjectNetworkGraph({ apps }: ProjectNetworkGraphProps) {
const router = useRouter();
const { nodes, edges } = useMemo(() => {
const nodes: Node[] = [];
const edges: Edge[] = [];
const radius = 200;
const centerX = 400;
const centerY = 300;
// Check if we need an Internet node
const hasInternetAccess = apps.some(app => app.appDomains.length > 0);
if (hasInternetAccess) {
nodes.push({
id: 'INTERNET',
position: { x: centerX, y: centerY - radius - 150 },
data: { label: 'Internet' },
style: { background: '#e0e0e0', border: '1px solid #777', padding: 10, borderRadius: '50%', width: 100, height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' },
type: 'input', // It's a source only
});
}
apps.forEach((app, index) => {
// Circular layout
const angle = (index / apps.length) * 2 * Math.PI;
const x = centerX + radius * Math.cos(angle);
const y = centerY + radius * Math.sin(angle);
const ports = Array.from(new Set([
...app.appDomains,
...app.appPorts
].map(d => d.port))).join(', ');
nodes.push({
id: app.id,
position: { x, y },
data: {
label: app.name,
ingressPolicy: app.ingressNetworkPolicy,
egressPolicy: app.egressNetworkPolicy,
appId: app.id,
ports
},
type: 'appNode',
});
// Edge from Internet to App
if (app.appDomains.length > 0) {
const hostnames = app.appDomains.map(d => d.hostname).join(', ');
edges.push({
id: `INTERNET-${app.id}`,
source: 'INTERNET',
target: app.id,
label: `${hostnames}`,
markerEnd: {
type: MarkerType.ArrowClosed,
},
animated: true,
style: { stroke: '#000' },
});
}
});
return { nodes, edges };
}, [apps]);
return (
<div className="space-y-4">
<div style={{ height: 600, border: '1px solid #eee', borderRadius: 8 }}>
<ReactFlow
defaultNodes={nodes}
defaultEdges={edges}
nodeTypes={nodeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
onNodeClick={(event, node) => {
if (node.id !== 'INTERNET') {
router.push(`/project/app/${node.id}`);
}
}}
>
{/* <Background />
<Controls />*/}
</ReactFlow>
</div>
<Legend />
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import AppTable from "./apps-table";
import ProjectNetworkGraph from "./project-network-graph";
import { App } from "@prisma/client";
import { UserSession } from "@/shared/model/sim-session.model";
interface ProjectOverviewProps {
apps: any[]; // Using any to avoid complex type imports, as we know the data structure is correct
session: UserSession;
projectId: string;
}
export default function ProjectOverview({ apps, session, projectId }: ProjectOverviewProps) {
return (
<Tabs defaultValue="table" className="w-full">
<TabsList>
<TabsTrigger value="table">Table View</TabsTrigger>
<TabsTrigger value="graph">Network Graph</TabsTrigger>
</TabsList>
<TabsContent value="table">
<AppTable session={session} app={apps} projectId={projectId} />
</TabsContent>
<TabsContent value="graph">
<ProjectNetworkGraph apps={apps} />
</TabsContent>
</Tabs>
);
}

View File

@@ -74,6 +74,10 @@ class AppService {
where: {
projectId
},
include: {
appPorts: true,
appDomains: true
},
orderBy: {
name: 'asc'
}

116
yarn.lock
View File

@@ -2956,12 +2956,19 @@
resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2"
integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==
"@types/d3-drag@^3.0.7":
version "3.0.7"
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
dependencies:
"@types/d3-selection" "*"
"@types/d3-ease@^3.0.0":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b"
integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==
"@types/d3-interpolate@^3.0.1":
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.1", "@types/d3-interpolate@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
@@ -2980,6 +2987,11 @@
dependencies:
"@types/d3-time" "*"
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
version "3.0.11"
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
"@types/d3-shape@^3.1.0":
version "3.1.6"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72"
@@ -2997,6 +3009,21 @@
resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70"
integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==
"@types/d3-transition@^3.0.8":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
dependencies:
"@types/d3-selection" "*"
"@types/d3-zoom@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
dependencies:
"@types/d3-interpolate" "*"
"@types/d3-selection" "*"
"@types/debug@4.1.7":
version "4.1.7"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82"
@@ -3242,6 +3269,30 @@
resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396"
integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==
"@xyflow/react@^12.10.0":
version "12.10.0"
resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.0.tgz#d2924cb38074e8e6141643dd8bd7a0666aaac868"
integrity sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==
dependencies:
"@xyflow/system" "0.0.74"
classcat "^5.0.3"
zustand "^4.4.0"
"@xyflow/system@0.0.74":
version "0.0.74"
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.74.tgz#4bc01af020504387c88a3b13ac6d426b47cba845"
integrity sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==
dependencies:
"@types/d3-drag" "^3.0.7"
"@types/d3-interpolate" "^3.0.4"
"@types/d3-selection" "^3.0.10"
"@types/d3-transition" "^3.0.8"
"@types/d3-zoom" "^3.0.8"
d3-drag "^3.0.0"
d3-interpolate "^3.0.1"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
abab@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
@@ -3907,6 +3958,11 @@ class-variance-authority@^0.7.1:
dependencies:
clsx "^2.1.1"
classcat@^5.0.3:
version "5.0.5"
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
client-only@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1"
@@ -4129,7 +4185,20 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2"
integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==
d3-ease@^3.0.1:
"d3-dispatch@1 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3", d3-drag@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
dependencies:
d3-dispatch "1 - 3"
d3-selection "3"
"d3-ease@1 - 3", d3-ease@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
@@ -4139,7 +4208,7 @@ d3-ease@^3.0.1:
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641"
integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==
"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
@@ -4162,6 +4231,11 @@ d3-scale@^4.0.2:
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
d3-shape@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5"
@@ -4183,11 +4257,33 @@ d3-shape@^3.1.0:
dependencies:
d3-array "2 - 3"
d3-timer@^3.0.1:
"d3-timer@1 - 3", d3-timer@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0"
integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==
"d3-transition@2 - 3":
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f"
integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==
dependencies:
d3-color "1 - 3"
d3-dispatch "1 - 3"
d3-ease "1 - 3"
d3-interpolate "1 - 3"
d3-timer "1 - 3"
d3-zoom@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
d3-drag "2 - 3"
d3-interpolate "1 - 3"
d3-selection "2 - 3"
d3-transition "2 - 3"
damerau-levenshtein@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7"
@@ -8649,6 +8745,11 @@ use-sidecar@^1.1.2:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@^1.2.2:
version "1.6.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
util-deprecate@^1.0.1, util-deprecate@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -9034,6 +9135,13 @@ zod@^3.23.8:
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==
zustand@^4.4.0:
version "4.5.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zustand@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.1.tgz#2bdca5e4be172539558ce3974fe783174a48fdcf"