diff --git a/src/App.css b/src/App.css index eab8b3d..03082c0 100644 --- a/src/App.css +++ b/src/App.css @@ -857,3 +857,29 @@ .database-connection-metric meter { width: 100%; } + +.largest-tables .page-header { + flex-wrap: wrap; + align-items: flex-start; +} + +.largest-tables .page-header > div:first-child { + flex: 1 1 320px; + min-width: 0; + overflow-wrap: anywhere; +} + +.largest-tables .page-header h1 + p { + margin-top: 12px; +} + +.largest-tables .page-header .page-actions { + flex-shrink: 0; +} + +@media (max-width: 720px) { + .largest-tables .page-header > div:first-child { + flex-basis: auto; + width: 100%; + } +} diff --git a/src/App.tsx b/src/App.tsx index 9611ebd..bde5c57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,8 +4,11 @@ import './App.css' import NodeConnections from './NodeConnections' import NodeActiveConnections from './NodeActiveConnections' import NodePerformance from './NodePerformance' +import NodeTupleActivity from './NodeTupleActivity' import NodeLocksDeadlocks from './NodeLocksDeadlocks' import NodeDatabaseSize from './NodeDatabaseSize' +import NodeIndexSize from './NodeIndexSize' +import NodeLargestTables from './NodeLargestTables' import NodeHardware from './NodeHardware' import ClusterNodeCard from './ClusterNodeCard' @@ -269,13 +272,14 @@ function App() { } /> } /> } /> + } /> } /> } /> ) } -function Workspace({ page: activePage }: { page: PageId | 'node-details' }) { +function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' }) { const navigate = useNavigate() const { clusterId, nodeId } = useParams() const isClusterPage = Boolean(useMatch('/clusters/*')) @@ -634,6 +638,11 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) { nodeId={nodeId} nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name} /> + node.uuid === nodeId)!.node_name} + /> node.uuid === nodeId)!.node_name} /> + node.uuid === nodeId)!.node_name} + /> + node.uuid === nodeId)!.node_name} + /> )} )} + {activePage === 'largest-tables' && clusterDetails && ( +
+ {nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? ( + node.uuid === nodeId)!.node_name} + detailed + /> + ) :

Node not found

} +
+ )} {activePage === 'cluster-details' && clusterDetails && (
diff --git a/src/NodeIndexSize.tsx b/src/NodeIndexSize.tsx new file mode 100644 index 0000000..5068279 --- /dev/null +++ b/src/NodeIndexSize.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react' + +type IndexSize = { + database_name: string + index_size_bytes: number +} + +function isIndexSize(value: unknown): value is IndexSize { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' + && typeof data.index_size_bytes === 'number' && Number.isSafeInteger(data.index_size_bytes) && data.index_size_bytes >= 0 +} + +export default function NodeIndexSize({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const [data, setData] = useState(null) + const [status, setStatus] = useState('Loading index 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/index-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 index size (${response.status}).`) + const size: unknown = await response.json() + if (!isIndexSize(size)) throw new Error('Invalid index size response.') + if (!active) return + setData(size) + setStatus('Loaded') + } catch (cause) { + if (!active) return + setStatus('') + setError(cause instanceof Error ? cause.message : 'Unable to load index size.') + } + } + + void load() + return () => { + active = false + controller.abort() + } + }, [nodeId, nodeName, attempt]) + + const loading = status === 'Loading index size…' + + return ( +
+
+
+

Index size

+ {data &&

Database: {data.database_name}

} +
+
+ {status &&

{status}

} + +
+
+ {error &&

{error}

} +
+
+

Size (KB)

+ {data ? (data.index_size_bytes / 1024).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} +
+
+
+ ) +} diff --git a/src/NodeLargestTables.tsx b/src/NodeLargestTables.tsx new file mode 100644 index 0000000..e87fc9f --- /dev/null +++ b/src/NodeLargestTables.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +type LargestTables = { + database_name: string + table_count: number + tables: { schema: string; table_name: string; size_bytes: number; size_mb: number }[] +} + +function isLargestTables(value: unknown): value is LargestTables { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' + && typeof data.table_count === 'number' && Number.isSafeInteger(data.table_count) && data.table_count >= 0 + && Array.isArray(data.tables) + && data.tables.every((value: unknown) => { + if (!value || typeof value !== 'object') return false + const table = value as Record + return typeof table.schema === 'string' && typeof table.table_name === 'string' + && typeof table.size_bytes === 'number' && Number.isSafeInteger(table.size_bytes) && table.size_bytes >= 0 + && typeof table.size_mb === 'number' && Number.isFinite(table.size_mb) && table.size_mb >= 0 + }) +} + +export default function NodeLargestTables({ nodeId, nodeName, clusterId, detailed = false }: { + nodeId: string; nodeName: string; clusterId: string; detailed?: boolean +}) { + const [page, setPage] = useState(1) + const [data, setData] = useState(null) + const [status, setStatus] = useState('Loading largest tables…') + 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/largest-tables`, { + 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 largest tables (${response.status}).`) + const size: unknown = await response.json() + if (!isLargestTables(size)) throw new Error('Invalid largest tables response.') + if (!active) return + setData(size) + setStatus('Loaded') + } catch (cause) { + if (!active) return + setStatus('') + setError(cause instanceof Error ? cause.message : 'Unable to load largest tables.') + } + } + + void load() + return () => { + active = false + controller.abort() + } + }, [nodeId, nodeName, attempt]) + + const loading = status === 'Loading largest tables…' + + const nodePath = `/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(nodeId)}` + const pageCount = Math.max(1, Math.ceil((data?.tables.length ?? 0) / 5)) + const currentPage = Math.min(page, pageCount) + const visibleTables = data?.tables.slice((currentPage - 1) * 5, currentPage * 5) ?? [] + + return ( +
+
+
+ {detailed + ?

Largest tables {nodeName}

+ :

Database tables

} + {detailed && data &&

Database: {data.database_name} · Total tables returned: {data.table_count.toLocaleString()}

} +
+
+ {detailed && Back to node} + +
+
+ {loading &&

{status}

} + {error &&

{error}

} + {!detailed && ( +
+

Total tables

+ {data?.table_count.toLocaleString() ?? '—'} + View largest tables +
+ )} + {detailed && data && (data.tables.length === 0 ?

No tables found.

: ( + <> +
+ + + + + + + {visibleTables.map((table) => ( + + + + + + ))} + +
Largest tables by size
SchemaTable nameSize (MB)Size (bytes)
{table.schema}{table.table_name}{table.size_mb.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}{table.size_bytes.toLocaleString()}
+
+ {data.tables.length > 5 && ( + + )} + + ))} +
+ ) +} diff --git a/src/NodeTupleActivity.tsx b/src/NodeTupleActivity.tsx new file mode 100644 index 0000000..d1e66a8 --- /dev/null +++ b/src/NodeTupleActivity.tsx @@ -0,0 +1,47 @@ +import useNodeDatabaseMetric from './useNodeDatabaseMetric' + +const metrics = [ + { field: 'tuple_reads_per_second', label: 'Tuple reads per second' }, + { field: 'tuple_writes_per_second', label: 'Tuple writes per second' }, + { field: 'inserts_per_second', label: 'Inserts per second' }, + { field: 'updates_per_second', label: 'Updates per second' }, + { field: 'deletes_per_second', label: 'Deletes per second' }, +] as const + +type TupleActivity = { + database_name: string +} & Record<(typeof metrics)[number]['field'], number> + +function isTupleActivity(value: unknown): value is TupleActivity { + 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 NodeTupleActivity({ nodeId, nodeName }: { nodeId: string; nodeName: string }) { + const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'tuple-activity', isTupleActivity) + + return ( +
+
+
+

Tuple activity

+ {data &&

Database: {data.database_name}

} +
+

{connection}

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

{label}

+ {data ? data[field].toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} +
+ ))} +
+
+ ) +} diff --git a/src/index.css b/src/index.css index 9b8d0d6..fc8187c 100644 --- a/src/index.css +++ b/src/index.css @@ -48,6 +48,7 @@ h2 { h1 { font-size: 56px; + line-height: 1.2; letter-spacing: 0; margin: 32px 0; @media (max-width: 1024px) {