import React, { useContext, useEffect, useMemo, useState } from 'react'; import Typography from '@mui/material/Typography'; import Checkbox from '@mui/material/Checkbox'; import { useTranslation } from 'react-i18next'; import CustomButton from '@/components/customButton/CustomButton'; import CustomInput from '@/components/customInput/CustomInput'; import { useFormValidation } from '@/hooks'; import { formatForm } from '@/utils'; import { useNavigate } from 'react-router-dom'; import { rootContext, authContext, dataContext } from '@/context'; import { ATTU_AUTH_HISTORY, MILVUS_DATABASE, MILVUS_SERVERS, MILVUS_URL, } from '@/consts'; import { CustomRadio } from '@/components/customRadio/CustomRadio'; import Icons from '@/components/icons/Icons'; import CustomToolTip from '@/components/customToolTip/CustomToolTip'; import CustomIconButton from '@/components/customButton/CustomIconButton'; import type { AuthReq } from '@server/types'; import FormControlLabel from '@mui/material/FormControlLabel'; import Box from '@mui/material/Box'; import type { Theme } from '@mui/material'; import Autocomplete from '@mui/material/Autocomplete'; import TextField from '@mui/material/TextField'; // Add Connection type definition back type Connection = AuthReq & { time: number; }; // Parse server list from environment variables const parseFixedConnections = (): Connection[] => { const serverList = MILVUS_SERVERS || ''; const defaultUrl = MILVUS_URL || '127.0.0.1:19530'; const defaultDatabase = MILVUS_DATABASE; // If MILVUS_SERVERS exists and is not empty, parse it if (serverList && serverList.trim() !== '') { return serverList.split(',').map((server: string) => { const [address, database] = server.trim().split('/'); return { address: address.trim(), database: database?.trim() || defaultDatabase, token: '', username: '', password: '', ssl: false, checkHealth: true, time: -1, clientId: '', }; }); } // If MILVUS_SERVERS is empty or doesn't exist, use MILVUS_URL return [ { address: defaultUrl, database: defaultDatabase, token: '', username: '', password: '', ssl: false, checkHealth: true, time: -1, clientId: '', }, ]; }; // Get fixed connections from environment variables const FIXED_CONNECTIONS: Connection[] = parseFixedConnections(); export const AuthForm = () => { // context const { openSnackBar } = useContext(rootContext); const { authReq, setAuthReq, login } = useContext(authContext); const { setDatabase } = useContext(dataContext); // i18n const { t: commonTrans } = useTranslation(); const { t: btnTrans } = useTranslation('btn'); const { t: successTrans } = useTranslation('success'); const { t: dbTrans } = useTranslation('database'); // hooks const navigate = useNavigate(); // UI states const [withPass, setWithPass] = useState(false); const [connections, setConnections] = useState([]); const [isConnecting, setIsConnecting] = useState(false); // form validation const checkedForm = useMemo(() => { return formatForm(authReq); }, [authReq]); const { validation, checkIsValid, resetValidation } = useFormValidation(checkedForm); // UI handlers // handle input change const handleInputChange = ( key: | 'address' | 'username' | 'password' | 'ssl' | 'database' | 'token' | 'checkHealth', value: string | boolean ) => { if (key === 'address' && typeof value === 'string') { // Check if address contains database name (format: address/database) const parts = value.split('/'); if (parts.length === 2) { setAuthReq(v => ({ ...v, address: parts[0], database: parts[1], })); return; } } setAuthReq(v => ({ ...v, [key]: value })); }; // handle auth toggle const handleEnableAuth = (val: boolean) => { setWithPass(val); }; // handle connect const handleConnect = async (event: React.FormEvent) => { event.preventDefault(); // set connecting setIsConnecting(true); try { // login const loginParams = { ...authReq }; if (!withPass) { loginParams.username = ''; loginParams.password = ''; loginParams.token = ''; } await login(loginParams); // set database setDatabase(authReq.database); // success message openSnackBar(successTrans('connect')); // get connection history const history = JSON.parse( window.localStorage.getItem(ATTU_AUTH_HISTORY) || '[]' ); // add new connection to history, filter out the same connection const newHistory = [ ...history.filter( (item: any) => item.address !== authReq.address || item.database !== authReq.database ), { address: authReq.address, database: authReq.database, username: authReq.username, password: authReq.password, token: authReq.token, time: Date.now(), checkHealth: authReq.checkHealth, }, ]; // if the count of history connections are more than 16, remove the first one, but it should keep the default one if (newHistory.length > 16) { newHistory.shift(); } // save to local storage window.localStorage.setItem( ATTU_AUTH_HISTORY, JSON.stringify(newHistory) ); // set title document.title = authReq.address ? `${authReq.address} - Attu` : 'Attu'; // redirect to homepage navigate('/'); } catch (error: any) { // if not authorized, show auth inputs if (error.response.data.message.includes('UNAUTHENTICATED')) { handleEnableAuth(true); } } finally { setIsConnecting(false); } }; // connect history clicked const handleClickOnHisotry = (connection: Connection) => { // set auth request setAuthReq(connection); }; const handleDeleteConnection = (connection: Connection) => { // Don't allow deletion of fixed connections if ( FIXED_CONNECTIONS.some( fixed => fixed.address === connection.address && fixed.database === connection.database ) ) { return; } const history = JSON.parse( window.localStorage.getItem(ATTU_AUTH_HISTORY) || '[]' ) as Connection[]; const newHistory = history.filter( item => item.address !== connection.address || item.database !== connection.database ); if (newHistory.length === 0) { newHistory.push(FIXED_CONNECTIONS[0]); } // save to local storage window.localStorage.setItem(ATTU_AUTH_HISTORY, JSON.stringify(newHistory)); // sort by time newHistory.sort((a, b) => { return new Date(b.time).getTime() - new Date(a.time).getTime(); }); // Combine fixed and history connections setConnections([...FIXED_CONNECTIONS, ...newHistory]); }; // Add clear all history handler const handleClearAllHistory = () => { // Save only the default connection const newHistory = [FIXED_CONNECTIONS[0]]; window.localStorage.setItem(ATTU_AUTH_HISTORY, JSON.stringify(newHistory)); // Combine fixed and history connections setConnections([...FIXED_CONNECTIONS, ...newHistory]); // Reset the form to default values setAuthReq(FIXED_CONNECTIONS[0]); }; // is button should be disabled const btnDisabled = authReq.address.trim().length === 0 || isConnecting; // load connection from local storage useEffect(() => { const historyConnections: Connection[] = JSON.parse( window.localStorage.getItem(ATTU_AUTH_HISTORY) || '[]' ); // Start with fixed connections const allConnections = [...FIXED_CONNECTIONS]; // Add history connections, filtering out any that match fixed connections const uniqueHistoryConnections = historyConnections.filter( historyConn => !FIXED_CONNECTIONS.some( fixedConn => fixedConn.address === historyConn.address && fixedConn.database === historyConn.database ) ); allConnections.push(...uniqueHistoryConnections); // Sort by time allConnections.sort((a, b) => { return new Date(b.time).getTime() - new Date(a.time).getTime(); }); setConnections(allConnections); }, []); // UI effect useEffect(() => { // if address contains zilliz, or username or password is not empty // set withpass to true const withPass = (authReq.address.length > 0 && authReq.address.includes('zilliz')) || authReq.username.length > 0 || authReq.password.length > 0; // set with pass setWithPass(withPass); // reset form resetValidation(formatForm(authReq)); }, [authReq.address, authReq.username, authReq.password]); return (
theme.spacing(0, 3), position: 'relative', height: '100%', }} > theme.spacing(2, 0, 1.5), '& svg': { fontSize: 16, marginLeft: (theme: Theme) => theme.spacing(0.5), color: 'primary.main', }, }} > {commonTrans('attu.connectTitle')} {/* Replace address input with Autocomplete */} typeof option === 'string' ? option : `${option.address}/${option.database}` } value={connections.find(c => c.address === authReq.address) || null} onChange={(event, newValue) => { if (newValue) { if (typeof newValue === 'string') { // Handle free text input const [address, database] = newValue.split('/'); setAuthReq(v => ({ ...v, address: address.trim(), database: database?.trim() || MILVUS_DATABASE, })); } else { handleClickOnHisotry(newValue); } } }} onInputChange={(event, newInputValue) => { // Only update if the input matches a valid connection const matchingConnection = connections.find( c => `${c.address}/${c.database}`.toLowerCase() === newInputValue.toLowerCase() ); if (matchingConnection) { handleClickOnHisotry(matchingConnection); } else if (newInputValue) { // Handle free text input const [address, database] = newInputValue.split('/'); setAuthReq(v => ({ ...v, address: address.trim(), database: database?.trim() || MILVUS_DATABASE, })); } }} filterOptions={(options, state) => { // Only filter when there's input text if (!state.inputValue) { return options; } return options.filter(option => `${option.address}/${option.database}` .toLowerCase() .includes(state.inputValue.toLowerCase()) ); }} renderInput={params => ( theme.spacing(0.5, 0), '& .MuiFilledInput-root': { backgroundColor: 'background.default', '&:hover': { backgroundColor: 'action.hover', }, '&.Mui-focused': { backgroundColor: 'background.default', }, }, }} /> )} ListboxProps={{ sx: { '& .MuiAutocomplete-listbox': { padding: 0, '& li': { padding: (theme: Theme) => theme.spacing(1.5, 2), '&:not(:last-child)': { borderBottom: (theme: Theme) => `1px solid ${theme.palette.divider}`, }, }, }, }, }} renderOption={(props, option) => { // Extract key from props const { key, ...otherProps } = props; // If it's the last option and there are multiple connections, add clear history option if ( option === connections[connections.length - 1] && connections.length > 1 ) { return ( theme.palette.action.hover, }, }} > {option.address}/{option.database} {(option.username || option.password || option.token) && ( )} {option.time !== -1 && !FIXED_CONNECTIONS.some( fixed => fixed.address === option.address && fixed.database === option.database ) && ( {new Date(option.time).toLocaleString()} { e.stopPropagation(); handleDeleteConnection(option); }} sx={{ padding: '4px' }} > )} `1px solid ${theme.palette.divider}`, color: 'error.main', cursor: 'pointer', padding: (theme: Theme) => theme.spacing(1), marginTop: (theme: Theme) => theme.spacing(1), backgroundColor: (theme: Theme) => theme.palette.background.default, '&:hover': { backgroundColor: (theme: Theme) => theme.palette.action.hover, }, }} > {commonTrans('attu.clearHistory')} ); } // Regular connection option return ( theme.spacing(1.5, 2), '&:hover': { backgroundColor: (theme: Theme) => theme.palette.action.hover, }, }} > {option.address}/{option.database} {(option.username || option.password || option.token) && ( )} {option.time !== -1 && ( {new Date(option.time).toLocaleString()} { e.stopPropagation(); handleDeleteConnection(option); }} sx={{ padding: '4px', marginLeft: 1, }} > )} ); }} /> {/* db */} handleInputChange('database', value), variant: 'filled', sx: { margin: (theme: Theme) => theme.spacing(0.5, 0), '& .MuiFilledInput-root': { backgroundColor: 'background.default', '&:hover': { backgroundColor: 'action.hover', }, '&.Mui-focused': { backgroundColor: 'background.default', }, }, }, placeholder: dbTrans('database'), fullWidth: true, value: authReq.database, }} checkValid={checkIsValid} validInfo={validation} key={commonTrans('attu.database')} /> {/* toggle auth */} theme.spacing(1), }} > {/* token */} {withPass && ( <> handleInputChange('token', val), variant: 'filled', sx: { margin: (theme: Theme) => theme.spacing(0.5, 0), '& .MuiFilledInput-root': { backgroundColor: 'background.default', '&:hover': { backgroundColor: 'action.hover', }, '&.Mui-focused': { backgroundColor: 'background.default', }, }, }, placeholder: commonTrans('attu.token'), fullWidth: true, value: authReq.token, }} checkValid={checkIsValid} validInfo={validation} key={commonTrans('attu.token')} /> {/* user */} handleInputChange('username', value), variant: 'filled', sx: { margin: (theme: Theme) => theme.spacing(0.5, 0), '& .MuiFilledInput-root': { backgroundColor: 'background.default', '&:hover': { backgroundColor: 'action.hover', }, '&.Mui-focused': { backgroundColor: 'background.default', }, }, }, placeholder: commonTrans('attu.username'), fullWidth: true, value: authReq.username, }} checkValid={checkIsValid} validInfo={validation} key={commonTrans('attu.username')} /> {/* pass */} handleInputChange('password', value), variant: 'filled', sx: { margin: (theme: Theme) => theme.spacing(0.5, 0), '& .MuiFilledInput-root': { backgroundColor: 'background.default', '&:hover': { backgroundColor: 'action.hover', }, '&.Mui-focused': { backgroundColor: 'background.default', }, }, }, placeholder: commonTrans('attu.password'), fullWidth: true, type: 'password', value: authReq.password, }} checkValid={checkIsValid} validInfo={validation} key={commonTrans('attu.password')} /> )} theme.spacing(2, 0), }} > theme.spacing(2), }} > {btnTrans(isConnecting ? 'connecting' : 'connect')} theme.spacing(1), borderLeft: (theme: Theme) => `1px solid ${theme.palette.divider}`, paddingLeft: (theme: Theme) => theme.spacing(2), }} > handleInputChange('ssl', e.target.checked)} sx={{ padding: '4px', '&.Mui-checked': { color: 'primary.main', }, }} /> } label={ {commonTrans('attu.ssl')} } /> ) => { handleInputChange('checkHealth', e.target.checked); }} sx={{ padding: '4px', '&.Mui-checked': { color: 'primary.main', }, }} /> } label={ {commonTrans('attu.checkHealth')} } />
); };