diff --git a/src/App.css b/src/App.css index debf14f..c27f7b6 100644 --- a/src/App.css +++ b/src/App.css @@ -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%; diff --git a/src/App.tsx b/src/App.tsx index 04c91ec..4e9cdca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> ) } -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 )} )} + {activePage === 'docker-containers' && clusterDetails && ( + + + {clusterDetails.nodes.find((node) => node.uuid === nodeId)?.node_name ?? 'Node not found'} + + {nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && ( + + + + )} + + )} {activePage === 'largest-tables' && clusterDetails && ( {nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? ( diff --git a/src/NodeDetailsDashboard.tsx b/src/NodeDetailsDashboard.tsx index 05c29d6..9f545c0 100644 --- a/src/NodeDetailsDashboard.tsx +++ b/src/NodeDetailsDashboard.tsx @@ -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: + diff --git a/src/NodeDockerContainers.tsx b/src/NodeDockerContainers.tsx new file mode 100644 index 0000000..dff9a2b --- /dev/null +++ b/src/NodeDockerContainers.tsx @@ -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).containers + return Array.isArray(containers) && containers.every((container: unknown) => { + if (!container || typeof container !== 'object') return false + const item = container as Record + 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(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 ( + + + Docker containers + + {detailed && Back to node} + {status} + { + setContainers(null) + setStatus('Connecting…') + setAttempt((current) => current + 1) + }}>Reconnect + + + {containers !== null && (containers.length === 0 ? No containers found. : ( + + + NameContainer IDStatus + + {visibleContainers?.map((container) => ( + + {container.name} + {container.id} + {container.status} + + ))} + + + + ))} + {!detailed && containers && containers.length > 3 && ( + View all {containers.length} containers + )} + + ) +}
{status}
No containers found.
{container.id}
View all {containers.length} containers