diff --git a/web/packages/shared/components/Search/SearchPanel.tsx b/web/packages/shared/components/Search/SearchPanel.tsx index 32f581b5cf326..521ca6d4a9294 100644 --- a/web/packages/shared/components/Search/SearchPanel.tsx +++ b/web/packages/shared/components/Search/SearchPanel.tsx @@ -40,7 +40,7 @@ export function SearchPanel({ updateSearch: (s: string) => void; pageIndicators?: { from: number; to: number; total: number }; filter: ResourceFilter; - disableSearch: boolean; + disableSearch?: boolean; hideAdvancedSearch?: boolean; extraChildren?: JSX.Element; }) { diff --git a/web/packages/shared/components/TextEditor/TextEditor.mock.tsx b/web/packages/shared/components/TextEditor/TextEditor.mock.tsx new file mode 100644 index 0000000000000..61edbbaa62ecc --- /dev/null +++ b/web/packages/shared/components/TextEditor/TextEditor.mock.tsx @@ -0,0 +1,42 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +jest.mock('./TextEditor', () => { + return { + __esModule: true, + default: MockTextEditor, + }; +}); + +/** + * How to use this? + * + * Import "shared/components/TextEditor/TextEditor.mock" in your test file and + * the mock will be setup for you. It can be used to test the content only, no + * other features are available in the mock. + */ + +function MockTextEditor(props: { data?: [{ content: string }] }) { + return ( +
+ {props.data?.map(d => ( +
{d.content}
+ ))} +
+ ); +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx new file mode 100644 index 0000000000000..a993324dee156 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx @@ -0,0 +1,215 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory } from 'history'; +import { MemoryRouter, Router } from 'react-router'; + +import Box from 'design/Box/Box'; + +import { Route } from 'teleport/components/Router'; +import cfg from 'teleport/config'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + getBotInstanceError, + getBotInstanceSuccess, + listBotInstancesError, + listBotInstancesForever, + listBotInstancesSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstances } from './BotInstances'; + +const meta = { + title: 'Teleport/BotInstances', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +const listBotInstancesSuccessHandler = listBotInstancesSuccess({ + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'aws:g49dh27dhjm3', + join_method_latest: 'ec2', + os_latest: 'linux', + version_latest: '1.3.2', + }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + }, + ], + next_page_token: '', +}); + +export const Happy: Story = { + parameters: { + msw: { + handlers: [ + listBotInstancesSuccessHandler, + getBotInstanceSuccess({ + bot_instance: { + spec: { + instance_id: 'a55259e8-9b17-466f-9d37-ab390ca4024e', + }, + }, + yaml: 'kind: bot_instance\nversion: v1\n', + }), + ], + }, + }, +}; + +export const ErrorLoadingList: Story = { + parameters: { + msw: { + handlers: [listBotInstancesError(500, 'something went wrong')], + }, + }, +}; + +export const StillLoadingList: Story = { + parameters: { + msw: { + handlers: [listBotInstancesForever()], + }, + }, +}; + +export const NoListPermission: Story = { + args: { + hasBotInstanceListPermission: false, + }, + parameters: { + msw: { + handlers: [ + listBotInstancesError( + 500, + 'this call should never be made without permissions' + ), + ], + }, + }, +}; + +export const NoReadPermission: Story = { + args: { + hasBotInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [ + listBotInstancesSuccessHandler, + getBotInstanceError( + 500, + 'this call should never be made without permissions' + ), + ], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { + hasBotInstanceListPermission?: boolean; + hasBotInstanceReadPermission?: boolean; +}) { + const { + hasBotInstanceListPermission = true, + hasBotInstanceReadPermission = true, + } = props ?? {}; + + const history = createMemoryHistory({ + initialEntries: ['/web/bots/instances'], + }); + + const customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + list: hasBotInstanceListPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + return ( + + + + + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index b073a9ca58f57..66d44e62a2b96 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -17,14 +17,14 @@ */ import { QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory } from 'history'; import { setupServer } from 'msw/node'; import { PropsWithChildren } from 'react'; -import { MemoryRouter } from 'react-router'; +import { MemoryRouter, Route, Router } from 'react-router'; import { darkTheme } from 'design/theme'; import { ConfiguredThemeProvider } from 'design/ThemeProvider'; import { - fireEvent, render, screen, testQueryClient, @@ -34,14 +34,18 @@ import { } from 'design/utils/testing'; import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; +import cfg from 'teleport/config'; import { createTeleportContext } from 'teleport/mocks/contexts'; import { listBotInstances } from 'teleport/services/bot/bot'; -import { makeAcl } from 'teleport/services/user/makeAcl'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { + getBotInstanceSuccess, listBotInstancesError, listBotInstancesSuccess, } from 'teleport/test/helpers/botInstances'; +import 'shared/components/TextEditor/TextEditor.mock'; + import { ContextProvider } from '..'; import { BotInstances } from './BotInstances'; @@ -51,6 +55,9 @@ jest.mock('teleport/services/bot/bot', () => { listBotInstances: jest.fn((...all) => { return actual.listBotInstances(...all); }), + getBotInstance: jest.fn((...all) => { + return actual.getBotInstance(...all); + }), }; }); @@ -83,11 +90,11 @@ describe('BotInstances', () => { }) ); - render(, { wrapper: makeWrapper() }); + renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('No active instances found')).toBeInTheDocument(); + expect(screen.getByText('No active instances')).toBeInTheDocument(); expect( screen.getByText( 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' @@ -96,13 +103,13 @@ describe('BotInstances', () => { }); it('Shows an error state', async () => { - server.use(listBotInstancesError(500, 'server error')); + server.use(listBotInstancesError(500, 'something went wrong')); - render(, { wrapper: makeWrapper() }); + renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('Error: server error')).toBeInTheDocument(); + expect(screen.getByText('something went wrong')).toBeInTheDocument(); }); it('Shows an unsupported sort error state', async () => { @@ -110,11 +117,11 @@ describe('BotInstances', () => { 'unsupported sort, only bot_name:asc is supported, but got "blah" (desc = true)'; server.use(listBotInstancesError(400, testErrorMessage)); - render(, { wrapper: makeWrapper() }); + const { user } = renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText(`Error: ${testErrorMessage}`)).toBeInTheDocument(); + expect(screen.getByText(testErrorMessage)).toBeInTheDocument(); server.use( listBotInstancesSuccess({ @@ -123,30 +130,22 @@ describe('BotInstances', () => { }) ); - const resetButton = screen.getByText('Reset sort'); - expect(resetButton).toBeInTheDocument(); - fireEvent.click(resetButton); + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally - await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + const resetButton = screen.getByRole('button', { name: 'Reset sort' }); + await user.click(resetButton); - expect( - screen.queryByText(`Error: ${testErrorMessage}`) - ).not.toBeInTheDocument(); + expect(screen.queryByText(testErrorMessage)).not.toBeInTheDocument(); }); it('Shows an unauthorised error state', async () => { - render(, { - wrapper: makeWrapper( - makeAcl({ - botInstances: { - list: false, - create: true, - edit: true, - remove: true, - read: true, - }, - }) - ), + renderComponent({ + customAcl: makeAcl({ + botInstances: { + ...defaultAccess, + list: false, + }, + }), }); expect( @@ -168,7 +167,7 @@ describe('BotInstances', () => { instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', active_at_latest: '2025-05-19T07:32:00Z', host_name_latest: 'test-hostname', - join_method_latest: 'test-join-method', + join_method_latest: 'github', version_latest: '1.0.0-dev-a12b3c', }, { @@ -180,19 +179,76 @@ describe('BotInstances', () => { }) ); - render(, { wrapper: makeWrapper() }); + renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - expect(screen.getByText('test-bot-1')).toBeInTheDocument(); - expect(screen.getByText('5e885c6')).toBeInTheDocument(); + expect(screen.getByText('test-bot-1/5e885c6')).toBeInTheDocument(); expect(screen.getByText('28 minutes ago')).toBeInTheDocument(); expect(screen.getByText('test-hostname')).toBeInTheDocument(); - expect(screen.getByText('test-join-method')).toBeInTheDocument(); + expect(screen.getByTestId('res-icon-github')).toBeInTheDocument(); expect(screen.getByText('v1.0.0-dev-a12b3c')).toBeInTheDocument(); }); + it('Selects an item', async () => { + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + + server.use( + listBotInstancesSuccess({ + bot_instances: [ + { + bot_name: 'test-bot-1', + instance_id: '5e885c66-1af3-4a36-987d-a604d8ee49d2', + active_at_latest: '2025-05-19T07:32:00Z', + host_name_latest: 'test-hostname', + join_method_latest: 'github', + version_latest: '1.0.0-dev-a12b3c', + }, + { + bot_name: 'test-bot-2', + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + ], + next_page_token: '', + }) + ); + + server.use( + getBotInstanceSuccess({ + bot_instance: { + spec: { + instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', + }, + }, + yaml: 'kind: bot_instance\nversion: v1\n', + }) + ); + + const { user } = renderComponent(); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect( + screen.queryByRole('heading', { name: 'Resource YAML' }) + ).not.toBeInTheDocument(); + + const item2 = screen.getByRole('listitem', { + name: 'test-bot-2/3c3aae3e-de25-4824-a8e9-5a531862f19a', + }); + await user.click(item2); + + expect( + screen.getByRole('heading', { name: 'Resource YAML' }) + ).toBeInTheDocument(); + + expect( + screen.getByText('kind: bot_instance version: v1') + ).toBeInTheDocument(); + }); + it('Allows paging', async () => { + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -200,7 +256,7 @@ describe('BotInstances', () => { bot_instances: [ { bot_name: `test-bot`, - instance_id: `00000000-0000-4000-0000-000000000000`, + instance_id: crypto.randomUUID(), active_at_latest: `2025-05-19T07:32:00Z`, host_name_latest: 'test-hostname', join_method_latest: 'test-join-method', @@ -214,73 +270,61 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(0); - render(, { wrapper: makeWrapper() }); + const { user } = renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const [nextButton] = screen.getAllByTitle('Next page'); + const moreAction = screen.getByRole('button', { name: 'Load More' }); expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '.next', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '.next.next', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - - const [prevButton] = screen.getAllByTitle('Previous page'); - - await waitFor(() => expect(prevButton).toBeEnabled()); - fireEvent.click(prevButton); - - // This page's data will have been cached - expect(listBotInstances).toHaveBeenCalledTimes(3); - - await waitFor(() => expect(prevButton).toBeEnabled()); - fireEvent.click(prevButton); - - // This page's data will have been cached - expect(listBotInstances).toHaveBeenCalledTimes(3); }); it('Allows filtering (search)', async () => { + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -288,7 +332,7 @@ describe('BotInstances', () => { bot_instances: [ { bot_name: `test-bot`, - instance_id: `00000000-0000-4000-0000-000000000000`, + instance_id: crypto.randomUUID(), active_at_latest: `2025-05-19T07:32:00Z`, host_name_latest: 'test-hostname', join_method_latest: 'test-join-method', @@ -301,56 +345,56 @@ describe('BotInstances', () => { ); expect(listBotInstances).toHaveBeenCalledTimes(0); - - render(, { wrapper: makeWrapper() }); + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - const [nextButton] = screen.getAllByTitle('Next page'); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + const moreAction = screen.getByRole('button', { name: 'Load More' }); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '.next', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - jest.useRealTimers(); // Required as user.type() uses setTimeout internally - const search = screen.getByPlaceholderText('Search...'); - await waitFor(() => expect(search).toBeEnabled()); - await userEvent.click(search); - await userEvent.paste('test-search-term'); + await userEvent.type(search, 'test-search-term'); await userEvent.type(search, '{enter}'); + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=test-search-term', + }); expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, - pageToken: '', // Search should reset to the first page + pageSize: 32, + pageToken: '', // Should reset to the first page searchTerm: 'test-search-term', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -359,6 +403,8 @@ describe('BotInstances', () => { }); it('Allows filtering (query)', async () => { + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -366,7 +412,7 @@ describe('BotInstances', () => { bot_instances: [ { bot_name: `test-bot`, - instance_id: `00000000-0000-4000-0000-000000000000`, + instance_id: crypto.randomUUID(), active_at_latest: `2025-05-19T07:32:00Z`, host_name_latest: 'test-hostname', join_method_latest: 'test-join-method', @@ -379,57 +425,61 @@ describe('BotInstances', () => { ); expect(listBotInstances).toHaveBeenCalledTimes(0); - - render(, { wrapper: makeWrapper() }); + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - const [nextButton] = screen.getAllByTitle('Next page'); - await waitFor(() => expect(nextButton).toBeEnabled()); - fireEvent.click(nextButton); + const moreAction = screen.getByRole('button', { name: 'Load More' }); + await waitFor(() => expect(moreAction).toBeEnabled()); + await user.click(moreAction); expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '.next', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + const advancedToggle = screen.getByLabelText('Advanced'); + expect(advancedToggle).not.toBeChecked(); + await userEvent.click(advancedToggle); + expect(advancedToggle).toBeChecked(); const search = screen.getByPlaceholderText('Search...'); - await waitFor(() => expect(search).toBeEnabled()); - await userEvent.click(screen.getByLabelText('Advanced')); - await userEvent.click(search); - await userEvent.paste(`status.latest_heartbeat.hostname == "host-1"`); + await userEvent.type(search, 'test-query'); await userEvent.type(search, '{enter}'); + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=test-query&is_advanced=1', + }); expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, - pageToken: '', // Search should reset to the first page - searchTerm: '', - query: `status.latest_heartbeat.hostname == "host-1"`, + pageSize: 32, + pageToken: '', // Should reset to the first page + searchTerm: undefined, + query: 'test-query', sortDir: 'DESC', sortField: 'active_at_latest', }, @@ -438,6 +488,8 @@ describe('BotInstances', () => { }); it('Allows sorting', async () => { + jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => new Promise(resolve => { @@ -459,51 +511,52 @@ describe('BotInstances', () => { expect(listBotInstances).toHaveBeenCalledTimes(0); - render(, { wrapper: makeWrapper() }); + const { user } = renderComponent(); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const lastHeartbeatHeader = screen.getByText('Last heartbeat'); - expect(listBotInstances).toHaveBeenCalledTimes(1); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '', searchTerm: '', - query: '', + query: undefined, sortDir: 'DESC', sortField: 'active_at_latest', }, expect.anything() ); - fireEvent.click(lastHeartbeatHeader); + const dirAction = screen.getByRole('button', { name: 'Sort direction' }); + await user.click(dirAction); expect(listBotInstances).toHaveBeenCalledTimes(2); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '', searchTerm: '', - query: '', + query: undefined, sortDir: 'ASC', sortField: 'active_at_latest', }, expect.anything() ); - const botHeader = screen.getByText('Bot'); - fireEvent.click(botHeader); + const sortFieldAction = screen.getByRole('button', { name: 'Sort by' }); + await user.click(sortFieldAction); + const option = screen.getByRole('menuitem', { name: 'Bot name' }); + await user.click(option); expect(listBotInstances).toHaveBeenCalledTimes(3); expect(listBotInstances).toHaveBeenLastCalledWith( { - pageSize: 30, + pageSize: 32, pageToken: '', searchTerm: '', - query: '', - sortDir: 'DESC', + query: undefined, + sortDir: 'ASC', sortField: 'bot_name', }, expect.anything() @@ -511,17 +564,36 @@ describe('BotInstances', () => { }); }); -function makeWrapper( - customAcl: ReturnType = makeAcl({ - botInstances: { - list: true, - create: true, - edit: true, - remove: true, - read: true, - }, - }) -) { +function renderComponent(options?: { customAcl?: ReturnType }) { + const { + customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: true, + list: true, + }, + }), + } = options ?? {}; + + const user = userEvent.setup(); + const history = createMemoryHistory({ + initialEntries: ['/web/bots/instances'], + }); + return { + ...render(, { + wrapper: makeWrapper({ customAcl, history }), + }), + user, + history, + }; +} + +function makeWrapper(options: { + customAcl: ReturnType; + history: ReturnType; +}) { + const { customAcl, history } = options ?? {}; + return ({ children }: PropsWithChildren) => { const ctx = createTeleportContext({ customAcl, @@ -531,7 +603,11 @@ function makeWrapper( - {children} + + + {children} + + diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index 232204cba3f76..da8dd9d81338d 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -16,21 +16,18 @@ * along with this program. If not, see . */ -import { keepPreviousData, useQuery } from '@tanstack/react-query'; -import { useCallback } from 'react'; +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo, useRef } from 'react'; import { useHistory, useLocation } from 'react-router'; +import styled, { css } from 'styled-components'; import { Alert } from 'design/Alert/Alert'; -import Box from 'design/Box/Box'; -import { SortType } from 'design/DataTable/types'; -import { Indicator } from 'design/Indicator/Indicator'; -import { Mark } from 'design/Mark/Mark'; -import { - InfoExternalTextLink, - InfoGuideButton, - InfoParagraph, - ReferenceLinks, -} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; +import { CardTile } from 'design/CardTile/CardTile'; +import Flex from 'design/Flex/Flex'; +import { Question } from 'design/Icon'; +import Text from 'design/Text'; +import { SearchPanel } from 'shared/components/Search'; +import { InfoGuideButton } from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; import { EmptyState } from 'teleport/Bots/List/EmptyState/EmptyState'; import { @@ -38,154 +35,119 @@ import { FeatureHeader, FeatureHeaderTitle, } from 'teleport/components/Layout/Layout'; -import cfg from 'teleport/config'; import { listBotInstances } from 'teleport/services/bot/bot'; import { BotInstanceSummary } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; -import { BotInstancesList } from './List/BotInstancesList'; +import { BotInstanceDetails } from './Details/BotInstanceDetails'; +import { InfoGuide } from './InfoGuide'; +import { + BotInstancesList, + BotInstancesListControls, +} from './List/BotInstancesList'; export function BotInstances() { const history = useHistory(); const location = useLocation<{ prevPageTokens?: readonly string[] }>(); const queryParams = new URLSearchParams(location.search); - const pageToken = queryParams.get('page') ?? ''; - const searchTerm = queryParams.get('search') ?? ''; const query = queryParams.get('query') ?? ''; + const isAdvancedQuery = queryParams.get('is_advanced') ?? ''; const sortField = queryParams.get('sort_field') || 'active_at_latest'; const sortDir = queryParams.get('sort_dir') || 'DESC'; + const selectedItemId = queryParams.get('selected'); + + const listRef = useRef(null); const ctx = useTeleport(); const flags = ctx.getFeatureFlags(); - const canListInstances = flags.listBotInstances; + const hasListPermission = flags.listBotInstances; - const { isPending, isFetching, isSuccess, isError, error, data } = useQuery({ - enabled: canListInstances, + const { + isSuccess, + data, + isLoading, + isFetchingNextPage, + error, + hasNextPage, + fetchNextPage, + } = useInfiniteQuery({ + enabled: hasListPermission, queryKey: [ 'bot_instances', 'list', - searchTerm, - query, - pageToken, sortField, sortDir, query, + isAdvancedQuery, ], - queryFn: ({ signal }) => + queryFn: ({ pageParam, signal }) => listBotInstances( { - pageSize: 30, - pageToken, - searchTerm, - query, + pageSize: 32, + pageToken: pageParam, sortField, sortDir, + searchTerm: isAdvancedQuery ? undefined : query, + query: isAdvancedQuery ? query : undefined, }, signal ), + initialPageParam: '', + getNextPageParam: data => data?.next_page_token, placeholderData: keepPreviousData, staleTime: 30_000, // Cached pages are valid for 30 seconds }); - const { prevPageTokens = [] } = location.state ?? {}; - const hasNextPage = !!data?.next_page_token; - const hasPrevPage = !!pageToken; - - const handleFetchNext = useCallback(() => { - const search = new URLSearchParams(location.search); - search.set('page', data?.next_page_token ?? ''); - - history.replace( - { - pathname: location.pathname, - search: search.toString(), - }, - { - prevPageTokens: [...prevPageTokens, pageToken], + const handleQueryChange = useCallback( + (query: string, isAdvanced: boolean) => { + const search = new URLSearchParams(location.search); + if (query) { + search.set('query', query); + } else { + search.delete('query'); } - ); - }, [ - data?.next_page_token, - history, - location.pathname, - location.search, - pageToken, - prevPageTokens, - ]); - - const handleFetchPrev = useCallback(() => { - const prevTokens = [...prevPageTokens]; - const nextToken = prevTokens.pop(); - - const search = new URLSearchParams(location.search); - search.set('page', nextToken ?? ''); - - history.replace( - { - pathname: location.pathname, - search: search.toString(), - }, - { - prevPageTokens: prevTokens, + if (isAdvanced) { + search.set('is_advanced', '1'); + } else { + search.delete('is_advanced'); } - ); - }, [history, location.pathname, location.search, prevPageTokens]); - - const handleSearchChange = useCallback( - (term: string) => { - const search = new URLSearchParams(location.search); - search.set('search', term); - search.delete('query'); - search.delete('page'); history.push({ pathname: `${location.pathname}`, search: search.toString(), }); + + listRef.current?.scrollToTop(); }, [history, location.pathname, location.search] ); - const handleQueryChange = useCallback( - (exp: string) => { + const handleSortChanged = useCallback( + (sortField: string, sortDir: string) => { const search = new URLSearchParams(location.search); - search.set('query', exp); - search.delete('search'); - search.delete('page'); + search.set('sort_field', sortField); + search.set('sort_dir', sortDir); - history.push({ - pathname: `${location.pathname}`, + history.replace({ + pathname: location.pathname, search: search.toString(), }); - }, - [history, location.pathname, location.search] - ); - const onItemSelected = useCallback( - (item: BotInstanceSummary) => { - history.push( - cfg.getBotInstanceDetailsRoute({ - botName: item.bot_name, - instanceId: item.instance_id, - }) - ); + listRef.current?.scrollToTop(); }, - [history] + [history, location.pathname, location.search] ); - const sortType: SortType = { - fieldName: sortField, - dir: sortDir.toLowerCase() === 'desc' ? 'DESC' : 'ASC', - }; - - const handleSortChanged = useCallback( - (sortType: SortType) => { + const handleItemSelected = useCallback( + (item: BotInstanceSummary | null) => { const search = new URLSearchParams(location.search); - search.set('sort_field', sortType.fieldName); - search.set('sort_dir', sortType.dir); - search.delete('page'); + if (item) { + search.set('selected', `${item.bot_name}/${item.instance_id}`); + } else { + search.delete('selected'); + } - history.replace({ + history.push({ pathname: location.pathname, search: search.toString(), }); @@ -193,9 +155,15 @@ export function BotInstances() { [history, location.pathname, location.search] ); - const hasUnsupportedSortError = isUnsupportedSortError(error); + const [selectedBotName, selectedInstanceId] = + selectedItemId?.split('/') ?? []; + + const flatData = useMemo( + () => (isSuccess ? data.pages.flatMap(page => page.bot_instances) : null), + [data?.pages, isSuccess] + ); - if (!canListInstances) { + if (!hasListPermission) { return ( @@ -214,103 +182,96 @@ export function BotInstances() { }} /> - {isPending ? ( - - - - ) : undefined} - - {isError && hasUnsupportedSortError ? ( - { - handleSortChanged({ fieldName: 'bot_name', dir: 'ASC' }); - }, + + - {`Error: ${error.message}`} - - ) : undefined} - - {isError && !hasUnsupportedSortError ? ( - {`Error: ${error.message}`} - ) : undefined} - - {isSuccess ? ( - handleQueryChange(query, false)} + updateQuery={query => handleQueryChange(query, true)} /> - ) : undefined} + + + + {selectedItemId ? ( + handleItemSelected(null)} + /> + ) : undefined} + + {!selectedItemId ? ( + + + + Select an instance to see full details. + + + ) : undefined} + + ); } -const InfoGuide = () => ( - - - A{' '} - - Bot Instance - {' '} - identifies a single lineage of{' '} - - bot - {' '} - identities, even through certificate renewals and rejoins. When the{' '} - tbot client first authenticates to a cluster, a Bot Instance - is generated and its UUID is embedded in the returned client identity. - - - Bot Instances track a variety of information about tbot{' '} - instances, including regular heartbeats which include basic information - about the tbot host, like its architecture and OS version. - - - {' '} - Bot Instances have a relatively short lifespan and are set to expire after - the most recent identity issued for that instance will expire. If the{' '} - tbot client associated with a particular Bot Instance renews - or rejoins, the expiration of the bot instance is reset. This is designed - to allow users to list Bot Instances for an accurate view of the number of - active tbot clients interacting with their Teleport cluster. - - - -); +const Container = styled(Flex)` + flex-direction: column; + flex: 1; + overflow: auto; + gap: ${props => props.theme.space[2]}px; +`; + +const ContentContainer = styled(Flex)` + flex: 1; + overflow: auto; + gap: ${props => props.theme.space[2]}px; +`; + +const ListAndDetailsContainer = styled(CardTile)<{ $listOnlyMode: boolean }>` + flex-direction: row; + overflow: auto; + padding: 0; + gap: 0; + margin: ${props => props.theme.space[1]}px; + + ${p => + p.$listOnlyMode + ? css` + min-width: 300px; + max-width: 400px; + ` + : ''} +`; + +const DashboardContainer = styled(Flex)` + flex-direction: column; + overflow: auto; + flex-basis: 100%; + align-items: center; + justify-content: center; +`; -const InfoGuideReferenceLinks = { - BotInstances: { - title: 'What are Bot instances', - href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances', - }, - Bots: { - title: 'What are Bots', - href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bots', - }, - Tctl: { - title: 'Use tctl to manage bot instances', - href: 'https://goteleport.com/docs/reference/cli/tctl/#tctl-bots-instances-add', - }, -}; +const DashboardHelpText = styled(Text)` + color: ${props => props.theme.colors.text.muted}; + text-align: center; +`; -const isUnsupportedSortError = (error: Error | null | undefined) => { - return error?.message && error.message.includes('unsupported sort'); -}; +const QuestionIcon = styled(Question)` + color: ${props => props.theme.colors.text.muted}; +`; diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx new file mode 100644 index 0000000000000..f1aa941c58c0e --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx @@ -0,0 +1,127 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { CardTile } from 'design/CardTile'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + getBotInstanceError, + getBotInstanceForever, + getBotInstanceSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstanceDetails } from './BotInstanceDetails'; + +const meta = { + title: 'Teleport/BotInstances/Details', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = { + parameters: { + msw: { + handlers: [ + getBotInstanceSuccess({ + bot_instance: { + spec: { + instance_id: 'a55259e8-9b17-466f-9d37-ab390ca4024e', + }, + }, + yaml: 'kind: bot_instance\nversion: v1\n', + }), + ], + }, + }, +}; + +export const ErrorLoadingList: Story = { + parameters: { + msw: { + handlers: [getBotInstanceError(500, 'something went wrong')], + }, + }, +}; + +export const StillLoadingList: Story = { + parameters: { + msw: { + handlers: [getBotInstanceForever()], + }, + }, +}; + +export const NoReadPermission: Story = { + args: { + hasBotInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [getBotInstanceError(500, 'this call should never be made')], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { hasBotInstanceReadPermission?: boolean }) { + const { hasBotInstanceReadPermission = true } = props ?? {}; + + const customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + return ( + + + + {}} + /> + + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx index cdf667327e4db..dfcbd90a031eb 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx @@ -17,24 +17,24 @@ */ import { QueryClientProvider } from '@tanstack/react-query'; -import { createMemoryHistory } from 'history'; import { setupServer } from 'msw/node'; -import { PropsWithChildren } from 'react'; -import { MemoryRouter, Router } from 'react-router'; +import { ComponentProps, PropsWithChildren } from 'react'; import darkTheme from 'design/theme/themes/darkTheme'; import { ConfiguredThemeProvider } from 'design/ThemeProvider'; -import { copyToClipboard } from 'design/utils/copyToClipboard'; import { - fireEvent, render, screen, testQueryClient, + userEvent, waitForElementToBeRemoved, } from 'design/utils/testing'; -import { Route } from 'teleport/components/Router'; -import cfg from 'teleport/config'; +import 'shared/components/TextEditor/TextEditor.mock'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { getBotInstanceError, getBotInstanceSuccess, @@ -42,20 +42,6 @@ import { import { BotInstanceDetails } from './BotInstanceDetails'; -jest.mock('shared/components/TextEditor/TextEditor', () => { - return { - __esModule: true, - default: MockTextEditor, - }; -}); - -jest.mock('design/utils/copyToClipboard', () => { - return { - __esModule: true, - copyToClipboard: jest.fn(), - }; -}); - const server = setupServer(); beforeAll(() => { @@ -71,85 +57,19 @@ afterEach(async () => { afterAll(() => server.close()); -const withSuccessResponse = () => { - server.use( - getBotInstanceSuccess({ - bot_instance: { - spec: { - instance_id: '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - }, - }, - yaml: 'kind: bot_instance\nversion: v1\n', - }) - ); -}; - -const withErrorResponse = () => { - server.use(getBotInstanceError(500)); -}; - describe('BotIntanceDetails', () => { - it('Allows back navigation', async () => { - const history = createMemoryHistory({ - initialEntries: [ - '/web/bot/test-bot-name/instance/4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - ], - }); - history.goBack = jest.fn(); - + it('Allows close action', async () => { + const onClose = jest.fn(); withSuccessResponse(); - renderComponent({ history }); + const { user } = renderComponent({ onClose }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - const backButton = screen.getByLabelText('back'); - fireEvent.click(backButton); - - expect(history.goBack).toHaveBeenCalledTimes(1); - }); - - it('Shows the short instance id', async () => { - withSuccessResponse(); - - renderComponent(); - - await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + const closeButton = screen.getByLabelText('close'); + await user.click(closeButton); - expect(screen.getByText('4fa10e6')).toBeInTheDocument(); - }); - - it('Allows the full instance id to be copied', async () => { - withSuccessResponse(); - - renderComponent(); - - await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - - const copyButton = screen.getByLabelText('copy'); - fireEvent.click(copyButton); - - expect(copyToClipboard).toHaveBeenCalledTimes(1); - expect(copyToClipboard).toHaveBeenLastCalledWith( - '4fa10e68-f2e0-4cf9-ad5b-1458febcd827' - ); - }); - - it('Shows a docs link', async () => { - const onClick = jest.fn(e => { - e.preventDefault(); - }); - - withSuccessResponse(); - - renderComponent({ onDocsLinkClicked: onClick }); - - await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); - - const docsButton = screen.getByText('View Documentation'); - fireEvent.click(docsButton); - - expect(onClick).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); }); it('Shows full yaml', async () => { @@ -171,57 +91,92 @@ describe('BotIntanceDetails', () => { await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + }); + + it('Shows a permisison warning', async () => { + withErrorResponse(); + + renderComponent({ + hasBotInstanceReadPermission: false, + }); + expect( - screen.getByText('Error: 500', { exact: false }) + screen.getByText('You do not have permission to read Bot instances', { + exact: false, + }) ).toBeInTheDocument(); + + expect(screen.getByText('bot_instance.read')).toBeInTheDocument(); }); }); -const renderComponent = async (options?: { - history?: ReturnType; - onDocsLinkClicked?: (e: unknown) => void; -}) => { - const { onDocsLinkClicked } = options ?? {}; - render( - , - { - wrapper: makeWrapper(options), - } - ); -}; - -function makeWrapper(options?: { - history?: ReturnType; -}) { +const renderComponent = ( + options?: Partial> & { + hasBotInstanceReadPermission?: boolean; + } +) => { const { - history = createMemoryHistory({ - initialEntries: [ - '/web/bot/test-bot-name/instance/4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - ], - }), + botName = 'test-bot-name', + instanceId = '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', + onClose = jest.fn(), + ...rest } = options ?? {}; + const user = userEvent.setup(); + return { + ...render( + , + { + wrapper: makeWrapper(rest), + } + ), + user, + }; +}; + +function makeWrapper(options?: { hasBotInstanceReadPermission?: boolean }) { + const { hasBotInstanceReadPermission = true } = options ?? {}; + const customAcl = makeAcl({ + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); return (props: PropsWithChildren) => { return ( - - + + - - {props.children} - + {props.children} - - + + ); }; } -function MockTextEditor(props: { data?: [{ content: string }] }) { - return ( -
- {props.data?.map(d => ( -
{d.content}
- ))} -
+const withSuccessResponse = () => { + server.use( + getBotInstanceSuccess({ + bot_instance: { + spec: { + instance_id: '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', + }, + }, + yaml: 'kind: bot_instance\nversion: v1\n', + }) ); -} +}; + +const withErrorResponse = () => { + server.use(getBotInstanceError(500, 'something went wrong')); +}; diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx index da5ae55babc5e..d300edb7de469 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx @@ -16,134 +16,128 @@ * along with this program. If not, see . */ -import { MouseEventHandler, useCallback } from 'react'; -import { useHistory, useLocation, useParams } from 'react-router'; import styled from 'styled-components'; import { Alert } from 'design/Alert/Alert'; import Box from 'design/Box/Box'; -import { ButtonBorder } from 'design/Button/Button'; import ButtonIcon from 'design/ButtonIcon/ButtonIcon'; import Flex from 'design/Flex/Flex'; -import { ArrowLeft } from 'design/Icon/Icons/ArrowLeft'; +import { Cross } from 'design/Icon/Icons/Cross'; import { Indicator } from 'design/Indicator/Indicator'; -import Text from 'design/Text'; +import Text from 'design/Text/Text'; import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; import TextEditor from 'shared/components/TextEditor/TextEditor'; -import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; -import { - FeatureBox, - FeatureHeader, - FeatureHeaderTitle, -} from 'teleport/components/Layout/Layout'; -import cfg from 'teleport/config'; +import useTeleport from 'teleport/useTeleport'; import { useGetBotInstance } from '../hooks'; -const docsUrl = - 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances'; - export function BotInstanceDetails(props: { - onDocsLinkClickedForTesting?: MouseEventHandler; + botName: string; + instanceId: string; + onClose: () => void; }) { - const history = useHistory(); - const location = useLocation(); - const params = useParams<{ - botName: string; - instanceId: string; - }>(); + const { botName, instanceId, onClose } = props; + + const ctx = useTeleport(); + const flags = ctx.getFeatureFlags(); + const hasReadPermission = flags.readBotInstances; const { data, error, isSuccess, isError, isLoading } = useGetBotInstance( - params, { + botName, + instanceId, + }, + { + enabled: hasReadPermission, staleTime: 30_000, // Keep data in the cache for 30 seconds } ); - const handleBackPress = useCallback(() => { - // If location.key is unset, or 'default', this is the first history entry in-app in the session. - if (!location.key || location.key === 'default') { - history.push(cfg.getBotInstancesRoute()); - } else { - history.goBack(); - } - }, [history, location.key]); - return ( - - - - - - + + + + + onClose()} aria-label="close"> + - Bot instance - {isSuccess && data.bot_instance?.spec?.instance_id ? ( - - - - {data.bot_instance.spec.instance_id.substring(0, 7)} - - - - - ) : undefined} + Resource YAML - - - View Documentation - - - - {isLoading ? ( - - - - ) : undefined} - - {isError ? ( - Error: {error.message} - ) : undefined} - - {isSuccess && data.yaml ? ( - - - - ) : undefined} - + + + + {isLoading ? ( + + + + ) : undefined} + + {isError ? ( + + {error.message} + + ) : undefined} + + {!hasReadPermission ? ( + + You do not have permission to read Bot instances. Missing role + permissions: bot_instance.read + + ) : undefined} + + {isSuccess && data.yaml ? ( + + + + ) : undefined} + + ); } -const MonoText = styled(Text)` - font-family: ${({ theme }) => theme.fonts.mono}; +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + border-left-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; + border-left-width: 1px; + border-left-style: solid; +`; + +const TitleContainer = styled(Flex)` + align-items: center; + justify-content: space-between; + height: ${p => p.theme.space[8]}px; + padding-left: ${p => p.theme.space[3]}px; + gap: ${p => p.theme.space[2]}px; +`; + +export const TitleText = styled(Text).attrs({ + as: 'h2', + typography: 'h2', +})``; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; `; -const InstanceId = styled.div` +const ContentContainer = styled.div` display: flex; - align-items: center; - padding-left: ${props => props.theme.space[2]}px; - padding-right: ${props => props.theme.space[2]}px; - height: ${props => props.theme.space[5]}px; - border-radius: ${props => props.theme.space[3]}px; - background-color: ${({ theme }) => theme.colors.interactive.tonal.neutral[0]}; + flex-direction: column; + flex: 1; `; const YamlContaner = styled(Flex)` diff --git a/web/packages/teleport/src/BotInstances/InfoGuide.tsx b/web/packages/teleport/src/BotInstances/InfoGuide.tsx new file mode 100644 index 0000000000000..7df54624989e2 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/InfoGuide.tsx @@ -0,0 +1,82 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import Box from 'design/Box/Box'; +import { Mark } from 'design/Mark/Mark'; +import { + InfoExternalTextLink, + InfoParagraph, + ReferenceLinks, +} from 'shared/components/SlidingSidePanel/InfoGuide/InfoGuide'; + +export function InfoGuide() { + return ( + + + A{' '} + + Bot Instance + {' '} + identifies a single lineage of{' '} + + bot + {' '} + identities, even through certificate renewals and rejoins. When the{' '} + tbot client first authenticates to a cluster, a Bot + Instance is generated and its UUID is embedded in the returned client + identity. + + + Bot Instances track a variety of information about tbot{' '} + instances, including regular heartbeats which include basic information + about the tbot host, like its architecture and OS version. + + + Bot Instances have a relatively short lifespan and are set to expire + after the most recent identity issued for that instance expires. If the{' '} + tbot client associated with a particular Bot Instance + renews or rejoins, the expiration of the bot instance is reset. This is + designed to allow users to list Bot Instances for an accurate view of + the number of active tbot clients interacting with their + Teleport cluster. + + + + ); +} + +const InfoGuideReferenceLinks = { + BotInstances: { + title: 'What are Bot instances', + href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances', + }, + Bots: { + title: 'What are Bots', + href: 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bots', + }, + Tctl: { + title: 'Use tctl to manage bot instances', + href: 'https://goteleport.com/docs/reference/cli/tctl/#tctl-bots-instances-add', + }, +}; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx new file mode 100644 index 0000000000000..5ff2668b219b5 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx @@ -0,0 +1,185 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient } from '@tanstack/react-query'; +import { ComponentProps, useRef, useState } from 'react'; + +import { CardTile } from 'design/CardTile/CardTile'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { BotInstanceSummary } from 'teleport/services/bot/types'; + +import { BotInstancesList, BotInstancesListControls } from './BotInstancesList'; + +const meta = { + title: 'Teleport/BotInstances/List', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); // Prevent cached data sharing between stories + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Happy: Story = {}; + +export const Empty: Story = { + args: { + data: [], + }, +}; + +export const ErrorLoadingList: Story = { + args: { + error: new Error('something went wrong'), + }, +}; + +export const NoMoreToLoad: Story = { + args: { + hasNextPage: false, + }, +}; + +export const LoadingMore: Story = { + args: { + isFetchingNextPage: true, + }, +}; + +export const UnsupportedSort: Story = { + args: { + error: new Error('unsupported sort: something went wrong'), + }, +}; + +export const StillLoadingList: Story = { + args: { + isLoading: true, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper( + props?: Partial< + Pick< + ComponentProps, + 'error' | 'isLoading' | 'hasNextPage' | 'isFetchingNextPage' | 'data' + > + > +) { + const { + data = [ + { + bot_name: 'ansible-worker', + instance_id: `966c0850-9bb5-4ed7-af2d-4b1f202a936a`, + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '2.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: 'ac7135ce-fde6-4a91-bd77-ba7419e1c175', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: '5283f4a9-c49b-4876-be48-b5f83000e612', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, + ], + error = null, + hasNextPage = true, + isFetchingNextPage = false, + isLoading = false, + } = props ?? {}; + + const [allData, setAllData] = useState(data); + const [selected, setSelected] = useState(null); + const [sortField, setSortField] = useState('active_at_latest'); + const [sortDir, setSortDir] = useState<'ASC' | 'DESC'>('ASC'); + + const listRef = useRef(null); + + const ctx = createTeleportContext(); + + return ( + + + { + setSortField(sortField); + setSortDir(sortDir); + listRef.current?.scrollToTop(); + }} + onLoadNextPage={() => + setAllData(existing => { + const newData = (data ?? []).map(i => { + return { + ...i, + instance_id: crypto.randomUUID(), + }; + }); + return [...(existing ?? []), ...newData]; + }) + } + onItemSelected={function (item: BotInstanceSummary | null): void { + setSelected(item ? `${item.bot_name}/${item.instance_id}` : null); + }} + /> + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx new file mode 100644 index 0000000000000..a3e22c58dcfd2 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx @@ -0,0 +1,310 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { QueryClientProvider } from '@tanstack/react-query'; +import { ComponentProps, PropsWithChildren } from 'react'; + +import darkTheme from 'design/theme/themes/darkTheme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + render, + screen, + testQueryClient, + userEvent, +} from 'design/utils/testing'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; + +import { BotInstancesList } from './BotInstancesList'; + +jest.mock('design/utils/copyToClipboard', () => { + return { + __esModule: true, + copyToClipboard: jest.fn(), + }; +}); + +afterEach(async () => { + jest.clearAllMocks(); +}); + +describe('BotIntancesList', () => { + it('renders items', async () => { + renderComponent(); + + expect(screen.getByText('ansible-worker/966c085')).toBeInTheDocument(); + expect(screen.getByText('ansible-worker/ac7135c')).toBeInTheDocument(); + expect(screen.getByText('ansible-worker/5283f4a')).toBeInTheDocument(); + }); + + it('select an item', async () => { + const onItemSelected = jest.fn(); + + const { user } = renderComponent({ + props: { + onItemSelected, + }, + }); + + const item2 = screen.getByRole('listitem', { + name: 'ansible-worker/ac7135ce-fde6-4a91-bd77-ba7419e1c175', + }); + await user.click(item2); + + expect(onItemSelected).toHaveBeenCalledTimes(1); + expect(onItemSelected).toHaveBeenLastCalledWith({ + active_at_latest: '2025-07-22T10:54:00Z', + bot_name: 'ansible-worker', + host_name_latest: 'win-123a', + instance_id: 'ac7135ce-fde6-4a91-bd77-ba7419e1c175', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }); + }); + + it('Shows a loading state', async () => { + renderComponent({ + props: { + isLoading: true, + }, + }); + + expect(screen.getByTestId('loading')).toBeInTheDocument(); + }); + + it('Shows an empty state', async () => { + renderComponent({ + props: { + data: [], + }, + }); + + expect(screen.getByText('No active instances')).toBeInTheDocument(); + expect( + screen.getByText( + 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' + ) + ).toBeInTheDocument(); + }); + + it('Shows an error', async () => { + renderComponent({ + props: { + error: new Error('something went wrong'), + }, + }); + + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + }); + + it('Allows fetch more action', async () => { + const onLoadNextPage = jest.fn(); + + const { user } = renderComponent({ + props: { + hasNextPage: true, + onLoadNextPage, + }, + }); + + const action = screen.getByText('Load More'); + await user.click(action); + + expect(onLoadNextPage).toHaveBeenCalledTimes(1); + }); + + it('Prevents next page action when no page', async () => { + const onLoadNextPage = jest.fn(); + + const { user } = renderComponent({ + props: { + hasNextPage: false, + onLoadNextPage, + }, + }); + + const action = screen.getByText('Load More'); + await user.click(action); + + expect(action).toBeDisabled(); + expect(onLoadNextPage).not.toHaveBeenCalled(); + }); + + it('Prevents next page action when loading next page', async () => { + const onLoadNextPage = jest.fn(); + + const { user } = renderComponent({ + props: { + hasNextPage: true, + isFetchingNextPage: true, + onLoadNextPage, + }, + }); + + const action = screen.getByText('Load More'); + await user.click(action); + + expect(action).toBeDisabled(); + expect(onLoadNextPage).not.toHaveBeenCalled(); + }); + + it('Allows sort change', async () => { + const onSortChanged = jest.fn(); + + const { user } = renderComponent({ + props: { + onSortChanged, + sortField: 'active_at_latest', + sortDir: 'DESC', + }, + }); + + const fieldAction = screen.getByRole('button', { name: 'Sort by' }); + await user.click(fieldAction); + + expect( + screen.getByRole('menuitem', { name: 'Bot name' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: 'Recent' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: 'Hostname' }) + ).toBeInTheDocument(); + const versionOption = screen.getByRole('menuitem', { name: 'Version' }); + await user.click(versionOption); + + expect(onSortChanged).toHaveBeenLastCalledWith('version_latest', 'DESC'); + + const dirAction = screen.getByRole('button', { name: 'Sort direction' }); + await user.click(dirAction); + + // The component under test does not keep sort state so the sort field will + // be 'active_at_latest' on the next change. + expect(onSortChanged).toHaveBeenLastCalledWith('active_at_latest', 'ASC'); + }); + + it('Shows an unsupported sort error', async () => { + const onSortChanged = jest.fn(); + + const { user } = renderComponent({ + props: { + onSortChanged, + sortField: 'active_at_latest', + sortDir: 'DESC', + error: new Error('unsupported sort: foo'), + }, + }); + + expect(screen.getByText('unsupported sort: foo')).toBeInTheDocument(); + + const resetAction = screen.getByRole('button', { name: 'Reset sort' }); + await user.click(resetAction); + + expect(onSortChanged).toHaveBeenLastCalledWith('bot_name', 'ASC'); + }); +}); + +const renderComponent = (options?: { + props: Partial>; +}) => { + const { props } = options ?? {}; + const { + data = mockData, + isLoading = false, + isFetchingNextPage = false, + error = null, + hasNextPage = true, + sortField = 'bot_name', + sortDir = 'ASC', + selectedItem = null, + onSortChanged = jest.fn(), + onLoadNextPage = jest.fn(), + onItemSelected = jest.fn(), + } = props ?? {}; + + const user = userEvent.setup(); + return { + ...render( + , + { + wrapper: makeWrapper(), + } + ), + user, + }; +}; + +function makeWrapper() { + const ctx = createTeleportContext(); + return (props: PropsWithChildren) => { + return ( + + + + {props.children} + + + + ); + }; +} + +const mockData = [ + { + bot_name: 'ansible-worker', + instance_id: `966c0850-9bb5-4ed7-af2d-4b1f202a936a`, + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'my-svc.my-namespace.svc.cluster-domain.example', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '2.4.0', + }, + { + bot_name: 'ansible-worker', + instance_id: 'ac7135ce-fde6-4a91-bd77-ba7419e1c175', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'win-123a', + join_method_latest: 'tpm', + os_latest: 'windows', + version_latest: '4.3.18+ab12hd', + }, + { + bot_name: 'ansible-worker', + instance_id: '5283f4a9-c49b-4876-be48-b5f83000e612', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'mac-007', + join_method_latest: 'kubernetes', + os_latest: 'darwin', + version_latest: '3.9.99', + }, +]; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 3b50734882d61..02e2e1f898f83 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -16,148 +16,231 @@ * along with this program. If not, see . */ -import { format } from 'date-fns/format'; -import { formatDistanceToNowStrict } from 'date-fns/formatDistanceToNowStrict'; -import { parseISO } from 'date-fns/parseISO'; +import React, { forwardRef, useImperativeHandle } from 'react'; import styled from 'styled-components'; -import { Info } from 'design/Alert/Alert'; -import { Cell, LabelCell } from 'design/DataTable/Cells'; -import Table from 'design/DataTable/Table'; -import { FetchingConfig, SortType } from 'design/DataTable/types'; -import Flex from 'design/Flex'; +import { Alert, Info } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import { ButtonSecondary } from 'design/Button/Button'; +import Flex from 'design/Flex/Flex'; +import { Indicator } from 'design/Indicator/Indicator'; import Text from 'design/Text'; -import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; -import { SearchPanel } from 'shared/components/Search'; -import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; +import { SortMenu } from 'shared/components/Controls/SortMenu'; +import { Instance } from 'teleport/Bots/Details/Instance'; import { BotInstanceSummary } from 'teleport/services/bot/types'; -const MonoText = styled(Text)` - font-family: ${({ theme }) => theme.fonts.mono}; -`; +export const BotInstancesList = forwardRef(InternalBotInstancesList); + +export type BotInstancesListControls = { + scrollToTop: () => void; +}; + +function InternalBotInstancesList( + props: { + data: BotInstanceSummary[] | null | undefined; + isLoading: boolean; + isFetchingNextPage: boolean; + error: Error | null | undefined; + hasNextPage: boolean; + sortField: string; + sortDir: 'ASC' | 'DESC'; + selectedItem: string | null; + onSortChanged: (sortField: string, sortDir: 'ASC' | 'DESC') => void; + onLoadNextPage: () => void; + onItemSelected: (item: BotInstanceSummary) => void; + }, + ref: React.RefObject +) { + const { + data, + isLoading, + isFetchingNextPage, + error, + hasNextPage, + sortField, + sortDir, + selectedItem, + onSortChanged, + onLoadNextPage, + onItemSelected, + } = props; + + const contentRef = React.useRef(null); + useImperativeHandle(ref, () => { + return { + scrollToTop() { + contentRef.current?.scrollTo({ top: 0, behavior: 'instant' }); + }, + }; + }, [contentRef]); -export function BotInstancesList({ - data, - fetchStatus, - onFetchNext, - onFetchPrev, - searchTerm, - query, - onSearchChange, - onQueryChange, - onItemSelected, - sortType, - onSortChanged, -}: { - data: BotInstanceSummary[]; - searchTerm: string; - query: string; - onSearchChange: (term: string) => void; - onQueryChange: (term: string) => void; - onItemSelected: (item: BotInstanceSummary) => void; - sortType: SortType; - onSortChanged: (sortType: SortType) => void; -} & Omit) { - const tableData = data.map(x => ({ - ...x, - host_name_latest: x.host_name_latest ?? '-', - instanceIdDisplay: x.instance_id.substring(0, 7), - version_latest: x.version_latest ? `v${x.version_latest}` : '-', - active_at_latest: x.active_at_latest - ? `${formatDistanceToNowStrict(parseISO(x.active_at_latest))} ago` - : '-', - activeAtLocal: x.active_at_latest - ? format(parseISO(x.active_at_latest), 'PP, p z') - : '-', - })); + const hasError = !!error; + const hasData = !hasError && !isLoading; + const hasUnsupportedSortError = isUnsupportedSortError(error); + + const makeOnSelectedCallback = (instance: BotInstanceSummary) => () => { + onItemSelected(instance); + }; return ( - - data={tableData} - fetching={{ - fetchStatus, - onFetchNext, - onFetchPrev, - disableLoadingIndicator: true, - }} - serversideProps={{ - sort: sortType, - setSort: onSortChanged, - serversideSearchPanel: ( - - ), - }} - row={{ - onClick: onItemSelected, - getStyle: () => ({ cursor: 'pointer' }), - }} - columns={[ - { - key: 'bot_name', - headerText: 'Bot', - isSortable: true, - }, - { - key: 'instanceIdDisplay', - headerText: 'ID', - isSortable: false, - render: ({ instance_id, instanceIdDisplay }) => ( - - - {instanceIdDisplay} - - - - ), - }, - { - key: 'join_method_latest', - headerText: 'Method', - isSortable: false, - render: ({ join_method_latest }) => - join_method_latest ? ( - - ) : ( - {'-'} - ), - }, - { - key: 'host_name_latest', - headerText: 'Hostname', - isSortable: true, - }, - { - key: 'version_latest', - headerText: 'Version (tbot)', - isSortable: true, - }, - { - key: 'active_at_latest', - headerText: 'Last heartbeat', - isSortable: true, - render: ({ active_at_latest, activeAtLocal }) => ( - - - {active_at_latest} - - - ), - }, - ]} - emptyText="No active instances found" - emptyButton={ - - Bot instances are ephemeral, and disappear once all issued credentials - have expired. - - } - /> + + + Currently Active + { + onSortChanged(value.fieldName, value.dir); + }} + /> + + + + + {isLoading ? ( + + + + ) : undefined} + + {hasError && hasUnsupportedSortError ? ( + { + onSortChanged('bot_name', 'ASC'); + }, + }} + > + {error.message} + + ) : undefined} + + {hasError && !hasUnsupportedSortError ? ( + + Failed to fetch instances + + ) : undefined} + + {hasData ? ( + <> + {data && data.length > 0 ? ( + + {data.map((instance, i) => ( + + {i === 0 ? undefined : } + + + ))} + + + + + onLoadNextPage()} + disabled={!hasNextPage || isFetchingNextPage} + > + Load More + + + + ) : ( + + No active instances + + Bot instances are ephemeral, and disappear once all issued + credentials have expired. + + + )} + + ) : undefined} + ); } + +const Container = styled.section` + display: flex; + flex-direction: column; + flex: 1; + min-width: 300px; + max-width: 400px; +`; + +const TitleContainer = styled(Flex)` + align-items: center; + justify-content: space-between; + gap: ${p => p.theme.space[2]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + min-height: ${p => p.theme.space[8]}px; +`; + +export const TitleText = styled(Text).attrs({ + as: 'h2', + typography: 'h2', +})``; + +const ContentContainer = styled.div` + overflow: auto; +`; + +const LoadMoreContainer = styled(Flex)` + justify-content: center; + padding: ${props => props.theme.space[3]}px; +`; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; +`; + +const isUnsupportedSortError = (error: Error | null | undefined) => { + return !!error && error.message.includes('unsupported sort'); +}; + +const sortFields = [ + { + value: 'bot_name' as const, + label: 'Bot name', + }, + { + value: 'active_at_latest' as const, + label: 'Recent', + }, + { + value: 'version_latest' as const, + label: 'Version', + }, + { + value: 'host_name_latest' as const, + label: 'Hostname', + }, +]; diff --git a/web/packages/teleport/src/Bots/Details/Instance.story.tsx b/web/packages/teleport/src/Bots/Details/Instance.story.tsx index ed99f2cbe257d..5b1d42e0b2489 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.story.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.story.tsx @@ -57,11 +57,14 @@ export default meta; export const Item: Story = { args: { id: '686750f5-0f21-4a6f-b151-fa11a603701d', + botName: '', activeAt: new Date('2025-07-18T14:54:32Z').getTime(), hostname: 'my-svc.my-namespace.svc.cluster-domain.example', method: 'kubernetes', version: '4.4.0', os: 'linux', + isSelectable: true, + isSelected: false, }, }; @@ -82,26 +85,43 @@ export const ItemWithLongValues: Story = { }, }; +export const ItemWithLongValuesAndBotName: Story = { + args: { + id: 'fa11a603701dfa11a603701dfa11a603701dfa11a603701dfa11a603701dfa113701d', + botName: 'ansible-worker-ansible-worker-ansible-worker-ansible-worker', + activeAt: new Date('2025-07-18T14:54:32Z').getTime(), + hostname: 'hostnamehostnamehostnamehostnamehostnamehostnamehostnamehostnam', + method: 'kubernetes', + version: '4.4.0-fa11a60', + os: 'linux', + }, +}; + type Props = { - id: Parameters[0]['id']; - version?: Parameters[0]['version']; - hostname?: Parameters[0]['hostname']; + id: Parameters[0]['data']['id']; + botName?: Parameters[0]['data']['botName']; + version?: Parameters[0]['data']['version']; + hostname?: Parameters[0]['data']['hostname']; activeAt?: number; - method?: Parameters[0]['method']; - os?: Parameters[0]['os']; + method?: Parameters[0]['data']['method']; + os?: Parameters[0]['data']['os']; + isSelectable?: Parameters[0]['isSelectable']; + isSelected?: Parameters[0]['isSelected']; }; function Wrapper(props: Props) { + const { isSelectable, isSelected, activeAt, ...data } = props; return ( diff --git a/web/packages/teleport/src/Bots/Details/Instance.tsx b/web/packages/teleport/src/Bots/Details/Instance.tsx index 0ada9f58faf9c..740f6ebd31381 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.tsx @@ -20,7 +20,7 @@ import { format } from 'date-fns/format'; import { formatDistanceToNowStrict } from 'date-fns/formatDistanceToNowStrict'; import { parseISO } from 'date-fns/parseISO'; import { ReactElement } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import Flex from 'design/Flex/Flex'; import { ArrowFatLinesUp } from 'design/Icon/Icons/ArrowFatLinesUp'; @@ -32,26 +32,54 @@ import { import { ResourceIcon } from 'design/ResourceIcon'; import Text from 'design/Text/Text'; import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; +import { CopyButton } from 'shared/components/UnifiedResources/shared/CopyButton'; import { useClusterVersion } from '../../useClusterVersion'; import { JoinMethodIcon } from './JoinMethodIcon'; export function Instance(props: { - id: string; - version?: string; - hostname?: string; - activeAt?: string; - method?: string; - os?: string; + data: { + id: string; + botName?: string; + version?: string; + hostname?: string; + activeAt?: string; + method?: string; + os?: string; + }; + isSelectable?: boolean; + isSelected?: boolean; + onSelected?: () => void; }) { - const { id, version, hostname, activeAt, method, os } = props; + const { + data: { id, botName, version, hostname, activeAt, method, os }, + isSelectable, + isSelected, + onSelected, + } = props; const hasHeartbeatData = !!version || !!hostname || !!method || !!os; return ( - + onSelected?.()} + role="listitem" + tabIndex={0} + aria-label={`${botName}/${id}`} + > - {id} + {botName ? ( + + + {botName}/{shortenId(id)} + + + + ) : ( + {id} + )} {activeAt ? ( ` flex-direction: column; padding: ${props => props.theme.space[3]}px; padding-top: ${p => p.theme.space[2]}px; padding-bottom: ${p => p.theme.space[2]}px; background-color: ${p => p.theme.colors.levels.surface}; gap: ${p => p.theme.space[1]}px; + + ${p => + p.$isSelected + ? css` + border-left: ${p.theme.space[1]}px solid + ${p.theme.colors.interactive.solid.primary.default}; + background-color: ${p.theme.colors.interactive.tonal.neutral[0]}; + ` + : ''} + + ${p => + p.$isSelectable + ? css` + cursor: pointer; + + &:hover { + background-color: ${p.theme.colors.interactive.tonal.neutral[0]}; + } + &:active { + background-color: ${p.theme.colors.interactive.tonal.neutral[1]}; + } + ` + : ''} `; const TopRow = styled(Flex)` @@ -142,6 +196,15 @@ const IdText = styled(Text)` white-space: nowrap; `; +const BotNameText = styled(Text)` + white-space: nowrap; +`; + +const BotNameContainer = styled(Flex)` + flex: 1; + overflow: hidden; +`; + const HostnameText = styled(Text).attrs({ typography: 'body3', })` @@ -202,3 +265,7 @@ function Version(props: { version: string | undefined }) { const VersionContainer = styled.div` flex-shrink: 0; `; + +function shortenId(id: string) { + return id.substring(0, 7); +} diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx index 54b8044af3f2a..c0fbaadc39069 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx @@ -130,12 +130,14 @@ export function InstancesPanel(props: { botName: string }) { {i === 0 && j === 0 ? undefined : } )) diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 6a8be00bf93c9..960dbccb628e1 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -188,7 +188,6 @@ const cfg = { bots: '/web/bots', bot: '/web/bot/:botName', botInstances: '/web/bots/instances', - botInstance: '/web/bot/:botName/instance/:instanceId', botsNew: '/web/bots/new/:type?', workloadIdentities: '/web/workloadidentities', console: '/web/cluster/:clusterId/console', @@ -850,10 +849,6 @@ const cfg = { return generatePath(cfg.routes.workloadIdentities); }, - getBotInstanceDetailsRoute(params: { botName: string; instanceId: string }) { - return generatePath(cfg.routes.botInstance, params); - }, - getBotsNewRoute(type?: string) { return generatePath(cfg.routes.botsNew, { type }); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index c12a0b38325a7..2e31312ac1428 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -53,7 +53,6 @@ import { AccountPage } from './Account'; import { AuditContainer as Audit } from './Audit'; import { AuthConnectorsContainer as AuthConnectors } from './AuthConnectors'; import { BotInstances } from './BotInstances/BotInstances'; -import { BotInstanceDetails } from './BotInstances/Details/BotInstanceDetails'; import { Bots } from './Bots'; import { AddBots } from './Bots/Add'; import { BotDetails } from './Bots/Details/BotDetails'; @@ -303,18 +302,11 @@ export class FeatureBotInstances implements TeleportFeature { } } +// TODO(nicholasmarais1158) Remove this feature stub when teleport.e no longer +// uses it. export class FeatureBotInstanceDetails implements TeleportFeature { - parent = FeatureBotInstances; - - route = { - title: 'Bot instance details', - path: cfg.routes.botInstance, - exact: true, - component: BotInstanceDetails, - }; - hasAccess() { - return true; + return false; } } @@ -829,7 +821,6 @@ export function getOSSFeatures(): TeleportFeature[] { new FeatureBots(), new FeatureBotDetails(), new FeatureBotInstances(), - new FeatureBotInstanceDetails(), new FeatureAddBotsShortcut(), new FeatureJoinTokens(), new FeatureRoles(), diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index b265b5314d35d..341193f93f241 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -223,6 +223,7 @@ class TeleportContext implements types.Context { gitServers: userContext.getGitServersAccess().list && userContext.getGitServersAccess().read, + readBotInstances: userContext.getBotInstancesAccess().read, listBotInstances: userContext.getBotInstancesAccess().list, listWorkloadIdentities: userContext.getWorkloadIdentityAccess().list, }; @@ -268,6 +269,7 @@ export const disabledFeatureFlags: types.FeatureFlags = { editBots: false, removeBots: false, gitServers: false, + readBotInstances: false, listBotInstances: false, listWorkloadIdentities: false, }; diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index 475aa0ab02088..f2a285b30e3bd 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -51,7 +51,19 @@ export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => return HttpResponse.json(mock); }); -export const getBotInstanceError = (status: number) => +export const getBotInstanceError = ( + status: number, + error: string | null = null +) => http.get(cfg.api.botInstance.read, () => { - return new HttpResponse(null, { status }); + return HttpResponse.json({ error: { message: error } }, { status }); }); + +export const getBotInstanceForever = () => + http.get( + cfg.api.botInstance.read, + () => + new Promise(() => { + /* never resolved */ + }) + ); diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index e377294ce2af6..2612edb8cc746 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -207,6 +207,7 @@ export interface FeatureFlags { externalAuditStorage: boolean; listBots: boolean; readBots: boolean; + readBotInstances: boolean; listBotInstances: boolean; addBots: boolean; editBots: boolean;