Files
pgMultiMasterFrontend/src/App.tsx
T
george 9543a22b1a feat:
--GUI CHANGE(NodeDetailsDashboard)
2026-09-29 10:56:28 +05:30

1056 lines
35 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
import './App.css'
import NodeDetailsDashboard from './NodeDetailsDashboard'
import NodeDeadTuples from './NodeDeadTuples'
import NodeLargestTables from './NodeLargestTables'
import NodeHardware from './NodeHardware'
import ClusterNodeCard from './ClusterNodeCard'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
const navItems = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'clusters', label: 'Clusters' },
] as const
type PageId = (typeof navItems)[number]['id'] | 'cluster-details'
type Theme = 'light' | 'dark'
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 Cluster = {
uuid: string
cluster_name: string
}
type ClusterNodesResponse = {
cluster_name: string
nodes: {
node_name: string
uuid: string
}[]
}
const getClusterResource = async (path: string) => {
const accessToken = localStorage.getItem('access_token')
const response = await fetch(`${API_BASE_URL}/spock/${path}`, {
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
})
if (!response.ok) {
throw new Error('Failed to load cluster data')
}
return response
}
type RegisterNodePayload = {
node_name: string
host: string
port: number
dbname: string
user: string
password: string
}
type RegisterNodeResponse = {
status: string
message?: string
}
type NodeInterface = {
if_id: number
if_name: string
if_nodeid: number
host: string
port: number
dbname: string
user: string
uuid: string
}
type NodeInterfacesResponse = {
status: string
count: number
nodes: NodeInterface[]
message?: string
}
type DeleteNodeInterfaceResponse = {
status: string
message?: string
}
type CreateClusterPayload = {
cluster_name: string
links: [string, string][]
}
const getSavedTheme = (): Theme => {
try {
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
} catch {
return 'light'
}
}
const saveTheme = (theme: Theme) => {
try {
localStorage.setItem('theme', theme)
} catch {
return
}
}
const getSavedPage = (): PageId => {
try {
const savedPage = localStorage.getItem('activePage')
if (savedPage === 'cluster-details') return 'clusters'
return navItems.some((item) => item.id === savedPage)
? (savedPage as PageId)
: 'dashboard'
} catch {
return 'dashboard'
}
}
const savePage = (page: PageId) => {
try {
localStorage.setItem('activePage', page)
} catch {
return
}
}
const getSessionToken = async () => {
const response = await fetch(`${API_BASE_URL}/get-token`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to get token')
}
const data = (await response.json()) as {
access_token: string
token_type: string
}
localStorage.setItem('access_token', data.access_token)
const storedToken = localStorage.getItem('access_token')
if (!storedToken) {
throw new Error('Token was not stored')
}
console.log('Token successfully stored:', storedToken)
return storedToken
}
const registerNode = async (
payload: RegisterNodePayload,
): Promise<RegisterNodeResponse> => {
const accessToken = localStorage.getItem('access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(`${API_BASE_URL}/register-node`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('Failed to register node')
}
return response.json() as Promise<RegisterNodeResponse>
}
const getNodeInterfaces = async (): Promise<NodeInterfacesResponse> => {
const accessToken = localStorage.getItem('access_token')
const headers: Record<string, string> = {}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(`${API_BASE_URL}/spock/node-interfaces`, {
method: 'GET',
headers,
})
if (!response.ok) {
throw new Error('Failed to get node interfaces')
}
return response.json() as Promise<NodeInterfacesResponse>
}
const deleteNodeInterface = async (
node: NodeInterface,
): Promise<DeleteNodeInterfaceResponse> => {
const accessToken = localStorage.getItem('access_token')
const headers: Record<string, string> = {}
const params = new URLSearchParams({
uuid: node.uuid,
})
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(
`${API_BASE_URL}/spock/del-node-interfaces?${params.toString()}`,
{
method: 'DELETE',
headers,
},
)
if (!response.ok) {
throw new Error('Failed to delete node interface')
}
return response.json() as Promise<DeleteNodeInterfaceResponse>
}
const createCluster = async (payload: CreateClusterPayload) => {
console.log(JSON.stringify(payload, null, 2))
const accessToken = localStorage.getItem('access_token')
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),
})
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() {
return (
<Routes>
<Route path="/" element={<Navigate to={`/${getSavedPage()}`} replace />} />
<Route path="/dashboard" element={<Workspace page="dashboard" />} />
<Route path="/nodes" element={<Workspace page="nodes" />} />
<Route path="/clusters" element={<Workspace page="clusters" />} />
<Route path="/clusters/:clusterId" element={<Workspace page="cluster-details" />} />
<Route path="/clusters/:clusterId/nodes/:nodeId/dead-tuples" element={<Workspace page="dead-tuples" />} />
<Route path="/clusters/:clusterId/nodes/:nodeId/largest-tables" element={<Workspace page="largest-tables" />} />
<Route path="/clusters/:clusterId/nodes/:nodeId" element={<Workspace page="node-details" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' | 'dead-tuples' }) {
const navigate = useNavigate()
const { clusterId, nodeId } = useParams()
const isClusterPage = Boolean(useMatch('/clusters/*'))
const [isCollapsed, setIsCollapsed] = useState(false)
const [theme, setTheme] = useState<Theme>(getSavedTheme)
const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false)
const [isClusterFormOpen, setIsClusterFormOpen] = useState(false)
const [clusterStep, setClusterStep] = useState<1 | 2>(1)
const [selectedClusterNodeIds, setSelectedClusterNodeIds] = useState<string[]>(
[],
)
const [clusterName, setClusterName] = useState('')
const [connectionNodeIndex, setConnectionNodeIndex] = useState(0)
const [nodeConnections, setNodeConnections] = useState<Record<string, string[]>>({})
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('idle')
const [tokenError, setTokenError] = useState('')
const [registerStatus, setRegisterStatus] = useState<RegisterStatus>('idle')
const [registerError, setRegisterError] = useState('')
const [nodes, setNodes] = useState<NodeInterface[]>([])
const [nodesStatus, setNodesStatus] = useState<NodesStatus>('idle')
const [nodesError, setNodesError] = useState('')
const [deletingNodeId, setDeletingNodeId] = useState<number | null>(null)
const [clusterStatus, setClusterStatus] = useState<ClusterStatus>('idle')
const [clusterError, setClusterError] = useState('')
const [clusters, setClusters] = useState<Cluster[]>([])
const [clustersStatus, setClustersStatus] = useState<ClusterStatus>('idle')
const [clustersError, setClustersError] = useState('')
const [clusterNodesStatus, setClusterNodesStatus] = useState<ClusterStatus>('idle')
const [clusterNodesError, setClusterNodesError] = useState('')
const [selectedCluster, setSelectedCluster] = useState<Cluster | null>(null)
const [clusterDetails, setClusterDetails] = useState<ClusterNodesResponse | null>(null)
const nextTheme = theme === 'light' ? 'dark' : 'light'
const selectedClusterNodes = nodes.filter((node) =>
selectedClusterNodeIds.includes(node.uuid),
)
const currentConnectionNode = selectedClusterNodes[connectionNodeIndex]
const loadClusters = async () => {
setClustersStatus('loading')
setClustersError('')
try {
const response = await getClusterResource('clusters')
setClusters(await response.json() as Cluster[])
setClustersStatus('success')
} catch (error) {
setClustersStatus('error')
setClustersError(error instanceof Error ? error.message : 'Failed to load clusters')
}
}
const handleClusterClick = (cluster: Cluster) => {
setSelectedCluster(cluster)
navigate(`/clusters/${encodeURIComponent(cluster.uuid)}`)
}
useEffect(() => {
if (!clusterId) return
let cancelled = false
setClusterDetails(null)
setClusterNodesStatus('loading')
setClusterNodesError('')
void getClusterResource(`cluster-nodes/${encodeURIComponent(clusterId)}`)
.then((response) => response.json() as Promise<ClusterNodesResponse>)
.then((details) => {
if (cancelled) return
setClusterDetails(details)
setClusterNodesStatus('success')
})
.catch((error: unknown) => {
if (cancelled) return
setClusterNodesStatus('error')
setClusterNodesError(error instanceof Error ? error.message : 'Failed to load cluster nodes')
})
return () => { cancelled = true }
}, [clusterId])
const loadNodeInterfaces = async () => {
setNodesStatus('loading')
setNodesError('')
try {
const nodeInterfacesResponse = await getNodeInterfaces()
if (nodeInterfacesResponse.status !== 'success') {
throw new Error(
nodeInterfacesResponse.message ?? 'Failed to get node interfaces',
)
}
setNodes(nodeInterfacesResponse.nodes)
setNodesStatus('success')
} catch (error) {
setNodesStatus('error')
setNodesError(
error instanceof Error
? error.message
: 'Failed to get node interfaces',
)
}
}
useEffect(() => {
if (activePage === 'clusters') {
void loadClusters()
}
if (activePage === 'dashboard' || activePage === 'nodes' || activePage === 'clusters') {
void loadNodeInterfaces()
}
}, [activePage])
const selectPage = (page: PageId) => {
navigate(`/${page}`)
savePage(page)
}
const toggleTheme = () => {
setTheme((currentTheme) => {
const updatedTheme = currentTheme === 'light' ? 'dark' : 'light'
saveTheme(updatedTheme)
return updatedTheme
})
}
const handleTempButtonClick = async () => {
setTokenStatus('loading')
setTokenError('')
try {
await getSessionToken()
setTokenStatus('success')
await loadNodeInterfaces()
} catch (error) {
setTokenStatus('error')
setTokenError(
error instanceof Error ? error.message : 'Failed to get token',
)
}
}
const handleRegisterNode = async (
event: React.FormEvent<HTMLFormElement>,
) => {
event.preventDefault()
const form = event.currentTarget
setRegisterStatus('loading')
setRegisterError('')
const formData = new FormData(form)
const payload: RegisterNodePayload = {
node_name: String(formData.get('node_name') ?? ''),
host: String(formData.get('host') ?? ''),
port: Number(formData.get('port')),
dbname: String(formData.get('dbname') ?? ''),
user: String(formData.get('user') ?? ''),
password: String(formData.get('password') ?? ''),
}
try {
const registerResponse = await registerNode(payload)
console.log('Node registration response:', registerResponse)
if (registerResponse.status !== 'success') {
throw new Error(registerResponse.message ?? 'Failed to register node')
}
setRegisterStatus('success')
form.reset()
setIsRegisterFormOpen(false)
window.location.reload()
} catch (error) {
setRegisterStatus('error')
setRegisterError(
error instanceof Error ? error.message : 'Failed to register node',
)
}
}
const handleDeleteNodeClick = async (node: NodeInterface) => {
setDeletingNodeId(node.if_id)
setNodesError('')
try {
const deleteResponse = await deleteNodeInterface(node)
if (deleteResponse.status !== 'success') {
throw new Error(deleteResponse.message ?? 'Failed to delete node')
}
await loadNodeInterfaces()
} catch (error) {
setNodesStatus('error')
setNodesError(
error instanceof Error ? error.message : 'Failed to delete node',
)
} finally {
setDeletingNodeId(null)
}
}
const handleClusterNodeToggle = (nodeUuid: string, isSelected: boolean) => {
setSelectedClusterNodeIds((currentNodeIds) =>
isSelected
? [...currentNodeIds, nodeUuid]
: currentNodeIds.filter((currentNodeId) => currentNodeId !== nodeUuid),
)
}
const handleClusterDetailsNext = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const name = String(formData.get('cluster_name') ?? '').trim()
if (!name || selectedClusterNodeIds.length < 2) {
setClusterStatus('error')
setClusterError(!name ? 'Enter a cluster name' : 'Select at least two nodes for the cluster')
return
}
console.log(JSON.stringify({ cluster_name: name, links: [] }, null, 2))
setClusterName(name)
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
}
setClusterStatus('loading')
setClusterError('')
try {
await createCluster(payload)
setClusterStatus('success')
setIsClusterFormOpen(false)
await loadClusters()
} catch (error) {
setClusterStatus('error')
setClusterError(
error instanceof Error ? error.message : 'Failed to create cluster',
)
}
}
return (
<div
className={`app-shell theme-${theme} ${isCollapsed ? 'nav-collapsed' : ''}`}
>
<aside className="side-nav" aria-label="Primary navigation">
<div className="nav-header">
<span className="nav-title">PG Multi Master</span>
<button
type="button"
className="nav-toggle"
onClick={() => setIsCollapsed((collapsed) => !collapsed)}
aria-label={isCollapsed ? 'Expand navigation' : 'Collapse navigation'}
aria-expanded={!isCollapsed}
>
<span aria-hidden="true">{isCollapsed ? '>' : '<'}</span>
</button>
</div>
<nav className="nav-list">
{navItems.map((item) => (
<button
key={item.id}
type="button"
className={`nav-link ${(activePage === item.id || (item.id === 'clusters' && isClusterPage)) ? 'active' : ''}`}
title={isCollapsed ? item.label : undefined}
onClick={() => selectPage(item.id)}
aria-current={(activePage === item.id || (item.id === 'clusters' && isClusterPage)) ? 'page' : undefined}
>
<span className="nav-icon" aria-hidden="true">
{item.label.charAt(0)}
</span>
<span className="nav-label">{item.label}</span>
</button>
))}
</nav>
<button
type="button"
className="theme-toggle"
onClick={toggleTheme}
title={isCollapsed ? `Switch to ${nextTheme} theme` : undefined}
aria-label={`Switch to ${nextTheme} theme`}
>
<span className="theme-icon" aria-hidden="true">
{theme === 'light' ? 'D' : 'L'}
</span>
<span className="theme-label">
{theme === 'light' ? 'Dark mode' : 'Light mode'}
</span>
</button>
</aside>
<main className="main-content">
{clusterId && (
<>
{clusterNodesStatus === 'loading' && <p role="status">Loading...</p>}
{clusterNodesStatus === 'error' && (
<div>
<p className="status-message error" role="alert">{clusterNodesError}</p>
<button type="button" className="secondary-button" onClick={() => navigate(activePage === 'node-details' ? `/clusters/${encodeURIComponent(clusterId)}` : '/clusters')}>
Back
</button>
</div>
)}
</>
)}
{activePage === 'node-details' && clusterDetails && (
<section className="content-panel node-details-page" id="node-details">
<div className="page-header">
<h1>{clusterDetails.nodes.find((node) => node.uuid === nodeId)?.node_name ?? 'Node not found'}</h1>
<button
type="button"
className="secondary-button"
onClick={() => navigate(`/clusters/${encodeURIComponent(clusterId!)}`)}
>
Back to cluster
</button>
</div>
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
<NodeDetailsDashboard
key={`${clusterId}/${nodeId}`}
clusterId={clusterId!}
nodeId={nodeId}
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
/>
)}
</section>
)}
{activePage === 'largest-tables' && clusterDetails && (
<section className="content-panel">
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? (
<NodeLargestTables
key={`tables-page/${clusterId}/${nodeId}`}
clusterId={clusterId!}
nodeId={nodeId}
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
detailed
/>
) : <h1>Node not found</h1>}
</section>
)}
{activePage === 'dead-tuples' && clusterDetails && (
<section className="content-panel">
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? (
<NodeDeadTuples
key={`dead-tuples-page/${clusterId}/${nodeId}`}
clusterId={clusterId!}
nodeId={nodeId}
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
detailed
/>
) : <h1>Node not found</h1>}
</section>
)}
{activePage === 'cluster-details' && clusterDetails && (
<section className="content-panel" id="cluster-details">
<div className="page-header">
<h1>{clusterDetails.cluster_name}</h1>
<button
type="button"
className="secondary-button"
onClick={() => selectPage('clusters')}
>
Back to clusters
</button>
</div>
<h2>Nodes</h2>
{clusterDetails.nodes.length > 0 ? (
<ul className="cluster-node-list" aria-label="Cluster nodes">
{clusterDetails.nodes.map((node) => (
<li key={node.uuid}>
<ClusterNodeCard node={node} clusterId={clusterId!} />
</li>
))}
</ul>
) : (
<p>No nodes found in this cluster.</p>
)}
</section>
)}
{activePage === 'dashboard' && (
<section className="content-panel hardware-dashboard" id="dashboard">
<p className="eyebrow">Dashboard</p>
<h1>Hardware usage</h1>
<p>Live CPU, memory, and disk usage for each registered node.</p>
{(nodesStatus === 'idle' || nodesStatus === 'loading') && <p role="status">Loading nodes…</p>}
{nodesStatus === 'error' && <div>
<p className="status-message error" role="alert">{nodesError}</p>
<button className="secondary-button" onClick={() => void loadNodeInterfaces()}>Retry loading nodes</button>
</div>}
{nodesStatus === 'success' && (nodes.length === 0
? <p>No registered nodes. Add a node from the Nodes page to monitor hardware usage.</p>
: <div className="hardware-nodes">
{nodes.map((node) => <NodeHardware key={`${node.uuid}:${node.host}`} name={node.if_name} host={node.host} />)}
</div>)}
</section>
)}
{activePage === 'nodes' && (
<section className="content-panel nodes-page" id="nodes">
<div className="page-header">
<h1>Nodes</h1>
<div className="page-actions">
<button
type="button"
className="primary-button"
onClick={() => {
setIsRegisterFormOpen(true)
setRegisterStatus('idle')
setRegisterError('')
}}
>
Register Nodes
</button>
<button
type="button"
className="secondary-button"
onClick={handleTempButtonClick}
disabled={tokenStatus === 'loading'}
>
{tokenStatus === 'loading' ? 'Getting Token' : 'Temp Button'}
</button>
</div>
</div>
{tokenStatus === 'success' && (
<p className="status-message success">Token successfully stored.</p>
)}
{tokenStatus === 'error' && (
<p className="status-message error">{tokenError}</p>
)}
{registerStatus === 'success' && (
<p className="status-message success">Node registered successfully.</p>
)}
{registerStatus === 'error' && (
<p className="status-message error">{registerError}</p>
)}
{nodesStatus === 'error' && (
<p className="status-message error">{nodesError}</p>
)}
{isRegisterFormOpen ? (
<form className="register-form" onSubmit={handleRegisterNode}>
<div className="form-grid">
<label className="form-field">
<span>Node Name</span>
<input name="node_name" type="text" required />
</label>
<label className="form-field">
<span>Host</span>
<input
name="host"
type="text"
inputMode="decimal"
pattern="^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$"
placeholder="192.168.1.10"
required
/>
</label>
<label className="form-field">
<span>Port</span>
<input name="port" type="number" min="1" max="65535" required />
</label>
<label className="form-field">
<span>Database Name</span>
<input name="dbname" type="text" required />
</label>
<label className="form-field">
<span>User</span>
<input name="user" type="text" required />
</label>
<label className="form-field">
<span>Password</span>
<input name="password" type="password" required />
</label>
</div>
<div className="form-actions">
<button
type="button"
className="secondary-button"
onClick={() => setIsRegisterFormOpen(false)}
>
Cancel
</button>
<button
type="submit"
className="primary-button"
disabled={registerStatus === 'loading'}
>
{registerStatus === 'loading' ? 'Registering' : 'Register'}
</button>
</div>
</form>
) : null}
{!isRegisterFormOpen && (
<div className="nodes-table-wrap">
{nodesStatus === 'loading' ? (
<p className="table-state">Loading nodes...</p>
) : nodes.length > 0 ? (
<table className="nodes-table">
<thead>
<tr>
<th>Node ID</th>
<th>Node Name</th>
<th>Host</th>
<th>Port</th>
<th>DB Name</th>
<th>DB Username</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.if_id}>
<td>{node.if_nodeid}</td>
<td>{node.if_name}</td>
<td>{node.host}</td>
<td>{node.port}</td>
<td>{node.dbname}</td>
<td>{node.user}</td>
<td>
<button
type="button"
className="danger-button"
disabled={deletingNodeId === node.if_id}
onClick={() => handleDeleteNodeClick(node)}
>
{deletingNodeId === node.if_id
? 'Deleting'
: 'Delete'}
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="table-state">No nodes found.</p>
)}
</div>
)}
</section>
)}
{activePage === 'clusters' && (
<section className="content-panel" id="clusters">
<div className="page-header">
<h1>Clusters</h1>
<button
type="button"
className="primary-button"
onClick={() => {
setIsClusterFormOpen(true)
setClusterStep(1)
setClusterStatus('idle')
setClusterError('')
setSelectedClusterNodeIds([])
setNodeConnections({})
}}
disabled={clusterStatus === 'loading'}
>
Create Cluster
</button>
</div>
{!isClusterFormOpen && (
<div>
{clustersStatus === 'loading' && <p role="status">Loading clusters...</p>}
{clustersStatus === 'error' && (
<p className="status-message error" role="alert">{clustersError}</p>
)}
{clustersStatus === 'success' && (
clusters.length > 0 ? (
<ul className="cluster-list">
{clusters.map((cluster) => (
<li key={cluster.uuid}>
<button
type="button"
className="secondary-button"
onClick={() => void handleClusterClick(cluster)}
disabled={clusterNodesStatus === 'loading'}
aria-pressed={selectedCluster?.uuid === cluster.uuid}
>
{cluster.cluster_name}
</button>
</li>
))}
</ul>
) : <p>No clusters found.</p>
)}
{clusterNodesStatus === 'loading' && <p role="status">Loading cluster nodes...</p>}
{clusterNodesStatus === 'error' && (
<p className="status-message error" role="alert">{clusterNodesError}</p>
)}
</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' && (
<p className="status-message success">Cluster created successfully.</p>
)}
{clusterStatus === 'error' && (
<p className="status-message error">{clusterError}</p>
)}
{nodesStatus === 'error' && (
<p className="status-message error">{nodesError}</p>
)}
{isClusterFormOpen && clusterStep === 2 && currentConnectionNode && (
<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">
<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>
</div>
<div className="form-actions">
<button
type="button"
className="secondary-button"
onClick={() => {
setIsClusterFormOpen(false)
setSelectedClusterNodeIds([])
setNodeConnections({})
}}
>
Cancel
</button>
<button
type="submit"
className="primary-button"
disabled={clusterStatus === 'loading'}
>
{clusterStatus === 'loading' ? 'Continuing' : 'Next'}
</button>
</div>
</form>
) : null}
</section>
)}
</main>
</div>
)
}
export default App