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;