diff --git a/lib/cache/inventory/inventory_cache.go b/lib/cache/inventory/inventory_cache.go index 9d8007782ca82..cd93a30221156 100644 --- a/lib/cache/inventory/inventory_cache.go +++ b/lib/cache/inventory/inventory_cache.go @@ -660,6 +660,7 @@ func (ic *InventoryCache) parseFilter(filter *inventoryv1.ListUnifiedInstancesFi // fully initialized. func (ic *InventoryCache) ListUnifiedInstances(ctx context.Context, req *inventoryv1.ListUnifiedInstancesRequest) (*inventoryv1.ListUnifiedInstancesResponse, error) { if !ic.IsHealthy() { + // This returns HTTP error 503. Keep in sync with web/packages/teleport/src/Instances/Instances.tsx (isCacheInitializing) return nil, trace.ConnectionProblem(nil, "inventory cache is not yet healthy") } diff --git a/lib/services/useracl.go b/lib/services/useracl.go index 45d265c06a93c..84bf96a63e9c7 100644 --- a/lib/services/useracl.go +++ b/lib/services/useracl.go @@ -108,6 +108,8 @@ type UserACL struct { Bots ResourceAccess `json:"bots"` // BotInstances defines access to manage bot instances BotInstances ResourceAccess `json:"botInstances"` + // Instances defines access to manage instances + Instances ResourceAccess `json:"instances"` // AccessMonitoringRule defines access to manage access monitoring rule resources. AccessMonitoringRule ResourceAccess `json:"accessMonitoringRule"` // CrownJewel defines access to manage CrownJewel resources. @@ -217,6 +219,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des externalAuditStorage := newAccess(userRoles, ctx, types.KindExternalAuditStorage) bots := newAccess(userRoles, ctx, types.KindBot) botInstances := newAccess(userRoles, ctx, types.KindBotInstance) + instances := newAccess(userRoles, ctx, types.KindInstance) crownJewelAccess := newAccess(userRoles, ctx, types.KindCrownJewel) userTasksAccess := newAccess(userRoles, ctx, types.KindUserTask) reviewRequests := userRoles.MaybeCanReviewRequests() @@ -275,6 +278,7 @@ func NewUserACL(user types.User, userRoles RoleSet, features proto.Features, des AccessGraph: accessGraphAccess, Bots: bots, BotInstances: botInstances, + Instances: instances, AccessMonitoringRule: accessMonitoringRules, CrownJewel: crownJewelAccess, AccessGraphSettings: accessGraphSettings, diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx index fe4066fc3a7a0..e261d75a25d8c 100644 --- a/web/packages/design/src/Icon/Icons.story.tsx +++ b/web/packages/design/src/Icon/Icons.story.tsx @@ -176,6 +176,7 @@ export const Icons = () => ( + @@ -215,6 +216,7 @@ export const Icons = () => ( + diff --git a/web/packages/design/src/Icon/Icons/Network.tsx b/web/packages/design/src/Icon/Icons/Network.tsx new file mode 100644 index 0000000000000..39e9189655957 --- /dev/null +++ b/web/packages/design/src/Icon/Icons/Network.tsx @@ -0,0 +1,65 @@ +/** + * Teleport + * Copyright (C) 2023 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 . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import { forwardRef } from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export const Network = forwardRef( + ({ size = 24, color, ...otherProps }, ref) => ( + + + + ) +); diff --git a/web/packages/design/src/Icon/Icons/Stack.tsx b/web/packages/design/src/Icon/Icons/Stack.tsx new file mode 100644 index 0000000000000..e061a00a2841a --- /dev/null +++ b/web/packages/design/src/Icon/Icons/Stack.tsx @@ -0,0 +1,71 @@ +/** + * Teleport + * Copyright (C) 2023 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 . + */ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import { forwardRef } from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export const Stack = forwardRef( + ({ size = 24, color, ...otherProps }, ref) => ( + + + + + + ) +); diff --git a/web/packages/design/src/Icon/assets/Network.svg b/web/packages/design/src/Icon/assets/Network.svg new file mode 100644 index 0000000000000..046b6b19e4c1e --- /dev/null +++ b/web/packages/design/src/Icon/assets/Network.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/packages/design/src/Icon/assets/Stack.svg b/web/packages/design/src/Icon/assets/Stack.svg new file mode 100644 index 0000000000000..04e416b7424d4 --- /dev/null +++ b/web/packages/design/src/Icon/assets/Stack.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts index 58a7137e8d15d..f5fd2d1eb8496 100644 --- a/web/packages/design/src/Icon/index.ts +++ b/web/packages/design/src/Icon/index.ts @@ -165,6 +165,7 @@ export { Moon } from './Icons/Moon'; export { MoreHoriz } from './Icons/MoreHoriz'; export { MoreVert } from './Icons/MoreVert'; export { Mute } from './Icons/Mute'; +export { Network } from './Icons/Network'; export { NewTab } from './Icons/NewTab'; export { NoteAdded } from './Icons/NoteAdded'; export { Notification } from './Icons/Notification'; @@ -204,6 +205,7 @@ export { SortDescending } from './Icons/SortDescending'; export { Speed } from './Icons/Speed'; export { Spinner } from './Icons/Spinner'; export { SquaresFour } from './Icons/SquaresFour'; +export { Stack } from './Icons/Stack'; export { Stars } from './Icons/Stars'; export { Sun } from './Icons/Sun'; export { SyncAlt } from './Icons/SyncAlt'; diff --git a/web/packages/shared/components/CopyButton/CopyButton.tsx b/web/packages/shared/components/CopyButton/CopyButton.tsx index 8362a6d8b9f2a..a18c74ff65942 100644 --- a/web/packages/shared/components/CopyButton/CopyButton.tsx +++ b/web/packages/shared/components/CopyButton/CopyButton.tsx @@ -31,13 +31,15 @@ export function CopyButton({ value, mr, ml, + tooltip, }: { value: string; mr?: number; ml?: number; + tooltip?: string; }) { const copySuccess = 'Copied!'; - const copyDefault = 'Click to copy'; + const copyDefault = tooltip || 'Click to copy'; const timeout = useRef>(undefined); const copyAnchorEl = useRef(null); const [copiedText, setCopiedText] = useState(copyDefault); diff --git a/web/packages/teleport/src/Instances/Instances.story.tsx b/web/packages/teleport/src/Instances/Instances.story.tsx new file mode 100644 index 0000000000000..0aff2240d6e90 --- /dev/null +++ b/web/packages/teleport/src/Instances/Instances.story.tsx @@ -0,0 +1,178 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Meta, StoryObj } from '@storybook/react-vite'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { createMemoryHistory } from 'history'; +import { MemoryRouter, Route, Router } from 'react-router'; + +import cfg from 'teleport/config'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { TeleportProviderBasic } from 'teleport/mocks/providers'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + listInstancesError, + listInstancesLoading, + listInstancesSuccess, + listOnlyBotInstances, + listOnlyRegularInstances, +} from 'teleport/test/helpers/instances'; + +import { Instances } from './Instances'; + +const meta = { + title: 'Teleport/Instance Inventory', + component: Wrapper, + beforeEach: () => { + queryClient.clear(); + }, +} satisfies Meta; + +type Story = StoryObj; + +export default meta; + +export const Loaded: Story = { + parameters: { + msw: { + handlers: [listInstancesSuccess], + }, + }, +}; + +export const CacheInitializing: Story = { + parameters: { + msw: { + handlers: [listInstancesError(503, 'inventory cache is not yet healthy')], + }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [listInstancesLoading], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { + handlers: [listInstancesError(500, 'some error')], + }, + }, +}; + +export const NoInstancePermissions: Story = { + args: { + hasInstanceListPermission: false, + hasInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [listOnlyBotInstances], + }, + }, +}; + +export const NoBotInstancePermissions: Story = { + args: { + hasBotInstanceListPermission: false, + hasBotInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [listOnlyRegularInstances], + }, + }, +}; + +export const NoPermissionsAtAll: Story = { + args: { + hasInstanceListPermission: false, + hasInstanceReadPermission: false, + hasBotInstanceListPermission: false, + hasBotInstanceReadPermission: false, + }, + parameters: { + msw: { + handlers: [listInstancesError(403, 'access denied')], + }, + }, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + }, + }, +}); + +function Wrapper(props?: { + hasInstanceListPermission?: boolean; + hasInstanceReadPermission?: boolean; + hasBotInstanceListPermission?: boolean; + hasBotInstanceReadPermission?: boolean; +}) { + const { + hasInstanceListPermission = true, + hasInstanceReadPermission = true, + hasBotInstanceListPermission = true, + hasBotInstanceReadPermission = true, + } = props ?? {}; + + const history = createMemoryHistory({ + initialEntries: [cfg.routes.instances], + }); + + const customAcl = makeAcl({ + instances: { + ...defaultAccess, + read: hasInstanceReadPermission, + list: hasInstanceListPermission, + }, + botInstances: { + ...defaultAccess, + read: hasBotInstanceReadPermission, + list: hasBotInstanceListPermission, + }, + }); + + const ctx = createTeleportContext({ + customAcl, + }); + + ctx.storeUser.state.cluster.authVersion = '18.2.4'; + + return ( + + + + + + + + + + + + ); +} diff --git a/web/packages/teleport/src/Instances/Instances.test.tsx b/web/packages/teleport/src/Instances/Instances.test.tsx new file mode 100644 index 0000000000000..62e35b5838b10 --- /dev/null +++ b/web/packages/teleport/src/Instances/Instances.test.tsx @@ -0,0 +1,328 @@ +/** + * 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 { createMemoryHistory } from 'history'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { PropsWithChildren } from 'react'; +import { MemoryRouter, Route, Router } from 'react-router'; + +import darkTheme from 'design/theme/themes/darkTheme'; +import { ConfiguredThemeProvider } from 'design/ThemeProvider'; +import { + render, + screen, + testQueryClient, + userEvent, + waitFor, +} from 'design/utils/testing'; + +import cfg from 'teleport/config'; +import { ContextProvider } from 'teleport/index'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { defaultAccess, makeAcl } from 'teleport/services/user/makeAcl'; +import { + listInstancesError, + listInstancesSuccess, + listOnlyBotInstances, + listOnlyRegularInstances, + mockInstances, +} from 'teleport/test/helpers/instances'; + +import { Instances } from './Instances'; + +const server = setupServer(); + +beforeAll(() => { + server.listen(); + + global.IntersectionObserver = class IntersectionObserver { + constructor() {} + disconnect() {} + observe() {} + takeRecords() { + return []; + } + unobserve() {} + } as any; +}); + +afterEach(async () => { + server.resetHandlers(); + await testQueryClient.resetQueries(); + jest.clearAllMocks(); +}); + +afterAll(() => server.close()); + +it('having no permissions should show correct error', async () => { + renderComponent({ + customAcl: makeAcl({ + instances: { + ...defaultAccess, + list: false, + read: false, + }, + botInstances: { + ...defaultAccess, + list: false, + read: false, + }, + }), + }); + + expect( + screen.getByText( + 'You do not have permission to view the instance inventory.', + { exact: false } + ) + ).toBeInTheDocument(); +}); + +it('having only bot instances permissions should show warning banner', async () => { + server.use(listOnlyBotInstances); + renderComponent({ + customAcl: makeAcl({ + instances: { + ...defaultAccess, + list: false, + read: false, + }, + botInstances: { + ...defaultAccess, + list: true, + read: true, + }, + }), + }); + + await waitFor(() => { + expect( + screen.getByText('You do not have permission to view instances.', { + exact: false, + }) + ).toBeInTheDocument(); + }); +}); + +it('having only instances permissions should show warning banner', async () => { + server.use(listOnlyRegularInstances); + renderComponent({ + customAcl: makeAcl({ + instances: { + ...defaultAccess, + list: true, + read: true, + }, + botInstances: { + ...defaultAccess, + list: false, + read: false, + }, + }), + }); + + await waitFor(() => { + expect( + screen.getByText('You do not have permission to view bot instances.', { + exact: false, + }) + ).toBeInTheDocument(); + }); +}); + +it('cache still initializing error should show correct error', async () => { + server.use(listInstancesError(503, 'inventory cache is not yet healthy')); + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText( + 'The instance inventory is not yet ready to be displayed', + { exact: false } + ) + ).toBeInTheDocument(); + }); +}); + +it('listing successfully should show instances', async () => { + server.use(listInstancesSuccess); + renderComponent(); + + expect( + await screen.findByText('ip-10-1-1-100.ec2.internal') + ).toBeInTheDocument(); + expect(screen.getByText('teleport-auth-01')).toBeInTheDocument(); + expect(screen.getByText('app-server-prod')).toBeInTheDocument(); + expect(screen.getByText('github-actions-bot')).toBeInTheDocument(); + expect(screen.getByText('ci-cd-bot')).toBeInTheDocument(); +}); + +it('no instances should show empty state', async () => { + server.use( + http.get('/v1/webapi/sites/:clusterId/instances', () => { + return HttpResponse.json({ + instances: [], + startKey: '', + }); + }) + ); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('No instances found')).toBeInTheDocument(); + }); +}); + +it('search query param in the URL should be populated in the search input', async () => { + server.use(listInstancesSuccess); + + renderComponent({ initialUrl: cfg.routes.instances + '?query=test-server' }); + + const searchInput = screen.getByPlaceholderText(/search/i); + expect(searchInput).toHaveValue('test-server'); +}); + +it('version filter query param URL should be populated in the version filter control', async () => { + server.use(listInstancesSuccess); + + renderComponent({ + initialUrl: cfg.routes.instances + '?version_filter=up-to-date', + }); + + const versionButton = screen.getByRole('button', { + name: /Version \(1\)/i, + }); + expect(versionButton).toBeInTheDocument(); +}); + +it('selecting a version filter should append the version predicate expression to an existing advanced query', async () => { + let lastRequestUrl: string; + + server.use( + http.get('/v1/webapi/sites/:clusterId/instances', ({ request }) => { + lastRequestUrl = request.url; + return HttpResponse.json(mockInstances); + }) + ); + + const { user } = renderComponent(); + + // Wait for initial load + await screen.findByText('ip-10-1-1-100.ec2.internal'); + + // Switch to advanced search mode + const advancedToggle = screen.getByRole('checkbox', { + name: /advanced/i, + }); + await user.click(advancedToggle); + + // Type in a predicate query + const searchInput = screen.getByPlaceholderText(/search/i); + await user.clear(searchInput); + await user.type(searchInput, 'name == "teleport-auth-01"{Enter}'); + + await waitFor(() => { + expect(lastRequestUrl).toBeDefined(); + const url = new URL(lastRequestUrl); + const query = url.searchParams.get('query'); + expect(query).toBe('name == "teleport-auth-01"'); + }); + + // Select a version filter + const versionButton = screen.getByRole('button', { name: /Version/i }); + await user.click(versionButton); + + const upToDateOption = screen.getByText('Up-to-date'); + await user.click(upToDateOption); + + const applyButton = screen.getByRole('button', { name: /Apply Filters/i }); + await user.click(applyButton); + + // Verify that the request made combines both predicates + await waitFor(() => { + expect(lastRequestUrl).toBeDefined(); + const url = new URL(lastRequestUrl); + const query = url.searchParams.get('query'); + expect(query).toBe('(name == "teleport-auth-01") && (version == "18.2.4")'); + }); +}, 15000); + +function renderComponent(options?: { + customAcl?: ReturnType; + initialUrl?: string; +}) { + const user = userEvent.setup(); + const history = createMemoryHistory({ + initialEntries: [options?.initialUrl || cfg.routes.instances], + }); + + return { + ...render(, { + wrapper: makeWrapper({ + customAcl: options?.customAcl, + history, + }), + }), + user, + }; +} + +function makeWrapper(options: { + history: ReturnType; + customAcl?: ReturnType; +}) { + const { + history, + customAcl = makeAcl({ + instances: { + ...defaultAccess, + list: true, + read: true, + }, + botInstances: { + ...defaultAccess, + list: true, + read: true, + }, + }), + } = options; + + return ({ children }: PropsWithChildren) => { + const ctx = createTeleportContext({ + customAcl, + }); + + ctx.storeUser.state.cluster.authVersion = '18.2.4'; + + return ( + + + + + + {children} + + + + + + ); + }; +} diff --git a/web/packages/teleport/src/Instances/Instances.tsx b/web/packages/teleport/src/Instances/Instances.tsx new file mode 100644 index 0000000000000..6c1bf3706e210 --- /dev/null +++ b/web/packages/teleport/src/Instances/Instances.tsx @@ -0,0 +1,514 @@ +/** + * 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 { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router'; +import styled from 'styled-components'; + +import { Alert } from 'design/Alert'; +import Box from 'design/Box'; +import Flex from 'design/Flex/Flex'; +import { MultiselectMenu } from 'shared/components/Controls/MultiselectMenu'; +import { SortMenu } from 'shared/components/Controls/SortMenuV2'; +import { SearchPanel } from 'shared/components/Search'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout/Layout'; +import cfg from 'teleport/config'; +import type { SortType } from 'teleport/services/agents'; +import api from 'teleport/services/api'; +import { ApiError } from 'teleport/services/api/parseError'; +import type { UnifiedInstancesResponse } from 'teleport/services/instances/types'; +import useTeleport from 'teleport/useTeleport'; + +import { InstancesList } from './InstancesList'; +import { + buildVersionPredicate, + CustomOperator, + FilterOption, + VersionsFilterPanel, +} from './VersionsFilterPanel'; + +async function fetchInstances( + variables: { + clusterId: string; + limit: number; + startKey?: string; + query?: string; + search?: string; + sort?: SortType; + types?: string; + services?: string; + upgraders?: string; + }, + signal?: AbortSignal +): Promise { + const { clusterId, ...params } = variables; + + const response = await api.get( + cfg.getInstancesUrl(clusterId, params), + signal + ); + + return { + instances: response?.instances || [], + startKey: response?.startKey, + }; +} + +export function Instances() { + const history = useHistory(); + const location = useLocation(); + const queryParams = new URLSearchParams(location.search); + const query = queryParams.get('query') ?? ''; + const isAdvancedQuery = Boolean(queryParams.get('is_advanced')); + const sortField = queryParams.get('sort') || 'name'; + const sortDir = queryParams.get('sort_dir') || 'ASC'; + + const typesParam = queryParams.get('types'); + const selectedTypes = ( + typesParam ? typesParam.split(',') : [] + ) as InstanceType[]; + + const servicesParam = queryParams.get('services'); + const selectedServices = ( + servicesParam ? servicesParam.split(',') : [] + ) as ServiceType[]; + + const upgradersParam = queryParams.get('upgraders'); + const selectedUpgraders = ( + upgradersParam ? upgradersParam.split(',') : [] + ) as UpgraderType[]; + + const versionFilter = queryParams.get('version_filter') || ''; + const versionOperator = queryParams.get('version_operator') || ''; + const versionValue1 = queryParams.get('version_value1') || ''; + const versionValue2 = queryParams.get('version_value2') || ''; + + const ctx = useTeleport(); + const clusterId = ctx.storeUser.getClusterId(); + const authVersion = ctx.storeUser.state.cluster.authVersion; + const flags = ctx.getFeatureFlags(); + + const hasInstancePermissions = flags.listInstances && flags.readInstances; + const hasBotInstancePermissions = + flags.listBotInstances && flags.readBotInstances; + const hasAnyPermissions = hasInstancePermissions || hasBotInstancePermissions; + + // versionPredicateQuery is the predicate query for the selected version filter, if any. + // Under the hood, the version filter works by appending a predicate query to the request which + // applies the selected version filters + const versionPredicateQuery = useMemo( + () => + buildVersionPredicate( + versionFilter, + versionOperator, + versionValue1, + versionValue2, + authVersion + ), + [versionFilter, versionOperator, versionValue1, versionValue2, authVersion] + ); + + // If there is also an existing predicate query (ie. the user made an advanced search), we append the version predicate query to it in the request + const combinedQuery = useMemo(() => { + if (versionPredicateQuery && query && isAdvancedQuery) { + return `(${query}) && (${versionPredicateQuery})`; + } + + if (versionPredicateQuery) { + return versionPredicateQuery; + } + + if (isAdvancedQuery && query) { + return query; + } + + return ''; + }, [query, isAdvancedQuery, versionPredicateQuery]); + + const onlyBotInstancesSelected = + selectedTypes.length === 1 && selectedTypes[0] === 'bot_instance'; + + const { + isSuccess, + data, + isFetching, + isFetchingNextPage, + error, + hasNextPage, + fetchNextPage, + } = useInfiniteQuery({ + enabled: hasAnyPermissions, + queryKey: [ + 'instances', + 'list', + clusterId, + sortField, + sortDir, + query, + isAdvancedQuery, + selectedTypes.join(','), + selectedServices.join(','), + selectedUpgraders.join(','), + versionPredicateQuery, + ], + queryFn: ({ pageParam, signal }) => + fetchInstances( + { + clusterId, + limit: 32, + startKey: pageParam, + query: combinedQuery || undefined, + search: !isAdvancedQuery ? query : undefined, + sort: { fieldName: sortField, dir: sortDir as 'ASC' | 'DESC' }, + types: selectedTypes.length > 0 ? selectedTypes.join(',') : undefined, + services: + selectedServices.length > 0 + ? selectedServices.join(',') + : undefined, + upgraders: + selectedUpgraders.length > 0 + ? selectedUpgraders.join(',') + : undefined, + }, + signal + ), + initialPageParam: '', + getNextPageParam: data => data?.startKey || undefined, + placeholderData: keepPreviousData, + staleTime: 30_000, + }); + + // Check if the error is due to cache initialization (HTTP 503) + const isCacheInitializing = + error instanceof ApiError && error.response.status === 503; + + const updateSearch = useCallback( + (updateFn: (search: URLSearchParams) => void) => { + const search = new URLSearchParams(location.search); + updateFn(search); + history.push({ + pathname: location.pathname, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const handleQueryChange = useCallback( + (query: string, isAdvanced: boolean) => + updateSearch(search => { + if (query) { + search.set('query', query); + } else { + search.delete('query'); + } + if (isAdvanced) { + search.set('is_advanced', '1'); + } else { + search.delete('is_advanced'); + } + }), + [updateSearch] + ); + + const handleSortChange = useCallback( + (sortField: string, sortDir: string) => { + const search = new URLSearchParams(location.search); + search.set('sort', sortField); + search.set('sort_dir', sortDir); + + history.replace({ + pathname: location.pathname, + search: search.toString(), + }); + }, + [history, location.pathname, location.search] + ); + + const handleTypesChange = useCallback( + (types: InstanceType[]) => + updateSearch(search => { + if (types.length > 0) { + search.set('types', types.join(',')); + } else { + search.delete('types'); + } + }), + [updateSearch] + ); + + const handleServicesChange = useCallback( + (services: ServiceType[]) => + updateSearch(search => { + if (services.length > 0) { + search.set('services', services.join(',')); + } else { + search.delete('services'); + } + }), + [updateSearch] + ); + + const handleUpgradersChange = useCallback( + (upgraders: UpgraderType[]) => + updateSearch(search => { + if (upgraders.length > 0) { + search.set('upgraders', upgraders.join(',')); + } else { + search.delete('upgraders'); + } + }), + [updateSearch] + ); + + const handleVersionFilterChange = useCallback( + (filter: { + selectedOption: string; + operator: string; + value1: string; + value2: string; + }) => + updateSearch(search => { + if (filter.selectedOption) { + // If it's one of the preset filters, set it as the version_filter param in the route + search.set('version_filter', filter.selectedOption); + + // For a custom condition version filter, we also set the custom version values + if (filter.selectedOption === 'custom') { + search.set('version_operator', filter.operator); + if (filter.value1) { + search.set('version_value1', filter.value1); + } else { + search.delete('version_value1'); + } + + if (filter.value2) { + search.set('version_value2', filter.value2); + } else { + search.delete('version_value2'); + } + } else { + search.delete('version_operator'); + search.delete('version_value1'); + search.delete('version_value2'); + } + } else { + search.delete('version_filter'); + search.delete('version_operator'); + search.delete('version_value1'); + search.delete('version_value2'); + } + }), + [updateSearch] + ); + + const flatData = useMemo( + () => (isSuccess ? data.pages.flatMap(page => page.instances) : []), + [data?.pages, isSuccess] + ); + + // If they have neither instances nor bot instances permissions, just render a message informing them + if (!hasAnyPermissions) { + return ( + + + Instance Inventory + + + You do not have permission to view the instance inventory. Missing + permissions: instance.list or instance.read, + and bot_instance.list or bot_instance.read. + + + ); + } + + if (isCacheInitializing) { + return ( + + + Instance Inventory + + + The instance inventory is not yet ready to be displayed, please check + back in a few minutes. + + + ); + } + + return ( + + + Instance Inventory + + + + {!hasInstancePermissions && hasBotInstancePermissions && ( + + You do not have permission to view instances. This list will only + show bot instances. +
+ Listing instances requires permissions + instance.list + {' '} + and instance.read. +
+ )} + {hasInstancePermissions && !hasBotInstancePermissions && ( + + You do not have permission to view bot instances. This list will + only show instances. +
+ Listing bot instances requires permissions{' '} + bot_instance.list and bot_instance.read. +
+ )} + handleQueryChange(query, false)} + updateQuery={query => handleQueryChange(query, true)} + mb={3} + /> + + + + + + + + { + handleSortChange(key, order); + }} + /> + + +
+
+ ); +} + +const FiltersRow = styled(Flex)` + flex-wrap: wrap; +`; + +type InstanceType = 'instance' | 'bot_instance'; + +type ServiceType = + | 'App' + | 'Db' + | 'WindowsDesktop' + | 'Kube' + | 'Node' + | 'Auth' + | 'Proxy'; + +type UpgraderType = + | '' + | 'kube-updater' + | 'unit-updater' + | 'systemd-unit-updater'; + +const typeOptions: { value: InstanceType; label: string }[] = [ + { value: 'instance', label: 'Instances' }, + { value: 'bot_instance', label: 'Bot Instances' }, +]; + +const serviceOptions: { value: ServiceType; label: string }[] = [ + { value: 'App', label: 'Applications' }, + { value: 'Db', label: 'Databases' }, + { value: 'WindowsDesktop', label: 'Desktops' }, + { value: 'Kube', label: 'Kubernetes Clusters' }, + { value: 'Node', label: 'SSH Servers' }, + { value: 'Auth', label: 'Auth' }, + { value: 'Proxy', label: 'Proxy' }, +]; + +const upgraderOptions = [ + { value: '', label: 'None' }, + { value: 'unit-updater', label: 'Unit Updater (legacy)' }, + { + value: 'systemd-unit-updater', + label: 'Systemd Unit Updater', + }, + { value: 'kube-updater', label: 'Kubernetes' }, +]; + +const sortFields = [ + { key: 'name', label: 'Name' }, + { key: 'version', label: 'Version' }, + { key: 'type', label: 'Type' }, +]; diff --git a/web/packages/teleport/src/Instances/InstancesList.tsx b/web/packages/teleport/src/Instances/InstancesList.tsx new file mode 100644 index 0000000000000..db4b0a1fa79cc --- /dev/null +++ b/web/packages/teleport/src/Instances/InstancesList.tsx @@ -0,0 +1,354 @@ +/** + * 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 { Link } from 'react-router-dom'; +import styled from 'styled-components'; + +import { Box, Flex, Text } from 'design'; +import { Danger } from 'design/Alert'; +import { ButtonBorder } from 'design/Button'; +import Table, { Cell } from 'design/DataTable'; +import * as Icons from 'design/Icon'; +import { Indicator } from 'design/Indicator'; +import { HoverTooltip } from 'design/Tooltip'; +import { CopyButton } from 'shared/components/CopyButton/CopyButton'; +import { useInfiniteScroll } from 'shared/hooks'; + +import cfg from 'teleport/config'; +import { UnifiedInstance } from 'teleport/services/instances/types'; + +type TableInstance = { + name: string; + version: string; + type: 'instance' | 'bot_instance'; + original: UnifiedInstance; +}; + +export function InstancesList(props: { + data: UnifiedInstance[]; + isLoading: boolean; + isFetchingNextPage: boolean; + error: Error | null; + hasNextPage: boolean; + sortField: string; + sortDir: string; + onSortChanged: (sortField: string, sortDir: string) => void; + onLoadNextPage: () => void; +}) { + const { + data, + isLoading, + isFetchingNextPage, + error, + hasNextPage, + sortField, + sortDir, + onSortChanged, + onLoadNextPage, + } = props; + + // Normalize the data + const tableData: TableInstance[] = data.map(instance => ({ + name: + instance.type === 'instance' + ? instance.instance?.name || instance.id + : instance.botInstance?.name || '', + version: + instance.type === 'instance' + ? instance.instance?.version || '' + : instance.botInstance?.version || '', + type: instance.type, + original: instance, + })); + + const { setTrigger } = useInfiniteScroll({ + fetch: async () => { + if (hasNextPage && !isFetchingNextPage) { + onLoadNextPage(); + } + }, + }); + + if (isLoading) { + return ( + + + + ); + } + + if (error) { + return ( + + Failed to fetch instances + + ); + } + + if (!data || data.length === 0) { + return ( + + + No instances found + + + ); + } + + return ( + + ( + + ), + }, + { + key: 'version', + headerText: 'Version', + isSortable: true, + render: (row: TableInstance) => {row.version}, + }, + { + key: 'type', + headerText: 'Type', + isSortable: true, + render: (row: TableInstance) => ( + + {row.type === 'instance' ? 'Instance' : 'Bot Instance'} + + ), + }, + { + altKey: 'services', + headerText: 'Services', + render: (row: TableInstance) => ( + + ), + }, + { + altKey: 'upgrader', + headerText: 'Upgrader', + render: (row: TableInstance) => { + const upgraderType = + row.type === 'instance' + ? row.original.instance?.upgrader?.type + : undefined; + return ; + }, + }, + { + altKey: 'upgrader-group', + headerText: 'Upgrader Group', + render: (row: TableInstance) => { + const group = + row.type === 'instance' + ? row.original.instance?.upgrader?.group + : undefined; + return {group || ''}; + }, + }, + ]} + emptyText="No instances found" + customSort={{ + fieldName: sortField, + dir: sortDir === 'DESC' ? 'DESC' : 'ASC', + onSort: sort => { + onSortChanged(sort.fieldName, sort.dir); + }, + }} + /> + + {isFetchingNextPage && ( + + + + )} + + ); +} + +const StyledTable = styled(Table)` + thead > tr > th { + color: ${props => props.theme.colors.text.slightlyMuted}; + } +` as typeof Table; + +function NameCell({ instance }: { instance: UnifiedInstance }) { + const name = + instance.type === 'instance' + ? instance.instance?.name || instance.id // Use the id as the name in case it doesn't have a friendly name + : instance.botInstance?.name; + + return ( + + {name && {name}} + + + {instance.id.substring(0, 7)} + + + + + + + ); +} + +/** + * UpgraderCell displays the upgrader in a more readable way with styling + */ +function UpgraderCell({ upgrader }: { upgrader: string | undefined }) { + if (!upgrader || upgrader === '') { + return ( + + None + + ); + } + + if (upgrader === 'unit-updater') { + return ( + + Unit Updater (legacy) + + ); + } + + if (upgrader === 'systemd-unit-updater') { + return ( + + Systemd Unit Updater + + ); + } + + if (upgrader === 'kube-updater') { + return ( + + Kubernetes + + ); + } + + // This normally shouldn't happen, but in case it's none of the expected values, and it's also not empty, just display whatever it is as is + return ( + + {upgrader} + + ); +} + +function ServicesCell({ instance }: { instance: UnifiedInstance }) { + // For bot instances, we don't list services in this table. Instead, we deeplink to the bot instance dashboard page with this + // particular bot instance filtered for and selected + if (instance.type === 'bot_instance') { + const query = `spec.instance_id == "${instance.id}"`; + const botName = instance.botInstance.name; + const url = cfg.getBotInstancesRoute({ + query, + isAdvancedQuery: true, + selectedItemId: `${botName}/${instance.id}`, + }); + + return ( + + + + Services + + + + ); + } + + const services = instance.instance?.services || []; + + return ( + + + {services.map(service => { + const IconComponent = getServiceIcon(service); + const displayName = getServiceDisplayName(service); + return ( + + + + + + ); + })} + + + ); +} + +function getServiceIcon(service: string): React.ComponentType { + const serviceMap: Record> = { + node: Icons.Server, + kube: Icons.Kubernetes, + app: Icons.Application, + db: Icons.Database, + windows_desktop: Icons.Desktop, + proxy: Icons.Network, + auth: Icons.Keypair, + }; + + return serviceMap[service.toLowerCase()] || Icons.Server; +} + +function getServiceDisplayName(service: string): string { + const displayNames: Record = { + node: 'SSH Server', + kube: 'Kubernetes', + app: 'Application', + db: 'Database', + windows_desktop: 'Windows Desktop', + proxy: 'Proxy', + auth: 'Auth', + }; + + return displayNames[service.toLowerCase()] || service; +} + +const IdContainer = styled(Box)` + display: inline-flex; + align-items: center; + gap: ${props => props.theme.space[1]}px; +`; + +const IdText = styled(Text)` + color: ${props => props.theme.colors.text.muted}; + font-size: ${props => props.theme.fontSizes[1]}px; + font-family: ${props => props.theme.fonts.mono}; +`; + +const CopyButtonWrapper = styled(Box)` + display: inline-flex; + align-items: center; + opacity: 0; + + tr:hover & { + opacity: 1; + } +`; diff --git a/web/packages/teleport/src/Instances/VersionsFilterPanel.tsx b/web/packages/teleport/src/Instances/VersionsFilterPanel.tsx new file mode 100644 index 0000000000000..b87f3aec5fa24 --- /dev/null +++ b/web/packages/teleport/src/Instances/VersionsFilterPanel.tsx @@ -0,0 +1,530 @@ +/** + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useState } from 'react'; +import styled, { useTheme } from 'styled-components'; + +import { + Box, + ButtonPrimary, + ButtonSecondary, + Flex, + Input, + Menu, + MenuItem, + Text, +} from 'design'; +import { Check, ChevronDown } from 'design/Icon'; +import * as Icons from 'design/Icon'; +import { HoverTooltip } from 'design/Tooltip'; +import { FiltersExistIndicator } from 'shared/components/Controls/MultiselectMenu'; +import Select from 'shared/components/Select'; +import { major, parse } from 'shared/utils/semVer'; + +export type FilterOption = + | 'up-to-date' + | 'patch' + | 'upgrade' + | 'incompatible' + | 'custom'; + +export type CustomOperator = + | 'equals' + | 'less-than' + | 'greater-than' + | 'between'; + +interface VersionsFilterPanelProps { + currentVersion: string; + onApply: (filter: { + selectedOption: FilterOption; + operator: CustomOperator; + value1: string; + value2: string; + }) => void; + tooltip?: string; + disabled?: boolean; + filter?: FilterOption; + operator?: CustomOperator; + value1?: string; + value2?: string; +} + +export function VersionsFilterPanel({ + currentVersion, + onApply, + tooltip = 'Filter by version', + disabled = false, + filter, + operator = 'equals', + value1 = '', + value2 = '', +}: VersionsFilterPanelProps) { + const theme = useTheme(); + const [anchorEl, setAnchorEl] = useState(null); + const [selectedOption, setSelectedOption] = useState( + null + ); + const [customOperator, setCustomOperator] = + useState('equals'); + const [customValue1, setCustomValue1] = useState(''); + const [customValue2, setCustomValue2] = useState(''); + + const handleOpen = (event: React.MouseEvent) => { + setSelectedOption(filter || null); + setCustomOperator(operator || 'equals'); + setCustomValue1(value1); + setCustomValue2(value2); + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleApply = () => { + onApply({ + selectedOption: selectedOption, + operator: customOperator, + value1: customValue1, + value2: customValue2, + }); + handleClose(); + }; + + const handleOptionSelect = (option: FilterOption) => { + // Clicking on the already selected option unselects it + if (selectedOption === option) { + setSelectedOption(null); + setCustomValue1(''); + setCustomValue2(''); + } else { + setSelectedOption(option); + if (option !== 'custom') { + setCustomValue1(''); + setCustomValue2(''); + } + } + }; + + const handleClearCustom = () => { + setCustomValue1(''); + setCustomValue2(''); + setSelectedOption(null); + }; + + const operatorOptions: { value: CustomOperator; label: string }[] = [ + { value: 'equals', label: 'Equals' }, + { value: 'less-than', label: 'Older than' }, + { value: 'greater-than', label: 'Newer than' }, + { value: 'between', label: 'Between' }, + ]; + + const minorVersion = getMinorVersion(currentVersion); + + const presetOptions: Array<{ + value: FilterOption; + label: string; + disabled?: boolean; + }> = [ + { value: 'up-to-date', label: 'Up-to-date' }, + { + value: 'patch', + label: 'Patch available', + // Disable if the minor version is the same as the current version, since that makes this option redundant. + // This can happen on the first major release version, (eg. 19.0.0). + disabled: minorVersion === currentVersion, + }, + { value: 'upgrade', label: 'Upgrade available' }, + { value: 'incompatible', label: 'Incompatible' }, + ]; + + return ( + + + + Version + {filter && ' (1)'} + + {filter && } + + + `margin-top: 36px; width: 360px;`} + transformOrigin={{ + vertical: 'top', + horizontal: 'left', + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'left', + }} + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleClose} + > + {presetOptions.map(opt => ( + !opt.disabled && handleOptionSelect(opt.value)} + width="100%" + disabled={opt.disabled} + > + + + {selectedOption === opt.value && } + + + + {opt.label} + + + {getFilterDescription(opt.value, currentVersion)} + + + + + ))} + + handleOptionSelect('custom')} + > + + + {selectedOption === 'custom' && } + + + + Custom condition + + { + if (selectedOption === 'custom') { + e.stopPropagation(); + } + }} + > + setCustomValue1(e.target.value)} + disabled={selectedOption !== 'custom'} + onFocus={() => { + if (selectedOption !== 'custom') { + handleOptionSelect('custom'); + } + }} + width="70px" + /> + + & + + setCustomValue2(e.target.value)} + disabled={selectedOption !== 'custom'} + onFocus={() => { + if (selectedOption !== 'custom') { + handleOptionSelect('custom'); + } + }} + width="70px" + /> + + ) : ( + setCustomValue1(e.target.value)} + disabled={selectedOption !== 'custom'} + onFocus={() => { + if (selectedOption !== 'custom') { + handleOptionSelect('custom'); + } + }} + /> + )} + + + + + + + + + + + + + Apply Filters + + + Cancel + + + + + ); +} + +// stripVersionPrefix removes the 'v' prefix from a version string if present +function stripVersionPrefix(version: string): string { + return version.startsWith('v') ? version.slice(1) : version; +} + +export function getMajorVersion(version: string): string { + const parsed = parse(stripVersionPrefix(version)); + return `${parsed.major}.0.0`; +} + +export function getMinorVersion(version: string): string { + const parsed = parse(stripVersionPrefix(version)); + return `${parsed.major}.${parsed.minor}.0`; +} + +export function getPreviousMajorVersion(version: string): string { + const majorNum = major(stripVersionPrefix(version)); + return `${majorNum - 1}.0.0`; +} + +export function getNextMajorVersion(version: string): string { + const majorNum = major(stripVersionPrefix(version)); + return `${majorNum + 1}.0.0`; +} + +// buildVersionPredicate returns the predicate query corresponding to a given version filter selection. +export function buildVersionPredicate( + filter: string, + operator: string, + value1: string, + value2: string, + currentVersion: string +): string { + if (!filter) return ''; + + // Strip 'v' prefix from versions if present + const strippedCurrentVersion = stripVersionPrefix(currentVersion); + const strippedValue1 = stripVersionPrefix(value1); + const strippedValue2 = stripVersionPrefix(value2); + + const minorVersion = getMinorVersion(currentVersion); + const prevMajor = getPreviousMajorVersion(currentVersion); + const nextMajor = getNextMajorVersion(currentVersion); + + switch (filter) { + case 'up-to-date': + return `version == "${strippedCurrentVersion}"`; + case 'patch': + return `between(version, "${minorVersion}", "${strippedCurrentVersion}")`; + case 'upgrade': + return `between(version, "${prevMajor}", "${minorVersion}")`; + case 'incompatible': + return `older_than(version, "${prevMajor}") || newer_than(version, "${nextMajor}")`; + case 'custom': + switch (operator) { + case 'equals': + return strippedValue1 ? `version == "${strippedValue1}"` : ''; + case 'less-than': + return strippedValue1 + ? `older_than(version, "${strippedValue1}")` + : ''; + case 'greater-than': + return strippedValue1 + ? `newer_than(version, "${strippedValue1}")` + : ''; + case 'between': + return strippedValue1 && strippedValue2 + ? `between(version, "${strippedValue1}", "${strippedValue2}")` + : ''; + default: + return ''; + } + default: + return ''; + } +} + +/** + * getFilterDescription returns the text on the right of the preset version filter options indicating what each option entails + */ +const getFilterDescription = ( + option: FilterOption, + currentVersion: string +): string => { + const minorVersion = getMinorVersion(currentVersion); + const prevMajor = getPreviousMajorVersion(currentVersion); + const nextMajor = getNextMajorVersion(currentVersion); + + switch (option) { + case 'up-to-date': + return currentVersion; + case 'patch': + return `between ${minorVersion} & ${currentVersion}`; + case 'upgrade': + return `between ${prevMajor} & ${minorVersion}`; + case 'incompatible': + return `<${prevMajor} or >${nextMajor}`; + default: + return ''; + } +}; + +const FilterMenuItem = styled(MenuItem)<{ disabled?: boolean }>` + &:hover:not(:disabled) { + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; + } + + ${p => + p.disabled && + ` + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + `} +`; + +const Divider = styled.div` + height: 1px; + background-color: ${p => p.theme.colors.interactive.tonal.neutral[1]}; +`; + +const CheckIconWrapper = styled.div` + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: ${p => p.theme.colors.text.main}; +`; + +const ClearButton = styled.button` + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + color: ${p => p.theme.colors.text.muted}; + border-radius: 4px; + height: 32px; + width: 32px; + margin-left: ${p => p.theme.space[1]}px; + + &:hover:not(:disabled) { + background-color: ${p => p.theme.colors.interactive.tonal.neutral[0]}; + color: ${p => p.theme.colors.text.main}; + } + + &:disabled { + opacity: 0.3; + } +`; + +const ActionButtonsContainer = styled(Flex)` + position: sticky; + bottom: 0; + background-color: ${p => p.theme.colors.levels.elevated}; + z-index: 1; +`; diff --git a/web/packages/teleport/src/Instances/index.ts b/web/packages/teleport/src/Instances/index.ts new file mode 100644 index 0000000000000..56d22cc69c808 --- /dev/null +++ b/web/packages/teleport/src/Instances/index.ts @@ -0,0 +1,19 @@ +/** + * 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 . + */ + +export { Instances } from './Instances'; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 8ec2c3f10e147..d34c8fa39aa4a 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -188,6 +188,7 @@ const cfg = { bots: '/web/bots', bot: '/web/bot/:botName', botInstances: '/web/bots/instances', + instances: '/web/instances', botsNew: '/web/bots/new/:type?', workloadIdentities: '/web/workloadidentities', console: '/web/cluster/:clusterId/console', @@ -294,6 +295,8 @@ const cfg = { list: `/v1/webapi/sites/:clusterId/databaseservers?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?`, }, + instancesPath: `/v1/webapi/sites/:clusterId/instances?limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?&types=:types?&services=:services?&upgraders=:upgraders?`, + desktopsPath: `/v1/webapi/sites/:clusterId/desktops?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?`, desktopPath: `/v1/webapi/sites/:clusterId/desktops/:desktopName`, desktopWsAddr: @@ -895,6 +898,10 @@ const cfg = { return generatePath(`${cfg.routes.botInstances}?${search.toString()}`); }, + getInstancesRoute() { + return generatePath(cfg.routes.instances); + }, + getWorkloadIdentitiesRoute() { return generatePath(cfg.routes.workloadIdentities); }, @@ -1171,6 +1178,13 @@ const cfg = { }); }, + getInstancesUrl(clusterId: string, params?: UrlResourcesParams) { + return generateResourcePath(cfg.api.instancesPath, { + clusterId, + ...params, + }); + }, + getYamlParseUrl(kind: YamlSupportedResourceKind) { return generatePath(cfg.api.yaml.parse, { kind }); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 6fe3589688cd4..3bd70d76d56a7 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -34,6 +34,7 @@ import { Question, Server, SlidersVertical, + Stack, Terminal, UserCircleGear, User as UserIcon, @@ -59,6 +60,7 @@ import { BotDetails } from './Bots/Details/BotDetails'; import { Clusters } from './Clusters'; import { DeviceTrustLocked } from './DeviceTrust'; import { Discover } from './Discover'; +import { Instances } from './Instances/Instances'; import { Integrations } from './Integrations'; import { JoinTokens } from './JoinTokens/JoinTokens'; import { Locks } from './LocksV2/Locks'; @@ -310,6 +312,40 @@ export class FeatureBotInstanceDetails implements TeleportFeature { } } +export class FeatureInstances implements TeleportFeature { + category = NavigationCategory.ZeroTrustAccess; + + route = { + title: 'Instance Inventory', + path: cfg.routes.instances, + exact: true, + component: Instances, + }; + + hasAccess(flags: FeatureFlags) { + // if feature hiding is enabled, only show + // if the user has access + if (shouldHideFromNavigation(cfg)) { + return flags.listInstances || flags.listBotInstances; + } + return true; + } + + navigationItem = { + title: NavTitle.InstanceInventory, + icon: Stack, + exact: true, + getLink() { + return cfg.getInstancesRoute(); + }, + searchableTags: ['instances', 'instance', 'agents', 'inventory'], + }; + + getRoute() { + return this.route; + } +} + export class FeatureBotDetails implements TeleportFeature { parent = FeatureBots; @@ -859,6 +895,7 @@ export function getOSSFeatures(): TeleportFeature[] { new FeatureBots(), new FeatureBotDetails(), new FeatureBotInstances(), + new FeatureInstances(), new FeatureAddBotsShortcut(), new FeatureJoinTokens(), new FeatureRoles(), diff --git a/web/packages/teleport/src/generateResourcePath.ts b/web/packages/teleport/src/generateResourcePath.ts index a999390fec9df..b434349b32bac 100644 --- a/web/packages/teleport/src/generateResourcePath.ts +++ b/web/packages/teleport/src/generateResourcePath.ts @@ -72,8 +72,11 @@ export default function generateResourcePath( .replace(':resourceType?', params.resourceType || '') .replace(':search?', processedParams.search || '') .replace(':searchAsRoles?', processedParams.searchAsRoles || '') + .replace(':services?', processedParams.services || '') .replace(':sort?', processedParams.sort || '') .replace(':startKey?', params.startKey || '') + .replace(':types?', processedParams.types || '') + .replace(':upgraders?', processedParams.upgraders || '') .replace(':regions?', processedParams.regions || '') .replace(':owners?', processedParams.owners || '') .replace(':roles?', processedParams.roles || '') diff --git a/web/packages/teleport/src/mocks/contexts.ts b/web/packages/teleport/src/mocks/contexts.ts index 1f65a0fbab07e..2bfdca330e00f 100644 --- a/web/packages/teleport/src/mocks/contexts.ts +++ b/web/packages/teleport/src/mocks/contexts.ts @@ -78,6 +78,7 @@ export const allAccessAcl: Acl = { contacts: fullAccess, gitServers: fullAccess, accessGraphSettings: fullAccess, + instances: fullAccess, botInstances: fullAccess, workloadIdentity: fullAccess, clientIpRestriction: fullAccess, diff --git a/web/packages/teleport/src/services/instances/index.ts b/web/packages/teleport/src/services/instances/index.ts new file mode 100644 index 0000000000000..0a494d6c54aa2 --- /dev/null +++ b/web/packages/teleport/src/services/instances/index.ts @@ -0,0 +1,25 @@ +/** + * 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 . + */ + +export type { + UnifiedInstance, + Instance, + BotInstance, + UpgraderInfo, + UnifiedInstancesResponse, +} from './types'; diff --git a/web/packages/teleport/src/services/instances/types.ts b/web/packages/teleport/src/services/instances/types.ts new file mode 100644 index 0000000000000..606c145f0c842 --- /dev/null +++ b/web/packages/teleport/src/services/instances/types.ts @@ -0,0 +1,47 @@ +/** + * 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 . + */ + +export type UnifiedInstance = { + id: string; + type: 'instance' | 'bot_instance'; + instance?: Instance; + botInstance?: BotInstance; +}; + +export type Instance = { + name: string; + version: string; + services: string[]; + upgrader?: UpgraderInfo; +}; + +export type BotInstance = { + name: string; + version: string; +}; + +export type UpgraderInfo = { + type: string; + version: string; + group: string; +}; + +export type UnifiedInstancesResponse = { + instances: UnifiedInstance[]; + startKey?: string; +}; diff --git a/web/packages/teleport/src/services/user/makeAcl.ts b/web/packages/teleport/src/services/user/makeAcl.ts index 501d1ae617dfc..e50fb73670498 100644 --- a/web/packages/teleport/src/services/user/makeAcl.ts +++ b/web/packages/teleport/src/services/user/makeAcl.ts @@ -86,6 +86,8 @@ export function makeAcl(json): Acl { const botInstances = json.botInstances || defaultAccess; + const instances = json.instances || defaultAccess; + const workloadIdentity = json.workloadIdentity || defaultAccess; const clientIpRestriction = json.clientIpRestriction || defaultAccess; @@ -132,6 +134,7 @@ export function makeAcl(json): Acl { gitServers, accessGraphSettings, botInstances, + instances, workloadIdentity, clientIpRestriction, }; diff --git a/web/packages/teleport/src/services/user/types.ts b/web/packages/teleport/src/services/user/types.ts index 12ef46be89256..7c76fee17dfdd 100644 --- a/web/packages/teleport/src/services/user/types.ts +++ b/web/packages/teleport/src/services/user/types.ts @@ -113,6 +113,7 @@ export interface Acl { gitServers: Access; accessGraphSettings: Access; botInstances: Access; + instances: Access; workloadIdentity: Access; clientIpRestriction: Access; } diff --git a/web/packages/teleport/src/services/user/user.test.ts b/web/packages/teleport/src/services/user/user.test.ts index 6ec02a67402d7..ab1e97fc5d258 100644 --- a/web/packages/teleport/src/services/user/user.test.ts +++ b/web/packages/teleport/src/services/user/user.test.ts @@ -303,6 +303,13 @@ test('undefined values in context response gives proper default values', async ( create: false, remove: false, }, + instances: { + list: false, + read: false, + edit: false, + create: false, + remove: false, + }, botInstances: { list: false, read: false, diff --git a/web/packages/teleport/src/stores/storeUserContext.ts b/web/packages/teleport/src/stores/storeUserContext.ts index f29f02fe7f768..2f202d2dc3487 100644 --- a/web/packages/teleport/src/stores/storeUserContext.ts +++ b/web/packages/teleport/src/stores/storeUserContext.ts @@ -263,6 +263,10 @@ export default class StoreUserContext extends Store { return this.state.acl.botInstances; } + getInstancesAccess() { + return this.state.acl.instances; + } + getContactsAccess() { return this.state.acl.contacts; } diff --git a/web/packages/teleport/src/teleportContext.tsx b/web/packages/teleport/src/teleportContext.tsx index 341193f93f241..87eea14db3927 100644 --- a/web/packages/teleport/src/teleportContext.tsx +++ b/web/packages/teleport/src/teleportContext.tsx @@ -225,6 +225,8 @@ class TeleportContext implements types.Context { userContext.getGitServersAccess().read, readBotInstances: userContext.getBotInstancesAccess().read, listBotInstances: userContext.getBotInstancesAccess().list, + readInstances: userContext.getInstancesAccess().read, + listInstances: userContext.getInstancesAccess().list, listWorkloadIdentities: userContext.getWorkloadIdentityAccess().list, }; } @@ -271,6 +273,8 @@ export const disabledFeatureFlags: types.FeatureFlags = { gitServers: false, readBotInstances: false, listBotInstances: false, + readInstances: false, + listInstances: false, listWorkloadIdentities: false, }; diff --git a/web/packages/teleport/src/test/helpers/instances.ts b/web/packages/teleport/src/test/helpers/instances.ts new file mode 100644 index 0000000000000..37b9a8b5fb0ee --- /dev/null +++ b/web/packages/teleport/src/test/helpers/instances.ts @@ -0,0 +1,129 @@ +/** + * 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 { delay, http, HttpResponse } from 'msw'; + +import { UnifiedInstancesResponse } from 'teleport/services/instances/types'; + +export const regularInstances = [ + { + id: crypto.randomUUID(), + type: 'instance' as const, + instance: { + name: 'ip-10-1-1-100.ec2.internal', + version: '18.2.4', + services: ['node', 'proxy'], + upgrader: { + type: 'systemd-unit-updater', + version: '18.2.4', + group: 'production', + }, + }, + }, + { + id: crypto.randomUUID(), + type: 'instance' as const, + instance: { + name: 'teleport-auth-01', + version: '18.2.3', + services: ['auth'], + upgrader: { + type: 'kube-updater', + version: '18.2.3', + group: 'staging', + }, + }, + }, + { + id: crypto.randomUUID(), + type: 'instance' as const, + instance: { + name: 'app-server-prod', + version: '18.1.0', + services: ['app', 'db'], + }, + }, +]; + +export const botInstances = [ + { + id: crypto.randomUUID(), + type: 'bot_instance' as const, + botInstance: { + name: 'github-actions-bot', + version: '18.2.4', + }, + }, + { + id: crypto.randomUUID(), + type: 'bot_instance' as const, + botInstance: { + name: 'ci-cd-bot', + version: '18.2.2', + }, + }, +]; + +export const mockInstances: UnifiedInstancesResponse = { + instances: [...regularInstances, ...botInstances], + startKey: '', +}; + +export const mockOnlyRegularInstances: UnifiedInstancesResponse = { + instances: regularInstances, + startKey: '', +}; + +export const mockOnlyBotInstances: UnifiedInstancesResponse = { + instances: botInstances, + startKey: '', +}; + +export const listInstancesSuccess = http.get( + '/v1/webapi/sites/:clusterId/instances', + () => { + return HttpResponse.json(mockInstances); + } +); + +export const listOnlyRegularInstances = http.get( + '/v1/webapi/sites/:clusterId/instances', + () => { + return HttpResponse.json(mockOnlyRegularInstances); + } +); + +export const listOnlyBotInstances = http.get( + '/v1/webapi/sites/:clusterId/instances', + () => { + return HttpResponse.json(mockOnlyBotInstances); + } +); + +export const listInstancesError = (status: number, message: string) => + http.get('/v1/webapi/sites/:clusterId/instances', () => { + return HttpResponse.json({ error: { message } }, { status }); + }); + +export const listInstancesLoading = http.get( + '/v1/webapi/sites/:clusterId/instances', + async () => { + await delay('infinite'); + return HttpResponse.json(mockInstances); + } +); diff --git a/web/packages/teleport/src/types.ts b/web/packages/teleport/src/types.ts index 55e80acd4e1a4..e587d21d7bfe4 100644 --- a/web/packages/teleport/src/types.ts +++ b/web/packages/teleport/src/types.ts @@ -59,6 +59,7 @@ export enum NavTitle { Users = 'Users', Bots = 'Bots', BotInstances = 'Bot Instances', + InstanceInventory = 'Instance Inventory', Roles = 'Roles', JoinTokens = 'Join Tokens', AuthConnectors = 'Auth Connectors', @@ -210,6 +211,8 @@ export interface FeatureFlags { readBots: boolean; readBotInstances: boolean; listBotInstances: boolean; + readInstances: boolean; + listInstances: boolean; addBots: boolean; editBots: boolean; removeBots: boolean;