Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48d5635095 | ||
|
|
ae96ca4756 | ||
|
|
45d9e22ae2 | ||
|
|
abbfdf66f4 | ||
|
|
9543a22b1a | ||
|
|
14e6981853 | ||
|
|
b48038adca | ||
|
|
83d065c530 | ||
|
|
cdf5f51e65 | ||
|
|
a47f8e52cb | ||
|
|
662611e10a | ||
|
|
0d46483c39 | ||
|
|
08d79770cf | ||
|
|
3b8e1dca3e | ||
|
|
8f43344c84 | ||
|
|
3d0d02d06c |
No files matched your search
@@ -1 +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.
|
||||
Generated
+59
-1
@@ -9,7 +9,8 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
@@ -676,6 +677,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1125,6 +1139,44 @@
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
|
||||
"integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.18.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
|
||||
"integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.18.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz",
|
||||
@@ -1165,6 +1217,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.3",
|
||||
|
||||
+844
@@ -484,6 +484,10 @@
|
||||
}
|
||||
|
||||
.cluster-node-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
@@ -493,6 +497,39 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cluster-node-name {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.cluster-node-database,
|
||||
.cluster-node-uptime > span {
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.cluster-node-uptime {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.cluster-node-uptime strong {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.cluster-node-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.cluster-node-card:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.cluster-list .secondary-button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
@@ -635,6 +672,47 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connections-table {
|
||||
min-width: 480px;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-status.connected {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.connection-status.disconnected {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.theme-dark .connection-status.connected {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.theme-dark .connection-status.disconnected {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.connection-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.connections-table caption {
|
||||
padding: 14px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nodes-table th {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
@@ -702,3 +780,769 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content:has(.hardware-dashboard) {
|
||||
padding: 1.2%;
|
||||
}
|
||||
|
||||
.hardware-dashboard {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.hardware-dashboard > .eyebrow {
|
||||
margin-bottom: 0.3%;
|
||||
font-size: 65%;
|
||||
}
|
||||
|
||||
.hardware-dashboard > h1 {
|
||||
margin: 0 0 0.5%;
|
||||
font-size: clamp(1.25rem, 2.2cqw, 1.75rem);
|
||||
}
|
||||
|
||||
.hardware-dashboard p {
|
||||
font-size: clamp(0.625rem, 0.85cqw, 0.75rem);
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hardware-nodes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 0.8%;
|
||||
row-gap: 0.8cqw;
|
||||
margin-top: 1%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hardware-node {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
padding: 2%;
|
||||
}
|
||||
|
||||
.hardware-node-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.3em 2%;
|
||||
margin-bottom: 2%;
|
||||
padding-bottom: 1.5%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hardware-node-header > div { min-width: 0; }
|
||||
.hardware-node-header h2 {
|
||||
margin: 0 0 0.2em;
|
||||
font-size: clamp(0.75rem, 1.1cqw, 0.95rem);
|
||||
line-height: 1.25;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hardware-node-header p { margin: 0; }
|
||||
|
||||
.hardware-dashboard .secondary-button {
|
||||
min-height: 2.2em;
|
||||
margin-top: 0.3em;
|
||||
padding: 0.35em 0.8em;
|
||||
font-size: clamp(0.65rem, 0.85cqw, 0.75rem);
|
||||
}
|
||||
|
||||
.hardware-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 2%;
|
||||
}
|
||||
|
||||
.hardware-metric {
|
||||
min-width: 0;
|
||||
padding: 5%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.hardware-metric h3 {
|
||||
font-size: clamp(0.625rem, 0.85cqw, 0.75rem);
|
||||
margin: 0 0 3%;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.hardware-metric strong {
|
||||
display: block;
|
||||
color: var(--text-h);
|
||||
font-size: clamp(1rem, 1.5cqw, 1.3rem);
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.hardware-metric meter { width: 100%; height: 0.65em; margin-top: 3%; }
|
||||
.hardware-metric p { margin: 3% 0 0; }
|
||||
.hardware-updated { margin: 1.5% 0 0; }
|
||||
|
||||
@container (min-width: 1400px) {
|
||||
.hardware-nodes { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@container (max-width: 700px) {
|
||||
.hardware-nodes {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 1.5cqw;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 320px) {
|
||||
.hardware-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.4em;
|
||||
}
|
||||
}
|
||||
|
||||
.database-connections {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.database-connection-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.database-connection-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.database-connection-metric h3 {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.database-connection-metric strong {
|
||||
color: var(--text-h);
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.database-connection-metric meter {
|
||||
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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Proportional spacing with readable text limits on very large/small screens. */
|
||||
.main-content:has(.node-details-page) {
|
||||
padding: 1.2%;
|
||||
}
|
||||
|
||||
.node-details-page {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
container-type: inline-size;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.node-details-page > .page-header {
|
||||
gap: 1%;
|
||||
margin-bottom: 1%;
|
||||
}
|
||||
|
||||
.node-details-page > .page-header h1 {
|
||||
font-size: clamp(1.25rem, 2.2cqw, 1.75rem);
|
||||
}
|
||||
|
||||
.node-details-page .secondary-button {
|
||||
min-height: 2.2em;
|
||||
padding: 0.35em 0.8em;
|
||||
font-size: clamp(0.65rem, 0.9cqw, 0.75rem);
|
||||
}
|
||||
|
||||
.node-metrics-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
column-gap: 0.8%;
|
||||
row-gap: 0.8cqw;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard > section {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 2.5%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .page-header {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 0.3em 2%;
|
||||
margin-bottom: 2%;
|
||||
padding-bottom: 2%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.node-metrics-dashboard h2 {
|
||||
margin-bottom: 0.25em;
|
||||
font-size: clamp(0.7rem, 1cqw, 0.85rem);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard p,
|
||||
.node-metrics-dashboard a {
|
||||
font-size: clamp(0.625rem, 0.8cqw, 0.7rem);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .page-actions {
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .database-connection-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 2%;
|
||||
row-gap: 0.35cqw;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .database-connection-metric {
|
||||
min-width: 0;
|
||||
gap: 0.2em;
|
||||
padding: 4%;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .database-connection-metric h3 {
|
||||
font-size: clamp(0.625rem, 0.8cqw, 0.7rem);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .database-connection-metric strong {
|
||||
font-size: clamp(0.9rem, 1.3cqw, 1.15rem);
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.node-docker-panel {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard > .node-docker-panel {
|
||||
padding: 0.8% 1.2%;
|
||||
}
|
||||
|
||||
.node-docker-panel code {
|
||||
font-size: inherit;
|
||||
padding: 0.3% 1%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.node-docker-panel .page-header {
|
||||
align-items: center;
|
||||
gap: 0 2%;
|
||||
margin-bottom: 0.5%;
|
||||
padding-bottom: 0.5%;
|
||||
}
|
||||
|
||||
.node-docker-panel h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .page-actions {
|
||||
flex: 1 1 60%;
|
||||
gap: 0 2%;
|
||||
}
|
||||
|
||||
.node-docker-panel .page-actions > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .secondary-button {
|
||||
min-height: 0;
|
||||
padding: 0.5% 1.2%;
|
||||
flex-shrink: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.node-docker-panel .connection-status {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .docker-view-all {
|
||||
margin: 0.6% 0 0;
|
||||
}
|
||||
|
||||
.node-replication-panel {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.node-replication-panel .nodes-table-wrap,
|
||||
.node-docker-panel .nodes-table-wrap {
|
||||
margin-top: 1%;
|
||||
}
|
||||
|
||||
.node-replication-panel .nodes-table,
|
||||
.node-docker-panel .nodes-table {
|
||||
min-width: 0;
|
||||
font-size: clamp(0.625rem, 0.8cqw, 0.7rem);
|
||||
}
|
||||
|
||||
.node-replication-panel .nodes-table th,
|
||||
.node-replication-panel .nodes-table td,
|
||||
.node-replication-panel .nodes-table caption,
|
||||
.node-docker-panel .nodes-table th,
|
||||
.node-docker-panel .nodes-table td {
|
||||
padding: 1% 1.5%;
|
||||
font-size: inherit;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.node-docker-panel .nodes-table-wrap {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.node-docker-panel .nodes-table th,
|
||||
.node-docker-panel .nodes-table td {
|
||||
padding: 0.4% 1%;
|
||||
}
|
||||
|
||||
.node-metrics-dashboard .status-message {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
margin-bottom: 2%;
|
||||
padding: 1% 2%;
|
||||
}
|
||||
|
||||
@container (min-width: 1200px) {
|
||||
.node-metrics-dashboard {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 780px) {
|
||||
.node-metrics-dashboard {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
row-gap: 1.2cqw;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 460px) {
|
||||
.node-metrics-dashboard {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 2cqw;
|
||||
}
|
||||
|
||||
.node-replication-panel {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the all-tables view aligned with the compact node dashboard. */
|
||||
.main-content:has(.all-tables-page) {
|
||||
padding: 0.6%;
|
||||
}
|
||||
|
||||
.all-tables-page {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
container-type: inline-size;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.all-tables-detail .page-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0.6%;
|
||||
margin-bottom: 0.5%;
|
||||
}
|
||||
|
||||
.all-tables-detail .page-header > div:first-child {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.all-tables-detail h1 {
|
||||
font-size: clamp(1.25rem, 2.2cqw, 1.75rem);
|
||||
}
|
||||
|
||||
.all-tables-detail .database-connection-metrics {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||
gap: 0 0.6%;
|
||||
margin-bottom: 0.4%;
|
||||
}
|
||||
|
||||
.all-tables-detail .database-connection-metric {
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
padding: 1.5% 2%;
|
||||
margin-bottom: 1%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.all-tables-detail .database-connection-metric h3 {
|
||||
font-size: clamp(0.625rem, 0.8cqw, 0.7rem);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.all-tables-detail .database-connection-metric strong {
|
||||
font-size: clamp(0.9rem, 1.3cqw, 1.15rem);
|
||||
line-height: 1.2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.all-tables-detail .secondary-button {
|
||||
min-height: 2.2em;
|
||||
padding: 1% 3%;
|
||||
font-size: clamp(0.65rem, 0.9cqw, 0.75rem);
|
||||
}
|
||||
|
||||
.all-tables-detail .nodes-table th,
|
||||
.all-tables-detail .nodes-table td {
|
||||
padding: 0.35% 0.6%;
|
||||
}
|
||||
|
||||
.all-tables-detail .nodes-table caption {
|
||||
padding: 0.35% 0.6%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.all-tables-detail nav.page-actions {
|
||||
align-items: center;
|
||||
margin-top: 0.5%;
|
||||
}
|
||||
|
||||
.all-tables-detail {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.all-tables-detail .page-actions {
|
||||
gap: 0 2%;
|
||||
}
|
||||
|
||||
.all-tables-detail .page-header > .page-actions {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.all-tables-detail .secondary-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.all-tables-detail .database-connection-metric h3 {
|
||||
margin-bottom: 0.75%;
|
||||
}
|
||||
|
||||
.all-tables-detail .status-message {
|
||||
margin-bottom: 0.5%;
|
||||
padding: 0.35% 0.6%;
|
||||
}
|
||||
|
||||
/* Compact Nodes overview and registration form. */
|
||||
.main-content:has(.nodes-page) {
|
||||
padding: 0.6%;
|
||||
}
|
||||
|
||||
.nodes-page {
|
||||
width: 100%;
|
||||
container-type: inline-size;
|
||||
font-size: 75%;
|
||||
}
|
||||
|
||||
.nodes-page .page-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 0 0.6%;
|
||||
margin-bottom: 0.5%;
|
||||
}
|
||||
|
||||
.nodes-page h1 {
|
||||
font-size: clamp(1.25rem, 2.2cqw, 1.75rem);
|
||||
}
|
||||
|
||||
.nodes-page .page-actions {
|
||||
flex: 0 1 40%;
|
||||
gap: 0 2%;
|
||||
}
|
||||
|
||||
.nodes-page .primary-button,
|
||||
.nodes-page .secondary-button,
|
||||
.nodes-page .danger-button {
|
||||
min-height: 2.2em;
|
||||
padding: 1% 3%;
|
||||
font-size: clamp(0.65rem, 0.9cqw, 0.75rem);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nodes-page .nodes-table th,
|
||||
.nodes-page .nodes-table td {
|
||||
padding: 0.35% 0.6%;
|
||||
}
|
||||
|
||||
.nodes-page .nodes-table .danger-button {
|
||||
padding: 1% 6%;
|
||||
}
|
||||
|
||||
.nodes-page .register-form {
|
||||
margin-bottom: 0.5%;
|
||||
padding: 0.8%;
|
||||
}
|
||||
|
||||
.nodes-page .form-grid {
|
||||
gap: 0 1%;
|
||||
}
|
||||
|
||||
.nodes-page .form-field {
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
margin-bottom: 1.5%;
|
||||
}
|
||||
|
||||
.nodes-page .form-field span {
|
||||
margin-bottom: 0.5%;
|
||||
font-size: clamp(0.65rem, 0.9cqw, 0.75rem);
|
||||
}
|
||||
|
||||
.nodes-page .form-field input {
|
||||
min-width: 0;
|
||||
min-height: 2.2em;
|
||||
padding: 0.5% 1.5%;
|
||||
font-size: clamp(0.75rem, 1cqw, 0.85rem);
|
||||
}
|
||||
|
||||
.nodes-page .form-actions {
|
||||
gap: 0 1%;
|
||||
margin-top: 0.5%;
|
||||
}
|
||||
|
||||
.nodes-page .status-message,
|
||||
.nodes-page .table-state {
|
||||
padding: 0.35% 0.6%;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
+142
-25
@@ -1,5 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom'
|
||||
import './App.css'
|
||||
import NodeDetailsDashboard from './NodeDetailsDashboard'
|
||||
import NodeDeadTuples from './NodeDeadTuples'
|
||||
import NodeLargestTables from './NodeLargestTables'
|
||||
import NodeHardware from './NodeHardware'
|
||||
import NodeDockerContainers from './NodeDockerContainers'
|
||||
import ClusterNodeCard from './ClusterNodeCard'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
@@ -254,8 +261,27 @@ const createCluster = async (payload: CreateClusterPayload) => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={`/${getSavedPage()}`} replace />} />
|
||||
<Route path="/dashboard" element={<Workspace page="dashboard" />} />
|
||||
<Route path="/nodes" element={<Workspace page="nodes" />} />
|
||||
<Route path="/clusters" element={<Workspace page="clusters" />} />
|
||||
<Route path="/clusters/:clusterId" element={<Workspace page="cluster-details" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/dead-tuples" element={<Workspace page="dead-tuples" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/largest-tables" element={<Workspace page="largest-tables" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId/docker-containers" element={<Workspace page="docker-containers" />} />
|
||||
<Route path="/clusters/:clusterId/nodes/:nodeId" element={<Workspace page="node-details" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function Workspace({ page: activePage }: { page: PageId | 'node-details' | 'largest-tables' | 'dead-tuples' | 'docker-containers' }) {
|
||||
const navigate = useNavigate()
|
||||
const { clusterId, nodeId } = useParams()
|
||||
const isClusterPage = Boolean(useMatch('/clusters/*'))
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [activePage, setActivePage] = useState<PageId>(getSavedPage)
|
||||
const [theme, setTheme] = useState<Theme>(getSavedTheme)
|
||||
const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false)
|
||||
const [isClusterFormOpen, setIsClusterFormOpen] = useState(false)
|
||||
@@ -304,21 +330,33 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClusterClick = async (cluster: Cluster) => {
|
||||
const handleClusterClick = (cluster: Cluster) => {
|
||||
setSelectedCluster(cluster)
|
||||
navigate(`/clusters/${encodeURIComponent(cluster.uuid)}`)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!clusterId) return
|
||||
let cancelled = false
|
||||
setClusterDetails(null)
|
||||
setClusterNodesStatus('loading')
|
||||
setClusterNodesError('')
|
||||
|
||||
try {
|
||||
const response = await getClusterResource(`cluster-nodes/${encodeURIComponent(cluster.uuid)}`)
|
||||
setClusterDetails(await response.json() as ClusterNodesResponse)
|
||||
setClusterNodesStatus('success')
|
||||
selectPage('cluster-details')
|
||||
} catch (error) {
|
||||
setClusterNodesStatus('error')
|
||||
setClusterNodesError(error instanceof Error ? error.message : 'Failed to load cluster nodes')
|
||||
}
|
||||
}
|
||||
void getClusterResource(`cluster-nodes/${encodeURIComponent(clusterId)}`)
|
||||
.then((response) => response.json() as Promise<ClusterNodesResponse>)
|
||||
.then((details) => {
|
||||
if (cancelled) return
|
||||
setClusterDetails(details)
|
||||
setClusterNodesStatus('success')
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setClusterNodesStatus('error')
|
||||
setClusterNodesError(error instanceof Error ? error.message : 'Failed to load cluster nodes')
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [clusterId])
|
||||
|
||||
const loadNodeInterfaces = async () => {
|
||||
setNodesStatus('loading')
|
||||
@@ -349,13 +387,13 @@ function App() {
|
||||
if (activePage === 'clusters') {
|
||||
void loadClusters()
|
||||
}
|
||||
if (activePage === 'nodes' || activePage === 'clusters') {
|
||||
if (activePage === 'dashboard' || activePage === 'nodes' || activePage === 'clusters') {
|
||||
void loadNodeInterfaces()
|
||||
}
|
||||
}, [activePage])
|
||||
|
||||
const selectPage = (page: PageId) => {
|
||||
setActivePage(page)
|
||||
navigate(`/${page}`)
|
||||
savePage(page)
|
||||
}
|
||||
|
||||
@@ -531,10 +569,10 @@ function App() {
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`nav-link ${activePage === item.id ? 'active' : ''}`}
|
||||
className={`nav-link ${(activePage === item.id || (item.id === 'clusters' && isClusterPage)) ? 'active' : ''}`}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
onClick={() => selectPage(item.id)}
|
||||
aria-current={activePage === item.id ? 'page' : undefined}
|
||||
aria-current={(activePage === item.id || (item.id === 'clusters' && isClusterPage)) ? 'page' : undefined}
|
||||
>
|
||||
<span className="nav-icon" aria-hidden="true">
|
||||
{item.label.charAt(0)}
|
||||
@@ -561,6 +599,79 @@ function App() {
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
{clusterId && (
|
||||
<>
|
||||
{clusterNodesStatus === 'loading' && <p role="status">Loading...</p>}
|
||||
{clusterNodesStatus === 'error' && (
|
||||
<div>
|
||||
<p className="status-message error" role="alert">{clusterNodesError}</p>
|
||||
<button type="button" className="secondary-button" onClick={() => navigate(activePage === 'node-details' ? `/clusters/${encodeURIComponent(clusterId)}` : '/clusters')}>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activePage === 'node-details' && clusterDetails && (
|
||||
<section className="content-panel node-details-page" id="node-details">
|
||||
<div className="page-header">
|
||||
<h1>{clusterDetails.nodes.find((node) => node.uuid === nodeId)?.node_name ?? 'Node not found'}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(`/clusters/${encodeURIComponent(clusterId!)}`)}
|
||||
>
|
||||
Back to cluster
|
||||
</button>
|
||||
</div>
|
||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
|
||||
<NodeDetailsDashboard
|
||||
key={`${clusterId}/${nodeId}`}
|
||||
clusterId={clusterId!}
|
||||
nodeId={nodeId}
|
||||
nodeName={clusterDetails.nodes.find((node) => node.uuid === nodeId)!.node_name}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{activePage === 'docker-containers' && clusterDetails && (
|
||||
<section className="content-panel node-details-page">
|
||||
<div className="page-header">
|
||||
<h1>{clusterDetails.nodes.find((node) => node.uuid === nodeId)?.node_name ?? 'Node not found'}</h1>
|
||||
</div>
|
||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) && (
|
||||
<div className="node-metrics-dashboard">
|
||||
<NodeDockerContainers key={`${clusterId}/${nodeId}`} nodeId={nodeId} clusterId={clusterId!} detailed />
|
||||
</div>
|
||||
)}
|
||||
</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 === 'dead-tuples' && clusterDetails && (
|
||||
<section className="content-panel all-tables-page">
|
||||
{nodeId && clusterDetails.nodes.some((node) => node.uuid === nodeId) ? (
|
||||
<NodeDeadTuples
|
||||
key={`dead-tuples-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 && (
|
||||
<section className="content-panel" id="cluster-details">
|
||||
<div className="page-header">
|
||||
@@ -577,8 +688,8 @@ function App() {
|
||||
{clusterDetails.nodes.length > 0 ? (
|
||||
<ul className="cluster-node-list" aria-label="Cluster nodes">
|
||||
{clusterDetails.nodes.map((node) => (
|
||||
<li className="cluster-node-card" key={node.uuid}>
|
||||
{node.node_name}
|
||||
<li key={node.uuid}>
|
||||
<ClusterNodeCard node={node} clusterId={clusterId!} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -589,14 +700,20 @@ function App() {
|
||||
)}
|
||||
|
||||
{activePage === 'dashboard' && (
|
||||
<section className="content-panel" id="dashboard">
|
||||
<section className="content-panel hardware-dashboard" id="dashboard">
|
||||
<p className="eyebrow">Dashboard</p>
|
||||
<h1>Welcome back</h1>
|
||||
<p>
|
||||
Your main workspace is ready. The left navigation can collapse
|
||||
now, and more sections can be added to the nav list as the app
|
||||
grows.
|
||||
</p>
|
||||
<h1>Hardware usage</h1>
|
||||
<p>Live CPU, memory, and disk usage for each registered node.</p>
|
||||
{(nodesStatus === 'idle' || nodesStatus === 'loading') && <p role="status">Loading nodes…</p>}
|
||||
{nodesStatus === 'error' && <div>
|
||||
<p className="status-message error" role="alert">{nodesError}</p>
|
||||
<button className="secondary-button" onClick={() => void loadNodeInterfaces()}>Retry loading nodes</button>
|
||||
</div>}
|
||||
{nodesStatus === 'success' && (nodes.length === 0
|
||||
? <p>No registered nodes. Add a node from the Nodes page to monitor hardware usage.</p>
|
||||
: <div className="hardware-nodes">
|
||||
{nodes.map((node) => <NodeHardware key={`${node.uuid}:${node.host}`} name={node.if_name} host={node.host} />)}
|
||||
</div>)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import useDatabaseStatus from './useDatabaseStatus'
|
||||
|
||||
export default function ClusterNodeCard({ node, clusterId }: {
|
||||
node: { uuid: string; node_name: string }
|
||||
clusterId: string
|
||||
}) {
|
||||
const { data: databaseStatus, connection: databaseConnection } = useDatabaseStatus(node.uuid, node.node_name)
|
||||
return (
|
||||
<Link
|
||||
className="cluster-node-card"
|
||||
to={`/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(node.uuid)}`}
|
||||
>
|
||||
<span className="cluster-node-name">{node.node_name}</span>
|
||||
<span className={`connection-status ${databaseStatus?.status === 'online' ? 'connected' : databaseStatus?.status === 'offline' ? 'disconnected' : ''}`}>
|
||||
<span className="connection-status-dot" aria-hidden="true" />
|
||||
{databaseStatus ? databaseStatus.status.charAt(0).toUpperCase() + databaseStatus.status.slice(1) : databaseConnection}
|
||||
</span>
|
||||
<span className="cluster-node-database">Database: {databaseStatus?.database_name ?? '—'}</span>
|
||||
<span className="cluster-node-uptime">
|
||||
<span>Uptime</span>
|
||||
<strong>{databaseStatus ? `${(databaseStatus.uptime_seconds / 3600).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} hours` : '—'}</strong>
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import useNodeDatabaseMetric from './useNodeDatabaseMetric'
|
||||
|
||||
type ActiveConnections = {
|
||||
database_name: string
|
||||
active_connections: number
|
||||
current_connections: number
|
||||
max_connections: number
|
||||
connection_usage_percent: number
|
||||
}
|
||||
|
||||
function isActiveConnections(value: unknown): value is ActiveConnections {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& ['active_connections', 'current_connections', 'max_connections'].every((field) => {
|
||||
const count = data[field]
|
||||
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0
|
||||
})
|
||||
&& typeof data.connection_usage_percent === 'number'
|
||||
&& Number.isFinite(data.connection_usage_percent)
|
||||
&& data.connection_usage_percent >= 0 && data.connection_usage_percent <= 100
|
||||
}
|
||||
|
||||
|
||||
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 },
|
||||
{ label: 'Max connections', value: data?.max_connections },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="database-connections" aria-labelledby="database-connections-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 id="database-connections-heading">Database connections</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>
|
||||
{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">
|
||||
<h3>Connection usage</h3>
|
||||
<strong>{data ? `${data.connection_usage_percent.toFixed(1)}%` : '—'}</strong>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type ReplicationHealth = {
|
||||
node: string
|
||||
connection_healthy: boolean
|
||||
subscriptions: {
|
||||
subscription_name: string
|
||||
status: string
|
||||
provider_node: string
|
||||
}[]
|
||||
}
|
||||
|
||||
function isReplicationHealth(value: unknown): value is ReplicationHealth {
|
||||
if (typeof value !== 'object' || value === null) return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.node === 'string'
|
||||
&& typeof data.connection_healthy === 'boolean'
|
||||
&& Array.isArray(data.subscriptions)
|
||||
&& data.subscriptions.every((subscription: unknown) => {
|
||||
if (typeof subscription !== 'object' || subscription === null) return false
|
||||
const item = subscription as Record<string, unknown>
|
||||
return typeof item.subscription_name === 'string'
|
||||
&& typeof item.status === 'string'
|
||||
&& typeof item.provider_node === 'string'
|
||||
})
|
||||
}
|
||||
|
||||
export default function NodeConnections({ nodeId }: { nodeId: string }) {
|
||||
const [health, setHealth] = useState<ReplicationHealth | null>(null)
|
||||
const [status, setStatus] = useState('Loading node connection details…')
|
||||
const [error, setError] = useState('')
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let socket: WebSocket | undefined
|
||||
const controller = new AbortController()
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const baseUrl = import.meta.env.VITE_WS_BASE_URL
|
||||
if (!apiBaseUrl) throw new Error('VITE_API_BASE_URL is not configured.')
|
||||
if (!baseUrl) throw new Error('VITE_WS_BASE_URL is not configured.')
|
||||
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
'Authentication required. Please log in again.'
|
||||
)
|
||||
}
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl.replace(/\/$/, '')}/spock/node-interfaces/${encodeURIComponent(nodeId)}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(response.status === 404
|
||||
? 'Node not found.'
|
||||
: `Failed to load node connection details (${response.status}).`)
|
||||
}
|
||||
const data = await response.json()
|
||||
const node = data?.node
|
||||
if (data?.status !== 'success' || !node
|
||||
|| node.uuid !== nodeId
|
||||
|| typeof node.if_name !== 'string'
|
||||
|| typeof node.host !== 'string'
|
||||
|| typeof node.port !== 'number'
|
||||
|| typeof node.dbname !== 'string'
|
||||
|| typeof node.user !== 'string') {
|
||||
throw new Error('Invalid node connection details response.')
|
||||
}
|
||||
if (!active) return
|
||||
|
||||
const wstoken = token
|
||||
if (!wstoken) throw new Error('Authentication required. Get a token from the Nodes page, then reconnect.')
|
||||
setStatus('Connecting to replication health…')
|
||||
const connection = new WebSocket(`${baseUrl.replace(/\/$/, '')}/ws/node/replication-health?token=${encodeURIComponent(wstoken)}`)
|
||||
socket = connection
|
||||
connection.onopen = () => {
|
||||
if (!active) return
|
||||
setStatus('Waiting for replication health…')
|
||||
connection.send(JSON.stringify({
|
||||
node_name: node.if_name,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
dbname: node.dbname,
|
||||
user: node.user,
|
||||
password: 'postgres',
|
||||
}))
|
||||
}
|
||||
connection.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const data: unknown = JSON.parse(event.data)
|
||||
if (!isReplicationHealth(data)) throw new Error('Invalid replication health response.')
|
||||
setHealth(data)
|
||||
setError('')
|
||||
setStatus('Live updates connected')
|
||||
} catch {
|
||||
setHealth(null)
|
||||
setStatus('')
|
||||
setError('Received an invalid replication health response.')
|
||||
}
|
||||
}
|
||||
connection.onerror = () => {
|
||||
if (!active) return
|
||||
setHealth(null)
|
||||
setStatus('')
|
||||
setError('Unable to receive replication health. Check that the WebSocket server is running.')
|
||||
}
|
||||
connection.onclose = () => {
|
||||
if (!active) return
|
||||
setHealth(null)
|
||||
setStatus('')
|
||||
setError((current) => current || 'Replication health connection closed. Reconnect to receive updates.')
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!active) return
|
||||
setStatus('')
|
||||
setError(cause instanceof Error ? cause.message : 'Unable to load node connection details.')
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
socket?.close()
|
||||
}
|
||||
}, [attempt, nodeId])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Connections</h2>
|
||||
{status && <p role="status">{status}</p>}
|
||||
{error && (
|
||||
<div>
|
||||
<p className="status-message error" role="alert">{error}</p>
|
||||
<button className="secondary-button" type="button" onClick={() => {
|
||||
setHealth(null)
|
||||
setError('')
|
||||
setStatus('Loading node connection details…')
|
||||
setAttempt((current) => current + 1)
|
||||
}}>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{health && (
|
||||
<>
|
||||
<p className={`status-message ${health.connection_healthy ? 'success' : 'error'}`} role="status">
|
||||
{health.node}: {health.connection_healthy ? 'Connection healthy' : 'Connection unhealthy'}
|
||||
</p>
|
||||
{health.subscriptions.length > 0 ? (
|
||||
<div className="nodes-table-wrap">
|
||||
<table className="nodes-table connections-table">
|
||||
<caption>Replication subscriptions for {health.node}</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Provider node</th><th scope="col">Subscription</th><th scope="col">Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{health.subscriptions.map((subscription) => (
|
||||
<tr key={subscription.subscription_name}>
|
||||
<td>{subscription.provider_node}</td>
|
||||
<td>{subscription.subscription_name}</td>
|
||||
<td>
|
||||
<span className={`connection-status ${subscription.status === 'replicating' ? 'connected' : subscription.status === 'down' ? 'disconnected' : ''}`}>
|
||||
{(subscription.status === 'replicating' || subscription.status === 'down') && (
|
||||
<span className="connection-status-dot" aria-hidden="true" />
|
||||
)}
|
||||
{subscription.status === 'replicating' ? 'Connected' : subscription.status === 'down' ? 'Disconnected' : subscription.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <p>No replication connections found.</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,218 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
type DeadTuples = {
|
||||
database_name: string
|
||||
table_count: number
|
||||
total_table_size_bytes: number
|
||||
total_table_size_mb: number
|
||||
total_dead_tuples: number
|
||||
tables: {
|
||||
schema: string
|
||||
table_name: string
|
||||
live_tuples: number
|
||||
dead_tuples: number
|
||||
size_bytes: number
|
||||
size_mb: number
|
||||
last_vacuum: string | null
|
||||
last_autovacuum: string | null
|
||||
}[]
|
||||
}
|
||||
|
||||
function isCount(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
function isSizeMb(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
|
||||
function formatMb(value: number) {
|
||||
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
function isTimestamp(value: unknown): value is string | null {
|
||||
return value === null || (typeof value === 'string' && Number.isFinite(Date.parse(value)))
|
||||
}
|
||||
|
||||
function isDeadTuples(value: unknown): value is DeadTuples {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string' && isCount(data.total_dead_tuples)
|
||||
&& isCount(data.table_count) && isCount(data.total_table_size_bytes) && isSizeMb(data.total_table_size_mb)
|
||||
&& 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'
|
||||
&& isCount(table.live_tuples) && isCount(table.dead_tuples)
|
||||
&& isCount(table.size_bytes) && isSizeMb(table.size_mb)
|
||||
&& isTimestamp(table.last_vacuum) && isTimestamp(table.last_autovacuum)
|
||||
})
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null) {
|
||||
return value === null ? 'Never' : new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
export default function NodeDeadTuples({ nodeId, nodeName, clusterId, detailed = false }: {
|
||||
nodeId: string; nodeName: string; clusterId: string; detailed?: boolean
|
||||
}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [data, setData] = useState<DeadTuples | null>(null)
|
||||
const [status, setStatus] = useState('Loading dead tuples…')
|
||||
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/dead-tuples`, {
|
||||
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 dead tuples (${response.status}).`)
|
||||
const size: unknown = await response.json()
|
||||
if (!isDeadTuples(size)) throw new Error('Invalid dead tuples response.')
|
||||
if (!active) return
|
||||
setData(size)
|
||||
setStatus('Loaded')
|
||||
} catch (cause) {
|
||||
if (!active) return
|
||||
setStatus('')
|
||||
setError(cause instanceof Error ? cause.message : 'Unable to load dead tuples.')
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
}
|
||||
}, [nodeId, nodeName, attempt])
|
||||
|
||||
const loading = status === 'Loading dead tuples…'
|
||||
const nodePath = `/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(nodeId)}`
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil((data?.tables.length ?? 0) / PAGE_SIZE))
|
||||
const currentPage = Math.min(page, pageCount)
|
||||
const visibleTables = data?.tables.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) ?? []
|
||||
|
||||
return (
|
||||
<section className={`database-connections${detailed ? ' all-tables-detail' : ''}`} aria-labelledby="dead-tuples-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
{detailed
|
||||
? <h1 id="dead-tuples-heading">Table tuples · {nodeName}</h1>
|
||||
: <h2 id="dead-tuples-heading">Show all tables</h2>}
|
||||
{data && <p>Database: {data.database_name} · Tables: {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={() => {
|
||||
setPage(1)
|
||||
setData(null)
|
||||
setError('')
|
||||
setStatus('Loading dead tuples…')
|
||||
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>}
|
||||
<div className={detailed ? 'database-connection-metrics' : undefined}>
|
||||
<div className="database-connection-metric">
|
||||
<h3>{detailed ? 'Total dead tuples' : 'Total tables'}</h3>
|
||||
<strong>{data ? (detailed ? data.total_dead_tuples : data.table_count).toLocaleString() : '—'}</strong>
|
||||
{!detailed && <Link to={`${nodePath}/dead-tuples`}>View all tables</Link>}
|
||||
</div>
|
||||
{detailed && data && (
|
||||
<>
|
||||
<div className="database-connection-metric">
|
||||
<h3>Total table size (MB)</h3>
|
||||
<strong>{formatMb(data.total_table_size_mb)}</strong>
|
||||
</div>
|
||||
<div className="database-connection-metric">
|
||||
<h3>Total table size (bytes)</h3>
|
||||
<strong>{data.total_table_size_bytes.toLocaleString()}</strong>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{detailed && data && (data.tables.length === 0 ? <p>No tables found.</p> : (
|
||||
<>
|
||||
<div className="nodes-table-wrap">
|
||||
<table className="nodes-table">
|
||||
<caption>Live and dead tuples for all tables · Vacuum times shown in your local time</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Schema</th><th scope="col">Table name</th>
|
||||
<th scope="col">Live tuples</th><th scope="col">Dead tuples</th>
|
||||
<th scope="col">Size (MB)</th><th scope="col">Size (bytes)</th>
|
||||
<th scope="col">Last vacuum</th><th scope="col">Last autovacuum</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.live_tuples.toLocaleString()}</td><td>{table.dead_tuples.toLocaleString()}</td>
|
||||
<td>{formatMb(table.size_mb)}</td><td>{table.size_bytes.toLocaleString()}</td>
|
||||
<td>{formatTimestamp(table.last_vacuum)}</td><td>{formatTimestamp(table.last_autovacuum)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<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,31 @@
|
||||
import NodeActiveConnections from './NodeActiveConnections'
|
||||
import NodePerformance from './NodePerformance'
|
||||
import NodeDatabaseSize from './NodeDatabaseSize'
|
||||
import NodeIndexSize from './NodeIndexSize'
|
||||
import NodeTupleActivity from './NodeTupleActivity'
|
||||
import NodeWalGenerated from './NodeWalGenerated'
|
||||
import NodeWalSize from './NodeWalSize'
|
||||
import NodeDeadTuples from './NodeDeadTuples'
|
||||
import NodeLocksDeadlocks from './NodeLocksDeadlocks'
|
||||
import NodeConnections from './NodeConnections'
|
||||
import NodeDockerContainers from './NodeDockerContainers'
|
||||
|
||||
export default function NodeDetailsDashboard(props: { nodeId: string; nodeName: string; clusterId: string }) {
|
||||
return (
|
||||
<section className="node-metrics-dashboard" aria-label="Node details dashboard">
|
||||
<NodeActiveConnections {...props} />
|
||||
<NodePerformance {...props} />
|
||||
<NodeTupleActivity {...props} />
|
||||
<NodeDatabaseSize {...props} />
|
||||
<NodeIndexSize {...props} />
|
||||
<NodeWalGenerated {...props} />
|
||||
<NodeWalSize {...props} />
|
||||
<NodeLocksDeadlocks {...props} />
|
||||
<NodeDeadTuples {...props} />
|
||||
<NodeDockerContainers key={props.nodeId} nodeId={props.nodeId} clusterId={props.clusterId} />
|
||||
<section className="node-replication-panel" aria-label="Replication connections">
|
||||
<NodeConnections nodeId={props.nodeId} />
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Container = { id: string; name: string; status: string }
|
||||
|
||||
function isContainerResponse(value: unknown): value is { containers: Container[] } {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const containers = (value as Record<string, unknown>).containers
|
||||
return Array.isArray(containers) && containers.every((container: unknown) => {
|
||||
if (!container || typeof container !== 'object') return false
|
||||
const item = container as Record<string, unknown>
|
||||
return typeof item.id === 'string' && typeof item.name === 'string' && typeof item.status === 'string'
|
||||
})
|
||||
}
|
||||
|
||||
export default function NodeDockerContainers({ nodeId, clusterId, detailed = false }: {
|
||||
nodeId: string; clusterId: string; detailed?: boolean
|
||||
}) {
|
||||
const [containers, setContainers] = useState<Container[] | null>(null)
|
||||
const [status, setStatus] = useState('Connecting…')
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
let socket: WebSocket | undefined
|
||||
const disconnected = () => {
|
||||
if (!active) return
|
||||
setContainers(null)
|
||||
setStatus('Docker feed disconnected. Reconnect to try again.')
|
||||
}
|
||||
const connect = async () => {
|
||||
setContainers(null)
|
||||
setStatus('Loading node details…')
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
if (!apiBaseUrl) throw new Error('API URL is not configured.')
|
||||
const token = localStorage.getItem('access_token')
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl.replace(/\/$/, '')}/spock/node-interfaces/${encodeURIComponent(nodeId)}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to load node details.')
|
||||
const result = await response.json()
|
||||
const node = result?.node
|
||||
if (result?.status !== 'success' || node?.uuid !== nodeId
|
||||
|| typeof node.host !== 'string' || !node.host.trim()) {
|
||||
throw new Error('Invalid node host received.')
|
||||
}
|
||||
if (!active) return
|
||||
const host = node.host.trim()
|
||||
const hostname = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
|
||||
setStatus('Connecting…')
|
||||
socket = new WebSocket(`ws://${hostname}:9100/ws/docker/containers`)
|
||||
socket.onopen = () => {
|
||||
if (active) setStatus('Waiting for containers…')
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!isContainerResponse(message)) throw new Error('Invalid container data')
|
||||
setContainers(message.containers)
|
||||
setStatus('Live')
|
||||
} catch {
|
||||
setContainers(null)
|
||||
setStatus('Invalid container data received.')
|
||||
}
|
||||
}
|
||||
socket.onerror = disconnected
|
||||
socket.onclose = disconnected
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
setStatus(error instanceof Error ? error.message : 'Unable to connect to the Docker feed.')
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
socket?.close()
|
||||
}
|
||||
}, [attempt, nodeId])
|
||||
|
||||
const nodePath = `/clusters/${encodeURIComponent(clusterId)}/nodes/${encodeURIComponent(nodeId)}`
|
||||
const visibleContainers = detailed ? containers : containers?.slice(0, 3)
|
||||
|
||||
return (
|
||||
<section className="node-docker-panel database-connections" aria-labelledby="docker-containers-heading">
|
||||
<div className="page-header">
|
||||
<h2 id="docker-containers-heading">Docker containers</h2>
|
||||
<div className="page-actions">
|
||||
{detailed && <Link className="secondary-button" to={nodePath}>Back to node</Link>}
|
||||
<p role="status">{status}</p>
|
||||
<button type="button" className="secondary-button" onClick={() => {
|
||||
setContainers(null)
|
||||
setStatus('Connecting…')
|
||||
setAttempt((current) => current + 1)
|
||||
}}>Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
{containers !== null && (containers.length === 0 ? <p>No containers found.</p> : (
|
||||
<div className="nodes-table-wrap">
|
||||
<table className="nodes-table">
|
||||
<thead><tr><th scope="col">Name</th><th scope="col">Container ID</th><th scope="col">Status</th></tr></thead>
|
||||
<tbody>
|
||||
{visibleContainers?.map((container) => (
|
||||
<tr key={container.id}>
|
||||
<td>{container.name}</td>
|
||||
<td><code>{container.id}</code></td>
|
||||
<td><span className={`connection-status${container.status === 'running' ? ' connected' : ''}`}>{container.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
{!detailed && containers && containers.length > 3 && (
|
||||
<p className="docker-view-all"><Link to={`${nodePath}/docker-containers`}>View all {containers.length} containers</Link></p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const fields = [
|
||||
'cpu_usage_percent', 'memory_usage_percent', 'memory_total_mb',
|
||||
'memory_used_mb', 'memory_available_mb', 'disk_usage_percent',
|
||||
'disk_total_gb', 'disk_used_gb', 'disk_free_gb',
|
||||
] as const
|
||||
type HardwareStatus = Record<(typeof fields)[number], number>
|
||||
|
||||
function isHardwareStatus(value: unknown): value is HardwareStatus {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return fields.every((field) => {
|
||||
const number = data[field]
|
||||
return typeof number === 'number' && Number.isFinite(number) && number >= 0
|
||||
&& (!field.endsWith('_percent') || number <= 100)
|
||||
})
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value?: number; detail?: string }) {
|
||||
return (
|
||||
<div className="hardware-metric">
|
||||
<h3>{label}</h3>
|
||||
<strong>{value === undefined ? '—' : `${value.toFixed(1)}%`}</strong>
|
||||
{value !== undefined && <meter aria-label={`${label} usage`} min={0} max={100} value={value} />}
|
||||
<p>{detail ?? 'Awaiting live data'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NodeHardware({ name, host }: { name: string; host: string }) {
|
||||
const [data, setData] = useState<HardwareStatus | null>(null)
|
||||
const [status, setStatus] = useState('Connecting…')
|
||||
const [updated, setUpdated] = useState<Date | null>(null)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let socket: WebSocket | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
const armTimeout = () => {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setStatus('No recent hardware data. Reconnect to try again.')
|
||||
}, 30000)
|
||||
}
|
||||
try {
|
||||
socket = new WebSocket(`ws://${host}:9100/ws/hardware/status`)
|
||||
armTimeout()
|
||||
socket.onopen = () => {
|
||||
if (active) setStatus('Waiting for hardware data…')
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!isHardwareStatus(message)) throw new Error('Invalid hardware data')
|
||||
setData(message)
|
||||
setUpdated(new Date())
|
||||
setStatus('Live')
|
||||
armTimeout()
|
||||
} catch {
|
||||
setData(null)
|
||||
setStatus('Invalid hardware data received.')
|
||||
}
|
||||
}
|
||||
const disconnected = () => {
|
||||
if (!active) return
|
||||
clearTimeout(timeout)
|
||||
setData(null)
|
||||
setStatus('Hardware feed disconnected. Reconnect to try again.')
|
||||
}
|
||||
socket.onerror = disconnected
|
||||
socket.onclose = disconnected
|
||||
} catch {
|
||||
setStatus('Unable to connect to the hardware feed.')
|
||||
}
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timeout)
|
||||
socket?.close()
|
||||
}
|
||||
}, [host, attempt])
|
||||
|
||||
return (
|
||||
<article className="hardware-node">
|
||||
<header className="hardware-node-header">
|
||||
<div><h2>{name}</h2><p>{host}</p></div>
|
||||
<div>
|
||||
<p role="status" className={data ? 'connection-status connected' : ''}>{status}</p>
|
||||
{!data && <button className="secondary-button" onClick={() => {
|
||||
setData(null)
|
||||
setUpdated(null)
|
||||
setStatus('Connecting…')
|
||||
setAttempt((value) => value + 1)
|
||||
}}>Reconnect</button>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="hardware-metrics">
|
||||
<Metric label="CPU" value={data?.cpu_usage_percent} detail={data ? 'Current processor usage' : undefined} />
|
||||
<Metric label="Memory" value={data?.memory_usage_percent} detail={data ? `${data.memory_used_mb.toFixed(2)} / ${data.memory_total_mb.toFixed(2)} MB used · ${data.memory_available_mb.toFixed(2)} MB available` : undefined} />
|
||||
<Metric label="Disk" value={data?.disk_usage_percent} detail={data ? `${data.disk_used_gb.toFixed(2)} / ${data.disk_total_gb.toFixed(2)} GB used · ${data.disk_free_gb.toFixed(2)} GB free` : undefined} />
|
||||
</div>
|
||||
{updated && <p className="hardware-updated">Last received: {updated.toLocaleTimeString()}</p>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import useNodeDatabaseMetric from './useNodeDatabaseMetric'
|
||||
|
||||
type Performance = {
|
||||
database_name: string
|
||||
tps: number
|
||||
cache_hit_ratio: number
|
||||
}
|
||||
|
||||
function isPerformance(value: unknown): value is Performance {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& typeof data.tps === 'number' && Number.isFinite(data.tps) && data.tps >= 0
|
||||
&& typeof data.cache_hit_ratio === 'number' && Number.isFinite(data.cache_hit_ratio)
|
||||
&& data.cache_hit_ratio >= 0 && data.cache_hit_ratio <= 100
|
||||
}
|
||||
|
||||
export default function NodePerformance({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||
const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'performance', isPerformance)
|
||||
|
||||
return (
|
||||
<section className="database-connections" aria-labelledby="database-performance-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 id="database-performance-heading">Database performance</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">
|
||||
<div className="database-connection-metric">
|
||||
<h3>Transactions per second (TPS)</h3>
|
||||
<strong>{data ? data.tps.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 }) : '—'}</strong>
|
||||
</div>
|
||||
<div className="database-connection-metric">
|
||||
<h3>Cache hit ratio</h3>
|
||||
<strong>{data ? `${data.cache_hit_ratio.toFixed(2)}%` : '—'}</strong>
|
||||
{data && <meter aria-label="Cache hit ratio" min={0} max={100} value={data.cache_hit_ratio} />}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import useNodeDatabaseMetric from './useNodeDatabaseMetric'
|
||||
|
||||
const metrics = [
|
||||
{ field: 'wal_generated_bytes_per_second', label: 'WAL generated (bytes/s)' },
|
||||
{ field: 'wal_generated_mb_per_second', label: 'WAL generated (MB/s)' },
|
||||
] as const
|
||||
|
||||
type WalGenerated = {
|
||||
database_name: string
|
||||
} & Record<(typeof metrics)[number]['field'], number>
|
||||
|
||||
function isWalGenerated(value: unknown): value is WalGenerated {
|
||||
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 NodeWalGenerated({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||
const { data, connection } = useNodeDatabaseMetric(nodeId, nodeName, 'wal-generated', isWalGenerated)
|
||||
|
||||
return (
|
||||
<section className="database-connections" aria-labelledby="database-wal-generated-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 id="database-wal-generated-heading">WAL generated per second</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type WalSize = {
|
||||
database_name: string
|
||||
wal_size_bytes: number
|
||||
wal_size_mb: number
|
||||
}
|
||||
|
||||
function isWalSize(value: unknown): value is WalSize {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& typeof data.wal_size_bytes === 'number' && Number.isSafeInteger(data.wal_size_bytes) && data.wal_size_bytes >= 0
|
||||
&& typeof data.wal_size_mb === 'number' && Number.isFinite(data.wal_size_mb) && data.wal_size_mb >= 0
|
||||
}
|
||||
|
||||
export default function NodeWalSize({ nodeId, nodeName }: { nodeId: string; nodeName: string }) {
|
||||
const [data, setData] = useState<WalSize | null>(null)
|
||||
const [status, setStatus] = useState('Loading WAL 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/wal-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 WAL size (${response.status}).`)
|
||||
const size: unknown = await response.json()
|
||||
if (!isWalSize(size)) throw new Error('Invalid WAL size response.')
|
||||
if (!active) return
|
||||
setData(size)
|
||||
setStatus('Loaded')
|
||||
} catch (cause) {
|
||||
if (!active) return
|
||||
setStatus('')
|
||||
setError(cause instanceof Error ? cause.message : 'Unable to load WAL size.')
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
}
|
||||
}, [nodeId, nodeName, attempt])
|
||||
|
||||
const loading = status === 'Loading WAL size…'
|
||||
|
||||
return (
|
||||
<section className="database-connections" aria-labelledby="wal-size-heading">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h2 id="wal-size-heading">WAL 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 WAL 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 (MB)</h3>
|
||||
<strong>{data ? data.wal_size_mb.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '—'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ h2 {
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type DatabaseStatus = {
|
||||
database_name: string
|
||||
status: string
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
function isDatabaseStatus(value: unknown): value is DatabaseStatus {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const data = value as Record<string, unknown>
|
||||
return typeof data.database_name === 'string'
|
||||
&& typeof data.status === 'string' && data.status.trim().length > 0
|
||||
&& typeof data.uptime_seconds === 'number'
|
||||
&& Number.isFinite(data.uptime_seconds) && data.uptime_seconds >= 0
|
||||
}
|
||||
|
||||
export default function useDatabaseStatus(nodeId: string, nodeName: string) {
|
||||
const [data, setData] = useState<DatabaseStatus | null>(null)
|
||||
const [connection, setConnection] = useState('Connecting…')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const connect = async () => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setConnection('Connecting…')
|
||||
const disconnected = () => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setConnection('Status unavailable · reconnecting…')
|
||||
clearTimeout(retry)
|
||||
retry = setTimeout(connect, 5000)
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
|
||||
if (!wsBaseUrl) throw new Error('VITE_WS_BASE_URL is not configured')
|
||||
if (!apiBaseUrl) throw new Error('API URL is not configured')
|
||||
setConnection('Loading node details…')
|
||||
const token = localStorage.getItem('access_token')
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl.replace(/\/$/, '')}/spock/node-interfaces/${encodeURIComponent(nodeId)}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to load node details')
|
||||
const result = await response.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
|
||||
setConnection('Connecting…')
|
||||
const wstoken = token
|
||||
if (!wstoken?.trim()) {
|
||||
setConnection('Connection skipped: JWT URL parameter required')
|
||||
return
|
||||
}
|
||||
const current = new WebSocket(`${wsBaseUrl.replace(/\/$/, '')}/ws/database/status?token=${encodeURIComponent(wstoken)}`)
|
||||
socket = current
|
||||
current.onopen = () => {
|
||||
if (!active) return
|
||||
current.send(JSON.stringify({
|
||||
node_name: nodeName,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
dbname: node.dbname,
|
||||
user: node.user,
|
||||
password: 'postgres',
|
||||
}))
|
||||
setConnection('Waiting for status…')
|
||||
}
|
||||
current.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!isDatabaseStatus(message)) throw new Error('Invalid database status')
|
||||
setData({ ...message, status: message.status.trim().toLowerCase() })
|
||||
setConnection('Live')
|
||||
} catch {
|
||||
setData(null)
|
||||
setConnection('Invalid status received')
|
||||
}
|
||||
}
|
||||
current.onerror = () => current.close()
|
||||
current.onclose = disconnected
|
||||
} catch {
|
||||
disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [nodeId, nodeName])
|
||||
|
||||
return { data, connection }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export default function useNodeDatabaseMetric<T>(
|
||||
nodeId: string,
|
||||
nodeName: string,
|
||||
endpoint: string,
|
||||
validate: (value: unknown) => value is T,
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [connection, setConnection] = useState('Connecting…')
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const controller = new AbortController()
|
||||
let socket: WebSocket | undefined
|
||||
let retry: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const connect = async () => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setConnection('Connecting…')
|
||||
const disconnected = () => {
|
||||
if (!active) return
|
||||
setData(null)
|
||||
setConnection('Status unavailable · reconnecting…')
|
||||
clearTimeout(retry)
|
||||
retry = setTimeout(connect, 5000)
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const wsBaseUrl = import.meta.env.VITE_WS_BASE_URL
|
||||
if (!wsBaseUrl) throw new Error('VITE_WS_BASE_URL is not configured')
|
||||
if (!apiBaseUrl) throw new Error('API URL is not configured')
|
||||
setConnection('Loading node details…')
|
||||
const token = localStorage.getItem('access_token')
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl.replace(/\/$/, '')}/spock/node-interfaces/${encodeURIComponent(nodeId)}`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to load node details')
|
||||
const result = await response.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
|
||||
setConnection('Connecting…')
|
||||
const wstoken = token
|
||||
if (!wstoken?.trim()) {
|
||||
setConnection('Connection skipped: JWT URL parameter required')
|
||||
return
|
||||
}
|
||||
const current = new WebSocket(`${wsBaseUrl.replace(/\/$/, '')}/ws/database/${endpoint}?token=${encodeURIComponent(wstoken)}`)
|
||||
socket = current
|
||||
current.onopen = () => {
|
||||
if (!active) return
|
||||
current.send(JSON.stringify({
|
||||
node_name: nodeName,
|
||||
host: node.host,
|
||||
port: node.port,
|
||||
dbname: node.dbname,
|
||||
user: node.user,
|
||||
password: 'postgres',
|
||||
}))
|
||||
setConnection('Waiting for metrics…')
|
||||
}
|
||||
current.onmessage = (event) => {
|
||||
if (!active) return
|
||||
try {
|
||||
const message: unknown = JSON.parse(event.data)
|
||||
if (!validate(message)) throw new Error('Invalid metrics')
|
||||
setData(message)
|
||||
setConnection('Live')
|
||||
} catch {
|
||||
setData(null)
|
||||
setConnection('Invalid metrics received')
|
||||
}
|
||||
}
|
||||
current.onerror = () => current.close()
|
||||
current.onclose = disconnected
|
||||
} catch {
|
||||
disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
return () => {
|
||||
active = false
|
||||
controller.abort()
|
||||
clearTimeout(retry)
|
||||
socket?.close()
|
||||
}
|
||||
}, [nodeId, nodeName, endpoint, validate])
|
||||
|
||||
return { data, connection }
|
||||
}
|
||||
|
||||
+16
-4
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user