feat:
--Added websockets
This commit is contained in:
1 parent
3d0d02d06c
commit
8f43344c84
4 files changed
+193
-1
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user