1056 lines
35 KiB
TypeScript
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
|