feat:
--Added form and node connection in create cluster
This commit is contained in:
1 parent
9d58606491
commit
95bb3cd050
2 files changed
+414
-9
No files matched your search
+129
@@ -416,6 +416,127 @@
|
|||||||
color: var(--text);
|
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 {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -504,4 +625,12 @@
|
|||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-choice-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-options {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+285
-9
@@ -15,6 +15,7 @@ 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
|
||||||
@@ -53,6 +54,18 @@ type DeleteNodeInterfaceResponse = {
|
|||||||
message?: string
|
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 => {
|
const getSavedTheme = (): Theme => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
|
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
|
||||||
@@ -189,7 +202,9 @@ const deleteNodeInterface = async (
|
|||||||
return response.json() as Promise<DeleteNodeInterfaceResponse>
|
return response.json() as Promise<DeleteNodeInterfaceResponse>
|
||||||
}
|
}
|
||||||
|
|
||||||
const createCluster = async () => {
|
const createCluster = async (payload: CreateClusterPayload) => {
|
||||||
|
console.log('Cluster creation payload:', payload)
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
window.setTimeout(resolve, 500)
|
window.setTimeout(resolve, 500)
|
||||||
})
|
})
|
||||||
@@ -202,6 +217,12 @@ function App() {
|
|||||||
const [activePage, setActivePage] = useState<PageId>(getSavedPage)
|
const [activePage, setActivePage] = useState<PageId>(getSavedPage)
|
||||||
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 [selectedClusterNodeIds, setSelectedClusterNodeIds] = useState<string[]>(
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
const [clusterCommunicationDirections, setClusterCommunicationDirections] =
|
||||||
|
useState<Record<string, CommunicationDirection>>({})
|
||||||
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')
|
||||||
@@ -213,6 +234,9 @@ function App() {
|
|||||||
const [clusterStatus, setClusterStatus] = useState<ClusterStatus>('idle')
|
const [clusterStatus, setClusterStatus] = useState<ClusterStatus>('idle')
|
||||||
const [clusterError, setClusterError] = useState('')
|
const [clusterError, setClusterError] = useState('')
|
||||||
const nextTheme = theme === 'light' ? 'dark' : 'light'
|
const nextTheme = theme === 'light' ? 'dark' : 'light'
|
||||||
|
const selectedClusterNodes = nodes.filter((node) =>
|
||||||
|
selectedClusterNodeIds.includes(node.uuid),
|
||||||
|
)
|
||||||
|
|
||||||
const loadNodeInterfaces = async () => {
|
const loadNodeInterfaces = async () => {
|
||||||
setNodesStatus('loading')
|
setNodesStatus('loading')
|
||||||
@@ -240,7 +264,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activePage === 'nodes') {
|
if (activePage === 'nodes' || activePage === 'clusters') {
|
||||||
void loadNodeInterfaces()
|
void loadNodeInterfaces()
|
||||||
}
|
}
|
||||||
}, [activePage])
|
}, [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<HTMLFormElement>,
|
||||||
|
) => {
|
||||||
|
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')
|
setClusterStatus('loading')
|
||||||
setClusterError('')
|
setClusterError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const clusterResponse = await createCluster()
|
const clusterResponse = await createCluster(payload)
|
||||||
|
|
||||||
if (clusterResponse.status !== 'success') {
|
if (clusterResponse.status !== 'success') {
|
||||||
throw new Error('Failed to create cluster')
|
throw new Error('Failed to create cluster')
|
||||||
}
|
}
|
||||||
|
|
||||||
setClusterStatus('success')
|
setClusterStatus('success')
|
||||||
|
form.reset()
|
||||||
|
setSelectedClusterNodeIds([])
|
||||||
|
setClusterCommunicationDirections({})
|
||||||
|
setIsClusterFormOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setClusterStatus('error')
|
setClusterStatus('error')
|
||||||
setClusterError(
|
setClusterError(
|
||||||
@@ -587,22 +699,186 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
onClick={handleCreateClusterClick}
|
onClick={() => {
|
||||||
|
setIsClusterFormOpen(true)
|
||||||
|
setClusterStatus('idle')
|
||||||
|
setClusterError('')
|
||||||
|
setSelectedClusterNodeIds([])
|
||||||
|
setClusterCommunicationDirections({})
|
||||||
|
}}
|
||||||
disabled={clusterStatus === 'loading'}
|
disabled={clusterStatus === 'loading'}
|
||||||
>
|
>
|
||||||
{clusterStatus === 'loading'
|
Create Cluster
|
||||||
? 'Creating Cluster'
|
|
||||||
: 'Create Cluster'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{clusterStatus === 'success' && (
|
{clusterStatus === 'success' && (
|
||||||
<p className="status-message success">Dummy API triggered.</p>
|
<p className="status-message success">Cluster created successfully.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{clusterStatus === 'error' && (
|
{clusterStatus === 'error' && (
|
||||||
<p className="status-message error">{clusterError}</p>
|
<p className="status-message error">{clusterError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{nodesStatus === 'error' && (
|
||||||
|
<p className="status-message error">{nodesError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isClusterFormOpen ? (
|
||||||
|
<form className="register-form" onSubmit={handleCreateCluster}>
|
||||||
|
<div className="form-grid cluster-form-grid">
|
||||||
|
<label className="form-field">
|
||||||
|
<span>Cluster Name</span>
|
||||||
|
<input name="cluster_name" type="text" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset className="form-field node-picker">
|
||||||
|
<legend>Nodes</legend>
|
||||||
|
{nodesStatus === 'loading' ? (
|
||||||
|
<p className="table-state">Loading nodes...</p>
|
||||||
|
) : nodes.length > 0 ? (
|
||||||
|
<div className="node-choice-list">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<label className="node-choice" key={node.if_id}>
|
||||||
|
<input
|
||||||
|
name="nodes"
|
||||||
|
type="checkbox"
|
||||||
|
value={node.uuid}
|
||||||
|
checked={selectedClusterNodeIds.includes(
|
||||||
|
node.uuid,
|
||||||
|
)}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleClusterNodeToggle(
|
||||||
|
node.uuid,
|
||||||
|
event.currentTarget.checked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{node.if_name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="table-state">No nodes found.</p>
|
||||||
|
)}
|
||||||
|
</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 className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsClusterFormOpen(false)
|
||||||
|
setSelectedClusterNodeIds([])
|
||||||
|
setClusterCommunicationDirections({})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="primary-button"
|
||||||
|
disabled={clusterStatus === 'loading'}
|
||||||
|
>
|
||||||
|
{clusterStatus === 'loading' ? 'Creating' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in new issue
Block a user