123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855 |
- 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<Connection[]>([]);
- 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 (
- <form onSubmit={handleConnect}>
- <Box
- sx={{
- display: 'flex',
- flexDirection: 'column',
- padding: (theme: Theme) => theme.spacing(0, 3),
- position: 'relative',
- height: '100%',
- }}
- >
- <Box
- sx={{
- textAlign: 'left',
- alignSelf: 'flex-start',
- padding: (theme: Theme) => theme.spacing(2, 0, 1.5),
- '& svg': {
- fontSize: 16,
- marginLeft: (theme: Theme) => theme.spacing(0.5),
- color: 'primary.main',
- },
- }}
- >
- <Typography
- variant="h4"
- component="h4"
- sx={{
- fontSize: 20,
- fontWeight: 600,
- color: 'text.primary',
- }}
- >
- {commonTrans('attu.connectTitle')}
- <CustomToolTip title={commonTrans('attu.connectionTip')}>
- <Icons.info />
- </CustomToolTip>
- </Typography>
- </Box>
- {/* Replace address input with Autocomplete */}
- <Autocomplete
- freeSolo={true}
- options={connections}
- getOptionLabel={option =>
- 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 => (
- <TextField
- {...params}
- label={commonTrans('attu.address')}
- variant="filled"
- required
- error={validation.address?.result}
- helperText={validation.address?.errText}
- sx={{
- margin: (theme: Theme) => 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 (
- <React.Fragment key={`${option.address}-${option.database}`}>
- <Box
- component="li"
- {...otherProps}
- sx={{
- display: 'flex',
- justifyContent: 'space-between',
- fontSize: '14px',
- '&:hover': {
- backgroundColor: (theme: Theme) =>
- theme.palette.action.hover,
- },
- }}
- >
- <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
- <Icons.link sx={{ fontSize: 16 }} />
- <Typography>
- {option.address}/{option.database}
- </Typography>
- {(option.username || option.password || option.token) && (
- <Icons.key
- sx={{
- fontSize: 14,
- color: 'text.secondary',
- ml: 0.5,
- }}
- />
- )}
- </Box>
- {option.time !== -1 &&
- !FIXED_CONNECTIONS.some(
- fixed =>
- fixed.address === option.address &&
- fixed.database === option.database
- ) && (
- <Box
- sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
- >
- <Typography
- sx={{
- fontSize: 11,
- color: 'text.secondary',
- fontStyle: 'italic',
- }}
- >
- {new Date(option.time).toLocaleString()}
- </Typography>
- <CustomIconButton
- onClick={e => {
- e.stopPropagation();
- handleDeleteConnection(option);
- }}
- sx={{ padding: '4px' }}
- >
- <Icons.cross sx={{ fontSize: 14 }} />
- </CustomIconButton>
- </Box>
- )}
- </Box>
- <Box
- component="li"
- onClick={handleClearAllHistory}
- sx={{
- display: 'flex',
- alignItems: 'center',
- gap: 1,
- fontSize: '12px',
- borderTop: (theme: Theme) =>
- `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,
- },
- }}
- >
- <Icons.delete sx={{ fontSize: 14 }} />
- <Typography sx={{ fontWeight: 500 }}>
- {commonTrans('attu.clearHistory')}
- </Typography>
- </Box>
- </React.Fragment>
- );
- }
- // Regular connection option
- return (
- <Box
- component="li"
- key={`${option.address}-${option.database}`}
- {...otherProps}
- sx={{
- display: 'flex',
- justifyContent: 'space-between',
- fontSize: '14px',
- padding: (theme: Theme) => theme.spacing(1.5, 2),
- '&:hover': {
- backgroundColor: (theme: Theme) =>
- theme.palette.action.hover,
- },
- }}
- >
- <Box
- sx={{
- display: 'flex',
- alignItems: 'center',
- gap: 1,
- minWidth: 0,
- flex: 1,
- }}
- >
- <Icons.link sx={{ fontSize: 16, flexShrink: 0, mt: 0.5 }} />
- <Typography
- sx={{
- wordBreak: 'break-all',
- lineHeight: 1.5,
- display: 'flex',
- alignItems: 'center',
- gap: 0.5,
- }}
- >
- {option.address}/{option.database}
- {(option.username || option.password || option.token) && (
- <Icons.key
- sx={{
- fontSize: 14,
- color: 'text.secondary',
- flexShrink: 0,
- }}
- />
- )}
- </Typography>
- </Box>
- {option.time !== -1 && (
- <Box
- sx={{
- display: 'flex',
- alignItems: 'center',
- gap: 2,
- minWidth: 200,
- justifyContent: 'flex-end',
- }}
- >
- <Typography
- sx={{
- fontSize: 11,
- color: 'text.secondary',
- fontStyle: 'italic',
- whiteSpace: 'nowrap',
- }}
- >
- {new Date(option.time).toLocaleString()}
- </Typography>
- <CustomIconButton
- onClick={e => {
- e.stopPropagation();
- handleDeleteConnection(option);
- }}
- sx={{
- padding: '4px',
- marginLeft: 1,
- }}
- >
- <Icons.cross sx={{ fontSize: 14 }} />
- </CustomIconButton>
- </Box>
- )}
- </Box>
- );
- }}
- />
- {/* db */}
- <CustomInput
- type="text"
- textConfig={{
- label: `Milvus ${dbTrans('database')} ${commonTrans('attu.optional')}`,
- key: 'database',
- onChange: (value: string) => 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 */}
- <Box
- sx={{
- display: 'flex',
- width: '100%',
- justifyContent: 'flex-start',
- marginTop: (theme: Theme) => theme.spacing(1),
- }}
- >
- <CustomRadio
- checked={withPass}
- label={commonTrans('attu.authentication')}
- handleChange={handleEnableAuth}
- />
- </Box>
- {/* token */}
- {withPass && (
- <>
- <CustomInput
- type="text"
- textConfig={{
- label: `${commonTrans('attu.token')} ${commonTrans('attu.optional')} `,
- key: 'token',
- onChange: (val: string) => 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 */}
- <CustomInput
- type="text"
- textConfig={{
- label: `${commonTrans('attu.username')} ${commonTrans('attu.optional')}`,
- key: 'username',
- onChange: (value: string) =>
- 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 */}
- <CustomInput
- type="text"
- textConfig={{
- label: `${commonTrans('attu.password')} ${commonTrans('attu.optional')}`,
- key: 'password',
- onChange: (value: string) =>
- 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')}
- />
- </>
- )}
- <Box
- sx={{
- marginTop: 'auto',
- padding: (theme: Theme) => theme.spacing(2, 0),
- }}
- >
- <Box
- sx={{
- display: 'flex',
- alignItems: 'center',
- gap: (theme: Theme) => theme.spacing(2),
- }}
- >
- <CustomButton
- type="submit"
- variant="contained"
- disabled={btnDisabled}
- sx={{
- height: 36,
- fontSize: 14,
- fontWeight: 500,
- flex: 1,
- }}
- >
- {btnTrans(isConnecting ? 'connecting' : 'connect')}
- </CustomButton>
- <Box
- sx={{
- display: 'flex',
- alignItems: 'center',
- gap: (theme: Theme) => theme.spacing(1),
- borderLeft: (theme: Theme) =>
- `1px solid ${theme.palette.divider}`,
- paddingLeft: (theme: Theme) => theme.spacing(2),
- }}
- >
- <FormControlLabel
- control={
- <Checkbox
- checked={authReq?.ssl ?? false}
- onChange={e => handleInputChange('ssl', e.target.checked)}
- sx={{
- padding: '4px',
- '&.Mui-checked': {
- color: 'primary.main',
- },
- }}
- />
- }
- label={
- <Typography
- sx={{
- fontSize: 13,
- color: 'text.secondary',
- }}
- >
- {commonTrans('attu.ssl')}
- </Typography>
- }
- />
- <FormControlLabel
- control={
- <Checkbox
- size="small"
- checked={authReq?.checkHealth ?? true}
- onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
- handleInputChange('checkHealth', e.target.checked);
- }}
- sx={{
- padding: '4px',
- '&.Mui-checked': {
- color: 'primary.main',
- },
- }}
- />
- }
- label={
- <Typography
- sx={{
- fontSize: 13,
- color: 'text.secondary',
- }}
- >
- {commonTrans('attu.checkHealth')}
- </Typography>
- }
- />
- </Box>
- </Box>
- </Box>
- </Box>
- </form>
- );
- };
|