feat: Add a Web Shell terminal to Pod's Container Cards
Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> Update .changeset/forty-rocks-jog.md Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> fix: Address PR comments Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Use sh instead of bash Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Move K8sTerminal into a dialog Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Use discoveryApi instead of configApi Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Update api-report.md Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Revert PodDrawer.tsx Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> test: Polyfill TextEncoder Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> feat: Refactor K8sTerminal inputs to receive only what it needs Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> test: Add tests Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> Update plugins/kubernetes/src/components/K8sTerminal/K8sTerminal.test.tsx Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> test: Fix tests for K8sTerminal Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> test: Fix isolated modules error Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Update api reports Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> Update plugins/kubernetes/src/components/K8sTerminal/K8sTerminalDialog.tsx Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> Update plugins/kubernetes/src/components/K8sTerminal/K8sTerminal.test.tsx Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> fix: Check for cluster size 1 & small refactors Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> chore: Update changeset Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Check client side auth providers to disable web shell Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> fix: Move K8sAPI logic to custom hook with mock Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> test: Fix hook mock warning & add internal tag for mock Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> Update plugins/kubernetes/src/components/K8sTerminal/K8sTerminal.test.tsx Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> Update .changeset/forty-rocks-jog.md Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> Update plugins/kubernetes/src/components/K8sTerminal/K8sTerminal.test.tsx Co-authored-by: Jamie Klassen <jklassen@vmware.com> Signed-off-by: Carlos Esteban Lopez Jaramillo <luchillo17@gmail.com> refactor: Rename stuff as requested in PR Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> chore: Update API Reports Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com> test: Fix mocked hook that was renamed Signed-off-by: Carlos Esteban Lopez <lcarlosesteb@vmware.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes': patch
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Add pod exec terminal to Container Card
|
||||
@@ -19,3 +19,9 @@
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// Do not remove, patching jsdom environment to support TextEncoder, refer to https://github.com/jsdom/jsdom/issues/2524
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { TextEncoder } from 'util';
|
||||
|
||||
global.TextEncoder = TextEncoder;
|
||||
|
||||
@@ -255,7 +255,8 @@ export const textContentMatcher =
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasText = (textNode: Element) => textNode?.textContent === text;
|
||||
const hasText = (textNode: Element) =>
|
||||
textNode?.textContent?.includes(text) ?? false;
|
||||
const childrenDontHaveText = (containerNode: Element) =>
|
||||
Array.from(containerNode?.children).every(child => !hasText(child));
|
||||
|
||||
|
||||
@@ -347,6 +347,18 @@ export const JobsAccordions: ({
|
||||
jobs,
|
||||
}: JobsAccordionsProps) => React_2.JSX.Element;
|
||||
|
||||
// @public
|
||||
export interface K8sTerminalProps {
|
||||
// (undocumented)
|
||||
clusterName: string;
|
||||
// (undocumented)
|
||||
containerName: string;
|
||||
// (undocumented)
|
||||
podName: string;
|
||||
// (undocumented)
|
||||
podNamespace: string;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "KubernetesApi" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
@@ -631,6 +643,14 @@ export const PodDrawer: ({
|
||||
open,
|
||||
}: PodDrawerProps) => React_2.JSX.Element;
|
||||
|
||||
// @public
|
||||
export const PodExecTerminal: (props: K8sTerminalProps) => React_2.JSX.Element;
|
||||
|
||||
// @public
|
||||
export const PodExecTerminalDialog: (
|
||||
props: K8sTerminalProps,
|
||||
) => React_2.JSX.Element;
|
||||
|
||||
// @public
|
||||
export const PodLogs: React_2.FC<PodLogsProps>;
|
||||
|
||||
|
||||
@@ -53,7 +53,10 @@
|
||||
"kubernetes-models": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.0.0",
|
||||
"react-use": "^17.2.4"
|
||||
"react-use": "^17.2.4",
|
||||
"xterm": "^5.2.1",
|
||||
"xterm-addon-attach": "^0.8.0",
|
||||
"xterm-addon-fit": "^0.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0",
|
||||
@@ -70,6 +73,8 @@
|
||||
"@testing-library/react": "^12.1.3",
|
||||
"@testing-library/react-hooks": "^8.0.0",
|
||||
"@testing-library/user-event": "^14.0.0",
|
||||
"@types/node": "^16.11.26",
|
||||
"jest-websocket-mock": "^2.4.1",
|
||||
"msw": "^1.0.0"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 './matchMedia.mock';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
renderWithEffects,
|
||||
TestApiProvider,
|
||||
textContentMatcher,
|
||||
wrapInTestApp,
|
||||
} from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import WS from 'jest-websocket-mock';
|
||||
import React from 'react';
|
||||
|
||||
import { PodExecTerminal } from './PodExecTerminal';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
describe('K8sTerminal', () => {
|
||||
const clusterName = 'cluster1';
|
||||
const containerName = 'container2';
|
||||
const podName = 'pod1';
|
||||
const podNamespace = 'podNamespace';
|
||||
|
||||
const mockDiscoveryApi: Partial<DiscoveryApi> = {
|
||||
getBaseUrl: () => Promise.resolve('http://localhost'),
|
||||
};
|
||||
|
||||
it('Should render an XTerm web terminal', async () => {
|
||||
const { getByText } = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
|
||||
<PodExecTerminal
|
||||
clusterName={clusterName}
|
||||
containerName={containerName}
|
||||
podName={podName}
|
||||
podNamespace={podNamespace}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
getByText(textContentMatcher('Starting terminal, please wait...')),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Should connect to WebSocket server & render response', async () => {
|
||||
const server = new WS(
|
||||
'ws://localhost/proxy/api/v1/namespaces/podNamespace/pods/pod1/exec?container=container2&stdin=true&stdout=true&stderr=true&tty=true&command=%2Fbin%2Fsh',
|
||||
);
|
||||
|
||||
const { getByText } = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
|
||||
<PodExecTerminal
|
||||
clusterName={clusterName}
|
||||
containerName={containerName}
|
||||
podName={podName}
|
||||
podNamespace={podNamespace}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// xterm uses a "W" character as a "measure element" -- if it exists, the terminal rendered correctly
|
||||
expect(getByText(textContentMatcher('W'))).toBeInTheDocument();
|
||||
|
||||
await server.connected;
|
||||
|
||||
const { buffer } = Uint8Array.from([
|
||||
1,
|
||||
...textEncoder.encode('hello world'),
|
||||
]);
|
||||
|
||||
server.send(buffer);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByText(textContentMatcher('hello world'))).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 'xterm/css/xterm.css';
|
||||
|
||||
import { discoveryApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
import { PodExecTerminalAttachAddon } from './PodExecTerminalAttachAddon';
|
||||
|
||||
/**
|
||||
* Props drilled down to the K8sTerminal component
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface K8sTerminalProps {
|
||||
clusterName: string;
|
||||
containerName: string;
|
||||
podName: string;
|
||||
podNamespace: string;
|
||||
}
|
||||
|
||||
const hasSocketProtocol = (url: string | URL) =>
|
||||
/wss?:\/\//.test(url.toString());
|
||||
|
||||
/**
|
||||
* Opens a Terminal shell to the given pod's container in a Dialog
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PodExecTerminal = (props: K8sTerminalProps) => {
|
||||
const { containerName, podNamespace, podName } = props;
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState(window.location.host);
|
||||
|
||||
const terminalRef = React.useRef(null);
|
||||
const discoveryApi = useApi(discoveryApiRef);
|
||||
const namespace = podNamespace ?? 'default';
|
||||
|
||||
useEffect(() => {
|
||||
discoveryApi
|
||||
.getBaseUrl('kubernetes')
|
||||
.then(url => url ?? window.location.host)
|
||||
.then(url => url.replace(/^http(s?):\/\//, 'ws$1://'))
|
||||
.then(url => setBaseUrl(url));
|
||||
}, [discoveryApi]);
|
||||
|
||||
const urlParams = useMemo(() => {
|
||||
const params = new URLSearchParams({
|
||||
container: containerName,
|
||||
stdin: 'true',
|
||||
stdout: 'true',
|
||||
stderr: 'true',
|
||||
tty: 'true',
|
||||
command: '/bin/sh',
|
||||
});
|
||||
return params;
|
||||
}, [containerName]);
|
||||
|
||||
const socketUrl = useMemo(() => {
|
||||
if (!hasSocketProtocol(baseUrl)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new URL(
|
||||
`${baseUrl}/proxy/api/v1/namespaces/${namespace}/pods/${podName}/exec?${urlParams}`,
|
||||
);
|
||||
}, [baseUrl, namespace, podName, urlParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSocketProtocol(socketUrl)) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const terminal = new Terminal();
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
if (terminalRef.current) {
|
||||
terminal.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
}
|
||||
|
||||
terminal.writeln('Starting terminal, please wait...');
|
||||
|
||||
const socket = new WebSocket(socketUrl, ['channel.k8s.io']);
|
||||
|
||||
socket.onopen = () => {
|
||||
terminal.clear();
|
||||
const attachAddon = new PodExecTerminalAttachAddon(socket, {
|
||||
bidirectional: true,
|
||||
});
|
||||
|
||||
terminal.loadAddon(attachAddon);
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
terminal.writeln('Socket connection closed');
|
||||
};
|
||||
|
||||
return () => {
|
||||
terminal?.clear();
|
||||
socket?.close();
|
||||
};
|
||||
}, [baseUrl, socketUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="terminal"
|
||||
ref={terminalRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { AttachAddon, IAttachOptions } from 'xterm-addon-attach';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export class PodExecTerminalAttachAddon extends AttachAddon {
|
||||
constructor(socket: WebSocket, options?: IAttachOptions) {
|
||||
super(socket, options);
|
||||
|
||||
const thisAddon = this as any;
|
||||
|
||||
// These methods are private in the original AttachAddon,
|
||||
// thus have to override at runtime like this
|
||||
thisAddon._sendBinary = (data: string) => {
|
||||
if (!thisAddon._checkOpenSocket()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const buffer = Uint8Array.from([0, ...textEncoder.encode(data)]);
|
||||
|
||||
thisAddon._socket.send(buffer);
|
||||
};
|
||||
|
||||
thisAddon._sendData = (data: string) => {
|
||||
if (!thisAddon._checkOpenSocket()) {
|
||||
return;
|
||||
}
|
||||
|
||||
thisAddon._sendBinary(data);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 {
|
||||
Button,
|
||||
createStyles,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
Theme,
|
||||
} from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import OpenInBrowserIcon from '@material-ui/icons/OpenInBrowser';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { useIsPodExecTerminalSupported } from '../../hooks';
|
||||
import { PodExecTerminal, K8sTerminalProps } from './PodExecTerminal';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
dialogPaper: { minHeight: 'calc(100% - 64px)' },
|
||||
dialogContent: { flexBasis: 0 },
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: theme.spacing(1),
|
||||
top: theme.spacing(1),
|
||||
color: theme.palette.grey[500],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens a Terminal shell to the given pod's container in a Dialog
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const PodExecTerminalDialog = (props: K8sTerminalProps) => {
|
||||
const classes = useStyles();
|
||||
const { clusterName, containerName, podName } = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const isPodExecTerminalSupported = useIsPodExecTerminalSupported();
|
||||
|
||||
const openDialog = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isPodExecTerminalSupported.loading &&
|
||||
isPodExecTerminalSupported.value && (
|
||||
<Dialog
|
||||
maxWidth={false}
|
||||
fullWidth
|
||||
open={open}
|
||||
onClose={closeDialog}
|
||||
PaperProps={{ className: classes.dialogPaper }}
|
||||
>
|
||||
<DialogTitle id="dialog-title">
|
||||
{podName} - {containerName} terminal shell on cluster{' '}
|
||||
{clusterName}
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
className={classes.closeButton}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent className={classes.dialogContent}>
|
||||
<PodExecTerminal {...props} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
aria-label="open terminal"
|
||||
component="label"
|
||||
disabled={
|
||||
isPodExecTerminalSupported.loading ||
|
||||
!isPodExecTerminalSupported.value
|
||||
}
|
||||
onClick={openDialog}
|
||||
startIcon={<OpenInBrowserIcon />}
|
||||
>
|
||||
Terminal
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export * from './PodExecTerminal';
|
||||
export * from './PodExecTerminalDialog';
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // deprecated
|
||||
removeListener: jest.fn(), // deprecated
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
export {};
|
||||
@@ -22,6 +22,8 @@ import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ContainerCard } from './ContainerCard';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
jest.mock('../../../hooks/useIsPodExecTerminalSupported');
|
||||
|
||||
const now = DateTime.now();
|
||||
const oneHourAgo = now.minus({ hours: 1 }).toISO();
|
||||
const twoHoursAgo = now.minus({ hours: 2 }).toISO();
|
||||
|
||||
@@ -13,9 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
import { IContainer, IContainerStatus } from 'kubernetes-models/v1';
|
||||
import { StructuredMetadataTable } from '@backstage/core-components';
|
||||
import { ClientContainerStatus } from '@backstage/plugin-kubernetes-common';
|
||||
import {
|
||||
Card,
|
||||
CardActions,
|
||||
@@ -24,14 +23,14 @@ import {
|
||||
Grid,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
|
||||
import { IContainer, IContainerStatus } from 'kubernetes-models/v1';
|
||||
import { DateTime } from 'luxon';
|
||||
import React from 'react';
|
||||
|
||||
import { PodScope, PodLogsDialog } from '../PodLogs';
|
||||
import { StructuredMetadataTable } from '@backstage/core-components';
|
||||
import { ClientContainerStatus } from '@backstage/plugin-kubernetes-common';
|
||||
import { ResourceUtilization } from '../../ResourceUtilization';
|
||||
import { bytesToMiB, formatMillicores } from '../../../utils/resources';
|
||||
import { PodExecTerminalDialog } from '../../PodExecTerminal/PodExecTerminalDialog';
|
||||
import { ResourceUtilization } from '../../ResourceUtilization';
|
||||
import { PodLogsDialog, PodScope } from '../PodLogs';
|
||||
|
||||
const getContainerHealthChecks = (
|
||||
containerSpec: IContainer,
|
||||
@@ -222,13 +221,19 @@ export const ContainerCard: React.FC<ContainerCardProps> = ({
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
<CardActions disableSpacing>
|
||||
<CardActions>
|
||||
<PodLogsDialog
|
||||
containerScope={{
|
||||
containerName: containerStatus.name,
|
||||
...podScope,
|
||||
}}
|
||||
/>
|
||||
<PodExecTerminalDialog
|
||||
clusterName={podScope.clusterName}
|
||||
containerName={containerStatus.name}
|
||||
podName={podScope.podName}
|
||||
podNamespace={podScope.podNamespace}
|
||||
/>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+58
-49
@@ -17,66 +17,75 @@
|
||||
import React from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
import { PodDrawer } from '.';
|
||||
import { DiscoveryApi, discoveryApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
jest.mock('../../../hooks/useIsPodExecTerminalSupported');
|
||||
|
||||
describe('PodDrawer', () => {
|
||||
it('Should show title and container names', async () => {
|
||||
const mockDiscoveryApi: Partial<DiscoveryApi> = {
|
||||
getBaseUrl: () => Promise.resolve('http://localhost'),
|
||||
};
|
||||
|
||||
const { getAllByText, getByText } = render(
|
||||
wrapInTestApp(
|
||||
<PodDrawer
|
||||
{...({
|
||||
open: true,
|
||||
podAndErrors: {
|
||||
clusterName: 'some-cluster-1',
|
||||
pod: {
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'some-container',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
podIP: '127.0.0.1',
|
||||
containerStatuses: [
|
||||
{
|
||||
name: 'some-container',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
type: 'some-error',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occurrenceCount: 1,
|
||||
sourceRef: {
|
||||
<TestApiProvider apis={[[discoveryApiRef, mockDiscoveryApi]]}>
|
||||
<PodDrawer
|
||||
{...({
|
||||
open: true,
|
||||
podAndErrors: {
|
||||
clusterName: 'some-cluster-1',
|
||||
pod: {
|
||||
metadata: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'Pod',
|
||||
apiGroup: 'v1',
|
||||
},
|
||||
proposedFix: [
|
||||
{
|
||||
type: 'logs',
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
],
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'some-container',
|
||||
},
|
||||
],
|
||||
},
|
||||
status: {
|
||||
podIP: '127.0.0.1',
|
||||
containerStatuses: [
|
||||
{
|
||||
name: 'some-container',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)}
|
||||
/>,
|
||||
errors: [
|
||||
{
|
||||
type: 'some-error',
|
||||
severity: 10,
|
||||
message: 'some error message',
|
||||
occurrenceCount: 1,
|
||||
sourceRef: {
|
||||
name: 'some-pod',
|
||||
namespace: 'some-namespace',
|
||||
kind: 'Pod',
|
||||
apiGroup: 'v1',
|
||||
},
|
||||
proposedFix: [
|
||||
{
|
||||
type: 'logs',
|
||||
container: 'some-container',
|
||||
errorType: 'some error type',
|
||||
rootCauseExplanation: 'some root cause',
|
||||
actions: ['fix1', 'fix2'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -27,3 +27,4 @@ export * from './Pods';
|
||||
export * from './ServicesAccordions';
|
||||
export * from './KubernetesContent';
|
||||
export * from './ResourceUtilization';
|
||||
export * from './PodExecTerminal';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { AsyncState } from 'react-use/lib/useAsync';
|
||||
|
||||
export const useIsPodExecTerminalSupported: () => AsyncState<boolean> = jest.fn(
|
||||
() => ({ loading: false, value: false } as AsyncState<boolean>),
|
||||
);
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './useIsPodExecTerminalSupported';
|
||||
export * from './useKubernetesObjects';
|
||||
export * from './useCustomResources';
|
||||
export * from './PodNamesWithErrors';
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import { useIsPodExecTerminalSupported } from './useIsPodExecTerminalSupported';
|
||||
|
||||
jest.mock('@backstage/core-plugin-api');
|
||||
|
||||
describe('useIsClusterShellEnabled', () => {
|
||||
let clusters: { authProvider: string }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
(useApi as any).mockReturnValue({
|
||||
getClusters: () => Promise.resolve(clusters),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
condition: 'valid Shell setup',
|
||||
returnValue: true,
|
||||
testClusters: [{ authProvider: 'localKubectlProxy' }],
|
||||
},
|
||||
{ condition: 'clusters empty', returnValue: false, testClusters: [] },
|
||||
{
|
||||
condition: 'multi cluster setup',
|
||||
returnValue: false,
|
||||
testClusters: [
|
||||
{ authProvider: 'localKubectlProxy' },
|
||||
{ authProvider: 'serviceAccount' },
|
||||
],
|
||||
},
|
||||
{
|
||||
condition: 'AuthProvider is aks',
|
||||
returnValue: false,
|
||||
testClusters: [{ authProvider: 'aks' }],
|
||||
},
|
||||
{
|
||||
condition: 'AuthProvider is google',
|
||||
returnValue: false,
|
||||
testClusters: [{ authProvider: 'google' }],
|
||||
},
|
||||
{
|
||||
condition: 'AuthProvider start with oidc',
|
||||
returnValue: false,
|
||||
testClusters: [{ authProvider: 'oidc.okta' }],
|
||||
},
|
||||
])(
|
||||
'Should return $returnValue if $condition',
|
||||
async ({ testClusters, returnValue }) => {
|
||||
clusters = testClusters;
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useIsPodExecTerminalSupported(),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.value).toBe(returnValue);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* 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 { useApi } from '@backstage/core-plugin-api';
|
||||
import useAsync, { AsyncState } from 'react-use/lib/useAsync';
|
||||
|
||||
import { kubernetesApiRef } from '../api';
|
||||
|
||||
/**
|
||||
* Check if conditions for K8sTerminal through K8sProxy are met
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export const useIsPodExecTerminalSupported = (): AsyncState<boolean> => {
|
||||
const kubernetesApi = useApi(kubernetesApiRef);
|
||||
|
||||
return useAsync(async () => {
|
||||
const clusters = await kubernetesApi.getClusters();
|
||||
|
||||
if (clusters.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { authProvider } = clusters[0];
|
||||
const isClientAuthProvider = ['aks', 'google', 'oidc'].some(
|
||||
authProviderName => authProvider.includes(authProviderName),
|
||||
);
|
||||
|
||||
return !isClientAuthProvider;
|
||||
});
|
||||
};
|
||||
@@ -7653,12 +7653,16 @@ __metadata:
|
||||
"@testing-library/user-event": ^14.0.0
|
||||
"@types/react": ^16.13.1 || ^17.0.0
|
||||
cronstrue: ^2.2.0
|
||||
jest-websocket-mock: ^2.4.1
|
||||
js-yaml: ^4.0.0
|
||||
kubernetes-models: ^4.1.0
|
||||
lodash: ^4.17.21
|
||||
luxon: ^3.0.0
|
||||
msw: ^1.0.0
|
||||
react-use: ^17.2.4
|
||||
xterm: ^5.2.1
|
||||
xterm-addon-attach: ^0.8.0
|
||||
xterm-addon-fit: ^0.7.0
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0
|
||||
@@ -12564,12 +12568,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@jest/schemas@npm:^29.4.3":
|
||||
version: 29.4.3
|
||||
resolution: "@jest/schemas@npm:29.4.3"
|
||||
"@jest/schemas@npm:^29.4.3, @jest/schemas@npm:^29.6.3":
|
||||
version: 29.6.3
|
||||
resolution: "@jest/schemas@npm:29.6.3"
|
||||
dependencies:
|
||||
"@sinclair/typebox": ^0.25.16
|
||||
checksum: ac754e245c19dc39e10ebd41dce09040214c96a4cd8efa143b82148e383e45128f24599195ab4f01433adae4ccfbe2db6574c90db2862ccd8551a86704b5bebd
|
||||
"@sinclair/typebox": ^0.27.8
|
||||
checksum: 910040425f0fc93cd13e68c750b7885590b8839066dfa0cd78e7def07bbb708ad869381f725945d66f2284de5663bbecf63e8fdd856e2ae6e261ba30b1687e93
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15035,10 +15039,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sinclair/typebox@npm:^0.25.16":
|
||||
version: 0.25.23
|
||||
resolution: "@sinclair/typebox@npm:0.25.23"
|
||||
checksum: 5720daec6e604be9ac849e6361cfa30d19f4d01934c9b79a3a5f5290dfcefaa300192ea0d384bb5dd0104432d88447bbad27adfacdf0b0f042b510bf15fbd5db
|
||||
"@sinclair/typebox@npm:^0.27.8":
|
||||
version: 0.27.8
|
||||
resolution: "@sinclair/typebox@npm:0.27.8"
|
||||
checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -23829,10 +23833,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"diff-sequences@npm:^29.4.3":
|
||||
version: 29.4.3
|
||||
resolution: "diff-sequences@npm:29.4.3"
|
||||
checksum: 28b265e04fdddcf7f9f814effe102cc95a9dec0564a579b5aed140edb24fc345c611ca52d76d725a3cab55d3888b915b5e8a4702e0f6058968a90fa5f41fcde7
|
||||
"diff-sequences@npm:^29.6.3":
|
||||
version: 29.6.3
|
||||
resolution: "diff-sequences@npm:29.6.3"
|
||||
checksum: f4914158e1f2276343d98ff5b31fc004e7304f5470bf0f1adb2ac6955d85a531a6458d33e87667f98f6ae52ebd3891bb47d420bb48a5bd8b7a27ee25b20e33aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29850,15 +29854,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jest-diff@npm:^29.4.3":
|
||||
version: 29.4.3
|
||||
resolution: "jest-diff@npm:29.4.3"
|
||||
"jest-diff@npm:^29.2.0, jest-diff@npm:^29.4.3":
|
||||
version: 29.6.3
|
||||
resolution: "jest-diff@npm:29.6.3"
|
||||
dependencies:
|
||||
chalk: ^4.0.0
|
||||
diff-sequences: ^29.4.3
|
||||
jest-get-type: ^29.4.3
|
||||
pretty-format: ^29.4.3
|
||||
checksum: 877fd1edffef6b319688c27b152e5b28e2bc4bcda5ce0ca90d7e137f9fafda4280bae25403d4c0bfd9806c2c0b15d966aa2dfaf5f9928ec8f1ccea7fa1d08ed6
|
||||
diff-sequences: ^29.6.3
|
||||
jest-get-type: ^29.6.3
|
||||
pretty-format: ^29.6.3
|
||||
checksum: 23b0a88efeab36566386f059f3da340754d2860969cbc34805154e2377714e37e3130e21a791fc68008fb460bbf5edd7ec43c16d96d15797b32ccfae5160fe37
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -29926,10 +29930,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jest-get-type@npm:^29.4.3":
|
||||
version: 29.4.3
|
||||
resolution: "jest-get-type@npm:29.4.3"
|
||||
checksum: 6ac7f2dde1c65e292e4355b6c63b3a4897d7e92cb4c8afcf6d397f2682f8080e094c8b0b68205a74d269882ec06bf696a9de6cd3e1b7333531e5ed7b112605ce
|
||||
"jest-get-type@npm:^29.4.3, jest-get-type@npm:^29.6.3":
|
||||
version: 29.6.3
|
||||
resolution: "jest-get-type@npm:29.6.3"
|
||||
checksum: 88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -30230,6 +30234,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jest-websocket-mock@npm:^2.4.1":
|
||||
version: 2.4.1
|
||||
resolution: "jest-websocket-mock@npm:2.4.1"
|
||||
dependencies:
|
||||
jest-diff: ^29.2.0
|
||||
mock-socket: ^9.1.0
|
||||
checksum: 5091bfc1baef36146ea1d0f7e16c591d8f8cd9604b7daaeb3d61d98cb1034f4ea3825c122477eb15d55f7d7fe6ec93a8e7b58d54f39838238d7345675d0afb37
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jest-when@npm:^3.1.0":
|
||||
version: 3.6.0
|
||||
resolution: "jest-when@npm:3.6.0"
|
||||
@@ -33417,6 +33431,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mock-socket@npm:^9.1.0":
|
||||
version: 9.2.1
|
||||
resolution: "mock-socket@npm:9.2.1"
|
||||
checksum: daf07689563163dbcefbefe23b2a9784a75d0af31706f23ad535c6ab2abbcdefa2e91acddeb50a3c39009139e47a8f909cbb38e8137452193ccb9331637fee3e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"moment@npm:^2.29.1":
|
||||
version: 2.29.4
|
||||
resolution: "moment@npm:2.29.4"
|
||||
@@ -36308,14 +36329,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.4.3":
|
||||
version: 29.4.3
|
||||
resolution: "pretty-format@npm:29.4.3"
|
||||
"pretty-format@npm:^29.0.0, pretty-format@npm:^29.4.3, pretty-format@npm:^29.6.3":
|
||||
version: 29.6.3
|
||||
resolution: "pretty-format@npm:29.6.3"
|
||||
dependencies:
|
||||
"@jest/schemas": ^29.4.3
|
||||
"@jest/schemas": ^29.6.3
|
||||
ansi-styles: ^5.0.0
|
||||
react-is: ^18.0.0
|
||||
checksum: 3258b9a010bd79b3cf73783ad1e4592b6326fc981b6e31b742f316f14e7fbac09b48a9dbf274d092d9bde404db9fe16f518370e121837dc078a597392e6e5cc5
|
||||
checksum: 4e1c0db48e65571c22e80ff92123925ff8b3a2a89b71c3a1683cfde711004d492de32fe60c6bc10eea8bf6c678e5cbe544ac6c56cb8096e1eb7caf856928b1c4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -43360,6 +43381,31 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xterm-addon-attach@npm:^0.8.0":
|
||||
version: 0.8.0
|
||||
resolution: "xterm-addon-attach@npm:0.8.0"
|
||||
peerDependencies:
|
||||
xterm: ^5.0.0
|
||||
checksum: 2ac70bdd1c97eb05b83b4573132ed9d3686c07ed14212e273800a5693bb0f3ea41c570b49ac90cb95a1e593d5a2764a28c13b649c596054bf5aa87c8489069eb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xterm-addon-fit@npm:^0.7.0":
|
||||
version: 0.7.0
|
||||
resolution: "xterm-addon-fit@npm:0.7.0"
|
||||
peerDependencies:
|
||||
xterm: ^5.0.0
|
||||
checksum: 512d41f80d6f9427ba02dab4e6fd642e94775a9cf7ef72ae4b55eab2a36856b5c67069bfc66b4af412fdce29a0842f9c6382af3672f0b514c4352dfd47defe8f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"xterm@npm:^5.2.1":
|
||||
version: 5.2.1
|
||||
resolution: "xterm@npm:5.2.1"
|
||||
checksum: 3a9de30e772c7ae30895ec97fcfb3b0906429c5ea0cddf8948e8e30301385f82e467c6e6aca28ae50a48300ce795381d83fe35b4e17886ab4a1357054a15f68f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"y18n@npm:^4.0.0":
|
||||
version: 4.0.3
|
||||
resolution: "y18n@npm:4.0.3"
|
||||
|
||||
Reference in New Issue
Block a user