feat:
--ADDED INDEX SIZE UI --ADDED LARGES TABLES UI --ADDED TUPLE ACTIVITY UI
This commit is contained in:
1 parent
a47f8e52cb
commit
cdf5f51e65
6 files changed
+393
-1
No files matched your search
+26
@@ -857,3 +857,29 @@
|
|||||||
.database-connection-metric meter {
|
.database-connection-metric meter {
|
||||||
width: 100%;
|
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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -4,8 +4,11 @@ import './App.css'
|
|||||||
import NodeConnections from './NodeConnections'
|
import NodeConnections from './NodeConnections'
|
||||||
import NodeActiveConnections from './NodeActiveConnections'
|
import NodeActiveConnections from './NodeActiveConnections'
|
||||||
import NodePerformance from './NodePerformance'
|
import NodePerformance from './NodePerformance'
|
||||||
|
import NodeTupleActivity from './NodeTupleActivity'
|
||||||
import NodeLocksDeadlocks from './NodeLocksDeadlocks'
|
import NodeLocksDeadlocks from './NodeLocksDeadlocks'
|
||||||
import NodeDatabaseSize from './NodeDatabaseSize'
|
import NodeDatabaseSize from './NodeDatabaseSize'
|
||||||
|
import NodeIndexSize from './NodeIndexSize'
|
||||||
|
import NodeLargestTables from './NodeLargestTables'
|
||||||
import NodeHardware from './NodeHardware'
|
import NodeHardware from './NodeHardware'
|
||||||
import ClusterNodeCard from './ClusterNodeCard'
|
import ClusterNodeCard from './ClusterNodeCard'
|
||||||
|
|
||||||
@@ -269,13 +272,14 @@ function App() {
|
|||||||
<Route path="/nodes" element={<Workspace page="nodes" />} />
|
<Route path="/nodes" element={<Workspace page="nodes" />} />
|
||||||
<Route path="/clusters" element={<Workspace page="clusters" />} />
|
<Route path="/clusters" element={<Workspace page="clusters" />} />
|
||||||
<Route path="/clusters/:clusterId" element={<Workspace page="cluster-details" />} />
|
<Route path="/clusters/:clusterId" element={<Workspace page="cluster-details" />} />
|
||||||
|
<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="/clusters/:clusterId/nodes/:nodeId" element={<Workspace page="node-details" />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { clusterId, nodeId } = useParams()
|
const { clusterId, nodeId } = useParams()
|
||||||
const isClusterPage = Boolean(useMatch('/clusters/*'))
|
const isClusterPage = Boolean(useMatch('/clusters/*'))
|
||||||
@@ -634,6 +638,11 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
|||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||||
/>
|
/>
|
||||||
|
<NodeTupleActivity
|
||||||
|
key={`tuple-activity/${clusterId}/${nodeId}`}
|
||||||
|
nodeId={nodeId}
|
||||||
|
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||||
|
/>
|
||||||
<NodeLocksDeadlocks
|
<NodeLocksDeadlocks
|
||||||
key={`locks-deadlocks/${clusterId}/${nodeId}`}
|
key={`locks-deadlocks/${clusterId}/${nodeId}`}
|
||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
@@ -644,11 +653,35 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
|||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||||
/>
|
/>
|
||||||
|
<NodeIndexSize
|
||||||
|
key={`index-size/${clusterId}/${nodeId}`}
|
||||||
|
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} />
|
<NodeConnections key={`${clusterId}/${nodeId}`} nodeId={nodeId} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{activePage === 'largest-tables' && clusterDetails && (
|
||||||
|
<section className="content-panel">
|
||||||
|
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? (
|
||||||
|
<NodeLargestTables
|
||||||
|
key={`tables-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 && (
|
{activePage === 'cluster-details' && clusterDetails && (
|
||||||
<section className="content-panel" id="cluster-details">
|
<section className="content-panel" id="cluster-details">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
|
|||||||
@@ -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<string, unknown>
|
||||||
|
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<IndexSize | null>(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<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/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 (
|
||||||
|
<section className="database-connections" aria-labelledby="index-size-heading">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="index-size-heading">Index size</h2>
|
||||||
|
{data && <p>Database: {data.database_name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="page-actions">
|
||||||
|
{status && <p role="status">{status}</p>}
|
||||||
|
<button className="secondary-button" type="button" disabled={loading} onClick={() => {
|
||||||
|
setData(null)
|
||||||
|
setError('')
|
||||||
|
setStatus('Loading index size…')
|
||||||
|
setAttempt((current) => current + 1)
|
||||||
|
}}>
|
||||||
|
{error ? 'Retry' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <p className="status-message error" role="alert">{error}</p>}
|
||||||
|
<div className="database-connection-metrics">
|
||||||
|
<div className="database-connection-metric">
|
||||||
|
<h3>Size (KB)</h3>
|
||||||
|
<strong>{data ? (data.index_size_bytes / 1024).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<string, unknown>
|
||||||
|
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<LargestTables | null>(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<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/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 (
|
||||||
|
<section className="database-connections largest-tables" aria-labelledby="largest-tables-heading">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
{detailed
|
||||||
|
? <h1 id="largest-tables-heading">Largest tables {nodeName}</h1>
|
||||||
|
: <h2 id="largest-tables-heading">Database tables</h2>}
|
||||||
|
{detailed && data && <p>Database: {data.database_name} · Total tables returned: {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={() => {
|
||||||
|
setData(null)
|
||||||
|
setError('')
|
||||||
|
setStatus('Loading largest tables…')
|
||||||
|
setPage(1)
|
||||||
|
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>}
|
||||||
|
{!detailed && (
|
||||||
|
<div className="database-connection-metric">
|
||||||
|
<h3>Total tables</h3>
|
||||||
|
<strong>{data?.table_count.toLocaleString() ?? '—'}</strong>
|
||||||
|
<Link to={`${nodePath}/largest-tables`}>View largest tables</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detailed && data && (data.tables.length === 0 ? <p>No tables found.</p> : (
|
||||||
|
<>
|
||||||
|
<div className="nodes-table-wrap">
|
||||||
|
<table className="nodes-table">
|
||||||
|
<caption>Largest tables by size</caption>
|
||||||
|
<thead>
|
||||||
|
<tr><th scope="col">Schema</th><th scope="col">Table name</th><th scope="col">Size (MB)</th><th scope="col">Size (bytes)</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.size_mb.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||||
|
<td>{table.size_bytes.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{data.tables.length > 5 && (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<string, unknown>
|
||||||
|
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 (
|
||||||
|
<section className="database-connections" aria-labelledby="database-tuple-activity-heading">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="database-tuple-activity-heading">Tuple activity</h2>
|
||||||
|
{data && <p>Database: {data.database_name}</p>}
|
||||||
|
</div>
|
||||||
|
<p role="status" className={data ? 'connection-status connected' : ''}>{connection}</p>
|
||||||
|
</div>
|
||||||
|
<div className="database-connection-metrics">
|
||||||
|
{metrics.map(({ field, label }) => (
|
||||||
|
<div className="database-connection-metric" key={field}>
|
||||||
|
<h3>{label}</h3>
|
||||||
|
<strong>{data ? data[field].toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ h2 {
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 56px;
|
font-size: 56px;
|
||||||
|
line-height: 1.2;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
margin: 32px 0;
|
margin: 32px 0;
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
|||||||
Reference in new issue
Block a user