From 12bee6e6417968a8bb36b1c97b3df93235af079e Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:23:51 +0200 Subject: [PATCH 1/7] Add auto refresh to collection info tab --- src/components/Collections/CollectionInfo.jsx | 71 +++++++-- .../Collections/CollectionInfo.test.jsx | 144 ++++++++++++++++++ src/hooks/useAutoRefresh.js | 55 +++++++ 3 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 src/components/Collections/CollectionInfo.test.jsx create mode 100644 src/hooks/useAutoRefresh.js diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx index 8842acc33..f81fd8eef 100644 --- a/src/components/Collections/CollectionInfo.jsx +++ b/src/components/Collections/CollectionInfo.jsx @@ -1,7 +1,9 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { Box, CardContent, IconButton, Tooltip } from '@mui/material'; +import { Box, CardContent, IconButton, MenuItem, Select, Tooltip } from '@mui/material'; +import { keyframes } from '@mui/material/styles'; import RefreshIcon from '@mui/icons-material/Refresh'; +import { Clock } from 'lucide-react'; import { useClient } from '../../context/client-context'; import { CopyButton } from '../Common/CopyButton'; import ClusterInfo from './CollectionCluster/ClusterInfo'; @@ -23,6 +25,21 @@ import { buildPathMap, } from './CollectionInfoKeyRenderer'; import { useJsonViewerTheme } from '../../theme/json-viewer-theme'; +import { useAutoRefresh } from '../../hooks/useAutoRefresh'; + +const spin = keyframes` + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +`; + +const REFRESH_INTERVAL_OPTIONS = [ + { value: 0, label: 'Off' }, + { value: 5_000, label: '5s' }, + { value: 10_000, label: '10s' }, + { value: 30_000, label: '30s' }, + { value: 60_000, label: '1m' }, + { value: 300_000, label: '5m' }, +]; export const CollectionInfo = ({ collectionName }) => { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); @@ -32,8 +49,9 @@ export const CollectionInfo = ({ collectionName }) => { const [createAliasOpen, setCreateAliasOpen] = useState(false); const [addMetadataOpen, setAddMetadataOpen] = useState(false); const [createIndexOpen, setCreateIndexOpen] = useState(false); + const [refreshIntervalMs, setRefreshIntervalMs] = useState(0); - const fetchClusterInfo = () => { + const fetchClusterInfo = (silent = false) => { if (isRestricted) { return; } @@ -47,7 +65,9 @@ export const CollectionInfo = ({ collectionName }) => { }); }) .catch((err) => { - enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + if (!silent) { + enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + } }); }; @@ -56,7 +76,7 @@ export const CollectionInfo = ({ collectionName }) => { typeof collection.config.metadata === 'object' && Object.keys(collection.config.metadata).length > 0; - const fetchCollection = () => { + const fetchCollection = (silent = false) => { qdrantClient .getCollection(collectionName) .then((res) => { @@ -65,19 +85,27 @@ export const CollectionInfo = ({ collectionName }) => { }); }) .catch((err) => { - enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + if (!silent) { + enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + } }); }; - const refreshAll = () => { - fetchCollection(); - fetchClusterInfo(); + const refreshAll = (silent = false) => { + fetchCollection(silent); + fetchClusterInfo(silent); }; useEffect(() => { refreshAll(); }, [collectionName]); + const { isRefreshing } = useAutoRefresh({ + enabled: refreshIntervalMs > 0, + intervalMs: refreshIntervalMs, + onTick: () => refreshAll(true), + }); + const triggerOptimizers = () => { qdrantClient .updateCollection(collectionName, { @@ -136,9 +164,32 @@ export const CollectionInfo = ({ collectionName }) => { triggerOptimizersDisabled={triggerOptimizersDisabled} /> + + + + + + - - + refreshAll()} + > + diff --git a/src/components/Collections/CollectionInfo.test.jsx b/src/components/Collections/CollectionInfo.test.jsx new file mode 100644 index 000000000..93c4a6cc8 --- /dev/null +++ b/src/components/Collections/CollectionInfo.test.jsx @@ -0,0 +1,144 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { act } from 'react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import CollectionInfo from './CollectionInfo'; + +const { enqueueSnackbarMock, closeSnackbarMock } = vi.hoisted(() => ({ + enqueueSnackbarMock: vi.fn(), + closeSnackbarMock: vi.fn(), +})); + +vi.mock('../../context/client-context', () => { + const client = { + getCollection: vi.fn().mockResolvedValue({ status: 'green' }), + updateCollection: vi.fn().mockResolvedValue({}), + api: vi.fn().mockReturnValue({ + collectionClusterInfo: vi.fn().mockResolvedValue({ data: { result: {} } }), + }), + }; + return { + useClient: () => ({ client, isRestricted: false }), + }; +}); + +vi.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: enqueueSnackbarMock, + closeSnackbar: closeSnackbarMock, + }), +})); + +import { useClient } from '../../context/client-context'; + +const COLLECTION_NAME = 'test_collection'; + +const flushMicrotasks = async () => { + await act(async () => {}); +}; + +const advanceTimers = async (ms) => { + await act(async () => { + vi.advanceTimersByTime(ms); + }); +}; + +const selectInterval = async (label) => { + await act(async () => { + fireEvent.mouseDown(screen.getByRole('combobox')); + }); + await act(async () => { + fireEvent.click(screen.getByRole('option', { name: label })); + }); +}; + +describe('CollectionInfo', () => { + let client; + + beforeEach(() => { + client = useClient().client; + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('fetches collection info on mount', async () => { + render(); + await flushMicrotasks(); + + expect(client.getCollection).toHaveBeenCalledTimes(1); + expect(client.getCollection).toHaveBeenCalledWith(COLLECTION_NAME); + }); + + it('refreshes immediately and then at the selected interval', async () => { + render(); + await flushMicrotasks(); + expect(client.getCollection).toHaveBeenCalledTimes(1); + + await selectInterval('5s'); + await advanceTimers(0); + expect(client.getCollection).toHaveBeenCalledTimes(2); + + await advanceTimers(5_000); + expect(client.getCollection).toHaveBeenCalledTimes(3); + + await advanceTimers(5_000); + expect(client.getCollection).toHaveBeenCalledTimes(4); + }); + + it('stops auto refresh when set to Off', async () => { + render(); + await flushMicrotasks(); + + await selectInterval('5s'); + await advanceTimers(0); + await advanceTimers(5_000); + expect(client.getCollection).toHaveBeenCalledTimes(3); + + await selectInterval('Off'); + await advanceTimers(5_000); + expect(client.getCollection).toHaveBeenCalledTimes(3); + }); + + it('clears the timer on unmount', async () => { + const { unmount } = render(); + await flushMicrotasks(); + + await selectInterval('5s'); + await advanceTimers(0); + expect(client.getCollection).toHaveBeenCalledTimes(2); + + unmount(); + await advanceTimers(5_000); + expect(client.getCollection).toHaveBeenCalledTimes(2); + }); + + it('does not show error snackbars for background refresh failures', async () => { + render(); + await flushMicrotasks(); + + client.getCollection.mockRejectedValue(new Error('boom')); + + await selectInterval('5s'); + await advanceTimers(0); + await advanceTimers(5_000); + + expect(enqueueSnackbarMock).not.toHaveBeenCalled(); + }); + + it('shows error snackbars for manual refresh failures', async () => { + client.getCollection.mockRejectedValue(new Error('boom')); + render(); + await flushMicrotasks(); + enqueueSnackbarMock.mockClear(); + + await act(async () => { + fireEvent.click(screen.getByLabelText('Refresh collection info')); + }); + await flushMicrotasks(); + + expect(enqueueSnackbarMock).toHaveBeenCalledWith('boom', expect.anything()); + }); +}); \ No newline at end of file diff --git a/src/hooks/useAutoRefresh.js b/src/hooks/useAutoRefresh.js new file mode 100644 index 000000000..f914144d3 --- /dev/null +++ b/src/hooks/useAutoRefresh.js @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Repeatedly invoke `onTick` while `enabled` is true, waiting for each tick + * to settle before scheduling the next one so slow requests never pile up. + * + * @param {Object} options - hook options + * @param {boolean} options.enabled - start/stop the auto refresh timer + * @param {number} options.intervalMs - delay between tick completions + * @param {Function} options.onTick - async callback fired on every tick + * @return {{isRefreshing: boolean}} true while a tick is in flight + */ +export function useAutoRefresh({ enabled, intervalMs, onTick }) { + const onTickRef = useRef(onTick); + useEffect(() => { + onTickRef.current = onTick; + }, [onTick]); + + const [isRefreshing, setIsRefreshing] = useState(false); + + useEffect(() => { + if (!enabled || intervalMs <= 0) { + return; + } + + let cancelled = false; + let timeoutId; + + const tick = async () => { + if (cancelled) { + return; + } + setIsRefreshing(true); + try { + await onTickRef.current(); + } catch { + // Background failures are silent; the next tick still runs. + } finally { + if (!cancelled) { + setIsRefreshing(false); + timeoutId = setTimeout(tick, intervalMs); + } + } + }; + + timeoutId = setTimeout(tick, 0); + + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [enabled, intervalMs]); + + return { isRefreshing }; +} \ No newline at end of file From ab48ee8a3d587e1406067c7cd9e409dd4cbd6b70 Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:25:59 +0200 Subject: [PATCH 2/7] Restyle auto refresh control to match actions menu --- src/components/Collections/CollectionInfo.jsx | 57 ++++++++++++------- .../Collections/CollectionInfo.test.jsx | 4 +- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx index f81fd8eef..f35ad2507 100644 --- a/src/components/Collections/CollectionInfo.jsx +++ b/src/components/Collections/CollectionInfo.jsx @@ -1,9 +1,9 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { Box, CardContent, IconButton, MenuItem, Select, Tooltip } from '@mui/material'; +import { Box, Button, CardContent, IconButton, Menu, MenuItem, Tooltip } from '@mui/material'; import { keyframes } from '@mui/material/styles'; import RefreshIcon from '@mui/icons-material/Refresh'; -import { Clock } from 'lucide-react'; +import { Check, ChevronDown, Clock } from 'lucide-react'; import { useClient } from '../../context/client-context'; import { CopyButton } from '../Common/CopyButton'; import ClusterInfo from './CollectionCluster/ClusterInfo'; @@ -50,6 +50,10 @@ export const CollectionInfo = ({ collectionName }) => { const [addMetadataOpen, setAddMetadataOpen] = useState(false); const [createIndexOpen, setCreateIndexOpen] = useState(false); const [refreshIntervalMs, setRefreshIntervalMs] = useState(0); + const [refreshAnchorEl, setRefreshAnchorEl] = useState(null); + const refreshMenuOpen = Boolean(refreshAnchorEl); + const refreshIntervalLabel = + REFRESH_INTERVAL_OPTIONS.find(({ value }) => value === refreshIntervalMs)?.label || 'Off'; const fetchClusterInfo = (silent = false) => { if (isRestricted) { @@ -164,24 +168,39 @@ export const CollectionInfo = ({ collectionName }) => { triggerOptimizersDisabled={triggerOptimizersDisabled} /> - - - - - - + {label} + {value === refreshIntervalMs && } + + ))} + { const selectInterval = async (label) => { await act(async () => { - fireEvent.mouseDown(screen.getByRole('combobox')); + fireEvent.click(screen.getByLabelText('Auto refresh interval')); }); await act(async () => { - fireEvent.click(screen.getByRole('option', { name: label })); + fireEvent.click(screen.getByRole('menuitem', { name: label })); }); }; From bed53bc5d79757778a59f70bcd9062a3b69d2d8c Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:35:18 +0200 Subject: [PATCH 3/7] Remove tooltip from refresh button --- src/components/Collections/CollectionInfo.jsx | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx index f35ad2507..bce50c971 100644 --- a/src/components/Collections/CollectionInfo.jsx +++ b/src/components/Collections/CollectionInfo.jsx @@ -1,6 +1,6 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { Box, Button, CardContent, IconButton, Menu, MenuItem, Tooltip } from '@mui/material'; +import { Box, Button, CardContent, IconButton, Menu, MenuItem } from '@mui/material'; import { keyframes } from '@mui/material/styles'; import RefreshIcon from '@mui/icons-material/Refresh'; import { Check, ChevronDown, Clock } from 'lucide-react'; @@ -148,6 +148,17 @@ export const CollectionInfo = ({ collectionName }) => { const metadata = collection.config?.metadata; + const refreshButton = ( + refreshAll()} + > + + + ); + return ( { ))} - - refreshAll()} - > - - - + {refreshButton} } > From fef01b33b9f46397a7eee392c6606d6d39c1a9de Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:40:43 +0200 Subject: [PATCH 4/7] Add custom auto refresh interval --- src/components/Collections/CollectionInfo.jsx | 140 +++++++++++++++++- .../Collections/CollectionInfo.test.jsx | 75 +++++++++- 2 files changed, 208 insertions(+), 7 deletions(-) diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx index bce50c971..9e724dc8b 100644 --- a/src/components/Collections/CollectionInfo.jsx +++ b/src/components/Collections/CollectionInfo.jsx @@ -1,6 +1,18 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { Box, Button, CardContent, IconButton, Menu, MenuItem } from '@mui/material'; +import { + Box, + Button, + CardContent, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Menu, + MenuItem, + TextField, +} from '@mui/material'; import { keyframes } from '@mui/material/styles'; import RefreshIcon from '@mui/icons-material/Refresh'; import { Check, ChevronDown, Clock } from 'lucide-react'; @@ -41,6 +53,47 @@ const REFRESH_INTERVAL_OPTIONS = [ { value: 300_000, label: '5m' }, ]; +const INTERVAL_UNITS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; + +/** + * Parse a user-typed refresh interval like "45s", "2m", "500ms" or "1h". + * A bare number is treated as seconds. Returns milliseconds, or null when invalid. + * + * @param {string} text - raw input from the custom interval dialog + * @return {number|null} interval in milliseconds + */ +export const parseRefreshInterval = (text) => { + const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(String(text)); + if (!match) { + return null; + } + const value = parseFloat(match[1]); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + const unit = (match[2] || 's').toLowerCase(); + return Math.round(value * INTERVAL_UNITS[unit]); +}; + +/** + * Render an interval in milliseconds as the shortest readable label. + * + * @param {number} ms - interval in milliseconds + * @return {string} label like "30s", "2m" or "500ms" + */ +export const formatRefreshInterval = (ms) => { + if (ms >= 3_600_000 && ms % 3_600_000 === 0) { + return `${ms / 3_600_000}h`; + } + if (ms >= 60_000 && ms % 60_000 === 0) { + return `${ms / 60_000}m`; + } + if (ms >= 1_000 && ms % 1_000 === 0) { + return `${ms / 1_000}s`; + } + return `${ms}ms`; +}; + export const CollectionInfo = ({ collectionName }) => { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const { client: qdrantClient, isRestricted } = useClient(); @@ -51,9 +104,44 @@ export const CollectionInfo = ({ collectionName }) => { const [createIndexOpen, setCreateIndexOpen] = useState(false); const [refreshIntervalMs, setRefreshIntervalMs] = useState(0); const [refreshAnchorEl, setRefreshAnchorEl] = useState(null); + const [customDialogOpen, setCustomDialogOpen] = useState(false); + const [customInput, setCustomInput] = useState(''); + const [customError, setCustomError] = useState(''); + const [customLabel, setCustomLabel] = useState(null); const refreshMenuOpen = Boolean(refreshAnchorEl); const refreshIntervalLabel = - REFRESH_INTERVAL_OPTIONS.find(({ value }) => value === refreshIntervalMs)?.label || 'Off'; + customLabel ?? (REFRESH_INTERVAL_OPTIONS.find(({ value }) => value === refreshIntervalMs)?.label || 'Off'); + + const openCustomDialog = () => { + setCustomInput(customLabel || formatRefreshInterval(refreshIntervalMs) || ''); + setCustomError(''); + setRefreshAnchorEl(null); + setCustomDialogOpen(true); + }; + + const closeCustomDialog = () => { + setCustomDialogOpen(false); + setCustomError(''); + }; + + const applyCustomInterval = () => { + const ms = parseRefreshInterval(customInput); + if (ms == null) { + setCustomError('Invalid interval. Use a number with an optional unit, e.g. 45s, 2m, 500ms, 1h'); + return; + } + setRefreshIntervalMs(ms); + setCustomLabel(formatRefreshInterval(ms)); + setCustomDialogOpen(false); + setCustomInput(''); + setCustomError(''); + }; + + const selectPresetInterval = (value) => { + setRefreshIntervalMs(value); + setCustomLabel(null); + setRefreshAnchorEl(null); + }; const fetchClusterInfo = (silent = false) => { if (isRestricted) { @@ -203,14 +291,22 @@ export const CollectionInfo = ({ collectionName }) => { setRefreshIntervalMs(value)} + selected={value === refreshIntervalMs && !customLabel} + onClick={() => selectPresetInterval(value)} sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }} > {label} - {value === refreshIntervalMs && } + {value === refreshIntervalMs && !customLabel && } ))} + + Custom… + {customLabel && } + {refreshButton} @@ -251,6 +347,40 @@ export const CollectionInfo = ({ collectionName }) => { /> {clusterInfo && } + + + Custom refresh interval + + { + setCustomInput(e.target.value); + setCustomError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + applyCustomInterval(); + } + }} + error={Boolean(customError)} + helperText={customError || 'Examples: 45s, 2m, 500ms, 1h'} + placeholder="e.g. 45s" + slotProps={{ + htmlInput: { 'aria-label': 'Custom refresh interval value' }, + }} + /> + + + + + + ); }; diff --git a/src/components/Collections/CollectionInfo.test.jsx b/src/components/Collections/CollectionInfo.test.jsx index 2995886f6..a04eb344f 100644 --- a/src/components/Collections/CollectionInfo.test.jsx +++ b/src/components/Collections/CollectionInfo.test.jsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { act } from 'react'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import CollectionInfo from './CollectionInfo'; +import CollectionInfo, { parseRefreshInterval, formatRefreshInterval } from './CollectionInfo'; const { enqueueSnackbarMock, closeSnackbarMock } = vi.hoisted(() => ({ enqueueSnackbarMock: vi.fn(), @@ -51,6 +51,39 @@ const selectInterval = async (label) => { }); }; +describe('parseRefreshInterval', () => { + it('parses values with units', () => { + expect(parseRefreshInterval('45s')).toBe(45_000); + expect(parseRefreshInterval('2m')).toBe(120_000); + expect(parseRefreshInterval('500ms')).toBe(500); + expect(parseRefreshInterval('1h')).toBe(3_600_000); + expect(parseRefreshInterval('1.5m')).toBe(90_000); + expect(parseRefreshInterval(' 10 s ')).toBe(10_000); + }); + + it('treats a bare number as seconds', () => { + expect(parseRefreshInterval('7')).toBe(7_000); + }); + + it('rejects invalid input', () => { + expect(parseRefreshInterval('')).toBeNull(); + expect(parseRefreshInterval('abc')).toBeNull(); + expect(parseRefreshInterval('-5s')).toBeNull(); + expect(parseRefreshInterval('0s')).toBeNull(); + expect(parseRefreshInterval('10x')).toBeNull(); + }); +}); + +describe('formatRefreshInterval', () => { + it('formats to the shortest readable unit', () => { + expect(formatRefreshInterval(45_000)).toBe('45s'); + expect(formatRefreshInterval(120_000)).toBe('2m'); + expect(formatRefreshInterval(3_600_000)).toBe('1h'); + expect(formatRefreshInterval(500)).toBe('500ms'); + expect(formatRefreshInterval(90_000)).toBe('90s'); + }); +}); + describe('CollectionInfo', () => { let client; @@ -141,4 +174,42 @@ describe('CollectionInfo', () => { expect(enqueueSnackbarMock).toHaveBeenCalledWith('boom', expect.anything()); }); -}); \ No newline at end of file + + it('refreshes at a custom typed interval', async () => { + render(); + await flushMicrotasks(); + expect(client.getCollection).toHaveBeenCalledTimes(1); + + await selectInterval('Custom…'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Custom refresh interval value'), { target: { value: '7s' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + }); + + expect(screen.getByLabelText('Auto refresh interval')).toHaveTextContent('7s'); + + await advanceTimers(0); + expect(client.getCollection).toHaveBeenCalledTimes(2); + + await advanceTimers(7_000); + expect(client.getCollection).toHaveBeenCalledTimes(3); + }); + + it('rejects an invalid custom interval', async () => { + render(); + await flushMicrotasks(); + + await selectInterval('Custom…'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Custom refresh interval value'), { target: { value: 'nope' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + }); + + expect(screen.getByText(/Invalid interval/)).toBeInTheDocument(); + expect(client.getCollection).toHaveBeenCalledTimes(1); + }); +}); From 75c7b0827ba2b9fc6e271e35edca3718015480fb Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:44:33 +0200 Subject: [PATCH 5/7] Extract auto refresh control to isolate typing re-renders --- .../Collections/AutoRefreshControl.jsx | 196 ++++++++++++++++++ src/components/Collections/CollectionInfo.jsx | 181 +--------------- .../Collections/CollectionInfo.test.jsx | 19 +- 3 files changed, 217 insertions(+), 179 deletions(-) create mode 100644 src/components/Collections/AutoRefreshControl.jsx diff --git a/src/components/Collections/AutoRefreshControl.jsx b/src/components/Collections/AutoRefreshControl.jsx new file mode 100644 index 000000000..dd8dafa3a --- /dev/null +++ b/src/components/Collections/AutoRefreshControl.jsx @@ -0,0 +1,196 @@ +import React, { memo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Menu, MenuItem, TextField } from '@mui/material'; +import { Check, ChevronDown, Clock } from 'lucide-react'; + +export const REFRESH_INTERVAL_OPTIONS = [ + { value: 0, label: 'Off' }, + { value: 5_000, label: '5s' }, + { value: 10_000, label: '10s' }, + { value: 30_000, label: '30s' }, + { value: 60_000, label: '1m' }, + { value: 300_000, label: '5m' }, +]; + +export const MIN_REFRESH_INTERVAL_MS = 100; + +const INTERVAL_UNITS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; + +/** + * Parse a user-typed refresh interval like "45s", "2m", "500ms" or "1h". + * A bare number is treated as seconds. Returns milliseconds, or null when invalid. + * + * @param {string} text - raw input from the custom interval dialog + * @return {number|null} interval in milliseconds + */ +export const parseRefreshInterval = (text) => { + const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(String(text)); + if (!match) { + return null; + } + const value = parseFloat(match[1]); + if (!Number.isFinite(value) || value <= 0) { + return null; + } + const unit = (match[2] || 's').toLowerCase(); + return Math.round(value * INTERVAL_UNITS[unit]); +}; + +/** + * Render an interval in milliseconds as the shortest readable label. + * + * @param {number} ms - interval in milliseconds + * @return {string} label like "30s", "2m" or "500ms" + */ +export const formatRefreshInterval = (ms) => { + if (ms >= 3_600_000 && ms % 3_600_000 === 0) { + return `${ms / 3_600_000}h`; + } + if (ms >= 60_000 && ms % 60_000 === 0) { + return `${ms / 60_000}m`; + } + if (ms >= 1_000 && ms % 1_000 === 0) { + return `${ms / 1_000}s`; + } + return `${ms}ms`; +}; + +/** + * Header control for the collection info auto refresh interval: a compact + * button with a preset menu and a custom interval dialog. All typing state + * lives here so keystrokes never re-render the parent component. + * + * @param {Object} props - component props + * @param {number} props.value - current interval in milliseconds (0 = off) + * @param {Function} props.onChange - called with the new interval in ms + * @return {JSX.Element} control with menu and dialog + */ +const AutoRefreshControl = ({ value, onChange }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [customDialogOpen, setCustomDialogOpen] = useState(false); + const [customInput, setCustomInput] = useState(''); + const [customError, setCustomError] = useState(''); + const [customLabel, setCustomLabel] = useState(null); + const menuOpen = Boolean(anchorEl); + const label = customLabel ?? (REFRESH_INTERVAL_OPTIONS.find(({ value: v }) => v === value)?.label || 'Off'); + + const openCustomDialog = () => { + setCustomInput(customLabel || formatRefreshInterval(value) || ''); + setCustomError(''); + setAnchorEl(null); + setCustomDialogOpen(true); + }; + + const closeCustomDialog = () => { + setCustomDialogOpen(false); + setCustomError(''); + }; + + const applyCustomInterval = () => { + const ms = parseRefreshInterval(customInput); + if (ms == null) { + setCustomError('Invalid interval. Use a number with an optional unit, e.g. 45s, 2m, 500ms, 1h'); + return; + } + if (ms < MIN_REFRESH_INTERVAL_MS) { + setCustomError(`Interval must be at least ${MIN_REFRESH_INTERVAL_MS}ms`); + return; + } + setCustomLabel(formatRefreshInterval(ms)); + setCustomDialogOpen(false); + setCustomInput(''); + setCustomError(''); + onChange(ms); + }; + + const selectPreset = (ms) => { + setCustomLabel(null); + setAnchorEl(null); + onChange(ms); + }; + + return ( + <> + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + {REFRESH_INTERVAL_OPTIONS.map(({ value: optionValue, label: optionLabel }) => ( + selectPreset(optionValue)} + sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }} + > + {optionLabel} + {optionValue === value && !customLabel && } + + ))} + + Custom… + {customLabel && } + + + + Custom refresh interval + + { + setCustomInput(e.target.value); + setCustomError(''); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + applyCustomInterval(); + } + }} + error={Boolean(customError)} + helperText={customError || 'Examples: 45s, 2m, 500ms, 1h (min 100ms)'} + placeholder="e.g. 45s" + slotProps={{ + htmlInput: { 'aria-label': 'Custom refresh interval value' }, + }} + /> + + + + + + + + ); +}; + +AutoRefreshControl.propTypes = { + value: PropTypes.number.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default memo(AutoRefreshControl); diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx index 9e724dc8b..54a8f7232 100644 --- a/src/components/Collections/CollectionInfo.jsx +++ b/src/components/Collections/CollectionInfo.jsx @@ -1,21 +1,8 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { - Box, - Button, - CardContent, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - IconButton, - Menu, - MenuItem, - TextField, -} from '@mui/material'; +import { Box, CardContent, IconButton } from '@mui/material'; import { keyframes } from '@mui/material/styles'; import RefreshIcon from '@mui/icons-material/Refresh'; -import { Check, ChevronDown, Clock } from 'lucide-react'; import { useClient } from '../../context/client-context'; import { CopyButton } from '../Common/CopyButton'; import ClusterInfo from './CollectionCluster/ClusterInfo'; @@ -38,62 +25,13 @@ import { } from './CollectionInfoKeyRenderer'; import { useJsonViewerTheme } from '../../theme/json-viewer-theme'; import { useAutoRefresh } from '../../hooks/useAutoRefresh'; +import AutoRefreshControl from './AutoRefreshControl'; const spin = keyframes` from { transform: rotate(0deg); } to { transform: rotate(360deg); } `; -const REFRESH_INTERVAL_OPTIONS = [ - { value: 0, label: 'Off' }, - { value: 5_000, label: '5s' }, - { value: 10_000, label: '10s' }, - { value: 30_000, label: '30s' }, - { value: 60_000, label: '1m' }, - { value: 300_000, label: '5m' }, -]; - -const INTERVAL_UNITS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }; - -/** - * Parse a user-typed refresh interval like "45s", "2m", "500ms" or "1h". - * A bare number is treated as seconds. Returns milliseconds, or null when invalid. - * - * @param {string} text - raw input from the custom interval dialog - * @return {number|null} interval in milliseconds - */ -export const parseRefreshInterval = (text) => { - const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$/i.exec(String(text)); - if (!match) { - return null; - } - const value = parseFloat(match[1]); - if (!Number.isFinite(value) || value <= 0) { - return null; - } - const unit = (match[2] || 's').toLowerCase(); - return Math.round(value * INTERVAL_UNITS[unit]); -}; - -/** - * Render an interval in milliseconds as the shortest readable label. - * - * @param {number} ms - interval in milliseconds - * @return {string} label like "30s", "2m" or "500ms" - */ -export const formatRefreshInterval = (ms) => { - if (ms >= 3_600_000 && ms % 3_600_000 === 0) { - return `${ms / 3_600_000}h`; - } - if (ms >= 60_000 && ms % 60_000 === 0) { - return `${ms / 60_000}m`; - } - if (ms >= 1_000 && ms % 1_000 === 0) { - return `${ms / 1_000}s`; - } - return `${ms}ms`; -}; - export const CollectionInfo = ({ collectionName }) => { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const { client: qdrantClient, isRestricted } = useClient(); @@ -103,45 +41,6 @@ export const CollectionInfo = ({ collectionName }) => { const [addMetadataOpen, setAddMetadataOpen] = useState(false); const [createIndexOpen, setCreateIndexOpen] = useState(false); const [refreshIntervalMs, setRefreshIntervalMs] = useState(0); - const [refreshAnchorEl, setRefreshAnchorEl] = useState(null); - const [customDialogOpen, setCustomDialogOpen] = useState(false); - const [customInput, setCustomInput] = useState(''); - const [customError, setCustomError] = useState(''); - const [customLabel, setCustomLabel] = useState(null); - const refreshMenuOpen = Boolean(refreshAnchorEl); - const refreshIntervalLabel = - customLabel ?? (REFRESH_INTERVAL_OPTIONS.find(({ value }) => value === refreshIntervalMs)?.label || 'Off'); - - const openCustomDialog = () => { - setCustomInput(customLabel || formatRefreshInterval(refreshIntervalMs) || ''); - setCustomError(''); - setRefreshAnchorEl(null); - setCustomDialogOpen(true); - }; - - const closeCustomDialog = () => { - setCustomDialogOpen(false); - setCustomError(''); - }; - - const applyCustomInterval = () => { - const ms = parseRefreshInterval(customInput); - if (ms == null) { - setCustomError('Invalid interval. Use a number with an optional unit, e.g. 45s, 2m, 500ms, 1h'); - return; - } - setRefreshIntervalMs(ms); - setCustomLabel(formatRefreshInterval(ms)); - setCustomDialogOpen(false); - setCustomInput(''); - setCustomError(''); - }; - - const selectPresetInterval = (value) => { - setRefreshIntervalMs(value); - setCustomLabel(null); - setRefreshAnchorEl(null); - }; const fetchClusterInfo = (silent = false) => { if (isRestricted) { @@ -267,47 +166,7 @@ export const CollectionInfo = ({ collectionName }) => { triggerOptimizersDisabled={triggerOptimizersDisabled} /> - - setRefreshAnchorEl(null)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} - transformOrigin={{ vertical: 'top', horizontal: 'right' }} - > - {REFRESH_INTERVAL_OPTIONS.map(({ value, label }) => ( - selectPresetInterval(value)} - sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }} - > - {label} - {value === refreshIntervalMs && !customLabel && } - - ))} - - Custom… - {customLabel && } - - + {refreshButton} } @@ -347,40 +206,6 @@ export const CollectionInfo = ({ collectionName }) => { /> {clusterInfo && } - - - Custom refresh interval - - { - setCustomInput(e.target.value); - setCustomError(''); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - applyCustomInterval(); - } - }} - error={Boolean(customError)} - helperText={customError || 'Examples: 45s, 2m, 500ms, 1h'} - placeholder="e.g. 45s" - slotProps={{ - htmlInput: { 'aria-label': 'Custom refresh interval value' }, - }} - /> - - - - - - ); }; diff --git a/src/components/Collections/CollectionInfo.test.jsx b/src/components/Collections/CollectionInfo.test.jsx index a04eb344f..63d598074 100644 --- a/src/components/Collections/CollectionInfo.test.jsx +++ b/src/components/Collections/CollectionInfo.test.jsx @@ -1,7 +1,8 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { act } from 'react'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import CollectionInfo, { parseRefreshInterval, formatRefreshInterval } from './CollectionInfo'; +import CollectionInfo from './CollectionInfo'; +import { parseRefreshInterval, formatRefreshInterval } from './AutoRefreshControl'; const { enqueueSnackbarMock, closeSnackbarMock } = vi.hoisted(() => ({ enqueueSnackbarMock: vi.fn(), @@ -212,4 +213,20 @@ describe('CollectionInfo', () => { expect(screen.getByText(/Invalid interval/)).toBeInTheDocument(); expect(client.getCollection).toHaveBeenCalledTimes(1); }); + + it('rejects a custom interval below the 100ms minimum', async () => { + render(); + await flushMicrotasks(); + + await selectInterval('Custom…'); + await act(async () => { + fireEvent.change(screen.getByLabelText('Custom refresh interval value'), { target: { value: '50ms' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + }); + + expect(screen.getByText(/at least 100ms/)).toBeInTheDocument(); + expect(client.getCollection).toHaveBeenCalledTimes(1); + }); }); From 84a86c6f82d709bc668142c9322f063f8da596ee Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:47:32 +0200 Subject: [PATCH 6/7] Polish auto refresh tests and formatting --- src/components/Collections/CollectionInfo.test.jsx | 3 +++ src/hooks/useAutoRefresh.js | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/Collections/CollectionInfo.test.jsx b/src/components/Collections/CollectionInfo.test.jsx index 63d598074..660cca70e 100644 --- a/src/components/Collections/CollectionInfo.test.jsx +++ b/src/components/Collections/CollectionInfo.test.jsx @@ -12,6 +12,7 @@ const { enqueueSnackbarMock, closeSnackbarMock } = vi.hoisted(() => ({ vi.mock('../../context/client-context', () => { const client = { getCollection: vi.fn().mockResolvedValue({ status: 'green' }), + getCollectionAliases: vi.fn().mockResolvedValue({ aliases: [] }), updateCollection: vi.fn().mockResolvedValue({}), api: vi.fn().mockReturnValue({ collectionClusterInfo: vi.fn().mockResolvedValue({ data: { result: {} } }), @@ -23,6 +24,8 @@ vi.mock('../../context/client-context', () => { }); vi.mock('notistack', () => ({ + enqueueSnackbar: enqueueSnackbarMock, + closeSnackbar: closeSnackbarMock, useSnackbar: () => ({ enqueueSnackbar: enqueueSnackbarMock, closeSnackbar: closeSnackbarMock, diff --git a/src/hooks/useAutoRefresh.js b/src/hooks/useAutoRefresh.js index f914144d3..010f0845e 100644 --- a/src/hooks/useAutoRefresh.js +++ b/src/hooks/useAutoRefresh.js @@ -52,4 +52,4 @@ export function useAutoRefresh({ enabled, intervalMs, onTick }) { }, [enabled, intervalMs]); return { isRefreshing }; -} \ No newline at end of file +} From 37effb6a0db5835599f9f087c115e9e1139d973c Mon Sep 17 00:00:00 2001 From: MarelGuy Date: Sat, 29 Aug 2026 09:55:00 +0200 Subject: [PATCH 7/7] Remove refresh icon spin animation --- src/components/Collections/CollectionInfo.jsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/Collections/CollectionInfo.jsx b/src/components/Collections/CollectionInfo.jsx index 54a8f7232..fcd4d02a2 100644 --- a/src/components/Collections/CollectionInfo.jsx +++ b/src/components/Collections/CollectionInfo.jsx @@ -1,7 +1,6 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { Box, CardContent, IconButton } from '@mui/material'; -import { keyframes } from '@mui/material/styles'; import RefreshIcon from '@mui/icons-material/Refresh'; import { useClient } from '../../context/client-context'; import { CopyButton } from '../Common/CopyButton'; @@ -27,11 +26,6 @@ import { useJsonViewerTheme } from '../../theme/json-viewer-theme'; import { useAutoRefresh } from '../../hooks/useAutoRefresh'; import AutoRefreshControl from './AutoRefreshControl'; -const spin = keyframes` - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -`; - export const CollectionInfo = ({ collectionName }) => { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); const { client: qdrantClient, isRestricted } = useClient(); @@ -91,7 +85,7 @@ export const CollectionInfo = ({ collectionName }) => { refreshAll(); }, [collectionName]); - const { isRefreshing } = useAutoRefresh({ + useAutoRefresh({ enabled: refreshIntervalMs > 0, intervalMs: refreshIntervalMs, onTick: () => refreshAll(true), @@ -142,7 +136,7 @@ export const CollectionInfo = ({ collectionName }) => { sx={{ color: 'text.primary' }} onClick={() => refreshAll()} > - + );