From 0d46483c39d35499582f0ca8c002e0c34089fb75 Mon Sep 17 00:00:00 2001 From: George Date: Wed, 23 Sep 2026 13:20:14 +0530 Subject: [PATCH] feat: --ADDED details to node cards in cluster --- src/App.css | 27 +++++++++- src/App.tsx | 10 ++-- src/ClusterNodeCard.tsx | 26 ++++++++++ src/useDatabaseStatus.ts | 107 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 src/ClusterNodeCard.tsx create mode 100644 src/useDatabaseStatus.ts 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' }) { 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} + + + 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 } +}