diff --git a/src/App.css b/src/App.css
index 5a5c160..a7fc4d2 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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);
diff --git a/src/App.tsx b/src/App.tsx
index 0eb44da..0d003b0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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' }) {
{clusterDetails.nodes.map((node) => (
-
-
- {node.node_name}
-
+
))}
diff --git a/src/ClusterNodeCard.tsx b/src/ClusterNodeCard.tsx
new file mode 100644
index 0000000..063154b
--- /dev/null
+++ b/src/ClusterNodeCard.tsx
@@ -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 (
+
+ {node.node_name}
+
+
+ {databaseStatus ? databaseStatus.status.charAt(0).toUpperCase() + databaseStatus.status.slice(1) : databaseConnection}
+
+ Database: {databaseStatus?.database_name ?? '—'}
+
+ Uptime
+ {databaseStatus ? `${(databaseStatus.uptime_seconds / 3600).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} hours` : '—'}
+
+
+ )
+}
diff --git a/src/useDatabaseStatus.ts b/src/useDatabaseStatus.ts
new file mode 100644
index 0000000..c61fbbb
--- /dev/null
+++ b/src/useDatabaseStatus.ts
@@ -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
+ 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(null)
+ const [connection, setConnection] = useState('Connecting…')
+
+ useEffect(() => {
+ let active = true
+ const controller = new AbortController()
+ let socket: WebSocket | undefined
+ let retry: ReturnType | 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 }
+}