feat:
--ADDED show Details of list of tables
This commit is contained in:
1 parent
83d065c530
commit
b48038adca
2 files changed
+238
-7
No files matched your search
+22
-7
@@ -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() {
|
||||
<Route path="/nodes" element={<Workspace page="nodes" />} />
|
||||
<Route path="/clusters" element={<Workspace page="clusters" />} />
|
||||
<Route path="/clusters/:clusterId" element={<Workspace page="cluster-details" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/dead-tuples" element={<Workspace page="dead-tuples" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/largest-tables" element={<Workspace page="largest-tables" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId" element={<Workspace page="node-details" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
@@ -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}
|
||||
/>
|
||||
<NodeDeadTuples
|
||||
key={`dead-tuples/${clusterId}/${nodeId}`}
|
||||
clusterId={clusterId!}
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
<NodeLocksDeadlocks
|
||||
key={`locks-deadlocks/${clusterId}/${nodeId}`}
|
||||
nodeId={nodeId}
|
||||
@@ -658,12 +666,6 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'larg
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
<NodeLargestTables
|
||||
key={`tables/${clusterId}/${nodeId}`}
|
||||
clusterId={clusterId!}
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
<NodeConnections key={`${clusterId}/${nodeId}`} nodeId={nodeId} />
|
||||
</>
|
||||
)}
|
||||
@@ -682,6 +684,19 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'larg
|
||||
) : <h1>Node not found</h1>}
|
||||
</section>
|
||||
)}
|
||||
{activePage === 'dead-tuples' && clusterDetails && (
|
||||
<section className="content-panel">
|
||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? (
|
||||
<NodeDeadTuples
|
||||
key={`dead-tuples-page/${clusterId}/${nodeId}`}
|
||||
clusterId={clusterId!}
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
detailed
|
||||
/>
|
||||
) : <h1>Node not found</h1>}
|
||||
</section>
|
||||
)}
|
||||
{activePage === 'cluster-details' && clusterDetails && (
|
||||
<section className="content-panel" id="cluster-details">
|
||||
<div className="page-header">
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<DeadTuples | null>(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<string, string> = { 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 (
|
||||
<section className="database-connections" aria-labelledby="dead-tuples-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{detailed
|
||||
? <h1 id="dead-tuples-heading">Table tuples · {nodeName}</h1>
|
||||
: <h2 id="dead-tuples-heading">Show all tables</h2>}
|
||||
{data && <p>Database: {data.database_name} · Tables: {data.table_count.toLocaleString()}</p>}
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{detailed && <Link className="secondary-button" to={nodePath}>Back to node</Link>}
|
||||
<button className="secondary-button" type="button" disabled={loading} onClick={() => {
|
||||
setPage(1)
|
||||
setData(null)
|
||||
setError('')
|
||||
setStatus('Loading dead tuples…')
|
||||
setAttempt((current) => current + 1)
|
||||
}}>{error ? 'Retry' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <p role="status">{status}</p>}
|
||||
{error && <p className="status-message error" role="alert">{error}</p>}
|
||||
<div className="database-connection-metric">
|
||||
<h3>{detailed ? 'Total dead tuples' : 'Total tables'}</h3>
|
||||
<strong>{data ? (detailed ? data.total_dead_tuples : data.table_count).toLocaleString() : '—'}</strong>
|
||||
{!detailed && <Link to={`${nodePath}/dead-tuples`}>View all tables</Link>}
|
||||
</div>
|
||||
{detailed && data && (
|
||||
<div className="database-connection-metrics">
|
||||
<div className="database-connection-metric">
|
||||
<h3>Total table size (MB)</h3>
|
||||
<strong>{formatMb(data.total_table_size_mb)}</strong>
|
||||
</div>
|
||||
<div className="database-connection-metric">
|
||||
<h3>Total table size (bytes)</h3>
|
||||
<strong>{data.total_table_size_bytes.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detailed && data && (data.tables.length === 0 ? <p>No tables found.</p> : (
|
||||
<>
|
||||
<div className="nodes-table-wrap">
|
||||
<table className="nodes-table">
|
||||
<caption>Live and dead tuples for all tables · Vacuum times shown in your local time</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Schema</th><th scope="col">Table name</th>
|
||||
<th scope="col">Live tuples</th><th scope="col">Dead tuples</th>
|
||||
<th scope="col">Size (MB)</th><th scope="col">Size (bytes)</th>
|
||||
<th scope="col">Last vacuum</th><th scope="col">Last autovacuum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleTables.map((table) => (
|
||||
<tr key={JSON.stringify([table.schema, table.table_name])}>
|
||||
<td>{table.schema}</td><td>{table.table_name}</td>
|
||||
<td>{table.live_tuples.toLocaleString()}</td><td>{table.dead_tuples.toLocaleString()}</td>
|
||||
<td>{formatMb(table.size_mb)}</td><td>{table.size_bytes.toLocaleString()}</td>
|
||||
<td>{formatTimestamp(table.last_vacuum)}</td><td>{formatTimestamp(table.last_autovacuum)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<nav className="page-actions" aria-label="Table pagination">
|
||||
<button className="secondary-button" type="button" disabled={currentPage === 1} onClick={() => setPage(currentPage - 1)}>Previous</button>
|
||||
<p role="status">Page {currentPage} of {pageCount}</p>
|
||||
<button className="secondary-button" type="button" disabled={currentPage === pageCount} onClick={() => setPage(currentPage + 1)}>Next</button>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user