diff --git a/src/App.tsx b/src/App.tsx index 3bd5d67..9e49bd6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ import NodeConnections from './NodeConnections' import NodeActiveConnections from './NodeActiveConnections' import NodePerformance from './NodePerformance' import NodeTupleActivity from './NodeTupleActivity' +import NodeWalGenerated from './NodeWalGenerated' +import NodeWalSize from './NodeWalSize' import NodeDeadTuples from './NodeDeadTuples' import NodeLocksDeadlocks from './NodeLocksDeadlocks' import NodeDatabaseSize from './NodeDatabaseSize' @@ -645,6 +647,16 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'larg nodeId={nodeId} nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name} /> + node.uuid === nodeId)!.node_name} + /> + node.uuid === nodeId)!.node_name} + /> + +function isWalGenerated(value: unknown): value is WalGenerated { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' + && metrics.every(({ field }) => { + const rate = data[field] + return typeof rate === 'number' && Number.isFinite(rate) && rate >= 0 + }) +} + +export default function NodeWalGenerated({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'wal-generated', isWalGenerated) + + return ( +
+
+
+

WAL generated per second

+ {data &&

Database: {data.database_name}

} +
+

{connection}

+
+
+ {metrics.map(({ field, label }) => ( +
+

{label}

+ {data ? data[field].toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} +
+ ))} +
+
+ ) +} diff --git a/src/NodeWalSize.tsx b/src/NodeWalSize.tsx new file mode 100644 index 0000000..b915156 --- /dev/null +++ b/src/NodeWalSize.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react' + +type WalSize = { + database_name: string + wal_size_bytes: number + wal_size_mb: number +} + +function isWalSize(value: unknown): value is WalSize { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' + && typeof data.wal_size_bytes === 'number' && Number.isSafeInteger(data.wal_size_bytes) && data.wal_size_bytes >= 0 + && typeof data.wal_size_mb === 'number' && Number.isFinite(data.wal_size_mb) && data.wal_size_mb >= 0 +} + +export default function NodeWalSize({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const [data, setData] = useState(null) + const [status, setStatus] = useState('Loading WAL size…') + const [error, setError] = useState('') + const [attempt, setAttempt] = useState(0) + + useEffect(() => { + let active = true + const controller = new AbortController() + + const load = async () => { + try { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL + if (!apiBaseUrl) throw new Error('API URL is not configured.') + const baseUrl = apiBaseUrl.replace(/\/$/, '') + const databaseApiBaseUrl = import.meta.env.VITE_DATABASE_API_BASE_URL + if (!databaseApiBaseUrl) throw new Error('Database API URL is not configured.') + const databaseBaseUrl = import.meta.env.DEV + ? '/database-api' + : databaseApiBaseUrl.replace(/\/$/, '') + const token = localStorage.getItem('access_token') + if (!token) throw new Error('Authentication required. Get a token from the Nodes page, then retry.') + const headers: Record = { Authorization: `Bearer ${token}` } + const nodeResponse = await fetch(`${baseUrl}/spock/node-interfaces/${encodeURIComponent(nodeId)}`, { + headers, + signal: controller.signal, + }) + if (!nodeResponse.ok) throw new Error('Failed to load node details.') + const result = await nodeResponse.json() + const node = result?.node + if (result?.status !== 'success' || !node || node.uuid !== nodeId + || typeof node.host !== 'string' || typeof node.port !== 'number' + || typeof node.dbname !== 'string' || typeof node.user !== 'string') { + throw new Error('Invalid node details.') + } + if (!active) return + const response = await fetch(`${databaseBaseUrl}/database/wal-size`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + signal: controller.signal, + body: JSON.stringify({ + node_name: nodeName, + host: node.host, + port: node.port, + dbname: node.dbname, + user: node.user, + password: 'postgres', + }), + }) + if (response.status === 401) throw new Error('Authentication failed. Get a new token from the Nodes page, then retry.') + if (!response.ok) throw new Error(`Failed to load WAL size (${response.status}).`) + const size: unknown = await response.json() + if (!isWalSize(size)) throw new Error('Invalid WAL size response.') + if (!active) return + setData(size) + setStatus('Loaded') + } catch (cause) { + if (!active) return + setStatus('') + setError(cause instanceof Error ? cause.message : 'Unable to load WAL size.') + } + } + + void load() + return () => { + active = false + controller.abort() + } + }, [nodeId, nodeName, attempt]) + + const loading = status === 'Loading WAL size…' + + return ( +
+
+
+

WAL size

+ {data &&

Database: {data.database_name}

} +
+
+ {status &&

{status}

} + +
+
+ {error &&

{error}

} +
+
+

Size (MB)

+ {data ? data.wal_size_mb.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} +
+
+
+ ) +}