feat:
--ADDED System matric --ADDED Node performante UI
This commit is contained in:
1 parent
0d46483c39
commit
662611e10a
5 files changed
+249
-1
No files matched your search
+36
@@ -821,3 +821,39 @@
|
|||||||
}
|
}
|
||||||
.hardware-metric meter { width: 100%; height: 16px; margin-top: 12px; }
|
.hardware-metric meter { width: 100%; height: 16px; margin-top: 12px; }
|
||||||
.hardware-metric p, .hardware-updated { font-size: 13px; margin: 12px 0 0; }
|
.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%;
|
||||||
|
}
|
||||||
+15
-1
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'
|
|||||||
import { 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 NodeActiveConnections from './NodeActiveConnections'
|
||||||
|
import NodePerformance from './NodePerformance'
|
||||||
import NodeHardware from './NodeHardware'
|
import NodeHardware from './NodeHardware'
|
||||||
import ClusterNodeCard from './ClusterNodeCard'
|
import ClusterNodeCard from './ClusterNodeCard'
|
||||||
|
|
||||||
@@ -619,7 +621,19 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
|
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
|
||||||
<NodeConnections key={`${clusterId}/${nodeId}`} nodeId={nodeId} />
|
<>
|
||||||
|
<NodeActiveConnections
|
||||||
|
key={`metrics/${clusterId}/${nodeId}`}
|
||||||
|
nodeId={nodeId}
|
||||||
|
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||||
|
/>
|
||||||
|
<NodePerformance
|
||||||
|
key={`performance/${clusterId}/${nodeId}`}
|
||||||
|
nodeId={nodeId}
|
||||||
|
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||||
|
/>
|
||||||
|
<NodeConnections key={`${clusterId}/${nodeId}`} nodeId={nodeId} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
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 (
|
||||||
|
<section className="database-connections" aria-labelledby="database-connections-heading">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="database-connections-heading">Database connections</h2>
|
||||||
|
{data && <p>Database: {data.database_name}</p>}
|
||||||
|
</div>
|
||||||
|
<p role="status" className={data ? 'connection-status connected' : ''}>{connection}</p>
|
||||||
|
</div>
|
||||||
|
<div className="database-connection-metrics">
|
||||||
|
{metrics.map(({ label, value }) => (
|
||||||
|
<div className="database-connection-metric" key={label}>
|
||||||
|
<h3>{label}</h3>
|
||||||
|
<strong>{value?.toLocaleString() ?? '—'}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="database-connection-metric">
|
||||||
|
<h3>Connection usage</h3>
|
||||||
|
<strong>{data ? `${data.connection_usage_percent.toFixed(1)}%` : '—'}</strong>
|
||||||
|
{data && <meter aria-label="Connection usage" min={0} max={100} value={data.connection_usage_percent} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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 (
|
||||||
|
<section className="database-connections" aria-labelledby="database-performance-heading">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="database-performance-heading">Database performance</h2>
|
||||||
|
{data && <p>Database: {data.database_name}</p>}
|
||||||
|
</div>
|
||||||
|
<p role="status" className={data ? 'connection-status connected' : ''}>{connection}</p>
|
||||||
|
</div>
|
||||||
|
<div className="database-connection-metrics">
|
||||||
|
<div className="database-connection-metric">
|
||||||
|
<h3>Transactions per second (TPS)</h3>
|
||||||
|
<strong>{data ? data.tps.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 }) : '—'}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="database-connection-metric">
|
||||||
|
<h3>Cache hit ratio</h3>
|
||||||
|
<strong>{data ? `${data.cache_hit_ratio.toFixed(2)}%` : '—'}</strong>
|
||||||
|
{data && <meter aria-label="Cache hit ratio" min={0} max={100} value={data.cache_hit_ratio} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export default function useNodeDatabaseMetric<T>(
|
||||||
|
nodeId: string,
|
||||||
|
nodeName: string,
|
||||||
|
endpoint: string,
|
||||||
|
validate: (value: unknown) => value is T,
|
||||||
|
) {
|
||||||
|
const [data, setData] = useState<T | 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/${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 }
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user