--Added websockets
This commit is contained in:
george committed 2026-09-22 12:27:05 +05:30
1 parent 3d0d02d06c
commit 8f43344c84
4 files changed
+193 -1

No files matched your search

+2 -1
View File
@@ -1 +1,2 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_API_BASE_URL=http://127.0.0.1:8000
VITE_WS_BASE_URL=ws://127.0.0.1:9000
+41
View File
@@ -647,6 +647,47 @@
white-space: nowrap;
}
.connections-table {
min-width: 480px;
}
.connection-status {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.connection-status.connected {
color: #15803d;
}
.connection-status.disconnected {
color: #dc2626;
}
.theme-dark .connection-status.connected {
color: #4ade80;
}
.theme-dark .connection-status.disconnected {
color: #f87171;
}
.connection-status-dot {
width: 8px;
height: 8px;
flex-shrink: 0;
border-radius: 50%;
background: currentColor;
}
.connections-table caption {
padding: 14px;
text-align: left;
color: var(--text);
}
.nodes-table th {
background: var(--surface);
color: var(--text);
+4
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
import './App.css'
import NodeConnections from './NodeConnections'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
@@ -615,6 +616,9 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
Back to cluster
</button>
</div>
{clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
<NodeConnections key={`${clusterId}/${nodeId}`} />
)}
</section>
)}
{activePage === 'cluster-details' && clusterDetails && (
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useState } from 'react'
type ReplicationHealth = {
node: string
connection_healthy: boolean
subscriptions: {
subscription_name: string
status: string
provider_node: string
}[]
}
function isReplicationHealth(value: unknown): value is ReplicationHealth {
if (typeof value !== 'object' || value === null) return false
const data = value as Record<string, unknown>
return typeof data.node === 'string'
&& typeof data.connection_healthy === 'boolean'
&& Array.isArray(data.subscriptions)
&& data.subscriptions.every((subscription: unknown) => {
if (typeof subscription !== 'object' || subscription === null) return false
const item = subscription as Record<string, unknown>
return typeof item.subscription_name === 'string'
&& typeof item.status === 'string'
&& typeof item.provider_node === 'string'
})
}
export default function NodeConnections() {
const [health, setHealth] = useState<ReplicationHealth | null>(null)
const [status, setStatus] = useState('Connecting to replication health…')
const [error, setError] = useState('')
const [attempt, setAttempt] = useState(0)
useEffect(() => {
let active = true
let socket: WebSocket
try {
const baseUrl = import.meta.env.VITE_WS_BASE_URL
if (!baseUrl) throw new Error('VITE_WS_BASE_URL is not configured.')
socket = new WebSocket(`${baseUrl.replace(/\/$/, '')}/ws/node/replication-health`)
} catch (cause) {
setStatus('')
setError(cause instanceof Error ? cause.message : 'Unable to open replication health connection.')
return
}
socket.onopen = () => {
if (!active) return
setStatus('Waiting for replication health…')
// Temporary connection details until the API supports the selected node.
socket.send(JSON.stringify({
node_name: 'node3',
host: '172.16.10.178',
port: 5444,
dbname: 'postgres',
user: 'postgres',
password: 'postgres',
}))
}
socket.onmessage = (event) => {
if (!active) return
try {
const data: unknown = JSON.parse(event.data)
if (!isReplicationHealth(data)) throw new Error('Invalid replication health response.')
setHealth(data)
setError('')
setStatus('Live updates connected')
} catch {
setHealth(null)
setStatus('')
setError('Received an invalid replication health response.')
}
}
socket.onerror = () => {
if (!active) return
setHealth(null)
setStatus('')
setError('Unable to receive replication health. Check that the WebSocket server is running.')
}
socket.onclose = () => {
if (!active) return
setHealth(null)
setStatus('')
setError((current) => current || 'Replication health connection closed. Reconnect to receive updates.')
}
return () => {
active = false
socket.close()
}
}, [attempt])
return (
<div>
<h2>Connections</h2>
{status && <p role="status">{status}</p>}
{error && (
<div>
<p className="status-message error" role="alert">{error}</p>
<button className="secondary-button" type="button" onClick={() => {
setHealth(null)
setError('')
setStatus('Connecting to replication health…')
setAttempt((current) => current + 1)
}}>
Reconnect
</button>
</div>
)}
{health && (
<>
<p className={`status-message ${health.connection_healthy ? 'success' : 'error'}`} role="status">
{health.node}: {health.connection_healthy ? 'Connection healthy' : 'Connection unhealthy'}
</p>
{health.subscriptions.length > 0 ? (
<div className="nodes-table-wrap">
<table className="nodes-table connections-table">
<caption>Replication subscriptions for {health.node}</caption>
<thead>
<tr><th scope="col">Provider node</th><th scope="col">Subscription</th><th scope="col">Status</th></tr>
</thead>
<tbody>
{health.subscriptions.map((subscription) => (
<tr key={subscription.subscription_name}>
<td>{subscription.provider_node}</td>
<td>{subscription.subscription_name}</td>
<td>
<span className={`connection-status ${subscription.status === 'replicating' ? 'connected' : subscription.status === 'down' ? 'disconnected' : ''}`}>
{(subscription.status === 'replicating' || subscription.status === 'down') && (
<span className="connection-status-dot" aria-hidden="true" />
)}
{subscription.status === 'replicating' ? 'Connected' : subscription.status === 'down' ? 'Disconnected' : subscription.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : <p>No replication connections found.</p>}
</>
)}
</div>
)
}