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 {
|
.cluster-node-card {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -495,6 +497,29 @@
|
|||||||
overflow-wrap: anywhere;
|
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 {
|
.cluster-node-card:hover {
|
||||||
border-color: var(--accent-border);
|
border-color: var(--accent-border);
|
||||||
background: var(--accent-bg);
|
background: var(--accent-bg);
|
||||||
|
|||||||
+3
-7
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 './App.css'
|
||||||
import NodeConnections from './NodeConnections'
|
import NodeConnections from './NodeConnections'
|
||||||
import NodeHardware from './NodeHardware'
|
import NodeHardware from './NodeHardware'
|
||||||
|
import ClusterNodeCard from './ClusterNodeCard'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
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">
|
<ul className="cluster-node-list" aria-label="Cluster nodes">
|
||||||
{clusterDetails.nodes.map((node) => (
|
{clusterDetails.nodes.map((node) => (
|
||||||
<li key={node.uuid}>
|
<li key={node.uuid}>
|
||||||
<Link
|
<ClusterNodeCard node={node} clusterId={clusterId!} />
|
||||||
className="cluster-node-card"
|
|
||||||
to={`/clusters/${encodeURIComponent(clusterId!)}/nodes/${encodeURIComponent(node.uuid)}`}
|
|
||||||
>
|
|
||||||
{node.node_name}
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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