From b48038adca78c5183b0b63375e013d422d09bdfa Mon Sep 17 00:00:00 2001 From: George Date: Mon, 28 Sep 2026 12:08:21 +0530 Subject: [PATCH] feat: --ADDED show Details of list of tables --- src/App.tsx | 29 ++++-- src/NodeDeadTuples.tsx | 216 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 238 insertions(+), 7 deletions(-) create mode 100644 src/NodeDeadTuples.tsx diff --git a/src/App.tsx b/src/App.tsx index bde5c57..3bd5d67 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import NodeConnections from './NodeConnections' import NodeActiveConnections from './NodeActiveConnections' import NodePerformance from './NodePerformance' import NodeTupleActivity from './NodeTupleActivity' +import NodeDeadTuples from './NodeDeadTuples' import NodeLocksDeadlocks from './NodeLocksDeadlocks' import NodeDatabaseSize from './NodeDatabaseSize' import NodeIndexSize from './NodeIndexSize' @@ -272,6 +273,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -279,7 +281,7 @@ function App() { ) } -function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' }) { +function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' | 'dead-tuples' }) { const navigate = useNavigate() const { clusterId, nodeId } = useParams() const isClusterPage = Boolean(useMatch('/clusters/*')) @@ -643,6 +645,12 @@ 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} /> - node.uuid === nodeId)!.node_name} - /> )} @@ -682,6 +684,19 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'larg ) :

Node not found

} )} + {activePage === 'dead-tuples' && 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/NodeDeadTuples.tsx b/src/NodeDeadTuples.tsx new file mode 100644 index 0000000..fc6af58 --- /dev/null +++ b/src/NodeDeadTuples.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +const PAGE_SIZE = 10 + +type DeadTuples = { + database_name: string + table_count: number + total_table_size_bytes: number + total_table_size_mb: number + total_dead_tuples: number + tables: { + schema: string + table_name: string + live_tuples: number + dead_tuples: number + size_bytes: number + size_mb: number + last_vacuum: string | null + last_autovacuum: string | null + }[] +} + +function isCount(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function isSizeMb(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function formatMb(value: number) { + return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +} + +function isTimestamp(value: unknown): value is string | null { + return value === null || (typeof value === 'string' && Number.isFinite(Date.parse(value))) +} + +function isDeadTuples(value: unknown): value is DeadTuples { + if (!value || typeof value !== 'object') return false + const data = value as Record + return typeof data.database_name === 'string' && isCount(data.total_dead_tuples) + && isCount(data.table_count) && isCount(data.total_table_size_bytes) && isSizeMb(data.total_table_size_mb) + && 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' + && isCount(table.live_tuples) && isCount(table.dead_tuples) + && isCount(table.size_bytes) && isSizeMb(table.size_mb) + && isTimestamp(table.last_vacuum) && isTimestamp(table.last_autovacuum) + }) +} + +function formatTimestamp(value: string | null) { + return value === null ? 'Never' : new Date(value).toLocaleString() +} + +export default function NodeDeadTuples({ 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 dead tuples…') + 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/dead-tuples`, { + 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 dead tuples (${response.status}).`) + const size: unknown = await response.json() + if (!isDeadTuples(size)) throw new Error('Invalid dead tuples response.') + if (!active) return + setData(size) + setStatus('Loaded') + } catch (cause) { + if (!active) return + setStatus('') + setError(cause instanceof Error ? cause.message : 'Unable to load dead tuples.') + } + } + + void load() + return () => { + active = false + controller.abort() + } + }, [nodeId, nodeName, attempt]) + + const loading = status === 'Loading dead tuples…' + const nodePath = `/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(nodeId)}` + + const pageCount = Math.max(1, Math.ceil((data?.tables.length ?? 0) / PAGE_SIZE)) + const currentPage = Math.min(page, pageCount) + const visibleTables = data?.tables.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) ?? [] + + return ( +
+
+
+ {detailed + ?

Table tuples · {nodeName}

+ :

Show all tables

} + {data &&

Database: {data.database_name} · Tables: {data.table_count.toLocaleString()}

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

{status}

} + {error &&

{error}

} +
+

{detailed ? 'Total dead tuples' : 'Total tables'}

+ {data ? (detailed ? data.total_dead_tuples : data.table_count).toLocaleString() : '—'} + {!detailed && View all tables} +
+ {detailed && data && ( +
+
+

Total table size (MB)

+ {formatMb(data.total_table_size_mb)} +
+
+

Total table size (bytes)

+ {data.total_table_size_bytes.toLocaleString()} +
+
+ )} + {detailed && data && (data.tables.length === 0 ?

No tables found.

: ( + <> +
+ + + + + + + + + + + + {visibleTables.map((table) => ( + + + + + + + ))} + +
Live and dead tuples for all tables · Vacuum times shown in your local time
SchemaTable nameLive tuplesDead tuplesSize (MB)Size (bytes)Last vacuumLast autovacuum
{table.schema}{table.table_name}{table.live_tuples.toLocaleString()}{table.dead_tuples.toLocaleString()}{formatMb(table.size_mb)}{table.size_bytes.toLocaleString()}{formatTimestamp(table.last_vacuum)}{formatTimestamp(table.last_autovacuum)}
+
+ {pageCount > 1 && ( + + )} + + ))} +
+ ) +}