feat:
--ADDED DEAD LOCK AND DATABASE SIZE UI
This commit is contained in:
1 parent
662611e10a
commit
a47f8e52cb
6 files changed
+213
-3
No files matched your search
@@ -1,2 +1,3 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:8000
|
||||
VITE_WS_BASE_URL=ws://127.0.0.1:9000
|
||||
VITE_DATABASE_API_BASE_URL=http://127.0.0.1:9000
|
||||
@@ -30,3 +30,15 @@ If you are developing a production application, we recommend enabling type-aware
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
|
||||
## Database API during development
|
||||
|
||||
The Vite dev server proxies `/database-api/*` to `VITE_DATABASE_API_BASE_URL`
|
||||
(configured as `http://127.0.0.1:9000` in `.env`), removing the `/database-api`
|
||||
prefix. Database size uses this same-origin route during development, forwarding
|
||||
its POST body and Bearer Authorization header without browser CORS preflight.
|
||||
Restart the dev server after changing the proxy or environment settings.
|
||||
|
||||
Production builds use `VITE_DATABASE_API_BASE_URL` directly. Configure that service
|
||||
to allow the deployed frontend origin, POST/OPTIONS requests, and Authorization
|
||||
and Content-Type headers, or set the base URL to a same-origin reverse proxy.
|
||||
+12
@@ -4,6 +4,8 @@ import './App.css'
|
||||
import NodeConnections from './NodeConnections'
|
||||
import NodeActiveConnections from './NodeActiveConnections'
|
||||
import NodePerformance from './NodePerformance'
|
||||
import NodeLocksDeadlocks from './NodeLocksDeadlocks'
|
||||
import NodeDatabaseSize from './NodeDatabaseSize'
|
||||
import NodeHardware from './NodeHardware'
|
||||
import ClusterNodeCard from './ClusterNodeCard'
|
||||
|
||||
@@ -632,6 +634,16 @@ function Workspace({ page: activePage }: { page: PageId | 'node-details' }) {
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
<NodeLocksDeadlocks
|
||||
key={`locks-deadlocks/${clusterId}/${nodeId}`}
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
<NodeDatabaseSize
|
||||
key={`database-size/${clusterId}/${nodeId}`}
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
<NodeConnections key={`${clusterId}/${nodeId}`} nodeId={nodeId} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type DatabaseSize = {
|
||||
database_name: string
|
||||
size_bytes: number
|
||||
size_gb: number
|
||||
}
|
||||
|
||||
function isDatabaseSize(value: unknown): value is DatabaseSize {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& typeof data.size_bytes === 'number' && Number.isSafeInteger(data.size_bytes) && data.size_bytes >= 0
|
||||
&& typeof data.size_gb === 'number' && Number.isFinite(data.size_gb) && data.size_gb >= 0
|
||||
}
|
||||
|
||||
export default function NodeDatabaseSize({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||
const [data, setData] = useState<DatabaseSize | null>(null)
|
||||
const [status, setStatus] = useState('Loading database 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/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 database size (${response.status}).`)
|
||||
const size: unknown = await response.json()
|
||||
if (!isDatabaseSize(size)) throw new Error('Invalid database size response.')
|
||||
if (!active) return
|
||||
setData(size)
|
||||
setStatus('Loaded')
|
||||
} catch (cause) {
|
||||
if (!active) return
|
||||
setStatus('')
|
||||
setError(cause instanceof Error ? cause.message : 'Unable to load database size.')
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
}
|
||||
}, [nodeId, nodeName, attempt])
|
||||
|
||||
const loading = status === 'Loading database size…'
|
||||
|
||||
return (
|
||||
<section className="database-connections" aria-labelledby="database-size-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 id="database-size-heading">Database 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 database 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 (GB)</h3>
|
||||
<strong>{data ? data.size_gb.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'}</strong>
|
||||
</div>
|
||||
<div className="database-connection-metric">
|
||||
<h3>Size (bytes)</h3>
|
||||
<strong>{data ? data.size_bytes.toLocaleString() : '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import useNodeDatabaseMetric from './useNodeDatabaseMetric'
|
||||
|
||||
type LocksDeadlocks = {
|
||||
database_name: string
|
||||
total_locks: number
|
||||
granted_locks: number
|
||||
waiting_locks: number
|
||||
deadlocks: number
|
||||
}
|
||||
|
||||
function isLocksDeadlocks(value: unknown): value is LocksDeadlocks {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& ['total_locks', 'granted_locks', 'waiting_locks', 'deadlocks'].every((field) => {
|
||||
const count = data[field]
|
||||
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
|
||||
})
|
||||
}
|
||||
|
||||
export default function NodeLocksDeadlocks({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||
const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'locks-deadlocks', isLocksDeadlocks)
|
||||
const metrics = [
|
||||
{ label: 'Total locks', value: data?.total_locks },
|
||||
{ label: 'Granted locks', value: data?.granted_locks },
|
||||
{ label: 'Waiting locks', value: data?.waiting_locks },
|
||||
{ label: 'Deadlocks', value: data?.deadlocks },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="database-connections" aria-labelledby="database-locks-deadlocks-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 id="database-locks-deadlocks-heading">Database locks and deadlocks</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(({ label, value }) => (
|
||||
<div className="database-connection-metric" key={label}>
|
||||
<h3>{label}</h3>
|
||||
<strong>{value?.toLocaleString() ?? '—'}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+15
-3
@@ -1,7 +1,19 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/database-api': {
|
||||
target: env.VITE_DATABASE_API_BASE_URL || 'http://127.0.0.1:9000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/database-api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user