diff --git a/.env b/.env index 7f32bf1..985b060 100644 --- a/.env +++ b/.env @@ -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 diff --git a/README.md b/README.md index d6af7e3..56e5c0e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/App.tsx b/src/App.tsx index 71e4fec..9611ebd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} /> + node.uuid === nodeId)!.node_name} + /> + node.uuid === nodeId)!.node_name} + /> )} diff --git a/src/NodeDatabaseSize.tsx b/src/NodeDatabaseSize.tsx new file mode 100644 index 0000000..41f3ced --- /dev/null +++ b/src/NodeDatabaseSize.tsx @@ -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 + 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(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 = { 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 ( +
+
+
+

Database size

+ {data &&

Database: {data.database_name}

} +
+
+ {status &&

{status}

} + +
+
+ {error &&

{error}

} +
+
+

Size (GB)

+ {data ? data.size_gb.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'} +
+
+

Size (bytes)

+ {data ? data.size_bytes.toLocaleString() : '—'} +
+
+
+ ) +} diff --git a/src/NodeLocksDeadlocks.tsx b/src/NodeLocksDeadlocks.tsx new file mode 100644 index 0000000..7f97492 --- /dev/null +++ b/src/NodeLocksDeadlocks.tsx @@ -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 + 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 ( +
+
+
+

Database locks and deadlocks

+ {data &&

Database: {data.database_name}

} +
+

{connection}

+
+
+ {metrics.map(({ label, value }) => ( +
+

{label}

+ {value?.toLocaleString() ?? '—'} +
+ ))} +
+
+ ) +} diff --git a/vite.config.ts b/vite.config.ts index 9982072..7541d4f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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({ - plugins: [react()], +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/, ''), + }, + }, + }, + } })