diff --git a/src/App.css b/src/App.css index 216639d..090697e 100644 --- a/src/App.css +++ b/src/App.css @@ -416,6 +416,127 @@ color: var(--text); } +.cluster-form-grid { + grid-template-columns: 1fr; +} + +.node-picker { + min-width: 0; + margin: 0; + border: 0; + padding: 0; +} + +.node-picker legend { + margin-bottom: 8px; + color: var(--text-h); + font-size: 14px; + font-weight: 600; +} + +.node-choice-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.node-choice { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + min-height: 40px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text-h); + font-size: 15px; + padding: 0 12px; +} + +.node-choice input { + width: 16px; + height: 16px; + flex: 0 0 16px; + margin: 0; + accent-color: var(--accent); +} + +.node-choice span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.communication-rules { + min-width: 0; + margin: 0; + border: 0; + padding: 0; +} + +.communication-rules legend { + margin-bottom: 8px; + color: var(--text-h); + font-size: 14px; + font-weight: 600; +} + +.communication-list { + display: grid; + gap: 12px; +} + +.communication-card { + display: grid; + gap: 12px; + min-width: 0; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + padding: 14px; +} + +.communication-title { + overflow: hidden; + color: var(--text-h); + font-size: 15px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.direction-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.direction-options label { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + color: var(--text-h); + font-size: 14px; +} + +.direction-options input { + width: 15px; + height: 15px; + flex: 0 0 15px; + margin: 0; + accent-color: var(--accent); +} + +.direction-options span { + overflow: hidden; + font-size: 14px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + .form-actions { display: flex; justify-content: flex-end; @@ -504,4 +625,12 @@ .form-grid { grid-template-columns: 1fr; } + + .node-choice-list { + grid-template-columns: 1fr; + } + + .direction-options { + grid-template-columns: 1fr; + } } diff --git a/src/App.tsx b/src/App.tsx index e32dd10..c79802b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ type TokenStatus = 'idle' | 'loading' | 'success' | 'error' type RegisterStatus = 'idle' | 'loading' | 'success' | 'error' type NodesStatus = 'idle' | 'loading' | 'success' | 'error' type ClusterStatus = 'idle' | 'loading' | 'success' | 'error' +type CommunicationDirection = 'none' | 'forward' | 'reverse' | 'both' type RegisterNodePayload = { node_name: string @@ -53,6 +54,18 @@ type DeleteNodeInterfaceResponse = { message?: string } +type CreateClusterPayload = { + cluster_name: string + nodes: string[] + connections: { + publisher_node: string + subscriber_node: string + }[] +} + +const getCommunicationKey = (sourceUuid: string, targetUuid: string) => + `${sourceUuid}__${targetUuid}` + const getSavedTheme = (): Theme => { try { return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light' @@ -189,7 +202,9 @@ const deleteNodeInterface = async ( return response.json() as Promise } -const createCluster = async () => { +const createCluster = async (payload: CreateClusterPayload) => { + console.log('Cluster creation payload:', payload) + await new Promise((resolve) => { window.setTimeout(resolve, 500) }) @@ -202,6 +217,12 @@ function App() { const [activePage, setActivePage] = useState(getSavedPage) const [theme, setTheme] = useState(getSavedTheme) const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false) + const [isClusterFormOpen, setIsClusterFormOpen] = useState(false) + const [selectedClusterNodeIds, setSelectedClusterNodeIds] = useState( + [], + ) + const [clusterCommunicationDirections, setClusterCommunicationDirections] = + useState>({}) const [tokenStatus, setTokenStatus] = useState('idle') const [tokenError, setTokenError] = useState('') const [registerStatus, setRegisterStatus] = useState('idle') @@ -213,6 +234,9 @@ function App() { const [clusterStatus, setClusterStatus] = useState('idle') const [clusterError, setClusterError] = useState('') const nextTheme = theme === 'light' ? 'dark' : 'light' + const selectedClusterNodes = nodes.filter((node) => + selectedClusterNodeIds.includes(node.uuid), + ) const loadNodeInterfaces = async () => { setNodesStatus('loading') @@ -240,7 +264,7 @@ function App() { } useEffect(() => { - if (activePage === 'nodes') { + if (activePage === 'nodes' || activePage === 'clusters') { void loadNodeInterfaces() } }, [activePage]) @@ -338,18 +362,106 @@ function App() { } } - const handleCreateClusterClick = async () => { + const handleClusterNodeToggle = (nodeUuid: string, isSelected: boolean) => { + setSelectedClusterNodeIds((currentNodeIds) => + isSelected + ? [...currentNodeIds, nodeUuid] + : currentNodeIds.filter((currentNodeId) => currentNodeId !== nodeUuid), + ) + + if (!isSelected) { + setClusterCommunicationDirections((currentDirections) => + Object.fromEntries( + Object.entries(currentDirections).filter( + ([key]) => !key.includes(nodeUuid), + ), + ), + ) + } + } + + const handleClusterDirectionChange = ( + pairKey: string, + direction: CommunicationDirection, + ) => { + setClusterCommunicationDirections((currentDirections) => ({ + ...currentDirections, + [pairKey]: direction, + })) + } + + const handleCreateCluster = async ( + event: React.FormEvent, + ) => { + event.preventDefault() + const form = event.currentTarget + const formData = new FormData(form) + const connections = selectedClusterNodes.flatMap((sourceNode, sourceIndex) => + selectedClusterNodes.slice(sourceIndex + 1).flatMap((targetNode) => { + const pairKey = getCommunicationKey(sourceNode.uuid, targetNode.uuid) + const direction = clusterCommunicationDirections[pairKey] ?? 'both' + + if (direction === 'both') { + return [ + { + publisher_node: sourceNode.uuid, + subscriber_node: targetNode.uuid, + }, + { + publisher_node: targetNode.uuid, + subscriber_node: sourceNode.uuid, + }, + ] + } + + if (direction === 'forward') { + return [ + { + publisher_node: sourceNode.uuid, + subscriber_node: targetNode.uuid, + }, + ] + } + + if (direction === 'reverse') { + return [ + { + publisher_node: targetNode.uuid, + subscriber_node: sourceNode.uuid, + }, + ] + } + + return [] + }), + ) + const payload: CreateClusterPayload = { + cluster_name: String(formData.get('cluster_name') ?? ''), + nodes: selectedClusterNodeIds, + connections, + } + + if (payload.nodes.length < 2) { + setClusterStatus('error') + setClusterError('Select at least two nodes for the cluster') + return + } + setClusterStatus('loading') setClusterError('') try { - const clusterResponse = await createCluster() + const clusterResponse = await createCluster(payload) if (clusterResponse.status !== 'success') { throw new Error('Failed to create cluster') } setClusterStatus('success') + form.reset() + setSelectedClusterNodeIds([]) + setClusterCommunicationDirections({}) + setIsClusterFormOpen(false) } catch (error) { setClusterStatus('error') setClusterError( @@ -587,22 +699,186 @@ function App() { {clusterStatus === 'success' && ( -

Dummy API triggered.

+

Cluster created successfully.

)} {clusterStatus === 'error' && (

{clusterError}

)} + + {nodesStatus === 'error' && ( +

{nodesError}

+ )} + + {isClusterFormOpen ? ( +
+
+ + +
+ Nodes + {nodesStatus === 'loading' ? ( +

Loading nodes...

+ ) : nodes.length > 0 ? ( +
+ {nodes.map((node) => ( + + ))} +
+ ) : ( +

No nodes found.

+ )} +
+ + {selectedClusterNodes.length > 1 ? ( +
+ Communication +
+ {selectedClusterNodes.map((sourceNode, sourceIndex) => + selectedClusterNodes + .slice(sourceIndex + 1) + .map((targetNode) => { + const pairKey = getCommunicationKey( + sourceNode.uuid, + targetNode.uuid, + ) + const direction = + clusterCommunicationDirections[pairKey] ?? + 'both' + + return ( +
+ + {sourceNode.if_name} - {targetNode.if_name} + +
+ + + + +
+
+ ) + }), + )} +
+
+ ) : null} +
+ +
+ + +
+
+ ) : null} )}