From 91db2e9c6cfbe2dfd67c726f5f8a12a15ea10a91 Mon Sep 17 00:00:00 2001 From: trean Date: Fri, 31 Jul 2026 17:05:25 +0200 Subject: [PATCH 01/13] wip: add new Settings page with limits setting UI (not wired to API yet) --- package-lock.json | 111 +++++++++++- package.json | 1 + src/components/Common/NumberField.jsx | 173 +++++++++++++++++++ src/components/Sidebar/Sidebar.jsx | 9 + src/pages/Settings.jsx | 238 ++++++++++++++++++++++++++ src/routes.jsx | 2 + 6 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 src/components/Common/NumberField.jsx create mode 100644 src/pages/Settings.jsx diff --git a/package-lock.json b/package-lock.json index c06928489..94dfca9a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.15", "license": "Apache-2.0", "dependencies": { + "@base-ui/react": "^1.6.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@mdx-js/react": "^2.3.0", @@ -310,7 +311,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "peer": true, "engines": { @@ -362,6 +365,66 @@ "node": ">=6.9.0" } }, + "node_modules/@base-ui/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", + "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@base-ui/utils": "0.3.1", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@date-fns/tz": "^1.2.0", + "@types/react": "^17 || ^18 || ^19", + "date-fns": "^4.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@date-fns/tz": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "date-fns": { + "optional": true + } + } + }, + "node_modules/@base-ui/utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", + "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@floating-ui/utils": "^0.2.11", + "reselect": "^5.2.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "@types/react": "^17 || ^18 || ^19", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "license": "MIT", @@ -948,6 +1011,44 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "dev": true, @@ -8815,7 +8916,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.1.1", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/resize-observer-polyfill": { @@ -9951,7 +10054,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 4d23a9271..628011b66 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "node": ">=24" }, "dependencies": { + "@base-ui/react": "^1.6.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@mdx-js/react": "^2.3.0", diff --git a/src/components/Common/NumberField.jsx b/src/components/Common/NumberField.jsx new file mode 100644 index 000000000..5023c55c7 --- /dev/null +++ b/src/components/Common/NumberField.jsx @@ -0,0 +1,173 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { styled } from '@mui/material/styles'; +import { NumberField as BaseNumberField } from '@base-ui/react/number-field'; +import { ChevronUp, ChevronDown } from 'lucide-react'; + +// A numeric input built on Base UI's NumberField, styled to match the app's +// MUI outlined inputs. Supports an optional suffix (e.g. "%") and up/down +// stepper buttons. Values are real numbers (`null` when empty), with min/max +// clamping and keyboard/scroll stepping handled by Base UI. + +const resolveBorder = (theme) => theme.palette.inputOutlinedEnabledBorder ?? theme.palette.divider; + +const Group = styled(BaseNumberField.Group)(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'stretch', + width: '100%', + borderRadius: '0.5rem', + border: `1px solid ${resolveBorder(theme)}`, + backgroundColor: theme.palette.background.paper, + overflow: 'hidden', + transition: theme.transitions.create(['border-color', 'box-shadow'], { + duration: theme.transitions.duration.shortest, + }), + '&:hover': { + borderColor: theme.palette.inputOutlinedHoverBorder ?? theme.palette.text.primary, + }, + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 1px ${theme.palette.primary.main}`, + }, + '&[data-disabled]': { + backgroundColor: 'transparent', + borderColor: theme.palette.action.disabledBackground, + '&:hover': { + borderColor: theme.palette.action.disabledBackground, + }, + }, +})); + +const Input = styled(BaseNumberField.Input)(({ theme }) => ({ + flex: 1, + minWidth: 0, + border: 0, + outline: 0, + background: 'transparent', + color: theme.palette.text.primary, + font: 'inherit', + fontSize: '1rem', + lineHeight: 1.4375, + padding: '8.5px 12px', + MozAppearance: 'textfield', + '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0, + }, + '&::placeholder': { + color: theme.palette.text.secondary, + opacity: 1, + }, + '&:disabled': { + color: theme.palette.text.disabled, + WebkitTextFillColor: theme.palette.text.disabled, + }, +})); + +const Suffix = styled('span')(({ theme }) => ({ + display: 'inline-flex', + alignItems: 'center', + paddingRight: 12, + color: theme.palette.text.secondary, + fontSize: '0.9375rem', + pointerEvents: 'none', +})); + +const Steppers = styled('div')(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + borderLeft: `1px solid ${resolveBorder(theme)}`, +})); + +const stepButton = ({ theme }) => ({ + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + width: 28, + padding: 0, + border: 0, + background: 'transparent', + color: theme.palette.text.secondary, + cursor: 'pointer', + transition: theme.transitions.create(['background-color', 'color'], { + duration: theme.transitions.duration.shortest, + }), + '&:hover': { + backgroundColor: theme.palette.action.hover, + color: theme.palette.text.primary, + }, + '&:disabled': { + opacity: 0.4, + cursor: 'default', + backgroundColor: 'transparent', + }, +}); + +const IncrementButton = styled(BaseNumberField.Increment)(({ theme }) => ({ + ...stepButton({ theme }), + borderBottom: `1px solid ${resolveBorder(theme)}`, +})); + +const DecrementButton = styled(BaseNumberField.Decrement)(stepButton); + +export function NumberField({ + id, + value, + onValueChange, + min, + max, + step = 1, + disabled = false, + placeholder, + suffix, + ariaLabel, + showSteppers = true, + sx, +}) { + return ( + + + + {suffix ? {suffix} : null} + {showSteppers ? ( + + + + + + + + + ) : null} + + + ); +} + +NumberField.propTypes = { + id: PropTypes.string, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf([null])]), + onValueChange: PropTypes.func.isRequired, + min: PropTypes.number, + max: PropTypes.number, + step: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['any'])]), + disabled: PropTypes.bool, + placeholder: PropTypes.string, + suffix: PropTypes.node, + ariaLabel: PropTypes.string, + showSteppers: PropTypes.bool, + sx: PropTypes.oneOfType([PropTypes.object, PropTypes.array, PropTypes.func]), +}; + +export default NumberField; diff --git a/src/components/Sidebar/Sidebar.jsx b/src/components/Sidebar/Sidebar.jsx index 1636b6596..edefbf219 100644 --- a/src/components/Sidebar/Sidebar.jsx +++ b/src/components/Sidebar/Sidebar.jsx @@ -14,6 +14,7 @@ import { CornerUpLeft, CircleHelp, HardDriveUpload, + Settings, } from 'lucide-react'; import { DrawerHeader, @@ -107,6 +108,14 @@ export default function Sidebar() { disabled={!jwtEnabled} /> )} + + } + linkTo="/settings" + active={isActive('/settings')} + disabled={false} + /> {anyLowerButtonVisible && ( diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx new file mode 100644 index 000000000..860cab86a --- /dev/null +++ b/src/pages/Settings.jsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Card, CardContent, CardHeader, Grid, Typography, Switch, InputLabel, Divider } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { MemoryStick, HardDrive, Database } from 'lucide-react'; +import StyledSlider from '../components/Common/StyledSlider'; +import NumberField from '../components/Common/NumberField'; +import { CenteredFrame } from '../components/Common/CenteredFrame'; + +const labelSx = { + color: 'text.primary', + fontWeight: 500, + fontSize: '0.9375rem', + lineHeight: 1.3, +}; + +// A single limit setting laid out as a row: an icon, a label with a short +// description, and a control aligned to the right (stacks on small screens). +function LimitRow({ icon, label, description, htmlFor, disabled, children }) { + return ( + + + alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.1), + }} + > + {icon} + + + + {label} + + + {description} + + + + + {children} + + + ); +} + +LimitRow.propTypes = { + icon: PropTypes.node.isRequired, + label: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + htmlFor: PropTypes.string.isRequired, + disabled: PropTypes.bool, + children: PropTypes.node.isRequired, +}; + +// Percentage control: a slider paired with a compact numeric field, kept in +// sync. Values are 0–100; `null` (empty field) reads as 0 on the slider. +function PercentField({ id, label, value, onChange, disabled }) { + return ( + + onChange(next)} + disabled={disabled} + min={0} + max={100} + aria-label={`${label} percentage`} + sx={{ flex: 1 }} + /> + + + + + ); +} + +PercentField.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf([null])]), + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, +}; + +function Settings() { + const [limitsEnabled, setLimitsEnabled] = useState(false); + const [memory, setMemory] = useState(80); + const [disk, setDisk] = useState(80); + const [maxCollections, setMaxCollections] = useState(null); + + const disabled = !limitsEnabled; + + return ( + + + + + Settings + + + + + + + + + Off + + setLimitsEnabled(event.target.checked)} + inputProps={{ 'aria-label': 'Enable limits' }} + /> + + On + + + } + /> + + + + Cap how much memory and disk this instance may use, and how many collections it may hold. + + + } + label="Memory" + description="Share of available RAM this instance may use." + htmlFor="memory-limit" + disabled={disabled} + > + + + + + + } + label="Disk space" + description="Share of available disk this instance may use." + htmlFor="disk-limit" + disabled={disabled} + > + + + + + + } + label="Collections" + description="Maximum number of collections allowed." + htmlFor="max-collections" + disabled={disabled} + > + + + + + + + + + + ); +} + +export default Settings; diff --git a/src/routes.jsx b/src/routes.jsx index 1bec4aa7e..be6ae658b 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -8,6 +8,7 @@ import TutorialIndex from './pages/TutorialIndex'; import Tutorial from './pages/Tutorial'; import Datasets from './pages/Datasets'; import Jwt from './pages/Jwt'; +import Settings from './pages/Settings'; import Graph from './pages/Graph'; import Welcome from './pages/Welcome'; import Homepage from './pages/Homepage'; @@ -34,6 +35,7 @@ const routes = () => [ { path: '/tutorial', element: }, { path: '/tutorial/:pageSlug', element: }, { path: '/jwt', element: }, + { path: '/settings', element: }, ], }, ]; From cc7ce4c0ae222ed0000138006459acf5ef56601f Mon Sep 17 00:00:00 2001 From: trean Date: Fri, 31 Jul 2026 17:47:16 +0200 Subject: [PATCH 02/13] change Settings page container width, extracted container widths to constants --- src/pages/Collection.jsx | 3 ++- src/pages/Collections.jsx | 3 ++- src/pages/Datasets.jsx | 3 ++- src/pages/Jwt.jsx | 3 ++- src/pages/Settings.jsx | 3 ++- src/pages/TutorialIndex.jsx | 3 ++- src/pages/Welcome.jsx | 3 ++- src/theme/constants.js | 14 ++++++++++++++ 8 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 src/theme/constants.js diff --git a/src/pages/Collection.jsx b/src/pages/Collection.jsx index ae10296b4..eec58f94d 100644 --- a/src/pages/Collection.jsx +++ b/src/pages/Collection.jsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Link, useLocation, useNavigate, useParams } from 'react-router'; import { Typography, Grid, Tabs, Tab, Box } from '@mui/material'; import { CenteredFrame } from '../components/Common/CenteredFrame'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; import { SnapshotsTab } from '../components/Snapshots/SnapshotsTab'; import CollectionInfo from '../components/Collections/CollectionInfo'; import PointsTabs from '../components/Points/PointsTabs'; @@ -61,7 +62,7 @@ function Collection() { return ( <> - + {collectionName} diff --git a/src/pages/Collections.jsx b/src/pages/Collections.jsx index b240c1509..b60b84ba5 100644 --- a/src/pages/Collections.jsx +++ b/src/pages/Collections.jsx @@ -6,6 +6,7 @@ import { keyframes } from '@mui/material/styles'; import { RefreshCw } from 'lucide-react'; import ErrorNotifier from '../components/ToastNotifications/ErrorNotifier'; import { CenteredFrame } from '../components/Common/CenteredFrame'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; import { SnapshotsUpload } from '../components/Snapshots/SnapshotsUpload'; import { getErrorMessage } from '../lib/get-error-message'; import CollectionsList from '../components/Collections/CollectionsList'; @@ -212,7 +213,7 @@ function Collections() { {errorMessage !== null && } - + - + Datasets diff --git a/src/pages/Jwt.jsx b/src/pages/Jwt.jsx index a9730b501..74fafec50 100644 --- a/src/pages/Jwt.jsx +++ b/src/pages/Jwt.jsx @@ -7,6 +7,7 @@ import * as jose from 'jose'; import { useSnackbar } from 'notistack'; import JwtTokenViewer from '../components/JwtSection/JwtTokenViewer'; import { CenteredFrame } from '../components/Common/CenteredFrame'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; async function getJwt(apiKey, token, setJwt) { try { @@ -108,7 +109,7 @@ function Jwt() { sx={{ pb: 12, width: '100%', - maxWidth: '900px', + maxWidth: PAGE_CONTENT_WIDTH.narrow, display: 'flex', flexDirection: 'column', gap: 4, diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 860cab86a..4e24b1ce0 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -6,6 +6,7 @@ import { MemoryStick, HardDrive, Database } from 'lucide-react'; import StyledSlider from '../components/Common/StyledSlider'; import NumberField from '../components/Common/NumberField'; import { CenteredFrame } from '../components/Common/CenteredFrame'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; const labelSx = { color: 'text.primary', @@ -127,7 +128,7 @@ function Settings() { return ( - + Settings diff --git a/src/pages/TutorialIndex.jsx b/src/pages/TutorialIndex.jsx index adf08c3d2..e31025c9a 100644 --- a/src/pages/TutorialIndex.jsx +++ b/src/pages/TutorialIndex.jsx @@ -4,6 +4,7 @@ import { useClient } from '../context/client-context'; import InfoCard from '../components/Common/InfoCard/InfoCard'; import TutorialLinks from '../components/InteractiveTutorial/TutorialLinks'; import { Zap, FileCode } from 'lucide-react'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; export const TutorialIndex = () => { const { isRestricted } = useClient(); @@ -29,7 +30,7 @@ export const TutorialIndex = () => { gap: '40px', p: 5, margin: 'auto', - maxWidth: '1120px', + maxWidth: PAGE_CONTENT_WIDTH.content, }} > diff --git a/src/pages/Welcome.jsx b/src/pages/Welcome.jsx index 7eec12f52..e91bd8bd7 100644 --- a/src/pages/Welcome.jsx +++ b/src/pages/Welcome.jsx @@ -7,6 +7,7 @@ import TutorialLinks from '../components/InteractiveTutorial/TutorialLinks'; import { Workflow, FileCode, BrainCircuit } from 'lucide-react'; import { useExternalInfo } from '../context/external-info-context'; import { getFullPath } from '../lib/common-helpers'; +import { PAGE_CONTENT_WIDTH } from '../theme/constants'; const Welcome = () => { const [showBanner, setShowBanner] = useState(true); @@ -44,7 +45,7 @@ const Welcome = () => { gap: '40px', p: 5, margin: 'auto', - maxWidth: '1120px', + maxWidth: PAGE_CONTENT_WIDTH.content, }} > {displayBannerContent()} diff --git a/src/theme/constants.js b/src/theme/constants.js new file mode 100644 index 000000000..b6fb91b8a --- /dev/null +++ b/src/theme/constants.js @@ -0,0 +1,14 @@ +// Shared max-width values for page content containers. +// Keeping these in one place keeps page widths consistent and avoids +// scattered magic values like '900px' / '1120px' / 'xl' across pages. +// +// Values are passed to MUI's `maxWidth` (system prop or Container prop), so +// they may be a CSS length ('900px') or a theme breakpoint key ('xl'). +export const PAGE_CONTENT_WIDTH = { + // Narrow, form-focused pages (e.g. Settings, Access Tokens). + narrow: '900px', + // Reading/content pages (e.g. Welcome, Tutorial). + content: '1120px', + // Wide dashboard/list pages (e.g. Collections, Datasets, Collection). + wide: 'xl', +}; From 36e087a3b6d0b58ec2c5fd165c966ba246a34095 Mon Sep 17 00:00:00 2001 From: trean Date: Fri, 31 Jul 2026 18:46:14 +0200 Subject: [PATCH 03/13] removed base-ui in favor of using MUI component for NumberField --- package-lock.json | 99 -------------- package.json | 1 - src/components/Common/NumberField.jsx | 180 +++++++------------------- src/pages/Settings.jsx | 1 - 4 files changed, 47 insertions(+), 234 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94dfca9a8..b0cad8dd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.2.15", "license": "Apache-2.0", "dependencies": { - "@base-ui/react": "^1.6.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@mdx-js/react": "^2.3.0", @@ -365,66 +364,6 @@ "node": ">=6.9.0" } }, - "node_modules/@base-ui/react": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", - "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2", - "@base-ui/utils": "0.3.1", - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", - "use-sync-external-store": "^1.6.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@date-fns/tz": "^1.2.0", - "@types/react": "^17 || ^18 || ^19", - "date-fns": "^4.0.0", - "react": "^17 || ^18 || ^19", - "react-dom": "^17 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "@date-fns/tz": { - "optional": true - }, - "@types/react": { - "optional": true - }, - "date-fns": { - "optional": true - } - } - }, - "node_modules/@base-ui/utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", - "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2", - "@floating-ui/utils": "^0.2.11", - "reselect": "^5.2.0", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "@types/react": "^17 || ^18 || ^19", - "react": "^17 || ^18 || ^19", - "react-dom": "^17 || ^18 || ^19" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "license": "MIT", @@ -1011,44 +950,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", - "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", - "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.8.0", - "@floating-ui/utils": "^0.2.12" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", - "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.8.0" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", - "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", - "license": "MIT" - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "dev": true, diff --git a/package.json b/package.json index 628011b66..4d23a9271 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "node": ">=24" }, "dependencies": { - "@base-ui/react": "^1.6.0", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@mdx-js/react": "^2.3.0", diff --git a/src/components/Common/NumberField.jsx b/src/components/Common/NumberField.jsx index 5023c55c7..ffbf5a2c6 100644 --- a/src/components/Common/NumberField.jsx +++ b/src/components/Common/NumberField.jsx @@ -1,116 +1,24 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { styled } from '@mui/material/styles'; -import { NumberField as BaseNumberField } from '@base-ui/react/number-field'; -import { ChevronUp, ChevronDown } from 'lucide-react'; +import { TextField, InputAdornment } from '@mui/material'; -// A numeric input built on Base UI's NumberField, styled to match the app's -// MUI outlined inputs. Supports an optional suffix (e.g. "%") and up/down -// stepper buttons. Values are real numbers (`null` when empty), with min/max -// clamping and keyboard/scroll stepping handled by Base UI. +// Numeric input built on MUI's TextField. The native number spin buttons are +// hidden (WebKit/Blink expose them as pseudo-elements, Gecko via appearance), +// so the field stays clean. Values are surfaced as numbers (`null` when empty) +// and clamped to any provided min/max. -const resolveBorder = (theme) => theme.palette.inputOutlinedEnabledBorder ?? theme.palette.divider; - -const Group = styled(BaseNumberField.Group)(({ theme }) => ({ - display: 'inline-flex', - alignItems: 'stretch', - width: '100%', - borderRadius: '0.5rem', - border: `1px solid ${resolveBorder(theme)}`, - backgroundColor: theme.palette.background.paper, - overflow: 'hidden', - transition: theme.transitions.create(['border-color', 'box-shadow'], { - duration: theme.transitions.duration.shortest, - }), - '&:hover': { - borderColor: theme.palette.inputOutlinedHoverBorder ?? theme.palette.text.primary, - }, - '&:focus-within': { - borderColor: theme.palette.primary.main, - boxShadow: `0 0 0 1px ${theme.palette.primary.main}`, - }, - '&[data-disabled]': { - backgroundColor: 'transparent', - borderColor: theme.palette.action.disabledBackground, - '&:hover': { - borderColor: theme.palette.action.disabledBackground, - }, - }, -})); - -const Input = styled(BaseNumberField.Input)(({ theme }) => ({ - flex: 1, - minWidth: 0, - border: 0, - outline: 0, - background: 'transparent', - color: theme.palette.text.primary, - font: 'inherit', - fontSize: '1rem', - lineHeight: 1.4375, - padding: '8.5px 12px', - MozAppearance: 'textfield', - '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { - WebkitAppearance: 'none', - margin: 0, - }, - '&::placeholder': { - color: theme.palette.text.secondary, - opacity: 1, - }, - '&:disabled': { - color: theme.palette.text.disabled, - WebkitTextFillColor: theme.palette.text.disabled, - }, -})); - -const Suffix = styled('span')(({ theme }) => ({ - display: 'inline-flex', - alignItems: 'center', - paddingRight: 12, - color: theme.palette.text.secondary, - fontSize: '0.9375rem', - pointerEvents: 'none', -})); - -const Steppers = styled('div')(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - flexShrink: 0, - borderLeft: `1px solid ${resolveBorder(theme)}`, -})); - -const stepButton = ({ theme }) => ({ - display: 'flex', - flex: 1, - alignItems: 'center', - justifyContent: 'center', - width: 28, - padding: 0, - border: 0, - background: 'transparent', - color: theme.palette.text.secondary, - cursor: 'pointer', - transition: theme.transitions.create(['background-color', 'color'], { - duration: theme.transitions.duration.shortest, - }), - '&:hover': { - backgroundColor: theme.palette.action.hover, - color: theme.palette.text.primary, - }, - '&:disabled': { - opacity: 0.4, - cursor: 'default', - backgroundColor: 'transparent', - }, -}); - -const IncrementButton = styled(BaseNumberField.Increment)(({ theme }) => ({ - ...stepButton({ theme }), - borderBottom: `1px solid ${resolveBorder(theme)}`, -})); +const hideSpinButtonsSx = { + '& input[type=number]': { MozAppearance: 'textfield' }, + '& input[type=number]::-webkit-outer-spin-button': { WebkitAppearance: 'none', margin: 0 }, + '& input[type=number]::-webkit-inner-spin-button': { WebkitAppearance: 'none', margin: 0 }, +}; -const DecrementButton = styled(BaseNumberField.Decrement)(stepButton); +const clamp = (value, min, max) => { + let next = value; + if (typeof min === 'number') next = Math.max(min, next); + if (typeof max === 'number') next = Math.min(max, next); + return next; +}; export function NumberField({ id, @@ -123,35 +31,42 @@ export function NumberField({ placeholder, suffix, ariaLabel, - showSteppers = true, sx, }) { + const handleChange = (event) => { + const raw = event.target.value; + if (raw === '') { + onValueChange(null); + return; + } + const parsed = Number(raw); + if (Number.isNaN(parsed)) return; + onValueChange(clamp(parsed, min, max)); + }; + return ( - - - - {suffix ? {suffix} : null} - {showSteppers ? ( - - - - - - - - - ) : null} - - + placeholder={placeholder} + slotProps={{ + htmlInput: { + min, + max, + step, + inputMode: 'numeric', + 'aria-label': ariaLabel, + style: suffix ? { textAlign: 'right' } : undefined, + }, + input: suffix ? { endAdornment: {suffix} } : undefined, + }} + sx={{ ...hideSpinButtonsSx, ...sx }} + /> ); } @@ -166,7 +81,6 @@ NumberField.propTypes = { placeholder: PropTypes.string, suffix: PropTypes.node, ariaLabel: PropTypes.string, - showSteppers: PropTypes.bool, sx: PropTypes.oneOfType([PropTypes.object, PropTypes.array, PropTypes.func]), }; diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 4e24b1ce0..eb2717800 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -102,7 +102,6 @@ function PercentField({ id, label, value, onChange, disabled }) { step={1} disabled={disabled} suffix="%" - showSteppers={false} ariaLabel={`${label} percentage value`} /> From 7b34ee3bcca7dc4d69ceaab8fc949029e1cbcc29 Mon Sep 17 00:00:00 2001 From: trean Date: Fri, 31 Jul 2026 19:53:24 +0200 Subject: [PATCH 04/13] save button --- src/pages/Settings.jsx | 236 +++++++++++++++++++++++++++++------------ 1 file changed, 171 insertions(+), 65 deletions(-) diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index eb2717800..665b917c8 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,6 +1,17 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { Box, Card, CardContent, CardHeader, Grid, Typography, Switch, InputLabel, Divider } from '@mui/material'; +import { + Box, + Card, + CardContent, + CardHeader, + Grid, + Typography, + Switch, + InputLabel, + Divider, + Button, +} from '@mui/material'; import { alpha } from '@mui/material/styles'; import { MemoryStick, HardDrive, Database } from 'lucide-react'; import StyledSlider from '../components/Common/StyledSlider'; @@ -15,9 +26,14 @@ const labelSx = { lineHeight: 1.3, }; -// A single limit setting laid out as a row: an icon, a label with a short -// description, and a control aligned to the right (stacks on small screens). -function LimitRow({ icon, label, description, htmlFor, disabled, children }) { +const dimSx = (dimmed) => ({ opacity: dimmed ? 0.55 : 1, transition: 'opacity 0.2s ease' }); + +// A single quota setting laid out as a row: an icon, a label with a short +// description, its own enable/disable switch, and a control. On desktop the +// switch sits at the far right; on small screens it moves up beside the label +// and the control drops to its own line. When the row is off, its content dims +// but the switch stays fully interactive. +function QuotaRow({ icon, label, description, htmlFor, enabled, onToggle, dimmed, children }) { return ( - - alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.1), - }} - > - {icon} - - - - {label} - - - {description} - + + onToggle(event.target.checked)} + inputProps={{ 'aria-label': `Enable ${label} quota` }} + sx={{ flexShrink: 0 }} + /> + + alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.1), + }} + > + {icon} + + + + {label} + + + {description} + + {children} @@ -69,12 +93,14 @@ function LimitRow({ icon, label, description, htmlFor, disabled, children }) { ); } -LimitRow.propTypes = { +QuotaRow.propTypes = { icon: PropTypes.node.isRequired, label: PropTypes.string.isRequired, description: PropTypes.string.isRequired, htmlFor: PropTypes.string.isRequired, - disabled: PropTypes.bool, + enabled: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + dimmed: PropTypes.bool, children: PropTypes.node.isRequired, }; @@ -117,13 +143,69 @@ PercentField.propTypes = { disabled: PropTypes.bool, }; +const INITIAL_QUOTAS = { + quotasEnabled: false, + memoryEnabled: false, + diskEnabled: false, + collectionsEnabled: false, + memory: 80, + disk: 80, + maxCollections: null, +}; + +// Value to pre-fill the collections field with the first time the collections +// quota is enabled without a value already set. +const DEFAULT_MAX_COLLECTIONS = 10000; + function Settings() { - const [limitsEnabled, setLimitsEnabled] = useState(false); - const [memory, setMemory] = useState(80); - const [disk, setDisk] = useState(80); - const [maxCollections, setMaxCollections] = useState(null); + // The whole card is a draft: switches and values change freely and are only + // committed to `saved` when the user clicks Save. Comparing the two tells us + // whether there are unsaved changes. + const [draft, setDraft] = useState(INITIAL_QUOTAS); + const [saved, setSaved] = useState(INITIAL_QUOTAS); + + const patch = (changes) => setDraft((prev) => ({ ...prev, ...changes })); + + const memoryActive = draft.quotasEnabled && draft.memoryEnabled; + const diskActive = draft.quotasEnabled && draft.diskEnabled; + const collectionsActive = draft.quotasEnabled && draft.collectionsEnabled; + const hasUnsavedChanges = + draft.quotasEnabled !== saved.quotasEnabled || + draft.memoryEnabled !== saved.memoryEnabled || + draft.diskEnabled !== saved.diskEnabled || + draft.collectionsEnabled !== saved.collectionsEnabled || + draft.memory !== saved.memory || + draft.disk !== saved.disk || + draft.maxCollections !== saved.maxCollections; - const disabled = !limitsEnabled; + // Pre-fills the collections field with a default the first time its quota is + // enabled while empty, so an enabled quota always has a concrete cap. + const collectionsDefault = (enabling) => + enabling && draft.maxCollections == null ? { maxCollections: DEFAULT_MAX_COLLECTIONS } : {}; + + // The master switch enables/disables every quota at once, keeping the + // invariant that no individual quota is on while quotas are globally off. + const toggleMaster = (next) => { + patch({ + quotasEnabled: next, + memoryEnabled: next, + diskEnabled: next, + collectionsEnabled: next, + ...collectionsDefault(next), + }); + }; + + // Turning a single quota on while quotas are globally off also turns quotas on. + const toggleRow = (key, next) => { + patch({ + [key]: next, + ...(next && !draft.quotasEnabled ? { quotasEnabled: true } : {}), + ...(key === 'collectionsEnabled' ? collectionsDefault(next) : {}), + }); + }; + + const save = () => setSaved(draft); + const discard = () => setDraft(saved); return ( @@ -138,7 +220,7 @@ function Settings() { Off setLimitsEnabled(event.target.checked)} - inputProps={{ 'aria-label': 'Enable limits' }} + checked={draft.quotasEnabled} + onChange={(event) => toggleMaster(event.target.checked)} + inputProps={{ 'aria-label': 'Enable quotas' }} /> On @@ -171,60 +253,84 @@ function Settings() { Cap how much memory and disk this instance may use, and how many collections it may hold. - } label="Memory" description="Share of available RAM this instance may use." - htmlFor="memory-limit" - disabled={disabled} + htmlFor="memory-quota" + enabled={draft.memoryEnabled} + onToggle={(next) => toggleRow('memoryEnabled', next)} + dimmed={!memoryActive} > patch({ memory: value })} + disabled={!memoryActive} /> - + - } label="Disk space" description="Share of available disk this instance may use." - htmlFor="disk-limit" - disabled={disabled} + htmlFor="disk-quota" + enabled={draft.diskEnabled} + onToggle={(next) => toggleRow('diskEnabled', next)} + dimmed={!diskActive} > patch({ disk: value })} + disabled={!diskActive} /> - + - } label="Collections" description="Maximum number of collections allowed." htmlFor="max-collections" - disabled={disabled} + enabled={draft.collectionsEnabled} + onToggle={(next) => toggleRow('collectionsEnabled', next)} + dimmed={!collectionsActive} > patch({ maxCollections: value })} min={0} step={1} - disabled={disabled} + disabled={!collectionsActive} placeholder="Unlimited" ariaLabel="Maximum number of collections" /> - + + + + + + + You have unsaved changes. + + + + From 90a1a7fd4de4b3e82f7299dc2ce1440499b50e1a Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 3 Aug 2026 14:03:17 +0200 Subject: [PATCH 05/13] add quotas current usage to the UI --- src/pages/Settings.jsx | 381 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 334 insertions(+), 47 deletions(-) diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 665b917c8..105e2dd53 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Box, @@ -11,10 +11,14 @@ import { InputLabel, Divider, Button, + Slider, + Collapse, + ButtonBase, + Tooltip, + Chip, } from '@mui/material'; -import { alpha } from '@mui/material/styles'; -import { MemoryStick, HardDrive, Database } from 'lucide-react'; -import StyledSlider from '../components/Common/StyledSlider'; +import { alpha, keyframes } from '@mui/material/styles'; +import { MemoryStick, HardDrive, Database, TriangleAlert, ChevronDown } from 'lucide-react'; import NumberField from '../components/Common/NumberField'; import { CenteredFrame } from '../components/Common/CenteredFrame'; import { PAGE_CONTENT_WIDTH } from '../theme/constants'; @@ -28,20 +32,40 @@ const labelSx = { const dimSx = (dimmed) => ({ opacity: dimmed ? 0.55 : 1, transition: 'opacity 0.2s ease' }); +// Attention flicker played on a quota row when the user clicks the "Quota +// exceeded" chip, to point them at the offending metric. +const flicker = keyframes` + 0%, 100% { opacity: 1; } + 12.5%, 37.5%, 62.5% { opacity: 0.3; } + 25%, 50%, 75% { opacity: 1; } +`; + // A single quota setting laid out as a row: an icon, a label with a short // description, its own enable/disable switch, and a control. On desktop the // switch sits at the far right; on small screens it moves up beside the label // and the control drops to its own line. When the row is off, its content dims // but the switch stays fully interactive. -function QuotaRow({ icon, label, description, htmlFor, enabled, onToggle, dimmed, children }) { +function QuotaRow({ icon, label, description, htmlFor, enabled, onToggle, dimmed, flash, children }) { + const [animate, setAnimate] = useState(false); + // Restart the flicker each time `flash` changes (i.e. the chip is clicked + // again), briefly clearing the animation so the browser replays it. + useEffect(() => { + if (!flash) return undefined; + setAnimate(false); + const id = requestAnimationFrame(() => setAnimate(true)); + return () => cancelAnimationFrame(id); + }, [flash]); + return ( setAnimate(false)} sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, - alignItems: { xs: 'stretch', sm: 'center' }, + alignItems: { xs: 'stretch', sm: 'flex-start' }, gap: { xs: 1.5, sm: 3 }, + animation: animate ? `${flicker} 0.9s ease-in-out` : undefined, }} > @@ -81,8 +105,9 @@ function QuotaRow({ icon, label, description, htmlFor, enabled, onToggle, dimmed `…${String(id).slice(-4)}`; + +// Reduce the per-node quota usage from GET /quotas into a headline number. +// The quota is enforced per node, so the busiest node is what matters; fall +// back to the serving node's usage when the cluster is single-node. +function summarizeUsage(status, key) { + const entries = status.peers ? Object.entries(status.peers) : []; + if (entries.length) { + let peak = null; + const peers = entries.map(([id, peer]) => { + const percent = peer[key] ?? null; + if (percent != null && (peak == null || percent > peak)) peak = percent; + return { id, percent }; + }); + return { percent: peak, peers, distributed: true }; + } + return { percent: status.usage?.[key] ?? null, peers: [], distributed: false }; +} + +// Classify current usage against the configured limit (minus the release +// margin) so the meter can colour itself. 'neutral' while the quota is off. +function usageStatus(percent, limit, margin, enabled) { + if (!enabled || limit == null || percent == null) return 'neutral'; + if (percent >= limit) return 'exceeded'; + if (percent >= limit - (margin ?? 0)) return 'warning'; + return 'ok'; +} + +// One node's usage inside the "Usage by node" disclosure: a mini bar coloured +// red when that node is over the configured limit. +function PeerUsageRow({ peer, limitPercent, showLimit }) { + const known = peer.percent != null; + const over = showLimit && limitPercent != null && known && peer.percent >= limitPercent; return ( - - onChange(next)} - disabled={disabled} - min={0} - max={100} - aria-label={`${label} percentage`} - sx={{ flex: 1 }} - /> - - + + + Node {shortPeerId(peer.id)} + + + + {known && ( + + )} + {showLimit && limitPercent != null && ( + + )} + + + {known ? `${peer.percent}%` : '—'} + + + ); +} + +PeerUsageRow.propTypes = { + peer: PropTypes.object.isRequired, + limitPercent: PropTypes.number, + showLimit: PropTypes.bool, +}; + +// Merged limit + usage control for a percentage quota: one slider whose thumb +// sets the limit (edited precisely in the "New" field) and whose coloured mark +// shows current cluster usage ("Current"). In a cluster, a collapsible section +// breaks usage down per node. +function PercentQuotaControl({ id, label, value, onChange, disabled, usage, status, peers, distributed }) { + const [nodesOpen, setNodesOpen] = useState(false); + const statusColor = usage != null ? USAGE_STATUS_COLOR[status] : 'text.disabled'; + const usageKnown = usage != null; + + return ( + + + + onChange(next)} + disabled={disabled} + min={0} + max={100} + track={false} + valueLabelDisplay="auto" + aria-label={`${label} limit`} + marks={usageKnown ? [{ value: usage, label: `${usage}%` }] : []} + sx={{ + py: 1, + '& .MuiSlider-markLabel': { + top: -18, + fontSize: '0.7rem', + fontWeight: 600, + color: 'text.secondary', + }, + '& .MuiSlider-mark': { + height: 14, + width: 3, + borderRadius: 1, + backgroundColor: 'text.secondary', + opacity: disabled ? 0.4 : 1, + }, + }} + /> + + + {/* Current usage vs. new limit, aligned like a two-column table */} + + + + Current + + + + {usageKnown ? `${usage}%` : '—'} + + + + + + + New + + + + + + {distributed && peers.length > 0 && ( + + setNodesOpen((open) => !open)} + aria-expanded={nodesOpen} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + color: 'text.secondary', + borderRadius: 1, + px: 0.5, + py: 0.25, + '&:hover': { color: 'text.primary' }, + }} + > + + Usage by node + + + + + + {peers.map((peer) => ( + + ))} + + + + )} ); } -PercentField.propTypes = { +PercentQuotaControl.propTypes = { id: PropTypes.string.isRequired, label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf([null])]), onChange: PropTypes.func.isRequired, disabled: PropTypes.bool, + usage: PropTypes.number, + status: PropTypes.oneOf(['ok', 'warning', 'exceeded', 'neutral']).isRequired, + peers: PropTypes.arrayOf(PropTypes.object), + distributed: PropTypes.bool, }; const INITIAL_QUOTAS = { @@ -157,12 +365,35 @@ const INITIAL_QUOTAS = { // quota is enabled without a value already set. const DEFAULT_MAX_COLLECTIONS = 10000; +// Live cluster quota usage, shaped like the `result` of `GET /quotas` +// (qdrant PR #10035): per-node resident-memory and disk percentages plus an +// `exceeded` flag. Sample data for now — swap for the API response once wired. +// `peers` is absent on single-node deployments; `usage` is the serving node. +const QUOTA_STATUS = { + config: { + enabled: false, + max_resident_memory_percent: 80, + max_disk_usage_percent: 80, + release_margin_percent: 5, + }, + usage: { resident_memory_percent: 71, disk_usage_percent: 44 }, + peers: { + 5644950770669488: { resident_memory_percent: 71, disk_usage_percent: 44, exceeded: false }, + 5255497362296823: { resident_memory_percent: 88, disk_usage_percent: 39, exceeded: true }, + 8741461806010521: { resident_memory_percent: 63, disk_usage_percent: 52, exceeded: false }, + }, +}; + function Settings() { // The whole card is a draft: switches and values change freely and are only // committed to `saved` when the user clicks Save. Comparing the two tells us // whether there are unsaved changes. const [draft, setDraft] = useState(INITIAL_QUOTAS); const [saved, setSaved] = useState(INITIAL_QUOTAS); + // Set on each "Quota exceeded" chip click: a bumped nonce plus a snapshot of + // which rows were exceeded at that moment, so the flicker fires only on click + // — not when a limit change makes a metric cross its threshold. + const [flash, setFlash] = useState({ nonce: 0, memory: false, disk: false }); const patch = (changes) => setDraft((prev) => ({ ...prev, ...changes })); @@ -207,6 +438,20 @@ function Settings() { const save = () => setSaved(draft); const discard = () => setDraft(saved); + // Current cluster usage vs. the (draft) limits, so the control gives live + // feedback while the slider moves. + const margin = QUOTA_STATUS.config.release_margin_percent; + const memoryUsage = summarizeUsage(QUOTA_STATUS, 'resident_memory_percent'); + const diskUsage = summarizeUsage(QUOTA_STATUS, 'disk_usage_percent'); + const memoryExceeded = usageStatus(memoryUsage.percent, draft.memory, margin, memoryActive) === 'exceeded'; + const diskExceeded = usageStatus(diskUsage.percent, draft.disk, margin, diskActive) === 'exceeded'; + const exceededResources = [memoryExceeded && 'memory', diskExceeded && 'disk'].filter(Boolean); + const exceededMessage = exceededResources.length + ? `${exceededResources.join(' and ').replace(/^./, (c) => c.toUpperCase())} usage ${ + exceededResources.length > 1 ? 'have' : 'has' + } exceeded the configured quota on at least one node.` + : null; + return ( @@ -220,7 +465,35 @@ function Settings() { + Quotas + {exceededMessage && ( + + } + label="Quota exceeded" + onClick={() => + setFlash((f) => ({ nonce: f.nonce + 1, memory: memoryExceeded, disk: diskExceeded })) + } + sx={{ + height: 24, + fontSize: '0.75rem', + fontWeight: 600, + cursor: 'pointer', + color: 'warning.main', + backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.16), + '&:hover': { + backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.28), + }, + '& .MuiChip-icon': { color: 'inherit', ml: 0.75 }, + }} + /> + + )} + + } variant="heading" sx={{ flexGrow: 1 }} action={ @@ -261,13 +534,18 @@ function Settings() { enabled={draft.memoryEnabled} onToggle={(next) => toggleRow('memoryEnabled', next)} dimmed={!memoryActive} + flash={flash.memory ? flash.nonce : 0} > - patch({ memory: value })} disabled={!memoryActive} + usage={memoryUsage.percent} + status={usageStatus(memoryUsage.percent, draft.memory, margin, memoryActive)} + peers={memoryUsage.peers} + distributed={memoryUsage.distributed} /> @@ -281,13 +559,18 @@ function Settings() { enabled={draft.diskEnabled} onToggle={(next) => toggleRow('diskEnabled', next)} dimmed={!diskActive} + flash={flash.disk ? flash.nonce : 0} > - patch({ disk: value })} disabled={!diskActive} + usage={diskUsage.percent} + status={usageStatus(diskUsage.percent, draft.disk, margin, diskActive)} + peers={diskUsage.peers} + distributed={diskUsage.distributed} /> @@ -302,16 +585,20 @@ function Settings() { onToggle={(next) => toggleRow('collectionsEnabled', next)} dimmed={!collectionsActive} > - patch({ maxCollections: value })} - min={0} - step={1} - disabled={!collectionsActive} - placeholder="Unlimited" - ariaLabel="Maximum number of collections" - /> + + + patch({ maxCollections: value })} + min={0} + step={1} + disabled={!collectionsActive} + placeholder="Unlimited" + ariaLabel="Maximum number of collections" + /> + + From 7bc700d976b885cba9353f3c03bce36a5a2192b0 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 3 Aug 2026 16:41:36 +0200 Subject: [PATCH 06/13] design fixes, wire up the API --- src/pages/Settings.jsx | 479 +++++++++++++++++++++++------------------ 1 file changed, 264 insertions(+), 215 deletions(-) diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 105e2dd53..858ef1350 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import { Box, @@ -16,12 +16,42 @@ import { ButtonBase, Tooltip, Chip, + Alert, + CircularProgress, } from '@mui/material'; import { alpha, keyframes } from '@mui/material/styles'; -import { MemoryStick, HardDrive, Database, TriangleAlert, ChevronDown } from 'lucide-react'; +import { MemoryStick, HardDrive, TriangleAlert, ChevronDown } from 'lucide-react'; import NumberField from '../components/Common/NumberField'; import { CenteredFrame } from '../components/Common/CenteredFrame'; import { PAGE_CONTENT_WIDTH } from '../theme/constants'; +import { axiosInstance as axios } from '../common/axios'; + +// Default percentage shown when a quota is first switched on with no value set. +const DEFAULT_LIMIT_PERCENT = 80; +// Fallback release margin when the API doesn't report one; used for the +// near-limit ("warning") band on the usage meters. +const DEFAULT_RELEASE_MARGIN = 5; + +// Map the GET /quotas config to the editable form. A `null` max means that +// resource is uncapped, i.e. its row switch is off. +const configToForm = (config = {}) => ({ + enabled: Boolean(config.enabled), + memoryEnabled: config.max_resident_memory_percent != null, + memory: config.max_resident_memory_percent ?? DEFAULT_LIMIT_PERCENT, + diskEnabled: config.max_disk_usage_percent != null, + disk: config.max_disk_usage_percent ?? DEFAULT_LIMIT_PERCENT, +}); + +// Map the form back to a PUT /quotas body, preserving the release margin. +const formToConfig = (form, releaseMargin) => ({ + enabled: form.enabled, + max_resident_memory_percent: form.memoryEnabled ? form.memory : null, + max_disk_usage_percent: form.diskEnabled ? form.disk : null, + release_margin_percent: releaseMargin, +}); + +const readErrorMessage = (err) => + err?.response?.data?.status?.error || err?.message || 'Failed to reach the quotas API.'; const labelSx = { color: 'text.primary', @@ -130,8 +160,10 @@ QuotaRow.propTypes = { children: PropTypes.node.isRequired, }; +// Colour for the "Current" usage number by status. 'ok' stays plain text so +// only the near-limit (amber) and exceeded (red) states draw the eye. const USAGE_STATUS_COLOR = { - ok: 'success.main', + ok: 'text.primary', warning: 'warning.main', exceeded: 'error.main', neutral: 'text.disabled', @@ -143,7 +175,7 @@ const shortPeerId = (id) => `…${String(id).slice(-4)}`; // The quota is enforced per node, so the busiest node is what matters; fall // back to the serving node's usage when the cluster is single-node. function summarizeUsage(status, key) { - const entries = status.peers ? Object.entries(status.peers) : []; + const entries = status && status.peers ? Object.entries(status.peers) : []; if (entries.length) { let peak = null; const peers = entries.map(([id, peer]) => { @@ -153,7 +185,7 @@ function summarizeUsage(status, key) { }); return { percent: peak, peers, distributed: true }; } - return { percent: status.usage?.[key] ?? null, peers: [], distributed: false }; + return { percent: status?.usage?.[key] ?? null, peers: [], distributed: false }; } // Classify current usage against the configured limit (minus the release @@ -186,7 +218,7 @@ function PeerUsageRow({ peer, limitPercent, showLimit }) { left: 0, width: `${Math.min(100, peer.percent)}%`, borderRadius: 3, - backgroundColor: over ? 'error.main' : 'success.main', + backgroundColor: over ? 'error.main' : 'primary.main', }} /> )} @@ -240,34 +272,49 @@ function PercentQuotaControl({ id, label, value, onChange, disabled, usage, stat return ( - - onChange(next)} - disabled={disabled} - min={0} - max={100} - track={false} - valueLabelDisplay="auto" - aria-label={`${label} limit`} - marks={usageKnown ? [{ value: usage, label: `${usage}%` }] : []} - sx={{ - py: 1, - '& .MuiSlider-markLabel': { - top: -18, - fontSize: '0.7rem', - fontWeight: 600, - color: 'text.secondary', - }, - '& .MuiSlider-mark': { - height: 14, - width: 3, - borderRadius: 1, - backgroundColor: 'text.secondary', - opacity: disabled ? 0.4 : 1, - }, - }} - /> + + {/* Empty caption keeps the same top offset as the Current/New columns, + so the slider track lands level with those values. */} + +   + + + onChange(next)} + disabled={disabled} + min={0} + max={100} + track={false} + valueLabelDisplay="auto" + aria-label={`${label} limit`} + marks={usageKnown ? [{ value: usage, label: `${usage}%` }] : []} + sx={{ + flex: 1, + py: 0.5, + // Marks add a reserved bottom margin that pushes the track up + // when centred; drop it so the track lines up with the values. + '&.MuiSlider-marked': { mb: 0 }, + '& .MuiSlider-markLabel': { + top: -18, + fontSize: '0.7rem', + fontWeight: 600, + color: 'text.secondary', + }, + '& .MuiSlider-mark': { + height: 14, + width: 3, + borderRadius: 1, + backgroundColor: 'text.secondary', + opacity: disabled ? 0.4 : 1, + }, + }} + /> + {/* Current usage vs. new limit, aligned like a two-column table */} @@ -327,7 +374,7 @@ function PercentQuotaControl({ id, label, value, onChange, disabled, usage, stat /> - + {peers.map((peer) => ( ))} @@ -351,100 +398,90 @@ PercentQuotaControl.propTypes = { distributed: PropTypes.bool, }; -const INITIAL_QUOTAS = { - quotasEnabled: false, - memoryEnabled: false, - diskEnabled: false, - collectionsEnabled: false, - memory: 80, - disk: 80, - maxCollections: null, -}; - -// Value to pre-fill the collections field with the first time the collections -// quota is enabled without a value already set. -const DEFAULT_MAX_COLLECTIONS = 10000; - -// Live cluster quota usage, shaped like the `result` of `GET /quotas` -// (qdrant PR #10035): per-node resident-memory and disk percentages plus an -// `exceeded` flag. Sample data for now — swap for the API response once wired. -// `peers` is absent on single-node deployments; `usage` is the serving node. -const QUOTA_STATUS = { - config: { - enabled: false, - max_resident_memory_percent: 80, - max_disk_usage_percent: 80, - release_margin_percent: 5, - }, - usage: { resident_memory_percent: 71, disk_usage_percent: 44 }, - peers: { - 5644950770669488: { resident_memory_percent: 71, disk_usage_percent: 44, exceeded: false }, - 5255497362296823: { resident_memory_percent: 88, disk_usage_percent: 39, exceeded: true }, - 8741461806010521: { resident_memory_percent: 63, disk_usage_percent: 52, exceeded: false }, - }, -}; - function Settings() { - // The whole card is a draft: switches and values change freely and are only - // committed to `saved` when the user clicks Save. Comparing the two tells us - // whether there are unsaved changes. - const [draft, setDraft] = useState(INITIAL_QUOTAS); - const [saved, setSaved] = useState(INITIAL_QUOTAS); + // Latest GET /quotas result ({ config, usage, peers }); refreshed on a timer + // so the usage meters stay live. + const [status, setStatus] = useState(null); + // Editable form derived from the config, plus the last-saved baseline so we + // know when there are unsaved changes. `null` until the first load. + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); // Set on each "Quota exceeded" chip click: a bumped nonce plus a snapshot of - // which rows were exceeded at that moment, so the flicker fires only on click - // — not when a limit change makes a metric cross its threshold. + // which rows were exceeded at that moment, so the flicker fires only on click. const [flash, setFlash] = useState({ nonce: 0, memory: false, disk: false }); + // Fetch the quota status. `initForm` seeds the editable form on first load; + // background refreshes only update the usage meters, never the form. + const loadStatus = useCallback(async ({ initForm = false } = {}) => { + const result = (await axios.get('/quotas')).data?.result ?? {}; + setStatus(result); + if (initForm) { + const form = configToForm(result.config); + setDraft(form); + setSaved(form); + } + return result; + }, []); + + useEffect(() => { + let active = true; + setLoading(true); + loadStatus({ initForm: true }) + .then(() => active && setError(null)) + .catch((err) => active && setError(readErrorMessage(err))) + .finally(() => active && setLoading(false)); + const interval = setInterval(() => loadStatus().catch(() => {}), 5000); + return () => { + active = false; + clearInterval(interval); + }; + }, [loadStatus]); + + const releaseMargin = status?.config?.release_margin_percent ?? DEFAULT_RELEASE_MARGIN; + const patch = (changes) => setDraft((prev) => ({ ...prev, ...changes })); - const memoryActive = draft.quotasEnabled && draft.memoryEnabled; - const diskActive = draft.quotasEnabled && draft.diskEnabled; - const collectionsActive = draft.quotasEnabled && draft.collectionsEnabled; - const hasUnsavedChanges = - draft.quotasEnabled !== saved.quotasEnabled || - draft.memoryEnabled !== saved.memoryEnabled || - draft.diskEnabled !== saved.diskEnabled || - draft.collectionsEnabled !== saved.collectionsEnabled || - draft.memory !== saved.memory || - draft.disk !== saved.disk || - draft.maxCollections !== saved.maxCollections; - - // Pre-fills the collections field with a default the first time its quota is - // enabled while empty, so an enabled quota always has a concrete cap. - const collectionsDefault = (enabling) => - enabling && draft.maxCollections == null ? { maxCollections: DEFAULT_MAX_COLLECTIONS } : {}; - - // The master switch enables/disables every quota at once, keeping the - // invariant that no individual quota is on while quotas are globally off. - const toggleMaster = (next) => { - patch({ - quotasEnabled: next, - memoryEnabled: next, - diskEnabled: next, - collectionsEnabled: next, - ...collectionsDefault(next), - }); - }; + // The master switch enables/disables every quota at once; turning a single + // quota on while quotas are globally off also turns quotas on. + const toggleMaster = (next) => patch({ enabled: next, memoryEnabled: next, diskEnabled: next }); + const toggleRow = (key, next) => patch({ [key]: next, ...(next && !draft.enabled ? { enabled: true } : {}) }); - // Turning a single quota on while quotas are globally off also turns quotas on. - const toggleRow = (key, next) => { - patch({ - [key]: next, - ...(next && !draft.quotasEnabled ? { quotasEnabled: true } : {}), - ...(key === 'collectionsEnabled' ? collectionsDefault(next) : {}), - }); + const hasUnsavedChanges = + !!draft && + !!saved && + (draft.enabled !== saved.enabled || + draft.memoryEnabled !== saved.memoryEnabled || + draft.memory !== saved.memory || + draft.diskEnabled !== saved.diskEnabled || + draft.disk !== saved.disk); + + const save = async () => { + if (!draft) return; + setSaving(true); + try { + await axios.put('/quotas?wait=true', formToConfig(draft, releaseMargin)); + setSaved(draft); + await loadStatus(); + setError(null); + } catch (err) { + setError(readErrorMessage(err)); + } finally { + setSaving(false); + } }; - - const save = () => setSaved(draft); const discard = () => setDraft(saved); - // Current cluster usage vs. the (draft) limits, so the control gives live - // feedback while the slider moves. - const margin = QUOTA_STATUS.config.release_margin_percent; - const memoryUsage = summarizeUsage(QUOTA_STATUS, 'resident_memory_percent'); - const diskUsage = summarizeUsage(QUOTA_STATUS, 'disk_usage_percent'); - const memoryExceeded = usageStatus(memoryUsage.percent, draft.memory, margin, memoryActive) === 'exceeded'; - const diskExceeded = usageStatus(diskUsage.percent, draft.disk, margin, diskActive) === 'exceeded'; + const memoryActive = Boolean(draft?.enabled && draft?.memoryEnabled); + const diskActive = Boolean(draft?.enabled && draft?.diskEnabled); + const memoryUsage = summarizeUsage(status, 'resident_memory_percent'); + const diskUsage = summarizeUsage(status, 'disk_usage_percent'); + const memoryStatus = usageStatus(memoryUsage.percent, draft?.memory, releaseMargin, memoryActive); + const diskStatus = usageStatus(diskUsage.percent, draft?.disk, releaseMargin, diskActive); + const memoryExceeded = memoryStatus === 'exceeded'; + const diskExceeded = diskStatus === 'exceeded'; const exceededResources = [memoryExceeded && 'memory', diskExceeded && 'disk'].filter(Boolean); const exceededMessage = exceededResources.length ? `${exceededResources.join(' and ').replace(/^./, (c) => c.toUpperCase())} usage ${ @@ -501,19 +538,20 @@ function Settings() { Off toggleMaster(event.target.checked)} + disabled={loading || saving || !draft} inputProps={{ 'aria-label': 'Enable quotas' }} /> On @@ -521,104 +559,115 @@ function Settings() { } /> - - - Cap how much memory and disk this instance may use, and how many collections it may hold. - - - } - label="Memory" - description="Share of available RAM this instance may use." - htmlFor="memory-quota" - enabled={draft.memoryEnabled} - onToggle={(next) => toggleRow('memoryEnabled', next)} - dimmed={!memoryActive} - flash={flash.memory ? flash.nonce : 0} + {loading ? ( + - + + Loading quotas… + + + ) : !draft ? ( + + + {error || 'Could not load quotas.'} + + + + ) : ( + + {error && ( + setError(null)}> + {error} + + )} + + } label="Memory" - value={draft.memory} - onChange={(value) => patch({ memory: value })} - disabled={!memoryActive} - usage={memoryUsage.percent} - status={usageStatus(memoryUsage.percent, draft.memory, margin, memoryActive)} - peers={memoryUsage.peers} - distributed={memoryUsage.distributed} - /> - - - - - } - label="Disk space" - description="Share of available disk this instance may use." - htmlFor="disk-quota" - enabled={draft.diskEnabled} - onToggle={(next) => toggleRow('diskEnabled', next)} - dimmed={!diskActive} - flash={flash.disk ? flash.nonce : 0} - > - toggleRow('memoryEnabled', next)} + dimmed={!memoryActive} + flash={flash.memory ? flash.nonce : 0} + > + patch({ memory: value })} + disabled={!memoryActive} + usage={memoryUsage.percent} + status={memoryStatus} + peers={memoryUsage.peers} + distributed={memoryUsage.distributed} + /> + + + + + } label="Disk space" - value={draft.disk} - onChange={(value) => patch({ disk: value })} - disabled={!diskActive} - usage={diskUsage.percent} - status={usageStatus(diskUsage.percent, draft.disk, margin, diskActive)} - peers={diskUsage.peers} - distributed={diskUsage.distributed} - /> - - - - - } - label="Collections" - description="Maximum number of collections allowed." - htmlFor="max-collections" - enabled={draft.collectionsEnabled} - onToggle={(next) => toggleRow('collectionsEnabled', next)} - dimmed={!collectionsActive} - > - - - patch({ maxCollections: value })} - min={0} - step={1} - disabled={!collectionsActive} - placeholder="Unlimited" - ariaLabel="Maximum number of collections" - /> - - - - - - - - toggleRow('diskEnabled', next)} + dimmed={!diskActive} + flash={flash.disk ? flash.nonce : 0} > - You have unsaved changes. - - - + patch({ disk: value })} + disabled={!diskActive} + usage={diskUsage.percent} + status={diskStatus} + peers={diskUsage.peers} + distributed={diskUsage.distributed} + /> + + + + + + + You have unsaved changes. + + + + - + )} From ee68c64c073d476cb221f919aa9cb0786e3c6d34 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 3 Aug 2026 16:45:28 +0200 Subject: [PATCH 07/13] Left-align quota value inputs Drop the suffix-triggered right alignment in NumberField so the quota "New" input lines up on the left with the "Current" usage value. Co-Authored-By: Claude Opus 4.8 --- src/components/Common/NumberField.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Common/NumberField.jsx b/src/components/Common/NumberField.jsx index ffbf5a2c6..122d74b14 100644 --- a/src/components/Common/NumberField.jsx +++ b/src/components/Common/NumberField.jsx @@ -61,7 +61,6 @@ export function NumberField({ step, inputMode: 'numeric', 'aria-label': ariaLabel, - style: suffix ? { textAlign: 'right' } : undefined, }, input: suffix ? { endAdornment: {suffix} } : undefined, }} From c46b43dbd6e7a69bb16b17b9aceaea061d4da89f Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 3 Aug 2026 17:00:37 +0200 Subject: [PATCH 08/13] Update labels text --- src/pages/Settings.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 858ef1350..6b5799396 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -321,7 +321,7 @@ function PercentQuotaControl({ id, label, value, onChange, disabled, usage, stat - Current + Usage @@ -332,7 +332,7 @@ function PercentQuotaControl({ id, label, value, onChange, disabled, usage, stat - New + Threshold Date: Mon, 3 Aug 2026 17:22:22 +0200 Subject: [PATCH 09/13] refactoring, move components to separate files --- src/components/Settings/QuotaControls.jsx | 351 ++++++++++++ src/components/Settings/QuotasCard.jsx | 311 ++++++++++ src/pages/Settings.jsx | 661 +--------------------- 3 files changed, 666 insertions(+), 657 deletions(-) create mode 100644 src/components/Settings/QuotaControls.jsx create mode 100644 src/components/Settings/QuotasCard.jsx diff --git a/src/components/Settings/QuotaControls.jsx b/src/components/Settings/QuotaControls.jsx new file mode 100644 index 000000000..542a957fc --- /dev/null +++ b/src/components/Settings/QuotaControls.jsx @@ -0,0 +1,351 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { Box, Typography, Switch, InputLabel, Slider, Collapse, ButtonBase } from '@mui/material'; +import { alpha, keyframes } from '@mui/material/styles'; +import { ChevronDown } from 'lucide-react'; +import NumberField from '../Common/NumberField'; + +const labelSx = { + color: 'text.primary', + fontWeight: 500, + fontSize: '0.9375rem', + lineHeight: 1.3, +}; + +const dimSx = (dimmed) => ({ opacity: dimmed ? 0.55 : 1, transition: 'opacity 0.2s ease' }); + +// Attention flicker played on a quota row when the user clicks the "Quota +// exceeded" chip, to point them at the offending metric. +const flicker = keyframes` + 0%, 100% { opacity: 1; } + 12.5%, 37.5%, 62.5% { opacity: 0.3; } + 25%, 50%, 75% { opacity: 1; } +`; + +// A single quota setting laid out as a row: an icon, a label with a short +// description, its own enable/disable switch, and a control. On desktop the +// switch sits at the far right; on small screens it moves up beside the label +// and the control drops to its own line. When the row is off, its content dims +// but the switch stays fully interactive. +export function QuotaRow({ icon, label, description, htmlFor, enabled, onToggle, dimmed, flash, children }) { + const [animate, setAnimate] = useState(false); + // Restart the flicker each time `flash` changes (i.e. the chip is clicked + // again), briefly clearing the animation so the browser replays it. + useEffect(() => { + if (!flash) return undefined; + setAnimate(false); + const id = requestAnimationFrame(() => setAnimate(true)); + return () => cancelAnimationFrame(id); + }, [flash]); + + return ( + setAnimate(false)} + sx={{ + display: 'flex', + flexDirection: { xs: 'column', sm: 'row' }, + alignItems: { xs: 'stretch', sm: 'flex-start' }, + gap: { xs: 1.5, sm: 3 }, + animation: animate ? `${flicker} 0.9s ease-in-out` : undefined, + }} + > + + onToggle(event.target.checked)} + inputProps={{ 'aria-label': `Enable ${label} quota` }} + sx={{ flexShrink: 0 }} + /> + + alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.1), + }} + > + {icon} + + + + {label} + + + {description} + + + + + + {children} + + + ); +} + +QuotaRow.propTypes = { + icon: PropTypes.node.isRequired, + label: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + htmlFor: PropTypes.string.isRequired, + enabled: PropTypes.bool.isRequired, + onToggle: PropTypes.func.isRequired, + dimmed: PropTypes.bool, + flash: PropTypes.number, + children: PropTypes.node.isRequired, +}; + +// Colour for the "Current" usage number by status. 'ok' stays plain text so +// only the near-limit (amber) and exceeded (red) states draw the eye. +const USAGE_STATUS_COLOR = { + ok: 'text.primary', + warning: 'warning.main', + exceeded: 'error.main', + neutral: 'text.disabled', +}; + +const shortPeerId = (id) => `…${String(id).slice(-4)}`; + +// Reduce the per-node quota usage from GET /quotas into a headline number. +// The quota is enforced per node, so the busiest node is what matters; fall +// back to the serving node's usage when the cluster is single-node. +export function summarizeUsage(status, key) { + const entries = status && status.peers ? Object.entries(status.peers) : []; + if (entries.length) { + let peak = null; + const peers = entries.map(([id, peer]) => { + const percent = peer[key] ?? null; + if (percent != null && (peak == null || percent > peak)) peak = percent; + return { id, percent }; + }); + return { percent: peak, peers, distributed: true }; + } + return { percent: status?.usage?.[key] ?? null, peers: [], distributed: false }; +} + +// Classify current usage against the configured limit (minus the release +// margin) so the meter can colour itself. 'neutral' while the quota is off. +export function usageStatus(percent, limit, margin, enabled) { + if (!enabled || limit == null || percent == null) return 'neutral'; + if (percent >= limit) return 'exceeded'; + if (percent >= limit - (margin ?? 0)) return 'warning'; + return 'ok'; +} + +// One node's usage inside the "Usage by node" disclosure: a mini bar coloured +// red when that node is over the configured limit. +function PeerUsageRow({ peer, limitPercent, showLimit }) { + const known = peer.percent != null; + const over = showLimit && limitPercent != null && known && peer.percent >= limitPercent; + return ( + + + Node {shortPeerId(peer.id)} + + + + {known && ( + + )} + {showLimit && limitPercent != null && ( + + )} + + + {known ? `${peer.percent}%` : '—'} + + + ); +} + +PeerUsageRow.propTypes = { + peer: PropTypes.object.isRequired, + limitPercent: PropTypes.number, + showLimit: PropTypes.bool, +}; + +// Merged limit + usage control for a percentage quota: one slider whose thumb +// sets the limit (edited precisely in the "New" field) and whose coloured mark +// shows current cluster usage ("Current"). In a cluster, a collapsible section +// breaks usage down per node. +export function PercentQuotaControl({ id, label, value, onChange, disabled, usage, status, peers, distributed }) { + const [nodesOpen, setNodesOpen] = useState(false); + const statusColor = usage != null ? USAGE_STATUS_COLOR[status] : 'text.disabled'; + const usageKnown = usage != null; + + return ( + + + + {/* Empty caption keeps the same top offset as the Current/New columns, + so the slider track lands level with those values. */} + +   + + + onChange(next)} + disabled={disabled} + min={0} + max={100} + track={false} + valueLabelDisplay="auto" + aria-label={`${label} limit`} + marks={usageKnown ? [{ value: usage, label: `${usage}%` }] : []} + sx={{ + flex: 1, + py: 0.5, + // Marks add a reserved bottom margin that pushes the track up + // when centred; drop it so the track lines up with the values. + '&.MuiSlider-marked': { mb: 0 }, + '& .MuiSlider-markLabel': { + top: -18, + fontSize: '0.7rem', + fontWeight: 600, + color: 'text.secondary', + }, + '& .MuiSlider-mark': { + height: 14, + width: 3, + borderRadius: 1, + backgroundColor: 'text.secondary', + opacity: disabled ? 0.4 : 1, + }, + }} + /> + + + + {/* Current usage vs. new limit, aligned like a two-column table */} + + + + Usage + + + + {usageKnown ? `${usage}%` : '—'} + + + + + + + Threshold + + + + + + + {distributed && peers.length > 0 && ( + + setNodesOpen((open) => !open)} + aria-expanded={nodesOpen} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + color: 'text.secondary', + borderRadius: 1, + px: 0.5, + py: 0.25, + '&:hover': { color: 'text.primary' }, + }} + > + + Usage by node + + + + + + {peers.map((peer) => ( + + ))} + + + + )} + + ); +} + +PercentQuotaControl.propTypes = { + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf([null])]), + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool, + usage: PropTypes.number, + status: PropTypes.oneOf(['ok', 'warning', 'exceeded', 'neutral']).isRequired, + peers: PropTypes.arrayOf(PropTypes.object), + distributed: PropTypes.bool, +}; diff --git a/src/components/Settings/QuotasCard.jsx b/src/components/Settings/QuotasCard.jsx new file mode 100644 index 000000000..4d9e05ad2 --- /dev/null +++ b/src/components/Settings/QuotasCard.jsx @@ -0,0 +1,311 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Box, + Card, + CardContent, + CardHeader, + Typography, + Switch, + Divider, + Button, + Tooltip, + Chip, + Alert, + CircularProgress, +} from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { MemoryStick, HardDrive, TriangleAlert } from 'lucide-react'; +import { axiosInstance as axios } from '../../common/axios'; +import { QuotaRow, PercentQuotaControl, summarizeUsage, usageStatus } from './QuotaControls'; + +// Default percentage shown when a quota is first switched on with no value set. +const DEFAULT_LIMIT_PERCENT = 80; +// Fallback release margin when the API doesn't report one; used for the +// near-limit ("warning") band on the usage meters. +const DEFAULT_RELEASE_MARGIN = 5; + +// Map the GET /quotas config to the editable form. A `null` max means that +// resource is uncapped, i.e. its row switch is off. +const configToForm = (config = {}) => ({ + enabled: Boolean(config.enabled), + memoryEnabled: config.max_resident_memory_percent != null, + memory: config.max_resident_memory_percent ?? DEFAULT_LIMIT_PERCENT, + diskEnabled: config.max_disk_usage_percent != null, + disk: config.max_disk_usage_percent ?? DEFAULT_LIMIT_PERCENT, +}); + +// Map the form back to a PUT /quotas body, preserving the release margin. +const formToConfig = (form, releaseMargin) => ({ + enabled: form.enabled, + max_resident_memory_percent: form.memoryEnabled ? form.memory : null, + max_disk_usage_percent: form.diskEnabled ? form.disk : null, + release_margin_percent: releaseMargin, +}); + +const readErrorMessage = (err) => + err?.response?.data?.status?.error || err?.message || 'Failed to reach the quotas API.'; + +function QuotasCard() { + // Latest GET /quotas result ({ config, usage, peers }); refreshed on a timer + // so the usage meters stay live. + const [status, setStatus] = useState(null); + // Editable form derived from the config, plus the last-saved baseline so we + // know when there are unsaved changes. `null` until the first load. + const [draft, setDraft] = useState(null); + const [saved, setSaved] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // Set on each "Quota exceeded" chip click: a bumped nonce plus a snapshot of + // which rows were exceeded at that moment, so the flicker fires only on click. + const [flash, setFlash] = useState({ nonce: 0, memory: false, disk: false }); + + // Fetch the quota status. `initForm` seeds the editable form on first load; + // background refreshes only update the usage meters, never the form. + const loadStatus = useCallback(async ({ initForm = false } = {}) => { + const result = (await axios.get('/quotas')).data?.result ?? {}; + setStatus(result); + if (initForm) { + const form = configToForm(result.config); + setDraft(form); + setSaved(form); + } + return result; + }, []); + + useEffect(() => { + let active = true; + setLoading(true); + loadStatus({ initForm: true }) + .then(() => active && setError(null)) + .catch((err) => active && setError(readErrorMessage(err))) + .finally(() => active && setLoading(false)); + const interval = setInterval(() => loadStatus().catch(() => {}), 5000); + return () => { + active = false; + clearInterval(interval); + }; + }, [loadStatus]); + + const releaseMargin = status?.config?.release_margin_percent ?? DEFAULT_RELEASE_MARGIN; + + const patch = (changes) => setDraft((prev) => ({ ...prev, ...changes })); + + // The master switch enables/disables every quota at once; turning a single + // quota on while quotas are globally off also turns quotas on. + const toggleMaster = (next) => patch({ enabled: next, memoryEnabled: next, diskEnabled: next }); + const toggleRow = (key, next) => patch({ [key]: next, ...(next && !draft.enabled ? { enabled: true } : {}) }); + + const hasUnsavedChanges = + !!draft && + !!saved && + (draft.enabled !== saved.enabled || + draft.memoryEnabled !== saved.memoryEnabled || + draft.memory !== saved.memory || + draft.diskEnabled !== saved.diskEnabled || + draft.disk !== saved.disk); + + const save = async () => { + if (!draft) return; + setSaving(true); + try { + await axios.put('/quotas?wait=true', formToConfig(draft, releaseMargin)); + setSaved(draft); + await loadStatus(); + setError(null); + } catch (err) { + setError(readErrorMessage(err)); + } finally { + setSaving(false); + } + }; + const discard = () => setDraft(saved); + + const memoryActive = Boolean(draft?.enabled && draft?.memoryEnabled); + const diskActive = Boolean(draft?.enabled && draft?.diskEnabled); + const memoryUsage = summarizeUsage(status, 'resident_memory_percent'); + const diskUsage = summarizeUsage(status, 'disk_usage_percent'); + const memoryStatus = usageStatus(memoryUsage.percent, draft?.memory, releaseMargin, memoryActive); + const diskStatus = usageStatus(diskUsage.percent, draft?.disk, releaseMargin, diskActive); + const memoryExceeded = memoryStatus === 'exceeded'; + const diskExceeded = diskStatus === 'exceeded'; + const exceededResources = [memoryExceeded && 'memory', diskExceeded && 'disk'].filter(Boolean); + const exceededMessage = exceededResources.length + ? `${exceededResources.join(' and ').replace(/^./, (c) => c.toUpperCase())} usage ${ + exceededResources.length > 1 ? 'have' : 'has' + } exceeded the configured quota on at least one node.` + : null; + + return ( + + + Quotas + {exceededMessage && ( + + } + label="Quota exceeded" + onClick={() => setFlash((f) => ({ nonce: f.nonce + 1, memory: memoryExceeded, disk: diskExceeded }))} + sx={{ + height: 24, + fontSize: '0.75rem', + fontWeight: 600, + cursor: 'pointer', + color: 'warning.main', + backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.16), + '&:hover': { + backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.28), + }, + '& .MuiChip-icon': { color: 'inherit', ml: 0.75 }, + }} + /> + + )} + + } + variant="heading" + sx={{ flexGrow: 1 }} + action={ + + + Off + + toggleMaster(event.target.checked)} + disabled={loading || saving || !draft} + inputProps={{ 'aria-label': 'Enable quotas' }} + /> + + On + + + } + /> + + {loading ? ( + + + + Loading quotas… + + + ) : !draft ? ( + + + {error || 'Could not load quotas.'} + + + + ) : ( + + {error && ( + setError(null)}> + {error} + + )} + + } + label="Memory" + description="Share of available RAM this instance may use." + htmlFor="memory-quota" + enabled={draft.memoryEnabled} + onToggle={(next) => toggleRow('memoryEnabled', next)} + dimmed={!memoryActive} + flash={flash.memory ? flash.nonce : 0} + > + patch({ memory: value })} + disabled={!memoryActive} + usage={memoryUsage.percent} + status={memoryStatus} + peers={memoryUsage.peers} + distributed={memoryUsage.distributed} + /> + + + + + } + label="Disk space" + description="Share of available disk this instance may use." + htmlFor="disk-quota" + enabled={draft.diskEnabled} + onToggle={(next) => toggleRow('diskEnabled', next)} + dimmed={!diskActive} + flash={flash.disk ? flash.nonce : 0} + > + patch({ disk: value })} + disabled={!diskActive} + usage={diskUsage.percent} + status={diskStatus} + peers={diskUsage.peers} + distributed={diskUsage.distributed} + /> + + + + + + + You have unsaved changes. + + + + + + )} + + + ); +} + +export default QuotasCard; diff --git a/src/pages/Settings.jsx b/src/pages/Settings.jsx index 6b5799396..2f681340a 100644 --- a/src/pages/Settings.jsx +++ b/src/pages/Settings.jsx @@ -1,494 +1,10 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import PropTypes from 'prop-types'; -import { - Box, - Card, - CardContent, - CardHeader, - Grid, - Typography, - Switch, - InputLabel, - Divider, - Button, - Slider, - Collapse, - ButtonBase, - Tooltip, - Chip, - Alert, - CircularProgress, -} from '@mui/material'; -import { alpha, keyframes } from '@mui/material/styles'; -import { MemoryStick, HardDrive, TriangleAlert, ChevronDown } from 'lucide-react'; -import NumberField from '../components/Common/NumberField'; +import React from 'react'; +import { Box, Grid, Typography } from '@mui/material'; import { CenteredFrame } from '../components/Common/CenteredFrame'; import { PAGE_CONTENT_WIDTH } from '../theme/constants'; -import { axiosInstance as axios } from '../common/axios'; - -// Default percentage shown when a quota is first switched on with no value set. -const DEFAULT_LIMIT_PERCENT = 80; -// Fallback release margin when the API doesn't report one; used for the -// near-limit ("warning") band on the usage meters. -const DEFAULT_RELEASE_MARGIN = 5; - -// Map the GET /quotas config to the editable form. A `null` max means that -// resource is uncapped, i.e. its row switch is off. -const configToForm = (config = {}) => ({ - enabled: Boolean(config.enabled), - memoryEnabled: config.max_resident_memory_percent != null, - memory: config.max_resident_memory_percent ?? DEFAULT_LIMIT_PERCENT, - diskEnabled: config.max_disk_usage_percent != null, - disk: config.max_disk_usage_percent ?? DEFAULT_LIMIT_PERCENT, -}); - -// Map the form back to a PUT /quotas body, preserving the release margin. -const formToConfig = (form, releaseMargin) => ({ - enabled: form.enabled, - max_resident_memory_percent: form.memoryEnabled ? form.memory : null, - max_disk_usage_percent: form.diskEnabled ? form.disk : null, - release_margin_percent: releaseMargin, -}); - -const readErrorMessage = (err) => - err?.response?.data?.status?.error || err?.message || 'Failed to reach the quotas API.'; - -const labelSx = { - color: 'text.primary', - fontWeight: 500, - fontSize: '0.9375rem', - lineHeight: 1.3, -}; - -const dimSx = (dimmed) => ({ opacity: dimmed ? 0.55 : 1, transition: 'opacity 0.2s ease' }); - -// Attention flicker played on a quota row when the user clicks the "Quota -// exceeded" chip, to point them at the offending metric. -const flicker = keyframes` - 0%, 100% { opacity: 1; } - 12.5%, 37.5%, 62.5% { opacity: 0.3; } - 25%, 50%, 75% { opacity: 1; } -`; - -// A single quota setting laid out as a row: an icon, a label with a short -// description, its own enable/disable switch, and a control. On desktop the -// switch sits at the far right; on small screens it moves up beside the label -// and the control drops to its own line. When the row is off, its content dims -// but the switch stays fully interactive. -function QuotaRow({ icon, label, description, htmlFor, enabled, onToggle, dimmed, flash, children }) { - const [animate, setAnimate] = useState(false); - // Restart the flicker each time `flash` changes (i.e. the chip is clicked - // again), briefly clearing the animation so the browser replays it. - useEffect(() => { - if (!flash) return undefined; - setAnimate(false); - const id = requestAnimationFrame(() => setAnimate(true)); - return () => cancelAnimationFrame(id); - }, [flash]); - - return ( - setAnimate(false)} - sx={{ - display: 'flex', - flexDirection: { xs: 'column', sm: 'row' }, - alignItems: { xs: 'stretch', sm: 'flex-start' }, - gap: { xs: 1.5, sm: 3 }, - animation: animate ? `${flicker} 0.9s ease-in-out` : undefined, - }} - > - - onToggle(event.target.checked)} - inputProps={{ 'aria-label': `Enable ${label} quota` }} - sx={{ flexShrink: 0 }} - /> - - alpha(theme.palette.primary.main, theme.palette.mode === 'dark' ? 0.18 : 0.1), - }} - > - {icon} - - - - {label} - - - {description} - - - - - - {children} - - - ); -} - -QuotaRow.propTypes = { - icon: PropTypes.node.isRequired, - label: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - htmlFor: PropTypes.string.isRequired, - enabled: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, - dimmed: PropTypes.bool, - flash: PropTypes.number, - children: PropTypes.node.isRequired, -}; - -// Colour for the "Current" usage number by status. 'ok' stays plain text so -// only the near-limit (amber) and exceeded (red) states draw the eye. -const USAGE_STATUS_COLOR = { - ok: 'text.primary', - warning: 'warning.main', - exceeded: 'error.main', - neutral: 'text.disabled', -}; - -const shortPeerId = (id) => `…${String(id).slice(-4)}`; - -// Reduce the per-node quota usage from GET /quotas into a headline number. -// The quota is enforced per node, so the busiest node is what matters; fall -// back to the serving node's usage when the cluster is single-node. -function summarizeUsage(status, key) { - const entries = status && status.peers ? Object.entries(status.peers) : []; - if (entries.length) { - let peak = null; - const peers = entries.map(([id, peer]) => { - const percent = peer[key] ?? null; - if (percent != null && (peak == null || percent > peak)) peak = percent; - return { id, percent }; - }); - return { percent: peak, peers, distributed: true }; - } - return { percent: status?.usage?.[key] ?? null, peers: [], distributed: false }; -} - -// Classify current usage against the configured limit (minus the release -// margin) so the meter can colour itself. 'neutral' while the quota is off. -function usageStatus(percent, limit, margin, enabled) { - if (!enabled || limit == null || percent == null) return 'neutral'; - if (percent >= limit) return 'exceeded'; - if (percent >= limit - (margin ?? 0)) return 'warning'; - return 'ok'; -} - -// One node's usage inside the "Usage by node" disclosure: a mini bar coloured -// red when that node is over the configured limit. -function PeerUsageRow({ peer, limitPercent, showLimit }) { - const known = peer.percent != null; - const over = showLimit && limitPercent != null && known && peer.percent >= limitPercent; - return ( - - - Node {shortPeerId(peer.id)} - - - - {known && ( - - )} - {showLimit && limitPercent != null && ( - - )} - - - {known ? `${peer.percent}%` : '—'} - - - ); -} - -PeerUsageRow.propTypes = { - peer: PropTypes.object.isRequired, - limitPercent: PropTypes.number, - showLimit: PropTypes.bool, -}; - -// Merged limit + usage control for a percentage quota: one slider whose thumb -// sets the limit (edited precisely in the "New" field) and whose coloured mark -// shows current cluster usage ("Current"). In a cluster, a collapsible section -// breaks usage down per node. -function PercentQuotaControl({ id, label, value, onChange, disabled, usage, status, peers, distributed }) { - const [nodesOpen, setNodesOpen] = useState(false); - const statusColor = usage != null ? USAGE_STATUS_COLOR[status] : 'text.disabled'; - const usageKnown = usage != null; - - return ( - - - - {/* Empty caption keeps the same top offset as the Current/New columns, - so the slider track lands level with those values. */} - -   - - - onChange(next)} - disabled={disabled} - min={0} - max={100} - track={false} - valueLabelDisplay="auto" - aria-label={`${label} limit`} - marks={usageKnown ? [{ value: usage, label: `${usage}%` }] : []} - sx={{ - flex: 1, - py: 0.5, - // Marks add a reserved bottom margin that pushes the track up - // when centred; drop it so the track lines up with the values. - '&.MuiSlider-marked': { mb: 0 }, - '& .MuiSlider-markLabel': { - top: -18, - fontSize: '0.7rem', - fontWeight: 600, - color: 'text.secondary', - }, - '& .MuiSlider-mark': { - height: 14, - width: 3, - borderRadius: 1, - backgroundColor: 'text.secondary', - opacity: disabled ? 0.4 : 1, - }, - }} - /> - - - - {/* Current usage vs. new limit, aligned like a two-column table */} - - - - Usage - - - - {usageKnown ? `${usage}%` : '—'} - - - - - - - Threshold - - - - - - - {distributed && peers.length > 0 && ( - - setNodesOpen((open) => !open)} - aria-expanded={nodesOpen} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 0.5, - color: 'text.secondary', - borderRadius: 1, - px: 0.5, - py: 0.25, - '&:hover': { color: 'text.primary' }, - }} - > - - Usage by node - - - - - - {peers.map((peer) => ( - - ))} - - - - )} - - ); -} - -PercentQuotaControl.propTypes = { - id: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf([null])]), - onChange: PropTypes.func.isRequired, - disabled: PropTypes.bool, - usage: PropTypes.number, - status: PropTypes.oneOf(['ok', 'warning', 'exceeded', 'neutral']).isRequired, - peers: PropTypes.arrayOf(PropTypes.object), - distributed: PropTypes.bool, -}; +import QuotasCard from '../components/Settings/QuotasCard'; function Settings() { - // Latest GET /quotas result ({ config, usage, peers }); refreshed on a timer - // so the usage meters stay live. - const [status, setStatus] = useState(null); - // Editable form derived from the config, plus the last-saved baseline so we - // know when there are unsaved changes. `null` until the first load. - const [draft, setDraft] = useState(null); - const [saved, setSaved] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - // Set on each "Quota exceeded" chip click: a bumped nonce plus a snapshot of - // which rows were exceeded at that moment, so the flicker fires only on click. - const [flash, setFlash] = useState({ nonce: 0, memory: false, disk: false }); - - // Fetch the quota status. `initForm` seeds the editable form on first load; - // background refreshes only update the usage meters, never the form. - const loadStatus = useCallback(async ({ initForm = false } = {}) => { - const result = (await axios.get('/quotas')).data?.result ?? {}; - setStatus(result); - if (initForm) { - const form = configToForm(result.config); - setDraft(form); - setSaved(form); - } - return result; - }, []); - - useEffect(() => { - let active = true; - setLoading(true); - loadStatus({ initForm: true }) - .then(() => active && setError(null)) - .catch((err) => active && setError(readErrorMessage(err))) - .finally(() => active && setLoading(false)); - const interval = setInterval(() => loadStatus().catch(() => {}), 5000); - return () => { - active = false; - clearInterval(interval); - }; - }, [loadStatus]); - - const releaseMargin = status?.config?.release_margin_percent ?? DEFAULT_RELEASE_MARGIN; - - const patch = (changes) => setDraft((prev) => ({ ...prev, ...changes })); - - // The master switch enables/disables every quota at once; turning a single - // quota on while quotas are globally off also turns quotas on. - const toggleMaster = (next) => patch({ enabled: next, memoryEnabled: next, diskEnabled: next }); - const toggleRow = (key, next) => patch({ [key]: next, ...(next && !draft.enabled ? { enabled: true } : {}) }); - - const hasUnsavedChanges = - !!draft && - !!saved && - (draft.enabled !== saved.enabled || - draft.memoryEnabled !== saved.memoryEnabled || - draft.memory !== saved.memory || - draft.diskEnabled !== saved.diskEnabled || - draft.disk !== saved.disk); - - const save = async () => { - if (!draft) return; - setSaving(true); - try { - await axios.put('/quotas?wait=true', formToConfig(draft, releaseMargin)); - setSaved(draft); - await loadStatus(); - setError(null); - } catch (err) { - setError(readErrorMessage(err)); - } finally { - setSaving(false); - } - }; - const discard = () => setDraft(saved); - - const memoryActive = Boolean(draft?.enabled && draft?.memoryEnabled); - const diskActive = Boolean(draft?.enabled && draft?.diskEnabled); - const memoryUsage = summarizeUsage(status, 'resident_memory_percent'); - const diskUsage = summarizeUsage(status, 'disk_usage_percent'); - const memoryStatus = usageStatus(memoryUsage.percent, draft?.memory, releaseMargin, memoryActive); - const diskStatus = usageStatus(diskUsage.percent, draft?.disk, releaseMargin, diskActive); - const memoryExceeded = memoryStatus === 'exceeded'; - const diskExceeded = diskStatus === 'exceeded'; - const exceededResources = [memoryExceeded && 'memory', diskExceeded && 'disk'].filter(Boolean); - const exceededMessage = exceededResources.length - ? `${exceededResources.join(' and ').replace(/^./, (c) => c.toUpperCase())} usage ${ - exceededResources.length > 1 ? 'have' : 'has' - } exceeded the configured quota on at least one node.` - : null; - return ( @@ -500,176 +16,7 @@ function Settings() { - - - Quotas - {exceededMessage && ( - - } - label="Quota exceeded" - onClick={() => - setFlash((f) => ({ nonce: f.nonce + 1, memory: memoryExceeded, disk: diskExceeded })) - } - sx={{ - height: 24, - fontSize: '0.75rem', - fontWeight: 600, - cursor: 'pointer', - color: 'warning.main', - backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.16), - '&:hover': { - backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.28), - }, - '& .MuiChip-icon': { color: 'inherit', ml: 0.75 }, - }} - /> - - )} - - } - variant="heading" - sx={{ flexGrow: 1 }} - action={ - - - Off - - toggleMaster(event.target.checked)} - disabled={loading || saving || !draft} - inputProps={{ 'aria-label': 'Enable quotas' }} - /> - - On - - - } - /> - - {loading ? ( - - - - Loading quotas… - - - ) : !draft ? ( - - - {error || 'Could not load quotas.'} - - - - ) : ( - - {error && ( - setError(null)}> - {error} - - )} - - } - label="Memory" - description="Share of available RAM this instance may use." - htmlFor="memory-quota" - enabled={draft.memoryEnabled} - onToggle={(next) => toggleRow('memoryEnabled', next)} - dimmed={!memoryActive} - flash={flash.memory ? flash.nonce : 0} - > - patch({ memory: value })} - disabled={!memoryActive} - usage={memoryUsage.percent} - status={memoryStatus} - peers={memoryUsage.peers} - distributed={memoryUsage.distributed} - /> - - - - - } - label="Disk space" - description="Share of available disk this instance may use." - htmlFor="disk-quota" - enabled={draft.diskEnabled} - onToggle={(next) => toggleRow('diskEnabled', next)} - dimmed={!diskActive} - flash={flash.disk ? flash.nonce : 0} - > - patch({ disk: value })} - disabled={!diskActive} - usage={diskUsage.percent} - status={diskStatus} - peers={diskUsage.peers} - distributed={diskUsage.distributed} - /> - - - - - - - You have unsaved changes. - - - - - - )} - - + From c000d780ff459022ee241515a8b135703b297b50 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 3 Aug 2026 17:38:37 +0200 Subject: [PATCH 10/13] refactoring and test --- src/components/Settings/QuotaControls.jsx | 26 ------- src/components/Settings/QuotasCard.jsx | 23 +----- src/components/Settings/quotaHelpers.js | 46 +++++++++++ src/components/Settings/quotaHelpers.test.js | 81 ++++++++++++++++++++ 4 files changed, 129 insertions(+), 47 deletions(-) create mode 100644 src/components/Settings/quotaHelpers.js create mode 100644 src/components/Settings/quotaHelpers.test.js diff --git a/src/components/Settings/QuotaControls.jsx b/src/components/Settings/QuotaControls.jsx index 542a957fc..2f9e33e51 100644 --- a/src/components/Settings/QuotaControls.jsx +++ b/src/components/Settings/QuotaControls.jsx @@ -123,32 +123,6 @@ const USAGE_STATUS_COLOR = { const shortPeerId = (id) => `…${String(id).slice(-4)}`; -// Reduce the per-node quota usage from GET /quotas into a headline number. -// The quota is enforced per node, so the busiest node is what matters; fall -// back to the serving node's usage when the cluster is single-node. -export function summarizeUsage(status, key) { - const entries = status && status.peers ? Object.entries(status.peers) : []; - if (entries.length) { - let peak = null; - const peers = entries.map(([id, peer]) => { - const percent = peer[key] ?? null; - if (percent != null && (peak == null || percent > peak)) peak = percent; - return { id, percent }; - }); - return { percent: peak, peers, distributed: true }; - } - return { percent: status?.usage?.[key] ?? null, peers: [], distributed: false }; -} - -// Classify current usage against the configured limit (minus the release -// margin) so the meter can colour itself. 'neutral' while the quota is off. -export function usageStatus(percent, limit, margin, enabled) { - if (!enabled || limit == null || percent == null) return 'neutral'; - if (percent >= limit) return 'exceeded'; - if (percent >= limit - (margin ?? 0)) return 'warning'; - return 'ok'; -} - // One node's usage inside the "Usage by node" disclosure: a mini bar coloured // red when that node is over the configured limit. function PeerUsageRow({ peer, limitPercent, showLimit }) { diff --git a/src/components/Settings/QuotasCard.jsx b/src/components/Settings/QuotasCard.jsx index 4d9e05ad2..47411aa74 100644 --- a/src/components/Settings/QuotasCard.jsx +++ b/src/components/Settings/QuotasCard.jsx @@ -16,32 +16,13 @@ import { import { alpha } from '@mui/material/styles'; import { MemoryStick, HardDrive, TriangleAlert } from 'lucide-react'; import { axiosInstance as axios } from '../../common/axios'; -import { QuotaRow, PercentQuotaControl, summarizeUsage, usageStatus } from './QuotaControls'; +import { QuotaRow, PercentQuotaControl } from './QuotaControls'; +import { configToForm, formToConfig, summarizeUsage, usageStatus } from './quotaHelpers'; -// Default percentage shown when a quota is first switched on with no value set. -const DEFAULT_LIMIT_PERCENT = 80; // Fallback release margin when the API doesn't report one; used for the // near-limit ("warning") band on the usage meters. const DEFAULT_RELEASE_MARGIN = 5; -// Map the GET /quotas config to the editable form. A `null` max means that -// resource is uncapped, i.e. its row switch is off. -const configToForm = (config = {}) => ({ - enabled: Boolean(config.enabled), - memoryEnabled: config.max_resident_memory_percent != null, - memory: config.max_resident_memory_percent ?? DEFAULT_LIMIT_PERCENT, - diskEnabled: config.max_disk_usage_percent != null, - disk: config.max_disk_usage_percent ?? DEFAULT_LIMIT_PERCENT, -}); - -// Map the form back to a PUT /quotas body, preserving the release margin. -const formToConfig = (form, releaseMargin) => ({ - enabled: form.enabled, - max_resident_memory_percent: form.memoryEnabled ? form.memory : null, - max_disk_usage_percent: form.diskEnabled ? form.disk : null, - release_margin_percent: releaseMargin, -}); - const readErrorMessage = (err) => err?.response?.data?.status?.error || err?.message || 'Failed to reach the quotas API.'; diff --git a/src/components/Settings/quotaHelpers.js b/src/components/Settings/quotaHelpers.js new file mode 100644 index 000000000..959540739 --- /dev/null +++ b/src/components/Settings/quotaHelpers.js @@ -0,0 +1,46 @@ +// Default percentage shown when a quota is first switched on with no value set. +export const DEFAULT_LIMIT_PERCENT = 80; + +// Map the GET /quotas config to the editable form. A `null` max means that +// resource is uncapped, i.e. its row switch is off. +export const configToForm = (config = {}) => ({ + enabled: Boolean(config.enabled), + memoryEnabled: config.max_resident_memory_percent != null, + memory: config.max_resident_memory_percent ?? DEFAULT_LIMIT_PERCENT, + diskEnabled: config.max_disk_usage_percent != null, + disk: config.max_disk_usage_percent ?? DEFAULT_LIMIT_PERCENT, +}); + +// Map the form back to a PUT /quotas body, preserving the release margin. +export const formToConfig = (form, releaseMargin) => ({ + enabled: form.enabled, + max_resident_memory_percent: form.memoryEnabled ? form.memory : null, + max_disk_usage_percent: form.diskEnabled ? form.disk : null, + release_margin_percent: releaseMargin, +}); + +// Reduce the per-node quota usage from GET /quotas into a headline number. +// The quota is enforced per node, so the busiest node is what matters; fall +// back to the serving node's usage when the cluster is single-node. +export function summarizeUsage(status, key) { + const entries = status && status.peers ? Object.entries(status.peers) : []; + if (entries.length) { + let peak = null; + const peers = entries.map(([id, peer]) => { + const percent = peer[key] ?? null; + if (percent != null && (peak == null || percent > peak)) peak = percent; + return { id, percent }; + }); + return { percent: peak, peers, distributed: true }; + } + return { percent: status?.usage?.[key] ?? null, peers: [], distributed: false }; +} + +// Classify current usage against the configured limit (minus the release +// margin) so the meter can colour itself. 'neutral' while the quota is off. +export function usageStatus(percent, limit, margin, enabled) { + if (!enabled || limit == null || percent == null) return 'neutral'; + if (percent >= limit) return 'exceeded'; + if (percent >= limit - (margin ?? 0)) return 'warning'; + return 'ok'; +} diff --git a/src/components/Settings/quotaHelpers.test.js b/src/components/Settings/quotaHelpers.test.js new file mode 100644 index 000000000..b6a89c336 --- /dev/null +++ b/src/components/Settings/quotaHelpers.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { + DEFAULT_LIMIT_PERCENT, + configToForm, + formToConfig, + summarizeUsage, + usageStatus, +} from './quotaHelpers'; + +describe('configToForm / formToConfig', () => { + it('treats null max as disabled and fills the default limit for editing', () => { + expect(configToForm({ enabled: true, max_resident_memory_percent: null, max_disk_usage_percent: null })).toEqual({ + enabled: true, + memoryEnabled: false, + memory: DEFAULT_LIMIT_PERCENT, + diskEnabled: false, + disk: DEFAULT_LIMIT_PERCENT, + }); + }); + + it('round-trips enabled limits and clears disabled ones', () => { + const form = { + enabled: true, + memoryEnabled: true, + memory: 70, + diskEnabled: false, + disk: 55, + }; + expect(formToConfig(form, 5)).toEqual({ + enabled: true, + max_resident_memory_percent: 70, + max_disk_usage_percent: null, + release_margin_percent: 5, + }); + expect(configToForm(formToConfig(form, 5))).toMatchObject({ + enabled: true, + memoryEnabled: true, + memory: 70, + diskEnabled: false, + disk: DEFAULT_LIMIT_PERCENT, + }); + }); +}); + +describe('summarizeUsage', () => { + it('uses local usage on a single node', () => { + expect(summarizeUsage({ usage: { resident_memory_percent: 42 } }, 'resident_memory_percent')).toEqual({ + percent: 42, + peers: [], + distributed: false, + }); + }); + + it('reports the peak across peers and ignores missing values for the peak', () => { + const status = { + peers: { + aaa: { resident_memory_percent: 30 }, + bbb: { resident_memory_percent: 80 }, + ccc: {}, + }, + }; + const result = summarizeUsage(status, 'resident_memory_percent'); + expect(result.percent).toBe(80); + expect(result.distributed).toBe(true); + expect(result.peers).toEqual([ + { id: 'aaa', percent: 30 }, + { id: 'bbb', percent: 80 }, + { id: 'ccc', percent: null }, + ]); + }); +}); + +describe('usageStatus', () => { + it('classifies against the limit and release margin', () => { + expect(usageStatus(50, 80, 5, true)).toBe('ok'); + expect(usageStatus(75, 80, 5, true)).toBe('warning'); + expect(usageStatus(80, 80, 5, true)).toBe('exceeded'); + expect(usageStatus(90, 80, 5, false)).toBe('neutral'); + expect(usageStatus(null, 80, 5, true)).toBe('neutral'); + }); +}); From 766cfe9169583f257e343bb00b3d7fe3c1d624d9 Mon Sep 17 00:00:00 2001 From: trean Date: Mon, 3 Aug 2026 17:39:36 +0200 Subject: [PATCH 11/13] format --- src/components/Settings/quotaHelpers.test.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/Settings/quotaHelpers.test.js b/src/components/Settings/quotaHelpers.test.js index b6a89c336..be21dbba6 100644 --- a/src/components/Settings/quotaHelpers.test.js +++ b/src/components/Settings/quotaHelpers.test.js @@ -1,11 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { - DEFAULT_LIMIT_PERCENT, - configToForm, - formToConfig, - summarizeUsage, - usageStatus, -} from './quotaHelpers'; +import { DEFAULT_LIMIT_PERCENT, configToForm, formToConfig, summarizeUsage, usageStatus } from './quotaHelpers'; describe('configToForm / formToConfig', () => { it('treats null max as disabled and fills the default limit for editing', () => { From 9f723605f716071c9cec346882f6bbf28d017fc3 Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 4 Aug 2026 10:21:34 +0200 Subject: [PATCH 12/13] Clarify quota descriptions, use "peer" wording, sort peer list - Threshold descriptions now state that writes are blocked once the Qdrant process passes the RAM share / overall disk usage passes the disk share, instead of implying an allocation budget. - "Usage by node" -> "Usage by peer" (label, row prefix, tooltip). - Sort peers by id so the breakdown doesn't reshuffle on refresh; ids are u64 strings, so compare digit strings by length then lexically. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/Settings/QuotaControls.jsx | 20 ++++++------ src/components/Settings/QuotasCard.jsx | 6 ++-- src/components/Settings/quotaHelpers.js | 32 +++++++++++++++----- src/components/Settings/quotaHelpers.test.js | 11 +++++++ 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/components/Settings/QuotaControls.jsx b/src/components/Settings/QuotaControls.jsx index 2f9e33e51..f4925d420 100644 --- a/src/components/Settings/QuotaControls.jsx +++ b/src/components/Settings/QuotaControls.jsx @@ -123,15 +123,15 @@ const USAGE_STATUS_COLOR = { const shortPeerId = (id) => `…${String(id).slice(-4)}`; -// One node's usage inside the "Usage by node" disclosure: a mini bar coloured -// red when that node is over the configured limit. +// One peer's usage inside the "Usage by peer" disclosure: a mini bar coloured +// red when that peer is over the configured limit. function PeerUsageRow({ peer, limitPercent, showLimit }) { const known = peer.percent != null; const over = showLimit && limitPercent != null && known && peer.percent >= limitPercent; return ( - Node {shortPeerId(peer.id)} + Peer {shortPeerId(peer.id)} @@ -189,9 +189,9 @@ PeerUsageRow.propTypes = { // Merged limit + usage control for a percentage quota: one slider whose thumb // sets the limit (edited precisely in the "New" field) and whose coloured mark // shows current cluster usage ("Current"). In a cluster, a collapsible section -// breaks usage down per node. +// breaks usage down per peer. export function PercentQuotaControl({ id, label, value, onChange, disabled, usage, status, peers, distributed }) { - const [nodesOpen, setNodesOpen] = useState(false); + const [peersOpen, setPeersOpen] = useState(false); const statusColor = usage != null ? USAGE_STATUS_COLOR[status] : 'text.disabled'; const usageKnown = usage != null; @@ -278,8 +278,8 @@ export function PercentQuotaControl({ id, label, value, onChange, disabled, usag {distributed && peers.length > 0 && ( setNodesOpen((open) => !open)} - aria-expanded={nodesOpen} + onClick={() => setPeersOpen((open) => !open)} + aria-expanded={peersOpen} sx={{ display: 'inline-flex', alignItems: 'center', @@ -292,14 +292,14 @@ export function PercentQuotaControl({ id, label, value, onChange, disabled, usag }} > - Usage by node + Usage by peer - + {peers.map((peer) => ( diff --git a/src/components/Settings/QuotasCard.jsx b/src/components/Settings/QuotasCard.jsx index 47411aa74..294f47628 100644 --- a/src/components/Settings/QuotasCard.jsx +++ b/src/components/Settings/QuotasCard.jsx @@ -114,7 +114,7 @@ function QuotasCard() { const exceededMessage = exceededResources.length ? `${exceededResources.join(' and ').replace(/^./, (c) => c.toUpperCase())} usage ${ exceededResources.length > 1 ? 'have' : 'has' - } exceeded the configured quota on at least one node.` + } exceeded the configured quota on at least one peer.` : null; return ( @@ -220,7 +220,7 @@ function QuotasCard() { } label="Memory" - description="Share of available RAM this instance may use." + description="Blocks writes once the Qdrant process uses more than this share of total RAM." htmlFor="memory-quota" enabled={draft.memoryEnabled} onToggle={(next) => toggleRow('memoryEnabled', next)} @@ -245,7 +245,7 @@ function QuotasCard() { } label="Disk space" - description="Share of available disk this instance may use." + description="Blocks writes once overall disk usage exceeds this share of total disk space." htmlFor="disk-quota" enabled={draft.diskEnabled} onToggle={(next) => toggleRow('diskEnabled', next)} diff --git a/src/components/Settings/quotaHelpers.js b/src/components/Settings/quotaHelpers.js index 959540739..3c24f87e5 100644 --- a/src/components/Settings/quotaHelpers.js +++ b/src/components/Settings/quotaHelpers.js @@ -19,18 +19,34 @@ export const formToConfig = (form, releaseMargin) => ({ release_margin_percent: releaseMargin, }); -// Reduce the per-node quota usage from GET /quotas into a headline number. -// The quota is enforced per node, so the busiest node is what matters; fall -// back to the serving node's usage when the cluster is single-node. +// Order peer ids deterministically so the list doesn't reshuffle on refresh. +// Peer ids are u64 and can exceed Number.MAX_SAFE_INTEGER, so compare digit +// strings by length first; anything non-numeric falls back to plain ordering. +export function comparePeerIds(a, b) { + const left = String(a); + const right = String(b); + const numeric = /^\d+$/; + if (numeric.test(left) && numeric.test(right)) { + if (left.length !== right.length) return left.length - right.length; + return left < right ? -1 : left > right ? 1 : 0; + } + return left < right ? -1 : left > right ? 1 : 0; +} + +// Reduce the per-peer quota usage from GET /quotas into a headline number. +// The quota is enforced per peer, so the busiest peer is what matters; fall +// back to the serving peer's usage when the cluster is single-node. export function summarizeUsage(status, key) { const entries = status && status.peers ? Object.entries(status.peers) : []; if (entries.length) { let peak = null; - const peers = entries.map(([id, peer]) => { - const percent = peer[key] ?? null; - if (percent != null && (peak == null || percent > peak)) peak = percent; - return { id, percent }; - }); + const peers = entries + .map(([id, peer]) => { + const percent = peer[key] ?? null; + if (percent != null && (peak == null || percent > peak)) peak = percent; + return { id, percent }; + }) + .sort((a, b) => comparePeerIds(a.id, b.id)); return { percent: peak, peers, distributed: true }; } return { percent: status?.usage?.[key] ?? null, peers: [], distributed: false }; diff --git a/src/components/Settings/quotaHelpers.test.js b/src/components/Settings/quotaHelpers.test.js index be21dbba6..ca1f41172 100644 --- a/src/components/Settings/quotaHelpers.test.js +++ b/src/components/Settings/quotaHelpers.test.js @@ -62,6 +62,17 @@ describe('summarizeUsage', () => { { id: 'ccc', percent: null }, ]); }); + + it('sorts peers by id so the list is stable across refreshes', () => { + const ids = ['9007199254740993123', '42', '9007199254740993122', '7']; + const peers = Object.fromEntries(ids.map((id, i) => [id, { resident_memory_percent: i }])); + expect(summarizeUsage({ peers }, 'resident_memory_percent').peers.map((p) => p.id)).toEqual([ + '7', + '42', + '9007199254740993122', + '9007199254740993123', + ]); + }); }); describe('usageStatus', () => { From f539d66ede530e2c73b51333b1b1e3a7f3cda2af Mon Sep 17 00:00:00 2001 From: generall Date: Tue, 4 Aug 2026 10:22:56 +0200 Subject: [PATCH 13/13] Run npm audit fix Co-Authored-By: Claude Opus 5 (1M context) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b0cad8dd0..1f7aeea65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9726,9 +9726,9 @@ } }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17"