feat:
--ADDED details to node cards in cluster
This commit is contained in:
1 parent
08d79770cf
commit
0d46483c39
4 files changed
+162
-8
No files matched your search
+26
-1
@@ -484,7 +484,9 @@
|
||||
}
|
||||
|
||||
.cluster-node-card {
|
||||
display: block;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
@@ -495,6 +497,29 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cluster-node-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cluster-node-database,
|
||||
.cluster-node-uptime > span {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cluster-node-uptime {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.cluster-node-uptime strong {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.cluster-node-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg);
|
||||
|
||||
+3
-7
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
|
||||
import './App.css'
|
||||
import NodeConnections from './NodeConnections'
|
||||
import NodeHardware from './NodeHardware'
|
||||
import ClusterNodeCard from './ClusterNodeCard'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
@@ -639,12 +640,7 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
||||
<ul className="cluster-node-list" aria-label="Cluster nodes">
|
||||
{clusterDetails.nodes.map((node) => (
|
||||
<li key={node.uuid}>
|
||||
<Link
|
||||
className="cluster-node-card"
|
||||
to={`/clusters/${encodeURIComponent(clusterId!)}/nodes/${encodeURIComponent(node.uuid)}`}
|
||||
>
|
||||
{node.node_name}
|
||||
</Link>
|
||||
<ClusterNodeCard node={node} clusterId={clusterId!} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import useDatabaseStatus from './useDatabaseStatus'
|
||||
|
||||
export default function ClusterNodeCard({ node, clusterId }: {
|
||||
node: { uuid: string; node_name: string }
|
||||
clusterId: string
|
||||
}) {
|
||||
const { data: databaseStatus, connection: databaseConnection } = useDatabaseStatus(node.uuid, node.node_name)
|
||||
return (
|
||||
<Link
|
||||
className="cluster-node-card"
|
||||
to={`/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(node.uuid)}`}
|
||||
>
|
||||
<span className="cluster-node-name">{node.node_name}</span>
|
||||
<span className={`connection-status ${databaseStatus?.status === 'online' ? 'connected' : databaseStatus?.status === 'offline' ? 'disconnected' : ''}`}>
|
||||
<span className="connection-status-dot" aria-hidden="true" />
|
||||
{databaseStatus ? databaseStatus.status.charAt(0).toUpperCase() + databaseStatus.status.slice(1) : databaseConnection}
|
||||
</span>
|
||||
<span className="cluster-node-database">Database: {databaseStatus?.database_name ?? '—'}</span>
|
||||
<span className="cluster-node-uptime">
|
||||
<span>Uptime</span>
|
||||
<strong>{databaseStatus ? `${(databaseStatus.uptime_seconds / 3600).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} hours` : '—'}</strong>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type DatabaseStatus = {
|
||||
database_name: string
|
||||
status: string
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
function isDatabaseStatus(value: unknown): value is DatabaseStatus {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& typeof data.status === 'string' && data.status.trim().length > 0
|
||||
&& typeof data.uptime_seconds === 'number'
|
||||
&& Number.isFinite(data.uptime_seconds) && data.uptime_seconds >= 0
|
||||
}
|
||||
|
||||
export default function useDatabaseStatus(nodeId: string, nodeName: string) {
|
||||
const [data, setData] = useState<DatabaseStatus | null>(null)
|
||||
const [connection, setConnection] = useState('Connecting…')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const connect = async () => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setConnection('Connecting…')
|
||||
const disconnected = () => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setConnection('Status unavailable · reconnecting…')
|
||||
clearTimeout(retry)
|
||||
retry = setTimeout(connect, 5000)
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
|
||||
if (!wsBaseUrl) throw new Error('VITE_WS_BASE_URL is not configured')
|
||||
if (!apiBaseUrl) throw new Error('API URL is not configured')
|
||||
setConnection('Loading node details…')
|
||||
const token = localStorage.getItem('access_token')
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl.replace(/\/$/, '')}/spock/node-interfaces/${encodeURIComponent(nodeId)}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to load node details')
|
||||
const result = await response.json()
|
||||
const node = result?.node
|
||||
if (result?.status !== 'success' || !node || node.uuid !== nodeId
|
||||
|| typeof node.host !== 'string' || typeof node.port !== 'number'
|
||||
|| typeof node.dbname !== 'string' || typeof node.user !== 'string') {
|
||||
throw new Error('Invalid node details')
|
||||
}
|
||||
if (!active) return
|
||||
setConnection('Connecting…')
|
||||
const current = new WebSocket(`${wsBaseUrl.replace(/\/$/, '')}/ws/database/status`)
|
||||
socket = current
|
||||
current.onopen = () => {
|
||||
if (!active) return
|
||||
current.send(JSON.stringify({
|
||||
node_name: nodeName,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
dbname: node.dbname,
|
||||
user: node.user,
|
||||
password: 'postgres',
|
||||
}))
|
||||
setConnection('Waiting for status…')
|
||||
}
|
||||
current.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!isDatabaseStatus(message)) throw new Error('Invalid database status')
|
||||
setData({ ...message, status: message.status.trim().toLowerCase() })
|
||||
setConnection('Live')
|
||||
} catch {
|
||||
setData(null)
|
||||
setConnection('Invalid status received')
|
||||
}
|
||||
}
|
||||
current.onerror = () => current.close()
|
||||
current.onclose = disconnected
|
||||
} catch {
|
||||
disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [nodeId, nodeName])
|
||||
|
||||
return { data, connection }
|
||||
}
|
||||
Reference in new issue
Block a user