Update integration status and add status filtering to the IntegrationList (#62821)

This commit is contained in:
charlestp
2026-01-21 19:24:46 +00:00
committed by GitHub
parent 004bcb545b
commit 897296654c
20 changed files with 991 additions and 348 deletions
+26 -23
View File
@@ -84,23 +84,6 @@ const kind = ({
};
}
if (kind === 'outline-success') {
return {
color: theme.colors.success.main,
backgroundColor: theme.colors.interactive.tonal.success[0],
borderColor: theme.colors.success.main,
borderWidth: 1,
borderStyle: 'solid',
fontWeight: theme.fontWeights.regular,
...(withHoverState && {
'&:hover': {
color: theme.colors.text.primaryInverse,
backgroundColor: theme.colors.interactive.solid['success'].hover,
},
}),
};
}
if (kind === 'outline-primary') {
return {
color: theme.colors.brand,
@@ -120,9 +103,9 @@ const kind = ({
if (kind === 'outline-secondary') {
return {
color: theme.colors.text.main,
backgroundColor: 'transparent',
borderColor: theme.colors.interactive.tonal.neutral[0],
color: theme.colors.text.muted,
backgroundColor: theme.colors.interactive.tonal.neutral[0],
borderColor: theme.colors.text.muted,
borderWidth: 1,
borderStyle: 'solid',
fontWeight: theme.fontWeights.regular,
@@ -135,11 +118,28 @@ const kind = ({
};
}
if (kind === 'outline-success') {
return {
color: theme.colors.interactive.solid.success.hover,
backgroundColor: theme.colors.interactive.tonal.success[0],
borderColor: theme.colors.interactive.solid.success.hover,
borderWidth: 1,
borderStyle: 'solid',
fontWeight: theme.fontWeights.regular,
...(withHoverState && {
'&:hover': {
color: theme.colors.text.primaryInverse,
backgroundColor: theme.colors.interactive.solid['success'].hover,
},
}),
};
}
if (kind === 'outline-warning') {
return {
color: theme.colors.dataVisualisation.primary.sunflower,
color: theme.colors.interactive.solid.alert.hover,
backgroundColor: theme.colors.interactive.tonal.alert[0],
borderColor: theme.colors.interactive.tonal.alert[2],
borderColor: theme.colors.interactive.solid.alert.hover,
borderWidth: 1,
borderStyle: 'solid',
fontWeight: theme.fontWeights.regular,
@@ -156,7 +156,7 @@ const kind = ({
return {
color: theme.colors.interactive.solid.danger.default,
backgroundColor: theme.colors.interactive.tonal.danger[0],
borderColor: theme.colors.interactive.tonal.danger[2],
borderColor: theme.colors.interactive.solid.danger.default,
borderWidth: 1,
borderStyle: 'solid',
fontWeight: theme.fontWeights.regular,
@@ -236,6 +236,9 @@ export const Danger = (props: LabelPropsWithoutKind) => (
export const SecondaryOutlined = (props: LabelPropsWithoutKind) => (
<Label kind="outline-secondary" {...props} />
);
export const SuccessOutlined = (props: LabelPropsWithoutKind) => (
<Label kind="outline-success" {...props} />
);
export const WarningOutlined = (props: LabelPropsWithoutKind) => (
<Label kind="outline-warning" {...props} />
);
+2 -1
View File
@@ -22,11 +22,12 @@ import Label, {
Primary,
Secondary,
SecondaryOutlined,
SuccessOutlined,
Warning,
WarningOutlined,
} from './Label';
export default Label;
export { Primary, Secondary, Warning, Danger };
export { SecondaryOutlined, WarningOutlined, DangerOutlined };
export { SecondaryOutlined, SuccessOutlined, WarningOutlined, DangerOutlined };
export type { LabelKind } from './Label';
+15 -4
View File
@@ -51,10 +51,21 @@ export const Labels = () => (
<Label kind="primary" css={{ visibility: 'hidden' }}>
Primary
</Label>
<Label kind="outline-primary">outline-primary</Label>
<Label kind="outline-secondary">outline-secondary</Label>
<Label kind="outline-warning">outline-warning</Label>
<Label kind="outline-danger">outline-danger</Label>
<Label kind="outline-primary" withHoverState>
outline-primary
</Label>
<Label kind="outline-secondary" withHoverState>
outline-secondary
</Label>
<Label kind="outline-success" withHoverState>
outline-success
</Label>
<Label kind="outline-warning" withHoverState>
outline-warning
</Label>
<Label kind="outline-danger" withHoverState>
outline-danger
</Label>
<Label kind="success" css={{ visibility: 'hidden' }}>
Success
</Label>
@@ -31,7 +31,7 @@ import { CheckboxInput } from 'design/Checkbox';
import { ChevronDown } from 'design/Icon';
import { HoverTooltip } from 'design/Tooltip';
type Option<T> = {
export type Option<T> = {
value: T;
label: string | ReactNode;
disabled?: boolean;
@@ -0,0 +1,208 @@
/*
* 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 { useState } from 'react';
import Table, { DateCell, LabelCell, StyledPanel } from 'design/DataTable';
import { TableProps } from 'design/DataTable/types';
import { FilterMap, ListFilters } from './ListFilters';
import { applyFilters } from './utilities';
export default {
title: 'Shared/ListFilters',
};
type IntegrationTag = 'test1: test1' | 'test2: test2' | 'test3: test3';
type Item = {
name: string;
desc: string;
amount: number;
createdDate: Date;
removedDate: Date;
tags: string[];
bool: boolean;
};
type Filters = {
Type: IntegrationTag;
Names: string;
};
export function ListFiltersBasic() {
const [filters, setFilters] = useState<FilterMap<Item, Filters>>({
Names: {
options: [
{ label: 'A Test', value: 'a-test' },
{ label: 'C Test', value: 'c-test' },
],
selected: ['c-test'],
apply: (l, s) => l.filter(i => s.includes(i.name)),
},
Type: {
options: [
{ label: 'Test1', value: 'test1: test1' },
{ label: 'Test2', value: 'test2: test2' },
],
selected: [],
apply: (l, s) => l.filter(i => s.some(tag => i.tags.includes(tag))),
},
});
const tableProps = getDefaultProps();
tableProps.data = applyFilters(tableProps.data, filters);
return (
<>
<StyledPanel>
<ListFilters filters={filters} onFilterChange={setFilters} />
</StyledPanel>
<Table {...tableProps} />
</>
);
}
const data: Item[] = [
{
name: 'a-test',
desc: 'this is a test',
amount: 1,
createdDate: new Date(1636467176000),
removedDate: new Date(1636423403000),
tags: ['test1: test1', 'mama: papa', 'test2: test2'],
bool: true,
},
{
name: 'b-test',
desc: 'this is b test',
amount: 55,
createdDate: new Date(1635367176000),
removedDate: new Date(1635323403000),
tags: ['test3: test3', 'mama: papa', 'test4: test4', 'test5: test5'],
bool: true,
},
{
name: 'd-test',
desc: 'this is another item',
amount: 14141,
createdDate: new Date(1635467176000),
removedDate: new Date(1635423403000),
tags: ['test6: test6', 'mama: papa'],
bool: false,
},
{
name: 'c-test',
desc: 'yet another item',
amount: -50,
createdDate: new Date(1635364176),
removedDate: new Date(1635322403),
tags: ['test7: test7'],
bool: false,
},
{
name: 'e-test',
desc: 'and another',
amount: -20,
createdDate: new Date(1635364176),
removedDate: new Date(1635322403),
tags: ['test8: test8'],
bool: true,
},
{
name: 'looong name with something',
desc: 'looong description with something important in it and stuff......',
amount: 1,
createdDate: new Date(1636467176000),
removedDate: new Date(1636423403000),
tags: [
'test3: test3',
'mama: papa',
'test4: test4',
'test5: test5',
'test6: test6',
'test7: longer text than normal',
'test8: test8',
'test9: test9',
'test10: test10',
'test11: test11',
`some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......
some relly long text about something or whatever......`,
],
bool: true,
},
];
const getDefaultProps = (): TableProps<Item> => ({
data: data,
emptyText: 'No Dummy Data Found',
columns: [
{ key: 'name', headerText: 'Name', isSortable: true },
{ key: 'desc', headerText: 'Description' },
{ key: 'amount', headerText: 'Amount', isSortable: true },
{
key: 'createdDate',
headerText: 'Created Date',
isSortable: true,
render: row => <DateCell data={row.createdDate} />,
},
{
key: 'removedDate',
headerText: 'Removed Date',
isSortable: true,
render: row => <DateCell data={row.removedDate} />,
},
{
key: 'tags',
headerText: 'Labels',
render: row => <LabelCell data={row.tags} />,
isSortable: true,
onSort: (a, b) => a.tags.length - b.tags.length,
},
{ key: 'bool', headerText: 'Boolean', isSortable: true },
],
});
@@ -0,0 +1,59 @@
/*
* 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 { fireEvent, render, screen } from '@testing-library/react';
import { FilterMap, ListFilters } from '.';
jest.mock('shared/components/Controls/MultiselectMenu', () => ({
MultiselectMenu: ({ onChange }: any) => (
<button onClick={() => onChange(['new'])}>change</button>
),
}));
type Item = {
test: string;
};
type Values = {
Type: string;
};
describe('ListFilters', () => {
it('calls onFilterChange with updated selected values in a filter', () => {
const onFilterChange = jest.fn();
const filters: FilterMap<Item, Values> = {
Type: {
options: [],
selected: [],
apply: jest.fn(),
},
};
render(<ListFilters filters={filters} onFilterChange={onFilterChange} />);
fireEvent.click(screen.getByRole('button'));
expect(onFilterChange).toHaveBeenCalledWith({
Type: {
...filters.Type,
selected: ['new'],
},
});
});
});
@@ -0,0 +1,80 @@
/*
* 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 Flex from 'design/Flex';
import {
MultiselectMenu,
Option,
} from 'shared/components/Controls/MultiselectMenu';
type Filter<Item, Value> = {
options: readonly Option<Value>[];
selected: Value[];
apply: (l: Item[], s: Value[]) => Item[];
};
// FilterMap gives readable structure to our input list of filters, i.e.:
// {
// Tags: {
// options: [{label: "Bot", value: "bot"}, {label: "CI/CD", name: "cicd"}],
// selected: ["bot"],
// apply: (list, selected) => list.filter(i => i.tags.includes(selected));
// }
// }
export type FilterMap<Item, Values extends Record<string, unknown>> = {
[K in keyof Values]: Filter<Item, Values[K]>;
};
type Props<Item, Values extends Record<string, unknown>> = {
filters: FilterMap<Item, Values>;
onFilterChange: (filters: FilterMap<Item, Values>) => void;
};
// ListFilters manages multiple filter dropdowns associated with
// a list of data.
export function ListFilters<Item, Values extends Record<string, unknown>>(
props: Props<Item, Values>
) {
const handleFilterChange = <K extends keyof Values>(
name: K,
opts: Values[K][]
) => {
props.onFilterChange({
...props.filters,
[name]: {
...props.filters[name],
selected: opts,
},
});
};
return (
<Flex gap={2}>
{Object.entries(props.filters).map(([name, filter]) => (
<MultiselectMenu
key={name}
options={filter.options}
selected={filter.selected}
onChange={opts => handleFilterChange(name, opts)}
label={name}
tooltip={`Filter by ${name}`}
/>
))}
</Flex>
);
}
@@ -0,0 +1,22 @@
/*
* 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 { FilterMap, ListFilters } from './ListFilters';
import { applyFilters } from './utilities';
export { ListFilters, type FilterMap, applyFilters };
@@ -0,0 +1,82 @@
/*
* 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 { applyFilters, FilterMap } from '.';
type Item = { id: number; tag: string };
type Values = {
Tag?: string;
ID?: number;
};
const items: Item[] = [
{ id: 1, tag: 'a' },
{ id: 2, tag: 'b' },
{ id: 3, tag: 'a' },
];
describe('applyFilters', () => {
it('applies a single filter to the list', () => {
const filters: FilterMap<Item, Values> = {
Tag: {
options: [],
selected: ['a'],
apply: (l, s) => l.filter(i => s.includes(i.tag)),
},
};
expect(applyFilters(items, filters)).toEqual([items[0], items[2]]);
});
it('applies a multiple filters to the list', () => {
const filters: FilterMap<Item, Values> = {
Tag: {
options: [],
selected: ['a'],
apply: (l, s) => l.filter(i => s.includes(i.tag)),
},
ID: {
options: [],
selected: [3],
apply: (l, s) => l.filter(i => s.includes(i.id)),
},
};
expect(applyFilters(items, filters)).toEqual([items[2]]);
});
it('skips filters with no selected values', () => {
const filters: FilterMap<Item, Values> = {
Tag: {
options: [],
selected: ['a'],
apply: (l, s) => l.filter(i => s.includes(i.tag)),
},
ID: {
options: [],
selected: [],
apply: jest.fn(),
},
};
applyFilters(items, filters);
expect(filters.ID.apply).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,28 @@
/*
* 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 { FilterMap } from './ListFilters';
export function applyFilters<Item, Values extends Record<string, unknown>>(
list: Item[],
filters: FilterMap<Item, Values>
) {
return Object.values(filters).reduce((acc, filter) => {
if (filter.selected.length === 0) return acc;
return filter.apply(acc, filter.selected);
}, list);
}
@@ -16,26 +16,33 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from 'react';
import React, { useMemo, useState } from 'react';
import { useHistory } from 'react-router';
import { Link as InternalRouteLink } from 'react-router-dom';
import styled from 'styled-components';
import { Box, Flex } from 'design';
import Table, { Cell } from 'design/DataTable';
import Table, { Cell, StyledPanel } from 'design/DataTable';
import InputSearch from 'design/DataTable/InputSearch';
import { ResourceIcon } from 'design/ResourceIcon';
import { IconTooltip } from 'design/Tooltip';
import {
applyFilters,
FilterMap,
ListFilters,
} from 'shared/components/ListFilters';
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
import { useAsync } from 'shared/hooks/useAsync';
import { saveOnDisk } from 'shared/utils/saveOnDisk';
import cfg from 'teleport/config';
import { getStatus } from 'teleport/Integrations/helpers';
import {
filterByIntegrationStatus,
filterBySearch,
sortByStatus,
} from 'teleport/Integrations/helpers';
import api from 'teleport/services/api';
import {
ExternalAuditStorageIntegration,
getStatusCodeDescription,
getStatusCodeTitle,
Integration,
IntegrationKind,
IntegrationStatusCode,
@@ -44,6 +51,8 @@ import {
import useStickyClusterId from 'teleport/useStickyClusterId';
import { ExternalAuditStorageOpType } from './Operations/useIntegrationOperation';
import { StatusLabel } from './shared/StatusLabel';
import { StatusOptions, type Status } from './types';
type Props = {
list: IntegrationLike[];
@@ -68,6 +77,10 @@ const statusKinds = [
IntegrationKind.AwsRa,
];
type Filters = {
Status: Status;
};
export function IntegrationList(props: Props) {
const history = useHistory();
@@ -101,123 +114,170 @@ export function IntegrationList(props: Props) {
}
);
const [searchValue, setSearchValue] = useState<string>('');
const [filters, setFilters] = useState<FilterMap<IntegrationLike, Filters>>({
Status: {
options: StatusOptions,
selected: [],
apply: filterByIntegrationStatus,
},
});
const filteredList = useMemo(
() => applyFilters(filterBySearch(props.list, searchValue), filters),
[props.list, searchValue, filters]
);
const { clusterId } = useStickyClusterId();
return (
<Table
pagination={{ pageSize: 20 }}
isSearchable
data={props.list}
row={{
onClick: handleRowClick,
getStyle: getRowStyle,
}}
columns={[
{
key: 'resourceType',
isNonRender: true,
},
{
key: 'kind',
headerText: 'Integration',
isSortable: true,
render: item => <IconCell item={item} />,
},
{
key: 'details',
headerText: 'Details',
},
{
key: 'statusCode',
headerText: 'Status',
isSortable: true,
render: item => <StatusCell item={item} />,
},
{
altKey: 'options-btn',
render: item => {
if (
item.kind === IntegrationKind.AwsOidc ||
item.kind === IntegrationKind.AwsRa ||
item.kind === 'entra-id'
) {
// action menu for these integrations are available on the status page dashboard.
return;
}
<>
<Box mb={3}>
<InputSearch
searchValue={searchValue}
setSearchValue={setSearchValue}
/>
</Box>
<StyledPanel>
<ListFilters filters={filters} onFilterChange={setFilters} />
</StyledPanel>
<Table
data={filteredList}
row={{
onClick: handleRowClick,
getStyle: getRowStyle,
}}
columns={[
{
key: 'resourceType',
isNonRender: true,
},
{
key: 'name',
headerText: 'Name',
isSortable: true,
render: item => <NameCell item={item} />,
},
{
key: 'statusCode',
headerText: 'Status',
isSortable: true,
onSort: sortByStatus,
render: item => (
<Cell>
<StatusLabel integration={item} />
</Cell>
),
},
{
key: 'details',
headerText: 'Details',
},
{
altKey: 'options-btn',
render: item => {
if (
item.kind === IntegrationKind.AwsOidc ||
item.kind === IntegrationKind.AwsRa ||
item.kind === 'entra-id'
) {
// action menu for these integrations are available on the status page dashboard.
return;
}
if (item.resourceType === 'plugin') {
return (
<Cell align="right">
<MenuButton>
{/* Currently, only okta supports status pages */}
{item.kind === 'okta' && (
<MenuItem
as={InternalRouteLink}
to={cfg.getIntegrationStatusRoute(item.kind, item.name)}
>
View Status
if (item.resourceType === 'plugin') {
return (
<Cell align="right">
<MenuButton>
{/* Currently, only okta supports status pages */}
{item.kind === 'okta' && (
<MenuItem
as={InternalRouteLink}
to={cfg.getIntegrationStatusRoute(
item.kind,
item.name
)}
>
View Status
</MenuItem>
)}
{item.kind === 'msteams' && (
<MenuItem
disabled={downloadAttempt.status === 'processing'}
onClick={() => download(clusterId, item.name)}
>
Download app.zip
</MenuItem>
)}
<MenuItem onClick={() => props.onDeletePlugin(item)}>
Delete...
</MenuItem>
)}
{item.kind === 'msteams' && (
<MenuItem
disabled={downloadAttempt.status === 'processing'}
onClick={() => download(clusterId, item.name)}
>
Download app.zip
</MenuItem>
)}
<MenuItem onClick={() => props.onDeletePlugin(item)}>
Delete...
</MenuItem>
</MenuButton>
</Cell>
);
}
</MenuButton>
</Cell>
);
}
// Normal 'integration' type.
if (item.resourceType === 'integration') {
return (
<Cell align="right">
<MenuButton>
{item.kind === IntegrationKind.GitHub && (
// Normal 'integration' type.
if (item.resourceType === 'integration') {
return (
<Cell align="right">
<MenuButton>
{item.kind === IntegrationKind.GitHub && (
<MenuItem
onClick={() =>
props.integrationOps.onEditIntegration(item)
}
>
Edit...
</MenuItem>
)}
<MenuItem
onClick={() =>
props.integrationOps.onEditIntegration(item)
props.integrationOps.onDeleteIntegration(item)
}
>
Edit...
Delete...
</MenuItem>
)}
<MenuItem
onClick={() =>
props.integrationOps.onDeleteIntegration(item)
}
>
Delete...
</MenuItem>
</MenuButton>
</Cell>
);
}
</MenuButton>
</Cell>
);
}
// draft external audit storage
if (item.statusCode === IntegrationStatusCode.Draft) {
// draft external audit storage
if (item.statusCode === IntegrationStatusCode.Draft) {
return (
<Cell align="right">
<MenuButton>
<MenuItem
as={InternalRouteLink}
to={{
pathname: cfg.getIntegrationEnrollRoute(
IntegrationKind.ExternalAuditStorage
),
state: { continueDraft: true },
}}
>
Continue Setup...
</MenuItem>
<MenuItem
onClick={() =>
props.onDeleteExternalAuditStorage('draft')
}
>
Delete...
</MenuItem>
</MenuButton>
</Cell>
);
}
// active external audit storage
return (
<Cell align="right">
<MenuButton>
<MenuItem
as={InternalRouteLink}
to={{
pathname: cfg.getIntegrationEnrollRoute(
IntegrationKind.ExternalAuditStorage
),
state: { continueDraft: true },
}}
>
Continue Setup...
</MenuItem>
<MenuItem
onClick={() =>
props.onDeleteExternalAuditStorage('draft')
props.onDeleteExternalAuditStorage('cluster')
}
>
Delete...
@@ -225,93 +285,16 @@ export function IntegrationList(props: Props) {
</MenuButton>
</Cell>
);
}
// active external audit storage
return (
<Cell align="right">
<MenuButton>
<MenuItem
onClick={() =>
props.onDeleteExternalAuditStorage('cluster')
}
>
Delete...
</MenuItem>
</MenuButton>
</Cell>
);
},
},
},
]}
emptyText="No Results Found"
/>
]}
emptyText="No Results Found"
/>
</>
);
}
const StatusCell = ({ item }: { item: IntegrationLike }) => {
const status = getStatus(item);
if (
item.resourceType === 'integration' &&
item.kind === IntegrationKind.AwsOidc &&
(!item.spec.issuerS3Bucket || !item.spec.issuerS3Prefix)
) {
return (
<Cell>
<Flex alignItems="center">
<StatusLight status={status} />
{getStatusCodeTitle(item.statusCode)}
</Flex>
</Cell>
);
}
const statusDescription = getStatusCodeDescription(
item.statusCode,
item.status?.errorMessage
);
return (
<Cell>
<Flex alignItems="center">
<StatusLight status={status} />
{getStatusCodeTitle(item.statusCode)}
{statusDescription && (
<Box mx="1">
<IconTooltip>{statusDescription}</IconTooltip>
</Box>
)}
</Flex>
</Cell>
);
};
export enum Status {
Success,
Warning,
Error,
OktaConfigError = 20,
}
const StatusLight = styled(Box)<{ status: Status }>`
border-radius: 50%;
margin-right: 4px;
width: 8px;
height: 8px;
background-color: ${({ status, theme }) => {
if (status === Status.Success) {
return theme.colors.success.main;
}
if ([Status.Error, Status.OktaConfigError].includes(status)) {
return theme.colors.error.main;
}
if (status === Status.Warning) {
return theme.colors.warning.main;
}
return theme.colors.grey[300]; // Unknown
}};
`;
const IconCell = ({ item }: { item: IntegrationLike }) => {
const NameCell = ({ item }: { item: IntegrationLike }) => {
let formattedText;
let icon;
if (item.resourceType === 'plugin') {
@@ -70,7 +70,7 @@ export const plugins: Plugin[] = [
name: 'openai',
details: '',
kind: 'openai',
statusCode: IntegrationStatusCode.Running,
statusCode: IntegrationStatusCode.Draft,
spec: {},
},
{
@@ -16,33 +16,32 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getStatus, getStatusAndLabel } from 'teleport/Integrations/helpers';
import { IntegrationLike, Status } from 'teleport/Integrations/IntegrationList';
import {
Integration,
IntegrationStatusCode,
} from 'teleport/services/integrations';
import { IntegrationLike } from 'teleport/Integrations/IntegrationList';
import { IntegrationStatusCode } from 'teleport/services/integrations';
import { getStatus } from './shared/StatusLabel';
import { Status } from './types';
test.each`
type | code | expected
${'integration'} | ${IntegrationStatusCode.Draft} | ${Status.Success}
${'integration'} | ${IntegrationStatusCode.Running} | ${Status.Success}
${'integration'} | ${IntegrationStatusCode.Unauthorized} | ${Status.Success}
${'integration'} | ${IntegrationStatusCode.SlackNotInChannel} | ${Status.Success}
${'integration'} | ${IntegrationStatusCode.Unknown} | ${Status.Success}
${'integration'} | ${IntegrationStatusCode.OtherError} | ${Status.Success}
${'external-audit-storage'} | ${IntegrationStatusCode.Draft} | ${Status.Warning}
${'external-audit-storage'} | ${IntegrationStatusCode.Running} | ${Status.Success}
${'external-audit-storage'} | ${IntegrationStatusCode.Unauthorized} | ${Status.Success}
${'external-audit-storage'} | ${IntegrationStatusCode.SlackNotInChannel} | ${Status.Success}
${'external-audit-storage'} | ${IntegrationStatusCode.Unknown} | ${Status.Success}
${'external-audit-storage'} | ${IntegrationStatusCode.OtherError} | ${Status.Success}
${'any'} | ${IntegrationStatusCode.Draft} | ${Status.Warning}
${'any'} | ${IntegrationStatusCode.Running} | ${Status.Success}
${'any'} | ${IntegrationStatusCode.Unauthorized} | ${Status.Error}
${'any'} | ${IntegrationStatusCode.SlackNotInChannel} | ${Status.Warning}
${'any'} | ${IntegrationStatusCode.Unknown} | ${null}
${'any'} | ${IntegrationStatusCode.OtherError} | ${Status.Error}
${'integration'} | ${IntegrationStatusCode.Draft} | ${Status.Healthy}
${'integration'} | ${IntegrationStatusCode.Running} | ${Status.Healthy}
${'integration'} | ${IntegrationStatusCode.Unauthorized} | ${Status.Healthy}
${'integration'} | ${IntegrationStatusCode.SlackNotInChannel} | ${Status.Healthy}
${'integration'} | ${IntegrationStatusCode.Unknown} | ${Status.Healthy}
${'integration'} | ${IntegrationStatusCode.OtherError} | ${Status.Healthy}
${'external-audit-storage'} | ${IntegrationStatusCode.Draft} | ${Status.Draft}
${'external-audit-storage'} | ${IntegrationStatusCode.Running} | ${Status.Healthy}
${'external-audit-storage'} | ${IntegrationStatusCode.Unauthorized} | ${Status.Healthy}
${'external-audit-storage'} | ${IntegrationStatusCode.SlackNotInChannel} | ${Status.Healthy}
${'external-audit-storage'} | ${IntegrationStatusCode.Unknown} | ${Status.Healthy}
${'external-audit-storage'} | ${IntegrationStatusCode.OtherError} | ${Status.Healthy}
${'any'} | ${IntegrationStatusCode.Draft} | ${Status.Draft}
${'any'} | ${IntegrationStatusCode.Running} | ${Status.Healthy}
${'any'} | ${IntegrationStatusCode.Unauthorized} | ${Status.Failed}
${'any'} | ${IntegrationStatusCode.SlackNotInChannel} | ${Status.Issues}
${'any'} | ${IntegrationStatusCode.Unknown} | ${Status.Unknown}
${'any'} | ${IntegrationStatusCode.OtherError} | ${Status.Failed}
`(
'getStatus type $type with code $code returns $expected',
async ({ type, code, expected }) => {
@@ -52,42 +51,7 @@ test.each`
resourceType: type,
statusCode: code,
};
const status = getStatus(item);
const { status } = getStatus(item);
expect(status).toBe(expected);
}
);
test.each`
type | code | expectedLabelKind | expectedTitle
${'integration'} | ${IntegrationStatusCode.Draft} | ${'success'} | ${'Draft'}
${'integration'} | ${IntegrationStatusCode.Running} | ${'success'} | ${'Running'}
${'integration'} | ${IntegrationStatusCode.Unauthorized} | ${'success'} | ${'Unauthorized'}
${'integration'} | ${IntegrationStatusCode.SlackNotInChannel} | ${'success'} | ${'Bot not invited to channel'}
${'integration'} | ${IntegrationStatusCode.Unknown} | ${'success'} | ${'Unknown'}
${'integration'} | ${IntegrationStatusCode.OtherError} | ${'success'} | ${'Unknown error'}
${'external-audit-storage'} | ${IntegrationStatusCode.Draft} | ${'warning'} | ${'Draft'}
${'external-audit-storage'} | ${IntegrationStatusCode.Running} | ${'success'} | ${'Running'}
${'external-audit-storage'} | ${IntegrationStatusCode.Unauthorized} | ${'success'} | ${'Unauthorized'}
${'external-audit-storage'} | ${IntegrationStatusCode.SlackNotInChannel} | ${'success'} | ${'Bot not invited to channel'}
${'external-audit-storage'} | ${IntegrationStatusCode.Unknown} | ${'success'} | ${'Unknown'}
${'external-audit-storage'} | ${IntegrationStatusCode.OtherError} | ${'success'} | ${'Unknown error'}
${'any'} | ${IntegrationStatusCode.Draft} | ${'warning'} | ${'Draft'}
${'any'} | ${IntegrationStatusCode.Running} | ${'success'} | ${'Running'}
${'any'} | ${IntegrationStatusCode.Unauthorized} | ${'danger'} | ${'Unauthorized'}
${'any'} | ${IntegrationStatusCode.SlackNotInChannel} | ${'warning'} | ${'Bot not invited to channel'}
${'any'} | ${IntegrationStatusCode.Unknown} | ${'secondary'} | ${'Unknown'}
${'any'} | ${IntegrationStatusCode.OtherError} | ${'danger'} | ${'Unknown error'}
`(
'getStatusAndLabel type $type with code $code returns expected',
async ({ type, code, expectedLabelKind, expectedTitle }) => {
const item: Integration = {
name: '',
kind: undefined,
resourceType: type,
statusCode: code,
};
const status = getStatusAndLabel(item);
expect(status.status).toBe(expectedTitle);
expect(status.labelKind).toBe(expectedLabelKind);
}
);
@@ -16,59 +16,51 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { LabelKind } from 'design/Label';
import { IntegrationLike } from './IntegrationList';
import { getStatus } from './shared/StatusLabel';
import { Status } from './types';
import { IntegrationLike, Status } from 'teleport/Integrations/IntegrationList';
import {
getStatusCodeTitle,
Integration,
IntegrationStatusCode,
} from 'teleport/services/integrations';
const StatusRank: Record<Status, number> = {
[Status.Draft]: 1,
[Status.Healthy]: 2,
[Status.Issues]: 3,
[Status.Failed]: 4,
[Status.Unknown]: 5,
};
export function getStatus(item: IntegrationLike): Status {
if (item.resourceType === 'integration') {
return Status.Success;
}
export const sortByStatus = (a, b) => {
const { status: statusA } = getStatus(a);
const { status: statusB } = getStatus(b);
return StatusRank[statusA] - StatusRank[statusB];
};
if (item.resourceType === 'external-audit-storage') {
switch (item.statusCode) {
case IntegrationStatusCode.Draft:
return Status.Warning;
default:
return Status.Success;
export function filterByIntegrationStatus(
l: IntegrationLike[],
s: Status[]
): IntegrationLike[] {
return l.filter(i => {
if (s.length) {
const { status } = getStatus(i);
if (!s.includes(status)) {
return false;
}
}
}
switch (item.statusCode) {
case IntegrationStatusCode.Unknown:
return null;
case IntegrationStatusCode.Running:
return Status.Success;
case IntegrationStatusCode.SlackNotInChannel:
return Status.Warning;
case IntegrationStatusCode.Draft:
return Status.Warning;
default:
return Status.Error;
}
return true;
});
}
export function getStatusAndLabel(integration: Integration): {
labelKind: LabelKind;
status: string;
} {
const modifiedStatus = getStatus(integration);
const statusCode = integration.statusCode;
const title = getStatusCodeTitle(statusCode);
export function filterBySearch(
l: IntegrationLike[],
s: string
): IntegrationLike[] {
const search = s.trim().toLocaleUpperCase();
if (!search) return l;
switch (modifiedStatus) {
case Status.Success:
return { labelKind: 'success', status: title };
case Status.Error:
return { labelKind: 'danger', status: title };
case Status.Warning:
return { labelKind: 'warning', status: title };
default:
return { labelKind: 'secondary', status: title };
}
return l.filter(i => {
return (
i.name.toLocaleUpperCase().includes(search) ||
i.kind.toLocaleUpperCase().includes(search) ||
(i.details && i.details.toLocaleUpperCase().includes(search))
);
});
}
@@ -0,0 +1,182 @@
/**
* Teleport
* Copyright (C) 2026 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 { ComponentType } from 'react';
import styled from 'styled-components';
import { Box, Flex, Text } from 'design';
import {
CircleCheck,
CircleCross,
Pencil,
Question,
Warning,
} from 'design/Icon';
import { IconSize } from 'design/Icon/Icon';
import {
DangerOutlined,
SecondaryOutlined,
SuccessOutlined,
WarningOutlined,
} from 'design/Label/Label';
import { HoverTooltip } from 'design/Tooltip';
import { IntegrationStatusCode } from 'teleport/services/integrations';
import { IntegrationLike } from '../IntegrationList';
import { Status } from '../types';
export const StatusLabel = ({
integration,
}: {
integration: IntegrationLike;
}) => {
const { status, label, tooltip } = getStatus(integration);
return (
<HoverTooltip
tipContent={
<Box>
<Text fontWeight={600}>Status</Text>
<Text>{tooltip}</Text>
</Box>
}
>
<PointerFlex inline alignItems="center">
{statusLabel(status, label)}
</PointerFlex>
</HoverTooltip>
);
};
const PointerFlex = styled(Flex)`
cursor: pointer;
`;
const HEALTHY = {
status: Status.Healthy,
label: 'Healthy',
tooltip: 'Integration is connected and working.',
};
const DRAFT = {
status: Status.Draft,
label: 'Draft',
tooltip: 'Integration setup has not been completed.',
};
const FAILED = (tooltip: string) => ({
status: Status.Failed,
label: 'Failed',
tooltip,
});
const UNKNOWN = (tooltip: string) => ({
status: Status.Unknown,
label: 'Unknown',
tooltip,
});
const ISSUES = (tooltip: string) => ({
status: Status.Issues,
label: 'Issues',
tooltip,
});
export function getStatus(item: IntegrationLike): {
status: Status;
label: string;
tooltip: string;
} {
if (item.resourceType === 'integration') {
return HEALTHY;
}
if (item.resourceType === 'external-audit-storage') {
return item.statusCode === IntegrationStatusCode.Draft ? DRAFT : HEALTHY;
}
switch (item.statusCode) {
case IntegrationStatusCode.Unknown:
return UNKNOWN(
'The integration is in an unknown state. If this state persists, try removing and re-connecting the integration.'
);
case IntegrationStatusCode.Running:
return HEALTHY;
case IntegrationStatusCode.Draft:
return DRAFT;
case IntegrationStatusCode.SlackNotInChannel:
return ISSUES(
'The Slack integration must be invited to the default channel in order to receive access request notifications.'
);
case IntegrationStatusCode.Unauthorized:
return FAILED(
'The integration was denied access. This could be a result of revoked authorization on the 3rd party provider. Try removing and re-connecting the integration.'
);
case IntegrationStatusCode.OktaConfigError:
return FAILED(
`There was an error with the integration's configuration.${item.status?.errorMessage ? ` ${item.status.errorMessage}` : ''}`
);
default:
return FAILED(
'Integration failed due to an unknown error. Try removing and re-connecting the integration.'
);
}
}
const StatusUI: Record<
Status,
{
Icon: ComponentType<{ size?: IconSize }>;
Label: ComponentType<{ children: React.ReactNode }>;
}
> = {
[Status.Healthy]: {
Icon: CircleCheck,
Label: SuccessOutlined,
},
[Status.Draft]: {
Icon: Pencil,
Label: SecondaryOutlined,
},
[Status.Unknown]: {
Icon: Question,
Label: SecondaryOutlined,
},
[Status.Failed]: {
Icon: CircleCross,
Label: DangerOutlined,
},
[Status.Issues]: {
Icon: Warning,
Label: WarningOutlined,
},
};
const statusLabel = (status: Status, label: string) => {
const { Icon, Label } = StatusUI[status];
return (
<Label aria-label="status">
<Flex alignItems="center" gap={1}>
<Icon size="small" />
{label}
</Flex>
</Label>
);
};
@@ -26,6 +26,7 @@ import { makeSuccessAttempt } from 'shared/hooks/useAsync';
import { ContextProvider } from 'teleport/index';
import { AwsOidcDashboard } from 'teleport/Integrations/status/AwsOidc/AwsOidcDashboard';
import { MockAwsOidcStatusProvider } from 'teleport/Integrations/status/AwsOidc/testHelpers/mockAwsOidcStatusProvider';
import { Status } from 'teleport/Integrations/types';
import { createTeleportContext } from 'teleport/mocks/contexts';
import {
IntegrationAwsOidc,
@@ -100,7 +101,7 @@ test('renders header and stats cards', async () => {
</ContextProvider>
);
await screen.findByText('Running');
await screen.findByText(Status.Healthy);
const breadcrumbs = screen.getByTestId('aws-oidc-header');
expect(within(breadcrumbs).getByText('integration-one')).toBeInTheDocument();
@@ -109,11 +110,9 @@ test('renders header and stats cards', async () => {
'href',
'/web/integrations'
);
expect(within(title).getByLabelText('status')).toHaveAttribute(
'kind',
'success'
expect(within(title).getByLabelText('status')).toHaveTextContent(
Status.Healthy
);
expect(within(title).getByLabelText('status')).toHaveTextContent('Running');
expect(within(title).getByText('integration-one')).toBeInTheDocument();
const ec2 = screen.getByTestId('ec2-stats');
@@ -23,6 +23,7 @@ import { InfoGuidePanelProvider } from 'shared/components/SlidingSidePanel/InfoG
import { AwsOidcTitle } from 'teleport/Integrations/status/AwsOidc/AwsOidcTitle';
import { AwsResource } from 'teleport/Integrations/status/AwsOidc/Cards/StatCard';
import { Status } from 'teleport/Integrations/types';
import {
IntegrationAwsOidc,
IntegrationKind,
@@ -60,7 +61,7 @@ test('renders with resource', () => {
expect(screen.getByText('EC2')).toBeInTheDocument();
expect(screen.queryByText('some-name')).not.toBeInTheDocument();
expect(
within(screen.getByLabelText('status')).getByText('Running')
within(screen.getByLabelText('status')).getByText(Status.Healthy)
).toBeInTheDocument();
});
@@ -79,7 +80,7 @@ test('renders without resource', () => {
);
expect(screen.getByText('some-name')).toBeInTheDocument();
expect(
within(screen.getByLabelText('status')).getByText('Running')
within(screen.getByLabelText('status')).getByText(Status.Healthy)
).toBeInTheDocument();
});
@@ -98,6 +99,6 @@ test('renders tasks', () => {
);
expect(screen.getByText('Pending Tasks')).toBeInTheDocument();
expect(
within(screen.getByLabelText('status')).getByText('Running')
within(screen.getByLabelText('status')).getByText(Status.Healthy)
).toBeInTheDocument();
});
@@ -20,7 +20,7 @@ import { useHistory } from 'react-router';
import { Link as InternalLink } from 'react-router-dom';
import { useTheme } from 'styled-components';
import { ButtonIcon, Flex, Label, Link, MenuItem, Text } from 'design';
import { ButtonIcon, Flex, Link, MenuItem, Text } from 'design';
import * as Icons from 'design/Icon';
import { ArrowLeft } from 'design/Icon';
import { HoverTooltip } from 'design/Tooltip';
@@ -28,13 +28,13 @@ import { MenuButton } from 'shared/components/MenuAction';
import { InfoGuideButton } from 'shared/components/SlidingSidePanel/InfoGuide';
import cfg from 'teleport/config';
import { getStatusAndLabel } from 'teleport/Integrations/helpers';
import {
IntegrationOperations,
useIntegrationOperation,
} from 'teleport/Integrations/Operations';
import { type DeleteRequestOptions } from 'teleport/Integrations/Operations/IntegrationOperations';
import type { EditableIntegrationFields } from 'teleport/Integrations/Operations/useIntegrationOperation';
import { StatusLabel } from 'teleport/Integrations/shared/StatusLabel';
import { AwsResource } from 'teleport/Integrations/status/AwsOidc/Cards/StatCard';
import { IntegrationAwsOidc } from 'teleport/services/integrations';
import { splitAwsIamArn } from 'teleport/services/integrations/aws';
@@ -53,7 +53,6 @@ export function AwsOidcTitle({
const theme = useTheme();
const history = useHistory();
const integrationOps = useIntegrationOperation();
const { status, labelKind } = getStatusAndLabel(integration);
const content = getContent(integration, resource, tasks);
async function removeIntegration(opt: DeleteRequestOptions) {
@@ -84,9 +83,7 @@ export function AwsOidcTitle({
<Text bold fontSize={6}>
{content.content}
</Text>
<Label kind={labelKind} aria-label="status" px={3}>
{status}
</Label>
<StatusLabel integration={integration} />
</Flex>
{integration.spec && (
<Flex gap={1}>
@@ -21,7 +21,7 @@ import { useHistory, useParams } from 'react-router';
import { Link as InternalLink } from 'react-router-dom';
import { useTheme } from 'styled-components';
import { ButtonIcon, Flex, Label, Link, MenuItem, Text } from 'design';
import { ButtonIcon, Flex, Link, MenuItem, Text } from 'design';
import * as Alerts from 'design/Alert';
import * as Icons from 'design/Icon';
import { ArrowLeft } from 'design/Icon';
@@ -33,12 +33,12 @@ import { InfoGuideButton } from 'shared/components/SlidingSidePanel/InfoGuide';
import { FeatureBox } from 'teleport/components/Layout';
import cfg from 'teleport/config';
import { Guide } from 'teleport/Integrations/Enroll/AwsConsole/Guide';
import { getStatusAndLabel } from 'teleport/Integrations/helpers';
import {
IntegrationOperations,
useIntegrationOperation,
} from 'teleport/Integrations/Operations';
import type { EditableIntegrationFields } from 'teleport/Integrations/Operations/useIntegrationOperation';
import { StatusLabel } from 'teleport/Integrations/shared/StatusLabel';
import { AwsOidcHeader } from 'teleport/Integrations/status/AwsOidc/AwsOidcHeader';
import { ConsoleCardEnrolled } from 'teleport/Integrations/status/AwsOidc/Cards/ConsoleCard';
import {
@@ -117,7 +117,6 @@ function AwsRaTitle({ integration }: { integration: IntegrationAwsRa }) {
const theme = useTheme();
const history = useHistory();
const integrationOps = useIntegrationOperation();
const { status, labelKind } = getStatusAndLabel(integration);
const anchorIdReg = new RegExp('[^\\/]+$');
const trustAnchorId = integration?.spec?.trustAnchorARN?.match(anchorIdReg);
@@ -150,9 +149,7 @@ function AwsRaTitle({ integration }: { integration: IntegrationAwsRa }) {
<Text bold fontSize={6}>
{integration.name}
</Text>
<Label kind={labelKind} aria-label="status" px={3}>
{status}
</Label>
<StatusLabel integration={integration} />
</Flex>
<Flex gap={1}>
Trust Anchor ARN:{' '}
@@ -0,0 +1,34 @@
/**
* Teleport
* Copyright (C) 2026 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 const Status = {
Healthy: 'Healthy',
Issues: 'Issues',
Failed: 'Failed',
Draft: 'Draft',
Unknown: 'Unknown',
} as const;
export type Status = (typeof Status)[keyof typeof Status];
export const StatusOptions: { label: Status; value: Status }[] = Object.values(
Status
).map(s => ({
label: s,
value: s,
}));