mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
Update integration status and add status filtering to the IntegrationList (#62821)
This commit is contained in:
@@ -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} />
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
Reference in New Issue
Block a user