diff --git a/web/packages/design/src/Label/Label.tsx b/web/packages/design/src/Label/Label.tsx index 324891ab20642..42ea0d086d8df 100644 --- a/web/packages/design/src/Label/Label.tsx +++ b/web/packages/design/src/Label/Label.tsx @@ -19,7 +19,7 @@ import React from 'react'; import styled from 'styled-components'; -import { space, SpaceProps } from '../system'; +import { border, BorderProps, space, SpaceProps } from '../system'; import { Theme } from '../theme'; const kind = ({ kind, theme }: { kind?: LabelKind; theme: Theme }) => { @@ -102,23 +102,26 @@ export type LabelKind = | 'outline-warning' | 'outline-danger'; -interface LabelProps extends SpaceProps { +type LabelProps = { kind?: LabelKind; children?: React.ReactNode; -} +} & SpaceProps & + BorderProps; const Label = styled.div` box-sizing: border-box; - border-radius: 10px; + border-radius: 999px; display: inline-block; font-size: 10px; font-weight: 500; padding: 0 8px; margin: 1px 0; vertical-align: middle; + overflow: hidden; ${kind} ${space} + ${border} `; export default Label; diff --git a/web/packages/design/src/ResourceIcon/ResourceIcon.story.tsx b/web/packages/design/src/ResourceIcon/ResourceIcon.story.tsx index 811f3ad50e46a..1fed1edde6a46 100644 --- a/web/packages/design/src/ResourceIcon/ResourceIcon.story.tsx +++ b/web/packages/design/src/ResourceIcon/ResourceIcon.story.tsx @@ -19,7 +19,7 @@ import React, { PropsWithChildren } from 'react'; import { useTheme } from 'styled-components'; -import { Flex, Text } from 'design'; +import { Flex, Stack, Text } from 'design'; import { ResourceIcon } from 'design/ResourceIcon'; import { iconNames } from './resourceIconSpecs'; @@ -43,6 +43,21 @@ export const Icons = () => { ); }; +export const StandardSizes = () => { + return ( + + + Small + + Medium + + Large + + Extra Large + + ); +}; + const IconBox: React.FC> = ({ children, text, diff --git a/web/packages/design/src/ResourceIcon/index.tsx b/web/packages/design/src/ResourceIcon/index.tsx index c6c082c8694ed..f5c5c24c4189f 100644 --- a/web/packages/design/src/ResourceIcon/index.tsx +++ b/web/packages/design/src/ResourceIcon/index.tsx @@ -20,6 +20,7 @@ import { ComponentProps } from 'react'; import { useTheme } from 'styled-components'; import { Image } from 'design'; +import { IconProps } from 'design/Icon/Icon'; import { iconNames, @@ -33,6 +34,11 @@ interface ResourceIconProps extends ComponentProps { * available names. */ name: ResourceIconName; + + /** + * Use a standard size. Otherwise, use `width` and `height` props. + */ + size?: IconProps['size']; } /** @@ -45,7 +51,32 @@ export const ResourceIcon = ({ name, ...props }: ResourceIconProps) => { if (!icon) { return null; } - return ; + const width = props.size ? sizetoPx(props.size) : props.width; + const height = props.size ? sizetoPx(props.size) : props.height; + return ( + + ); }; +/** + * Convert a standard size to a pixel width/height. This is different to the + * conversion done for Icons as they include in-asset padding. + * + * @param size the standard size to convert. + * @returns the pixel size + */ +function sizetoPx(size: IconProps['size']) { + if (size === 'small') return '14px'; + if (size === 'medium') return '16px'; + if (size === 'large') return '20px'; + if (size === 'extra-large') return '24px'; + return '24px'; +} + export { type ResourceIconName, resourceIconSpecs, iconNames }; diff --git a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx index 08a72d3df09f6..da5ae55babc5e 100644 --- a/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx +++ b/web/packages/teleport/src/BotInstances/Details/BotInstanceDetails.tsx @@ -17,7 +17,7 @@ */ import { MouseEventHandler, useCallback } from 'react'; -import { useHistory, useParams } from 'react-router'; +import { useHistory, useLocation, useParams } from 'react-router'; import styled from 'styled-components'; import { Alert } from 'design/Alert/Alert'; @@ -37,6 +37,7 @@ import { FeatureHeader, FeatureHeaderTitle, } from 'teleport/components/Layout/Layout'; +import cfg from 'teleport/config'; import { useGetBotInstance } from '../hooks'; @@ -47,6 +48,7 @@ export function BotInstanceDetails(props: { onDocsLinkClickedForTesting?: MouseEventHandler; }) { const history = useHistory(); + const location = useLocation(); const params = useParams<{ botName: string; instanceId: string; @@ -60,8 +62,13 @@ export function BotInstanceDetails(props: { ); const handleBackPress = useCallback(() => { - history.goBack(); - }, [history]); + // If location.key is unset, or 'default', this is the first history entry in-app in the session. + if (!location.key || location.key === 'default') { + history.push(cfg.getBotInstancesRoute()); + } else { + history.goBack(); + } + }, [history, location.key]); return ( diff --git a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx index cc192727dc29b..bade432d1792b 100644 --- a/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx +++ b/web/packages/teleport/src/BotInstances/List/BotInstancesList.tsx @@ -19,7 +19,6 @@ 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'; @@ -69,14 +68,6 @@ export function BotInstancesList({ : '-', })); - const rowConfig = useMemo( - () => ({ - onClick: onItemSelected, - getStyle: () => ({ cursor: 'pointer' }), - }), - [onItemSelected] - ); - return ( data={tableData} @@ -99,7 +90,10 @@ export function BotInstancesList({ /> ), }} - row={rowConfig} + row={{ + onClick: onItemSelected, + getStyle: () => ({ cursor: 'pointer' }), + }} columns={[ { key: 'bot_name', diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx index 637181b0bf268..a517d1904c2f8 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.story.tsx @@ -118,13 +118,76 @@ export const HappyWithEmpty: Story = { }, }), listV2TokensSuccess({ - isEmpty: true, + tokens: [], }), mfaAuthnChallengeSuccess(), listBotInstancesSuccess({ bot_instances: [], next_page_token: '', }), + successGetRoles({ + startKey: '', + items: Array.from({ length: 10 }, (_, k) => k).map(r => ({ + content: `role-${r}`, + id: `role-${r}`, + name: `role-${r}`, + kind: 'role', + })), + }), + editBotSuccess(), + ], + }, + }, +}; + +export const HappyWithLongValues: Story = { + parameters: { + msw: { + handlers: [ + getBotSuccess({ + name: 'ansibleworkeransibleworkeransibleworkeransibleworkeransibleworkeransibleworker', + roles: [ + 'rolerolerolerolerolerolerolerolerolerolerolerolerolerolerolerolerolerolerolerolerole', + ], + traits: [ + { + name: 'traittraittraittraittraittraittraittraittraittraittraittraittraittraittrait', + values: ['value'], + }, + { + name: 'name', + values: [ + 'valuevaluevaluevaluevaluevaluevaluevaluevaluevaluevaluevaluevaluevaluevaluevalue', + ], + }, + ], + max_session_ttl: { + seconds: 43200, + }, + }), + listV2TokensSuccess({ + tokens: [ + 'tokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentokentoken', + ], + }), + mfaAuthnChallengeSuccess(), + listBotInstancesSuccess({ + bot_instances: [ + { + bot_name: '', + instance_id: + '04241a2a66b904241a2a66b904241a2a66b904241a2a66b904241a2a66b9', + host_name_latest: + 'hotnamehotnamehotnamehotnamehotnamehotnamehotnamehotnamehotname', + active_at_latest: '2025-01-01T00:00:00Z', + join_method_latest: 'github', + os_latest: 'linux', + version_latest: + '17.2.6-04241a2a66b904241a2a66b904241a2a66b904241a2a66b9', + }, + ], + next_page_token: '', + }), successGetRoles({ startKey: '', items: ['access', 'editor', 'terraform-provider'].map(r => ({ diff --git a/web/packages/teleport/src/Bots/Details/BotDetails.tsx b/web/packages/teleport/src/Bots/Details/BotDetails.tsx index 4236677df46e5..bba3dcf747134 100644 --- a/web/packages/teleport/src/Bots/Details/BotDetails.tsx +++ b/web/packages/teleport/src/Bots/Details/BotDetails.tsx @@ -17,7 +17,7 @@ */ import React, { useState } from 'react'; -import { useHistory, useParams } from 'react-router'; +import { useHistory, useLocation, useParams } from 'react-router'; import styled, { useTheme } from 'styled-components'; import { Alert } from 'design/Alert/Alert'; @@ -59,6 +59,7 @@ import { Panel } from './Panel'; export function BotDetails() { const ctx = useTeleport(); const history = useHistory(); + const location = useLocation(); const params = useParams<{ botName: string; }>(); @@ -74,7 +75,12 @@ export function BotDetails() { }); const handleBackPress = () => { - history.goBack(); + // If location.key is unset, or 'default', this is the first history entry in-app in the session. + if (!location.key || location.key === 'default') { + history.push(cfg.getBotsRoute()); + } else { + history.goBack(); + } }; const handleEdit = () => { @@ -97,10 +103,12 @@ export function BotDetails() { - + {isSuccess && data ? ( <> - {data.name} + + {data.name} + Edit Bot @@ -154,7 +162,12 @@ export function BotDetails() { Bot name - + {data.name} @@ -173,15 +186,15 @@ export function BotDetails() { {data.roles.length ? ( - + {data.roles.toSorted().map(r => ( - - {r} + + {r} ))} - + ) : ( - 'No roles assigned' + No roles assigned )} @@ -199,20 +212,20 @@ export function BotDetails() { -
+ {r.values.length > 0 ? r.values.toSorted().map(v => ( - - {v} + + {v} )) : 'no values'} -
+ ))}
) : ( - 'No traits set' + No traits set )}
@@ -260,7 +273,11 @@ const PanelContentContainer = styled(Flex)` padding-top: 0; `; -const RolesContainer = styled.div``; +const LabelsContainer = styled(Flex)` + flex-wrap: wrap; + overflow: hidden; + gap: ${props => props.theme.space[1]}px; +`; const Divider = styled.div` height: 1px; @@ -278,6 +295,7 @@ const Grid = styled(Box)` display: grid; grid-template-columns: repeat(2, auto); gap: ${({ theme }) => theme.space[2]}px; + overflow: hidden; `; const GridLabel = styled(Text)` @@ -292,6 +310,21 @@ const MonoText = styled(Text)` const EditButton = styled(ButtonSecondary)` gap: ${props => props.theme.space[2]}px; + white-space: nowrap; +`; + +const TitleText = styled(Text)` + white-space: nowrap; +`; + +const LabelText = styled(Text).attrs({ + typography: 'body3', +})` + white-space: nowrap; +`; + +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; `; const traitDescriptions: { [key in (typeof traitsPreset)[number]]: string } = { @@ -402,28 +435,32 @@ function JoinTokens(props: { botName: string; onViewAllClicked: () => void }) { {isSuccess ? ( <> {data.items.length ? ( - + {data.items .toSorted((a, b) => a.safeName.localeCompare(b.safeName)) .map(t => { return ( - - + + - {t.safeName} + {t.safeName} - - + + ); })} - + ) : ( - 'No join tokens' + No join tokens )} ) : undefined} diff --git a/web/packages/teleport/src/Bots/Details/Instance.story.tsx b/web/packages/teleport/src/Bots/Details/Instance.story.tsx index 0b4dddee4b431..7108e268ba371 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.story.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.story.tsx @@ -71,6 +71,17 @@ export const ItemWithNoHeartbeatData: Story = { }, }; +export const ItemWithLongValues: Story = { + args: { + id: 'fa11a603701dfa11a603701dfa11a603701dfa11a603701dfa11a603701dfa113701d', + activeAt: new Date('2025-07-18T14:54:32Z').getTime(), + hostname: 'hostnamehostnamehostnamehostnamehostnamehostnamehostnamehostnam', + method: 'kubernetes', + version: '4.4.0-fa11a603701dfa11a603701dfa11a603701dfa11a603701dfa11a6031d', + os: 'linux', + }, +}; + type Props = { id: Parameters[0]['id']; version?: Parameters[0]['version']; diff --git a/web/packages/teleport/src/Bots/Details/Instance.tsx b/web/packages/teleport/src/Bots/Details/Instance.tsx index efc25d2804a3b..af26b3b0df2fd 100644 --- a/web/packages/teleport/src/Bots/Details/Instance.tsx +++ b/web/packages/teleport/src/Bots/Details/Instance.tsx @@ -46,56 +46,59 @@ export function Instance(props: { }) { const { id, version, hostname, activeAt, method, os } = props; + const hasHeartbeatData = !!version || !!hostname || !!method || !!os; + return ( - {id} + {id} {activeAt ? ( - {`${formatDistanceToNowStrict(parseISO(activeAt))} ago`} + {`${formatDistanceToNowStrict(parseISO(activeAt))} ago`} ) : undefined} - - - - - {hostname ? ( - - - {hostname} - - - ) : undefined} - - - {method ? ( - - ) : undefined} - - {os ? ( - - - {os === 'darwin' ? ( - - ) : os === 'windows' ? ( - - ) : os === 'linux' ? ( - - ) : ( - - )} - - - ) : undefined} - - + {hasHeartbeatData ? ( + + + + + {hostname ? ( + + + {hostname} + + + ) : undefined} + + + {method ? ( + + ) : undefined} + + {os ? ( + + + {os === 'darwin' ? ( + + ) : os === 'windows' ? ( + + ) : os === 'linux' ? ( + + ) : ( + + )} + + + ) : undefined} + + + ) : ( + No heartbeat data + )} ); } @@ -112,11 +115,15 @@ const Container = styled(Flex)` const TopRow = styled(Flex)` justify-content: space-between; align-items: center; + overflow: hidden; + gap: ${p => p.theme.space[2]}px; `; const BottomRow = styled(Flex)` justify-content: space-between; align-items: flex-end; + gap: ${p => p.theme.space[2]}px; + overflow: hidden; `; const OsIconContainer = styled(Flex)` @@ -126,6 +133,21 @@ const OsIconContainer = styled(Flex)` justify-content: center; `; +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; +`; + +const TimeText = styled(Text).attrs({ + typography: 'body4', +})` + white-space: nowrap; +`; + +const IdText = styled(Text)` + flex: 1; + white-space: nowrap; +`; + function Version(props: { version: string | undefined }) { const { version } = props; const { checkCompatibility } = useClusterVersion(); @@ -165,12 +187,18 @@ function Version(props: { version: string | undefined }) { return version ? ( - + {icon} - v{version} + v{version} ) : undefined; } + +const LabelText = styled(Text)` + font-size: ${({ theme }) => theme.fontSizes[1]}px; + white-space: nowrap; + max-width: 96px; +`; diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx index 19c55912d224e..6831826fa7e31 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.story.tsx @@ -85,6 +85,10 @@ export const listBotInstancesSuccessHandler = listBotInstancesSuccess({ os_latest: 'linux', version_latest: '1.3.2', }, + { + bot_name: 'ansible-worker', + instance_id: crypto.randomUUID(), + }, ], next_page_token: '', }); diff --git a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx index 8b9ae92b9fee5..7ab4f28b858d7 100644 --- a/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx +++ b/web/packages/teleport/src/Bots/Details/InstancesPanel.tsx @@ -26,6 +26,7 @@ import { ButtonSecondary, ButtonText } from 'design/Button'; import Flex from 'design/Flex/Flex'; import { SortAscending, SortDescending } from 'design/Icon'; import { Indicator } from 'design/Indicator/Indicator'; +import Text from 'design/Text'; import { H2 } from 'design/Text/Text'; import { fontWeights } from 'design/theme/typography'; @@ -154,7 +155,9 @@ export function InstancesPanel(props: { botName: string }) { ) : ( - No active instances + + No active instances + )} ) : undefined} @@ -195,3 +198,7 @@ const Divider = styled.div` flex-shrink: 0; background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; `; + +const EmptyText = styled(Text)` + color: ${p => p.theme.colors.text.muted}; +`; diff --git a/web/packages/teleport/src/Bots/Details/JoinMethodIcon.tsx b/web/packages/teleport/src/Bots/Details/JoinMethodIcon.tsx index 17c58a85c39ad..a62d8b8d23f3f 100644 --- a/web/packages/teleport/src/Bots/Details/JoinMethodIcon.tsx +++ b/web/packages/teleport/src/Bots/Details/JoinMethodIcon.tsx @@ -16,10 +16,6 @@ * along with this program. If not, see . */ -import { ReactElement } from 'react'; -import styled from 'styled-components'; - -import Flex from 'design/Flex'; import { IconProps } from 'design/Icon/Icon'; import { Key } from 'design/Icon/Icons/Key'; import { Keypair } from 'design/Icon/Icons/Keypair'; @@ -48,56 +44,39 @@ export function JoinMethodIcon(props: { } const renderLogo = (method: string, size: IconProps['size']) => { - let logo: ReactElement | null = null; - - switch (method) { - case 'ec2': - logo = ; - break; - case 'iam': - logo = ; - break; - case 'github': - logo = ; - break; - case 'circleci': - logo = ; - break; - case 'kubernetes': - logo = ; - break; - case 'azure': - logo = ; - break; - case 'gitlab': - logo = ; - break; - case 'gcp': - logo = ; - break; - case 'spacelift': - logo = ; - break; - case 'terraform_cloud': - logo = ; - break; - case 'bitbucket': - logo = ; - break; - case 'oracle': - // TODO(nicholasmarais1158): Add missing oracle icon/logo - logo = ; - break; - case 'azure_devops': - logo = ; - break; - } - - if (logo) { - return {logo}; - } + const name = (() => { + switch (method) { + case 'ec2': + return 'ec2'; + case 'iam': + return 'awsaccount'; + case 'github': + return 'github'; + case 'circleci': + return 'circleci'; + case 'kubernetes': + return 'kube'; + case 'azure': + return 'azure'; + case 'gitlab': + return 'gitlab'; + case 'gcp': + return 'googlecloud'; + case 'spacelift': + return 'spacelift'; + case 'terraform_cloud': + return 'terraform'; + case 'bitbucket': + return 'git'; + case 'oracle': + // TODO(nicholasmarais1158): Add missing oracle icon/logo + return 'database'; + case 'azure_devops': + return 'azure'; + } + })(); - return null; + return name ? : undefined; }; const renderIcon = ( @@ -116,26 +95,3 @@ const renderIcon = ( return ; } }; - -const ResourceIconContainer = styled(Flex)<{ size: IconProps['size'] }>` - width: ${({ size }) => sizetoOuterPx(size)}; - height: ${({ size }) => sizetoOuterPx(size)}; - align-items: center; - justify-content: center; -`; - -function sizetoOuterPx(size: IconProps['size']) { - if (size === 'small') return '16px'; - if (size === 'medium') return '20px'; - if (size === 'large') return '24px'; - if (size === 'extra-large') return '32px'; - return '24px'; -} - -function sizetoInnerPx(size: IconProps['size']) { - if (size === 'small') return '12px'; - if (size === 'medium') return '16px'; - if (size === 'large') return '20px'; - if (size === 'extra-large') return '24px'; - return '24px'; -} diff --git a/web/packages/teleport/src/Bots/EditBot.test.tsx b/web/packages/teleport/src/Bots/EditBot.test.tsx deleted file mode 100644 index 4b350c1b5b2b8..0000000000000 --- a/web/packages/teleport/src/Bots/EditBot.test.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Teleport - * Copyright (C) 2024 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 { waitFor } from '@testing-library/react'; - -import { render, screen, userEvent } from 'design/utils/testing'; - -import { EditBot } from 'teleport/Bots/EditBot'; -import { EditBotProps } from 'teleport/Bots/types'; - -const makeProps = (overrides: Partial = {}): EditBotProps => ({ - fetchRoles: jest.fn().mockResolvedValueOnce([]), - attempt: { status: '' }, - name: 'bot-007', - onClose: () => {}, - onEdit: () => {}, - selectedRoles: [], - setSelectedRoles: () => {}, - ...overrides, -}); - -test('renders', async () => { - const props = makeProps({ selectedRoles: ['foo-role'] }); - render(); - await waitFor(() => expect(props.fetchRoles).toHaveBeenCalledTimes(1)); - - expect(screen.getByText('Edit Bot')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - - expect(screen.getByRole('textbox', { name: 'Name' })).toHaveValue('bot-007'); - expect(screen.getByRole('textbox', { name: 'Name' })).toHaveAttribute( - 'readonly' - ); - - expect(screen.getByRole('combobox', { name: 'Bot Roles' })).toBeEnabled(); - expect(screen.getByText('foo-role')).toBeInTheDocument(); -}); - -test('cancel calls onclose cb', async () => { - const mockClose = jest.fn(); - const props = makeProps({ onClose: mockClose }); - render(); - - expect(mockClose).not.toHaveBeenCalled(); - await userEvent.click(screen.queryByRole('button', { name: 'Cancel' })); - expect(mockClose).toHaveBeenCalled(); -}); - -test('edit calls onedit cb', async () => { - const mockEdit = jest.fn(); - const props = makeProps({ onEdit: mockEdit }); - render(); - - expect(mockEdit).not.toHaveBeenCalled(); - await userEvent.click(screen.queryByRole('button', { name: 'Save' })); - expect(mockEdit).toHaveBeenCalled(); -}); - -test('disables buttons when processing', async () => { - const props = makeProps({ attempt: { status: 'processing' } }); - render(); - await waitFor(() => expect(props.fetchRoles).toHaveBeenCalledTimes(1)); - - expect(screen.queryByRole('button', { name: 'Save' })).toBeDisabled(); - expect(screen.queryByRole('button', { name: 'Cancel' })).toBeDisabled(); -}); - -test('displays error text', async () => { - const props = makeProps({ - attempt: { status: 'failed', statusText: 'error editing' }, - }); - render(); - await waitFor(() => expect(props.fetchRoles).toHaveBeenCalledTimes(1)); - - expect(screen.getByText('error editing')).toBeInTheDocument(); -}); diff --git a/web/packages/teleport/src/Bots/EditBot.tsx b/web/packages/teleport/src/Bots/EditBot.tsx deleted file mode 100644 index ccac6e58b102d..0000000000000 --- a/web/packages/teleport/src/Bots/EditBot.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Teleport - * Copyright (C) 2024 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 { Alert, ButtonSecondary, ButtonWarning } from 'design'; -import Dialog, { - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from 'design/DialogConfirmation'; -import FieldInput from 'shared/components/FieldInput'; -import { FieldSelectAsync } from 'shared/components/FieldSelect'; -import { Option } from 'shared/components/Select'; -import Validation from 'shared/components/Validation'; -import { requiredField } from 'shared/components/Validation/rules'; - -import { EditBotProps } from 'teleport/Bots/types'; - -export function EditBot({ - fetchRoles, - attempt, - name, - onClose, - onEdit, - selectedRoles, - setSelectedRoles, -}: EditBotProps) { - return ( - - - Edit Bot - - - - {attempt.status === 'failed' && ( - - )} - {}} - /> - ({ - value: r, - label: r, - }))} - onChange={(values: Option[]) => - setSelectedRoles(values?.map(v => v.value) || []) - } - loadOptions={async input => { - const roles = await fetchRoles(input); - return roles.map(r => ({ - value: r, - label: r, - })); - }} - noOptionsMessage={() => 'No roles found'} - elevated={true} - /> - - - - - Save - - - Cancel - - - - ); -} diff --git a/web/packages/teleport/src/Bots/List/ActionCell.tsx b/web/packages/teleport/src/Bots/List/ActionCell.tsx index 7658d7bb0e641..5d9ec0a386dfc 100644 --- a/web/packages/teleport/src/Bots/List/ActionCell.tsx +++ b/web/packages/teleport/src/Bots/List/ActionCell.tsx @@ -40,7 +40,7 @@ export function BotOptionsCell({ Delete... {bot.type === BotUiFlow.GitHubActionsSsh && ( - View... + View GitHub example... )} diff --git a/web/packages/teleport/src/Bots/List/BotList.test.tsx b/web/packages/teleport/src/Bots/List/BotList.test.tsx index ca7a1d4657401..8d93e49dc93d3 100644 --- a/web/packages/teleport/src/Bots/List/BotList.test.tsx +++ b/web/packages/teleport/src/Bots/List/BotList.test.tsx @@ -31,11 +31,9 @@ const makeProps = (): BotListProps => ({ onClose: () => {}, onDelete: () => {}, onEdit: () => {}, - fetchRoles: async () => [], + onSelect: () => {}, selectedBot: null, - selectedRoles: [], setSelectedBot: () => {}, - setSelectedRoles: () => {}, }); test('renders table with bots', () => { @@ -74,7 +72,7 @@ test('renders View options if type is github actions ssh', async () => { props.bots = [bot]; render(); fireEvent.click(await screen.findByText('Options')); - expect(screen.getByText('View...')).toBeInTheDocument(); + expect(screen.getByText('View GitHub example...')).toBeInTheDocument(); }); test('doesnt renders View options if bot type is not github actions', async () => { @@ -98,5 +96,5 @@ test('doesnt renders View options if bot type is not github actions', async () = props.bots = [bot]; render(); fireEvent.click(await screen.findByText('Options')); - expect(screen.queryByText('View...')).not.toBeInTheDocument(); + expect(screen.queryByText('View GitHub example...')).not.toBeInTheDocument(); }); diff --git a/web/packages/teleport/src/Bots/List/BotList.tsx b/web/packages/teleport/src/Bots/List/BotList.tsx index b3894d962ece0..ed9fb39ce5c81 100644 --- a/web/packages/teleport/src/Bots/List/BotList.tsx +++ b/web/packages/teleport/src/Bots/List/BotList.tsx @@ -21,14 +21,14 @@ import { useState } from 'react'; import Table, { LabelCell } from 'design/DataTable'; import { DeleteBot } from 'teleport/Bots/DeleteBot'; -import { EditBot } from 'teleport/Bots/EditBot'; import { BotOptionsCell } from 'teleport/Bots/List/ActionCell'; import { BotListProps } from 'teleport/Bots/types'; +import { EditDialog } from '../Edit/EditDialog'; import { ViewBot } from '../ViewBot'; enum Interaction { - VIEW, + GITHUB_EXAMPLE, EDIT, DELETE, NONE, @@ -39,14 +39,12 @@ export function BotList({ bots, disabledEdit, disabledDelete, - fetchRoles, onClose, onDelete, onEdit, + onSelect, selectedBot, setSelectedBot, - selectedRoles, - setSelectedRoles, }: BotListProps) { const [interaction, setInteraction] = useState(Interaction.NONE); @@ -75,13 +73,12 @@ export function BotList({ bot={bot} onClickView={() => { setSelectedBot(bot); - setInteraction(Interaction.VIEW); + setInteraction(Interaction.GITHUB_EXAMPLE); }} disabledEdit={disabledEdit} disabledDelete={disabledDelete} onClickEdit={() => { setSelectedBot(bot); - setSelectedRoles(bot.roles); setInteraction(Interaction.EDIT); }} onClickDelete={() => { @@ -95,6 +92,10 @@ export function BotList({ emptyText="No Bots Found" isSearchable pagination={{ pageSize: 20 }} + row={{ + onClick: onSelect, + getStyle: () => ({ cursor: 'pointer' }), + }} /> {selectedBot && interaction === Interaction.DELETE && ( )} {selectedBot && interaction === Interaction.EDIT && ( - )} - {selectedBot && interaction === Interaction.VIEW && ( + {selectedBot && interaction === Interaction.GITHUB_EXAMPLE && ( )} diff --git a/web/packages/teleport/src/Bots/List/Bots.story.tsx b/web/packages/teleport/src/Bots/List/Bots.story.tsx index bcca12a34e727..b842a9d1da3bc 100644 --- a/web/packages/teleport/src/Bots/List/Bots.story.tsx +++ b/web/packages/teleport/src/Bots/List/Bots.story.tsx @@ -25,7 +25,7 @@ import { TeleportProviderBasic } from 'teleport/mocks/providers'; import { EmptyState } from './EmptyState/EmptyState'; export default { - title: 'Teleport/Bots', + title: 'Teleport/Bots/List', }; export const Empty = () => { @@ -48,11 +48,9 @@ export const List = () => { onClose={() => {}} onDelete={() => {}} onEdit={() => {}} - fetchRoles={async () => []} + onSelect={() => {}} selectedBot={null} - selectedRoles={[]} setSelectedBot={() => {}} - setSelectedRoles={() => {}} /> ); }; diff --git a/web/packages/teleport/src/Bots/List/Bots.test.tsx b/web/packages/teleport/src/Bots/List/Bots.test.tsx index 2170d55bed607..139595b83e032 100644 --- a/web/packages/teleport/src/Bots/List/Bots.test.tsx +++ b/web/packages/teleport/src/Bots/List/Bots.test.tsx @@ -81,37 +81,6 @@ test('shows missing permissions error if user lacks permissions to list', async ).toBeInTheDocument(); }); -test('calls edit endpoint', async () => { - jest - .spyOn(api, 'get') - .mockResolvedValueOnce({ ...botsApiResponseFixture }) - .mockResolvedValueOnce(['role-1', 'editor']); - jest.spyOn(api, 'put').mockResolvedValue({}); - renderWithContext(); - - await waitFor(() => { - expect( - screen.getByText(botsApiResponseFixture.items[0].metadata.name) - ).toBeInTheDocument(); - }); - - const actionCells = screen.queryAllByRole('button', { name: 'Options' }); - expect(actionCells).toHaveLength(botsApiResponseFixture.items.length); - await userEvent.click(actionCells[0]); - - expect(screen.getByText('Edit...')).toBeInTheDocument(); - await userEvent.click(screen.getByText('Edit...')); - - expect(screen.getByText('Edit Bot')).toBeInTheDocument(); - await userEvent.click(screen.queryByRole('button', { name: 'Save' })); - - expect(screen.queryByText('Edit Bot')).not.toBeInTheDocument(); - expect(api.put).toHaveBeenCalledWith( - `/v1/webapi/sites/localhost/machine-id/bot/${botsApiResponseFixture.items[0].metadata.name}`, - { roles: ['bot-bot-role'] } - ); -}); - test('calls delete endpoint', async () => { jest .spyOn(api, 'get') diff --git a/web/packages/teleport/src/Bots/List/Bots.tsx b/web/packages/teleport/src/Bots/List/Bots.tsx index 615f67e771b32..d83acc805710d 100644 --- a/web/packages/teleport/src/Bots/List/Bots.tsx +++ b/web/packages/teleport/src/Bots/List/Bots.tsx @@ -16,8 +16,8 @@ * along with this program. If not, see . */ -import { useEffect, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { Link, useHistory } from 'react-router-dom'; import { Alert, Box, Button, Indicator } from 'design'; import { HoverTooltip } from 'design/Tooltip'; @@ -31,12 +31,7 @@ import { FeatureHeaderTitle, } from 'teleport/components/Layout'; import cfg from 'teleport/config'; -import { - deleteBot, - editBot, - fetchBots, - fetchRoles, -} from 'teleport/services/bot/bot'; +import { deleteBot, fetchBots } from 'teleport/services/bot/bot'; import { FlatBot } from 'teleport/services/bot/types'; import useTeleport from 'teleport/useTeleport'; @@ -45,13 +40,13 @@ import { EmptyState } from './EmptyState/EmptyState'; export function Bots() { const ctx = useTeleport(); + const history = useHistory(); const flags = ctx.getFeatureFlags(); const hasAddBotPermissions = flags.addBots; const canListBots = flags.listBots; const [bots, setBots] = useState([]); const [selectedBot, setSelectedBot] = useState(); - const [selectedRoles, setSelectedRoles] = useState(); const { attempt: crudAttempt, run: crudRun } = useAttemptNext(); const { attempt: fetchAttempt, run: fetchRun } = useAttemptNext( canListBots ? 'processing' : 'success' @@ -77,12 +72,6 @@ export function Bots() { }; }, [ctx, fetchRun, canListBots]); - async function fetchRoleNames(search: string): Promise { - const flags = ctx.getFeatureFlags(); - const roles = await fetchRoles({ search, flags }); - return roles.items.map(r => r.name); - } - function onDelete() { crudRun(() => deleteBot(flags, selectedBot.name)).then(() => { setBots(bots.filter(bot => bot.name !== selectedBot.name)); @@ -90,32 +79,32 @@ export function Bots() { }); } - function onEdit() { - crudRun(() => - editBot(flags, selectedBot.name, { roles: selectedRoles }).then( - (updated: FlatBot) => { - const updatedList: FlatBot[] = bots.map((item: FlatBot): FlatBot => { - if (item.name !== selectedBot.name) { - return item; - } - return { - ...item, - ...updated, - }; - }); - - setBots(updatedList); - onClose(); - } - ) - ); + function onEdit(updated: FlatBot) { + const updatedList = bots.map((item: FlatBot): FlatBot => { + if (item.name !== selectedBot?.name) { + return item; + } + return { + ...item, + ...updated, + }; + }); + + setBots(updatedList); + onClose(); } function onClose() { setSelectedBot(null); - setSelectedRoles(null); } + const handleSelect = useCallback( + (item: FlatBot) => { + history.push(cfg.getBotDetailsRoute(item.name)); + }, + [history] + ); + if (fetchAttempt.status === 'processing') { return ( @@ -174,7 +163,7 @@ export function Bots() { {fetchAttempt.status == 'failed' && ( - + {fetchAttempt.statusText} )} {fetchAttempt.status == 'success' && ( )} diff --git a/web/packages/teleport/src/Bots/ViewBot.story.tsx b/web/packages/teleport/src/Bots/ViewBot.story.tsx index 1a3c92a85b1d8..c0e0f312db924 100644 --- a/web/packages/teleport/src/Bots/ViewBot.story.tsx +++ b/web/packages/teleport/src/Bots/ViewBot.story.tsx @@ -24,10 +24,10 @@ import { ViewBotProps } from './types'; import { ViewBot } from './ViewBot'; export default { - title: 'Teleport/Bots/Add/ViewBot', + title: 'Teleport/Bots/Github Actions', }; -export const GitHubActionsSsh = () => { +export const GithubActionsSsh = () => { const ctx = createTeleportContext(); return ( @@ -43,7 +43,7 @@ const props: ViewBotProps = { type: BotUiFlow.GitHubActionsSsh, namespace: '', description: '', - labels: null, + labels: new Map(), revision: '', traits: [], status: '', diff --git a/web/packages/teleport/src/Bots/ViewBot.tsx b/web/packages/teleport/src/Bots/ViewBot.tsx index 7112cfea80c3f..c0ef602beac76 100644 --- a/web/packages/teleport/src/Bots/ViewBot.tsx +++ b/web/packages/teleport/src/Bots/ViewBot.tsx @@ -43,14 +43,13 @@ export function ViewBot({ bot, onClose }: ViewBotProps) { ); return ( - + {bot.name} Below is an example GitHub Actions workflow to help you get started. - You can find this again from the bot’s options dropdown. . */ -import { ApiBot, BotResponse, FlatBot } from 'teleport/services/bot/types'; +import { + ApiBot, + BotResponse, + BotUiFlow, + FlatBot, +} from 'teleport/services/bot/types'; // nonDisplayedFields are not leveraged in the UI, so we don't explicitly set them const nonDisplayedFields = { namespace: '', description: '', - labels: null, + labels: new Map(), revision: '', traits: [], status: '', @@ -37,6 +42,7 @@ export const botsFixture: FlatBot[] = [ kind: 'GitHub Actions', name: 'bot-github-actions', roles: ['bot-bot-role'], + type: BotUiFlow.GitHubActionsSsh, }, { ...nonDisplayedFields, @@ -72,7 +78,7 @@ const getEmptyApiBot = ( kind: kind, metadata: { description: '', - labels: null, + labels: new Map(), name: name, namespace: '', revision: '', diff --git a/web/packages/teleport/src/Bots/types.ts b/web/packages/teleport/src/Bots/types.ts index ead0fac477cb4..8ec39f4312ea7 100644 --- a/web/packages/teleport/src/Bots/types.ts +++ b/web/packages/teleport/src/Bots/types.ts @@ -35,14 +35,12 @@ export type BotListProps = { bots: FlatBot[]; disabledEdit: boolean; disabledDelete: boolean; - fetchRoles: (input: string) => Promise; onClose: () => void; onDelete: () => void; - onEdit: () => void; + onEdit: (updatedBot: FlatBot) => void; + onSelect: (item: FlatBot) => void; selectedBot: FlatBot; setSelectedBot: Dispatch>; - selectedRoles: string[]; - setSelectedRoles: Dispatch>; }; export type DeleteBotProps = { diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index d02fe05fe4820..592b35f8f92c8 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -760,8 +760,8 @@ const cfg = { return generatePath(cfg.routes.bots); }, - getBotDetailsRoute(name: string) { - return generatePath(cfg.routes.bot, { name }); + getBotDetailsRoute(botName: string) { + return generatePath(cfg.routes.bot, { botName }); }, getBotInstancesRoute() { diff --git a/web/packages/teleport/src/test/helpers/tokens.ts b/web/packages/teleport/src/test/helpers/tokens.ts index 2dac5f5581747..6505560be9de3 100644 --- a/web/packages/teleport/src/test/helpers/tokens.ts +++ b/web/packages/teleport/src/test/helpers/tokens.ts @@ -42,36 +42,36 @@ export const listV2TokensError = ( export const listV2TokensSuccess = (options?: { hasNextPage?: boolean; - isEmpty?: boolean; + tokens?: string[]; }) => { - const { hasNextPage = false, isEmpty = false } = options ?? {}; + const { hasNextPage = false, tokens } = options ?? {}; return http.get(cfg.api.joinToken.listV2, () => { return HttpResponse.json( { - items: isEmpty - ? [] - : [ - 'token', - 'ec2', - 'iam', - 'github', - 'circleci', - 'kubernetes', - 'azure', - 'gitlab', - 'gcp', - 'spacelift', - 'tpm', - 'terraform_cloud', - 'bitbucket', - 'oracle', - 'azure_devops', - 'bound_keypair', - ].map(method => ({ - id: `token-${method}`, - safeName: method, - method: method, - })), + items: ( + tokens ?? [ + 'token', + 'ec2', + 'iam', + 'github', + 'circleci', + 'kubernetes', + 'azure', + 'gitlab', + 'gcp', + 'spacelift', + 'tpm', + 'terraform_cloud', + 'bitbucket', + 'oracle', + 'azure_devops', + 'bound_keypair', + ] + ).map(method => ({ + id: `token-${method}`, + safeName: method, + method: method, + })), next_page_token: hasNextPage ? 'yes' : undefined, }, { status: 200 } diff --git a/web/packages/teleport/src/useClusterVersion.ts b/web/packages/teleport/src/useClusterVersion.ts index 4db49562cab85..5434982f57b88 100644 --- a/web/packages/teleport/src/useClusterVersion.ts +++ b/web/packages/teleport/src/useClusterVersion.ts @@ -22,17 +22,17 @@ import useTeleport from 'teleport/useTeleport'; /** * **useClusterVersion** returns the cluster (auth) version and a comparison - * utility (`check`). The check utility can be used to compare the provided - * client version to the cluster/control-plane version. An indication of - * cluster compatibility is returned. + * utility (`checkCompatibility`). The check utility can be used to compare the + * providedclient version to the cluster/control-plane version. An indication + * of cluster compatibility is returned. * * @returns the cluster (auth) version and a comparison utility (diff) */ export function useClusterVersion(): { clusterVersion: string; /** - * **check** compares the provided client version to the cluster/control- - * plane version. An indication of cluster compatibility is returned. + * **checkCompatibility** compares the provided client version to the cluster/ + * control-plane version. An indication of cluster compatibility is returned. * @param version the compare version as string. * @returns an indication of cluster compatibility */ @@ -59,7 +59,8 @@ export type ClientCompatibility = /** * match - versions are the same. * upgrade-major - the client is one version behind. - * upgrade-minor - the major version is the same, but older on minor or patch. + * upgrade-minor - the major version is the same, but older on minor or + * patch. */ reason: 'match' | 'upgrade-major' | 'upgrade-minor'; }