[APM] Create plugin for logs onboarding (#154728)

Closes #154733

Creates a new plugin for logs onboarding with wizard to organize steps
into discrete views.

#### TODO:
- [x] rename plugin to observability_onboarding
- [x] configure: UI and server plugin
- [x] enable/disable new plugin
- [x] remove the link to it from Observability nav

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Yngrid Coello <yngrid.coello@elastic.co>
Co-authored-by: Yngrid Coello <yngrdyn@gmail.com>
This commit is contained in:
Oliver Gupte 2023-04-25 11:14:21 -04:00 committed by GitHub
parent 4dda8cf995
commit 077245606b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 2280 additions and 0 deletions

1
.github/CODEOWNERS vendored
View file

@ -479,6 +479,7 @@ x-pack/plugins/notifications @elastic/appex-sharedux
packages/kbn-object-versioning @elastic/appex-sharedux
x-pack/packages/observability/alert_details @elastic/actionable-observability
x-pack/test/cases_api_integration/common/plugins/observability @elastic/response-ops
x-pack/plugins/observability_onboarding @elastic/apm-ui
x-pack/plugins/observability @elastic/actionable-observability
x-pack/plugins/observability_shared @elastic/actionable-observability
x-pack/test/security_api_integration/plugins/oidc_provider @elastic/kibana-security

View file

@ -641,6 +641,10 @@ Elastic.
|This plugin provides shared components and services for use across observability solutions, as well as the observability landing page UI.
|{kib-repo}blob/{branch}/x-pack/plugins/observability_onboarding/README.md[observabilityOnboarding]
|This plugin provides an onboarding framework for observability solutions: Logs and APM.
|{kib-repo}blob/{branch}/x-pack/plugins/observability_shared/README.md[observabilityShared]
|A plugin that contains components and utilities shared by all Observability plugins.

View file

@ -494,6 +494,7 @@
"@kbn/object-versioning": "link:packages/kbn-object-versioning",
"@kbn/observability-alert-details": "link:x-pack/packages/observability/alert_details",
"@kbn/observability-fixtures-plugin": "link:x-pack/test/cases_api_integration/common/plugins/observability",
"@kbn/observability-onboarding-plugin": "link:x-pack/plugins/observability_onboarding",
"@kbn/observability-plugin": "link:x-pack/plugins/observability",
"@kbn/observability-shared-plugin": "link:x-pack/plugins/observability_shared",
"@kbn/oidc-provider-plugin": "link:x-pack/test/security_api_integration/plugins/oidc_provider",

View file

@ -94,6 +94,7 @@ pageLoadAssetSize:
navigation: 37269
newsfeed: 42228
observability: 95000
observabilityOnboarding: 19573
observabilityShared: 21266
osquery: 107090
painlessLab: 179748

View file

@ -236,6 +236,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.observability.unsafe.alertDetails.metrics.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.logs.enabled (boolean)',
'xpack.observability.unsafe.alertDetails.uptime.enabled (boolean)',
'xpack.observability_onboarding.ui.enabled (boolean)',
];
// We don't assert that actualExposedConfigKeys and expectedExposedConfigKeys are equal, because test failure messages with large
// arrays are hard to grok. Instead, we take the difference between the two arrays and assert them separately, that way it's

View file

@ -952,6 +952,8 @@
"@kbn/observability-alert-details/*": ["x-pack/packages/observability/alert_details/*"],
"@kbn/observability-fixtures-plugin": ["x-pack/test/cases_api_integration/common/plugins/observability"],
"@kbn/observability-fixtures-plugin/*": ["x-pack/test/cases_api_integration/common/plugins/observability/*"],
"@kbn/observability-onboarding-plugin": ["x-pack/plugins/observability_onboarding"],
"@kbn/observability-onboarding-plugin/*": ["x-pack/plugins/observability_onboarding/*"],
"@kbn/observability-plugin": ["x-pack/plugins/observability"],
"@kbn/observability-plugin/*": ["x-pack/plugins/observability/*"],
"@kbn/observability-shared-plugin": ["x-pack/plugins/observability_shared"],

View file

@ -50,6 +50,7 @@
"xpack.monitoring": ["plugins/monitoring"],
"xpack.observability": "plugins/observability",
"xpack.observabilityShared": "plugins/observability_shared",
"xpack.observability_onboarding": "plugins/observability_onboarding",
"xpack.osquery": ["plugins/osquery"],
"xpack.painlessLab": "plugins/painless_lab",
"xpack.profiling": ["plugins/profiling"],

View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"semi": true
}

View file

@ -0,0 +1,3 @@
# Observability onboarding plugin
This plugin provides an onboarding framework for observability solutions: Logs and APM.

View file

@ -0,0 +1,14 @@
/*
* 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 type { HttpFetchOptions } from '@kbn/core/public';
export type FetchOptions = Omit<HttpFetchOptions, 'body'> & {
pathname: string;
method?: string;
body?: any;
};

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export const PLUGIN_ID = 'observabilityOnboarding';
export const PLUGIN_NAME = 'observabilityOnboarding';

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
const path = require('path');
module.exports = {
preset: '@kbn/test',
rootDir: path.resolve(__dirname, '../../..'),
roots: ['<rootDir>/x-pack/plugins/observability_onboarding'],
};

View file

@ -0,0 +1,23 @@
{
"type": "plugin",
"id": "@kbn/observability-onboarding-plugin",
"owner": "@elastic/apm-ui",
"plugin": {
"id": "observabilityOnboarding",
"server": true,
"browser": true,
"configPath": ["xpack", "observability_onboarding"],
"requiredPlugins": [
"data",
"observability",
],
"optionalPlugins": [
"cloud",
"usageCollection",
],
"requiredBundles": [
"kibanaReact"
],
"extraPublicDirs": ["common"]
}
}

View file

@ -0,0 +1,179 @@
/*
* 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 { EuiErrorBoundary } from '@elastic/eui';
import { Theme, ThemeProvider } from '@emotion/react';
import {
APP_WRAPPER_CLASS,
AppMountParameters,
CoreStart,
} from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import {
KibanaContextProvider,
KibanaThemeProvider,
RedirectAppLinks,
useKibana,
useUiSetting$,
} from '@kbn/kibana-react-plugin/public';
import { useBreadcrumbs } from '@kbn/observability-plugin/public';
import { RouterProvider, createRouter } from '@kbn/typed-react-router-config';
import { euiDarkVars, euiLightVars } from '@kbn/ui-theme';
import React from 'react';
import ReactDOM from 'react-dom';
import { Redirect, RouteComponentProps, RouteProps } from 'react-router-dom';
import { Home } from '../components/app/home';
import {
ObservabilityOnboardingPluginSetupDeps,
ObservabilityOnboardingPluginStartDeps,
} from '../plugin';
export type BreadcrumbTitle<
T extends { [K in keyof T]?: string | undefined } = {}
> = string | ((props: RouteComponentProps<T>) => string) | null;
export interface RouteDefinition<
T extends { [K in keyof T]?: string | undefined } = any
> extends RouteProps {
breadcrumb: BreadcrumbTitle<T>;
}
export const onBoardingTitle = i18n.translate(
'xpack.observability_onboarding.breadcrumbs.onboarding',
{
defaultMessage: 'Onboarding',
}
);
export const onboardingRoutes: RouteDefinition[] = [
{
exact: true,
path: '/',
render: () => <Redirect to="/observabilityOnboarding" />,
breadcrumb: onBoardingTitle,
},
];
function ObservabilityOnboardingApp() {
const [darkMode] = useUiSetting$<boolean>('theme:darkMode');
const { http } = useKibana<ObservabilityOnboardingPluginStartDeps>().services;
const basePath = http.basePath.get();
useBreadcrumbs([
{
text: onBoardingTitle,
href: basePath + '/app/observabilityOnboarding',
},
{
text: i18n.translate('xpack.observability_onboarding.breadcrumbs.logs', {
defaultMessage: 'Logs',
}),
},
]);
return (
<ThemeProvider
theme={(outerTheme?: Theme) => ({
...outerTheme,
eui: darkMode ? euiDarkVars : euiLightVars,
darkMode,
})}
>
<div className={APP_WRAPPER_CLASS} data-test-subj="csmMainContainer">
<Home />
</div>
</ThemeProvider>
);
}
export const observabilityOnboardingRouter = createRouter({});
export function ObservabilityOnboardingAppRoot({
appMountParameters,
core,
deps,
corePlugins: { observability, data },
}: {
appMountParameters: AppMountParameters;
core: CoreStart;
deps: ObservabilityOnboardingPluginSetupDeps;
corePlugins: ObservabilityOnboardingPluginStartDeps;
}) {
const { history } = appMountParameters;
const i18nCore = core.i18n;
const plugins = { ...deps };
return (
<RedirectAppLinks
className={APP_WRAPPER_CLASS}
application={core.application}
>
<KibanaContextProvider
services={{
...core,
...plugins,
observability,
data,
}}
>
<KibanaThemeProvider
theme$={appMountParameters.theme$}
modify={{
breakpoint: {
xxl: 1600,
xxxl: 2000,
},
}}
>
<i18nCore.Context>
<RouterProvider
history={history}
router={observabilityOnboardingRouter}
>
<EuiErrorBoundary>
<ObservabilityOnboardingApp />
</EuiErrorBoundary>
</RouterProvider>
</i18nCore.Context>
</KibanaThemeProvider>
</KibanaContextProvider>
</RedirectAppLinks>
);
}
/**
* This module is rendered asynchronously in the Kibana platform.
*/
export const renderApp = ({
core,
deps,
appMountParameters,
corePlugins,
}: {
core: CoreStart;
deps: ObservabilityOnboardingPluginSetupDeps;
appMountParameters: AppMountParameters;
corePlugins: ObservabilityOnboardingPluginStartDeps;
}) => {
const { element } = appMountParameters;
ReactDOM.render(
<ObservabilityOnboardingAppRoot
appMountParameters={appMountParameters}
core={core}
deps={deps}
corePlugins={corePlugins}
/>,
element
);
return () => {
corePlugins.data.search.session.clear();
ReactDOM.unmountComponentAtNode(element);
};
};

View file

@ -0,0 +1,99 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import React, { ComponentType, useRef, useState } from 'react';
import {
FilmstripFrame,
FilmstripTransition,
TransitionState,
} from '../../shared/filmstrip_transition';
import {
Provider as WizardProvider,
Step as WizardStep,
} from './logs_onboarding_wizard';
import { HorizontalSteps } from './logs_onboarding_wizard/horizontal_steps';
import { PageTitle } from './logs_onboarding_wizard/page_title';
export function Home({ animated = true }: { animated?: boolean }) {
if (animated) {
return <AnimatedTransitionsWizard />;
}
return <StillTransitionsWizard />;
}
function StillTransitionsWizard() {
return (
<WizardProvider>
<EuiFlexGroup alignItems="center" justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<WizardStep />
</EuiFlexItem>
</EuiFlexGroup>
</WizardProvider>
);
}
const TRANSITION_DURATION = 180;
function AnimatedTransitionsWizard() {
const [transition, setTransition] = useState<TransitionState>('ready');
const TransitionComponent = useRef<ComponentType>(() => null);
function onChangeStep({
direction,
StepComponent,
}: {
direction: 'back' | 'next';
StepComponent: ComponentType;
}) {
setTransition(direction);
TransitionComponent.current = StepComponent;
setTimeout(() => {
setTransition('ready');
}, TRANSITION_DURATION + 10);
}
return (
<WizardProvider
transitionDuration={TRANSITION_DURATION}
onChangeStep={onChangeStep}
>
<EuiFlexGroup direction="column" alignItems="center">
<EuiFlexItem grow={false}>
<EuiSpacer size="l" />
<PageTitle />
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ width: '50%' }}>
<HorizontalSteps />
</EuiFlexItem>
<EuiFlexItem grow={1} style={{ width: '50%' }}>
<FilmstripTransition
duration={TRANSITION_DURATION}
transition={transition}
>
<FilmstripFrame position="left">
{
// eslint-disable-next-line react/jsx-pascal-case
transition === 'back' ? <TransitionComponent.current /> : null
}
</FilmstripFrame>
<FilmstripFrame position="center">
<WizardStep />
</FilmstripFrame>
<FilmstripFrame position="right">
{
// eslint-disable-next-line react/jsx-pascal-case
transition === 'next' ? <TransitionComponent.current /> : null
}
</FilmstripFrame>
</FilmstripTransition>
</EuiFlexItem>
</EuiFlexGroup>
</WizardProvider>
);
}

View file

@ -0,0 +1,218 @@
/*
* 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 React, { PropsWithChildren, useState } from 'react';
import {
EuiTitle,
EuiText,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiSpacer,
EuiCard,
EuiIcon,
EuiIconProps,
} from '@elastic/eui';
import {
StepPanel,
StepPanelContent,
StepPanelFooter,
} from '../../../shared/step_panel';
import { useWizard } from '.';
export function ConfigureLogs() {
const { goToStep, goBack, getState, setState } = useWizard();
const wizardState = getState();
const [logsType, setLogsType] = useState(wizardState.logsType);
const [uploadType, setUploadType] = useState(wizardState.uploadType);
function onContinue() {
if (logsType && uploadType) {
setState({ ...getState(), logsType, uploadType });
goToStep('installElasticAgent');
}
}
function createLogsTypeToggle(type: NonNullable<typeof logsType>) {
return () => {
if (type === logsType) {
setLogsType(undefined);
} else {
setLogsType(type);
}
};
}
function createUploadToggle(type: NonNullable<typeof uploadType>) {
return () => {
if (type === uploadType) {
setUploadType(undefined);
} else {
setUploadType(type);
}
};
}
function onBack() {
goBack();
}
return (
<StepPanel title="Choose what logs to collect">
<StepPanelContent>
<LogsTypeSection
title="System logs"
description="The quickest way to start using Elastic is to start uploading logs from your system."
>
<EuiFlexGroup>
<EuiFlexItem>
<OptionCard
title="Stream system logs"
iconType="document"
onClick={createLogsTypeToggle('system')}
isSelected={logsType === 'system'}
/>
</EuiFlexItem>
<EuiFlexItem />
</EuiFlexGroup>
</LogsTypeSection>
<EuiHorizontalRule margin="l" />
<LogsTypeSection
title="Custom logs"
description="Stream custom logs files from your data sink to Elastic."
>
<EuiFlexGroup>
<EuiFlexItem>
<OptionCard
title="Stream sys logs"
iconType="package"
onClick={createLogsTypeToggle('sys')}
isSelected={logsType === 'sys'}
/>
</EuiFlexItem>
<EuiFlexItem>
<OptionCard
title="Stream HTTP Endpoint logs"
iconType="package"
onClick={createLogsTypeToggle('http-endpoint')}
isSelected={logsType === 'http-endpoint'}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
</LogsTypeSection>
<EuiHorizontalRule margin="l" />
<LogsTypeSection title="Stream logs" description="Lorem ipsum">
<EuiFlexGroup>
<EuiFlexItem>
<OptionCard
title="Stream log file"
iconType="document"
onClick={createLogsTypeToggle('log-file')}
isSelected={logsType === 'log-file'}
/>
</EuiFlexItem>
<EuiFlexItem>
<OptionCard
title="Send it from my service"
iconType="document"
onClick={createLogsTypeToggle('service')}
isSelected={logsType === 'service'}
/>
</EuiFlexItem>
</EuiFlexGroup>
</LogsTypeSection>
<EuiHorizontalRule margin="l" />
<LogsTypeSection
title="Log files"
description="Upload your custom logs files to start analyzing it with Elastic."
>
<EuiFlexGroup>
<EuiFlexItem>
<OptionCard
title="Upload log file"
iconType="document"
onClick={createUploadToggle('log-file')}
isSelected={uploadType === 'log-file'}
/>
</EuiFlexItem>
<EuiFlexItem>
<OptionCard
title="Get an API key"
iconType="document"
onClick={createUploadToggle('api-key')}
isSelected={uploadType === 'api-key'}
/>
</EuiFlexItem>
</EuiFlexGroup>
</LogsTypeSection>
</StepPanelContent>
<StepPanelFooter
items={[
<EuiButton color="ghost" fill onClick={onBack}>
Back
</EuiButton>,
<EuiButton
color="primary"
fill
onClick={onContinue}
isDisabled={!(logsType && uploadType)}
>
Continue
</EuiButton>,
]}
/>
</StepPanel>
);
}
function LogsTypeSection({
title,
description,
children,
}: PropsWithChildren<{ title: string; description: string }>) {
return (
<>
<EuiTitle size="s">
<h3>{title}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued">
<p>{description}</p>
</EuiText>
<EuiSpacer size="m" />
{children}
</>
);
}
function OptionCard({
title,
iconType,
onClick,
isSelected,
}: {
title: string;
iconType: EuiIconProps['type'];
onClick: () => void;
isSelected: boolean;
}) {
return (
<EuiCard
layout="horizontal"
icon={<EuiIcon type={iconType} size="l" />}
title={title}
titleSize="xs"
paddingSize="m"
style={{ height: 56 }}
onClick={onClick}
hasBorder={true}
display={isSelected ? 'primary' : undefined}
/>
);
}

View file

@ -0,0 +1,52 @@
/*
* 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 React from 'react';
import { EuiStepsHorizontal } from '@elastic/eui';
import { useWizard } from '.';
export function HorizontalSteps() {
const { getPath } = useWizard();
const [currentStep, ...previousSteps] = getPath().reverse();
function getStatus(stepKey: ReturnType<typeof getPath>[0]) {
if (currentStep === stepKey) {
return 'current';
}
if (previousSteps.includes(stepKey)) {
return 'complete';
}
return 'incomplete';
}
return (
<EuiStepsHorizontal
steps={[
{
title: 'Name logs',
status: getStatus('nameLogs'),
onClick: () => {},
},
{
title: 'Configure logs',
status: getStatus('configureLogs'),
onClick: () => {},
},
{
title: 'Install shipper',
status: getStatus('installElasticAgent'),
onClick: () => {},
},
{
title: 'Import data',
status: getStatus('importData'),
onClick: () => {},
},
]}
/>
);
}

View file

@ -0,0 +1,105 @@
/*
* 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 {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiLoadingSpinner,
EuiSpacer,
EuiSteps,
EuiText,
} from '@elastic/eui';
import React from 'react';
import { useWizard } from '.';
import { useFetcher } from '../../../../hooks/use_fetcher';
import {
StepPanel,
StepPanelContent,
StepPanelFooter,
} from '../../../shared/step_panel';
export function ImportData() {
const { goToStep, goBack } = useWizard();
const { data } = useFetcher((callApi) => {
return callApi('GET /internal/observability_onboarding/get_status');
}, []);
function onContinue() {
goToStep('inspect');
}
function onBack() {
goBack();
}
return (
<StepPanel title="">
<StepPanelContent>
<EuiText color="subdued">
<p>
It might take a few minutes for the data to get to Elasticsearch. If
you&apos;re not seeing any, try generating some to verify. If
you&apos;re having trouble connecting, check out the troubleshooting
guide.
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiCallOut>
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="m" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText color="subdued">
<p>Listening for incoming logs</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</EuiCallOut>
<EuiSpacer size="m" />
<EuiSteps
titleSize="xs"
steps={[
{ title: 'Ping received', status: data?.status, children: null },
{ title: 'File found', status: 'complete', children: null },
{
title: 'Downloading Elastic Agent',
status: 'loading',
children: null,
},
{
title: 'Starting Elastic Agent',
status: 'incomplete',
children: null,
},
]}
/>
<EuiHorizontalRule margin="l" />
<EuiFlexGroup justifyContent="center" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="help">Need some help?</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
</StepPanelContent>
<StepPanelFooter
items={[
<EuiButton color="ghost" fill onClick={onBack}>
Back
</EuiButton>,
<EuiButton color="primary" fill onClick={onContinue}>
Continue
</EuiButton>,
]}
/>
</StepPanel>
);
}

View file

@ -0,0 +1,58 @@
/*
* 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 { NameLogs } from './name_logs';
import { ConfigureLogs } from './configure_logs';
import { InstallElasticAgent } from './install_elastic_agent';
import { createWizardContext } from '../../../../context/create_wizard_context';
import { ImportData } from './import_data';
import { Inspect } from './inspect';
interface WizardState {
datasetName: string;
logsType?:
| 'system'
| 'sys'
| 'http-endpoint'
| 'opentelemetry'
| 'amazon-firehose'
| 'log-file'
| 'service';
uploadType?: 'log-file' | 'api-key';
elasticAgentPlatform: 'linux-tar' | 'macos' | 'windows' | 'deb' | 'rpm';
alternativeShippers: {
filebeat: boolean;
fluentbit: boolean;
logstash: boolean;
fluentd: boolean;
};
}
const initialState: WizardState = {
datasetName: '',
elasticAgentPlatform: 'linux-tar',
alternativeShippers: {
filebeat: false,
fluentbit: false,
logstash: false,
fluentd: false,
},
};
const { Provider, Step, useWizard } = createWizardContext({
initialState,
initialStep: 'nameLogs',
steps: {
nameLogs: NameLogs,
configureLogs: ConfigureLogs,
installElasticAgent: InstallElasticAgent,
importData: ImportData,
inspect: Inspect,
},
});
export { Provider, Step, useWizard };

View file

@ -0,0 +1,46 @@
/*
* 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 React from 'react';
import { EuiButton, EuiTitle, EuiSpacer } from '@elastic/eui';
import {
StepPanel,
StepPanelContent,
StepPanelFooter,
} from '../../../shared/step_panel';
import { useWizard } from '.';
export function Inspect() {
const { goBack, getState, getPath, getUsage } = useWizard();
return (
<StepPanel title="Inspect wizard">
<StepPanelContent>
<EuiTitle size="s">
<h3>State</h3>
</EuiTitle>
<pre>{JSON.stringify(getState(), null, 4)}</pre>
<EuiSpacer size="m" />
<EuiTitle size="s">
<h3>Path</h3>
</EuiTitle>
<pre>{JSON.stringify(getPath(), null, 4)}</pre>
<EuiSpacer size="m" />
<EuiTitle size="s">
<h3>Usage</h3>
</EuiTitle>
<pre>{JSON.stringify(getUsage(), null, 4)}</pre>
</StepPanelContent>
<StepPanelFooter
items={[
<EuiButton color="ghost" fill onClick={goBack}>
Back
</EuiButton>,
]}
/>
</StepPanel>
);
}

View file

@ -0,0 +1,199 @@
/*
* 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 React, { PropsWithChildren, useState } from 'react';
import {
EuiTitle,
EuiText,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiSpacer,
EuiCard,
EuiIcon,
EuiIconProps,
EuiButtonGroup,
EuiCodeBlock,
} from '@elastic/eui';
import {
StepPanel,
StepPanelContent,
StepPanelFooter,
} from '../../../shared/step_panel';
import { useWizard } from '.';
export function InstallElasticAgent() {
const { goToStep, goBack, getState, setState } = useWizard();
const wizardState = getState();
const [elasticAgentPlatform, setElasticAgentPlatform] = useState(
wizardState.elasticAgentPlatform
);
const [alternativeShippers, setAlternativeShippers] = useState(
wizardState.alternativeShippers
);
function onContinue() {
setState({ ...getState(), elasticAgentPlatform, alternativeShippers });
goToStep('importData');
}
function createAlternativeShipperToggle(
type: NonNullable<keyof typeof alternativeShippers>
) {
return () => {
setAlternativeShippers({
...alternativeShippers,
[type]: !alternativeShippers[type],
});
};
}
function onBack() {
goBack();
}
return (
<StepPanel title="Install the Elastic Agent">
<StepPanelContent>
<EuiText color="subdued">
<p>
Select a platform and run the command to install, enroll, and start
the Elastic Agent. Do this for each host. For other platforms, see
our downloads page. Review host requirements and other installation
options.
</p>
</EuiText>
<EuiSpacer size="m" />
<EuiButtonGroup
isFullWidth
legend="Choose platform"
options={[
{ id: 'linux-tar', label: 'Linux Tar' },
{ id: 'macos', label: 'MacOs' },
{ id: 'windows', label: 'Windows' },
{ id: 'deb', label: 'DEB' },
{ id: 'rpm', label: 'RPM' },
]}
type="single"
idSelected={elasticAgentPlatform}
onChange={(id: string) =>
setElasticAgentPlatform(id as typeof elasticAgentPlatform)
}
/>
<EuiSpacer size="m" />
<EuiCodeBlock language="html" isCopyable>
{PLATFORM_COMMAND[elasticAgentPlatform]}
</EuiCodeBlock>
<EuiHorizontalRule margin="l" />
<LogsTypeSection title="Or select alternative shippers" description="">
<EuiFlexGroup>
<EuiFlexItem>
<OptionCard
title="Filebeat"
iconType="document"
onClick={createAlternativeShipperToggle('filebeat')}
isSelected={alternativeShippers.filebeat}
/>
</EuiFlexItem>
<EuiFlexItem>
<OptionCard
title="fluentbit"
iconType="package"
onClick={createAlternativeShipperToggle('fluentbit')}
isSelected={alternativeShippers.fluentbit}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<OptionCard
title="Logstash"
iconType="logstashIf"
onClick={createAlternativeShipperToggle('logstash')}
isSelected={alternativeShippers.logstash}
/>
</EuiFlexItem>
<EuiFlexItem>
<OptionCard
title="Fluentd"
iconType="package"
onClick={createAlternativeShipperToggle('fluentd')}
isSelected={alternativeShippers.fluentd}
/>
</EuiFlexItem>
</EuiFlexGroup>
</LogsTypeSection>
</StepPanelContent>
<StepPanelFooter
items={[
<EuiButton color="ghost" fill onClick={onBack}>
Back
</EuiButton>,
<EuiButton color="primary" fill onClick={onContinue}>
Continue
</EuiButton>,
]}
/>
</StepPanel>
);
}
function LogsTypeSection({
title,
description,
children,
}: PropsWithChildren<{ title: string; description: string }>) {
return (
<>
<EuiTitle size="s">
<h3>{title}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiText color="subdued">
<p>{description}</p>
</EuiText>
<EuiSpacer size="m" />
{children}
</>
);
}
function OptionCard({
title,
iconType,
onClick,
isSelected,
}: {
title: string;
iconType: EuiIconProps['type'];
onClick: () => void;
isSelected: boolean;
}) {
return (
<EuiCard
layout="horizontal"
icon={<EuiIcon type={iconType} size="l" />}
title={title}
titleSize="xs"
paddingSize="m"
style={{ height: 56 }}
onClick={onClick}
hasBorder={true}
display={isSelected ? 'primary' : undefined}
/>
);
}
const PLATFORM_COMMAND = {
'linux-tar': `curl -O https://elastic.co/agent-setup.sh && sudo bash agent-setup.sh -- service.name=my-service --url=https://elasticsearch:8220 --enrollment-token=SRSc2ozWUItWXNuWE5oZzdERFU6anJtY0FIzhSRGlzeTJYcUF5UklfUQ==`,
macos: `curl -O https://elastic.co/agent-setup.sh && sudo bash agent-setup.sh -- service.name=my-service --url=https://elasticsearch:8220 --enrollment-token=SRSc2ozWUItWXNuWE5oZzdERFU6anJtY0FIzhSRGlzeTJYcUF5UklfUQ==`,
windows: `curl -O https://elastic.co/agent-setup.sh && sudo bash agent-setup.sh -- service.name=my-service --url=https://elasticsearch:8220 --enrollment-token=SRSc2ozWUItWXNuWE5oZzdERFU6anJtY0FIzhSRGlzeTJYcUF5UklfUQ==`,
deb: `curl -O https://elastic.co/agent-setup.sh && sudo bash agent-setup.sh -- service.name=my-service --url=https://elasticsearch:8220 --enrollment-token=SRSc2ozWUItWXNuWE5oZzdERFU6anJtY0FIzhSRGlzeTJYcUF5UklfUQ==`,
rpm: `curl -O https://elastic.co/agent-setup.sh && sudo bash agent-setup.sh -- service.name=my-service --url=https://elasticsearch:8220 --enrollment-token=SRSc2ozWUItWXNuWE5oZzdERFU6anJtY0FIzhSRGlzeTJYcUF5UklfUQ==`,
} as const;

View file

@ -0,0 +1,72 @@
/*
* 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 React, { useState } from 'react';
import {
EuiText,
EuiButton,
EuiButtonEmpty,
EuiForm,
EuiFormRow,
EuiFieldText,
EuiSpacer,
} from '@elastic/eui';
import {
StepPanel,
StepPanelContent,
StepPanelFooter,
} from '../../../shared/step_panel';
import { useWizard } from '.';
export function NameLogs() {
const { goToStep, getState, setState } = useWizard();
const wizardState = getState();
const [datasetName, setDatasetName] = useState(wizardState.datasetName);
function onContinue() {
setState({ ...getState(), datasetName });
goToStep('configureLogs');
}
return (
<StepPanel title="Give your logs a name">
<StepPanelContent>
<EuiText color="subdued">
<p>Pick a name for your logs, this will become your dataset name.</p>
</EuiText>
<EuiSpacer size="m" />
<EuiForm>
<EuiFormRow
label="Name"
helpText="Special characters and space are not allowed."
>
<EuiFieldText
placeholder="Dataset name"
value={datasetName}
onChange={(event) => setDatasetName(event.target.value)}
/>
</EuiFormRow>
</EuiForm>
</StepPanelContent>
<StepPanelFooter
items={[
<EuiButtonEmpty href="/app/observability/overview">
Skip for now
</EuiButtonEmpty>,
<EuiButton
color="primary"
fill
onClick={onContinue}
isDisabled={!datasetName}
>
Save and Continue
</EuiButton>,
]}
/>
</StepPanel>
);
}

View file

@ -0,0 +1,37 @@
/*
* 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 React from 'react';
import { EuiTitle } from '@elastic/eui';
import { useWizard } from '.';
export function PageTitle() {
const { getPath } = useWizard();
const [currentStep] = getPath().reverse();
if (currentStep === 'installElasticAgent') {
return (
<EuiTitle size="l">
<h1>Select your shipper</h1>
</EuiTitle>
);
}
if (currentStep === 'importData') {
return (
<EuiTitle size="l">
<h1>Incoming logs</h1>
</EuiTitle>
);
}
return (
<EuiTitle size="l">
<h1>Collect and analyze my logs</h1>
</EuiTitle>
);
}

View file

@ -0,0 +1,68 @@
/*
* 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 React, { PropsWithChildren } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
export type TransitionState = 'ready' | 'back' | 'next';
export function FilmstripTransition({
children,
duration,
transition,
}: PropsWithChildren<{ duration: number; transition: TransitionState }>) {
return (
<div
style={{
display: 'flex',
flexFlow: 'column nowrap',
flexGrow: 1,
position: 'relative',
zIndex: 0,
transitionTimingFunction: 'ease-out',
transition:
transition !== 'ready' ? `transform ${duration}ms` : undefined,
transform:
transition === 'ready'
? 'translateX(0)'
: transition === 'back'
? 'translateX(200%)'
: 'translateX(-200%)',
}}
>
{children}
</div>
);
}
export function FilmstripFrame({
children,
position,
}: PropsWithChildren<{ position: 'left' | 'center' | 'right' }>) {
return (
<EuiFlexGroup
// alignItems="center"
// justifyContent="spaceAround"
alignItems="flexStart"
style={
position !== 'center'
? {
position: 'absolute',
width: '100%',
height: '100%',
transform:
position === 'left' ? 'translateX(-200%)' : 'translateX(200%)',
pointerEvents: 'none',
}
: undefined
}
>
<EuiFlexItem>{children}</EuiFlexItem>
{/* <EuiFlexItem grow={false}>{children}</EuiFlexItem>*/}
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,68 @@
/*
* 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 React, { ReactNode } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiPanelProps,
EuiTitle,
} from '@elastic/eui';
interface StepPanelProps {
title: string;
panelProps?: EuiPanelProps;
children?: ReactNode;
}
export function StepPanel(props: StepPanelProps) {
const { title, children } = props;
const panelProps = props.panelProps ?? null;
return (
<EuiPanel {...panelProps}>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="m">
<h2>{title}</h2>
</EuiTitle>
</EuiFlexItem>
{children}
</EuiFlexGroup>
</EuiPanel>
);
}
interface StepPanelContentProps {
children?: ReactNode;
}
export function StepPanelContent(props: StepPanelContentProps) {
const { children } = props;
return <EuiFlexItem>{children}</EuiFlexItem>;
}
interface StepPanelFooterProps {
children?: ReactNode;
items?: ReactNode[];
}
export function StepPanelFooter(props: StepPanelFooterProps) {
const { items = [], children } = props;
return (
<EuiFlexItem grow={false}>
{children}
{items && (
<EuiFlexGroup justifyContent="spaceBetween">
{items.map((itemReactNode, index) => (
<EuiFlexItem key={index} grow={false}>
{itemReactNode}
</EuiFlexItem>
))}
</EuiFlexGroup>
)}
</EuiFlexItem>
);
}

View file

@ -0,0 +1,180 @@
/*
* 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 React, {
ComponentType,
ReactNode,
createContext,
useContext,
useState,
useRef,
} from 'react';
interface WizardContext<T, StepKey extends string> {
CurrentStep: ComponentType;
goToStep: (step: StepKey) => void;
goBack: () => void;
getState: () => T;
setState: (state: T) => void;
getPath: () => StepKey[];
getUsage: () => {
timeSinceStart: number;
navEvents: Array<{
type: string;
step: StepKey;
timestamp: number;
duration: number;
}>;
};
}
export function createWizardContext<
T,
StepKey extends string,
InitialStepKey extends StepKey
>({
initialState,
initialStep,
steps,
}: {
initialState: T;
initialStep: InitialStepKey;
steps: Record<StepKey, ComponentType>;
}) {
const context = createContext<WizardContext<T, StepKey>>({
CurrentStep: () => null,
goToStep: () => {},
goBack: () => {},
getState: () => initialState,
setState: () => {},
getPath: () => [],
getUsage: () => ({ timeSinceStart: 0, navEvents: [] }),
});
function Provider({
children,
onChangeStep,
transitionDuration,
}: {
children?: ReactNode;
onChangeStep?: (stepChangeEvent: {
direction: 'back' | 'next';
stepKey: StepKey;
StepComponent: ComponentType;
}) => void;
transitionDuration?: number;
}) {
const [step, setStep] = useState<StepKey>(initialStep);
const pathRef = useRef<StepKey[]>([initialStep]);
const usageRef = useRef<ReturnType<WizardContext<T, StepKey>['getUsage']>>({
timeSinceStart: 0,
navEvents: [
{ type: 'initial', step, timestamp: Date.now(), duration: 0 },
],
});
const [state, setState] = useState<T>(initialState);
return (
<context.Provider
value={{
CurrentStep: steps[step],
goToStep(stepKey: StepKey) {
if (stepKey === step) {
return;
}
pathRef.current.push(stepKey);
const navEvents = usageRef.current.navEvents;
const currentNavEvent = navEvents[navEvents.length - 1];
const timestamp = Date.now();
currentNavEvent.duration = timestamp - currentNavEvent.timestamp;
usageRef.current.navEvents.push({
type: 'progress',
step: stepKey,
timestamp,
duration: 0,
});
if (onChangeStep) {
onChangeStep({
direction: 'next',
stepKey,
StepComponent: steps[stepKey],
});
}
if (transitionDuration) {
setTimeout(() => {
setStep(stepKey);
}, transitionDuration);
} else {
setStep(stepKey);
}
},
goBack() {
if (step === initialStep) {
return;
}
const path = pathRef.current;
path.pop();
const lastStep = path[path.length - 1];
const navEvents = usageRef.current.navEvents;
const currentNavEvent = navEvents[navEvents.length - 1];
const timestamp = Date.now();
currentNavEvent.duration = timestamp - currentNavEvent.timestamp;
usageRef.current.navEvents.push({
type: 'back',
step: lastStep,
timestamp,
duration: 0,
});
if (onChangeStep) {
onChangeStep({
direction: 'back',
stepKey: lastStep,
StepComponent: steps[lastStep],
});
}
if (transitionDuration) {
setTimeout(() => {
setStep(lastStep);
}, transitionDuration);
} else {
setStep(lastStep);
}
},
getState: () => state as T,
setState: (_state: T) => {
setState(_state);
},
getPath: () => [...pathRef.current],
getUsage: () => {
const currentTime = Date.now();
const navEvents = usageRef.current.navEvents;
const firstNavEvent = navEvents[0];
const lastNavEvent = navEvents[navEvents.length - 1];
lastNavEvent.duration = currentTime - lastNavEvent.timestamp;
return {
timeSinceStart: currentTime - firstNavEvent.timestamp,
navEvents,
};
},
}}
>
{children}
</context.Provider>
);
}
function Step() {
const { CurrentStep } = useContext(context);
return <CurrentStep />;
}
function useWizard() {
const { CurrentStep: _, ...rest } = useContext(context);
return rest;
}
return { context, Provider, Step, useWizard };
}

View file

@ -0,0 +1,209 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React, { useEffect, useMemo, useState } from 'react';
import type {
IHttpFetchError,
ResponseErrorBody,
} from '@kbn/core-http-browser';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useInspectorContext } from '@kbn/observability-plugin/public';
import {
AutoAbortedObservabilityClient,
callObservabilityOnboardingApi,
} from '../services/rest/create_call_api';
export enum FETCH_STATUS {
LOADING = 'loading',
SUCCESS = 'success',
FAILURE = 'failure',
NOT_INITIATED = 'not_initiated',
}
export const isPending = (fetchStatus: FETCH_STATUS) =>
fetchStatus === FETCH_STATUS.LOADING ||
fetchStatus === FETCH_STATUS.NOT_INITIATED;
export interface FetcherResult<Data> {
data?: Data;
status: FETCH_STATUS;
error?: IHttpFetchError<ResponseErrorBody>;
}
function getDetailsFromErrorResponse(
error: IHttpFetchError<ResponseErrorBody>
) {
const message = error.body?.message ?? error.response?.statusText;
return (
<>
{message} ({error.response?.status})
<h5>
{i18n.translate('xpack.observability_onboarding.fetcher.error.url', {
defaultMessage: `URL`,
})}
</h5>
{error.response?.url}
</>
);
}
const createAutoAbortedClient = (
signal: AbortSignal,
addInspectorRequest: <Data>(result: FetcherResult<Data>) => void
): AutoAbortedObservabilityClient => {
return ((endpoint, options) => {
return callObservabilityOnboardingApi(endpoint, {
...options,
signal,
} as any)
.catch((err) => {
addInspectorRequest({
status: FETCH_STATUS.FAILURE,
data: err.body?.attributes,
});
throw err;
})
.then((response) => {
addInspectorRequest({
data: response,
status: FETCH_STATUS.SUCCESS,
});
return response;
});
}) as AutoAbortedObservabilityClient;
};
// fetcher functions can return undefined OR a promise. Previously we had a more simple type
// but it led to issues when using object destructuring with default values
type InferResponseType<TReturn> = Exclude<TReturn, undefined> extends Promise<
infer TResponseType
>
? TResponseType
: unknown;
export function useFetcher<TReturn>(
fn: (callApi: AutoAbortedObservabilityClient) => TReturn,
fnDeps: any[],
options: {
preservePreviousData?: boolean;
showToastOnError?: boolean;
} = {}
): FetcherResult<InferResponseType<TReturn>> & { refetch: () => void } {
const { notifications } = useKibana();
const { preservePreviousData = true, showToastOnError = true } = options;
const [result, setResult] = useState<
FetcherResult<InferResponseType<TReturn>>
>({
data: undefined,
status: FETCH_STATUS.NOT_INITIATED,
});
const [counter, setCounter] = useState(0);
const { addInspectorRequest } = useInspectorContext();
useEffect(() => {
let controller: AbortController = new AbortController();
async function doFetch() {
controller.abort();
controller = new AbortController();
const signal = controller.signal;
const promise = fn(createAutoAbortedClient(signal, addInspectorRequest));
// if `fn` doesn't return a promise it is a signal that data fetching was not initiated.
// This can happen if the data fetching is conditional (based on certain inputs).
// In these cases it is not desirable to invoke the global loading spinner, or change the status to success
if (!promise) {
return;
}
setResult((prevResult) => ({
data: preservePreviousData ? prevResult.data : undefined, // preserve data from previous state while loading next state
status: FETCH_STATUS.LOADING,
error: undefined,
}));
try {
const data = await promise;
// when http fetches are aborted, the promise will be rejected
// and this code is never reached. For async operations that are
// not cancellable, we need to check whether the signal was
// aborted before updating the result.
if (!signal.aborted) {
setResult({
data,
status: FETCH_STATUS.SUCCESS,
error: undefined,
} as FetcherResult<InferResponseType<TReturn>>);
}
} catch (e) {
const err = e as Error | IHttpFetchError<ResponseErrorBody>;
if (!signal.aborted) {
const errorDetails =
'response' in err ? getDetailsFromErrorResponse(err) : err.message;
if (showToastOnError) {
notifications.toasts.danger({
title: i18n.translate(
'xpack.observability_onboarding.fetcher.error.title',
{
defaultMessage: `Error while fetching resource`,
}
),
body: (
<div>
<h5>
{i18n.translate(
'xpack.observability_onboarding.fetcher.error.status',
{
defaultMessage: `Error`,
}
)}
</h5>
{errorDetails}
</div>
),
});
}
setResult({
data: undefined,
status: FETCH_STATUS.FAILURE,
error: e,
});
}
}
}
doFetch();
return () => {
controller.abort();
};
/* eslint-disable react-hooks/exhaustive-deps */
}, [
counter,
preservePreviousData,
showToastOnError,
...fnDeps,
/* eslint-enable react-hooks/exhaustive-deps */
]);
return useMemo(() => {
return {
...result,
refetch: () => {
// this will invalidate the deps to `useEffect` and will result in a new request
setCounter((count) => count + 1);
},
};
}, [result]);
}

View file

@ -0,0 +1,23 @@
/*
* 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 { PluginInitializer, PluginInitializerContext } from '@kbn/core/public';
import {
ObservabilityOnboardingPlugin,
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginStart,
} from './plugin';
export const plugin: PluginInitializer<
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginStart
> = (ctx: PluginInitializerContext) => new ObservabilityOnboardingPlugin(ctx);
export type {
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginStart,
};

View file

@ -0,0 +1,99 @@
/*
* 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 {
ObservabilityPublicSetup,
ObservabilityPublicStart,
} from '@kbn/observability-plugin/public';
import {
HttpStart,
AppMountParameters,
CoreSetup,
CoreStart,
DEFAULT_APP_CATEGORIES,
Plugin,
PluginInitializerContext,
AppNavLinkStatus,
} from '@kbn/core/public';
import {
DataPublicPluginSetup,
DataPublicPluginStart,
} from '@kbn/data-plugin/public';
import type { ObservabilityOnboardingConfig } from '../server';
export type ObservabilityOnboardingPluginSetup = void;
export type ObservabilityOnboardingPluginStart = void;
export interface ObservabilityOnboardingPluginSetupDeps {
data: DataPublicPluginSetup;
observability: ObservabilityPublicSetup;
}
export interface ObservabilityOnboardingPluginStartDeps {
http: HttpStart;
data: DataPublicPluginStart;
observability: ObservabilityPublicStart;
}
export class ObservabilityOnboardingPlugin
implements
Plugin<
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginStart
>
{
constructor(private ctx: PluginInitializerContext) {}
public setup(
core: CoreSetup,
plugins: ObservabilityOnboardingPluginSetupDeps
) {
const {
ui: { enabled: isObservabilityOnboardingUiEnabled },
} = this.ctx.config.get<ObservabilityOnboardingConfig>();
const pluginSetupDeps = plugins;
// set xpack.observability_onboarding.ui.enabled: true
// and go to /app/observabilityOnboarding
if (isObservabilityOnboardingUiEnabled) {
core.application.register({
navLinkStatus: AppNavLinkStatus.hidden,
id: 'observabilityOnboarding',
title: 'Observability Onboarding',
order: 8500,
euiIconType: 'logoObservability',
category: DEFAULT_APP_CATEGORIES.observability,
keywords: [],
async mount(appMountParameters: AppMountParameters<unknown>) {
// Load application bundle and Get start service
const [{ renderApp }, [coreStart, corePlugins]] = await Promise.all([
import('./application/app'),
core.getStartServices(),
]);
const { createCallApi } = await import(
'./services/rest/create_call_api'
);
createCallApi(core);
return renderApp({
core: coreStart,
deps: pluginSetupDeps,
appMountParameters,
corePlugins: corePlugins as ObservabilityOnboardingPluginStartDeps,
});
},
});
}
}
public start(
core: CoreStart,
plugins: ObservabilityOnboardingPluginStartDeps
) {}
}

View file

@ -0,0 +1,45 @@
/*
* 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 { CoreSetup, CoreStart } from '@kbn/core/public';
import { FetchOptions } from '../../../common/fetch_options';
function getFetchOptions(fetchOptions: FetchOptions) {
const { body, ...rest } = fetchOptions;
return {
...rest,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
query: {
...fetchOptions.query,
},
};
}
export type CallApi = typeof callApi;
export async function callApi<T = void>(
{ http }: CoreStart | CoreSetup,
fetchOptions: FetchOptions
): Promise<T> {
const {
pathname,
method = 'get',
...options
} = getFetchOptions(fetchOptions);
const lowercaseMethod = method.toLowerCase() as
| 'get'
| 'post'
| 'put'
| 'delete'
| 'patch';
const res = await http[lowercaseMethod]<T>(pathname, options);
return res;
}

View file

@ -0,0 +1,55 @@
/*
* 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 { CoreSetup, CoreStart } from '@kbn/core/public';
import type { RouteRepositoryClient } from '@kbn/server-route-repository';
import { formatRequest } from '@kbn/server-route-repository';
import { FetchOptions } from '../../../common/fetch_options';
import type { ObservabilityOnboardingServerRouteRepository } from '../../../server/routes';
import { CallApi, callApi } from './call_api';
export type ObservabilityOnboardingClientOptions = Omit<
FetchOptions,
'query' | 'body' | 'pathname' | 'signal'
> & {
signal: AbortSignal | null;
};
export type ObservabilityOnboardingClient = RouteRepositoryClient<
ObservabilityOnboardingServerRouteRepository,
ObservabilityOnboardingClientOptions
>;
export type AutoAbortedObservabilityClient = RouteRepositoryClient<
ObservabilityOnboardingServerRouteRepository,
Omit<ObservabilityOnboardingClientOptions, 'signal'>
>;
export let callObservabilityOnboardingApi: ObservabilityOnboardingClient =
() => {
throw new Error(
'callObservabilityOnboardingApi has to be initialized before used. Call createCallApi first.'
);
};
export function createCallApi(core: CoreStart | CoreSetup) {
callObservabilityOnboardingApi = ((endpoint, options) => {
const { params } = options as unknown as {
params?: Partial<Record<string, any>>;
};
const { method, pathname } = formatRequest(endpoint, params?.path);
return callApi(core, {
...options,
method,
pathname,
body: params?.body,
query: params?.query,
} as unknown as Parameters<CallApi>[1]);
}) as ObservabilityOnboardingClient;
}

View file

@ -0,0 +1,38 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import {
PluginConfigDescriptor,
PluginInitializerContext,
} from '@kbn/core/server';
import { ObservabilityOnboardingPlugin } from './plugin';
const configSchema = schema.object({
ui: schema.object({
enabled: schema.boolean({ defaultValue: false }),
}),
});
export type ObservabilityOnboardingConfig = TypeOf<typeof configSchema>;
// plugin config
export const config: PluginConfigDescriptor<ObservabilityOnboardingConfig> = {
exposeToBrowser: {
ui: true,
},
schema: configSchema,
};
export function plugin(initializerContext: PluginInitializerContext) {
return new ObservabilityOnboardingPlugin(initializerContext);
}
export type {
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginStart,
} from './types';

View file

@ -0,0 +1,80 @@
/*
* 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 type {
CoreSetup,
CoreStart,
Logger,
Plugin,
PluginInitializerContext,
} from '@kbn/core/server';
import { mapValues } from 'lodash';
import { getObservabilityOnboardingServerRouteRepository } from './routes';
import { registerRoutes } from './routes/register_routes';
import { ObservabilityOnboardingRouteHandlerResources } from './routes/types';
import {
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginSetupDependencies,
ObservabilityOnboardingPluginStart,
ObservabilityOnboardingPluginStartDependencies,
} from './types';
import { ObservabilityOnboardingConfig } from '.';
export class ObservabilityOnboardingPlugin
implements
Plugin<
ObservabilityOnboardingPluginSetup,
ObservabilityOnboardingPluginStart,
ObservabilityOnboardingPluginSetupDependencies,
ObservabilityOnboardingPluginStartDependencies
>
{
private readonly logger: Logger;
constructor(
private readonly initContext: PluginInitializerContext<ObservabilityOnboardingConfig>
) {
this.initContext = initContext;
this.logger = this.initContext.logger.get();
}
public setup(
core: CoreSetup<ObservabilityOnboardingPluginStartDependencies>,
plugins: ObservabilityOnboardingPluginSetupDependencies
) {
this.logger.debug('observability_onboarding: Setup');
const resourcePlugins = mapValues(plugins, (value, key) => {
return {
setup: value,
start: () =>
core.getStartServices().then((services) => {
const [, pluginsStartContracts] = services;
return pluginsStartContracts[
key as keyof ObservabilityOnboardingPluginStartDependencies
];
}),
};
}) as ObservabilityOnboardingRouteHandlerResources['plugins'];
registerRoutes({
core,
logger: this.logger,
repository: getObservabilityOnboardingServerRouteRepository(),
plugins: resourcePlugins,
});
return {};
}
public start(core: CoreStart) {
this.logger.debug('observability_onboarding: Started');
return {};
}
public stop() {}
}

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 { createServerRouteFactory } from '@kbn/server-route-repository';
import {
ObservabilityOnboardingRouteCreateOptions,
ObservabilityOnboardingRouteHandlerResources,
} from './types';
export const createObservabilityOnboardingServerRoute =
createServerRouteFactory<
ObservabilityOnboardingRouteHandlerResources,
ObservabilityOnboardingRouteCreateOptions
>();

View file

@ -0,0 +1,31 @@
/*
* 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 type {
EndpointOf,
ServerRouteRepository,
} from '@kbn/server-route-repository';
import { statusRouteRepository } from './status/route';
function getTypedObservabilityOnboardingServerRouteRepository() {
const repository = {
...statusRouteRepository,
};
return repository;
}
export const getObservabilityOnboardingServerRouteRepository =
(): ServerRouteRepository => {
return getTypedObservabilityOnboardingServerRouteRepository();
};
export type ObservabilityOnboardingServerRouteRepository = ReturnType<
typeof getTypedObservabilityOnboardingServerRouteRepository
>;
export type APIEndpoint =
EndpointOf<ObservabilityOnboardingServerRouteRepository>;

View file

@ -0,0 +1,103 @@
/*
* 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 { errors } from '@elastic/elasticsearch';
import Boom from '@hapi/boom';
import { CoreSetup, Logger, RouteRegistrar } from '@kbn/core/server';
import {
ServerRouteRepository,
decodeRequestParams,
parseEndpoint,
routeValidationObject,
} from '@kbn/server-route-repository';
import * as t from 'io-ts';
import { ObservabilityOnboardingRequestHandlerContext } from '../types';
import { ObservabilityOnboardingRouteHandlerResources } from './types';
interface RegisterRoutes {
core: CoreSetup;
repository: ServerRouteRepository;
logger: Logger;
plugins: ObservabilityOnboardingRouteHandlerResources['plugins'];
}
export function registerRoutes({
repository,
core,
logger,
plugins,
}: RegisterRoutes) {
const routes = Object.values(repository);
const router = core.http.createRouter();
routes.forEach((route) => {
const { endpoint, options, handler, params } = route;
const { pathname, method } = parseEndpoint(endpoint);
(
router[method] as RouteRegistrar<
typeof method,
ObservabilityOnboardingRequestHandlerContext
>
)(
{
path: pathname,
validate: routeValidationObject,
options,
},
async (context, request, response) => {
try {
const decodedParams = decodeRequestParams(
{
params: request.params,
body: request.body,
query: request.query,
},
params ?? t.strict({})
);
const data = (await handler({
context,
request,
logger,
params: decodedParams,
plugins,
})) as any;
if (data === undefined) {
return response.noContent();
}
return response.ok({ body: data });
} catch (error) {
if (Boom.isBoom(error)) {
logger.error(error.output.payload.message);
return response.customError({
statusCode: error.output.statusCode,
body: { message: error.output.payload.message },
});
}
logger.error(error);
const opts = {
statusCode: 500,
body: {
message: error.message,
},
};
if (error instanceof errors.RequestAbortedError) {
opts.statusCode = 499;
opts.body.message = 'Client closed request';
}
return response.customError(opts);
}
}
);
});
}

View file

@ -0,0 +1,22 @@
/*
* 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 { createObservabilityOnboardingServerRoute } from '../create_observability_onboarding_server_route';
const statusRoute = createObservabilityOnboardingServerRoute({
endpoint: 'GET /internal/observability_onboarding/get_status',
options: {
tags: [],
},
async handler(resources): Promise<{ status: 'incomplete' | 'complete' }> {
return { status: 'complete' };
},
});
export const statusRouteRepository = {
...statusRoute,
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { KibanaRequest, Logger } from '@kbn/core/server';
import { ObservabilityOnboardingServerRouteRepository } from '.';
import {
ObservabilityOnboardingPluginSetupDependencies,
ObservabilityOnboardingPluginStartDependencies,
ObservabilityOnboardingRequestHandlerContext,
} from '../types';
export type { ObservabilityOnboardingServerRouteRepository };
export interface ObservabilityOnboardingRouteHandlerResources {
context: ObservabilityOnboardingRequestHandlerContext;
logger: Logger;
request: KibanaRequest;
plugins: {
[key in keyof ObservabilityOnboardingPluginSetupDependencies]: {
setup: Required<ObservabilityOnboardingPluginSetupDependencies>[key];
start: () => Promise<
Required<ObservabilityOnboardingPluginStartDependencies>[key]
>;
};
};
}
export interface ObservabilityOnboardingRouteCreateOptions {
options: {
tags: string[];
};
}

View file

@ -0,0 +1,31 @@
/*
* 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 { CustomRequestHandlerContext } from '@kbn/core/server';
import {
PluginSetup as DataPluginSetup,
PluginStart as DataPluginStart,
} from '@kbn/data-plugin/server';
import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server';
export interface ObservabilityOnboardingPluginSetupDependencies {
data: DataPluginSetup;
observability: ObservabilityPluginSetup;
}
export interface ObservabilityOnboardingPluginStartDependencies {
data: DataPluginStart;
observability: undefined;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ObservabilityOnboardingPluginSetup {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface ObservabilityOnboardingPluginStart {}
export type ObservabilityOnboardingRequestHandlerContext =
CustomRequestHandlerContext<{}>;

View file

@ -0,0 +1,29 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
},
"include": [
"../../../typings/**/*",
"common/**/*",
"public/**/*",
"typings/**/*",
"public/**/*.json",
"server/**/*"
],
"kbn_references": [
"@kbn/core",
"@kbn/data-plugin",
"@kbn/kibana-react-plugin",
"@kbn/observability-plugin",
"@kbn/i18n",
"@kbn/core-http-browser",
"@kbn/ui-theme",
"@kbn/typed-react-router-config",
"@kbn/server-route-repository",
"@kbn/config-schema",
],
"exclude": [
"target/**/*",
]
}

View file

@ -4641,6 +4641,10 @@
version "0.0.0"
uid ""
"@kbn/observability-onboarding-plugin@link:x-pack/plugins/observability_onboarding":
version "0.0.0"
uid ""
"@kbn/observability-plugin@link:x-pack/plugins/observability":
version "0.0.0"
uid ""