diff --git a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx index 2d6fd654d3c6f..4dee985911b4c 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx @@ -30,6 +30,7 @@ import { TeleportProviderBasic } from 'teleport/mocks/providers'; import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { getBotInstanceError, + getBotInstanceMetricsSuccess, getBotInstanceSuccess, listBotInstancesError, listBotInstancesForever, @@ -50,7 +51,7 @@ type Story = StoryObj; export default meta; -const listBotInstancesSuccessHandler = listBotInstancesSuccess({ +const listBotInstances = { bot_instances: [ { bot_name: 'ansible-worker', @@ -94,13 +95,14 @@ const listBotInstancesSuccessHandler = listBotInstancesSuccess({ }, ], next_page_token: '', -}); +}; export const Happy: Story = { parameters: { msw: { handlers: [ - listBotInstancesSuccessHandler, + listBotInstancesSuccess(listBotInstances, 'v1'), + listBotInstancesSuccess(listBotInstances, 'v2'), getBotInstanceSuccess({ bot_instance: { spec: { @@ -109,6 +111,7 @@ export const Happy: Story = { }, yaml: 'kind: bot_instance\nversion: v1\n', }), + getBotInstanceMetricsSuccess(), ], }, }, @@ -117,7 +120,10 @@ export const Happy: Story = { export const ErrorLoadingList: Story = { parameters: { msw: { - handlers: [listBotInstancesError(500, 'something went wrong')], + handlers: [ + listBotInstancesError(500, 'something went wrong'), + getBotInstanceMetricsSuccess(), + ], }, }, }; @@ -125,7 +131,7 @@ export const ErrorLoadingList: Story = { export const StillLoadingList: Story = { parameters: { msw: { - handlers: [listBotInstancesForever()], + handlers: [listBotInstancesForever(), getBotInstanceMetricsSuccess()], }, }, }; @@ -141,6 +147,7 @@ export const NoListPermission: Story = { 500, 'this call should never be made without permissions' ), + getBotInstanceMetricsSuccess(), ], }, }, @@ -153,11 +160,13 @@ export const NoReadPermission: Story = { parameters: { msw: { handlers: [ - listBotInstancesSuccessHandler, + listBotInstancesSuccess(listBotInstances, 'v1'), + listBotInstancesSuccess(listBotInstances, 'v2'), getBotInstanceError( 500, 'this call should never be made without permissions' ), + getBotInstanceMetricsSuccess(), ], }, }, diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index fdc83a0bcbb7b..acdaf77bb9050 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -39,6 +39,7 @@ import { createTeleportContext } from 'teleport/mocks/contexts'; import { listBotInstances } from 'teleport/services/bot/bot'; import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; import { + getBotInstanceMetricsSuccess, getBotInstanceSuccess, listBotInstancesError, listBotInstancesSuccess, @@ -58,6 +59,9 @@ jest.mock('teleport/services/bot/bot', () => { getBotInstance: jest.fn((...all) => { return actual.getBotInstance(...all); }), + getBotInstanceMetrics: jest.fn((...all) => { + return actual.getBotInstanceMetrics(...all); + }), }; }); @@ -67,10 +71,6 @@ beforeAll(() => { server.listen(); }); -beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); -}); - afterEach(async () => { server.resetHandlers(); await testQueryClient.resetQueries(); @@ -84,11 +84,15 @@ afterAll(() => server.close()); describe('BotInstances', () => { it('Shows an empty state', async () => { server.use( - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); renderComponent(); @@ -104,6 +108,7 @@ describe('BotInstances', () => { it('Shows an error state', async () => { server.use(listBotInstancesError(500, 'something went wrong')); + server.use(getBotInstanceMetricsSuccess()); renderComponent(); @@ -116,6 +121,7 @@ describe('BotInstances', () => { const testErrorMessage = 'unsupported sort, only bot_name:asc is supported, but got "blah" (desc = true)'; server.use(listBotInstancesError(400, testErrorMessage)); + server.use(getBotInstanceMetricsSuccess()); const { user } = renderComponent(); @@ -124,14 +130,15 @@ describe('BotInstances', () => { expect(screen.getByText(testErrorMessage)).toBeInTheDocument(); server.use( - listBotInstancesSuccess({ - bot_instances: [], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [], + next_page_token: '', + }, + 'v1' + ) ); - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally - const resetButton = screen.getByRole('button', { name: 'Reset sort' }); await user.click(resetButton); @@ -159,25 +166,31 @@ describe('BotInstances', () => { }); it('Shows a list', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-05-19T08:00:00Z')); + 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: '', - }) + 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: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); renderComponent(); @@ -191,27 +204,29 @@ describe('BotInstances', () => { }); 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: '', - }) + 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: '', + }, + 'v1' + ) ); + server.use(getBotInstanceMetricsSuccess()); server.use( getBotInstanceSuccess({ @@ -251,7 +266,7 @@ describe('BotInstances', () => { }); it('Allows paging', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -327,7 +342,7 @@ describe('BotInstances', () => { }); it('Allows filtering (search)', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -407,7 +422,7 @@ describe('BotInstances', () => { }); it('Allows filtering (query)', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => @@ -491,8 +506,62 @@ describe('BotInstances', () => { ); }); + it('Allows a filter to be applied from the dashboard', async () => { + server.use(getBotInstanceMetricsSuccess()); + + jest.mocked(listBotInstances).mockImplementation( + ({ pageToken }) => + new Promise(resolve => { + resolve({ + bot_instances: [], + next_page_token: pageToken + '.next', + }); + }) + ); + + const { user, history } = renderComponent(); + jest.spyOn(history, 'push'); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('loading-dashboard') + ); + + expect(listBotInstances).toHaveBeenCalledTimes(1); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', + searchTerm: '', + query: undefined, + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + + const item = screen.getByLabelText('Up to date'); + await user.click(item); + + expect(history.push).toHaveBeenLastCalledWith({ + pathname: '/web/bots/instances', + search: 'query=up+to+date+filter+goes+here&is_advanced=1', + }); + expect(listBotInstances).toHaveBeenCalledTimes(2); + expect(listBotInstances).toHaveBeenLastCalledWith( + { + pageSize: 32, + pageToken: '', // Should reset to the first page + searchTerm: undefined, + query: 'up to date filter goes here', + sortDir: 'DESC', + sortField: 'active_at_latest', + }, + expect.anything() + ); + }); + it('Allows sorting', async () => { - jest.useRealTimers(); // Required as userEvent.type() uses setTimeout internally + server.use(getBotInstanceMetricsSuccess()); jest.mocked(listBotInstances).mockImplementation( ({ pageToken }) => diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index f412bbf91040e..fe1b5c1521641 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -24,8 +24,6 @@ import styled, { css } from 'styled-components'; import { Alert } from 'design/Alert/Alert'; 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'; @@ -39,6 +37,7 @@ import { listBotInstances } from 'teleport/services/bot/bot'; import { BotInstanceSummary } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; +import { BotInstancesDashboard } from './Dashboard/BotInstanceDashboard'; import { BotInstanceDetails } from './Details/BotInstanceDetails'; import { InfoGuide } from './InfoGuide'; import { @@ -163,6 +162,13 @@ export function BotInstances() { [data?.pages, isSuccess] ); + const handleFilterSelected = useCallback( + (filter: string) => { + handleQueryChange(filter, true); + }, + [handleQueryChange] + ); + if (!hasListPermission) { return ( @@ -207,6 +213,7 @@ export function BotInstances() { onLoadNextPage={fetchNextPage} selectedItem={selectedItemId} onItemSelected={handleItemSelected} + isFiltering={!!query} /> {selectedItemId ? ( {!selectedItemId ? ( - - - - Select an instance to see full details. - - + ) : undefined} @@ -259,20 +261,3 @@ const ListAndDetailsContainer = styled(CardTile)<{ $listOnlyMode: boolean }>` ` : ''} `; - -const DashboardContainer = styled(Flex)` - flex-direction: column; - overflow: auto; - flex-basis: 100%; - align-items: center; - justify-content: center; -`; - -const DashboardHelpText = styled(Text)` - color: ${props => props.theme.colors.text.muted}; - text-align: center; -`; - -const QuestionIcon = styled(Question)` - color: ${props => props.theme.colors.text.muted}; -`; diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx new file mode 100644 index 0000000000000..fa327d5028d7e --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.story.tsx @@ -0,0 +1,118 @@ +/** + * 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 . + */ + +/** + * 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 { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { + getBotInstanceMetricsError, + getBotInstanceMetricsForever, + getBotInstanceMetricsSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstancesDashboard } from './BotInstanceDashboard'; + +const meta = { + title: 'Teleport/BotInstances/Dashboard', + 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: [getBotInstanceMetricsSuccess()], + }, + }, +}; + +export const NoData: Story = { + parameters: { + msw: { + handlers: [ + getBotInstanceMetricsSuccess({ + upgrade_statuses: null, + refresh_after_seconds: 60_000, + }), + ], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsForever()], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { + handlers: [getBotInstanceMetricsError(500, 'something went wrong')], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper() { + const ctx = createTeleportContext(); + + return ( + + + {}} /> + + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx new file mode 100644 index 0000000000000..12348d43f05b3 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.test.tsx @@ -0,0 +1,264 @@ +/** + * 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 { setupServer } from 'msw/node'; +import { ComponentProps, PropsWithChildren } from 'react'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + render, + screen, + testQueryClient, + userEvent, + waitForElementToBeRemoved, + within, +} from 'design/utils/testing'; + +import { + getBotInstanceMetricsError, + getBotInstanceMetricsSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstancesDashboard } from './BotInstanceDashboard'; + +const server = setupServer(); + +beforeAll(() => { + server.listen(); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +describe('BotInstanceDashboard', () => { + it('renders', async () => { + withSuccessResponse(); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('Insights')).toBeInTheDocument(); + expect(screen.getByText('Version Compatibility')).toBeInTheDocument(); + + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('100 (57%)')).toBeInTheDocument(); + + const patch = screen.getByLabelText('Patch available'); + expect(within(patch).getByText('50 (29%)')).toBeInTheDocument(); + + const upgrade = screen.getByLabelText('Upgrade required'); + expect(within(upgrade).getByText('25 (14%)')).toBeInTheDocument(); + + const unsupported = screen.getByLabelText('Unsupported'); + expect(within(unsupported).getByText('0 (0%)')).toBeInTheDocument(); + + expect( + screen.getByText('Select a category above to filter bot instances.') + ).toBeInTheDocument(); + }); + + it('shows no data message', async () => { + withSuccessResponse({ + upgrade_statuses: null, + refresh_after_seconds: 60_000, + }); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('No data available')).toBeInTheDocument(); + expect( + screen.queryByText('Select a status above to view instances.') + ).not.toBeInTheDocument(); + }); + + it('shows an error', async () => { + withErrorResponse(500, 'something went wrong'); + + renderComponent(); + + await waitForLoading(); + + expect(screen.getByText('something went wrong')).toBeInTheDocument(); + expect( + screen.queryByText('Select a status above to view instances.') + ).not.toBeInTheDocument(); + }); + + it('items are selectable', async () => { + const onFilterSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onFilterSelected } }); + + await waitForLoading(); + + { + const item = screen.getByLabelText('Up to date'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(1); + expect(onFilterSelected).toHaveBeenLastCalledWith( + 'mock up-to-date filter' + ); + } + + { + const item = screen.getByLabelText('Patch available'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(2); + expect(onFilterSelected).toHaveBeenLastCalledWith('mock patch filter'); + } + + { + const item = screen.getByLabelText('Upgrade required'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(3); + expect(onFilterSelected).toHaveBeenLastCalledWith('mock upgrade filter'); + } + + { + const item = screen.getByLabelText('Unsupported'); + await user.click(item); + expect(onFilterSelected).toHaveBeenCalledTimes(4); + expect(onFilterSelected).toHaveBeenLastCalledWith( + 'mock unsupported filter' + ); + } + }); + + it('refreshes', async () => { + const onFilterSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onFilterSelected } }); + + await waitForLoading(); + + { + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('100 (57%)')).toBeInTheDocument(); + } + + withSuccessResponse({ + upgrade_statuses: { + up_to_date: { + count: 99, + }, + patch_available: { + count: 0, + }, + requires_upgrade: { + count: 0, + }, + unsupported: { + count: 0, + }, + updated_at: '1970-01-01T00:00:00Z', + }, + refresh_after_seconds: 60_000, + }); + + const refreshButton = screen.getByLabelText('refresh'); + await user.click(refreshButton); + + { + const upToDate = screen.getByLabelText('Up to date'); + expect(within(upToDate).getByText('99 (100%)')).toBeInTheDocument(); + } + }); +}); + +function renderComponent(options?: { + props?: ComponentProps; +}) { + const { props } = options ?? {}; + const { onFilterSelected = jest.fn() } = props ?? {}; + + const user = userEvent.setup(); + + return { + ...render(, { + wrapper: makeWrapper(), + }), + user, + history, + }; +} + +function makeWrapper() { + return ({ children }: PropsWithChildren) => { + return ( + + + {children} + + + ); + }; +} + +async function waitForLoading() { + await waitForElementToBeRemoved(() => + screen.queryByTestId('loading-dashboard') + ); +} + +function withSuccessResponse( + mock: Parameters[0] = { + upgrade_statuses: { + up_to_date: { + count: 100, + filter: 'mock up-to-date filter', + }, + patch_available: { + count: 50, + filter: 'mock patch filter', + }, + requires_upgrade: { + count: 25, + filter: 'mock upgrade filter', + }, + unsupported: { + count: 0, + filter: 'mock unsupported filter', + }, + updated_at: new Date().toISOString(), + }, + refresh_after_seconds: 60_000, + } +) { + server.use(getBotInstanceMetricsSuccess(mock)); +} + +function withErrorResponse( + ...params: Parameters +) { + server.use(getBotInstanceMetricsError(...params)); +} diff --git a/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx new file mode 100644 index 0000000000000..c6148dfd1bd11 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Dashboard/BotInstanceDashboard.tsx @@ -0,0 +1,380 @@ +/** + * 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 { useQuery } from '@tanstack/react-query'; +import { format, formatDistanceToNowStrict, parseISO } from 'date-fns'; +import { useEffect, useState } from 'react'; +import styled, { useTheme } from 'styled-components'; + +import { Alert } from 'design/Alert/Alert'; +import Box from 'design/Box/Box'; +import ButtonIcon from 'design/ButtonIcon/ButtonIcon'; +import { CardTile } from 'design/CardTile/CardTile'; +import Flex from 'design/Flex'; +import { Refresh } from 'design/Icon'; +import { Indicator } from 'design/Indicator/Indicator'; +import Text, { H2, H3 } from 'design/Text'; +import { IconTooltip } from 'design/Tooltip'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; + +import { getBotInstanceMetrics } from 'teleport/services/bot/bot'; +import { GetBotInstanceMetricsResponse } from 'teleport/services/bot/types'; + +export function BotInstancesDashboard(props: { + /** + * Callback used when a dashbaord item is selected (e.g. "unsupported" + * instance versions). The given filter is used as an advanced query (in the + * Teleport predicate language) to filter the items in the instances list. + * + * @param filter query (verbatum) used to filter the bot instance list. + */ + onFilterSelected: (filter: string) => void; +}) { + const { onFilterSelected } = props; + + const { data, error, isLoading, isPending, refetch } = useQuery({ + queryKey: ['bot_instance', 'metrics'], + queryFn: ({ signal }) => getBotInstanceMetrics(null, signal), + // The metrics endpoint (used by this query) returns a + // `refresh_after_seconds` value to indicate how frequently the client + // should poll for updated metrics, which may take jitter into account. This + // allows the polling rate to most closely match the backend data refresh, + // and allows the rate to be controlled server-side. + // + // The `refetchInterval` is set to this value from the lasty successful + // response, otherwise 1 min as a fallback. + refetchInterval: ({ state }) => + (state.data?.refresh_after_seconds ?? 60) * 1_000, + }); + + // Used to keep "Last updated x minutes ago" label current + useTick(30_000); + + return ( + + +

Insights

+ + refetch()} + aria-label="refresh" + disabled={isLoading} + > + + + +
+ + + {error ? ( + + {error.message} + + ) : undefined} + + {isLoading ? ( + + + + ) : undefined} + + {isPending ? undefined : ( + <> + + + + + {data?.upgrade_statuses ? ( + + Select a category above to filter bot instances. + + ) : undefined} + + )} +
+ ); +} + +const Container = styled(CardTile)` + flex-direction: column; + flex-basis: 100%; + margin: ${props => props.theme.space[1]}px; + padding: 0; + gap: 0; +`; + +const TitleContainer = styled(Flex)` + align-items: center; + justify-content: space-between; + min-height: ${p => p.theme.space[8]}px; + padding-left: ${p => p.theme.space[3]}px; + padding-right: ${p => p.theme.space[3]}px; + gap: ${p => p.theme.space[2]}px; +`; + +const Divider = styled.div` + height: 1px; + flex-shrink: 0; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const InnerContainer = styled(Flex)` + overflow: auto; + flex-direction: column; + padding: ${p => p.theme.space[3]}px; +`; + +function UpgradeStatusChart(props: { + data: GetBotInstanceMetricsResponse['upgrade_statuses']; + onFilterSelected: (status: string) => void; +}) { + const { data, onFilterSelected } = props; + + const theme = useTheme(); + + const max = Math.max( + 1, // Never zero + data?.up_to_date?.count ?? 0, + data?.patch_available?.count ?? 0, + data?.requires_upgrade?.count ?? 0, + data?.unsupported?.count ?? 0 + ); + + const total = Math.max( + 1, // Never zero + (data?.up_to_date?.count ?? 0) + + (data?.patch_available?.count ?? 0) + + (data?.requires_upgrade?.count ?? 0) + + (data?.unsupported?.count ?? 0) + ); + + const series = data + ? [ + { + name: 'Up to date', + percent: (data.up_to_date?.count ?? 0) / max, + count: data.up_to_date?.count ?? 0, + label: `${data.up_to_date?.count ?? 0}\xa0(${formatPercent((data.up_to_date?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.success.default, + onClick: () => + data.up_to_date?.filter + ? onFilterSelected(data.up_to_date?.filter) + : undefined, + tooltip: + 'Up-to-date instances are running the same version as the Teleport cluster.', + }, + { + name: 'Patch available', + percent: (data.patch_available?.count ?? 0) / max, + count: data.patch_available?.count ?? 0, + label: `${data.patch_available?.count ?? 0}\xa0(${formatPercent((data.patch_available?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.accent.default, + onClick: () => + data.patch_available?.filter + ? onFilterSelected(data.patch_available?.filter) + : undefined, + tooltip: + 'Instances with a patch available are running the same major version as the Teleport cluster.', + }, + { + name: 'Upgrade required', + percent: (data.requires_upgrade?.count ?? 0) / max, + count: data.requires_upgrade?.count ?? 0, + label: `${data.requires_upgrade?.count ?? 0}\xa0(${formatPercent((data.requires_upgrade?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.alert.default, + onClick: () => + data.requires_upgrade?.filter + ? onFilterSelected(data.requires_upgrade?.filter) + : undefined, + tooltip: + 'Instances requiring an upgrade are running the one major version behind the Teleport cluster.', + }, + { + name: 'Unsupported', + percent: (data.unsupported?.count ?? 0) / max, + count: data.unsupported?.count ?? 0, + label: `${data.unsupported?.count ?? 0}\xa0(${formatPercent((data.unsupported?.count ?? 0) / total)})`, + color: theme.colors.interactive.solid.danger.default, + onClick: () => + data.unsupported?.filter + ? onFilterSelected(data.unsupported?.filter) + : undefined, + tooltip: + 'Unsupported instances are running two or more major versions behind the Teleport cluster, or are running a newer version.', + }, + ] + : null; + + return ( + + +

Version Compatibility

+ {data?.updated_at ? ( + + + Last updated{' '} + {formatDistanceToNowStrict(parseISO(data.updated_at))} ago + + + ) : undefined} +
+ + {series ? ( + series.map(s => ( + { + if (event.key === 'Enter') { + s.onClick(); + } + }} + role="button" + tabIndex={0} + aria-label={`${s.name}`} + > + + {s.name} + + {s.tooltip} + + + + + )) + ) : ( + No data available + )} + +
+ ); +} + +const UpgradeStatusContainer = styled(Flex)` + flex-direction: column; + padding: ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[2]}px; + gap: ${({ theme }) => theme.space[3]}px; + border: 1px solid ${p => p.theme.colors.interactive.tonal.neutral[0]}; +`; + +const BarsContainer = styled(Flex)` + flex-direction: column; +`; + +const SeriesContainer = styled.div` + padding: ${({ theme }) => theme.space[2]}px ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[2]}px; + + cursor: pointer; + + &:hover { + background-color: ${({ theme }) => theme.colors.levels.sunken}; + } + &:focus, + &:active { + outline: none; + + background-color: ${({ theme }) => theme.colors.levels.deep}; + } + + transition: background-color 200ms linear; +`; + +const ChartLabelContainer = styled(Flex)` + align-items: center; + gap: ${({ theme }) => theme.space[2]}px; +`; + +const ChartLabelText = styled(Text)` + white-space: nowrap; + font-size: ${({ theme }) => theme.fontSizes[1]}px; +`; + +const ChartNoDataContainer = styled(Flex)` + align-items: center; + justify-content: center; + padding: ${({ theme }) => theme.space[4]}px; + color: ${({ theme }) => theme.colors.text.muted}; +`; + +const ChartUpdatedAtText = styled(Text)` + font-size: ${({ theme }) => theme.fontSizes[1]}px; + font-weight: ${({ theme }) => theme.fontWeights.medium}; + text-align: right; +`; + +function Bar(props: { percent: number; label: string; color: string }) { + const { percent, label, color } = props; + + return ( + + + {label} + + ); +} + +const BarContainer = styled(Flex)` + align-items: center; + gap: ${({ theme }) => theme.space[2]}px; +`; + +const BarAmount = styled.div<{ $percent: number; $color: string }>` + flex-grow: ${({ $percent }) => $percent}; + background-color: ${({ $color }) => $color}; + height: ${({ theme }) => theme.space[3]}px; + border-radius: ${({ theme }) => theme.space[1]}px; + min-width: ${({ theme }) => theme.space[1]}px; + + transition: flex-grow 1000ms ease-in-out; +`; + +const BarLabel = styled.div<{ $percent: number }>` + flex-grow: ${({ $percent }) => 1 - $percent}; + + transition: flex-grow 1000ms ease-in-out; +`; + +function formatPercent(percent: number) { + return `${(percent * 100).toFixed(0)}%`; +} + +/** + * A hook which ticks at the given interval and will cause a re-render of + * components which use it. Useful for updating messaging such as "updated 10 + * seconds ago". + * @param interval how often to tick (in milliseconds) + * @returns A date instance representing the last tick + */ +function useTick(interval: number) { + const [tick, setTick] = useState(new Date()); + + useEffect(() => { + const id = setInterval(() => setTick(new Date()), interval); + return () => clearInterval(id); + }); + + return tick; +} diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx index 5ff2668b219b5..f6606d633f8bc 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.story.tsx @@ -48,6 +48,13 @@ export const Empty: Story = { }, }; +export const EmptyWithFilter: Story = { + args: { + data: [], + isFiltering: true, + }, +}; + export const ErrorLoadingList: Story = { args: { error: new Error('something went wrong'), @@ -91,7 +98,12 @@ function Wrapper( props?: Partial< Pick< ComponentProps, - 'error' | 'isLoading' | 'hasNextPage' | 'isFetchingNextPage' | 'data' + | 'error' + | 'isLoading' + | 'hasNextPage' + | 'isFetchingNextPage' + | 'data' + | 'isFiltering' > > ) { @@ -129,6 +141,7 @@ function Wrapper( hasNextPage = true, isFetchingNextPage = false, isLoading = false, + isFiltering = false, } = props ?? {}; const [allData, setAllData] = useState(data); @@ -178,6 +191,7 @@ function Wrapper( onItemSelected={function (item: BotInstanceSummary | null): void { setSelected(item ? `${item.bot_name}/${item.instance_id}` : null); }} + isFiltering={isFiltering} /> diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx index a3e22c58dcfd2..4f2004d21aac2 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.test.tsx @@ -220,6 +220,38 @@ describe('BotIntancesList', () => { expect(onSortChanged).toHaveBeenLastCalledWith('bot_name', 'ASC'); }); + + describe('When filtering', () => { + it('Shows an alterate title', async () => { + renderComponent({ + props: { + isFiltering: true, + }, + }); + + expect( + screen.getByRole('heading', { name: 'Filtered Instances' }) + ).toBeInTheDocument(); + }); + + it('Shows an empty state', async () => { + renderComponent({ + props: { + data: [], + isFiltering: true, + }, + }); + + expect( + screen.getByText('No instances matching filter') + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'Bot instances are ephemeral, and disappear once all issued credentials have expired.' + ) + ).not.toBeInTheDocument(); + }); + }); }); const renderComponent = (options?: { @@ -238,6 +270,7 @@ const renderComponent = (options?: { onSortChanged = jest.fn(), onLoadNextPage = jest.fn(), onItemSelected = jest.fn(), + isFiltering = false, } = props ?? {}; const user = userEvent.setup(); @@ -255,6 +288,7 @@ const renderComponent = (options?: { onSortChanged={onSortChanged} onLoadNextPage={onLoadNextPage} onItemSelected={onItemSelected} + isFiltering={isFiltering} />, { wrapper: makeWrapper(), diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 6ce8874992a81..86e83710342fa 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -49,6 +49,7 @@ function InternalBotInstancesList( onSortChanged: (sortField: string, sortDir: 'ASC' | 'DESC') => void; onLoadNextPage: () => void; onItemSelected: (item: BotInstanceSummary) => void; + isFiltering: boolean; }, ref: React.RefObject ) { @@ -64,6 +65,7 @@ function InternalBotInstancesList( onSortChanged, onLoadNextPage, onItemSelected, + isFiltering, } = props; const contentRef = React.useRef(null); @@ -86,7 +88,9 @@ function InternalBotInstancesList( return ( - Active Instances + + {isFiltering ? 'Filtered Instances' : 'Active Instances'} + ) : ( - No active instances - - Bot instances are ephemeral, and disappear once all issued - credentials have expired. - + + {isFiltering + ? 'No instances matching filter' + : 'No active instances'} + + {!isFiltering ? ( + + Bot instances are ephemeral, and disappear once all issued + credentials have expired. + + ) : undefined} )} diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx index 7910dcb65226f..21901c5679f26 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.test.tsx @@ -752,20 +752,23 @@ const withFetchJoinTokensOutdatedProxy = () => { function withFetchInstancesSuccess() { server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'svr-lon-01-ab23cd', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.16', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'svr-lon-01-ab23cd', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.16', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); } diff --git a/web/packages/teleport/src/Bots/Details/Instance.tsx b/web/packages/teleport/src/Bots/Details/Instance.tsx index 2a05c6e737f8b..0049a44ae8d19 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.tsx @@ -177,6 +177,8 @@ const Container = styled(Flex)<{ } ` : ''} + + transition: background-color 200ms linear; `; const TopRow = styled(Flex)` @@ -254,8 +256,7 @@ function Version(props: { version: string | undefined }) { break; case 'too-new': Wrapper = DangerOutlined; - tooltip = - 'Version is one or more major versions ahead, and is not compatible.'; + tooltip = 'Version is ahead, and is not compatible.'; break; } } diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx index c231a6813e263..8fc30ffdfc49f 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.test.tsx @@ -107,20 +107,23 @@ const waitForLoading = async () => { function withFetchSuccess() { server.use( - listBotInstancesSuccess({ - bot_instances: [ - { - bot_name: 'ansible-worker', - instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', - active_at_latest: '2025-07-22T10:54:00Z', - host_name_latest: 'svr-lon-01-ab23cd', - join_method_latest: 'github', - os_latest: 'linux', - version_latest: '4.4.16', - }, - ], - next_page_token: '', - }) + listBotInstancesSuccess( + { + bot_instances: [ + { + bot_name: 'ansible-worker', + instance_id: 'c11250e0-00c2-4f52-bcdf-b367f80b9461', + active_at_latest: '2025-07-22T10:54:00Z', + host_name_latest: 'svr-lon-01-ab23cd', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: '4.4.16', + }, + ], + next_page_token: '', + }, + 'v1' + ) ); } diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 064ab57d7ded8..f659f70cb6fd3 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -505,6 +505,7 @@ const cfg = { read: '/v1/webapi/sites/:clusterId/machine-id/bot/:botName/bot-instance/:instanceId', list: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', listV2: '/v2/webapi/sites/:clusterId/machine-id/bot-instance', + metrics: '/v1/webapi/sites/:clusterId/machine-id/bot-instance/metrics', }, workloadIdentity: { @@ -1720,6 +1721,9 @@ const cfg = { botName: string; instanceId: string; } + | { + action: 'metrics'; + } ) & { clusterId?: string } ) { const { clusterId = cfg.proxyCluster } = req; @@ -1738,6 +1742,10 @@ const cfg = { botName: req.botName, instanceId: req.instanceId, }); + case 'metrics': + return generatePath(cfg.api.botInstance.metrics, { + clusterId, + }); default: req satisfies never; return ''; diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index 41b9027f39385..35c76466db44c 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -25,6 +25,7 @@ import { canUseV2Edit, makeBot, toApiGitHubTokenSpec, + validateGetBotInstanceMetricsResponse, validateGetBotInstanceResponse, validateListBotInstancesResponse, } from 'teleport/services/bot/consts'; @@ -264,3 +265,23 @@ export async function getBotInstance( return data; } + +export async function getBotInstanceMetrics( + variables: null, + signal?: AbortSignal +) { + const path = cfg.getBotInstanceUrl({ action: 'metrics' }); + + try { + const data = await api.get(path, signal); + + if (!validateGetBotInstanceMetricsResponse(data)) { + throw new Error('failed to validate get bot instance metrics response'); + } + + return data; + } catch (err: unknown) { + // TODO(nicholasmarais1158) DELETE IN v20.0.0 + withGenericUnsupportedError(err, '19.0.0'); + } +} diff --git a/web/packages/teleport/src/services/bot/consts.ts b/web/packages/teleport/src/services/bot/consts.ts index 4bb7b0a99f321..f6f3dcb7cbbe5 100644 --- a/web/packages/teleport/src/services/bot/consts.ts +++ b/web/packages/teleport/src/services/bot/consts.ts @@ -22,6 +22,7 @@ import { BotUiFlow, EditBotRequest, FlatBot, + GetBotInstanceMetricsResponse, GetBotInstanceResponse, GitHubRepoRule, ListBotInstancesResponse, @@ -137,6 +138,24 @@ export function validateGetBotInstanceResponse( return true; } +export function validateGetBotInstanceMetricsResponse( + data: unknown +): data is GetBotInstanceMetricsResponse { + if (typeof data !== 'object' || data === null) { + return false; + } + + if (!('upgrade_statuses' in data)) { + return false; + } + + if (typeof data.upgrade_statuses !== 'object') { + return false; + } + + return true; +} + export function getBotType(labels: Map): BotType { if (!labels) { return null; diff --git a/web/packages/teleport/src/services/bot/types.ts b/web/packages/teleport/src/services/bot/types.ts index f42d7612b281c..06e4f29efe87c 100644 --- a/web/packages/teleport/src/services/bot/types.ts +++ b/web/packages/teleport/src/services/bot/types.ts @@ -82,6 +82,22 @@ export type GetBotInstanceResponse = { yaml?: string; }; +export type GetBotInstanceMetricsResponse = { + upgrade_statuses?: { + unsupported?: BotInstanceMetric | null; + patch_available?: BotInstanceMetric | null; + requires_upgrade?: BotInstanceMetric | null; + up_to_date?: BotInstanceMetric | null; + updated_at?: string; + } | null; + refresh_after_seconds: number; +}; + +type BotInstanceMetric = { + count?: number; + filter?: string; +}; + export type BotList = { bots: FlatBot[]; }; diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index f2a285b30e3bd..bb42fbae872c3 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -20,18 +20,27 @@ import { http, HttpResponse } from 'msw'; import cfg from 'teleport/config'; import { + GetBotInstanceMetricsResponse, GetBotInstanceResponse, ListBotInstancesResponse, } from 'teleport/services/bot/types'; -export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => - http.get(cfg.api.botInstance.list, () => { - return HttpResponse.json(mock); - }); +export const listBotInstancesSuccess = ( + mock: ListBotInstancesResponse, + version: ListBotInstancesApiVersion = 'v2' +) => + http.get( + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, + () => { + return HttpResponse.json(mock); + } + ); -export const listBotInstancesForever = () => +export const listBotInstancesForever = ( + version: ListBotInstancesApiVersion = 'v1' +) => http.get( - cfg.api.botInstance.list, + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, () => new Promise(() => { /* never resolved */ @@ -40,11 +49,15 @@ export const listBotInstancesForever = () => export const listBotInstancesError = ( status: number, - error: string | null = null + error: string | null = null, + version: ListBotInstancesApiVersion = 'v1' ) => - http.get(cfg.api.botInstance.list, () => { - return HttpResponse.json({ error: { message: error } }, { status }); - }); + http.get( + version == 'v1' ? cfg.api.botInstance.list : cfg.api.botInstance.listV2, + () => { + return HttpResponse.json({ error: { message: error } }, { status }); + } + ); export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => http.get(cfg.api.botInstance.read, () => { @@ -67,3 +80,56 @@ export const getBotInstanceForever = () => /* never resolved */ }) ); + +export const getBotInstanceMetricsSuccess = ( + mock?: GetBotInstanceMetricsResponse +) => + http.get(cfg.api.botInstance.metrics, () => { + return HttpResponse.json( + mock ?? { + upgrade_statuses: { + updated_at: new Date().toISOString(), + up_to_date: { + count: randBetween(0, 2000), + filter: 'up to date filter goes here', + }, + patch_available: { + count: randBetween(0, 2000), + filter: 'patch filter goes here', + }, + requires_upgrade: { + count: randBetween(0, 2000), + filter: 'upgrade filter goes here', + }, + unsupported: { + count: randBetween(0, 2000), + filter: 'unsupported filter goes here', + }, + }, + } + ); + }); + +export const getBotInstanceMetricsForever = () => + http.get( + cfg.api.botInstance.metrics, + () => + new Promise(() => { + /* never resolved */ + }) + ); + +export const getBotInstanceMetricsError = ( + status: number, + error: string | null = null +) => + http.get(cfg.api.botInstance.metrics, () => { + return HttpResponse.json({ error: { message: error } }, { status }); + }); + +function randBetween(low: number, high: number) { + if (low > high) [low, high] = [high, low]; + return Math.floor(Math.random() * (high - low + 1)) + low; +} + +export type ListBotInstancesApiVersion = 'v1' | 'v2'; diff --git a/web/packages/teleport/src/useClusterVersion.test.tsx b/web/packages/teleport/src/useClusterVersion.test.tsx index 98b43c01c7cb6..b26a0740d902b 100644 --- a/web/packages/teleport/src/useClusterVersion.test.tsx +++ b/web/packages/teleport/src/useClusterVersion.test.tsx @@ -31,15 +31,15 @@ describe('useClusterVersion', () => { }); it.each` - clientVersion | compatibility - ${'4.4.0'} | ${{ isCompatible: true, reason: 'match' }} - ${'4.4.1'} | ${{ isCompatible: true, reason: 'match' }} - ${'4.3.999'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} - ${'4.3.0'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} - ${'5.0.0'} | ${{ isCompatible: true, reason: 'match' }} - ${'3.0.0'} | ${{ isCompatible: true, reason: 'upgrade-major' }} - ${'6.0.0'} | ${{ isCompatible: false, reason: 'too-new' }} - ${'2.0.0'} | ${{ isCompatible: false, reason: 'too-old' }} + clientVersion | compatibility + ${'4.4.0-dev'} | ${{ isCompatible: true, reason: 'match' }} + ${'4.4.0'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'4.4.1'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'4.3.999'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} + ${'4.3.0'} | ${{ isCompatible: true, reason: 'upgrade-minor' }} + ${'5.0.0'} | ${{ isCompatible: false, reason: 'too-new' }} + ${'3.0.0'} | ${{ isCompatible: true, reason: 'upgrade-major' }} + ${'2.0.0'} | ${{ isCompatible: false, reason: 'too-old' }} `( 'diff("$clientVersion") should be "$compatibility"', ({ clientVersion, compatibility }) => { diff --git a/web/packages/teleport/src/useClusterVersion.ts b/web/packages/teleport/src/useClusterVersion.ts index 5434982f57b88..e05f6acd6e1d7 100644 --- a/web/packages/teleport/src/useClusterVersion.ts +++ b/web/packages/teleport/src/useClusterVersion.ts @@ -73,20 +73,32 @@ export function checkClientCompatibility( const client = parse(clientVersion); const cluster = parse(clusterVersion); if (!client || !cluster) return null; + if (client.compare(cluster) === 0) { + return { + isCompatible: true, + reason: 'match', + }; + } + if (client.compare(cluster) === 1) { + return { + isCompatible: false, + reason: 'too-new', + }; + } if (client.major === cluster.major) { return { isCompatible: true, - reason: client.compare(cluster) === -1 ? 'upgrade-minor' : 'match', + reason: 'upgrade-minor', }; } - if (Math.abs(client.major - cluster.major) == 1) { + if (client.major === cluster.major - 1) { return { isCompatible: true, - reason: client.major > cluster.major ? 'match' : 'upgrade-major', + reason: 'upgrade-major', }; } return { isCompatible: false, - reason: client.major > cluster.major ? 'too-new' : 'too-old', + reason: 'too-old', }; }