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;
|
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 {
|
.nodes-table th {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
|
import { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
import NodeConnections from './NodeConnections'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
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
|
Back to cluster
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
|
||||||
|
<NodeConnections key={`${clusterId}/${nodeId}`} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{activePage === 'cluster-details' && clusterDetails && (
|
{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