ADDED :
--NodeDetailsDashboard.tsx
This commit is contained in:
1 parent
ae96ca4756
commit
48d5635095
4 files changed
+211
-4
No files matched your search
+68
-3
@@ -1100,28 +1100,93 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.node-docker-panel {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard > .node-docker-panel {
|
||||
padding: 0.8% 1.2%;
|
||||
}
|
||||
|
||||
.node-docker-panel code {
|
||||
font-size: inherit;
|
||||
padding: 0.3% 1%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.node-docker-panel .page-header {
|
||||
align-items: center;
|
||||
gap: 0 2%;
|
||||
margin-bottom: 0.5%;
|
||||
padding-bottom: 0.5%;
|
||||
}
|
||||
|
||||
.node-docker-panel h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .page-actions {
|
||||
flex: 1 1 60%;
|
||||
gap: 0 2%;
|
||||
}
|
||||
|
||||
.node-docker-panel .page-actions > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .secondary-button {
|
||||
min-height: 0;
|
||||
padding: 0.5% 1.2%;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.node-docker-panel .connection-status {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .docker-view-all {
|
||||
margin: 0.6% 0 0;
|
||||
}
|
||||
|
||||
.node-replication-panel {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.node-replication-panel .nodes-table-wrap {
|
||||
.node-replication-panel .nodes-table-wrap,
|
||||
.node-docker-panel .nodes-table-wrap {
|
||||
margin-top: 1%;
|
||||
}
|
||||
|
||||
.node-replication-panel .nodes-table {
|
||||
.node-replication-panel .nodes-table,
|
||||
.node-docker-panel .nodes-table {
|
||||
min-width: 0;
|
||||
font-size: clamp(0.625rem, 0.8cqw, 0.7rem);
|
||||
}
|
||||
|
||||
.node-replication-panel .nodes-table th,
|
||||
.node-replication-panel .nodes-table td,
|
||||
.node-replication-panel .nodes-table caption {
|
||||
.node-replication-panel .nodes-table caption,
|
||||
.node-docker-panel .nodes-table th,
|
||||
.node-docker-panel .nodes-table td {
|
||||
padding: 1% 1.5%;
|
||||
font-size: inherit;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.node-docker-panel .nodes-table-wrap {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .nodes-table th,
|
||||
.node-docker-panel .nodes-table td {
|
||||
padding: 0.4% 1%;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .status-message {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
|
||||
+15
-1
@@ -5,6 +5,7 @@ import NodeDetailsDashboard from './NodeDetailsDashboard'
|
||||
import NodeDeadTuples from './NodeDeadTuples'
|
||||
import NodeLargestTables from './NodeLargestTables'
|
||||
import NodeHardware from './NodeHardware'
|
||||
import NodeDockerContainers from './NodeDockerContainers'
|
||||
import ClusterNodeCard from './ClusterNodeCard'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
@@ -269,13 +270,14 @@ function App() {
|
||||
<Route path="/clusters/:clusterId" element={<Workspace page="cluster-details" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/dead-tuples" element={<Workspace page="dead-tuples" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/largest-tables" element={<Workspace page="largest-tables" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/docker-containers" element={<Workspace page="docker-containers" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId" element={<Workspace page="node-details" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' | 'dead-tuples' }) {
|
||||
function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' | 'dead-tuples' | 'docker-containers' }) {
|
||||
const navigate = useNavigate()
|
||||
const { clusterId, nodeId } = useParams()
|
||||
const isClusterPage = Boolean(useMatch('/clusters/*'))
|
||||
@@ -632,6 +634,18 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'larg
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{activePage === 'docker-containers' && clusterDetails && (
|
||||
<section className="content-panel node-details-page">
|
||||
<div className="page-header">
|
||||
<h1>{clusterDetails.nodes.find((node) => node.uuid === nodeId)?.node_name ?? 'Node not found'}</h1>
|
||||
</div>
|
||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
|
||||
<div className="node-metrics-dashboard">
|
||||
<NodeDockerContainers key={`${clusterId}/${nodeId}`} nodeId={nodeId} clusterId={clusterId!} detailed />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{activePage === 'largest-tables' && clusterDetails && (
|
||||
<section className="content-panel">
|
||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import NodeWalSize from './NodeWalSize'
|
||||
import NodeDeadTuples from './NodeDeadTuples'
|
||||
import NodeLocksDeadlocks from './NodeLocksDeadlocks'
|
||||
import NodeConnections from './NodeConnections'
|
||||
import NodeDockerContainers from './NodeDockerContainers'
|
||||
|
||||
export default function NodeDetailsDashboard(props: { nodeId: string; nodeName: string; clusterId: string }) {
|
||||
return (
|
||||
@@ -21,6 +22,7 @@ export default function NodeDetailsDashboard(props: { nodeId: string; nodeName:
|
||||
<NodeWalSize {...props} />
|
||||
<NodeLocksDeadlocks {...props} />
|
||||
<NodeDeadTuples {...props} />
|
||||
<NodeDockerContainers key={props.nodeId} nodeId={props.nodeId} clusterId={props.clusterId} />
|
||||
<section className="node-replication-panel" aria-label="Replication connections">
|
||||
<NodeConnections nodeId={props.nodeId} />
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Container = { id: string; name: string; status: string }
|
||||
|
||||
function isContainerResponse(value: unknown): value is { containers: Container[] } {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const containers = (value as Record<string, unknown>).containers
|
||||
return Array.isArray(containers) && containers.every((container: unknown) => {
|
||||
if (!container || typeof container !== 'object') return false
|
||||
const item = container as Record<string, unknown>
|
||||
return typeof item.id === 'string' && typeof item.name === 'string' && typeof item.status === 'string'
|
||||
})
|
||||
}
|
||||
|
||||
export default function NodeDockerContainers({ nodeId, clusterId, detailed = false }: {
|
||||
nodeId: string; clusterId: string; detailed?: boolean
|
||||
}) {
|
||||
const [containers, setContainers] = useState<Container[] | null>(null)
|
||||
const [status, setStatus] = useState('Connecting…')
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
let socket: WebSocket | undefined
|
||||
const disconnected = () => {
|
||||
if (!active) return
|
||||
setContainers(null)
|
||||
setStatus('Docker feed disconnected. Reconnect to try again.')
|
||||
}
|
||||
const connect = async () => {
|
||||
setContainers(null)
|
||||
setStatus('Loading node details…')
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
if (!apiBaseUrl) throw new Error('API URL is not configured.')
|
||||
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?.uuid !== nodeId
|
||||
|| typeof node.host !== 'string' || !node.host.trim()) {
|
||||
throw new Error('Invalid node host received.')
|
||||
}
|
||||
if (!active) return
|
||||
const host = node.host.trim()
|
||||
const hostname = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
|
||||
setStatus('Connecting…')
|
||||
socket = new WebSocket(`ws://${hostname}:9100/ws/docker/containers`)
|
||||
socket.onopen = () => {
|
||||
if (active) setStatus('Waiting for containers…')
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!isContainerResponse(message)) throw new Error('Invalid container data')
|
||||
setContainers(message.containers)
|
||||
setStatus('Live')
|
||||
} catch {
|
||||
setContainers(null)
|
||||
setStatus('Invalid container data received.')
|
||||
}
|
||||
}
|
||||
socket.onerror = disconnected
|
||||
socket.onclose = disconnected
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
setStatus(error instanceof Error ? error.message : 'Unable to connect to the Docker feed.')
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
socket?.close()
|
||||
}
|
||||
}, [attempt, nodeId])
|
||||
|
||||
const nodePath = `/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(nodeId)}`
|
||||
const visibleContainers = detailed ? containers : containers?.slice(0, 3)
|
||||
|
||||
return (
|
||||
<section className="node-docker-panel database-connections" aria-labelledby="docker-containers-heading">
|
||||
<div className="page-header">
|
||||
<h2 id="docker-containers-heading">Docker containers</h2>
|
||||
<div className="page-actions">
|
||||
{detailed && <Link className="secondary-button" to={nodePath}>Back to node</Link>}
|
||||
<p role="status">{status}</p>
|
||||
<button type="button" className="secondary-button" onClick={() => {
|
||||
setContainers(null)
|
||||
setStatus('Connecting…')
|
||||
setAttempt((current) => current + 1)
|
||||
}}>Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
{containers !== null && (containers.length === 0 ? <p>No containers found.</p> : (
|
||||
<div className="nodes-table-wrap">
|
||||
<table className="nodes-table">
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Container ID</th><th scope="col">Status</th></tr></thead>
|
||||
<tbody>
|
||||
{visibleContainers?.map((container) => (
|
||||
<tr key={container.id}>
|
||||
<td>{container.name}</td>
|
||||
<td><code>{container.id}</code></td>
|
||||
<td><span className={`connection-status${container.status === 'running' ? ' connected' : ''}`}>{container.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
{!detailed && containers && containers.length > 3 && (
|
||||
<p className="docker-view-all"><Link to={`${nodePath}/docker-containers`}>View all {containers.length} containers</Link></p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user