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