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 8842acc33..fcd4d02a2 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, CardContent, IconButton, Tooltip } from '@mui/material'; +import { Box, CardContent, IconButton } from '@mui/material'; import RefreshIcon from '@mui/icons-material/Refresh'; import { useClient } from '../../context/client-context'; import { CopyButton } from '../Common/CopyButton'; @@ -23,6 +23,8 @@ import { buildPathMap, } from './CollectionInfoKeyRenderer'; import { useJsonViewerTheme } from '../../theme/json-viewer-theme'; +import { useAutoRefresh } from '../../hooks/useAutoRefresh'; +import AutoRefreshControl from './AutoRefreshControl'; export const CollectionInfo = ({ collectionName }) => { const { enqueueSnackbar, closeSnackbar } = useSnackbar(); @@ -32,8 +34,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 +50,9 @@ export const CollectionInfo = ({ collectionName }) => { }); }) .catch((err) => { - enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + if (!silent) { + enqueueSnackbar(err.message, getSnackbarOptions('error', closeSnackbar)); + } }); }; @@ -56,7 +61,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 +70,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]); + useAutoRefresh({ + enabled: refreshIntervalMs > 0, + intervalMs: refreshIntervalMs, + onTick: () => refreshAll(true), + }); + const triggerOptimizers = () => { qdrantClient .updateCollection(collectionName, { @@ -116,6 +129,17 @@ export const CollectionInfo = ({ collectionName }) => { const metadata = collection.config?.metadata; + const refreshButton = ( + refreshAll()} + > + + + ); + return ( { triggerOptimizersDisabled={triggerOptimizersDisabled} /> - - - - - + + {refreshButton} } > diff --git a/src/components/Collections/CollectionInfo.test.jsx b/src/components/Collections/CollectionInfo.test.jsx new file mode 100644 index 000000000..660cca70e --- /dev/null +++ b/src/components/Collections/CollectionInfo.test.jsx @@ -0,0 +1,235 @@ +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 { parseRefreshInterval, formatRefreshInterval } from './AutoRefreshControl'; + +const { enqueueSnackbarMock, closeSnackbarMock } = vi.hoisted(() => ({ + enqueueSnackbarMock: vi.fn(), + closeSnackbarMock: vi.fn(), +})); + +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: {} } }), + }), + }; + return { + useClient: () => ({ client, isRestricted: false }), + }; +}); + +vi.mock('notistack', () => ({ + enqueueSnackbar: enqueueSnackbarMock, + closeSnackbar: closeSnackbarMock, + 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.click(screen.getByLabelText('Auto refresh interval')); + }); + await act(async () => { + fireEvent.click(screen.getByRole('menuitem', { name: 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; + + 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()); + }); + + 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); + }); + + 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); + }); +}); diff --git a/src/hooks/useAutoRefresh.js b/src/hooks/useAutoRefresh.js new file mode 100644 index 000000000..010f0845e --- /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 }; +}