feat:
--Added UI feature for adding connections
This commit is contained in:
1 parent
95bb3cd050
commit
0832a2318a
2 files changed
+157
-189
No files matched your search
+31
@@ -468,6 +468,37 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cluster-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 24px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-steps li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-steps li[aria-current='step'] {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cluster-step-number {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
.communication-rules {
|
.communication-rules {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
+126
-189
@@ -15,7 +15,6 @@ type TokenStatus = 'idle' | 'loading' | 'success' | 'error'
|
|||||||
type RegisterStatus = 'idle' | 'loading' | 'success' | 'error'
|
type RegisterStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||||
type NodesStatus = 'idle' | 'loading' | 'success' | 'error'
|
type NodesStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||||
type ClusterStatus = 'idle' | 'loading' | 'success' | 'error'
|
type ClusterStatus = 'idle' | 'loading' | 'success' | 'error'
|
||||||
type CommunicationDirection = 'none' | 'forward' | 'reverse' | 'both'
|
|
||||||
|
|
||||||
type RegisterNodePayload = {
|
type RegisterNodePayload = {
|
||||||
node_name: string
|
node_name: string
|
||||||
@@ -56,16 +55,9 @@ type DeleteNodeInterfaceResponse = {
|
|||||||
|
|
||||||
type CreateClusterPayload = {
|
type CreateClusterPayload = {
|
||||||
cluster_name: string
|
cluster_name: string
|
||||||
nodes: string[]
|
links: [string, string][]
|
||||||
connections: {
|
|
||||||
publisher_node: string
|
|
||||||
subscriber_node: string
|
|
||||||
}[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCommunicationKey = (sourceUuid: string, targetUuid: string) =>
|
|
||||||
`${sourceUuid}__${targetUuid}`
|
|
||||||
|
|
||||||
const getSavedTheme = (): Theme => {
|
const getSavedTheme = (): Theme => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
|
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
|
||||||
@@ -86,6 +78,8 @@ const getSavedPage = (): PageId => {
|
|||||||
try {
|
try {
|
||||||
const savedPage = localStorage.getItem('activePage')
|
const savedPage = localStorage.getItem('activePage')
|
||||||
|
|
||||||
|
if (savedPage === 'cluster-details') return 'clusters'
|
||||||
|
|
||||||
return navItems.some((item) => item.id === savedPage)
|
return navItems.some((item) => item.id === savedPage)
|
||||||
? (savedPage as PageId)
|
? (savedPage as PageId)
|
||||||
: 'dashboard'
|
: 'dashboard'
|
||||||
@@ -203,13 +197,34 @@ const deleteNodeInterface = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createCluster = async (payload: CreateClusterPayload) => {
|
const createCluster = async (payload: CreateClusterPayload) => {
|
||||||
console.log('Cluster creation payload:', payload)
|
console.log(JSON.stringify(payload, null, 2))
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
const accessToken = localStorage.getItem('access_token')
|
||||||
window.setTimeout(resolve, 500)
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
headers.Authorization = `Bearer ${accessToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/spock/create_cluster`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
|
|
||||||
return { status: 'success' }
|
const result = await response.json().catch(() => null) as {
|
||||||
|
status?: string
|
||||||
|
message?: string
|
||||||
|
detail?: string
|
||||||
|
} | null
|
||||||
|
|
||||||
|
if (!response.ok || (result?.status && result.status !== 'success')) {
|
||||||
|
throw new Error(
|
||||||
|
result?.message || result?.detail || 'Failed to create cluster',
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -218,11 +233,13 @@ function App() {
|
|||||||
const [theme, setTheme] = useState<Theme>(getSavedTheme)
|
const [theme, setTheme] = useState<Theme>(getSavedTheme)
|
||||||
const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false)
|
const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false)
|
||||||
const [isClusterFormOpen, setIsClusterFormOpen] = useState(false)
|
const [isClusterFormOpen, setIsClusterFormOpen] = useState(false)
|
||||||
|
const [clusterStep, setClusterStep] = useState<1 | 2>(1)
|
||||||
const [selectedClusterNodeIds, setSelectedClusterNodeIds] = useState<string[]>(
|
const [selectedClusterNodeIds, setSelectedClusterNodeIds] = useState<string[]>(
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const [clusterCommunicationDirections, setClusterCommunicationDirections] =
|
const [clusterName, setClusterName] = useState('')
|
||||||
useState<Record<string, CommunicationDirection>>({})
|
const [connectionNodeIndex, setConnectionNodeIndex] = useState(0)
|
||||||
|
const [nodeConnections, setNodeConnections] = useState<Record<string, string[]>>({})
|
||||||
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('idle')
|
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('idle')
|
||||||
const [tokenError, setTokenError] = useState('')
|
const [tokenError, setTokenError] = useState('')
|
||||||
const [registerStatus, setRegisterStatus] = useState<RegisterStatus>('idle')
|
const [registerStatus, setRegisterStatus] = useState<RegisterStatus>('idle')
|
||||||
@@ -238,6 +255,8 @@ function App() {
|
|||||||
selectedClusterNodeIds.includes(node.uuid),
|
selectedClusterNodeIds.includes(node.uuid),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const currentConnectionNode = selectedClusterNodes[connectionNodeIndex]
|
||||||
|
|
||||||
const loadNodeInterfaces = async () => {
|
const loadNodeInterfaces = async () => {
|
||||||
setNodesStatus('loading')
|
setNodesStatus('loading')
|
||||||
setNodesError('')
|
setNodesError('')
|
||||||
@@ -369,81 +388,40 @@ function App() {
|
|||||||
: currentNodeIds.filter((currentNodeId) => currentNodeId !== nodeUuid),
|
: currentNodeIds.filter((currentNodeId) => currentNodeId !== nodeUuid),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isSelected) {
|
|
||||||
setClusterCommunicationDirections((currentDirections) =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(currentDirections).filter(
|
|
||||||
([key]) => !key.includes(nodeUuid),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClusterDirectionChange = (
|
const handleClusterDetailsNext = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
pairKey: string,
|
|
||||||
direction: CommunicationDirection,
|
|
||||||
) => {
|
|
||||||
setClusterCommunicationDirections((currentDirections) => ({
|
|
||||||
...currentDirections,
|
|
||||||
[pairKey]: direction,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateCluster = async (
|
|
||||||
event: React.FormEvent<HTMLFormElement>,
|
|
||||||
) => {
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const form = event.currentTarget
|
const formData = new FormData(event.currentTarget)
|
||||||
const formData = new FormData(form)
|
const name = String(formData.get('cluster_name') ?? '').trim()
|
||||||
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') {
|
if (!name || selectedClusterNodeIds.length < 2) {
|
||||||
return [
|
setClusterStatus('error')
|
||||||
{
|
setClusterError(!name ? 'Enter a cluster name' : 'Select at least two nodes for the cluster')
|
||||||
publisher_node: sourceNode.uuid,
|
return
|
||||||
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) {
|
console.log(JSON.stringify({ cluster_name: name, links: [] }, null, 2))
|
||||||
setClusterStatus('error')
|
setClusterName(name)
|
||||||
setClusterError('Select at least two nodes for the cluster')
|
setConnectionNodeIndex(0)
|
||||||
|
setClusterStatus('idle')
|
||||||
|
setClusterError('')
|
||||||
|
setClusterStep(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConnectionNext = async () => {
|
||||||
|
const payload: CreateClusterPayload = {
|
||||||
|
cluster_name: clusterName,
|
||||||
|
links: selectedClusterNodes.flatMap((node) =>
|
||||||
|
selectedClusterNodes
|
||||||
|
.filter((target) => (nodeConnections[node.uuid] ?? []).includes(target.uuid))
|
||||||
|
.map((target): [string, string] => [node.if_name, target.if_name]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionNodeIndex < selectedClusterNodes.length - 1) {
|
||||||
|
console.log(JSON.stringify(payload, null, 2))
|
||||||
|
setConnectionNodeIndex((index) => index + 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,16 +429,9 @@ function App() {
|
|||||||
setClusterError('')
|
setClusterError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clusterResponse = await createCluster(payload)
|
await createCluster(payload)
|
||||||
|
|
||||||
if (clusterResponse.status !== 'success') {
|
|
||||||
throw new Error('Failed to create cluster')
|
|
||||||
}
|
|
||||||
|
|
||||||
setClusterStatus('success')
|
setClusterStatus('success')
|
||||||
form.reset()
|
|
||||||
setSelectedClusterNodeIds([])
|
|
||||||
setClusterCommunicationDirections({})
|
|
||||||
setIsClusterFormOpen(false)
|
setIsClusterFormOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setClusterStatus('error')
|
setClusterStatus('error')
|
||||||
@@ -701,10 +672,11 @@ function App() {
|
|||||||
className="primary-button"
|
className="primary-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsClusterFormOpen(true)
|
setIsClusterFormOpen(true)
|
||||||
|
setClusterStep(1)
|
||||||
setClusterStatus('idle')
|
setClusterStatus('idle')
|
||||||
setClusterError('')
|
setClusterError('')
|
||||||
setSelectedClusterNodeIds([])
|
setSelectedClusterNodeIds([])
|
||||||
setClusterCommunicationDirections({})
|
setNodeConnections({})
|
||||||
}}
|
}}
|
||||||
disabled={clusterStatus === 'loading'}
|
disabled={clusterStatus === 'loading'}
|
||||||
>
|
>
|
||||||
@@ -712,6 +684,19 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isClusterFormOpen && (
|
||||||
|
<ol className="cluster-steps" aria-label="Cluster creation progress">
|
||||||
|
<li aria-current={clusterStep === 1 ? 'step' : undefined}>
|
||||||
|
<span className="cluster-step-number">1</span>
|
||||||
|
Cluster name and nodes
|
||||||
|
</li>
|
||||||
|
<li aria-current={clusterStep === 2 ? 'step' : undefined}>
|
||||||
|
<span className="cluster-step-number">2</span>
|
||||||
|
Node connections
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
|
||||||
{clusterStatus === 'success' && (
|
{clusterStatus === 'success' && (
|
||||||
<p className="status-message success">Cluster created successfully.</p>
|
<p className="status-message success">Cluster created successfully.</p>
|
||||||
)}
|
)}
|
||||||
@@ -724,8 +709,52 @@ function App() {
|
|||||||
<p className="status-message error">{nodesError}</p>
|
<p className="status-message error">{nodesError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isClusterFormOpen ? (
|
{isClusterFormOpen && clusterStep === 2 && currentConnectionNode && (
|
||||||
<form className="register-form" onSubmit={handleCreateCluster}>
|
<div className="register-form">
|
||||||
|
<p className="eyebrow">
|
||||||
|
Node {connectionNodeIndex + 1} of {selectedClusterNodes.length}
|
||||||
|
</p>
|
||||||
|
<fieldset className="form-field node-picker" key={currentConnectionNode.uuid}>
|
||||||
|
<legend>Connect {currentConnectionNode.if_name} to</legend>
|
||||||
|
<div className="node-choice-list">
|
||||||
|
{selectedClusterNodes
|
||||||
|
.filter((node) => node.uuid !== currentConnectionNode.uuid)
|
||||||
|
.map((node) => (
|
||||||
|
<label className="node-choice" key={node.uuid}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(nodeConnections[currentConnectionNode.uuid] ?? []).includes(node.uuid)}
|
||||||
|
disabled={clusterStatus === 'loading'}
|
||||||
|
onChange={(event) => {
|
||||||
|
const checked = event.currentTarget.checked
|
||||||
|
setNodeConnections((current) => ({
|
||||||
|
...current,
|
||||||
|
[currentConnectionNode.uuid]: checked
|
||||||
|
? [...(current[currentConnectionNode.uuid] ?? []), node.uuid]
|
||||||
|
: (current[currentConnectionNode.uuid] ?? []).filter((uuid) => uuid !== node.uuid),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{node.if_name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
disabled={clusterStatus === 'loading'}
|
||||||
|
onClick={() => void handleConnectionNext()}
|
||||||
|
>
|
||||||
|
{clusterStatus === 'loading' ? 'Creating' : connectionNodeIndex === selectedClusterNodes.length - 1 ? 'Finish' : 'Next'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isClusterFormOpen && clusterStep === 1 ? (
|
||||||
|
<form className="register-form" onSubmit={handleClusterDetailsNext}>
|
||||||
<div className="form-grid cluster-form-grid">
|
<div className="form-grid cluster-form-grid">
|
||||||
<label className="form-field">
|
<label className="form-field">
|
||||||
<span>Cluster Name</span>
|
<span>Cluster Name</span>
|
||||||
@@ -763,98 +792,6 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{selectedClusterNodes.length > 1 ? (
|
|
||||||
<fieldset className="form-field communication-rules">
|
|
||||||
<legend>Communication</legend>
|
|
||||||
<div className="communication-list">
|
|
||||||
{selectedClusterNodes.map((sourceNode, sourceIndex) =>
|
|
||||||
selectedClusterNodes
|
|
||||||
.slice(sourceIndex + 1)
|
|
||||||
.map((targetNode) => {
|
|
||||||
const pairKey = getCommunicationKey(
|
|
||||||
sourceNode.uuid,
|
|
||||||
targetNode.uuid,
|
|
||||||
)
|
|
||||||
const direction =
|
|
||||||
clusterCommunicationDirections[pairKey] ??
|
|
||||||
'both'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="communication-card" key={pairKey}>
|
|
||||||
<span className="communication-title">
|
|
||||||
{sourceNode.if_name} - {targetNode.if_name}
|
|
||||||
</span>
|
|
||||||
<div className="direction-options">
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
name={`direction-${pairKey}`}
|
|
||||||
type="radio"
|
|
||||||
checked={direction === 'both'}
|
|
||||||
onChange={() =>
|
|
||||||
handleClusterDirectionChange(
|
|
||||||
pairKey,
|
|
||||||
'both',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>Both directions</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
name={`direction-${pairKey}`}
|
|
||||||
type="radio"
|
|
||||||
checked={direction === 'forward'}
|
|
||||||
onChange={() =>
|
|
||||||
handleClusterDirectionChange(
|
|
||||||
pairKey,
|
|
||||||
'forward',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{sourceNode.if_name} to{' '}
|
|
||||||
{targetNode.if_name}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
name={`direction-${pairKey}`}
|
|
||||||
type="radio"
|
|
||||||
checked={direction === 'reverse'}
|
|
||||||
onChange={() =>
|
|
||||||
handleClusterDirectionChange(
|
|
||||||
pairKey,
|
|
||||||
'reverse',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{targetNode.if_name} to{' '}
|
|
||||||
{sourceNode.if_name}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<input
|
|
||||||
name={`direction-${pairKey}`}
|
|
||||||
type="radio"
|
|
||||||
checked={direction === 'none'}
|
|
||||||
onChange={() =>
|
|
||||||
handleClusterDirectionChange(
|
|
||||||
pairKey,
|
|
||||||
'none',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span>No publishing</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
@@ -864,7 +801,7 @@ function App() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsClusterFormOpen(false)
|
setIsClusterFormOpen(false)
|
||||||
setSelectedClusterNodeIds([])
|
setSelectedClusterNodeIds([])
|
||||||
setClusterCommunicationDirections({})
|
setNodeConnections({})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -874,7 +811,7 @@ function App() {
|
|||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={clusterStatus === 'loading'}
|
disabled={clusterStatus === 'loading'}
|
||||||
>
|
>
|
||||||
{clusterStatus === 'loading' ? 'Creating' : 'Create'}
|
{clusterStatus === 'loading' ? 'Continuing' : 'Next'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in new issue
Block a user