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 (
+ <>
+ }
+ endIcon={}
+ onClick={(e) => setAnchorEl(e.currentTarget)}
+ aria-label="Auto refresh interval"
+ aria-haspopup="true"
+ aria-expanded={menuOpen ? 'true' : undefined}
+ sx={{ py: 0.75, mb: 0.2 }}
+ >
+ {label}
+
+
+
+ >
+ );
+};
+
+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 };
+}