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.
: (
+ <>
+
+
+ Live and dead tuples for all tables · Vacuum times shown in your local time
+
+
+ | Schema | Table name |
+ Live tuples | Dead tuples |
+ Size (MB) | Size (bytes) |
+ Last vacuum | Last autovacuum |
+
+
+
+ {visibleTables.map((table) => (
+
+ | {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 && (
+
+ )}
+ >
+ ))}
+
+ )
+}