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 });
+ });