mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
Add unified resources view to Connect (#33916)
* Extract resource action buttons
* Add a method to fetch unified resources
* Add unified resources view to Connect
* Adjust stories and tests
* Properly log error events
* Inline `actionButtons.tsx` props
* Remove `ClusterContext.connectKube`
* Revert database type formatting
* Set `req.setSortBy` only if sort is present
* Take `serverId` from `server.name`
* Keep all `Layout` styling in one place
* Use `Flex` element directly, instead of `as={Flex}`
* Use entire `params` object as a dependency
* Move mapping resources ot of the component
* Document changes to `window.addEventListener('error')`
* Fix lint issues
* Call `clear()` when changing params
* Do not specify height on the `Layout` component
* Fix tests depending on mocked intersection observer
* Run prettier
This commit is contained in:
@@ -799,15 +799,17 @@ export default function createClient(
|
||||
.setClusterUri(params.clusterUri)
|
||||
.setLimit(params.limit)
|
||||
.setKindsList(params.kindsList)
|
||||
.setSortBy(
|
||||
new api.SortBy()
|
||||
.setField(params.sortBy.field)
|
||||
.setIsDesc(params.sortBy.isDesc)
|
||||
)
|
||||
.setStartKey(params.startKey)
|
||||
.setSearch(params.search)
|
||||
.setQuery(params.query)
|
||||
.setSearchAsRoles(params.searchAsRoles);
|
||||
if (params.sortBy) {
|
||||
req.setSortBy(
|
||||
new api.SortBy()
|
||||
.setField(params.sortBy.field)
|
||||
.setIsDesc(params.sortBy.isDesc)
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<types.ListUnifiedResourcesResponse>(
|
||||
(resolve, reject) => {
|
||||
|
||||
@@ -381,16 +381,16 @@ export type WaitForConnectMyComputerNodeJoinResponse =
|
||||
export type ListUnifiedResourcesRequest =
|
||||
apiService.ListUnifiedResourcesRequest.AsObject;
|
||||
export type ListUnifiedResourcesResponse = {
|
||||
resources: (
|
||||
| { kind: 'server'; resource: Server }
|
||||
| {
|
||||
kind: 'database';
|
||||
resource: Database;
|
||||
}
|
||||
| { kind: 'kube'; resource: Kube }
|
||||
)[];
|
||||
resources: UnifiedResourceResponse[];
|
||||
nextKey: string;
|
||||
};
|
||||
export type UnifiedResourceResponse =
|
||||
| { kind: 'server'; resource: Server }
|
||||
| {
|
||||
kind: 'database';
|
||||
resource: Database;
|
||||
}
|
||||
| { kind: 'kube'; resource: Kube };
|
||||
|
||||
// Replaces object property with a new type
|
||||
type Modify<T, R> = Omit<T, keyof R> & R;
|
||||
|
||||
+25
-86
@@ -20,21 +20,22 @@ import Table, {
|
||||
StyledTableWrapper,
|
||||
} from 'design/DataTable';
|
||||
import { Danger } from 'design/Alert';
|
||||
import { MenuLogin, MenuLoginProps } from 'shared/components/MenuLogin';
|
||||
import { SearchPanel, SearchPagination } from 'shared/components/Search';
|
||||
import {
|
||||
formatDatabaseInfo,
|
||||
DbType,
|
||||
DbProtocol,
|
||||
} from 'shared/services/databases';
|
||||
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import { retryWithRelogin } from 'teleterm/ui/utils';
|
||||
import { IAppContext } from 'teleterm/ui/types';
|
||||
import { GatewayProtocol } from 'teleterm/services/tshd/types';
|
||||
import { makeDatabase } from 'teleterm/ui/services/clusters';
|
||||
import { DatabaseUri, routing } from 'teleterm/ui/uri';
|
||||
import { routing } from 'teleterm/ui/uri';
|
||||
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
|
||||
|
||||
import { DarkenWhileDisabled } from '../DarkenWhileDisabled';
|
||||
import { getEmptyTableStatus, getEmptyTableText } from '../getEmptyTableText';
|
||||
import { useClusterContext } from '../../clusterContext';
|
||||
|
||||
import { ConnectDatabaseActionButton } from '../../actionButtons';
|
||||
|
||||
import { useDatabases, State } from './useDatabases';
|
||||
|
||||
export default function Container() {
|
||||
@@ -44,7 +45,6 @@ export default function Container() {
|
||||
|
||||
function DatabaseList(props: State) {
|
||||
const {
|
||||
connect,
|
||||
fetchAttempt,
|
||||
agentFilter,
|
||||
pageCount,
|
||||
@@ -55,7 +55,7 @@ function DatabaseList(props: State) {
|
||||
onAgentLabelClick,
|
||||
updateSearch,
|
||||
} = props;
|
||||
const dbs = fetchAttempt.data?.agentsList.map(makeDatabase) || [];
|
||||
const dbs = fetchAttempt.data?.agentsList || [];
|
||||
const disabled = fetchAttempt.status === 'processing';
|
||||
const loggedInUser = useWorkspaceLoggedInUser();
|
||||
const { clusterUri } = useClusterContext();
|
||||
@@ -95,7 +95,7 @@ function DatabaseList(props: State) {
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
key: 'desc',
|
||||
headerText: 'Description',
|
||||
isSortable: true,
|
||||
},
|
||||
@@ -103,25 +103,31 @@ function DatabaseList(props: State) {
|
||||
key: 'type',
|
||||
headerText: 'Type',
|
||||
isSortable: true,
|
||||
render: ({ type, protocol }) => (
|
||||
<Cell>
|
||||
{
|
||||
formatDatabaseInfo(type as DbType, protocol as DbProtocol)
|
||||
.title
|
||||
}
|
||||
</Cell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
key: 'labelsList',
|
||||
headerText: 'Labels',
|
||||
render: ({ labels }) => (
|
||||
render: ({ labelsList }) => (
|
||||
<ClickableLabelCell
|
||||
labels={labels}
|
||||
labels={labelsList}
|
||||
onClick={onAgentLabelClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
altKey: 'connect-btn',
|
||||
render: db => (
|
||||
<ConnectButton
|
||||
dbUri={db.uri}
|
||||
protocol={db.protocol as GatewayProtocol}
|
||||
onConnect={dbUser => connect(db, dbUser)}
|
||||
/>
|
||||
render: database => (
|
||||
<Cell align="right">
|
||||
<ConnectDatabaseActionButton database={database} />
|
||||
</Cell>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -135,70 +141,3 @@ function DatabaseList(props: State) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectButton({
|
||||
dbUri,
|
||||
protocol,
|
||||
onConnect,
|
||||
}: {
|
||||
dbUri: DatabaseUri;
|
||||
protocol: GatewayProtocol;
|
||||
onConnect: (dbUser: string) => void;
|
||||
}) {
|
||||
const appContext = useAppContext();
|
||||
|
||||
return (
|
||||
<Cell align="right">
|
||||
<MenuLogin
|
||||
{...getMenuLoginOptions(protocol)}
|
||||
width="195px"
|
||||
getLoginItems={() => getDatabaseUsers(appContext, dbUri)}
|
||||
onSelect={(_, user) => {
|
||||
onConnect(user);
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
/>
|
||||
</Cell>
|
||||
);
|
||||
}
|
||||
|
||||
function getMenuLoginOptions(
|
||||
protocol: GatewayProtocol
|
||||
): Pick<MenuLoginProps, 'placeholder' | 'required'> {
|
||||
if (protocol === 'redis') {
|
||||
return {
|
||||
placeholder: 'Enter username (optional)',
|
||||
required: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
placeholder: 'Enter username',
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function getDatabaseUsers(appContext: IAppContext, dbUri: DatabaseUri) {
|
||||
try {
|
||||
const dbUsers = await retryWithRelogin(appContext, dbUri, () =>
|
||||
appContext.resourcesService.getDbUsers(dbUri)
|
||||
);
|
||||
return dbUsers.map(user => ({ login: user, url: '' }));
|
||||
} catch (e) {
|
||||
// Emitting a warning instead of an error here because fetching those username suggestions is
|
||||
// not the most important part of the app.
|
||||
appContext.notificationsService.notifyWarning({
|
||||
title: 'Could not fetch database usernames',
|
||||
description: e.message,
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
-12
@@ -16,8 +16,6 @@ limitations under the License.
|
||||
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import { Database, GetResourcesParams } from 'teleterm/services/tshd/types';
|
||||
import { makeDatabase } from 'teleterm/ui/services/clusters';
|
||||
import { connectToDatabase } from 'teleterm/ui/services/workspacesService';
|
||||
|
||||
import { useServerSideResources } from '../useServerSideResources';
|
||||
|
||||
@@ -31,18 +29,8 @@ export function useDatabases() {
|
||||
appContext.resourcesService.fetchDatabases(params)
|
||||
);
|
||||
|
||||
function connect(db: ReturnType<typeof makeDatabase>, dbUser: string): void {
|
||||
const { uri, name, protocol } = db;
|
||||
connectToDatabase(
|
||||
appContext,
|
||||
{ uri, name, protocol, dbUser },
|
||||
{ origin: 'resource_table' }
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
fetchAttempt,
|
||||
connect,
|
||||
...serverSideResources,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,11 +20,9 @@ import Table, {
|
||||
ClickableLabelCell,
|
||||
StyledTableWrapper,
|
||||
} from 'design/DataTable';
|
||||
import { ButtonBorder } from 'design';
|
||||
import { Danger } from 'design/Alert';
|
||||
import { SearchPanel, SearchPagination } from 'shared/components/Search';
|
||||
|
||||
import { makeKube } from 'teleterm/ui/services/clusters';
|
||||
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
|
||||
import { routing } from 'teleterm/ui/uri';
|
||||
|
||||
@@ -32,6 +30,8 @@ import { DarkenWhileDisabled } from '../DarkenWhileDisabled';
|
||||
import { getEmptyTableStatus, getEmptyTableText } from '../getEmptyTableText';
|
||||
import { useClusterContext } from '../../clusterContext';
|
||||
|
||||
import { ConnectKubeActionButton } from '../../actionButtons';
|
||||
|
||||
import { useKubes, State } from './useKubes';
|
||||
|
||||
export default function Container() {
|
||||
@@ -41,7 +41,6 @@ export default function Container() {
|
||||
|
||||
function KubeList(props: State) {
|
||||
const {
|
||||
connect,
|
||||
fetchAttempt,
|
||||
agentFilter,
|
||||
pageCount,
|
||||
@@ -52,7 +51,7 @@ function KubeList(props: State) {
|
||||
onAgentLabelClick,
|
||||
updateSearch,
|
||||
} = props;
|
||||
const kubes = fetchAttempt.data?.agentsList.map(makeKube) || [];
|
||||
const kubes = fetchAttempt.data?.agentsList || [];
|
||||
const disabled = fetchAttempt.status === 'processing';
|
||||
const loggedInUser = useWorkspaceLoggedInUser();
|
||||
const { clusterUri } = useClusterContext();
|
||||
@@ -89,18 +88,22 @@ function KubeList(props: State) {
|
||||
isSortable: true,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
key: 'labelsList',
|
||||
headerText: 'Labels',
|
||||
render: ({ labels }) => (
|
||||
render: ({ labelsList }) => (
|
||||
<ClickableLabelCell
|
||||
labels={labels}
|
||||
labels={labelsList}
|
||||
onClick={onAgentLabelClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
altKey: 'connect-btn',
|
||||
render: kube => renderConnectButtonCell(kube.uri, connect),
|
||||
render: kube => (
|
||||
<Cell align="right">
|
||||
<ConnectKubeActionButton kube={kube} />
|
||||
</Cell>
|
||||
),
|
||||
},
|
||||
]}
|
||||
customSort={customSort}
|
||||
@@ -113,21 +116,3 @@ function KubeList(props: State) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const renderConnectButtonCell = (
|
||||
uri: string,
|
||||
connect: (kubeUri: string) => void
|
||||
) => {
|
||||
return (
|
||||
<Cell align="right">
|
||||
<ButtonBorder
|
||||
size="small"
|
||||
onClick={() => {
|
||||
connect(uri);
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</ButtonBorder>
|
||||
</Cell>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,14 +16,11 @@ limitations under the License.
|
||||
|
||||
import { Kube, GetResourcesParams } from 'teleterm/services/tshd/types';
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import { useClusterContext } from 'teleterm/ui/DocumentCluster/clusterContext';
|
||||
import { KubeUri } from 'teleterm/ui/uri';
|
||||
|
||||
import { useServerSideResources } from '../useServerSideResources';
|
||||
|
||||
export function useKubes() {
|
||||
const appContext = useAppContext();
|
||||
const ctx = useClusterContext();
|
||||
const { fetchAttempt, ...serversideResources } = useServerSideResources<Kube>(
|
||||
{ fieldName: 'name', dir: 'ASC' }, // default sort
|
||||
(params: GetResourcesParams) =>
|
||||
@@ -31,8 +28,6 @@ export function useKubes() {
|
||||
);
|
||||
|
||||
return {
|
||||
connect: (kubeUri: KubeUri) =>
|
||||
ctx.connectKube(kubeUri, { origin: 'resource_table' }),
|
||||
fetchAttempt,
|
||||
...serversideResources,
|
||||
};
|
||||
|
||||
@@ -22,18 +22,19 @@ import Table, {
|
||||
import { ButtonPrimary } from 'design';
|
||||
import { Danger } from 'design/Alert';
|
||||
import * as icons from 'design/Icon';
|
||||
import { MenuLogin } from 'shared/components/MenuLogin';
|
||||
import { SearchPanel, SearchPagination } from 'shared/components/Search';
|
||||
|
||||
import { makeServer } from 'teleterm/ui/services/clusters';
|
||||
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
|
||||
import { useWorkspaceContext } from 'teleterm/ui/Documents';
|
||||
import { useConnectMyComputerContext } from 'teleterm/ui/ConnectMyComputer';
|
||||
import { Server } from 'teleterm/services/tshd/types';
|
||||
|
||||
import { DarkenWhileDisabled } from '../DarkenWhileDisabled';
|
||||
import { getEmptyTableStatus, getEmptyTableText } from '../getEmptyTableText';
|
||||
import { useClusterContext } from '../../clusterContext';
|
||||
|
||||
import { ConnectServerActionButton } from '../../actionButtons';
|
||||
|
||||
import { useServers, State } from './useServers';
|
||||
|
||||
export default function Container() {
|
||||
@@ -43,8 +44,6 @@ export default function Container() {
|
||||
|
||||
function ServerList(props: State) {
|
||||
const {
|
||||
getSshLogins,
|
||||
connect,
|
||||
fetchAttempt,
|
||||
agentFilter,
|
||||
pageCount,
|
||||
@@ -61,7 +60,7 @@ function ServerList(props: State) {
|
||||
const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } =
|
||||
useConnectMyComputerContext();
|
||||
|
||||
const servers = fetchAttempt.data?.agentsList.map(makeServer) || [];
|
||||
const servers = fetchAttempt.data?.agentsList || [];
|
||||
const disabled = fetchAttempt.status === 'processing';
|
||||
const isRootCluster = clusterUri === rootClusterUri;
|
||||
const canAddResources = isRootCluster && loggedInUser?.acl?.tokens.create;
|
||||
@@ -128,22 +127,22 @@ function ServerList(props: State) {
|
||||
render: renderAddressCell,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
key: 'labelsList',
|
||||
headerText: 'Labels',
|
||||
render: ({ labels }) => (
|
||||
render: ({ labelsList }) => (
|
||||
<ClickableLabelCell
|
||||
labels={labels}
|
||||
labels={labelsList}
|
||||
onClick={onAgentLabelClick}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
altKey: 'connect-btn',
|
||||
render: server =>
|
||||
renderConnectCell(
|
||||
() => getSshLogins(server.uri),
|
||||
login => connect(server, login)
|
||||
),
|
||||
render: server => (
|
||||
<Cell align="right">
|
||||
<ConnectServerActionButton server={server} />
|
||||
</Cell>
|
||||
),
|
||||
},
|
||||
]}
|
||||
customSort={customSort}
|
||||
@@ -159,29 +158,7 @@ function ServerList(props: State) {
|
||||
);
|
||||
}
|
||||
|
||||
const renderConnectCell = (
|
||||
getSshLogins: () => string[],
|
||||
onConnect: (login: string) => void
|
||||
) => {
|
||||
return (
|
||||
<Cell align="right">
|
||||
<MenuLogin
|
||||
getLoginItems={() => getSshLogins().map(login => ({ login, url: '' }))}
|
||||
onSelect={(e, login) => onConnect(login)}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
/>
|
||||
</Cell>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAddressCell = ({ addr, tunnel }: ReturnType<typeof makeServer>) => (
|
||||
const renderAddressCell = ({ addr, tunnel }: Server) => (
|
||||
<Cell>
|
||||
{tunnel && (
|
||||
<span
|
||||
|
||||
@@ -15,13 +15,9 @@ limitations under the License.
|
||||
*/
|
||||
import { Server, GetResourcesParams } from 'teleterm/services/tshd/types';
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import { makeServer } from 'teleterm/ui/services/clusters';
|
||||
import { connectToServer } from 'teleterm/ui/services/workspacesService';
|
||||
|
||||
import { useServerSideResources } from '../useServerSideResources';
|
||||
|
||||
import type * as uri from 'teleterm/ui/uri';
|
||||
|
||||
export function useServers() {
|
||||
const appContext = useAppContext();
|
||||
|
||||
@@ -32,26 +28,8 @@ export function useServers() {
|
||||
appContext.resourcesService.fetchServers(params)
|
||||
);
|
||||
|
||||
function getSshLogins(serverUri: uri.ServerUri): string[] {
|
||||
const cluster = appContext.clustersService.findClusterByResource(serverUri);
|
||||
return cluster?.loggedInUser?.sshLoginsList || [];
|
||||
}
|
||||
|
||||
function connect(server: ReturnType<typeof makeServer>, login: string): void {
|
||||
const { uri, hostname } = server;
|
||||
connectToServer(
|
||||
appContext,
|
||||
{ uri, hostname, login },
|
||||
{
|
||||
origin: 'resource_table',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
fetchAttempt,
|
||||
getSshLogins,
|
||||
connect,
|
||||
...serversideResources,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
makeLoggedInUser,
|
||||
makeRootCluster,
|
||||
makeServer,
|
||||
makeDatabase,
|
||||
makeKube,
|
||||
} from 'teleterm/services/tshd/testHelpers';
|
||||
|
||||
import { ResourcesService } from 'teleterm/ui/services/resources';
|
||||
@@ -83,10 +85,10 @@ export const OnlineEmptyResourcesAndCanAddResourcesAndConnectComputer = () => {
|
||||
state,
|
||||
doc: rootClusterDoc,
|
||||
platform: 'darwin',
|
||||
fetchServersPromise: Promise.resolve({
|
||||
agentsList: [],
|
||||
listUnifiedResourcesPromise: Promise.resolve({
|
||||
resources: [],
|
||||
totalCount: 0,
|
||||
startKey: '',
|
||||
nextKey: '',
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -118,10 +120,10 @@ export const OnlineEmptyResourcesAndCanAddResourcesButCannotConnectComputer =
|
||||
state,
|
||||
doc: rootClusterDoc,
|
||||
platform: 'win32',
|
||||
fetchServersPromise: Promise.resolve({
|
||||
agentsList: [],
|
||||
listUnifiedResourcesPromise: Promise.resolve({
|
||||
resources: [],
|
||||
totalCount: 0,
|
||||
startKey: '',
|
||||
nextKey: '',
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -150,10 +152,10 @@ export const OnlineEmptyResourcesAndCannotAddResources = () => {
|
||||
return renderState({
|
||||
state,
|
||||
doc: rootClusterDoc,
|
||||
fetchServersPromise: Promise.resolve({
|
||||
agentsList: [],
|
||||
listUnifiedResourcesPromise: Promise.resolve({
|
||||
resources: [],
|
||||
totalCount: 0,
|
||||
startKey: '',
|
||||
nextKey: '',
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -167,21 +169,21 @@ export const OnlineLoadingResources = () => {
|
||||
})
|
||||
);
|
||||
|
||||
let rejectPromise: () => void;
|
||||
let rejectPromise: (error: Error) => void;
|
||||
const promiseRejectedOnUnmount = new Promise<any>((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
rejectPromise();
|
||||
rejectPromise(new Error('Aborted'));
|
||||
};
|
||||
}, [rejectPromise]);
|
||||
|
||||
return renderState({
|
||||
state,
|
||||
doc: rootClusterDoc,
|
||||
fetchServersPromise: promiseRejectedOnUnmount,
|
||||
listUnifiedResourcesPromise: promiseRejectedOnUnmount,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -197,17 +199,25 @@ export const OnlineLoadedResources = () => {
|
||||
return renderState({
|
||||
state,
|
||||
doc: rootClusterDoc,
|
||||
fetchServersPromise: Promise.resolve({
|
||||
agentsList: [
|
||||
makeServer(),
|
||||
makeServer({
|
||||
uri: '/clusters/foo/servers/1234',
|
||||
hostname: 'bar',
|
||||
tunnel: true,
|
||||
}),
|
||||
listUnifiedResourcesPromise: Promise.resolve({
|
||||
resources: [
|
||||
{
|
||||
kind: 'server',
|
||||
resource: makeServer(),
|
||||
},
|
||||
{
|
||||
kind: 'server',
|
||||
resource: makeServer({
|
||||
uri: '/clusters/foo/servers/1234',
|
||||
hostname: 'bar',
|
||||
tunnel: true,
|
||||
}),
|
||||
},
|
||||
{ kind: 'database', resource: makeDatabase() },
|
||||
{ kind: 'kube', resource: makeKube() },
|
||||
],
|
||||
totalCount: 2,
|
||||
startKey: '',
|
||||
totalCount: 4,
|
||||
nextKey: '',
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -224,7 +234,7 @@ export const OnlineErrorLoadingResources = () => {
|
||||
return renderState({
|
||||
state,
|
||||
doc: rootClusterDoc,
|
||||
fetchServersPromise: Promise.reject(
|
||||
listUnifiedResourcesPromise: Promise.reject(
|
||||
new Error('Whoops, something went wrong, sorry!')
|
||||
),
|
||||
});
|
||||
@@ -257,12 +267,14 @@ export const Notfound = () => {
|
||||
function renderState({
|
||||
state,
|
||||
doc,
|
||||
fetchServersPromise,
|
||||
listUnifiedResourcesPromise,
|
||||
platform = 'darwin',
|
||||
}: {
|
||||
state: ClustersServiceState;
|
||||
doc: docTypes.DocumentCluster;
|
||||
fetchServersPromise?: ReturnType<ResourcesService['fetchServers']>;
|
||||
listUnifiedResourcesPromise?: ReturnType<
|
||||
ResourcesService['listUnifiedResources']
|
||||
>;
|
||||
platform?: NodeJS.Platform;
|
||||
userType?: tsh.UserType;
|
||||
}) {
|
||||
@@ -280,8 +292,9 @@ function renderState({
|
||||
};
|
||||
});
|
||||
|
||||
appContext.resourcesService.fetchServers = () =>
|
||||
fetchServersPromise || Promise.reject('No fetchServersPromise passed');
|
||||
appContext.resourcesService.listUnifiedResources = () =>
|
||||
listUnifiedResourcesPromise ||
|
||||
Promise.reject('No fetchServersPromise passed');
|
||||
|
||||
return (
|
||||
<AppContextProvider value={appContext}>
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { act } from '@testing-library/react';
|
||||
import { render, screen } from 'design/utils/testing';
|
||||
import { mockIntersectionObserver } from 'jsdom-testing-mocks';
|
||||
|
||||
import { MockAppContext } from 'teleterm/ui/fixtures/mocks';
|
||||
import { MockAppContextProvider } from 'teleterm/ui/fixtures/MockAppContextProvider';
|
||||
@@ -29,7 +31,9 @@ import { ConnectMyComputerContextProvider } from 'teleterm/ui/ConnectMyComputer'
|
||||
|
||||
import { ResourcesContextProvider } from './resourcesContext';
|
||||
|
||||
import DocumentCluster from '.';
|
||||
import DocumentCluster from './DocumentCluster';
|
||||
|
||||
const mio = mockIntersectionObserver();
|
||||
|
||||
it('displays a button for Connect My Computer in the empty state if the user can use Connect My Computer', async () => {
|
||||
const doc = {
|
||||
@@ -74,12 +78,12 @@ it('displays a button for Connect My Computer in the empty state if the user can
|
||||
});
|
||||
|
||||
const emptyResponse = {
|
||||
agentsList: [],
|
||||
resources: [],
|
||||
totalCount: 0,
|
||||
startKey: '',
|
||||
nextKey: '',
|
||||
};
|
||||
jest
|
||||
.spyOn(appContext.resourcesService, 'fetchServers')
|
||||
.spyOn(appContext.resourcesService, 'listUnifiedResources')
|
||||
.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(
|
||||
@@ -94,6 +98,8 @@ it('displays a button for Connect My Computer in the empty state if the user can
|
||||
</MockAppContextProvider>
|
||||
);
|
||||
|
||||
act(mio.enterAll);
|
||||
|
||||
await expect(
|
||||
screen.findByRole('button', { name: 'Connect My Computer' })
|
||||
).resolves.toBeInTheDocument();
|
||||
@@ -142,12 +148,12 @@ it('does not display a button for Connect My Computer in the empty state if the
|
||||
});
|
||||
|
||||
const emptyResponse = {
|
||||
agentsList: [],
|
||||
resources: [],
|
||||
totalCount: 0,
|
||||
startKey: '',
|
||||
nextKey: '',
|
||||
};
|
||||
jest
|
||||
.spyOn(appContext.resourcesService, 'fetchServers')
|
||||
.spyOn(appContext.resourcesService, 'listUnifiedResources')
|
||||
.mockResolvedValue(emptyResponse);
|
||||
|
||||
render(
|
||||
@@ -162,8 +168,10 @@ it('does not display a button for Connect My Computer in the empty state if the
|
||||
</MockAppContextProvider>
|
||||
);
|
||||
|
||||
act(mio.enterAll);
|
||||
|
||||
await expect(
|
||||
screen.findByText('No servers found.')
|
||||
screen.findByText('No Resources Found')
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
|
||||
@@ -28,7 +28,7 @@ import ClusterCtx, {
|
||||
ClusterContextProvider,
|
||||
useClusterContext,
|
||||
} from './clusterContext';
|
||||
import ClusterResources from './ClusterResources';
|
||||
import { UnifiedResources } from './UnifiedResources';
|
||||
|
||||
export default function Container(props: DocumentProps) {
|
||||
const { clusterUri } = props.doc;
|
||||
@@ -82,8 +82,8 @@ export function Cluster() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout mx="auto" px={5} pt={2} height="100%">
|
||||
<ClusterResources />
|
||||
<Layout>
|
||||
<UnifiedResources clusterUri={clusterCtx.clusterUri} />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -138,14 +138,8 @@ type DocumentProps = {
|
||||
doc: types.DocumentCluster;
|
||||
};
|
||||
|
||||
const Layout = styled(Box)`
|
||||
const Layout = styled(Box).attrs({ mx: 'auto', px: 5, pt: 4 })`
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
max-width: 1248px;
|
||||
|
||||
::after {
|
||||
content: ' ';
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* Copyright 2023 Gravitational, Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
UnifiedResources as SharedUnifiedResources,
|
||||
useUnifiedResourcesFetch,
|
||||
UnifiedResourcesQueryParams,
|
||||
SharedUnifiedResource,
|
||||
} from 'shared/components/UnifiedResources';
|
||||
import {
|
||||
DbProtocol,
|
||||
formatDatabaseInfo,
|
||||
DbType,
|
||||
} from 'shared/services/databases';
|
||||
|
||||
import { Flex, ButtonPrimary, Text } from 'design';
|
||||
|
||||
import * as icons from 'design/Icon';
|
||||
import Image from 'design/Image';
|
||||
import stack from 'design/assets/resources/stack.png';
|
||||
|
||||
import SearchPanel from 'teleport/UnifiedResources/SearchPanel';
|
||||
|
||||
import { UnifiedResourceResponse } from 'teleterm/services/tshd/types';
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import * as uri from 'teleterm/ui/uri';
|
||||
import { useWorkspaceContext } from 'teleterm/ui/Documents';
|
||||
import { useWorkspaceLoggedInUser } from 'teleterm/ui/hooks/useLoggedInUser';
|
||||
import { useConnectMyComputerContext } from 'teleterm/ui/ConnectMyComputer';
|
||||
|
||||
import { retryWithRelogin } from 'teleterm/ui/utils';
|
||||
|
||||
import {
|
||||
ConnectServerActionButton,
|
||||
ConnectKubeActionButton,
|
||||
ConnectDatabaseActionButton,
|
||||
} from './actionButtons';
|
||||
import { useResourcesContext } from './resourcesContext';
|
||||
|
||||
interface UnifiedResourcesProps {
|
||||
clusterUri: uri.ClusterUri;
|
||||
}
|
||||
|
||||
export function UnifiedResources(props: UnifiedResourcesProps) {
|
||||
const appContext = useAppContext();
|
||||
const { onResourcesRefreshRequest } = useResourcesContext();
|
||||
|
||||
const [params, setParams] = useState<UnifiedResourcesQueryParams>({
|
||||
sort: { fieldName: 'name', dir: 'ASC' },
|
||||
});
|
||||
|
||||
const { documentsService, rootClusterUri } = useWorkspaceContext();
|
||||
const loggedInUser = useWorkspaceLoggedInUser();
|
||||
const { canUse: hasPermissionsForConnectMyComputer, agentCompatibility } =
|
||||
useConnectMyComputerContext();
|
||||
|
||||
const isRootCluster = props.clusterUri === rootClusterUri;
|
||||
const canAddResources = isRootCluster && loggedInUser?.acl?.tokens.create;
|
||||
|
||||
const canUseConnectMyComputer =
|
||||
isRootCluster &&
|
||||
hasPermissionsForConnectMyComputer &&
|
||||
agentCompatibility === 'compatible';
|
||||
|
||||
const { fetch, resources, attempt, clear } = useUnifiedResourcesFetch({
|
||||
fetchFunc: useCallback(
|
||||
async (paginationParams, signal) => {
|
||||
const response = await retryWithRelogin(
|
||||
appContext,
|
||||
props.clusterUri,
|
||||
() =>
|
||||
appContext.resourcesService.listUnifiedResources(
|
||||
{
|
||||
clusterUri: props.clusterUri,
|
||||
searchAsRoles: false,
|
||||
sortBy: {
|
||||
isDesc: params.sort.dir === 'DESC',
|
||||
field: params.sort.fieldName,
|
||||
},
|
||||
search: params.search,
|
||||
kindsList: params.kinds,
|
||||
query: params.query,
|
||||
pinnedOnly: params.pinnedOnly,
|
||||
startKey: paginationParams.startKey,
|
||||
limit: paginationParams.limit,
|
||||
},
|
||||
signal
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
startKey: response.nextKey,
|
||||
agents: response.resources,
|
||||
totalCount: response.resources.length,
|
||||
};
|
||||
},
|
||||
[appContext, params, props.clusterUri]
|
||||
),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const { cleanup } = onResourcesRefreshRequest(() => {
|
||||
clear();
|
||||
fetch({ force: true });
|
||||
});
|
||||
return cleanup;
|
||||
}, [onResourcesRefreshRequest, fetch, clear]);
|
||||
|
||||
function onParamsChange(newParams: UnifiedResourcesQueryParams): void {
|
||||
clear();
|
||||
setParams(newParams);
|
||||
}
|
||||
|
||||
return (
|
||||
<SharedUnifiedResources
|
||||
params={params}
|
||||
setParams={onParamsChange}
|
||||
updateUnifiedResourcesPreferences={() => alert('Not implemented')}
|
||||
onLabelClick={() => alert('Not implemented')}
|
||||
pinning={{ kind: 'hidden' }}
|
||||
resources={resources.map(mapToSharedResource)}
|
||||
resourcesFetchAttempt={attempt}
|
||||
fetchResources={fetch}
|
||||
availableKinds={['db', 'kube_cluster', 'node']}
|
||||
Header={pinAllButton => (
|
||||
<Flex alignItems="center" justifyContent="space-between">
|
||||
{/*temporary search panel*/}
|
||||
<SearchPanel
|
||||
params={params}
|
||||
pathname={''}
|
||||
replaceHistory={() => undefined}
|
||||
setParams={onParamsChange}
|
||||
/>
|
||||
{pinAllButton}
|
||||
</Flex>
|
||||
)}
|
||||
NoResources={
|
||||
<NoResources
|
||||
canCreate={canAddResources}
|
||||
canUseConnectMyComputer={canUseConnectMyComputer}
|
||||
onConnectMyComputerCtaClick={() => {
|
||||
documentsService.openConnectMyComputerDocument({ rootClusterUri });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const mapToSharedResource = (
|
||||
resource: UnifiedResourceResponse
|
||||
): SharedUnifiedResource => {
|
||||
switch (resource.kind) {
|
||||
case 'server': {
|
||||
const { resource: server } = resource;
|
||||
return {
|
||||
resource: {
|
||||
kind: 'node' as const,
|
||||
labels: server.labelsList,
|
||||
id: server.name,
|
||||
hostname: server.hostname,
|
||||
addr: server.addr,
|
||||
tunnel: server.tunnel,
|
||||
},
|
||||
ui: {
|
||||
ActionButton: <ConnectServerActionButton server={server} />,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'database': {
|
||||
const { resource: database } = resource;
|
||||
return {
|
||||
resource: {
|
||||
kind: 'db' as const,
|
||||
labels: database.labelsList,
|
||||
description: database.desc,
|
||||
name: database.name,
|
||||
type: formatDatabaseInfo(
|
||||
database.type as DbType,
|
||||
database.protocol as DbProtocol
|
||||
).title,
|
||||
protocol: database.protocol as DbProtocol,
|
||||
},
|
||||
ui: {
|
||||
ActionButton: <ConnectDatabaseActionButton database={database} />,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'kube': {
|
||||
const { resource: kube } = resource;
|
||||
|
||||
return {
|
||||
resource: {
|
||||
kind: 'kube_cluster' as const,
|
||||
labels: kube.labelsList,
|
||||
name: kube.name,
|
||||
},
|
||||
ui: {
|
||||
ActionButton: <ConnectKubeActionButton kube={kube} />,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function NoResources(props: {
|
||||
canCreate: boolean;
|
||||
canUseConnectMyComputer: boolean;
|
||||
onConnectMyComputerCtaClick(): void;
|
||||
}) {
|
||||
let $content: React.ReactElement;
|
||||
if (!props.canCreate) {
|
||||
$content = (
|
||||
<>
|
||||
<Text typography="h3" mb="2" fontWeight={600}>
|
||||
No Resources Found
|
||||
</Text>
|
||||
<Text>
|
||||
Either there are no resources in the cluster, or your roles don't
|
||||
grant you access.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
$content = (
|
||||
<>
|
||||
<Image src={stack} ml="auto" mr="auto" mb={4} height="100px" />
|
||||
<Text typography="h3" mb={2} fontWeight={600}>
|
||||
Add your first resource to Teleport
|
||||
</Text>
|
||||
<Text color="text.slightlyMuted">
|
||||
{props.canUseConnectMyComputer
|
||||
? 'You can add it in the Teleport Web UI or by connecting your computer to the cluster.'
|
||||
: 'Connect SSH servers, Kubernetes clusters, Databases and more from Teleport Web UI.'}
|
||||
</Text>
|
||||
{props.canUseConnectMyComputer && (
|
||||
<ButtonPrimary
|
||||
type="button"
|
||||
mt={3}
|
||||
gap={2}
|
||||
onClick={props.onConnectMyComputerCtaClick}
|
||||
>
|
||||
<icons.Laptop size={'medium'} />
|
||||
Connect My Computer
|
||||
</ButtonPrimary>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
maxWidth={600}
|
||||
p={8}
|
||||
pt={5}
|
||||
width="100%"
|
||||
mx="auto"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{$content}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright 2023 Gravitational, Inc
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { MenuLogin, MenuLoginProps } from 'shared/components/MenuLogin';
|
||||
|
||||
import { ButtonBorder } from 'design';
|
||||
|
||||
import {
|
||||
connectToServer,
|
||||
connectToDatabase,
|
||||
connectToKube,
|
||||
} from 'teleterm/ui/services/workspacesService';
|
||||
import { useAppContext } from 'teleterm/ui/appContextProvider';
|
||||
import {
|
||||
Server,
|
||||
Kube,
|
||||
GatewayProtocol,
|
||||
Database,
|
||||
} from 'teleterm/services/tshd/types';
|
||||
|
||||
import { DatabaseUri } from 'teleterm/ui/uri';
|
||||
import { IAppContext } from 'teleterm/ui/types';
|
||||
import { retryWithRelogin } from 'teleterm/ui/utils';
|
||||
|
||||
export function ConnectServerActionButton(props: {
|
||||
server: Server;
|
||||
}): React.JSX.Element {
|
||||
const ctx = useAppContext();
|
||||
|
||||
function getSshLogins(): string[] {
|
||||
const cluster = ctx.clustersService.findClusterByResource(props.server.uri);
|
||||
return cluster?.loggedInUser?.sshLoginsList || [];
|
||||
}
|
||||
|
||||
function connect(login: string): void {
|
||||
const { uri, hostname } = props.server;
|
||||
connectToServer(
|
||||
ctx,
|
||||
{ uri, hostname, login },
|
||||
{
|
||||
origin: 'resource_table',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuLogin
|
||||
getLoginItems={() => getSshLogins().map(login => ({ login, url: '' }))}
|
||||
onSelect={(e, login) => connect(login)}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectKubeActionButton(props: {
|
||||
kube: Kube;
|
||||
}): React.JSX.Element {
|
||||
const appContext = useAppContext();
|
||||
|
||||
function connect(): void {
|
||||
connectToKube(
|
||||
appContext,
|
||||
{ uri: props.kube.uri },
|
||||
{ origin: 'resource_table' }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonBorder size="small" onClick={connect}>
|
||||
Connect
|
||||
</ButtonBorder>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectDatabaseActionButton(props: {
|
||||
database: Database;
|
||||
}): React.JSX.Element {
|
||||
const appContext = useAppContext();
|
||||
|
||||
function connect(dbUser: string): void {
|
||||
const { uri, name, protocol } = props.database;
|
||||
connectToDatabase(
|
||||
appContext,
|
||||
{ uri, name, protocol, dbUser },
|
||||
{ origin: 'resource_table' }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuLogin
|
||||
{...getDatabaseMenuLoginOptions(
|
||||
props.database.protocol as GatewayProtocol
|
||||
)}
|
||||
width="195px"
|
||||
getLoginItems={() => getDatabaseUsers(appContext, props.database.uri)}
|
||||
onSelect={(_, user) => {
|
||||
connect(user);
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
anchorOrigin={{
|
||||
vertical: 'center',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getDatabaseMenuLoginOptions(
|
||||
protocol: GatewayProtocol
|
||||
): Pick<MenuLoginProps, 'placeholder' | 'required'> {
|
||||
if (protocol === 'redis') {
|
||||
return {
|
||||
placeholder: 'Enter username (optional)',
|
||||
required: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
placeholder: 'Enter username',
|
||||
required: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function getDatabaseUsers(appContext: IAppContext, dbUri: DatabaseUri) {
|
||||
try {
|
||||
const dbUsers = await retryWithRelogin(appContext, dbUri, () =>
|
||||
appContext.resourcesService.getDbUsers(dbUri)
|
||||
);
|
||||
return dbUsers.map(user => ({ login: user, url: '' }));
|
||||
} catch (e) {
|
||||
// Emitting a warning instead of an error here because fetching those username suggestions is
|
||||
// not the most important part of the app.
|
||||
appContext.notificationsService.notifyWarning({
|
||||
title: 'Could not fetch database usernames',
|
||||
description: e.message,
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,7 @@ import { matchPath } from 'react-router';
|
||||
import { useStore, Store } from 'shared/libs/stores';
|
||||
|
||||
import { IAppContext } from 'teleterm/ui/types';
|
||||
import { ClusterUri, DocumentUri, KubeUri, routing } from 'teleterm/ui/uri';
|
||||
import {
|
||||
connectToKube,
|
||||
DocumentOrigin,
|
||||
} from 'teleterm/ui/services/workspacesService';
|
||||
import { ClusterUri, DocumentUri, routing } from 'teleterm/ui/uri';
|
||||
|
||||
import type * as tsh from 'teleterm/services/tshd/types';
|
||||
|
||||
@@ -75,10 +71,6 @@ class ClusterContext extends Store<State> {
|
||||
});
|
||||
};
|
||||
|
||||
connectKube = (kubeUri: KubeUri, params: { origin: DocumentOrigin }) => {
|
||||
connectToKube(this.appCtx, { uri: kubeUri }, { origin: params.origin });
|
||||
};
|
||||
|
||||
refresh = () => {
|
||||
const rootCluster = this.appCtx.clustersService.findRootClusterByResource(
|
||||
this.clusterUri
|
||||
|
||||
@@ -32,8 +32,12 @@ async function boot(): Promise<void> {
|
||||
const appContext = new AppContext(globals);
|
||||
|
||||
window.addEventListener('error', event => {
|
||||
console.error(event.error.stack);
|
||||
logger.error(event.error.stack);
|
||||
// The event object is a `ErrorEvent` instance if it was generated from
|
||||
// a user interface element, or an `Event` instance otherwise.
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event#event_type
|
||||
const message = event.error ? event.error.stack : event.message;
|
||||
console.error(message);
|
||||
logger.error(message);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
|
||||
@@ -125,6 +125,29 @@ export class ResourcesService {
|
||||
|
||||
return Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
listUnifiedResources(
|
||||
params: types.ListUnifiedResourcesRequest,
|
||||
abortSignal: AbortSignal
|
||||
) {
|
||||
const tshAbortSignal = {
|
||||
aborted: false,
|
||||
addEventListener: (cb: (...args: any[]) => void) => {
|
||||
abortSignal.addEventListener('abort', cb);
|
||||
},
|
||||
removeEventListener: (cb: (...args: any[]) => void) => {
|
||||
abortSignal.removeEventListener('abort', cb);
|
||||
},
|
||||
};
|
||||
abortSignal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
tshAbortSignal.aborted = true;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
return this.tshClient.listUnifiedResources(params, tshAbortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
export class AmbiguousHostnameError extends Error {
|
||||
|
||||
Reference in New Issue
Block a user