diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Bots.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Bots.spec.ts index fe1a4b195c39..e926c92bcb35 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Bots.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Bots.spec.ts @@ -19,6 +19,7 @@ import { tokenExpirationForDays, tokenExpirationUnlimitedDays, updateBotDetails, + verifyBotSearch, verifyGenerateTokenAPIContract, } from '../../utils/bot'; @@ -48,6 +49,10 @@ test.describe( await updateBotDetails(page); }); + await test.step('Verify bot search works by name and email', async () => { + await verifyBotSearch(page); + }); + await test.step('Verify generateToken API contract', async () => { await verifyGenerateTokenAPIContract(page); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts index 238f185221a8..8a787b159beb 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/bot.ts @@ -15,7 +15,9 @@ import { GlobalSettingOptions } from '../constant/settings'; import { descriptionBox, redirectToHomePage, + searchFromSearchInput, toastNotification, + updateSearchInputAndWait, uuid, } from './common'; import { customFormatDateTime, getEpochMillisForFutureDays } from './dateTime'; @@ -70,9 +72,15 @@ export const createBot = async (page: Page) => { await page.locator(descriptionBox).fill(BOT_DETAILS.description); - const saveResponse = page.waitForResponse('/api/v1/bots'); + const saveResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/bots') && + response.request().method() === 'POST' + ); await page.click('[data-testid="save-user"]'); - await saveResponse; + const createBotResponse = await saveResponse; + + expect(createBotResponse.status()).toBe(201); // Verify bot is getting added in the bots listing page await expect( @@ -115,7 +123,10 @@ export const deleteBot = async (page: Page) => { await toastNotification(page, /deleted successfully!/); - await expect(page.locator('.ant-table-tbody')).not.toContainText(botName); + await page.getByTestId('searchbar').clear(); + await page.getByTestId('searchbar').fill(BOT_DETAILS.updatedBotName); + await waitForAllLoadersToDisappear(page); + await expect(page.getByTestId('search-error-placeholder')).toBeVisible(); }; export const updateBotDetails = async (page: Page) => { @@ -154,6 +165,34 @@ export const updateBotDetails = async (page: Page) => { ).toContainText(BOT_DETAILS.updatedDescription); }; +export const verifyBotSearch = async (page: Page) => { + const searchInput = page.getByTestId('searchbar'); + const createdBotLink = page.getByTestId( + `bot-link-${BOT_DETAILS.updatedBotName}` + ); + + await searchFromSearchInput(page, searchInput, BOT_DETAILS.updatedBotName, { + waitForSearchApi: true, + }); + await expect(createdBotLink).toBeVisible(); + + await searchFromSearchInput(page, searchInput, BOT_DETAILS.botEmail, { + waitForSearchApi: true, + }); + await expect(createdBotLink).toBeVisible(); + + await searchFromSearchInput( + page, + searchInput, + `${BOT_DETAILS.updatedBotName}-no-match`, + { waitForSearchApi: true } + ); + await expect(page.getByTestId('search-error-placeholder')).toBeVisible(); + + await updateSearchInputAndWait(page, searchInput, ''); + await expect(createdBotLink).toBeVisible(); +}; + export const tokenExpirationForDays = async (page: Page) => { await getCreatedBot(page, { botName, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts index 24f2fd6d365f..3c1ed62656ca 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/common.ts @@ -198,6 +198,52 @@ export const clickOutside = async (page: Page) => { }); }; +export const updateSearchInputAndWait = async ( + page: Page, + searchInput: Locator, + value: string +) => { + await searchInput.clear(); + await searchInput.fill(value); + await expect(searchInput).toHaveValue(value); + await waitForAllLoadersToDisappear(page); +}; + +type SearchInputOptions = { + waitForSearchApi?: boolean; + searchApiPattern?: string; +}; + +export const searchFromSearchInput = async ( + page: Page, + searchInput: Locator, + searchTerm: string, + options: SearchInputOptions = {} +) => { + const { + waitForSearchApi = false, + searchApiPattern = '/api/v1/search/query', + } = options; + + const searchResponsePromise = waitForSearchApi + ? page.waitForResponse( + (response) => + response.url().includes(searchApiPattern) && + response.request().method() === 'GET' && + response.url().includes(encodeURIComponent(searchTerm)) + ) + : undefined; + + await updateSearchInputAndWait(page, searchInput, searchTerm); + + if (!searchResponsePromise) { + return; + } + + const searchResponse = await searchResponsePromise; + expect(searchResponse.status()).toBe(200); +}; + export const visitOwnProfilePage = async (page: Page) => { await page.locator('[data-testid="dropdown-profile"] svg').click(); await page.locator('[role="menu"].profile-dropdown').waitFor({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx index 3ff53b359ff2..c52dec0c2081 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Bot/BotListV1/BotListV1.component.tsx @@ -15,8 +15,8 @@ import Icon from '@ant-design/icons/lib/components/Icon'; import { Button, Col, Row, Space, Switch, Tooltip, Typography } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { AxiosError } from 'axios'; -import { isEmpty, lowerCase } from 'lodash'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { ReactComponent as IconDelete } from '../../../../assets/svg/ic-delete.svg'; @@ -27,20 +27,28 @@ import { PAGE_HEADERS } from '../../../../constants/PageHeaders.constant'; import { useLimitStore } from '../../../../context/LimitsProvider/useLimitsStore'; import { ERROR_PLACEHOLDER_TYPE } from '../../../../enums/common.enum'; import { EntityType } from '../../../../enums/entity.enum'; +import { SearchIndex } from '../../../../enums/search.enum'; import { Bot, ProviderType } from '../../../../generated/entity/bot'; +import { User } from '../../../../generated/entity/teams/user'; import { Include } from '../../../../generated/type/include'; import { Paging } from '../../../../generated/type/paging'; import LimitWrapper from '../../../../hoc/LimitWrapper'; import { useAuth } from '../../../../hooks/authHooks'; import { usePaging } from '../../../../hooks/paging/usePaging'; -import { getBots } from '../../../../rest/botsAPI'; +import { getBotByName, getBots } from '../../../../rest/botsAPI'; +import { searchQuery } from '../../../../rest/searchAPI'; +import { formatUsersResponse } from '../../../../utils/APIUtils'; import { getEntityName, highlightSearchText, } from '../../../../utils/EntityUtils'; import { getSettingPageEntityBreadCrumb } from '../../../../utils/GlobalSettingsUtils'; import { getBotsPath } from '../../../../utils/RouterUtils'; -import { stringToHTML } from '../../../../utils/StringsUtils'; +import { getTermQuery } from '../../../../utils/SearchUtils'; +import { + escapeESReservedCharacters, + stringToHTML, +} from '../../../../utils/StringsUtils'; import { showErrorToast } from '../../../../utils/ToastUtils'; import DeleteWidgetModal from '../../../common/DeleteWidget/DeleteWidgetModal'; import ErrorPlaceHolder from '../../../common/ErrorWithPlaceholder/ErrorPlaceHolder'; @@ -54,6 +62,12 @@ import { TitleBreadcrumbProps } from '../../../common/TitleBreadcrumb/TitleBread import PageHeader from '../../../PageHeader/PageHeader.component'; import './bot-list-v1.less'; import { BotListV1Props } from './BotListV1.interfaces'; + +const BOT_SEARCH_PAGE_SIZE = 100; +const BOT_SEARCH_CONCURRENCY = 10; +const MAX_BOT_SEARCH_PAGES = 5; +const MAX_BOT_USER_RESOLUTION = BOT_SEARCH_PAGE_SIZE * MAX_BOT_SEARCH_PAGES; + const BotListV1 = ({ showDeleted, handleAddBotClick, @@ -79,12 +93,221 @@ const BotListV1 = ({ const [handleErrorPlaceholder, setHandleErrorPlaceholder] = useState(false); const [searchedData, setSearchedData] = useState([]); const [searchTerm, setSearchTerm] = useState(''); + const latestSearchRequest = useRef(0); + + const getBotIncludeFilter = useCallback( + () => (showDeleted ? Include.Deleted : Include.NonDeleted), + [showDeleted] + ); const breadcrumbs: TitleBreadcrumbProps['titleLinks'] = useMemo( () => getSettingPageEntityBreadCrumb(GlobalSettingsMenuCategory.BOTS), [] ); + const getBotUserFromUser = (botUser: User): NonNullable => ({ + id: botUser.id, + name: botUser.name, + displayName: botUser.displayName, + fullyQualifiedName: botUser.fullyQualifiedName, + email: botUser.email, + }); + + const enrichBotWithMatchedUser = useCallback( + (bot: Bot, botUser?: User) => { + if (!botUser) { + return bot; + } + + return { + ...bot, + botUser: { + ...(bot.botUser ?? {}), + ...getBotUserFromUser(botUser), + } as Bot['botUser'], + }; + }, + [getBotUserFromUser] + ); + + const enrichBotsWithBotUsers = async (bots: Bot[]) => { + if (!bots.length) { + return bots; + } + + try { + const response = await searchQuery({ + query: '', + pageNumber: 1, + pageSize: bots.length, + includeDeleted: showDeleted, + searchIndex: SearchIndex.USER, + queryFilter: { + bool: { + must: [{ term: { isBot: true } }], + should: bots.map((bot) => ({ + term: { 'name.keyword': bot.name }, + })), + minimum_should_match: 1, + }, + }, + }); + const botUsers = formatUsersResponse(response.hits.hits); + const botUsersByName = new Map( + botUsers.map((botUser) => [botUser.name, botUser]) + ); + + return bots.map((bot) => + enrichBotWithMatchedUser(bot, botUsersByName.get(bot.name)) + ); + } catch { + return bots; + } + }; + + const getBotsByBotUserNames = async (botUserNames: string[]) => { + const include = getBotIncludeFilter(); + const botsByBotUserName = new Map(); + const workerCount = Math.min(BOT_SEARCH_CONCURRENCY, botUserNames.length); + let currentIndex = 0; + + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (currentIndex < botUserNames.length) { + const botIndex = currentIndex; + currentIndex += 1; + const botName = botUserNames[botIndex]; + + try { + const bot = await getBotByName(botName, { + include, + }); + + botsByBotUserName.set(bot.name, bot); + } catch { + continue; + } + } + }) + ); + + return botsByBotUserName; + }; + + const getSearchMatchedBotUsers = async ( + text: string, + queryFilter: Record + ) => { + const usersByName = new Map(); + let pageNumber = 1; + let totalMatches = 0; + + while (pageNumber <= MAX_BOT_SEARCH_PAGES) { + const response = await searchQuery({ + query: text, + pageNumber, + pageSize: BOT_SEARCH_PAGE_SIZE, + includeDeleted: showDeleted, + queryFilter, + searchIndex: SearchIndex.USER, + trackTotalHits: true, + }); + const users = formatUsersResponse(response.hits.hits); + + users.forEach((user) => { + if (user.name) { + usersByName.set(user.name, user); + } + }); + + totalMatches = response.hits.total.value ?? usersByName.size; + if (!users.length || pageNumber * BOT_SEARCH_PAGE_SIZE >= totalMatches) { + break; + } + + pageNumber += 1; + } + + return Array.from(usersByName.values()); + }; + + const searchBots = async (text: string) => { + const getMatchedBots = async (matchedBotUsers: User[]) => { + const matchedBotUserNames = Array.from( + new Set( + matchedBotUsers + .map((botUser) => botUser.name) + .filter((name): name is string => Boolean(name)) + ) + ).slice(0, MAX_BOT_USER_RESOLUTION); + const botsByBotUserName = matchedBotUserNames.length + ? await getBotsByBotUserNames(matchedBotUserNames) + : new Map(); + const matchedBotUsersByName = new Map( + matchedBotUsers.map((botUser) => [botUser.name, botUser]) + ); + + return matchedBotUserNames.flatMap((botUserName) => { + const matchedBot = botsByBotUserName.get(botUserName); + + if (!matchedBot) { + return []; + } + + return [ + enrichBotWithMatchedUser( + matchedBot, + matchedBotUsersByName.get(botUserName) + ), + ]; + }); + }; + + const matchedBotUsers = await getSearchMatchedBotUsers( + text, + getTermQuery({ isBot: true }) + ); + const matchedBots = await getMatchedBots(matchedBotUsers); + + if (matchedBots.length) { + return matchedBots; + } + + const escapedText = escapeESReservedCharacters(text); + const wildcardPattern = `*${escapedText}*`; + const fallbackMatchedBotUsers = await getSearchMatchedBotUsers('*', { + bool: { + must: [{ term: { isBot: true } }], + should: [ + { wildcard: { 'name.keyword': wildcardPattern } }, + { wildcard: { 'displayName.keyword': wildcardPattern } }, + { wildcard: { 'fullyQualifiedName.keyword': wildcardPattern } }, + { wildcard: { 'email.keyword': wildcardPattern } }, + ], + minimum_should_match: 1, + }, + }); + + return getMatchedBots(fallbackMatchedBotUsers); + }; + + const runActiveSearch = async (activeSearchTerm: string) => { + const searchRequestId = ++latestSearchRequest.current; + + try { + const matchedBots = await searchBots(activeSearchTerm); + + if (searchRequestId === latestSearchRequest.current) { + setSearchedData(matchedBots); + } + } catch (error) { + if (searchRequestId === latestSearchRequest.current) { + showErrorToast((error as AxiosError).message); + setSearchedData([]); + } + } + }; + /** * * @param after - Pagination value if passed data will be fetched post cursor value @@ -101,10 +324,18 @@ const BotListV1 = ({ limit: pageSize, include: showDeleted ? Include.Deleted : Include.NonDeleted, }); + const botsWithUsers = await enrichBotsWithBotUsers(data); + const activeSearchTerm = searchTerm.trim(); + handlePagingChange(paging); - setBotUsers(data); - setSearchedData(data); - if (!showDeleted && isEmpty(data)) { + setBotUsers(botsWithUsers); + if (activeSearchTerm) { + await runActiveSearch(activeSearchTerm); + } else { + setSearchedData(botsWithUsers); + } + + if (!showDeleted && isEmpty(botsWithUsers)) { setHandleErrorPlaceholder(true); } else { setHandleErrorPlaceholder(false); @@ -188,7 +419,7 @@ const BotListV1 = ({ }, }, ], - [] + [t, searchTerm, isAdminUser] ); /** @@ -220,24 +451,32 @@ const BotListV1 = ({ fetchBots(showDeleted); }, [selectedUser]); - const handleSearch = (text: string) => { + const handleSearch = async (text: string) => { setSearchTerm(text); - if (text) { - const normalizeText = lowerCase(text); - const matchedData = botUsers.filter( - (bot) => - bot.name.includes(normalizeText) || - bot.displayName?.includes(normalizeText) || - bot.description?.includes(normalizeText) - ); - setSearchedData(matchedData); - } else { + const normalizedSearchTerm = text.trim(); + + if (!normalizedSearchTerm) { + latestSearchRequest.current += 1; + handlePageChange(INITIAL_PAGING_VALUE, { + cursorType: null, + cursorValue: undefined, + }); setSearchedData(botUsers); + setLoading(false); + + return; + } + + const currentSearchRequestId = latestSearchRequest.current + 1; + setLoading(true); + + try { + await runActiveSearch(normalizedSearchTerm); + } finally { + if (latestSearchRequest.current === currentSearchRequestId) { + setLoading(false); + } } - handlePageChange(INITIAL_PAGING_VALUE, { - cursorType: null, - cursorValue: undefined, - }); }; const handleShowDeletedBots = (checked: boolean) => { @@ -351,7 +590,7 @@ const BotListV1 = ({ paging, pagingHandler: handleBotPageChange, onShowSizeChange: handlePageSizeChange, - showPagination, + showPagination: showPagination && !searchTerm.trim(), }} dataSource={searchedData} loading={loading} diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/botsAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/botsAPI.ts index aeb271b9e696..8b2272fd2697 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/botsAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/botsAPI.ts @@ -16,6 +16,8 @@ import axiosClient from '.'; import { CreateBot } from '../generated/api/createBot'; import { Bot } from '../generated/entity/bot'; import { Include } from '../generated/type/include'; +import { ListParams } from '../interface/API.interface'; +import { getEncodedFqn } from '../utils/StringsUtils'; import { Paging } from '../generated/type/paging'; const BASE_URL = '/bots'; @@ -46,3 +48,12 @@ export const createBot = async (data: CreateBot) => { return response.data; }; + +export const getBotByName = async (name: string, params?: ListParams) => { + const response = await axiosClient.get( + `${BASE_URL}/name/${getEncodedFqn(name)}`, + { params } + ); + + return response.data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/userAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/userAPI.ts index 7e8c6209627a..de5a529d1464 100644 --- a/openmetadata-ui/src/main/resources/ui/src/rest/userAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/rest/userAPI.ts @@ -149,15 +149,6 @@ export const getAuthMechanismForBotUser = async (botId: string) => { return response.data; }; -export const getBotByName = async (name: string, params?: ListParams) => { - const response = await APIClient.get( - `/bots/name/${getEncodedFqn(name)}`, - { params } - ); - - return response.data; -}; - export const updateBotDetail = async (id: string, data: Operation[]) => { const response = await APIClient.patch>( `/bots/${id}`,