diff --git a/.env b/.env index 7972a46..7f32bf1 100644 --- a/.env +++ b/.env @@ -1 +1,2 @@ -VITE_API_BASE_URL=http://127.0.0.1:8000 \ No newline at end of file +VITE_API_BASE_URL=http://127.0.0.1:8000 +VITE_WS_BASE_URL=ws://127.0.0.1:9000 diff --git a/src/App.css b/src/App.css index 70e4321..a4486bb 100644 --- a/src/App.css +++ b/src/App.css @@ -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); diff --git a/src/App.tsx b/src/App.tsx index fe16100..59ff600 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 + {clusterDetails.nodes.some((node) => node.uuid === nodeId) && ( + + )} )} {activePage === 'cluster-details' && clusterDetails && ( diff --git a/src/NodeConnections.tsx b/src/NodeConnections.tsx new file mode 100644 index 0000000..45297b3 --- /dev/null +++ b/src/NodeConnections.tsx @@ -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 + 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 + return typeof item.subscription_name === 'string' + && typeof item.status === 'string' + && typeof item.provider_node === 'string' + }) +} + +export default function NodeConnections() { + const [health, setHealth] = useState(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 ( +
+

Connections

+ {status &&

{status}

} + {error && ( +
+

{error}

+ +
+ )} + {health && ( + <> +

+ {health.node}: {health.connection_healthy ? 'Connection healthy' : 'Connection unhealthy'} +

+ {health.subscriptions.length > 0 ? ( +
+ + + + + + + {health.subscriptions.map((subscription) => ( + + + + + + ))} + +
Replication subscriptions for {health.node}
Provider nodeSubscriptionStatus
{subscription.provider_node}{subscription.subscription_name} + + {(subscription.status === 'replicating' || subscription.status === 'down') && ( + +
+
+ ) :

No replication connections found.

} + + )} +
+ ) +}