diff --git a/package-lock.json b/package-lock.json index c06928489..1f7aeea65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -310,7 +310,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": { @@ -8815,7 +8817,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": { @@ -9722,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" @@ -9951,7 +9955,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/src/components/Common/NumberField.jsx b/src/components/Common/NumberField.jsx new file mode 100644 index 000000000..122d74b14 --- /dev/null +++ b/src/components/Common/NumberField.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { TextField, InputAdornment } from '@mui/material'; + +// 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 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 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, + value, + onValueChange, + min, + max, + step = 1, + disabled = false, + placeholder, + suffix, + ariaLabel, + 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} } : undefined, + }} + sx={{ ...hideSpinButtonsSx, ...sx }} + /> + ); +} + +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, + sx: PropTypes.oneOfType([PropTypes.object, PropTypes.array, PropTypes.func]), +}; + +export default NumberField; diff --git a/src/components/Settings/QuotaControls.jsx b/src/components/Settings/QuotaControls.jsx new file mode 100644 index 000000000..f4925d420 --- /dev/null +++ b/src/components/Settings/QuotaControls.jsx @@ -0,0 +1,325 @@ +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)}`; + +// 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 ( + + + Peer {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 peer. +export function PercentQuotaControl({ id, label, value, onChange, disabled, usage, status, peers, distributed }) { + const [peersOpen, setPeersOpen] = 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 && ( + + setPeersOpen((open) => !open)} + aria-expanded={peersOpen} + 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 peer + + + + + + {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..294f47628 --- /dev/null +++ b/src/components/Settings/QuotasCard.jsx @@ -0,0 +1,292 @@ +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 } from './QuotaControls'; +import { configToForm, formToConfig, summarizeUsage, usageStatus } from './quotaHelpers'; + +// 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; + +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 peer.` + : 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="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)} + 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="Blocks writes once overall disk usage exceeds this share of total disk space." + 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/components/Settings/quotaHelpers.js b/src/components/Settings/quotaHelpers.js new file mode 100644 index 000000000..3c24f87e5 --- /dev/null +++ b/src/components/Settings/quotaHelpers.js @@ -0,0 +1,62 @@ +// 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, +}); + +// 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 }; + }) + .sort((a, b) => comparePeerIds(a.id, b.id)); + 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..ca1f41172 --- /dev/null +++ b/src/components/Settings/quotaHelpers.test.js @@ -0,0 +1,86 @@ +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 }, + ]); + }); + + 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', () => { + 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'); + }); +}); 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/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 new file mode 100644 index 000000000..2f681340a --- /dev/null +++ b/src/pages/Settings.jsx @@ -0,0 +1,27 @@ +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 QuotasCard from '../components/Settings/QuotasCard'; + +function Settings() { + return ( + + + + + Settings + + + + + + + + + + + ); +} + +export default 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/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: }, ], }, ]; 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', +};