From 0f70394a409a027908788ae61f64741601117344 Mon Sep 17 00:00:00 2001 From: Nick Marais Date: Tue, 21 Oct 2025 14:17:48 +0100 Subject: [PATCH] feat: Bot instance service health (#60133) * Add tabs to instance details * Add `kind` to bot instance heartbeat proto * Extend `GetBotInstanceResponse` type * Add `InfoTab` component for Overview tab * Add `HealthTab` component for Services tab * Wire-up tabs content * Use `join_attrs.meta` for join token fields * Fix links style * Fix handling of unspecified health status * Fix tab spacing * Remove tab tooltips * Replace service item background * Add zero services story * Support tctl instance kind * Fix styled links * Fix test * Fix bot instances story --- .../src/BotInstances/BotInstances.story.tsx | 9 +- .../src/BotInstances/BotInstances.test.tsx | 19 +- .../src/BotInstances/BotInstances.tsx | 18 + .../Details/BotInstanceDetails.story.tsx | 24 +- .../Details/BotInstanceDetails.test.tsx | 88 +++- .../Details/BotInstanceDetails.tsx | 108 ++++- .../BotInstances/Details/HealthTab.test.tsx | 125 ++++++ .../src/BotInstances/Details/HealthTab.tsx | 201 +++++++++ .../src/BotInstances/Details/InfoTab.test.tsx | 185 ++++++++ .../src/BotInstances/Details/InfoTab.tsx | 418 ++++++++++++++++++ .../teleport/src/services/bot/types.ts | 103 +++++ .../teleport/src/test/helpers/botInstances.ts | 91 +++- 12 files changed, 1328 insertions(+), 61 deletions(-) create mode 100644 web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx create mode 100644 web/packages/teleport/src/BotInstances/Details/HealthTab.tsx create mode 100644 web/packages/teleport/src/BotInstances/Details/InfoTab.test.tsx create mode 100644 web/packages/teleport/src/BotInstances/Details/InfoTab.tsx diff --git a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx index 4dee985911b4c..0d0afec88d159 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.story.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.story.tsx @@ -103,14 +103,7 @@ export const Happy: Story = { handlers: [ listBotInstancesSuccess(listBotInstances, 'v1'), listBotInstancesSuccess(listBotInstances, 'v2'), - getBotInstanceSuccess({ - bot_instance: { - spec: { - instance_id: 'a55259e8-9b17-466f-9d37-ab390ca4024e', - }, - }, - yaml: 'kind: bot_instance\nversion: v1\n', - }), + getBotInstanceSuccess(), getBotInstanceMetricsSuccess(), ], }, diff --git a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx index acdaf77bb9050..618a1ff538cac 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.test.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.test.tsx @@ -31,6 +31,7 @@ import { userEvent, waitFor, waitForElementToBeRemoved, + within, } from 'design/utils/testing'; import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoGuide'; @@ -228,16 +229,7 @@ describe('BotInstances', () => { ); server.use(getBotInstanceMetricsSuccess()); - server.use( - getBotInstanceSuccess({ - bot_instance: { - spec: { - instance_id: '3c3aae3e-de25-4824-a8e9-5a531862f19a', - }, - }, - yaml: 'kind: bot_instance\nversion: v1\n', - }) - ); + server.use(getBotInstanceSuccess()); const { user } = renderComponent(); @@ -260,8 +252,13 @@ describe('BotInstances', () => { }) ).toBeInTheDocument(); + const summarySection = screen + .getByRole('heading', { + name: 'Summary', + }) + .closest('section'); expect( - screen.getByText('kind: bot_instance version: v1') + within(summarySection!).getByText('test-bot-name') ).toBeInTheDocument(); }); diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index fe1b5c1521641..73a340a1e5428 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -54,6 +54,7 @@ export function BotInstances() { const sortField = queryParams.get('sort_field') || 'active_at_latest'; const sortDir = queryParams.get('sort_dir') || 'DESC'; const selectedItemId = queryParams.get('selected'); + const activeTab = queryParams.get('tab'); const listRef = useRef(null); @@ -144,6 +145,7 @@ export function BotInstances() { search.set('selected', `${item.bot_name}/${item.instance_id}`); } else { search.delete('selected'); + search.delete('tab'); } history.push({ @@ -154,6 +156,20 @@ export function BotInstances() { [history, location.pathname, location.search] ); + const handleDetailsTabSelected = useCallback( + (tab: string) => { + const search = new URLSearchParams(location.search); + + search.set('tab', tab); + + history.push({ + pathname: location.pathname, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + const [selectedBotName, selectedInstanceId] = selectedItemId?.split('/') ?? []; @@ -221,6 +237,8 @@ export function BotInstances() { botName={selectedBotName} instanceId={selectedInstanceId} onClose={() => handleItemSelected(null)} + activeTab={activeTab} + onTabSelected={tab => handleDetailsTabSelected(tab)} /> ) : undefined} diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx index f1aa941c58c0e..6b2a7224192fd 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.story.tsx @@ -18,6 +18,7 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; import { CardTile } from 'design/CardTile'; @@ -28,6 +29,7 @@ import { getBotInstanceError, getBotInstanceForever, getBotInstanceSuccess, + mockGetBotInstanceResponse, } from 'teleport/test/helpers/botInstances'; import { BotInstanceDetails } from './BotInstanceDetails'; @@ -45,16 +47,26 @@ type Story = StoryObj; export default meta; export const Happy: Story = { + parameters: { + msw: { + handlers: [getBotInstanceSuccess()], + }, + }, +}; + +export const ZeroServices: Story = { parameters: { msw: { handlers: [ getBotInstanceSuccess({ + ...mockGetBotInstanceResponse, bot_instance: { - spec: { - instance_id: 'a55259e8-9b17-466f-9d37-ab390ca4024e', + ...mockGetBotInstanceResponse.bot_instance, + status: { + ...mockGetBotInstanceResponse.bot_instance.status, + service_health: [], }, }, - yaml: 'kind: bot_instance\nversion: v1\n', }), ], }, @@ -100,6 +112,8 @@ const queryClient = new QueryClient({ function Wrapper(props?: { hasBotInstanceReadPermission?: boolean }) { const { hasBotInstanceReadPermission = true } = props ?? {}; + const [activeTab, setActiveTab] = useState('info'); + const customAcl = makeAcl({ botInstances: { ...defaultAccess, @@ -114,11 +128,13 @@ function Wrapper(props?: { hasBotInstanceReadPermission?: boolean }) { return ( - + {}} + activeTab={activeTab} + onTabSelected={tab => setActiveTab(tab)} /> diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx index dfcbd90a031eb..efe8f93f14dfe 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx @@ -28,6 +28,7 @@ import { testQueryClient, userEvent, waitForElementToBeRemoved, + within, } from 'design/utils/testing'; import 'shared/components/TextEditor/TextEditor.mock'; @@ -62,7 +63,7 @@ describe('BotIntanceDetails', () => { const onClose = jest.fn(); withSuccessResponse(); - const { user } = renderComponent({ onClose }); + const { user } = renderComponent({ props: { onClose } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); @@ -72,10 +73,63 @@ describe('BotIntanceDetails', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('Allows switching tab', async () => { + const onTabSelected = jest.fn(); + + withSuccessResponse(); + + const { user } = renderComponent({ props: { onTabSelected } }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const overviewTab = screen.getByRole('tab', { name: 'Overview' }); + await user.click(overviewTab); + expect(onTabSelected).toHaveBeenCalledTimes(1); + expect(onTabSelected).toHaveBeenLastCalledWith('info'); + + const servicesTab = screen.getByRole('tab', { name: 'Services' }); + await user.click(servicesTab); + expect(onTabSelected).toHaveBeenCalledTimes(2); + expect(onTabSelected).toHaveBeenLastCalledWith('health'); + + const yamlTab = screen.getByRole('tab', { name: 'YAML' }); + await user.click(yamlTab); + expect(onTabSelected).toHaveBeenCalledTimes(3); + expect(onTabSelected).toHaveBeenLastCalledWith('yaml'); + }); + + it('Shows instance info', async () => { + withSuccessResponse(); + + renderComponent({ props: { activeTab: 'info' } }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const summarySection = screen + .getByRole('heading', { + name: 'Summary', + }) + .closest('section'); + expect( + within(summarySection!).getByText('test-bot-name') + ).toBeInTheDocument(); + }); + + it('Shows instance services', async () => { + withSuccessResponse(); + + renderComponent({ props: { activeTab: 'health' } }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const item = screen.getByTestId('application-tunnel-1'); + expect(within(item!).getByText('application-tunnel-1')).toBeInTheDocument(); + }); + it('Shows full yaml', async () => { withSuccessResponse(); - renderComponent(); + renderComponent({ props: { activeTab: 'yaml' } }); await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); @@ -111,24 +165,29 @@ describe('BotIntanceDetails', () => { }); }); -const renderComponent = ( - options?: Partial> & { - hasBotInstanceReadPermission?: boolean; - } -) => { +const renderComponent = (options?: { + props?: Partial>; + hasBotInstanceReadPermission?: boolean; +}) => { + const { props, ...rest } = options ?? {}; const { botName = 'test-bot-name', instanceId = '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', onClose = jest.fn(), - ...rest - } = options ?? {}; + activeTab = 'info', + onTabSelected = jest.fn(), + } = props ?? {}; + const user = userEvent.setup(); + return { ...render( , { wrapper: makeWrapper(rest), @@ -165,16 +224,7 @@ function makeWrapper(options?: { hasBotInstanceReadPermission?: boolean }) { } const withSuccessResponse = () => { - server.use( - getBotInstanceSuccess({ - bot_instance: { - spec: { - instance_id: '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', - }, - }, - yaml: 'kind: bot_instance\nversion: v1\n', - }) - ); + server.use(getBotInstanceSuccess()); }; const withErrorResponse = () => { diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx index 37cb4c429c589..83bf5689f16ec 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx @@ -24,6 +24,8 @@ import ButtonIcon from 'design/ButtonIcon/ButtonIcon'; import Flex from 'design/Flex/Flex'; import { Cross } from 'design/Icon/Icons/Cross'; import { Indicator } from 'design/Indicator/Indicator'; +import { TabBorder, TabContainer, TabsContainer } from 'design/Tabs/Tabs'; +import { useSlidingBottomBorderTabs } from 'design/Tabs/useSlidingBottomBorderTabs'; import Text from 'design/Text/Text'; import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; import TextEditor from 'shared/components/TextEditor/TextEditor'; @@ -31,13 +33,17 @@ import TextEditor from 'shared/components/TextEditor/TextEditor'; import useTeleport from 'teleport/useTeleport'; import { useGetBotInstance } from '../hooks'; +import { HealthTab } from './HealthTab'; +import { InfoTab } from './InfoTab'; export function BotInstanceDetails(props: { botName: string; instanceId: string; onClose: () => void; + activeTab?: string | null; + onTabSelected: (tab: string) => void; }) { - const { botName, instanceId, onClose } = props; + const { botName, instanceId, onClose, activeTab, onTabSelected } = props; const ctx = useTeleport(); const flags = ctx.getFeatureFlags(); @@ -54,6 +60,8 @@ export function BotInstanceDetails(props: { } ); + const tab = tabs.find(t => t.id === activeTab)?.id ?? 'info'; + return ( @@ -87,19 +95,34 @@ export function BotInstanceDetails(props: { ) : undefined} - {isSuccess && data.yaml ? ( - - - + {isSuccess ? ( + <> + + + {tab === 'info' ? ( + + onTabSelected('health')} + /> + + ) : undefined} + + {tab === 'health' ? : undefined} + + {tab === 'yaml' ? ( + + ) : undefined} + ) : undefined} @@ -114,12 +137,13 @@ const Container = styled.section` border-left-width: 1px; border-left-style: solid; overflow: hidden; + min-width: 300px; `; const TitleContainer = styled(Flex)` align-items: center; justify-content: space-between; - height: ${p => p.theme.space[8]}px; + 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; @@ -144,10 +168,60 @@ const ContentContainer = styled.div` display: flex; flex-direction: column; flex: 1; + min-height: 0; `; -const YamlContaner = styled(Flex)` +const TabContentContainer = styled(Flex)` + overflow: auto; flex: 1; - border-radius: ${props => props.theme.space[2]}px; background-color: ${({ theme }) => theme.colors.levels.surface}; `; + +const tabs = [ + { + id: 'info', + label: 'Overview', + }, + { + id: 'health', + label: 'Services', + }, + { id: 'yaml', label: 'YAML' }, +] as const; + +type TabId = (typeof tabs)[number]['id']; + +function Tabs(props: { + activeTab: TabId; + onTabSelected: (tab: TabId) => void; +}) { + const { activeTab, onTabSelected } = props; + const { borderRef, parentRef } = useSlidingBottomBorderTabs({ activeTab }); + + return ( + + {tabs.map(t => ( + onTabSelected(t.id)} + role="tab" + > + {t.label} + + ))} + + + ); +} + +const StyledTabsContainer = styled(TabsContainer)` + gap: 0; +`; + +const StyledTabContainer = styled(TabContainer)` + padding: ${p => p.theme.space[2]}px ${p => p.theme.space[3]}px; + font-weight: ${p => p.theme.fontWeights.medium}; + font-size: ${p => p.theme.fontSizes[2]}px; +`; diff --git a/web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx b/web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx new file mode 100644 index 0000000000000..8e752fb08bd03 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/HealthTab.test.tsx @@ -0,0 +1,125 @@ +/** + * 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 userEvent from '@testing-library/user-event'; +import { ComponentProps, PropsWithChildren } from 'react'; + +import darkTheme from 'design/theme/themes/darkTheme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { render, screen, within } from 'design/utils/testing'; + +import { mockGetBotInstanceResponse } from 'teleport/test/helpers/botInstances'; + +import { HealthTab } from './HealthTab'; + +beforeAll(() => { + jest.useFakeTimers({ + now: new Date('2025-10-10T11:00:00Z'), + }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +describe('HealthTab', () => { + // eslint-disable-next-line jest/expect-expect + it('renders', async () => { + renderComponent(); + + expectItem({ + name: 'application-tunnel-1', + type: 'application-tunnel', + updatedAt: 'Reported 15 minutes ago', + status: 'Healthy', + }); + + expectItem({ + name: 'db-eu-lon-1', + type: 'database-tunnel', + updatedAt: 'Reported 14 minutes ago', + status: 'Unhealthy', + reason: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }); + + expectItem({ + name: 'workload-identity-aws-roles-anywhere-1', + type: 'workload-identity-aws-roles-anywhere', + updatedAt: 'Reported 13 minutes ago', + status: 'Initializing', + }); + + expectItem({ + name: 'application-tunnel-2', + type: 'application-tunnel', + updatedAt: 'Reported 12 minutes ago', + status: 'Unknown', + }); + }); + + it('show an empty state', async () => { + renderComponent({ + data: { + bot_instance: { + status: { + service_health: [], + }, + }, + }, + }); + + expect(screen.getByText('No reported services')).toBeInTheDocument(); + }); +}); + +function expectItem(match: { + name: string; + type: string; + updatedAt: string; + status: string; + reason?: string; +}) { + const item = screen.getByTestId(match.name); + expect(within(item).getByText(match.name)).toBeInTheDocument(); + expect(within(item).getByText(`Type: ${match.type}`)).toBeInTheDocument(); + expect(within(item).getByText(match.updatedAt)).toBeInTheDocument(); + expect(within(item).getByText(match.status)).toBeInTheDocument(); + if (match.reason) { + expect(within(item).getByText(match.reason)).toBeInTheDocument(); + } +} + +function renderComponent(props?: Partial>) { + const { data = mockGetBotInstanceResponse } = props ?? {}; + const user = userEvent.setup(); + + return { + ...render(, { wrapper: makeWrapper() }), + user, + history, + }; +} + +function makeWrapper() { + return (props: PropsWithChildren) => ( + + {props.children} + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/HealthTab.tsx b/web/packages/teleport/src/BotInstances/Details/HealthTab.tsx new file mode 100644 index 0000000000000..b3d7806892ea4 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/HealthTab.tsx @@ -0,0 +1,201 @@ +/** + * 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 format from 'date-fns/format'; +import formatDistanceToNowStrict from 'date-fns/formatDistanceToNowStrict'; +import styled from 'styled-components'; + +import Flex from 'design/Flex'; +import { SecondaryOutlined } from 'design/Label/Label'; +import Text from 'design/Text'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; + +import { + BotInstanceServiceHealthStatus, + GetBotInstanceResponse, +} from 'teleport/services/bot/types'; + +export function HealthTab(props: { data: GetBotInstanceResponse }) { + const { data } = props; + const { bot_instance } = data ?? {}; + const { status } = bot_instance ?? {}; + const { service_health } = status ?? {}; + + return ( + + {service_health?.length ? ( + service_health + ?.toSorted((a, b) => + (a.service?.name ?? '').localeCompare(b.service?.name ?? '') + ) + .map(h => + h.service?.name ? ( + + + + {h.service.name} + {h.service.type ? ( + Type: {h.service.type} + ) : undefined} + + + + {h.updated_at?.seconds ? ( + + {`Reported ${formatDistanceToNowStrict(new Date(h.updated_at.seconds * 1000))} ago`} + + ) : undefined} + + + + {makeHealthLabel(h.status)} + + + + + + + + {h.reason ? ( + + {h.reason} + + ) : undefined} + + ) : undefined + ) + ) : ( + No reported services + )} + + ); +} + +const Container = styled(Flex)` + flex-direction: column; + flex: 1; + min-width: 0; + padding: ${({ theme }) => theme.space[3]}px; + gap: ${({ theme }) => theme.space[3]}px; + overflow: auto; +`; + +const ItemContainer = styled(Flex)` + flex-direction: column; + border: 1px solid ${p => p.theme.colors.interactive.tonal.neutral[0]}; + border-radius: ${({ theme }) => theme.space[1]}px; + padding: ${({ theme }) => theme.space[3]}px; + gap: ${({ theme }) => theme.space[3]}px; +`; + +const HealthStatusDot = styled.div<{ + $status: BotInstanceServiceHealthStatus | undefined; +}>` + width: ${({ theme }) => theme.space[3] - theme.space[1]}px; + height: ${({ theme }) => theme.space[3] - theme.space[1]}px; + border-radius: 999px; + background-color: ${({ theme, $status }) => + $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ? theme.colors.interactive.solid.success.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ? theme.colors.interactive.solid.danger.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ? theme.colors.interactive.tonal.neutral[1] + : theme.colors.interactive.solid.alert.default}; +`; + +const ReasonContainer = styled.div<{ + $status: BotInstanceServiceHealthStatus | undefined; +}>` + border-width: 0; + border-left-width: ${({ theme }) => theme.space[1]}px; + border-style: solid; + border-color: ${({ theme, $status }) => + $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ? theme.colors.interactive.solid.success.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ? theme.colors.interactive.solid.danger.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ? theme.colors.interactive.tonal.neutral[1] + : theme.colors.interactive.solid.alert.default}; + padding: 0 ${({ theme }) => theme.space[2]}px; +`; + +const TitleText = styled(Text).attrs({ + typography: 'body2', +})` + white-space: nowrap; + font-weight: ${({ theme }) => theme.fontWeights.medium}; +`; + +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; +`; + +const TimeText = styled(Text).attrs({ + typography: 'body4', +})` + white-space: nowrap; +`; + +function makeHealthLabel(status: BotInstanceServiceHealthStatus | undefined) { + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ) { + return 'Initializing'; + } + if ( + status === BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ) { + return 'Healthy'; + } + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ) { + return 'Unhealthy'; + } + return 'Unknown'; +} diff --git a/web/packages/teleport/src/BotInstances/Details/InfoTab.test.tsx b/web/packages/teleport/src/BotInstances/Details/InfoTab.test.tsx new file mode 100644 index 0000000000000..56437fc718508 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/InfoTab.test.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 . + */ + +// Required to allow using `.closest()` to find sections +/* eslint-disable testing-library/no-node-access */ + +import userEvent from '@testing-library/user-event'; +import { createMemoryHistory } from 'history'; +import { ComponentProps, PropsWithChildren } from 'react'; +import { Router } from 'react-router'; + +import { darkTheme } from 'design/theme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { render, screen, within } from 'design/utils/testing'; + +import cfg from 'teleport/config'; +import { mockGetBotInstanceResponse } from 'teleport/test/helpers/botInstances'; + +import { InfoTab } from './InfoTab'; + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('InfoTab', () => { + it('renders summary section', async () => { + renderComponent(); + + const section = screen + .getByRole('heading', { name: 'Summary' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + expectFieldAndValue('Bot name', 'test-bot-name', section); + expectFieldAndValue('Up time', '12h 1m', section); + expectFieldAndValue('Kind', 'tctl', section); + expectFieldAndValue('Version', 'v18.4.0', section); + expectFieldAndValue('OS', 'linux', section); + expectFieldAndValue('Hostname', 'test-hostname', section); + }); + + it('renders health section', async () => { + renderComponent(); + + const section = screen + .getByRole('heading', { name: 'Health Status' }) + .closest('section'); + + expect( + within(section!).getByText( + (_, element) => element?.textContent === '1 of 4 services are healthy' + ) + ).toBeInTheDocument(); + + expect( + within(section!).getByText('application-tunnel-1', {}) + ).toBeInTheDocument(); + expect(within(section!).getByText('db-eu-lon-1', {})).toBeInTheDocument(); + expect( + within(section!).getByText('workload-identity-aws-roles-anywhere-1', {}) + ).toBeInTheDocument(); + expect( + within(section!).getByText('application-tunnel-2', {}) + ).toBeInTheDocument(); + }); + + it('renders join token section', async () => { + renderComponent(); + + const section = screen + .getByRole('heading', { name: 'Join Token' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + expectFieldAndValue('Name', 'test-token-name', section); + expectFieldAndValue('Method', 'github', section); + expectFieldAndValue('Repository', 'gravitational/teleport', section); + expectFieldAndValue('Subject', 'test-github-sub', section); + }); + + it('navigate on bot name link click', async () => { + const { history, user } = renderComponent(); + const pushMock = jest.spyOn(history, 'push'); + + const section = screen + .getByRole('heading', { name: 'Summary' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + const link = within(section!).getByText('test-bot-name'); + await user.click(link); + + expect(pushMock).toHaveBeenCalledTimes(1); + expect(pushMock).toHaveBeenLastCalledWith('/web/bot/test-bot-name'); + }); + + it('navigate on join token name link click', async () => { + const { history, user } = renderComponent(); + const pushMock = jest.spyOn(history, 'push'); + + const section = screen + .getByRole('heading', { name: 'Join Token' }) + .closest('section'); + expect(section).toBeInTheDocument(); + + const link = within(section!).getByText('test-token-name'); + await user.click(link); + + expect(pushMock).toHaveBeenCalledTimes(1); + expect(pushMock).toHaveBeenLastCalledWith('/web/tokens'); + }); + + it('callback on "view services" click', async () => { + const callback = jest.fn(); + const { user } = renderComponent({ onGoToServicesClick: callback }); + + const section = screen + .getByRole('heading', { name: 'Health Status' }) + .closest('section'); + + const button = within(section!).getByText('View Services'); + await user.click(button); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +function expectFieldAndValue( + field: string, + value: string, + container?: HTMLElement | null +) { + if (container) { + expect(within(container).getByText(field)).toBeInTheDocument(); + expect(within(container).getByText(value)).toBeInTheDocument(); + } else { + expect(screen.getByText(field)).toBeInTheDocument(); + expect(screen.getByText(value)).toBeInTheDocument(); + } +} + +function renderComponent(props?: Partial>) { + const { data = mockGetBotInstanceResponse, onGoToServicesClick = jest.fn() } = + props ?? {}; + const user = userEvent.setup(); + + const history = createMemoryHistory({ + initialEntries: [cfg.getBotInstancesRoute()], + }); + + return { + ...render( + , + { wrapper: makeWrapper({ history }) } + ), + user, + history, + }; +} + +function makeWrapper(options: { + history: ReturnType; +}) { + return (props: PropsWithChildren) => ( + + {/* A Router with history is required to render */} + {props.children} + + ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/InfoTab.tsx b/web/packages/teleport/src/BotInstances/Details/InfoTab.tsx new file mode 100644 index 0000000000000..80d41b6d054bd --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/InfoTab.tsx @@ -0,0 +1,418 @@ +/** + * 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 React from 'react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; + +import Box from 'design/Box/Box'; +import Flex from 'design/Flex/Flex'; +import { SecondaryOutlined } from 'design/Label/Label'; +import Text from 'design/Text'; +import { HoverTooltip } from 'design/Tooltip/HoverTooltip'; +import { IconTooltip } from 'design/Tooltip/IconTooltip'; +import { CopyButton } from 'shared/components/CopyButton/CopyButton'; + +import { Panel } from 'teleport/Bots/Details/Panel'; +import { formatDuration } from 'teleport/Bots/formatDuration'; +import cfg from 'teleport/config'; +import { + BotInstanceKind, + BotInstanceServiceHealthStatus, + GetBotInstanceResponse, + GetBotInstanceResponseJoinAttrs, +} from 'teleport/services/bot/types'; + +export function InfoTab(props: { + data: GetBotInstanceResponse; + onGoToServicesClick: () => void; +}) { + const { + data: { bot_instance }, + onGoToServicesClick, + } = props; + + const { spec, status } = bot_instance ?? {}; + const { bot_name } = spec ?? {}; + const { latest_heartbeats, latest_authentications, service_health } = + status ?? {}; + const latestHeartbeat = latest_heartbeats?.at(-1); + const { kind, uptime, version, os, hostname } = latestHeartbeat ?? {}; + const latestAuthentication = latest_authentications?.at(-1); + const { join_attrs } = latestAuthentication ?? {}; + const { meta } = join_attrs ?? {}; + const { join_method, join_token_name } = meta ?? {}; + + const joinExtras = makeJoinExtras(join_attrs); + + const { kindLabel, kindTooltip } = makeKindInfo(kind) ?? {}; + + const healthyCount = + service_health?.filter( + h => + h.status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ).length ?? 0; + const totalCount = service_health?.length ?? 0; + + return ( + + + + + Bot name + {bot_name ? ( + + + + {bot_name} + + + + + ) : ( + '-' + )} + Up time + {uptime?.seconds + ? formatDuration( + { seconds: uptime.seconds }, + { + separator: ' ', + } + ) + : '-'} + Kind + {kindLabel ? ( + + {kindLabel} + + {kindTooltip} + + + ) : ( + '-' + )} + Version + {version ? `v${version}` : '-'} + OS + {os || '-'} + Hostname + {hostname || '-'} + + + + + + + + +
+ {healthyCount} of{' '} + {totalCount} services are healthy +
+ + + {service_health + ?.toSorted((a, b) => + (a.service?.name ?? '').localeCompare(b.service?.name ?? '') + ) + .map(h => + h.service?.name ? ( + + + + + {h.service.name} + + + + ) : undefined + )} + +
+
+ + + + + + + Name + {join_token_name ? ( + + + + {join_token_name} + + + + + ) : ( + '-' + )} + Method + {join_method || '-'} + + {joinExtras + ? joinExtras.map(([label, value]) => ( + + {label} + {value || '-'} + + )) + : undefined} + + + +
+ ); +} + +const Container = styled.div` + flex: 1; + min-width: 0; +`; + +const PanelContentContainer = styled(Flex)` + flex-direction: column; + padding: ${props => props.theme.space[3]}px; + padding-top: 0; + overflow: hidden; +`; + +const Grid = styled(Box)` + align-self: flex-start; + display: grid; + grid-template-columns: repeat(2, auto); + gap: ${({ theme }) => theme.space[2]}px; + overflow: hidden; +`; + +const GridLabel = styled(Text)` + color: ${({ theme }) => theme.colors.text.muted}; + font-weight: ${({ theme }) => theme.fontWeights.regular}; + padding-right: ${({ theme }) => theme.space[2]}px; +`; + +const GridValue = styled(Text)` + white-space: nowrap; +`; + +const PaddedDivider = styled.div` + height: 1px; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; + flex-shrink: 0; + margin-left: ${props => props.theme.space[3]}px; + margin-right: ${props => props.theme.space[3]}px; +`; + +const HealthLabelsContainer = styled(Flex)` + flex-wrap: wrap; + overflow: hidden; + gap: ${props => props.theme.space[1]}px; +`; + +const HealthLabelText = styled(Text).attrs({ + typography: 'body3', +})` + white-space: nowrap; +`; + +const HealthStatusDot = styled.div<{ + $status: BotInstanceServiceHealthStatus | undefined; +}>` + width: ${props => props.theme.space[3] - props.theme.space[1]}px; + height: ${props => props.theme.space[3] - props.theme.space[1]}px; + border-radius: 999px; + background-color: ${({ theme, $status }) => + $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ? theme.colors.interactive.solid.success.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ? theme.colors.interactive.solid.danger.default + : $status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED + ? theme.colors.interactive.solid.alert.default + : theme.colors.interactive.tonal.neutral[1]}; +`; + +const AccentCountText = styled(Text)` + font-size: ${({ theme }) => theme.fontSizes[8]}px; + font-weight: ${({ theme }) => theme.fontWeights.light}px; +`; + +const StyledLink = styled(Link)` + color: ${({ theme }) => theme.colors.interactive.solid.accent.default}; + background: none; + text-decoration: underline; + text-transform: none; + + &:hover { + color: ${({ theme }) => theme.colors.interactive.solid.accent.hover}; + } + + &:active { + color: ${({ theme }) => theme.colors.interactive.solid.accent.active}; + } +`; + +function makeKindInfo(kind: BotInstanceKind | undefined) { + if (kind === BotInstanceKind.BOT_KIND_TBOT) { + return { + kindLabel: 'tbot', + kindTooltip: 'This instance is running using the tbot CLI.', + }; + } + if (kind === BotInstanceKind.BOT_KIND_TERRAFORM_PROVIDER) { + return { + kindLabel: 'Terraform', + kindTooltip: + 'This instance is running using the Teleport Terraform Provider.', + }; + } + if (kind === BotInstanceKind.BOT_KIND_KUBERNETES_OPERATOR) { + return { + kindLabel: 'Kubernetes', + kindTooltip: + 'This instance is running using the Teleport Kubernetes Operator.', + }; + } + if (kind === BotInstanceKind.BOT_KIND_TCTL) { + return { + kindLabel: 'tctl', + kindTooltip: 'This instance is running inside tctl.', + }; + } + + return undefined; +} + +function makeHealthTooltip(status: BotInstanceServiceHealthStatus | undefined) { + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING + ) { + return 'Status: Initializing'; + } + if ( + status === BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY + ) { + return 'Status: Healthy'; + } + if ( + status === + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY + ) { + return 'Status: Unhealthy'; + } + return 'Status: Unspecified'; +} + +function makeJoinExtras( + joinAttrs?: GetBotInstanceResponseJoinAttrs | null +): [string, string | undefined][] { + if (joinAttrs?.azure) { + return [ + ['Resource group', joinAttrs.azure.resource_group], + ['Subscription', joinAttrs.azure.subscription], + ]; + } + if (joinAttrs?.azure_devops) { + return [ + ['Repository ID', joinAttrs.azure_devops.pipeline?.repository_id], + ['Subject', joinAttrs.azure_devops.pipeline?.sub], + ]; + } + if (joinAttrs?.bitbucket) { + return [ + ['Repository UUID', joinAttrs.bitbucket.repository_uuid], + ['Subject', joinAttrs.bitbucket.sub], + ]; + } + if (joinAttrs?.circleci) { + return [ + ['Project ID', joinAttrs.circleci.project_id], + ['Subject', joinAttrs.circleci.sub], + ]; + } + if (joinAttrs?.gcp) { + return [['Service account', joinAttrs.gcp.service_account]]; + } + if (joinAttrs?.github) { + return [ + ['Repository', joinAttrs.github.repository], + ['Subject', joinAttrs.github.sub], + ]; + } + if (joinAttrs?.gitlab) { + return [ + ['Project path', joinAttrs.gitlab.project_path], + ['Subject', joinAttrs.gitlab.sub], + ]; + } + if (joinAttrs?.iam) { + return [ + ['Account', joinAttrs.iam.account], + ['ARN', joinAttrs.iam.arn], + ]; + } + if (joinAttrs?.kubernetes) { + return [['Subject', joinAttrs.kubernetes.subject]]; + } + if (joinAttrs?.oracle) { + return [ + ['Tenancy ID', joinAttrs.oracle.tenancy_id], + ['Compartment ID', joinAttrs.oracle.compartment_id], + ]; + } + if (joinAttrs?.spacelift) { + return [ + ['Space ID', joinAttrs.spacelift.space_id], + ['Subject', joinAttrs.spacelift.sub], + ]; + } + if (joinAttrs?.terraform_cloud) { + return [ + ['Workspace', joinAttrs.terraform_cloud.full_workspace], + ['Subject', joinAttrs.terraform_cloud.sub], + ]; + } + if (joinAttrs?.tpm) { + return [['Public key', joinAttrs.tpm.ek_pub_hash]]; + } + return []; +} diff --git a/web/packages/teleport/src/services/bot/types.ts b/web/packages/teleport/src/services/bot/types.ts index 06e4f29efe87c..6dea8400a811f 100644 --- a/web/packages/teleport/src/services/bot/types.ts +++ b/web/packages/teleport/src/services/bot/types.ts @@ -77,11 +77,114 @@ export type GetBotInstanceResponse = { bot_instance?: { spec?: { instance_id?: string; + bot_name?: string; } | null; + status?: { + latest_heartbeats?: + | { + uptime?: { + seconds?: number; + } | null; + version?: string; + os?: string; + hostname?: string; + kind?: BotInstanceKind; + }[] + | null; + latest_authentications?: + | { + join_attrs?: GetBotInstanceResponseJoinAttrs | null; + }[] + | null; + service_health?: + | { + service?: { + type?: string; + name?: string; + } | null; + status?: BotInstanceServiceHealthStatus; + reason?: string; + updated_at?: { seconds: number } | null; + }[] + | null; + }; } | null; yaml?: string; }; +export enum BotInstanceServiceHealthStatus { + BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED = 0, + BOT_INSTANCE_HEALTH_STATUS_INITIALIZING = 1, + BOT_INSTANCE_HEALTH_STATUS_HEALTHY = 2, + BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY = 3, +} + +export enum BotInstanceKind { + BOT_KIND_UNSPECIFIED = 0, + BOT_KIND_TBOT = 1, + BOT_KIND_TERRAFORM_PROVIDER = 2, + BOT_KIND_KUBERNETES_OPERATOR = 3, + BOT_KIND_TCTL = 4, +} + +export type GetBotInstanceResponseJoinAttrs = { + meta?: { + join_token_name?: string; + join_method?: string; + } | null; + gitlab?: { + sub?: string; + project_path?: string; + } | null; + github?: { + sub?: string; + repository?: string; + } | null; + iam?: { + account?: string; + arn?: string; + } | null; + tpm?: { + ek_pub_hash?: string; + } | null; + azure?: { + subscription?: string; + resource_group?: string; + } | null; + circleci?: { + sub?: string; + project_id?: string; + } | null; + bitbucket?: { + sub?: string; + repository_uuid?: string; + } | null; + terraform_cloud?: { + sub?: string; + full_workspace?: string; + } | null; + spacelift?: { + sub?: string; + space_id?: string; + } | null; + gcp?: { + service_account?: string; + } | null; + kubernetes?: { + subject?: string; + } | null; + oracle?: { + tenancy_id?: string; + compartment_id?: string; + } | null; + azure_devops?: { + pipeline?: { + sub?: string; + repository_id?: string; + } | null; + } | null; +}; + export type GetBotInstanceMetricsResponse = { upgrade_statuses?: { unsupported?: BotInstanceMetric | null; diff --git a/web/packages/teleport/src/test/helpers/botInstances.ts b/web/packages/teleport/src/test/helpers/botInstances.ts index bb42fbae872c3..faa0b85f50e38 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -20,6 +20,8 @@ import { http, HttpResponse } from 'msw'; import cfg from 'teleport/config'; import { + BotInstanceKind, + BotInstanceServiceHealthStatus, GetBotInstanceMetricsResponse, GetBotInstanceResponse, ListBotInstancesResponse, @@ -59,9 +61,9 @@ export const listBotInstancesError = ( } ); -export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => +export const getBotInstanceSuccess = (mock?: GetBotInstanceResponse) => http.get(cfg.api.botInstance.read, () => { - return HttpResponse.json(mock); + return HttpResponse.json(mock ?? mockGetBotInstanceResponse); }); export const getBotInstanceError = ( @@ -81,6 +83,91 @@ export const getBotInstanceForever = () => }) ); +export const mockGetBotInstanceResponse = { + bot_instance: { + spec: { + instance_id: 'a55259e8-9b17-466f-9d37-ab390ca4024e', + bot_name: 'test-bot-name', + }, + status: { + latest_heartbeats: [ + { + uptime: { + seconds: 43200 + 60, + }, + version: '18.4.0', + hostname: 'test-hostname', + os: 'linux', + kind: BotInstanceKind.BOT_KIND_TCTL, + }, + ], + latest_authentications: [ + { + join_attrs: { + meta: { + join_method: 'github', + join_token_name: 'test-token-name', + }, + github: { + sub: 'test-github-sub', + repository: 'gravitational/teleport', + }, + }, + }, + ], + service_health: [ + { + service: { + name: 'application-tunnel-1', + type: 'application-tunnel', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_HEALTHY, + updated_at: { + seconds: new Date('2025-10-10T10:45:00Z').getTime() / 1_000, + }, + }, + { + service: { + name: 'db-eu-lon-1', + type: 'database-tunnel', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNHEALTHY, + updated_at: { + seconds: new Date('2025-10-10T10:46:00Z').getTime() / 1_000, + }, + reason: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, + { + service: { + name: 'workload-identity-aws-roles-anywhere-1', + type: 'workload-identity-aws-roles-anywhere', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_INITIALIZING, + updated_at: { + seconds: new Date('2025-10-10T10:47:00Z').getTime() / 1_000, + }, + }, + { + service: { + name: 'application-tunnel-2', + type: 'application-tunnel', + }, + status: + BotInstanceServiceHealthStatus.BOT_INSTANCE_HEALTH_STATUS_UNSPECIFIED, + updated_at: { + seconds: new Date('2025-10-10T10:48:00Z').getTime() / 1_000, + }, + }, + ], + }, + }, + yaml: 'kind: bot_instance\nversion: v1\n', +}; + export const getBotInstanceMetricsSuccess = ( mock?: GetBotInstanceMetricsResponse ) =>