--feat
--ADDED MAX CONNECTIONS BUTTON ADDED --ADDED POP UP TO ADJ THIS CONNECTION
This commit is contained in:
1 parent
45d9e22ae2
commit
ae96ca4756
2 files changed
+328
-1
No files matched your search
+183
@@ -936,6 +936,47 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.max-connections-value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.max-connections-value-row strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.adjust-max-connections-button {
|
||||
box-sizing: border-box;
|
||||
flex: 0 1 auto;
|
||||
max-width: 65%;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
padding: 2% 3%;
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 0.4em;
|
||||
background: var(--accent-bg);
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-size: 65%;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.adjust-max-connections-button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.adjust-max-connections-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.largest-tables .page-header {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
@@ -1298,3 +1339,145 @@
|
||||
.nodes-page .status-message {
|
||||
margin-bottom: 0.5%;
|
||||
}
|
||||
|
||||
|
||||
.max-connections-dialog {
|
||||
box-sizing: border-box;
|
||||
width: 40%;
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
margin: auto;
|
||||
padding: 2%;
|
||||
font-size: 100%;
|
||||
line-height: 140%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5em;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.max-connections-dialog::backdrop {
|
||||
background: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.max-connections-dialog-header,
|
||||
.max-connections-dialog-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 3%;
|
||||
}
|
||||
|
||||
.max-connections-dialog .max-connections-dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 110%;
|
||||
font-weight: 600;
|
||||
line-height: 125%;
|
||||
}
|
||||
|
||||
.max-connections-recommendations {
|
||||
width: 100%;
|
||||
margin: 4% 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
.max-connections-recommendations th,
|
||||
.max-connections-recommendations td {
|
||||
padding: 2% 3%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.max-connections-recommendations th {
|
||||
background: var(--surface);
|
||||
font-size: 90%;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.max-connections-recommendations th:last-child,
|
||||
.max-connections-recommendations td:last-child {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.max-connections-form {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.max-connections-form > * {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.max-connections-form > * + * {
|
||||
margin-top: 3%;
|
||||
}
|
||||
|
||||
.max-connections-form label {
|
||||
font-size: 100%;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.max-connections-dialog .max-connections-form p {
|
||||
padding: 2% 0;
|
||||
font-size: 100%;
|
||||
line-height: 140%;
|
||||
}
|
||||
|
||||
.max-connections-form input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 2% 3%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.4em;
|
||||
background: var(--surface);
|
||||
color: var(--text-h);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.max-connections-form input:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.max-connections-form .max-connections-dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4%;
|
||||
}
|
||||
|
||||
.max-connections-dialog .primary-button,
|
||||
.max-connections-dialog .secondary-button {
|
||||
min-height: 2.2em;
|
||||
max-width: 100%;
|
||||
padding: 2% 3%;
|
||||
font-size: 100%;
|
||||
line-height: 125%;
|
||||
}
|
||||
|
||||
.max-connections-dialog-header .secondary-button {
|
||||
flex-shrink: 0;
|
||||
padding: 1% 3%;
|
||||
}
|
||||
|
||||
.max-connections-dialog-actions > button {
|
||||
margin-top: 2%;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.max-connections-dialog {
|
||||
width: 60%;
|
||||
padding: 3%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.max-connections-dialog {
|
||||
width: 90%;
|
||||
padding: 4%;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import useNodeDatabaseMetric from './useNodeDatabaseMetric'
|
||||
|
||||
type ActiveConnections = {
|
||||
@@ -24,6 +25,97 @@ function isActiveConnections(value: unknown): value is ActiveConnections {
|
||||
|
||||
export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||
const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'active-connections', isActiveConnections)
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
const [maxConnections, setMaxConnections] = useState('')
|
||||
const [submitMessage, setSubmitMessage] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [submitError, setSubmitError] = useState(false)
|
||||
const requestRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
requestRef.current?.abort()
|
||||
requestRef.current = null
|
||||
dialogRef.current?.close()
|
||||
}, [nodeId, nodeName])
|
||||
|
||||
const updateMaxConnections = async () => {
|
||||
if (requestRef.current) return
|
||||
const value = Number(maxConnections)
|
||||
if (!/^[1-9][0-9]*$/.test(maxConnections) || !Number.isSafeInteger(value) || value < 1) {
|
||||
setSubmitError(true)
|
||||
setSubmitMessage('Enter a positive whole number.')
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
requestRef.current = controller
|
||||
setSaving(true)
|
||||
setSubmitError(false)
|
||||
setSubmitMessage('')
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const databaseApiBaseUrl = import.meta.env.VITE_DATABASE_API_BASE_URL
|
||||
if (!apiBaseUrl) throw new Error('API URL is not configured.')
|
||||
if (!databaseApiBaseUrl && !import.meta.env.DEV) 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 = { Authorization: `Bearer ${token}` }
|
||||
const nodeResponse = await fetch(`${apiBaseUrl.replace(/\/$/, '')}/spock/node-interfaces/${encodeURIComponent(nodeId)}`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!nodeResponse.ok) throw new Error(`Failed to load node details (${nodeResponse.status}).`)
|
||||
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.')
|
||||
}
|
||||
const response = await fetch(`${databaseBaseUrl}/database/max-connections`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, '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',
|
||||
max_connections: value,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to update max connections (${response.status}).`)
|
||||
const updated = await response.json()
|
||||
if (updated?.status !== 'success'
|
||||
|| !Number.isSafeInteger(updated.previous_max_connections) || updated.previous_max_connections < 1
|
||||
|| !Number.isSafeInteger(updated.new_max_connections) || updated.new_max_connections < 1
|
||||
|| typeof updated.restart_required !== 'boolean' || typeof updated.message !== 'string') {
|
||||
throw new Error('Unable to confirm the update: invalid server response. Check the configuration before retrying.')
|
||||
}
|
||||
if (controller.signal.aborted) return
|
||||
setSubmitMessage(`Configured max connections: ${updated.previous_max_connections.toLocaleString()} → ${updated.new_max_connections.toLocaleString()}. ${updated.message}${updated.restart_required ? ' Restart required; the live value will change after PostgreSQL restarts.' : ''}`)
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted) return
|
||||
setSubmitError(true)
|
||||
setSubmitMessage(cause instanceof Error ? cause.message : 'Unable to update max connections.')
|
||||
} finally {
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openAdjustment = () => {
|
||||
setMaxConnections(data ? String(data.max_connections) : '')
|
||||
setSubmitMessage('')
|
||||
setSubmitError(false)
|
||||
setSaving(false)
|
||||
dialogRef.current?.showModal()
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
{ label: 'Active connections', value: data?.active_connections },
|
||||
{ label: 'Current connections', value: data?.current_connections },
|
||||
@@ -43,7 +135,14 @@ export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: st
|
||||
{metrics.map(({ label, value }) => (
|
||||
<div className="database-connection-metric" key={label}>
|
||||
<h3>{label}</h3>
|
||||
<strong>{value?.toLocaleString() ?? '—'}</strong>
|
||||
{label === 'Max connections' ? (
|
||||
<div className="max-connections-value-row">
|
||||
<strong>{value?.toLocaleString() ?? '—'}</strong>
|
||||
<button className="adjust-max-connections-button" type="button" onClick={openAdjustment}>
|
||||
Adjust max connection
|
||||
</button>
|
||||
</div>
|
||||
) : <strong>{value?.toLocaleString() ?? '—'}</strong>}
|
||||
</div>
|
||||
))}
|
||||
<div className="database-connection-metric">
|
||||
@@ -52,6 +151,51 @@ export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: st
|
||||
{data && <meter aria-label="Connection usage" min={0} max={100} value={data.connection_usage_percent} />}
|
||||
</div>
|
||||
</div>
|
||||
<dialog className="max-connections-dialog" ref={dialogRef} aria-labelledby="max-connections-dialog-title" onCancel={(event) => { if (saving) event.preventDefault() }}>
|
||||
<div className="max-connections-dialog-header">
|
||||
<h2 id="max-connections-dialog-title">Adjust max connection</h2>
|
||||
<button className="secondary-button" type="button" disabled={saving} aria-label="Close popup" onClick={() => dialogRef.current?.close()}>×</button>
|
||||
</div>
|
||||
<table className="max-connections-recommendations">
|
||||
<thead>
|
||||
<tr><th scope="col">RECOMMENDED RAM SIZE</th><th scope="col">CONNECTIONS</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>2–4 GB RAM</td><td>50–100</td></tr>
|
||||
<tr><td>8 GB RAM</td><td>100–300</td></tr>
|
||||
<tr><td>16 GB RAM</td><td>200–500</td></tr>
|
||||
<tr><td>32+ GB RAM</td><td>300–1000+</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<form className="max-connections-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void updateMaxConnections()
|
||||
}}>
|
||||
<label htmlFor="max-connections-input">Max connections</label>
|
||||
<input
|
||||
id="max-connections-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[1-9][0-9]*"
|
||||
title="Enter a positive whole number (1 or greater)."
|
||||
required
|
||||
disabled={saving}
|
||||
value={maxConnections}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
if (nextValue !== '' && (!/^[1-9][0-9]*$/.test(nextValue) || !Number.isSafeInteger(Number(nextValue)))) return
|
||||
setSubmitError(false)
|
||||
setMaxConnections(nextValue)
|
||||
setSubmitMessage('')
|
||||
}}
|
||||
/>
|
||||
{submitMessage && <p className={submitError ? 'status-message error' : undefined} role={submitError ? 'alert' : 'status'}>{submitMessage}</p>}
|
||||
<div className="max-connections-dialog-actions">
|
||||
<button className="secondary-button" type="button" disabled={saving} onClick={() => dialogRef.current?.close()}>Cancel</button>
|
||||
<button className="primary-button" type="submit" disabled={saving}>{saving ? 'Updating…' : 'Update max connections'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user