Update onboarding interstitial to handle default Fleet assets (#108193)

This commit is contained in:
Josh Dover 2021-08-17 21:47:03 +02:00 committed by GitHub
parent 468daebe76
commit 66a06f97df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 380 additions and 19 deletions

View file

@ -4,6 +4,7 @@ exports[`should render a Welcome screen with no telemetry disclaimer 1`] = `
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
@ -65,6 +66,7 @@ exports[`should render a Welcome screen with the telemetry disclaimer 1`] = `
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
@ -163,6 +165,7 @@ exports[`should render a Welcome screen with the telemetry disclaimer when optIn
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"
@ -261,6 +264,7 @@ exports[`should render a Welcome screen with the telemetry disclaimer when optIn
<EuiPortal>
<div
className="homWelcome"
data-test-subj="homeWelcomeInterstitial"
>
<header
className="homWelcome__header"

View file

@ -70,15 +70,9 @@ export class Home extends Component {
}
}, 500);
const resp = await this.props.find({
type: 'index-pattern',
fields: ['title'],
search: `*`,
search_fields: ['title'],
perPage: 1,
});
const { isNewInstance } = await this.props.http.get('/internal/home/new_instance_status');
this.endLoading({ isNewKibanaInstance: resp.total === 0 });
this.endLoading({ isNewKibanaInstance: isNewInstance });
} catch (err) {
// An error here is relatively unimportant, as it only means we don't provide
// some UI niceties.

View file

@ -188,7 +188,9 @@ describe('home', () => {
defaultProps.localStorage.getItem = sinon.spy(() => 'true');
const component = await renderHome({
find: () => Promise.resolve({ total: 0 }),
http: {
get: () => Promise.resolve({ isNewInstance: true }),
},
});
sinon.assert.calledOnce(defaultProps.localStorage.getItem);

View file

@ -33,6 +33,7 @@ export function HomeApp({ directories, solutions }) {
addBasePath,
environmentService,
telemetry,
http,
} = getServices();
const environment = environmentService.getEnvironment();
const isCloudEnabled = environment.cloud;
@ -71,10 +72,10 @@ export function HomeApp({ directories, solutions }) {
addBasePath={addBasePath}
directories={directories}
solutions={solutions}
find={savedObjectsClient.find}
localStorage={localStorage}
urlBasePath={getBasePath()}
telemetry={telemetry}
http={http}
/>
</Route>
<Route path="*" exact={true} component={RedirectToDefaultApp} />

View file

@ -119,7 +119,7 @@ export class Welcome extends React.Component<Props> {
const { urlBasePath, telemetry } = this.props;
return (
<EuiPortal>
<div className="homWelcome">
<div className="homWelcome" data-test-subj="homeWelcomeInterstitial">
<header className="homWelcome__header">
<div className="homWelcome__content eui-textCenter">
<EuiSpacer size="xl" />

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { IRouter } from 'src/core/server';
import { isNewInstance } from '../services/new_instance_status';
export const registerNewInstanceStatusRoute = (router: IRouter) => {
router.get(
{
path: '/internal/home/new_instance_status',
validate: false,
},
router.handleLegacyErrors(async (context, req, res) => {
const { client: soClient } = context.core.savedObjects;
const { client: esClient } = context.core.elasticsearch;
try {
return res.ok({
body: {
isNewInstance: await isNewInstance({ esClient, soClient }),
},
});
} catch (e) {
return res.customError({
statusCode: 500,
});
}
})
);
};

View file

@ -8,7 +8,9 @@
import { IRouter } from 'src/core/server';
import { registerHitsStatusRoute } from './fetch_es_hits_status';
import { registerNewInstanceStatusRoute } from './fetch_new_instance_status';
export const registerRoutes = (router: IRouter) => {
registerHitsStatusRoute(router);
registerNewInstanceStatusRoute(router);
};

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isNewInstance } from './new_instance_status';
import { elasticsearchServiceMock, savedObjectsClientMock } from '../../../../core/server/mocks';
describe('isNewInstance', () => {
const esClient = elasticsearchServiceMock.createScopedClusterClient();
const soClient = savedObjectsClientMock.create();
beforeEach(() => jest.resetAllMocks());
it('returns true when there are no index patterns', async () => {
soClient.find.mockResolvedValue({
page: 1,
per_page: 100,
total: 0,
saved_objects: [],
});
expect(await isNewInstance({ esClient, soClient })).toEqual(true);
});
it('returns false when there are any index patterns other than metrics-* or logs-*', async () => {
soClient.find.mockResolvedValue({
page: 1,
per_page: 100,
total: 1,
saved_objects: [
{
id: '1',
references: [],
type: 'index-pattern',
score: 99,
attributes: { title: 'my-pattern-*' },
},
],
});
expect(await isNewInstance({ esClient, soClient })).toEqual(false);
});
describe('when only metrics-* and logs-* index patterns exist', () => {
beforeEach(() => {
soClient.find.mockResolvedValue({
page: 1,
per_page: 100,
total: 2,
saved_objects: [
{
id: '1',
references: [],
type: 'index-pattern',
score: 99,
attributes: { title: 'metrics-*' },
},
{
id: '2',
references: [],
type: 'index-pattern',
score: 99,
attributes: { title: 'logs-*' },
},
],
});
});
it('calls /_cat/indices for the index patterns', async () => {
await isNewInstance({ esClient, soClient });
expect(esClient.asCurrentUser.cat.indices).toHaveBeenCalledWith({
index: 'logs-*,metrics-*',
format: 'json',
});
});
it('returns true if no logs or metrics indices exist', async () => {
esClient.asCurrentUser.cat.indices.mockReturnValue(
elasticsearchServiceMock.createSuccessTransportRequestPromise([])
);
expect(await isNewInstance({ esClient, soClient })).toEqual(true);
});
it('returns true if no logs or metrics indices contain data', async () => {
esClient.asCurrentUser.cat.indices.mockReturnValue(
elasticsearchServiceMock.createSuccessTransportRequestPromise([
{ index: '.ds-metrics-foo', 'docs.count': '0' },
])
);
expect(await isNewInstance({ esClient, soClient })).toEqual(true);
});
it('returns true if only metrics-elastic_agent index contains data', async () => {
esClient.asCurrentUser.cat.indices.mockReturnValue(
elasticsearchServiceMock.createSuccessTransportRequestPromise([
{ index: '.ds-metrics-elastic_agent', 'docs.count': '100' },
])
);
expect(await isNewInstance({ esClient, soClient })).toEqual(true);
});
it('returns true if only logs-elastic_agent index contains data', async () => {
esClient.asCurrentUser.cat.indices.mockReturnValue(
elasticsearchServiceMock.createSuccessTransportRequestPromise([
{ index: '.ds-logs-elastic_agent', 'docs.count': '100' },
])
);
expect(await isNewInstance({ esClient, soClient })).toEqual(true);
});
it('returns false if any other logs or metrics indices contain data', async () => {
esClient.asCurrentUser.cat.indices.mockReturnValue(
elasticsearchServiceMock.createSuccessTransportRequestPromise([
{ index: '.ds-metrics-foo', 'docs.count': '100' },
])
);
expect(await isNewInstance({ esClient, soClient })).toEqual(false);
});
it('returns false if an authentication error is thrown', async () => {
esClient.asCurrentUser.cat.indices.mockReturnValue(
elasticsearchServiceMock.createErrorTransportRequestPromise({})
);
expect(await isNewInstance({ esClient, soClient })).toEqual(false);
});
});
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { IScopedClusterClient, SavedObjectsClientContract } from '../../../../core/server';
import type { IndexPatternSavedObjectAttrs } from '../../../data/common/index_patterns/index_patterns';
const LOGS_INDEX_PATTERN = 'logs-*';
const METRICS_INDEX_PATTERN = 'metrics-*';
const INDEX_PREFIXES_TO_IGNORE = [
'.ds-metrics-elastic_agent', // ignore index created by Fleet server itself
'.ds-logs-elastic_agent', // ignore index created by Fleet server itself
];
interface Deps {
esClient: IScopedClusterClient;
soClient: SavedObjectsClientContract;
}
export const isNewInstance = async ({ esClient, soClient }: Deps): Promise<boolean> => {
const indexPatterns = await soClient.find<IndexPatternSavedObjectAttrs>({
type: 'index-pattern',
fields: ['title'],
search: `*`,
searchFields: ['title'],
perPage: 100,
});
// If there are no index patterns, assume this is a new instance
if (indexPatterns.total === 0) {
return true;
}
// If there are any index patterns that are not the default metrics-* and logs-* ones created by Fleet, assume this
// is not a new instance
if (
indexPatterns.saved_objects.some(
(ip) =>
ip.attributes.title !== LOGS_INDEX_PATTERN && ip.attributes.title !== METRICS_INDEX_PATTERN
)
) {
return false;
}
try {
const logsAndMetricsIndices = await esClient.asCurrentUser.cat.indices({
index: `${LOGS_INDEX_PATTERN},${METRICS_INDEX_PATTERN}`,
format: 'json',
});
const anyIndicesContainerUserData = logsAndMetricsIndices.body
// Ignore some data that is shipped by default
.filter(({ index }) => !INDEX_PREFIXES_TO_IGNORE.some((prefix) => index?.startsWith(prefix)))
// If any other logs and metrics indices have data, return false
.some((catResult) => (catResult['docs.count'] ?? '0') !== '0');
return !anyIndicesContainerUserData;
} catch (e) {
// If any errors are encountered return false to be safe
return false;
}
};

View file

@ -41,7 +41,6 @@ export default function () {
)}`,
`--elasticsearch.username=${kibanaServerTestUser.username}`,
`--elasticsearch.password=${kibanaServerTestUser.password}`,
`--home.disableWelcomeScreen=true`,
// Needed for async search functional tests to introduce a delay
`--data.search.aggs.shardDelay.enabled=true`,
`--security.showInsecureClusterWarning=false`,

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'home']);
describe('Welcome interstitial', () => {
before(async () => {
// Need to navigate to page first to clear storage before test can be run
await PageObjects.common.navigateToUrl('home', undefined);
await browser.clearLocalStorage();
await esArchiver.emptyKibanaIndex();
});
it('is displayed on a fresh on-prem install', async () => {
await PageObjects.common.navigateToUrl('home', undefined, { disableWelcomePrompt: false });
expect(await PageObjects.home.isWelcomeInterstitialDisplayed()).to.be(true);
});
});
}

View file

@ -21,5 +21,6 @@ export default function ({ getService, loadTestFile }) {
loadTestFile(require.resolve('./_newsfeed'));
loadTestFile(require.resolve('./_add_data'));
loadTestFile(require.resolve('./_sample_data'));
loadTestFile(require.resolve('./_welcome'));
});
}

View file

@ -19,6 +19,7 @@ interface NavigateProps {
shouldLoginIfPrompted: boolean;
useActualUrl: boolean;
insertTimestamp: boolean;
disableWelcomePrompt: boolean;
}
export class CommonPageObject extends FtrService {
private readonly log = this.ctx.getService('log');
@ -37,11 +38,17 @@ export class CommonPageObject extends FtrService {
* Logins to Kibana as default user and navigates to provided app
* @param appUrl Kibana URL
*/
private async loginIfPrompted(appUrl: string, insertTimestamp: boolean) {
private async loginIfPrompted(
appUrl: string,
insertTimestamp: boolean,
disableWelcomePrompt: boolean
) {
// Disable the welcome screen. This is relevant for environments
// which don't allow to use the yml setting, e.g. cloud production.
// It is done here so it applies to logins but also to a login re-use.
await this.browser.setLocalStorageItem('home:welcome:show', 'false');
if (disableWelcomePrompt) {
await this.browser.setLocalStorageItem('home:welcome:show', 'false');
}
let currentUrl = await this.browser.getCurrentUrl();
this.log.debug(`currentUrl = ${currentUrl}\n appUrl = ${appUrl}`);
@ -76,6 +83,7 @@ export class CommonPageObject extends FtrService {
appConfig,
ensureCurrentUrl,
shouldLoginIfPrompted,
disableWelcomePrompt,
useActualUrl,
insertTimestamp,
} = navigateProps;
@ -95,7 +103,7 @@ export class CommonPageObject extends FtrService {
await alert?.accept();
const currentUrl = shouldLoginIfPrompted
? await this.loginIfPrompted(appUrl, insertTimestamp)
? await this.loginIfPrompted(appUrl, insertTimestamp, disableWelcomePrompt)
: await this.browser.getCurrentUrl();
if (ensureCurrentUrl && !currentUrl.includes(appUrl)) {
@ -117,6 +125,7 @@ export class CommonPageObject extends FtrService {
basePath = '',
ensureCurrentUrl = true,
shouldLoginIfPrompted = true,
disableWelcomePrompt = true,
useActualUrl = false,
insertTimestamp = true,
shouldUseHashForSubUrl = true,
@ -136,6 +145,7 @@ export class CommonPageObject extends FtrService {
appConfig,
ensureCurrentUrl,
shouldLoginIfPrompted,
disableWelcomePrompt,
useActualUrl,
insertTimestamp,
});
@ -156,6 +166,7 @@ export class CommonPageObject extends FtrService {
basePath = '',
ensureCurrentUrl = true,
shouldLoginIfPrompted = true,
disableWelcomePrompt = true,
useActualUrl = true,
insertTimestamp = true,
} = {}
@ -170,6 +181,7 @@ export class CommonPageObject extends FtrService {
appConfig,
ensureCurrentUrl,
shouldLoginIfPrompted,
disableWelcomePrompt,
useActualUrl,
insertTimestamp,
});
@ -202,7 +214,13 @@ export class CommonPageObject extends FtrService {
async navigateToApp(
appName: string,
{ basePath = '', shouldLoginIfPrompted = true, hash = '', insertTimestamp = true } = {}
{
basePath = '',
shouldLoginIfPrompted = true,
disableWelcomePrompt = true,
hash = '',
insertTimestamp = true,
} = {}
) {
let appUrl: string;
if (this.config.has(['apps', appName])) {
@ -233,7 +251,7 @@ export class CommonPageObject extends FtrService {
this.log.debug('returned from get, calling refresh');
await this.browser.refresh();
let currentUrl = shouldLoginIfPrompted
? await this.loginIfPrompted(appUrl, insertTimestamp)
? await this.loginIfPrompted(appUrl, insertTimestamp, disableWelcomePrompt)
: await this.browser.getCurrentUrl();
if (currentUrl.includes('app/kibana')) {

View file

@ -30,6 +30,10 @@ export class HomePageObject extends FtrService {
return !(await this.testSubjects.exists(`addSampleDataSet${id}`));
}
async isWelcomeInterstitialDisplayed() {
return await this.testSubjects.isDisplayed('homeWelcomeInterstitial');
}
async getVisibileSolutions() {
const solutionPanels = await this.testSubjects.findAll('~homSolutionPanel', 2000);
const panelAttributes = await Promise.all(

View file

@ -469,6 +469,15 @@ class BrowserService extends FtrService {
await this.driver.executeScript('return window.localStorage.removeItem(arguments[0]);', key);
}
/**
* Clears all values in local storage for the focused window/frame.
*
* @return {Promise<void>}
*/
public async clearLocalStorage(): Promise<void> {
await this.driver.executeScript('return window.localStorage.clear();');
}
/**
* Clears session storage for the focused window/frame.
*

View file

@ -153,7 +153,7 @@ export async function handleInstallPackageFailure({
try {
const installType = getInstallType({ pkgVersion, installedPkg });
if (installType === 'install' || installType === 'reinstall') {
logger.error(`uninstalling ${pkgkey} after error installing`);
logger.error(`uninstalling ${pkgkey} after error installing: [${error.toString()}]`);
await removeInstallation({ savedObjectsClient, pkgkey, esClient });
}
@ -271,6 +271,7 @@ async function installPackageFromRegistry({
return { assets, status: 'installed', installType };
})
.catch(async (err: Error) => {
logger.warn(`Failure to install package [${pkgName}]: [${err.toString()}]`);
await handleInstallPackageFailure({
savedObjectsClient,
error: err,

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function (providerContext: FtrProviderContext) {
const { loadTestFile } = providerContext;
describe('home onboarding', function () {
this.tags('ciGroup7');
loadTestFile(require.resolve('./welcome'));
});
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const browser = getService('browser');
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'home']);
const kibanaServer = getService('kibanaServer');
describe('Welcome interstitial', () => {
before(async () => {
// Need to navigate to page first to clear storage before test can be run
await PageObjects.common.navigateToUrl('home', undefined);
await browser.clearLocalStorage();
await esArchiver.emptyKibanaIndex();
});
/**
* When we run this against a Cloud cluster, we also test the case where Fleet server is running
* and ingesting elastic_agent data.
*/
it('is displayed on a fresh install with Fleet setup executed', async () => {
// Setup Fleet and verify the metrics index pattern was created
await kibanaServer.request({ path: '/api/fleet/setup', method: 'POST' });
const metricsIndexPattern = await kibanaServer.savedObjects.get({
type: 'index-pattern',
id: 'metrics-*',
});
expect(metricsIndexPattern?.attributes.title).to.eql('metrics-*');
// Reload the home screen and verify the interstitial is displayed
await PageObjects.common.navigateToUrl('home', undefined, { disableWelcomePrompt: false });
expect(await PageObjects.home.isWelcomeInterstitialDisplayed()).to.be(true);
});
// Pending tests we should add once the FTR supports Elastic Agent / Fleet Server
it('is still displayed after a Fleet server is enrolled with agent metrics');
it('is not displayed after an agent is enrolled with system metrics');
it('is not displayed after a standalone agent is enrolled with system metrics');
});
}

View file

@ -16,7 +16,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
...xpackFunctionalConfig.getAll(),
pageObjects,
testFiles: [resolve(__dirname, './apps/fleet')],
testFiles: [resolve(__dirname, './apps/fleet'), resolve(__dirname, './apps/home')],
junit: {
reportName: 'X-Pack Fleet Functional Tests',
},