implement instance inventory frontend (#62027)

This commit is contained in:
Yassine Bounekhla
2025-12-19 23:29:12 +00:00
committed by GitHub
parent e76216ca6a
commit 4eb85869a8
28 changed files with 2357 additions and 1 deletions
+1
View File
@@ -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")
}
+4
View File
@@ -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,
@@ -176,6 +176,7 @@ export const Icons = () => (
<IconBox IconCmpt={Icon.MoreHoriz} text="MoreHoriz" />
<IconBox IconCmpt={Icon.MoreVert} text="MoreVert" />
<IconBox IconCmpt={Icon.Mute} text="Mute" />
<IconBox IconCmpt={Icon.Network} text="Network" />
<IconBox IconCmpt={Icon.NewTab} text="NewTab" />
<IconBox IconCmpt={Icon.NoteAdded} text="NoteAdded" />
<IconBox IconCmpt={Icon.Notification} text="Notification" />
@@ -215,6 +216,7 @@ export const Icons = () => (
<IconBox IconCmpt={Icon.Speed} text="Speed" />
<IconBox IconCmpt={Icon.Spinner} text="Spinner" />
<IconBox IconCmpt={Icon.SquaresFour} text="SquaresFour" />
<IconBox IconCmpt={Icon.Stack} text="Stack" />
<IconBox IconCmpt={Icon.Stars} text="Stars" />
<IconBox IconCmpt={Icon.Sun} text="Sun" />
<IconBox IconCmpt={Icon.SyncAlt} text="SyncAlt" />
@@ -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 <http://www.gnu.org/licenses/>.
*/
/* 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<HTMLSpanElement, IconProps>(
({ size = 24, color, ...otherProps }, ref) => (
<Icon
size={size}
color={color}
className="icon icon-network"
{...otherProps}
ref={ref}
>
<path d="M21.75 10.5h-9V8.25h.75a1.5 1.5 0 0 0 1.5-1.5v-3a1.5 1.5 0 0 0-1.5-1.5h-3A1.5 1.5 0 0 0 9 3.75v3a1.5 1.5 0 0 0 1.5 1.5h.75v2.25h-9a.75.75 0 1 0 0 1.5h3v3H4.5A1.5 1.5 0 0 0 3 16.5v3A1.5 1.5 0 0 0 4.5 21h3A1.5 1.5 0 0 0 9 19.5v-3A1.5 1.5 0 0 0 7.5 15h-.75v-3h10.5v3h-.75a1.5 1.5 0 0 0-1.5 1.5v3a1.5 1.5 0 0 0 1.5 1.5h3a1.5 1.5 0 0 0 1.5-1.5v-3a1.5 1.5 0 0 0-1.5-1.5h-.75v-3h3a.75.75 0 1 0 0-1.5M10.5 3.75h3v3h-3zm-3 15.75h-3v-3h3zm12 0h-3v-3h3z" />
</Icon>
)
);
@@ -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 <http://www.gnu.org/licenses/>.
*/
/* 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<HTMLSpanElement, IconProps>(
({ size = 24, color, ...otherProps }, ref) => (
<Icon
size={size}
color={color}
className="icon icon-stack"
{...otherProps}
ref={ref}
>
<path
fillRule="evenodd"
d="M12.378 1.602a.75.75 0 0 0-.756 0l-9 5.25a.75.75 0 0 0 0 1.296l9 5.25a.75.75 0 0 0 .756 0l9-5.25a.75.75 0 0 0 0-1.296zM12 11.882 4.488 7.5 12 3.118 19.512 7.5z"
clipRule="evenodd"
/>
<path d="M2.352 11.622a.75.75 0 0 1 1.026-.27L12 16.382l8.622-5.03a.75.75 0 0 1 .756 1.296l-9 5.25a.75.75 0 0 1-.756 0l-9-5.25a.75.75 0 0 1-.27-1.026" />
<path d="M2.352 16.122a.75.75 0 0 1 1.026-.27L12 20.882l8.622-5.03a.75.75 0 0 1 .756 1.296l-9 5.25a.75.75 0 0 1-.756 0l-9-5.25a.75.75 0 0 1-.27-1.026" />
</Icon>
)
);
@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.75 10.5H12.75V8.25H13.5C13.8978 8.25 14.2794 8.09196 14.5607 7.81066C14.842 7.52936 15 7.14782 15 6.75V3.75C15 3.35218 14.842 2.97064 14.5607 2.68934C14.2794 2.40804 13.8978 2.25 13.5 2.25H10.5C10.1022 2.25 9.72064 2.40804 9.43934 2.68934C9.15804 2.97064 9 3.35218 9 3.75V6.75C9 7.14782 9.15804 7.52936 9.43934 7.81066C9.72064 8.09196 10.1022 8.25 10.5 8.25H11.25V10.5H2.25C2.05109 10.5 1.86032 10.579 1.71967 10.7197C1.57902 10.8603 1.5 11.0511 1.5 11.25C1.5 11.4489 1.57902 11.6397 1.71967 11.7803C1.86032 11.921 2.05109 12 2.25 12H5.25V15H4.5C4.10218 15 3.72064 15.158 3.43934 15.4393C3.15804 15.7206 3 16.1022 3 16.5V19.5C3 19.8978 3.15804 20.2794 3.43934 20.5607C3.72064 20.842 4.10218 21 4.5 21H7.5C7.89782 21 8.27936 20.842 8.56066 20.5607C8.84196 20.2794 9 19.8978 9 19.5V16.5C9 16.1022 8.84196 15.7206 8.56066 15.4393C8.27936 15.158 7.89782 15 7.5 15H6.75V12H17.25V15H16.5C16.1022 15 15.7206 15.158 15.4393 15.4393C15.158 15.7206 15 16.1022 15 16.5V19.5C15 19.8978 15.158 20.2794 15.4393 20.5607C15.7206 20.842 16.1022 21 16.5 21H19.5C19.8978 21 20.2794 20.842 20.5607 20.5607C20.842 20.2794 21 19.8978 21 19.5V16.5C21 16.1022 20.842 15.7206 20.5607 15.4393C20.2794 15.158 19.8978 15 19.5 15H18.75V12H21.75C21.9489 12 22.1397 11.921 22.2803 11.7803C22.421 11.6397 22.5 11.4489 22.5 11.25C22.5 11.0511 22.421 10.8603 22.2803 10.7197C22.1397 10.579 21.9489 10.5 21.75 10.5ZM10.5 3.75H13.5V6.75H10.5V3.75ZM7.5 19.5H4.5V16.5H7.5V19.5ZM19.5 19.5H16.5V16.5H19.5V19.5Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.3779 1.60217C12.1444 1.46594 11.8556 1.46594 11.6221 1.60217L2.6221 6.85217C2.39168 6.98657 2.25 7.23325 2.25 7.5C2.25 7.76675 2.39168 8.01343 2.6221 8.14783L11.6221 13.3978C11.8556 13.5341 12.1444 13.5341 12.3779 13.3978L21.3779 8.14783C21.6083 8.01343 21.75 7.76675 21.75 7.5C21.75 7.23325 21.6083 6.98657 21.3779 6.85217L12.3779 1.60217ZM12 11.8817L4.48848 7.5L12 3.11828L19.5115 7.5L12 11.8817Z" fill="black"/>
<path d="M2.35229 11.6222C2.561 11.2644 3.02024 11.1436 3.37803 11.3523L12.0001 16.3818L20.6222 11.3523C20.98 11.1436 21.4392 11.2644 21.648 11.6222C21.8567 11.98 21.7358 12.4392 21.378 12.648L12.378 17.898C12.1445 18.0342 11.8557 18.0342 11.6222 17.898L2.62222 12.648C2.26443 12.4392 2.14358 11.98 2.35229 11.6222Z" fill="black"/>
<path d="M2.35229 16.1222C2.561 15.7644 3.02024 15.6436 3.37803 15.8523L12.0001 20.8818L20.6222 15.8523C20.98 15.6436 21.4392 15.7644 21.648 16.1222C21.8567 16.48 21.7358 16.9392 21.378 17.148L12.378 22.398C12.1445 22.5342 11.8557 22.5342 11.6222 22.398L2.62222 17.148C2.26443 16.9392 2.14358 16.48 2.35229 16.1222Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+2
View File
@@ -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';
@@ -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<ReturnType<typeof setTimeout>>(undefined);
const copyAnchorEl = useRef(null);
const [copiedText, setCopiedText] = useState(copyDefault);
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<typeof Wrapper>;
type Story = StoryObj<typeof meta>;
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 (
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<TeleportProviderBasic teleportCtx={ctx}>
<Router history={history}>
<Route path={cfg.routes.instances}>
<Instances />
</Route>
</Router>
</TeleportProviderBasic>
</MemoryRouter>
</QueryClientProvider>
);
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<typeof makeAcl>;
initialUrl?: string;
}) {
const user = userEvent.setup();
const history = createMemoryHistory({
initialEntries: [options?.initialUrl || cfg.routes.instances],
});
return {
...render(<Instances />, {
wrapper: makeWrapper({
customAcl: options?.customAcl,
history,
}),
}),
user,
};
}
function makeWrapper(options: {
history: ReturnType<typeof createMemoryHistory>;
customAcl?: ReturnType<typeof makeAcl>;
}) {
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 (
<MemoryRouter>
<QueryClientProvider client={testQueryClient}>
<ConfiguredThemeProvider theme={darkTheme}>
<ContextProvider ctx={ctx}>
<Router history={history}>
<Route path={cfg.routes.instances}>{children}</Route>
</Router>
</ContextProvider>
</ConfiguredThemeProvider>
</QueryClientProvider>
</MemoryRouter>
);
};
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<UnifiedInstancesResponse> {
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 (
<FeatureBox>
<FeatureHeader mb="0">
<FeatureHeaderTitle>Instance Inventory</FeatureHeaderTitle>
</FeatureHeader>
<Alert kind="info" mt={4}>
You do not have permission to view the instance inventory. Missing
permissions: <code>instance.list</code> or <code>instance.read</code>,
and <code>bot_instance.list</code> or <code>bot_instance.read</code>.
</Alert>
</FeatureBox>
);
}
if (isCacheInitializing) {
return (
<FeatureBox>
<FeatureHeader mb="0">
<FeatureHeaderTitle>Instance Inventory</FeatureHeaderTitle>
</FeatureHeader>
<Alert kind="info" mt={3} mb={3}>
The instance inventory is not yet ready to be displayed, please check
back in a few minutes.
</Alert>
</FeatureBox>
);
}
return (
<FeatureBox>
<FeatureHeader mb="0">
<FeatureHeaderTitle>Instance Inventory</FeatureHeaderTitle>
</FeatureHeader>
<Box>
{!hasInstancePermissions && hasBotInstancePermissions && (
<Alert kind="info" mt={3} mb={3}>
You do not have permission to view instances. This list will only
show bot instances.
<br />
Listing instances requires permissions <code>
instance.list
</code>{' '}
and <code>instance.read</code>.
</Alert>
)}
{hasInstancePermissions && !hasBotInstancePermissions && (
<Alert kind="info" mt={3} mb={3}>
You do not have permission to view bot instances. This list will
only show instances.
<br />
Listing bot instances requires permissions{' '}
<code>bot_instance.list</code> and <code>bot_instance.read</code>.
</Alert>
)}
<SearchPanel
filter={{
query: isAdvancedQuery ? query : undefined,
search: isAdvancedQuery ? undefined : query,
}}
updateSearch={query => handleQueryChange(query, false)}
updateQuery={query => handleQueryChange(query, true)}
mb={3}
/>
<Flex justifyContent="space-between" alignItems="flex-start" mb={3}>
<FiltersRow gap={2}>
<MultiselectMenu
options={typeOptions}
selected={selectedTypes}
onChange={handleTypesChange}
label="Type"
tooltip="Filter by instance type"
buffered={true}
disabled={!hasInstancePermissions}
/>
<MultiselectMenu
options={serviceOptions}
selected={selectedServices}
onChange={handleServicesChange}
label="Services"
tooltip={
onlyBotInstancesSelected
? 'You cannot filter bot instances by services'
: 'Filter by services'
}
buffered={true}
disabled={onlyBotInstancesSelected}
/>
<MultiselectMenu
options={upgraderOptions}
selected={selectedUpgraders}
onChange={handleUpgradersChange}
label="Upgrader"
tooltip="Filter by upgrader"
buffered={true}
/>
<VersionsFilterPanel
currentVersion={authVersion}
onApply={handleVersionFilterChange}
tooltip="Filter by version"
filter={versionFilter as FilterOption}
operator={versionOperator as CustomOperator}
value1={versionValue1}
value2={versionValue2}
/>
</FiltersRow>
<SortMenu
items={sortFields}
selectedKey={sortField}
selectedOrder={sortDir as 'ASC' | 'DESC'}
onChange={(key, order) => {
handleSortChange(key, order);
}}
/>
</Flex>
<InstancesList
data={flatData}
isLoading={isFetching && !isFetchingNextPage}
isFetchingNextPage={isFetchingNextPage}
error={error}
hasNextPage={hasNextPage}
sortField={sortField}
sortDir={sortDir}
onSortChanged={handleSortChange}
onLoadNextPage={fetchNextPage}
/>
</Box>
</FeatureBox>
);
}
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' },
];
@@ -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 <http://www.gnu.org/licenses/>.
*/
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 (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
);
}
if (error) {
return (
<Danger m={3} details={error.message}>
Failed to fetch instances
</Danger>
);
}
if (!data || data.length === 0) {
return (
<Box textAlign="center" m={10}>
<Text typography="h3" mb={3}>
No instances found
</Text>
</Box>
);
}
return (
<Box>
<StyledTable
data={tableData}
columns={[
{
key: 'name',
headerText: 'Host/Bot Name',
isSortable: true,
render: (row: TableInstance) => (
<NameCell instance={row.original} />
),
},
{
key: 'version',
headerText: 'Version',
isSortable: true,
render: (row: TableInstance) => <Cell>{row.version}</Cell>,
},
{
key: 'type',
headerText: 'Type',
isSortable: true,
render: (row: TableInstance) => (
<Cell>
{row.type === 'instance' ? 'Instance' : 'Bot Instance'}
</Cell>
),
},
{
altKey: 'services',
headerText: 'Services',
render: (row: TableInstance) => (
<ServicesCell instance={row.original} />
),
},
{
altKey: 'upgrader',
headerText: 'Upgrader',
render: (row: TableInstance) => {
const upgraderType =
row.type === 'instance'
? row.original.instance?.upgrader?.type
: undefined;
return <UpgraderCell upgrader={upgraderType} />;
},
},
{
altKey: 'upgrader-group',
headerText: 'Upgrader Group',
render: (row: TableInstance) => {
const group =
row.type === 'instance'
? row.original.instance?.upgrader?.group
: undefined;
return <Cell>{group || ''}</Cell>;
},
},
]}
emptyText="No instances found"
customSort={{
fieldName: sortField,
dir: sortDir === 'DESC' ? 'DESC' : 'ASC',
onSort: sort => {
onSortChanged(sort.fieldName, sort.dir);
},
}}
/>
<Box ref={setTrigger} height="40px" />
{isFetchingNextPage && (
<Box textAlign="center">
<Indicator size={32} delay="none" />
</Box>
)}
</Box>
);
}
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 (
<Cell>
{name && <Text>{name}</Text>}
<IdContainer>
<HoverTooltip tipContent={instance.id}>
<IdText>{instance.id.substring(0, 7)}</IdText>
</HoverTooltip>
<CopyButtonWrapper>
<CopyButton value={instance.id} tooltip="Copy instance ID" />
</CopyButtonWrapper>
</IdContainer>
</Cell>
);
}
/**
* UpgraderCell displays the upgrader in a more readable way with styling
*/
function UpgraderCell({ upgrader }: { upgrader: string | undefined }) {
if (!upgrader || upgrader === '') {
return (
<Cell>
<Text color="editor.abbey">None</Text>
</Cell>
);
}
if (upgrader === 'unit-updater') {
return (
<Cell>
<Text color="editor.sunflower">Unit Updater (legacy)</Text>
</Cell>
);
}
if (upgrader === 'systemd-unit-updater') {
return (
<Cell>
<Text>Systemd Unit Updater</Text>
</Cell>
);
}
if (upgrader === 'kube-updater') {
return (
<Cell>
<Text>Kubernetes</Text>
</Cell>
);
}
// 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 (
<Cell>
<Text>{upgrader}</Text>
</Cell>
);
}
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 (
<Cell>
<Link to={url}>
<ButtonBorder size="small" px={2} py={1}>
Services <Icons.ArrowSquareIn size="small" ml={1} />
</ButtonBorder>
</Link>
</Cell>
);
}
const services = instance.instance?.services || [];
return (
<Cell>
<Flex gap={2}>
{services.map(service => {
const IconComponent = getServiceIcon(service);
const displayName = getServiceDisplayName(service);
return (
<HoverTooltip key={service} tipContent={displayName}>
<Box>
<IconComponent size="medium" />
</Box>
</HoverTooltip>
);
})}
</Flex>
</Cell>
);
}
function getServiceIcon(service: string): React.ComponentType<any> {
const serviceMap: Record<string, React.ComponentType<any>> = {
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<string, string> = {
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;
}
`;
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<HTMLElement>(null);
const [selectedOption, setSelectedOption] = useState<FilterOption | null>(
null
);
const [customOperator, setCustomOperator] =
useState<CustomOperator>('equals');
const [customValue1, setCustomValue1] = useState('');
const [customValue2, setCustomValue2] = useState('');
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
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 (
<Flex textAlign="center" alignItems="center">
<HoverTooltip tipContent={tooltip}>
<ButtonSecondary
size="small"
onClick={handleOpen}
aria-haspopup="true"
aria-expanded={!!anchorEl}
disabled={disabled}
>
Version
{filter && ' (1)'}
<ChevronDown
ml={2}
size="small"
color={disabled ? 'text.disabled' : 'text.slightlyMuted'}
/>
{filter && <FiltersExistIndicator />}
</ButtonSecondary>
</HoverTooltip>
<Menu
popoverCss={() => `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 => (
<FilterMenuItem
key={opt.value}
px={2}
onClick={() => !opt.disabled && handleOptionSelect(opt.value)}
width="100%"
disabled={opt.disabled}
>
<Flex alignItems="center" gap={2} width="100%">
<CheckIconWrapper>
{selectedOption === opt.value && <Check size="small" />}
</CheckIconWrapper>
<Flex width="100%" justifyContent="space-between">
<Text fontWeight={300} fontSize={2}>
{opt.label}
</Text>
<Text
fontSize={1}
color="text.muted"
css={`
white-space: nowrap;
`}
>
{getFilterDescription(opt.value, currentVersion)}
</Text>
</Flex>
</Flex>
</FilterMenuItem>
))}
<FilterMenuItem
px={2}
py={2}
width="100%"
css={`
cursor: pointer;
`}
onClick={() => handleOptionSelect('custom')}
>
<Flex alignItems="flex-start" gap={2}>
<CheckIconWrapper css="margin-top: 4px; cursor: pointer;">
{selectedOption === 'custom' && <Check size="small" />}
</CheckIconWrapper>
<Box flex="1">
<Text fontWeight={300} fontSize={2} mb={2}>
Custom condition
</Text>
<Flex
alignItems="center"
flexWrap="nowrap"
onClick={e => {
if (selectedOption === 'custom') {
e.stopPropagation();
}
}}
>
<Select
size="small"
value={operatorOptions.find(
opt => opt.value === customOperator
)}
options={operatorOptions}
onChange={(option: { value: CustomOperator }) => {
setCustomOperator(option.value);
if (selectedOption !== 'custom') {
handleOptionSelect('custom');
}
}}
isDisabled={selectedOption !== 'custom'}
isSearchable={false}
menuPosition="fixed"
stylesConfig={{
control: base => ({ ...base, width: '120px' }),
menuPortal: base => ({
...base,
zIndex: 100,
backgroundColor: theme.colors.levels.elevated,
}),
menu: base => ({
...base,
backgroundColor: theme.colors.levels.elevated,
width: '120px',
}),
option: (base, state) => ({
...base,
backgroundColor: state.isFocused
? theme.colors.interactive.tonal.neutral[0]
: 'transparent',
color: theme.colors.text.main,
cursor: 'pointer',
'&:active': {
backgroundColor:
theme.colors.interactive.tonal.neutral[1],
},
}),
}}
/>
<Flex alignItems="center">
{customOperator === 'between' ? (
<>
<Input
ml={1}
size="small"
placeholder={currentVersion}
value={customValue1}
onChange={e => setCustomValue1(e.target.value)}
disabled={selectedOption !== 'custom'}
onFocus={() => {
if (selectedOption !== 'custom') {
handleOptionSelect('custom');
}
}}
width="70px"
/>
<Text color="text.muted" mx={1}>
&
</Text>
<Input
size="small"
placeholder={currentVersion}
value={customValue2}
onChange={e => setCustomValue2(e.target.value)}
disabled={selectedOption !== 'custom'}
onFocus={() => {
if (selectedOption !== 'custom') {
handleOptionSelect('custom');
}
}}
width="70px"
/>
</>
) : (
<Input
ml={1}
size="small"
placeholder={currentVersion}
value={customValue1}
onChange={e => setCustomValue1(e.target.value)}
disabled={selectedOption !== 'custom'}
onFocus={() => {
if (selectedOption !== 'custom') {
handleOptionSelect('custom');
}
}}
/>
)}
</Flex>
<ClearButton
onClick={handleClearCustom}
disabled={selectedOption !== 'custom'}
>
<Icons.Cross size="small" />
</ClearButton>
</Flex>
</Box>
</Flex>
</FilterMenuItem>
<Divider />
<ActionButtonsContainer justifyContent="flex-start" p={3} gap={2}>
<ButtonPrimary size="small" onClick={handleApply}>
Apply Filters
</ButtonPrimary>
<ButtonSecondary
size="small"
css={`
background-color: transparent;
`}
onClick={handleClose}
>
Cancel
</ButtonSecondary>
</ActionButtonsContainer>
</Menu>
</Flex>
);
}
// 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;
`;
@@ -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 <http://www.gnu.org/licenses/>.
*/
export { Instances } from './Instances';
+14
View File
@@ -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 });
},
+37
View File
@@ -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(),
@@ -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 || '')
@@ -78,6 +78,7 @@ export const allAccessAcl: Acl = {
contacts: fullAccess,
gitServers: fullAccess,
accessGraphSettings: fullAccess,
instances: fullAccess,
botInstances: fullAccess,
workloadIdentity: fullAccess,
clientIpRestriction: fullAccess,
@@ -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 <http://www.gnu.org/licenses/>.
*/
export type {
UnifiedInstance,
Instance,
BotInstance,
UpgraderInfo,
UnifiedInstancesResponse,
} from './types';
@@ -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 <http://www.gnu.org/licenses/>.
*/
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;
};
@@ -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,
};
@@ -113,6 +113,7 @@ export interface Acl {
gitServers: Access;
accessGraphSettings: Access;
botInstances: Access;
instances: Access;
workloadIdentity: Access;
clientIpRestriction: Access;
}
@@ -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,
@@ -263,6 +263,10 @@ export default class StoreUserContext extends Store<UserContext> {
return this.state.acl.botInstances;
}
getInstancesAccess() {
return this.state.acl.instances;
}
getContactsAccess() {
return this.state.acl.contacts;
}
@@ -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,
};
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
);
+3
View File
@@ -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;