diff --git a/src/App.css b/src/App.css index a7fc4d2..eab8b3d 100644 --- a/src/App.css +++ b/src/App.css @@ -821,3 +821,39 @@ } .hardware-metric meter { width: 100%; height: 16px; margin-top: 12px; } .hardware-metric p, .hardware-updated { font-size: 13px; margin: 12px 0 0; } + +.database-connections { + margin-bottom: 28px; +} + +.database-connection-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + gap: 14px; +} + +.database-connection-metric { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.database-connection-metric h3 { + margin: 0; + color: var(--text); + font-size: 0.9rem; + font-weight: 500; +} + +.database-connection-metric strong { + color: var(--text-h); + font-size: 1.75rem; +} + +.database-connection-metric meter { + width: 100%; +} diff --git a/src/App.tsx b/src/App.tsx index 0d003b0..71e4fec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom' import './App.css' import NodeConnections from './NodeConnections' +import NodeActiveConnections from './NodeActiveConnections' +import NodePerformance from './NodePerformance' import NodeHardware from './NodeHardware' import ClusterNodeCard from './ClusterNodeCard' @@ -619,7 +621,19 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) { {nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && ( - + <> + node.uuid === nodeId)!.node_name} + /> + node.uuid === nodeId)!.node_name} + /> + + > )} )} diff --git a/src/NodeActiveConnections.tsx b/src/NodeActiveConnections.tsx new file mode 100644 index 0000000..1f88dca --- /dev/null +++ b/src/NodeActiveConnections.tsx @@ -0,0 +1,57 @@ +import useNodeDatabaseMetric from './useNodeDatabaseMetric' + +type ActiveConnections = { + database_name: string + active_connections: number + current_connections: number + max_connections: number + connection_usage_percent: number +} + +function isActiveConnections(value: unknown): value is ActiveConnections { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' + && ['active_connections', 'current_connections', 'max_connections'].every((field) => { + const count = data[field] + return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 + }) + && typeof data.connection_usage_percent === 'number' + && Number.isFinite(data.connection_usage_percent) + && data.connection_usage_percent >= 0 && data.connection_usage_percent <= 100 +} + + +export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'active-connections', isActiveConnections) + const metrics = [ + { label: 'Active connections', value: data?.active_connections }, + { label: 'Current connections', value: data?.current_connections }, + { label: 'Max connections', value: data?.max_connections }, + ] + + return ( + + + + Database connections + {data && Database: {data.database_name}} + + {connection} + + + {metrics.map(({ label, value }) => ( + + {label} + {value?.toLocaleString() ?? '—'} + + ))} + + Connection usage + {data ? `${data.connection_usage_percent.toFixed(1)}%` : '—'} + {data && } + + + + ) +} diff --git a/src/NodePerformance.tsx b/src/NodePerformance.tsx new file mode 100644 index 0000000..24a5aa8 --- /dev/null +++ b/src/NodePerformance.tsx @@ -0,0 +1,43 @@ +import useNodeDatabaseMetric from './useNodeDatabaseMetric' + +type Performance = { + database_name: string + tps: number + cache_hit_ratio: number +} + +function isPerformance(value: unknown): value is Performance { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' + && typeof data.tps === 'number' && Number.isFinite(data.tps) && data.tps >= 0 + && typeof data.cache_hit_ratio === 'number' && Number.isFinite(data.cache_hit_ratio) + && data.cache_hit_ratio >= 0 && data.cache_hit_ratio <= 100 +} + +export default function NodePerformance({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'performance', isPerformance) + + return ( + + + + Database performance + {data && Database: {data.database_name}} + + {connection} + + + + Transactions per second (TPS) + {data ? data.tps.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 }) : '—'} + + + Cache hit ratio + {data ? `${data.cache_hit_ratio.toFixed(2)}%` : '—'} + {data && } + + + + ) +} diff --git a/src/useNodeDatabaseMetric.ts b/src/useNodeDatabaseMetric.ts new file mode 100644 index 0000000..7edc013 --- /dev/null +++ b/src/useNodeDatabaseMetric.ts @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react' + +export default function useNodeDatabaseMetric( + nodeId: string, + nodeName: string, + endpoint: string, + validate: (value: unknown) => value is T, +) { + 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/${endpoint}`) + 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 metrics…') + } + current.onmessage = (event) => { + if (!active) return + try { + const message: unknown = JSON.parse(event.data) + if (!validate(message)) throw new Error('Invalid metrics') + setData(message) + setConnection('Live') + } catch { + setData(null) + setConnection('Invalid metrics received') + } + } + current.onerror = () => current.close() + current.onclose = disconnected + } catch { + disconnected() + } + } + + void connect() + return () => { + active = false + controller.abort() + clearTimeout(retry) + socket?.close() + } + }, [nodeId, nodeName, endpoint, validate]) + + return { data, connection } +} +
Database: {data.database_name}
{connection}