--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%;
|
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 {
|
.largest-tables .page-header {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1298,3 +1339,145 @@
|
|||||||
.nodes-page .status-message {
|
.nodes-page .status-message {
|
||||||
margin-bottom: 0.5%;
|
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'
|
import useNodeDatabaseMetric from './useNodeDatabaseMetric'
|
||||||
|
|
||||||
type ActiveConnections = {
|
type ActiveConnections = {
|
||||||
@@ -24,6 +25,97 @@ function isActiveConnections(value: unknown): value is ActiveConnections {
|
|||||||
|
|
||||||
export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||||
const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'active-connections', isActiveConnections)
|
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 = [
|
const metrics = [
|
||||||
{ label: 'Active connections', value: data?.active_connections },
|
{ label: 'Active connections', value: data?.active_connections },
|
||||||
{ label: 'Current connections', value: data?.current_connections },
|
{ label: 'Current connections', value: data?.current_connections },
|
||||||
@@ -43,7 +135,14 @@ export default function NodeActiveConnections({ nodeId, nodeName }: { nodeId: st
|
|||||||
{metrics.map(({ label, value }) => (
|
{metrics.map(({ label, value }) => (
|
||||||
<div className="database-connection-metric" key={label}>
|
<div className="database-connection-metric" key={label}>
|
||||||
<h3>{label}</h3>
|
<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>
|
||||||
))}
|
))}
|
||||||
<div className="database-connection-metric">
|
<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} />}
|
{data && <meter aria-label="Connection usage" min={0} max={100} value={data.connection_usage_percent} />}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user