diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 1ed03495a1af2..15bcd748f3dd6 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -1169,6 +1169,8 @@ func (h *Handler) bindDefaultEndpoints() { // Delete Machine ID bot h.DELETE("/webapi/sites/:site/machine-id/bot/:name", h.WithClusterAuth(h.deleteBot)) + // GET Machine ID instance for a bot by id + h.GET("/webapi/sites/:site/machine-id/bot/:name/bot-instance/:id", h.WithClusterAuth(h.getBotInstance)) // GET Machine ID bot instances (paged) h.GET("/webapi/sites/:site/machine-id/bot-instance", h.WithClusterAuth(h.listBotInstances)) diff --git a/lib/web/machineid.go b/lib/web/machineid.go index bd49a7e8ee55f..a5bddfdc7d304 100644 --- a/lib/web/machineid.go +++ b/lib/web/machineid.go @@ -21,6 +21,7 @@ import ( "strconv" "time" + yaml "github.com/ghodss/yaml" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" "google.golang.org/protobuf/types/known/fieldmaskpb" @@ -262,6 +263,45 @@ type updateBotRequest struct { Roles []string `json:"roles"` } +// getBotInstance retrieves a bot instance by id +func (h *Handler) getBotInstance(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { + botName := p.ByName("name") + instanceId := p.ByName("id") + if botName == "" { + return nil, trace.BadParameter("empty bot name") + } + if instanceId == "" { + return nil, trace.BadParameter("empty id") + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + instance, err := clt.BotInstanceServiceClient().GetBotInstance(r.Context(), &machineidv1.GetBotInstanceRequest{ + InstanceId: instanceId, + BotName: botName, + }) + if err != nil { + return nil, trace.Wrap(err, "error querying bot instance") + } + + yaml, err := yaml.Marshal(types.ProtoResource153ToLegacy(instance)) + if err != nil { + return nil, trace.Wrap(err, "error stringifying to yaml") + } + + return GetBotInstanceResponse{ + BotInstance: instance, + YAML: string(yaml), + }, nil +} + +type GetBotInstanceResponse struct { + BotInstance *machineidv1.BotInstance `json:"bot_instance"` + YAML string `json:"yaml"` +} + // listBotInstances returns a list of bot instances for a given cluster site. func (h *Handler) listBotInstances(_ http.ResponseWriter, r *http.Request, _ httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (any, error) { clt, err := sctx.GetUserClient(r.Context(), site) diff --git a/lib/web/machineid_test.go b/lib/web/machineid_test.go index 944e92f8d42ef..dbe1bd378edca 100644 --- a/lib/web/machineid_test.go +++ b/lib/web/machineid_test.go @@ -32,6 +32,7 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/timestamppb" machineidv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/machineid/v1" @@ -372,14 +373,14 @@ func TestListBotInstances(t *testing.T) { "bot-instance", ) - instanceId := uuid.New().String() + instanceID := uuid.New().String() _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ Kind: types.KindBotInstance, Version: types.V1, Spec: &machineidv1.BotInstanceSpec{ BotName: "test-bot", - InstanceId: instanceId, + InstanceId: instanceID, }, Status: &machineidv1.BotInstanceStatus{ LatestHeartbeats: []*machineidv1.BotInstanceStatusHeartbeat{ @@ -420,7 +421,7 @@ func TestListBotInstances(t *testing.T) { require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ BotInstances: []BotInstance{ { - InstanceId: instanceId, + InstanceId: instanceID, BotName: "test-bot", JoinMethodLatest: "test-join-method", HostNameLatest: "test-hostname", @@ -447,14 +448,14 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { "bot-instance", ) - instanceId := uuid.New().String() + instanceID := uuid.New().String() _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ Kind: types.KindBotInstance, Version: types.V1, Spec: &machineidv1.BotInstanceSpec{ BotName: "test-bot", - InstanceId: instanceId, + InstanceId: instanceID, }, Status: &machineidv1.BotInstanceStatus{ InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ @@ -482,7 +483,7 @@ func TestListBotInstancesWithInitialHeartbeat(t *testing.T) { require.Empty(t, cmp.Diff(instances, ListBotInstancesResponse{ BotInstances: []BotInstance{ { - InstanceId: instanceId, + InstanceId: instanceID, BotName: "test-bot", JoinMethodLatest: "test-join-method", HostNameLatest: "test-hostname", @@ -722,3 +723,67 @@ func TestListBotInstancesWithSearchTermFilter(t *testing.T) { }) } } + +func TestGetBotInstance(t *testing.T) { + ctx := context.Background() + env := newWebPack(t, 1) + proxy := env.proxies[0] + pack := proxy.authPack(t, "admin", []types.Role{services.NewPresetEditorRole()}) + clusterName := env.server.ClusterName() + + botName := "test-bot" + instanceID := uuid.New().String() + + _, err := env.server.Auth().CreateBotInstance(ctx, &machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: botName, + InstanceId: instanceID, + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + RecordedAt: ×tamppb.Timestamp{ + Seconds: 1, + Nanos: 0, + }, + }, + }, + }) + require.NoError(t, err) + + endpoint := pack.clt.Endpoint( + "webapi", + "sites", + clusterName, + "machine-id", + "bot", + botName, + "bot-instance", + instanceID, + ) + response, err := pack.clt.Get(ctx, endpoint, url.Values{}) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, response.Code(), "unexpected status code") + + var resp GetBotInstanceResponse + require.NoError(t, json.Unmarshal(response.Bytes(), &resp), "invalid response received") + + require.Empty(t, cmp.Diff(resp.BotInstance, machineidv1.BotInstance{ + Kind: types.KindBotInstance, + Version: types.V1, + Spec: &machineidv1.BotInstanceSpec{ + BotName: botName, + InstanceId: instanceID, + }, + Status: &machineidv1.BotInstanceStatus{ + InitialHeartbeat: &machineidv1.BotInstanceStatusHeartbeat{ + RecordedAt: ×tamppb.Timestamp{ + Seconds: 1, + Nanos: 0, + }, + }, + }, + }, protocmp.Transform(), protocmp.IgnoreFields(&machineidv1.BotInstance{}, "metadata"))) + assert.YAMLEq(t, fmt.Sprintf("kind: bot_instance\nmetadata:\n name: %[1]s\n revision: %[2]s\nspec:\n bot_name: test-bot\n instance_id: %[1]s\nstatus:\n initial_heartbeat:\n recorded_at: \"1970-01-01T00:00:01Z\"\nversion: v1\n", instanceID, resp.BotInstance.Metadata.Revision), resp.YAML) +} diff --git a/web/packages/shared/components/UnifiedResources/shared/CopyButton.test.tsx b/web/packages/shared/components/UnifiedResources/shared/CopyButton.test.tsx new file mode 100644 index 0000000000000..f02789bf9cb38 --- /dev/null +++ b/web/packages/shared/components/UnifiedResources/shared/CopyButton.test.tsx @@ -0,0 +1,46 @@ +/** + * 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 { copyToClipboard } from 'design/utils/copyToClipboard'; +import { fireEvent, render, screen } from 'design/utils/testing'; + +import { CopyButton } from './CopyButton'; + +jest.mock('design/utils/copyToClipboard', () => { + return { + __esModule: true, + copyToClipboard: jest.fn(), + }; +}); + +describe('CopyButton', () => { + it('prevents parent elements from stealing clicks', () => { + const parentClick = jest.fn(); + + render( +
+ +
+ ); + + fireEvent.click(screen.getByLabelText('copy')); + + expect(parentClick).toHaveBeenCalledTimes(0); + expect(copyToClipboard).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx index edb0589ff3ef1..6565e5ee7663d 100644 --- a/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx +++ b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { useEffect, useRef, useState } from 'react'; +import { MouseEventHandler, useEffect, useRef, useState } from 'react'; import Box from 'design/Box'; import ButtonIcon from 'design/ButtonIcon'; @@ -46,7 +46,9 @@ export function CopyButton({ } }; - const handleCopy = () => { + const handleCopy: MouseEventHandler = e => { + e.stopPropagation(); // Prevent parent onClick callbacks from stealing the click + clearCurrentTimeout(); setCopiedText(copySuccess); copyToClipboard(name); @@ -63,7 +65,12 @@ export function CopyButton({ return ( - + {copiedText === copySuccess ? ( ) : ( diff --git a/web/packages/teleport/src/BotInstances/BotInstances.tsx b/web/packages/teleport/src/BotInstances/BotInstances.tsx index f2c0c2601c6ca..1d5b25a6df94e 100644 --- a/web/packages/teleport/src/BotInstances/BotInstances.tsx +++ b/web/packages/teleport/src/BotInstances/BotInstances.tsx @@ -36,7 +36,9 @@ 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 { BotInstancesList } from './List/BotInstancesList'; @@ -87,6 +89,18 @@ export function BotInstances() { }); }, []); + const onItemSelected = useCallback( + (item: BotInstanceSummary) => { + history.push( + cfg.getBotInstanceDetailsRoute({ + botName: item.bot_name, + instanceId: item.instance_id, + }) + ); + }, + [history] + ); + return ( @@ -112,6 +126,7 @@ export function BotInstances() { onFetchPrev={hasPrevPage ? handleFetchPrev : undefined} onSearchChange={handleSearchChange} searchTerm={searchTerm} + onItemSelected={onItemSelected} /> ) : undefined} diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx new file mode 100644 index 0000000000000..b347c96b79daa --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.test.tsx @@ -0,0 +1,210 @@ +/** + * 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 { PropsWithChildren } from 'react'; +import { MemoryRouter, useHistory } from 'react-router'; + +import darkTheme from 'design/theme/themes/darkTheme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { copyToClipboard } from 'design/utils/copyToClipboard'; +import { + fireEvent, + render, + screen, + testQueryClient, + waitForElementToBeRemoved, +} from 'design/utils/testing'; + +import { + getBotInstanceError, + getBotInstanceSuccess, +} from 'teleport/test/helpers/botInstances'; + +import { BotInstanceDetails } from './BotInstanceDetails'; + +jest.mock('react-router', () => { + const actual = jest.requireActual('react-router'); + return { + ...actual, + useHistory: jest.fn(), + useParams: jest.fn(() => ({ + botName: 'test-bot-name', + instanceId: '4fa10e68-f2e0-4cf9-ad5b-1458febcd827', + })), + }; +}); + +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(); + +beforeEach(() => { + server.listen(); +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + + jest.clearAllMocks(); +}); + +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 goBack = jest.fn(); + jest.mocked(useHistory).mockImplementation( + () => + ({ + goBack, + }) as unknown as ReturnType + ); + + withSuccessResponse(); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const backButton = screen.getByLabelText('back'); + fireEvent.click(backButton); + + expect(goBack).toHaveBeenCalledTimes(1); + }); + + it('Shows the short instance id', async () => { + withSuccessResponse(); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect(screen.getByText('4fa10e6')).toBeInTheDocument(); + }); + + it('Allows the full instance id to be copied', async () => { + withSuccessResponse(); + + render(, { wrapper: Wrapper }); + + 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(); + + render(, { + wrapper: Wrapper, + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + const docsButton = screen.getByText('View Documentation'); + fireEvent.click(docsButton); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('Shows full yaml', async () => { + withSuccessResponse(); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect( + screen.getByText('kind: bot_instance version: v1') + ).toBeInTheDocument(); + }); + + it('Shows an error', async () => { + withErrorResponse(); + + render(, { wrapper: Wrapper }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading')); + + expect( + screen.getByText('Error: 500', { exact: false }) + ).toBeInTheDocument(); + }); +}); + +function Wrapper(props: PropsWithChildren) { + return ( + + + + {props.children} + + + + ); +} + +function MockTextEditor(props: { data?: [{ content: string }] }) { + return ( +
+ {props.data?.map(d =>
{d.content}
)} +
+ ); +} diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx new file mode 100644 index 0000000000000..717857865009c --- /dev/null +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx @@ -0,0 +1,146 @@ +/** + * 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 { MouseEventHandler, useCallback } from 'react'; +import { useHistory, 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 { Indicator } from 'design/Indicator/Indicator'; +import Text from 'design/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 { useGetBotInstance } from '../hooks'; + +const docsUrl = + 'https://goteleport.com/docs/enroll-resources/machine-id/introduction/#bot-instances'; + +export function BotInstanceDetails(props: { + onDocsLinkClickedForTesting?: MouseEventHandler; +}) { + const history = useHistory(); + const params = useParams<{ + botName: string; + instanceId: string; + }>(); + + const { data, error, isSuccess, isError, isLoading } = useGetBotInstance( + params, + { + staleTime: 30_000, // Keep data in the cache for 30 seconds + } + ); + + const handleBackPress = useCallback(() => { + history.goBack(); + }, [history]); + + return ( + + + + + + + + + Bot instance + {isSuccess && data.bot_instance?.spec?.instance_id ? ( + + + + {data.bot_instance.spec.instance_id.substring(0, 7)} + + + + + ) : undefined} + + + + View Documentation + + + + {isLoading ? ( + + + + ) : undefined} + + {isError ? ( + {`Error: ${error.message}`} + ) : undefined} + + {isSuccess && data.yaml ? ( + + + + ) : undefined} + + ); +} + +const MonoText = styled(Text)` + font-family: ${({ theme }) => theme.fonts.mono}; +`; + +const InstanceId = styled.div` + display: flex; + align-items: center; + padding-left: 8px; + padding-right: 6px; + height: 32px; + border-radius: 16px; + background-color: ${({ theme }) => theme.colors.interactive.tonal.neutral[0]}; +`; + +const YamlContaner = styled(Flex)` + flex: 1; + border-radius: 8px; + background-color: ${({ theme }) => theme.colors.levels.elevated}; +`; diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index 1e2cc43052f6d..026fbf76ad19b 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -19,6 +19,7 @@ import format from 'date-fns/format'; import formatDistanceToNowStrict from 'date-fns/formatDistanceToNowStrict'; import parseISO from 'date-fns/parseISO'; +import { useMemo } from 'react'; import styled from 'styled-components'; import { Info } from 'design/Alert/Alert'; @@ -44,10 +45,12 @@ export function BotInstancesList({ onFetchPrev, searchTerm, onSearchChange, + onItemSelected, }: { data: BotInstanceSummary[]; searchTerm: string; onSearchChange: (term: string) => void; + onItemSelected: (item: BotInstanceSummary) => void; } & Omit) { const tableData = data.map(x => ({ ...x, @@ -62,8 +65,16 @@ export function BotInstancesList({ : '-', })); + const rowConfig = useMemo( + () => ({ + onClick: onItemSelected, + getStyle: () => ({ cursor: 'pointer' }), + }), + [onItemSelected] + ); + return ( - data={tableData} fetching={{ fetchStatus, @@ -84,6 +95,7 @@ export function BotInstancesList({ /> ), }} + row={rowConfig} columns={[ { key: 'bot_name', diff --git a/web/packages/teleport/src/BotInstances/hooks.ts b/web/packages/teleport/src/BotInstances/hooks.ts new file mode 100644 index 0000000000000..fcee2dd06c613 --- /dev/null +++ b/web/packages/teleport/src/BotInstances/hooks.ts @@ -0,0 +1,23 @@ +/** + * 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 { getBotInstance } from 'teleport/services/bot/bot'; +import { createQueryHook } from 'teleport/services/queryHelpers'; + +export const { queryKey: GetBotInstanceQueryKey, useQuery: useGetBotInstance } = + createQueryHook(['bot_instance', 'get'], getBotInstance); diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 217cd8abcc6e9..daee20d38c1b4 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -182,6 +182,7 @@ const cfg = { users: '/web/users', bots: '/web/bots', botInstances: '/web/bots/instances', + botInstance: '/web/bot/:botName/instance/:instanceId', botsNew: '/web/bots/new/:type?', console: '/web/cluster/:clusterId/console', consoleNodes: '/web/cluster/:clusterId/console/nodes', @@ -456,6 +457,8 @@ const cfg = { botsPath: '/v1/webapi/sites/:clusterId/machine-id/bot/:name?', botsTokenPath: '/v1/webapi/sites/:clusterId/machine-id/token', + botInstancePath: + '/v1/webapi/sites/:clusterId/machine-id/bot/:botName/bot-instance/:instanceId', botInstancesPath: '/v1/webapi/sites/:clusterId/machine-id/bot-instance', gcpWorkforceConfigurePath: @@ -729,6 +732,10 @@ const cfg = { return generatePath(cfg.routes.botInstances); }, + getBotInstanceDetailsRoute(params: { botName: string; instanceId: string }) { + return generatePath(cfg.routes.botInstance, params); + }, + getBotsNewRoute(type?: string) { return generatePath(cfg.routes.botsNew, { type }); }, @@ -1451,6 +1458,15 @@ const cfg = { return generatePath(cfg.api.botInstancesPath, { clusterId }); }, + getBotInstanceUrl(botName: string, instanceId: string) { + const clusterId = cfg.proxyCluster; + return generatePath(cfg.api.botInstancePath, { + clusterId, + botName, + instanceId, + }); + }, + getGcpWorkforceConfigScriptUrl(p: UrlGcpWorkforceConfigParam) { return ( cfg.baseUrl + generatePath(cfg.api.gcpWorkforceConfigurePath, { ...p }) diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 2dab14ee590e4..a244ed2ea62eb 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -52,6 +52,7 @@ 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 { Clusters } from './Clusters'; @@ -305,6 +306,20 @@ export class FeatureBotInstances implements TeleportFeature { } } +export class FeatureBotInstanceDetails implements TeleportFeature { + parent = FeatureBotInstances; + + route = { + title: 'Bot instance details', + path: cfg.routes.botInstance, + component: BotInstanceDetails, + }; + + hasAccess() { + return true; + } +} + export class FeatureAddBotsShortcut implements TeleportFeature { category = NavigationCategory.MachineWorkloadId; isHyperLink = true; @@ -542,7 +557,7 @@ export class FeatureIntegrationEnroll implements TeleportFeature { title: NavTitle.EnrollNewIntegration, icon: IntegrationsIcon, getLink() { - return cfg.getIntegrationEnrollRoute(null); + return cfg.getIntegrationEnrollRoute(); }, searchableTags: ['new', 'add', 'enroll', 'integration'], }; @@ -798,6 +813,7 @@ export function getOSSFeatures(): TeleportFeature[] { new FeatureUsers(), new FeatureBots(), new FeatureBotInstances(), + new FeatureBotInstanceDetails(), new FeatureAddBotsShortcut(), new FeatureJoinTokens(), new FeatureRoles(), diff --git a/web/packages/teleport/src/services/bot/bot.ts b/web/packages/teleport/src/services/bot/bot.ts index a8b03db9eed05..e5a308eca923f 100644 --- a/web/packages/teleport/src/services/bot/bot.ts +++ b/web/packages/teleport/src/services/bot/bot.ts @@ -20,6 +20,7 @@ import cfg from 'teleport/config'; import api from 'teleport/services/api'; import { makeBot, + parseGetBotInstanceResponse, parseListBotInstancesResponse, toApiGitHubTokenSpec, } from 'teleport/services/bot/consts'; @@ -139,3 +140,21 @@ export async function listBotInstances( return data; } + +export async function getBotInstance( + variables: { + botName: string; + instanceId: string; + }, + signal?: AbortSignal +) { + const path = cfg.getBotInstanceUrl(variables.botName, variables.instanceId); + + const data = await api.get(path, signal); + + if (!parseGetBotInstanceResponse(data)) { + throw new Error('failed to parse get bot instance response'); + } + + return data; +} diff --git a/web/packages/teleport/src/services/bot/consts.ts b/web/packages/teleport/src/services/bot/consts.ts index 091b7e3939f4b..80472313d7edb 100644 --- a/web/packages/teleport/src/services/bot/consts.ts +++ b/web/packages/teleport/src/services/bot/consts.ts @@ -21,6 +21,7 @@ import { BotType, BotUiFlow, FlatBot, + GetBotInstanceResponse, GitHubRepoRule, ListBotInstancesResponse, ProvisionTokenSpecV2GitHub, @@ -115,6 +116,24 @@ export function parseListBotInstancesResponse( return data.bot_instances.every(x => typeof x === 'object' || x !== null); } +export function parseGetBotInstanceResponse( + data: unknown +): data is GetBotInstanceResponse { + if (typeof data !== 'object' || data === null) { + return false; + } + + if (!('bot_instance' in data && 'yaml' in data)) { + return false; + } + + if (typeof data.bot_instance !== 'object' || data.bot_instance === null) { + 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 af161c602ffbd..acd34124a2320 100644 --- a/web/packages/teleport/src/services/bot/types.ts +++ b/web/packages/teleport/src/services/bot/types.ts @@ -67,6 +67,15 @@ export type BotInstanceSummary = { active_at_latest?: string; }; +export type GetBotInstanceResponse = { + bot_instance?: { + spec?: { + instance_id?: string; + } | null; + } | null; + yaml?: 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 70fd5c278e3b8..13034b2a8936d 100644 --- a/web/packages/teleport/src/test/helpers/botInstances.ts +++ b/web/packages/teleport/src/test/helpers/botInstances.ts @@ -18,11 +18,17 @@ import { http, HttpResponse } from 'msw'; -import { ListBotInstancesResponse } from 'teleport/services/bot/types'; +import { + GetBotInstanceResponse, + ListBotInstancesResponse, +} from 'teleport/services/bot/types'; const listBotInstancesPath = '/v1/webapi/sites/:cluster_id/machine-id/bot-instance'; +const getBotInstancePath = + '/v1/webapi/sites/:cluster_id/machine-id/bot/:bot_name/bot-instance/:id'; + export const listBotInstancesSuccess = (mock: ListBotInstancesResponse) => http.get(listBotInstancesPath, () => { return HttpResponse.json(mock); @@ -32,3 +38,13 @@ export const listBotInstancesError = (status: number) => http.get(listBotInstancesPath, () => { return new HttpResponse(null, { status }); }); + +export const getBotInstanceSuccess = (mock: GetBotInstanceResponse) => + http.get(getBotInstancePath, () => { + return HttpResponse.json(mock); + }); + +export const getBotInstanceError = (status: number) => + http.get(getBotInstancePath, () => { + return new HttpResponse(null, { status }); + });