[Beats CM] Backport refactor(#26636) to 6.x (#26886)

* [BeatsCM] Cleanup and refactor (#26636)

* Refactor BeatsCM

* update deps

* update more deps

* update for new EUI definitions

* update import

* Revert "update deps"

This reverts commit 759a14561d.

* use _source_includes

* remove _source_includes

* work-around due to watcher UI tests

* Keep all xpack checks safe because we cant trust its there in tests for some reason

* VALIDATION. This commit is to ensure the errors in CI are coming from beats

* remove validation that this is a beats CM issue

* More try/catch to try and find where this error is

* testing another call

* revert back to dangerouslyGetActiveInjector

* ensure expire always is a number

* fix swallowed error

* Update x-pack/plugins/beats_management/public/lib/compose/kibana.ts

Co-Authored-By: mattapperson <me@mattapperson.com>

* Update x-pack/plugins/beats_management/public/utils/page_loader.test.ts

Co-Authored-By: mattapperson <me@mattapperson.com>

* Update x-pack/plugins/beats_management/public/utils/page_loader.ts

Co-Authored-By: mattapperson <me@mattapperson.com>

* fix for new webpack import

* Fix translation map

* fix URL path

* fix other link

* removing tag from beats via tag details screen now uses container

* remove debug text

* added comment/readme about routing on the client side

* enrolled beat UI now works on overview screen

* newly enrolled beat now reloads the beats table

* fix TS errors

# Conflicts:
#	x-pack/plugins/beats_management/public/lib/adapters/elasticsearch/rest.ts
#	x-pack/plugins/beats_management/server/lib/adapters/beats/elasticsearch_beats_adapter.ts
#	x-pack/plugins/beats_management/server/lib/adapters/database/adapter_types.ts

* fix import for 6.x

* fix formatting
This commit is contained in:
Matt Apperson 2018-12-11 17:59:07 -05:00 committed by GitHub
parent ebdba853e1
commit 80d9fe5bb7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
121 changed files with 2819 additions and 2645 deletions

View file

@ -123,8 +123,8 @@
"@elastic/numeral": "2.3.2",
"@kbn/babel-preset": "1.0.0",
"@kbn/i18n": "1.0.0",
"@kbn/ui-framework": "1.0.0",
"@kbn/interpreter": "1.0.0",
"@kbn/ui-framework": "1.0.0",
"@samverschueren/stream-to-observable": "^0.3.0",
"@scant/router": "^0.1.0",
"@slack/client": "^4.8.0",
@ -179,6 +179,7 @@
"icalendar": "0.7.1",
"inline-style": "^2.0.0",
"intl": "^1.2.5",
"io-ts": "^1.4.2",
"isomorphic-fetch": "2.2.1",
"joi": "^13.5.2",
"jquery": "^3.3.1",
@ -257,6 +258,7 @@
"typescript-fsa-reducers": "^0.4.5",
"ui-select": "0.19.4",
"unbzip2-stream": "1.0.9",
"unstated": "^2.1.1",
"uuid": "3.0.1",
"venn.js": "0.2.9",
"xregexp": "3.2.0"

View file

@ -7,5 +7,6 @@
export { PLUGIN } from './plugin';
export { INDEX_NAMES } from './index_names';
export { UNIQUENESS_ENFORCING_TYPES, ConfigurationBlockTypes } from './configuration_blocks';
export const BASE_PATH = '/management/beats_management/';
export const BASE_PATH = '/management/beats_management';
export { TABLE_CONFIG } from './table';
export { REQUIRED_ROLES, LICENSES, REQUIRED_LICENSES } from './security';

View file

@ -7,3 +7,4 @@
export const PLUGIN = {
ID: 'beats_management',
};
export const CONFIG_PREFIX = 'xpack.beats';

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const REQUIRED_ROLES = ['beats_admin'];
export const REQUIRED_LICENSES = ['standard', 'gold', 'trial', 'platinum'];
export const LICENSES = ['oss', 'standard', 'gold', 'trial', 'platinum'];
export type LicenseType = 'oss' | 'trial' | 'standard' | 'basic' | 'gold' | 'platinum';

View file

@ -6,23 +6,24 @@
import Joi from 'joi';
import { resolve } from 'path';
import { PLUGIN } from './common/constants';
import { CONFIG_PREFIX } from './common/constants/plugin';
import { initServerWithKibana } from './server/kibana.index';
import { KibanaLegacyServer } from './server/lib/adapters/framework/adapter_types';
const DEFAULT_ENROLLMENT_TOKENS_TTL_S = 10 * 60; // 10 minutes
export const config = Joi.object({
enabled: Joi.boolean().default(true),
encryptionKey: Joi.string(),
defaultUserRoles: Joi.array()
.items(Joi.string())
.default(['superuser']),
encryptionKey: Joi.string().default('xpack_beats_default_encryptionKey'),
enrollmentTokensTtlInSeconds: Joi.number()
.integer()
.min(1)
.max(10 * 60 * 14) // No more then 2 weeks for security reasons
.default(DEFAULT_ENROLLMENT_TOKENS_TTL_S),
}).default();
export const configPrefix = 'xpack.beats';
export function beats(kibana: any) {
return new kibana.Plugin({
@ -33,8 +34,8 @@ export function beats(kibana: any) {
managementSections: ['plugins/beats_management'],
},
config: () => config,
configPrefix,
init(server: any) {
configPrefix: CONFIG_PREFIX,
init(server: KibanaLegacyServer) {
initServerWithKibana(server);
},
});

View file

@ -4,9 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
// @ts-ignore typings for EuiBasicTable not present in current version
EuiBasicTable,
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiLoadingSpinner,
@ -15,37 +13,43 @@ import {
EuiSelect,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { FormattedMessage } from '@kbn/i18n/react';
import { capitalize } from 'lodash';
import React from 'react';
import { RouteComponentProps } from 'react-router';
import { CMBeat } from '../../../common/domain_types';
import { AppURLState } from '../../app';
import { URLStateProps, withUrlState } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
import { CMBeat } from '../../common/domain_types';
interface BeatsProps extends URLStateProps<AppURLState>, RouteComponentProps<any> {
match: any;
libs: FrontendLibs;
intl: InjectedIntl;
interface ComponentProps {
/** Such as kibanas basePath, for use to generate command */
frameworkBasePath?: string;
enrollmentToken?: string;
getBeatWithToken(token: string): Promise<CMBeat | null>;
createEnrollmentToken(): Promise<void>;
onBeatEnrolled(enrolledBeat: CMBeat): void;
}
export class EnrollBeat extends React.Component<BeatsProps, any> {
interface ComponentState {
enrolledBeat: CMBeat | null;
hasPolledForBeat: boolean;
command: string;
beatType: string;
}
export class EnrollBeat extends React.Component<ComponentProps, ComponentState> {
private pinging = false;
constructor(props: BeatsProps) {
constructor(props: ComponentProps) {
super(props);
this.state = {
enrolledBeat: null,
hasPolledForBeat: false,
command: 'sudo filebeat',
beatType: 'filebeat',
};
}
public pingForBeatWithToken = async (
libs: FrontendLibs,
token: string
): Promise<CMBeat | void> => {
public pingForBeatWithToken = async (token: string): Promise<CMBeat | void> => {
try {
const beats = await libs.beats.getBeatWithToken(token);
const beats = await this.props.getBeatWithToken(token);
if (!beats) {
throw new Error('no beats');
}
@ -54,69 +58,34 @@ export class EnrollBeat extends React.Component<BeatsProps, any> {
if (this.pinging) {
const timeout = (ms: number) => new Promise(res => setTimeout(res, ms));
await timeout(5000);
return await this.pingForBeatWithToken(libs, token);
return await this.pingForBeatWithToken(token);
}
}
};
public async componentDidMount() {
if (!this.props.urlState.enrollmentToken) {
const enrollmentToken = await this.props.libs.tokens.createEnrollmentToken();
this.props.setUrlState({
enrollmentToken,
});
if (!this.props.enrollmentToken) {
await this.props.createEnrollmentToken();
}
}
public waitForToken = async (token: string) => {
if (this.pinging) {
public waitForTokenToEnrollBeat = async () => {
if (this.pinging || !this.props.enrollmentToken) {
return;
}
this.pinging = true;
const enrolledBeat = (await this.pingForBeatWithToken(this.props.libs, token)) as CMBeat;
const enrolledBeat = (await this.pingForBeatWithToken(this.props.enrollmentToken)) as CMBeat;
this.setState({
enrolledBeat,
});
this.props.onBeatEnrolled(enrolledBeat);
this.pinging = false;
};
public render() {
if (!this.props.urlState.enrollmentToken) {
if (!this.props.enrollmentToken && !this.state.enrolledBeat) {
return null;
}
if (this.props.urlState.enrollmentToken && !this.state.enrolledBeat) {
this.waitForToken(this.props.urlState.enrollmentToken);
}
const { goTo, intl } = this.props;
const actions = [];
switch (this.props.location.pathname) {
case '/overview/initial/beats':
actions.push({
goTo: '/overview/initial/tag',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.continueButtonLabel',
defaultMessage: 'Continue',
}),
});
break;
case '/overview/beats/enroll':
actions.push({
goTo: '/overview/beats/enroll',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.enrollAnotherBeatButtonLabel',
defaultMessage: 'Enroll another Beat',
}),
newToken: true,
});
actions.push({
goTo: '/overview/beats',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.doneButtonLabel',
defaultMessage: 'Done',
}),
clearToken: true,
});
break;
if (this.props.enrollmentToken && !this.state.enrolledBeat) {
this.waitForTokenToEnrollBeat();
}
return (
@ -229,10 +198,7 @@ export class EnrollBeat extends React.Component<BeatsProps, any> {
/>
{`//`}
{window.location.host}
{this.props.libs.framework.baseURLPath
? this.props.libs.framework.baseURLPath
: ''}{' '}
{this.props.urlState.enrollmentToken}
{this.props.frameworkBasePath} {this.props.enrollmentToken}
</div>
</div>
<br />
@ -309,38 +275,9 @@ export class EnrollBeat extends React.Component<BeatsProps, any> {
/>
<br />
<br />
{actions.map(action => (
<EuiButton
key={action.name}
size="s"
color="primary"
style={{ marginLeft: 10 }}
onClick={async () => {
if (action.clearToken) {
this.props.setUrlState({ enrollmentToken: '' });
}
if (action.newToken) {
const enrollmentToken = await this.props.libs.tokens.createEnrollmentToken();
this.props.setUrlState({ enrollmentToken });
return this.setState({
enrolledBeat: null,
});
}
goTo(action.goTo);
}}
>
{action.name}
</EuiButton>
))}
</EuiModalBody>
)}
</React.Fragment>
);
}
}
export const EnrollBeatPageUi = withUrlState<BeatsProps>(EnrollBeat);
export const EnrollBeatPage = injectI18n(EnrollBeatPageUi);

View file

@ -4,11 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
export type FlatObject<T> = { [Key in keyof T]: string };
import styled from 'styled-components';
export interface AppURLState {
beatsKBar?: string;
tagsKBar?: string;
enrollmentToken?: string;
createdTag?: string;
}
export const Background = styled.div`
flex-grow: 1;
height: 100vh;
background: #f5f5f5;
`;

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiBreadcrumbDefinition,
EuiHeader,
EuiHeaderBreadcrumbs,
EuiHeaderSection,
} from '@elastic/eui';
import React from 'react';
import styled from 'styled-components';
interface HeaderProps {
breadcrumbs?: EuiBreadcrumbDefinition[];
}
export class Header extends React.PureComponent<HeaderProps> {
public render() {
const { breadcrumbs = [] } = this.props;
return (
<HeaderWrapper>
<EuiHeaderSection>
<EuiHeaderBreadcrumbs breadcrumbs={[...breadcrumbs]} />
</EuiHeaderSection>
</HeaderWrapper>
);
}
}
const HeaderWrapper = styled(EuiHeader)`
height: 29px;
`;

View file

@ -11,8 +11,6 @@ import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiOverlayMask,
EuiPage,
EuiPageBody,
EuiPageContent,
@ -21,13 +19,11 @@ import {
interface LayoutProps {
title: string;
actionSection?: React.ReactNode;
modalRender?: () => React.ReactNode;
modalClosePath?: string;
}
export const NoDataLayout: React.SFC<LayoutProps> = withRouter<any>(
({ actionSection, title, modalRender, modalClosePath, children, history }) => {
const modalContent = modalRender && modalRender();
({ actionSection, title, modalClosePath, children, history }) => {
return (
<EuiPage>
<EuiPageBody>
@ -44,18 +40,6 @@ export const NoDataLayout: React.SFC<LayoutProps> = withRouter<any>(
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageBody>
{modalContent && (
<EuiOverlayMask>
<EuiModal
onClose={() => {
history.push(modalClosePath);
}}
style={{ width: '640px' }}
>
{modalContent}
</EuiModal>
</EuiOverlayMask>
)}
</EuiPage>
);
}

View file

@ -4,12 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { withRouter } from 'react-router-dom';
import {
EuiModal,
EuiOverlayMask,
EuiHeader,
EuiHeaderBreadcrumbs,
EuiHeaderSection,
EuiPage,
EuiPageBody,
EuiPageContent,
@ -18,45 +16,85 @@ import {
EuiPageHeaderSection,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { Component, ReactNode } from 'react';
import styled from 'styled-components';
import { BreadcrumbConsumer } from '../navigation/breadcrumb';
type RenderCallback = ((component: () => JSX.Element) => void);
interface PrimaryLayoutProps {
title: string;
actionSection?: React.ReactNode;
modalRender?: () => React.ReactNode;
modalClosePath?: string;
hideBreadcrumbs?: boolean;
}
export class PrimaryLayout extends Component<PrimaryLayoutProps> {
private actionSection: (() => JSX.Element) | null = null;
constructor(props: PrimaryLayoutProps) {
super(props);
}
export const PrimaryLayout: React.SFC<PrimaryLayoutProps> = withRouter<any>(
({ actionSection, title, modalRender, modalClosePath, children, history }) => {
const modalContent = modalRender && modalRender();
public render() {
const children: (callback: RenderCallback) => void | ReactNode = this.props.children as any;
return (
<EuiPage>
<EuiPageBody>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle>
<h1>{title}</h1>
</EuiTitle>
</EuiPageHeaderSection>
<EuiPageHeaderSection> {actionSection}</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>{children}</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
{modalContent && (
<EuiOverlayMask>
<EuiModal
onClose={() => {
history.push(modalClosePath);
}}
style={{ width: '640px' }}
>
{modalContent}
</EuiModal>
</EuiOverlayMask>
<React.Fragment>
{!this.props.hideBreadcrumbs && (
<BreadcrumbConsumer>
{({ breadcrumbs }) => (
<HeaderWrapper>
<EuiHeaderSection>
<EuiHeaderBreadcrumbs
breadcrumbs={[
{
href: '#/management',
text: i18n.translate('xpack.beatsManagement.breadcrumb.managementTitle', {
defaultMessage: 'Management',
}),
},
{
href: '#/management/beats_management',
text: i18n.translate('xpack.beatsManagement.breadcrumb.beatsTitle', {
defaultMessage: 'Beats',
}),
},
...breadcrumbs,
]}
/>
</EuiHeaderSection>
</HeaderWrapper>
)}
</BreadcrumbConsumer>
)}
</EuiPage>
<EuiPage>
<EuiPageBody>
<EuiPageHeader>
<EuiPageHeaderSection>
<EuiTitle>
<h1>{this.props.title}</h1>
</EuiTitle>
</EuiPageHeaderSection>
<EuiPageHeaderSection>
{(this.actionSection && this.actionSection()) || this.props.actionSection}
</EuiPageHeaderSection>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
{(children && typeof children === 'function'
? children(this.renderAction)
: children) || <span />}
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</React.Fragment>
);
}
) as any;
private renderAction = (component: () => JSX.Element) => {
this.actionSection = component;
this.forceUpdate();
};
}
const HeaderWrapper = styled(EuiHeader)`
height: 29px;
`;

View file

@ -22,7 +22,6 @@ interface LayoutProps {
walkthroughSteps: Array<{
id: string;
name: string;
disabled: boolean;
}>;
activePath: string;
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import * as React from 'react';
export const Loading: React.SFC<{}> = () => (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xl" />
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import { RouteProps } from 'react-router';
import { BASE_PATH } from 'x-pack/plugins/beats_management/common/constants';
import { BreadcrumbConsumer } from './consumer';
import { Breadcrumb as BreadcrumbData, BreadcrumbContext } from './types';
interface BreadcrumbManagerProps extends RouteProps {
text: string;
href: string;
parents?: BreadcrumbData[];
context: BreadcrumbContext;
}
class BreadcrumbManager extends Component<BreadcrumbManagerProps, {}, BreadcrumbContext> {
public componentWillUnmount() {
const { text, href, context } = this.props;
context.removeCrumb({
text,
href,
});
}
public componentDidMount() {
const { text, href, parents, context } = this.props;
context.addCrumb(
{
text,
href,
},
parents
);
}
public render() {
return <span />;
}
}
interface BreadcrumbProps extends RouteProps {
title: string;
path: string;
parentBreadcrumbs?: BreadcrumbData[];
}
export const Breadcrumb: React.SFC<BreadcrumbProps> = ({ title, path, parentBreadcrumbs }) => (
<BreadcrumbConsumer>
{context => (
<BreadcrumbManager
text={title}
href={`#${BASE_PATH}${path}`}
parents={parentBreadcrumbs}
context={context}
/>
)}
</BreadcrumbConsumer>
);

View file

@ -6,4 +6,4 @@
export { BreadcrumbProvider } from './provider';
export { BreadcrumbConsumer } from './consumer';
export { RouteWithBreadcrumb } from './route_with_breadcrumb';
export { Breadcrumb } from './breadcrumb';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { SFC } from 'react';
import { Route, Switch } from 'react-router-dom';
interface RouteConfig {
path: string;
component: React.ComponentType<any>;
routes?: RouteConfig[];
}
export const ChildRoutes: SFC<{
routes?: RouteConfig[];
useSwitch?: boolean;
[other: string]: any;
}> = ({ routes, useSwitch = true, ...rest }) => {
if (!routes) {
return null;
}
const Parent = useSwitch ? Switch : React.Fragment;
return (
<Parent>
{routes.map(route => (
<Route
key={route.path}
path={route.path}
render={routeProps => {
const Component = route.component;
return <Component {...routeProps} routes={route.routes} {...rest} />;
}}
/>
))}
</Parent>
);
};

View file

@ -13,6 +13,7 @@ export function ConnectedLinkComponent({
path,
query,
disabled,
children,
...props
}: {
location: any;
@ -30,7 +31,7 @@ export function ConnectedLinkComponent({
return (
<Link
{...props}
children={children}
to={{ ...location, ...props.to, pathname, query }}
className={`euiLink euiLink--primary ${props.className || ''}`}
/>

View file

@ -1,88 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import { RouteProps } from 'react-router';
import { Route } from 'react-router-dom';
import { BreadcrumbConsumer } from './consumer';
import { Breadcrumb, BreadcrumbContext } from './types';
interface WrappedRouteWithBreadcrumbProps extends RouteProps {
text: string;
href: string;
parents?: Breadcrumb[];
context: BreadcrumbContext;
}
class WrappedRouteWithBreadcrumb extends Component<
WrappedRouteWithBreadcrumbProps,
{},
BreadcrumbContext
> {
public componentWillUnmount() {
const { text, href, context } = this.props;
context.removeCrumb({
text,
href,
});
}
public componentDidMount() {
const { text, href, parents, context } = this.props;
context.addCrumb(
{
text,
href,
},
parents
);
}
public render() {
return this.props.children;
}
}
type titleCallback = (
urlParams: {
[key: string]: string;
}
) => string;
interface RouteWithBreadcrumbProps extends RouteProps {
title: string | titleCallback;
path: string;
parentBreadcrumbs?: Breadcrumb[];
}
export const RouteWithBreadcrumb: React.SFC<RouteWithBreadcrumbProps> = ({
title,
render,
component: RouteComponent,
parentBreadcrumbs,
...props
}) => (
<Route
{...props}
render={renderProps => {
return (
<BreadcrumbConsumer>
{context => (
<WrappedRouteWithBreadcrumb
parents={parentBreadcrumbs}
href={props.path}
text={typeof title === 'function' ? title(renderProps.match.params) : title}
context={context}
>
{render && render(renderProps)}
{RouteComponent && <RouteComponent {...renderProps} />}
</WrappedRouteWithBreadcrumb>
)}
</BreadcrumbConsumer>
);
}}
/>
);

View file

@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { AutocompleteField } from '../autocomplete_field/index';
import { OptionControl } from '../table_controls';
import { OptionControl } from './controls/index';
import { AssignmentOptions as AssignmentOptionsType, KueryBarProps } from './table';
interface ControlBarProps {

View file

@ -19,7 +19,7 @@ import { EuiIcon } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { isArray } from 'lodash';
import React from 'react';
import { AssignmentControlSchema } from '../table';
import { AssignmentControlSchema } from '../index';
import { AssignmentActionType } from '../table';
import { ActionControl } from './action_control';
import { TagBadgeList } from './tag_badge_list';

View file

@ -6,8 +6,8 @@
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import { TABLE_CONFIG } from '../../../common/constants';
import { TagBadge } from '../tag/tag_badge';
import { TABLE_CONFIG } from '../../../../common/constants';
import { TagBadge } from '../../tag/tag_badge';
interface TagAssignmentProps {
tag: any;

View file

@ -6,7 +6,7 @@
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { AssignmentActionType } from '../table/table';
import { AssignmentActionType } from '../index';
import { TagAssignment } from './tag_assignment';
interface TagBadgeListProps {

View file

@ -10,7 +10,7 @@ import { first, sortBy, sortByOrder, uniq } from 'lodash';
import moment from 'moment';
import React from 'react';
import { BeatTag, CMPopulatedBeat, ConfigurationBlock } from '../../../common/domain_types';
import { ConnectedLink } from '../connected_link';
import { ConnectedLink } from '../navigation/connected_link';
import { TagBadge } from '../tag';
export interface ColumnDefinition {
@ -61,7 +61,7 @@ export const BeatsTableType: TableType = {
defaultMessage: 'Beat name',
}),
render: (name: string, beat: CMPopulatedBeat) => (
<ConnectedLink path={`/beat/${beat.id}`}>{name}</ConnectedLink>
<ConnectedLink path={`/beat/${beat.id}/details`}>{name}</ConnectedLink>
),
sortable: true,
},
@ -91,7 +91,6 @@ export const BeatsTableType: TableType = {
sortable: false,
},
{
// TODO: update to use actual metadata field
field: 'config_status',
name: i18n.translate('xpack.beatsManagement.beatsTable.configStatusTitle', {
defaultMessage: 'Config Status',

View file

@ -10,7 +10,7 @@ import yaml from 'js-yaml';
import { get } from 'lodash';
import React from 'react';
import { ConfigurationBlock } from '../../../../common/domain_types';
import { YamlConfigSchema } from '../../../lib/lib';
import { YamlConfigSchema } from '../../../lib/types';
import {
FormsyEuiCodeEditor,
FormsyEuiFieldText,

View file

@ -17,11 +17,9 @@ import {
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiFormRow,
// @ts-ignore
EuiHorizontalRule,
// @ts-ignore
EuiSearchBar,
// @ts-ignore
EuiSelect,
// @ts-ignore
EuiTabbedContent,

View file

@ -19,26 +19,23 @@ import {
EuiText,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import 'brace/mode/yaml';
import 'brace/theme/github';
import { isEqual } from 'lodash';
import React from 'react';
import { BeatTag, CMBeat, ConfigurationBlock } from '../../../common/domain_types';
import { ConfigList } from '../config_list';
import { AssignmentActionType, Table } from '../table';
import { BeatsTableType } from '../table';
import { tagConfigAssignmentOptions } from '../table';
import { AssignmentActionType, BeatsTableType, Table, tagConfigAssignmentOptions } from '../table';
import { ConfigView } from './config_view';
import { TagBadge } from './tag_badge';
interface TagEditProps {
mode: 'edit' | 'create';
tag: Pick<BeatTag, Exclude<keyof BeatTag, 'last_updated'>>;
onDetachBeat: (beatIds: string[]) => void;
onDetachBeat?: (beatIds: string[]) => void;
onTagChange: (field: keyof BeatTag, value: string) => any;
attachedBeats: CMBeat[] | null;
intl: InjectedIntl;
attachedBeats?: CMBeat[];
}
interface TagEditState {
@ -47,7 +44,7 @@ interface TagEditState {
selectedConfigIndex?: number;
}
class TagEditUi extends React.PureComponent<TagEditProps, TagEditState> {
export class TagEdit extends React.PureComponent<TagEditProps, TagEditState> {
constructor(props: TagEditProps) {
super(props);
@ -58,7 +55,7 @@ class TagEditUi extends React.PureComponent<TagEditProps, TagEditState> {
}
public render() {
const { tag, attachedBeats, intl } = this.props;
const { tag, attachedBeats } = this.props;
return (
<div>
<EuiFlexGroup>
@ -99,18 +96,16 @@ class TagEditUi extends React.PureComponent<TagEditProps, TagEditState> {
name="name"
isInvalid={!!this.getNameError(tag.id)}
onChange={this.updateTag('id')}
disabled={this.props.mode === 'edit'}
disabled={!!this.props.onDetachBeat}
value={tag.id}
placeholder={intl.formatMessage({
id: 'xpack.beatsManagement.tag.tagNamePlaceholder',
placeholder={i18n.translate('xpack.beatsManagement.tag.tagNamePlaceholder', {
defaultMessage: 'Tag name (required)',
})}
/>
</EuiFormRow>
{this.props.mode === 'create' && (
{!this.props.onDetachBeat && (
<EuiFormRow
label={intl.formatMessage({
id: 'xpack.beatsManagement.tag.tagColorLabel',
label={i18n.translate('xpack.beatsManagement.tag.tagColorLabel', {
defaultMessage: 'Tag Color',
})}
>
@ -236,10 +231,8 @@ class TagEditUi extends React.PureComponent<TagEditProps, TagEditState> {
}
private getNameError = (name: string) => {
const { intl } = this.props;
if (name && name !== '' && name.search(/^[a-zA-Z0-9-]+$/) === -1) {
return intl.formatMessage({
id: 'xpack.beatsManagement.tag.tagName.validationErrorMessage',
return i18n.translate('xpack.beatsManagement.tag.tagName.validationErrorMessage', {
defaultMessage: 'Tag name must consist of letters, numbers, and dashes only',
});
} else {
@ -251,15 +244,14 @@ class TagEditUi extends React.PureComponent<TagEditProps, TagEditState> {
switch (action) {
case AssignmentActionType.Delete:
const { selection } = this.state.tableRef.current.state;
this.props.onDetachBeat(selection.map((beat: any) => beat.id));
if (this.props.onDetachBeat) {
this.props.onDetachBeat(selection.map((beat: any) => beat.id));
}
}
};
// TODO this should disable save button on bad validations
private updateTag = (key: keyof BeatTag, value?: any) =>
value !== undefined
? this.props.onTagChange(key, value)
: (e: any) => this.props.onTagChange(key, e.target ? e.target.value : e);
}
export const TagEdit = injectI18n(TagEditUi);

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { YamlConfigSchema } from './lib/lib';
import { YamlConfigSchema } from './lib/types';
const filebeatInputConfig: YamlConfigSchema[] = [
{

View file

@ -7,7 +7,7 @@
import { i18n } from '@kbn/i18n';
import { cloneDeep } from 'lodash';
import { supportedConfigs } from './config_schemas';
import { YamlConfigSchema } from './lib/lib';
import { YamlConfigSchema } from './lib/types';
interface ConfigSchema {
text: string;
@ -226,10 +226,9 @@ export const getSupportedConfig = () => {
}
translatedConfigs = cloneDeep(supportedConfigs);
translatedConfigs.forEach(({ text, config }) => {
translatedConfigs.forEach(({ text, config }, index) => {
if (text) {
text = supportedConfigLabelsMap.get(text) || '';
translatedConfigs[index].text = supportedConfigLabelsMap.get(text) || '';
}
config.forEach(yanlConfig => {

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Container } from 'unstated';
import { CMPopulatedBeat } from './../../common/domain_types';
import { BeatsTagAssignment } from './../../server/lib/adapters/beats/adapter_types';
import { FrontendLibs } from './../lib/types';
interface ContainerState {
list: CMPopulatedBeat[];
}
export class BeatsContainer extends Container<ContainerState> {
private query?: string;
constructor(private readonly libs: FrontendLibs) {
super();
this.state = {
list: [],
};
}
public getBeatWithToken = async (token: string) => {
const beat = await this.libs.beats.getBeatWithToken(token);
if (beat) {
this.setState({
list: [beat as CMPopulatedBeat, ...this.state.list],
});
return beat as CMPopulatedBeat;
}
return null;
};
public reload = async (kuery?: string) => {
if (kuery) {
this.query = await this.libs.elasticsearch.convertKueryToEsQuery(kuery);
} else {
this.query = undefined;
}
const beats = await this.libs.beats.getAll(this.query);
this.setState({
list: beats,
});
};
public deactivate = async (beats: CMPopulatedBeat[]) => {
for (const beat of beats) {
await this.libs.beats.update(beat.id, { active: false });
}
// because the compile code above has a very minor race condition, we wait,
// the max race condition time is really 10ms but doing 100 to be safe
setTimeout(async () => {
await this.reload(this.query);
}, 100);
};
public toggleTagAssignment = async (tagId: string, beats: CMPopulatedBeat[]) => {
if (beats.some(beat => beat.full_tags.some(({ id }) => id === tagId))) {
await this.removeTagsFromBeats(beats, tagId);
return 'removed';
}
await this.assignTagsToBeats(beats, tagId);
return 'added';
};
public removeTagsFromBeats = async (beats: CMPopulatedBeat[] | string[], tagId: string) => {
if (!beats.length) {
return false;
}
const assignments = createBeatTagAssignments(beats, tagId);
await this.libs.beats.removeTagsFromBeats(assignments);
await this.reload(this.query);
};
public assignTagsToBeats = async (beats: CMPopulatedBeat[] | string[], tagId: string) => {
if (!beats.length) {
return false;
}
const assignments = createBeatTagAssignments(beats, tagId);
await this.libs.beats.assignTagsToBeats(assignments);
await this.reload(this.query);
};
}
function createBeatTagAssignments(
beats: CMPopulatedBeat[] | string[],
tagId: string
): BeatsTagAssignment[] {
if (typeof beats[0] === 'string') {
return (beats as string[]).map(id => ({ beatId: id, tag: tagId }));
} else {
return (beats as CMPopulatedBeat[]).map(({ id }) => ({ beatId: id, tag: tagId }));
}
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { Container } from 'unstated';
import { BeatTag } from '../../common/domain_types';
import { FrontendLibs } from '../lib/types';
interface ContainerState {
list: BeatTag[];
}
export class TagsContainer extends Container<ContainerState> {
private query?: string;
constructor(private readonly libs: FrontendLibs) {
super();
this.state = {
list: [],
};
}
public reload = async (kuery?: string) => {
if (kuery) {
this.query = await this.libs.elasticsearch.convertKueryToEsQuery(kuery);
} else {
this.query = undefined;
}
const tags = await this.libs.tags.getAll(this.query);
this.setState({
list: tags,
});
};
public delete = async (tags: BeatTag[]) => {
const tagIds = tags.map((tag: BeatTag) => tag.id);
const success = await this.libs.tags.delete(tagIds);
if (success) {
this.reload(this.query);
}
return success;
};
}

View file

@ -8,7 +8,7 @@ import React from 'react';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import { FrontendLibs } from '../lib/lib';
import { FrontendLibs } from '../lib/types';
import { RendererFunction } from '../utils/typed_react';
interface WithKueryAutocompletionLifecycleProps {

View file

@ -7,7 +7,7 @@
import { parse, stringify } from 'querystring';
import React from 'react';
import { withRouter } from 'react-router-dom';
import { FlatObject } from '../app';
import { FlatObject } from '../frontend_types';
import { RendererFunction } from '../utils/typed_react';
type StateCallback<T> = (previousState: T) => T;
@ -88,7 +88,9 @@ export class WithURLStateComponent<URLState extends object> extends React.Compon
}
export const WithURLState = withRouter<any>(WithURLStateComponent);
export function withUrlState<OP>(UnwrappedComponent: React.ComponentType<OP>): React.SFC<any> {
export function withUrlState<OP>(
UnwrappedComponent: React.ComponentType<OP & URLStateProps>
): React.SFC<any> {
return (origProps: OP) => {
return (
<WithURLState>

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RouteComponentProps } from 'react-router';
import { BeatsContainer } from './containers/beats';
import { TagsContainer } from './containers/tags';
import { URLStateProps } from './containers/with_url_state';
import { FrontendLibs } from './lib/types';
export type FlatObject<T> = { [Key in keyof T]: string };
export interface AppURLState {
beatsKBar?: string;
tagsKBar?: string;
enrollmentToken?: string;
createdTag?: string;
}
export interface RouteConfig {
path: string;
component: React.ComponentType<any>;
routes?: RouteConfig[];
}
export interface AppPageProps extends URLStateProps<AppURLState>, RouteComponentProps<any> {
libs: FrontendLibs;
containers: {
beats: BeatsContainer;
tags: TagsContainer;
};
routes?: RouteConfig[];
}

View file

@ -8,30 +8,60 @@ import * as euiVars from '@elastic/eui/dist/eui_theme_k6_light.json';
import { i18n } from '@kbn/i18n';
import { I18nProvider } from '@kbn/i18n/react';
import React from 'react';
import { HashRouter } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { Provider as UnstatedProvider, Subscribe } from 'unstated';
import { BASE_PATH } from '../common/constants';
import { BreadcrumbProvider } from './components/route_with_breadcrumb';
import { Background } from './components/layouts/background';
import { BreadcrumbProvider } from './components/navigation/breadcrumb';
import { BeatsContainer } from './containers/beats';
import { TagsContainer } from './containers/tags';
import { compose } from './lib/compose/kibana';
import { FrontendLibs } from './lib/lib';
import { PageRouter } from './router';
import { FrontendLibs } from './lib/types';
import { AppRouter } from './router';
function startApp(libs: FrontendLibs) {
libs.framework.registerManagementSection(
'beats',
i18n.translate('xpack.beatsManagement.managementMainPage.centralManagementLinkLabel', {
defaultMessage: 'Central Management (Beta)',
}),
BASE_PATH
);
libs.framework.render(
<I18nProvider>
<ThemeProvider theme={{ eui: euiVars }}>
<BreadcrumbProvider>
<PageRouter libs={libs} />
</BreadcrumbProvider>
</ThemeProvider>
</I18nProvider>
async function startApp(libs: FrontendLibs) {
libs.framework.renderUIAtPath(
BASE_PATH,
<ThemeProvider theme={{ eui: euiVars }}>
<I18nProvider>
<HashRouter basename="/management/beats_management">
<UnstatedProvider inject={[new BeatsContainer(libs), new TagsContainer(libs)]}>
<BreadcrumbProvider>
<Subscribe to={[BeatsContainer, TagsContainer]}>
{(beats: BeatsContainer, tags: TagsContainer) => (
<Background>
<AppRouter libs={libs} beatsContainer={beats} tagsContainer={tags} />
</Background>
)}
</Subscribe>
</BreadcrumbProvider>
</UnstatedProvider>
</HashRouter>
</I18nProvider>
</ThemeProvider>,
libs.framework.getUISetting('k7design') ? 'management' : 'self'
);
await libs.framework.waitUntilFrameworkReady();
if (libs.framework.licenseIsAtLeast('standard')) {
libs.framework.registerManagementSection({
id: 'beats',
name: i18n.translate('xpack.beatsManagement.centralManagementSectionLabel', {
defaultMessage: 'Beats',
}),
iconName: 'logoBeats',
});
libs.framework.registerManagementUI({
sectionId: 'beats',
name: i18n.translate('xpack.beatsManagement.centralManagementLinkLabel', {
defaultMessage: 'Central Management (Beta)',
}),
basePath: BASE_PATH,
});
}
}
startApp(compose());

View file

@ -24,7 +24,7 @@ export class RestBeatsAdapter implements CMBeatsAdapter {
return beat;
}
public async getAll(ESQuery?: any): Promise<CMBeat[]> {
public async getAll(ESQuery?: string): Promise<CMBeat[]> {
return (await this.REST.get<{ beats: CMBeat[] }>('/api/beats/agents/all', { ESQuery })).beats;
}

View file

@ -6,7 +6,6 @@
import { isEmpty } from 'lodash';
import { AutocompleteSuggestion, getAutocompleteProvider } from 'ui/autocomplete_providers';
// @ts-ignore TODO type this
import { fromKueryExpression, toElasticsearchQuery } from 'ui/kuery';
import { RestAPIAdapter } from '../rest_api/adapter_types';
import { ElasticsearchAdapter } from './adapter_types';

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';
import { LICENSES } from './../../../../common/constants/security';
export interface FrameworkAdapter {
// Instance vars
info: FrameworkInfo;
currentUser: FrameworkUser;
// Methods
waitUntilFrameworkReady(): Promise<void>;
renderUIAtPath(path: string, component: React.ReactElement<any>): void;
registerManagementSection(settings: {
id?: string;
name: string;
iconName: string;
order?: number;
}): void;
registerManagementUI(settings: {
id?: string;
name: string;
basePath: string;
visable?: boolean;
order?: number;
}): void;
setUISettings(key: string, value: any): void;
getUISetting(key: 'k7design'): boolean;
}
export const RuntimeFrameworkInfo = t.type({
basePath: t.string,
k7Design: t.boolean,
license: t.type({
type: t.union(LICENSES.map(s => t.literal(s))),
expired: t.boolean,
expiry_date_in_millis: t.number,
}),
security: t.type({
enabled: t.boolean,
available: t.boolean,
}),
settings: t.type({
encryptionKey: t.string,
enrollmentTokensTtlInSeconds: t.number,
defaultUserRoles: t.array(t.string),
}),
});
export interface FrameworkInfo extends t.TypeOf<typeof RuntimeFrameworkInfo> {}
interface ManagementSection {
register(
sectionId: string,
options: {
visible: boolean;
display: string;
order: number;
url: string;
}
): void;
}
export interface ManagementAPI {
getSection(sectionId: string): ManagementSection;
hasItem(sectionId: string): boolean;
register(sectionId: string, options: { display: string; icon: string; order: number }): void;
}
export const RuntimeFrameworkUser = t.interface(
{
username: t.string,
roles: t.array(t.string),
full_name: t.union([t.null, t.string]),
email: t.union([t.null, t.string]),
enabled: t.boolean,
},
'FrameworkUser'
);
export interface FrameworkUser extends t.TypeOf<typeof RuntimeFrameworkUser> {}

View file

@ -4,49 +4,64 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { IModule, IScope } from 'angular';
import { IScope } from 'angular';
import { PathReporter } from 'io-ts/lib/PathReporter';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { i18n } from '@kbn/i18n';
import { UIRoutes } from 'ui/routes';
import { BufferedKibanaServiceCall, KibanaAdapterServiceRefs, KibanaUIConfig } from '../../types';
import {
BufferedKibanaServiceCall,
FrameworkAdapter,
KibanaAdapterServiceRefs,
KibanaUIConfig,
} from '../../lib';
FrameworkInfo,
FrameworkUser,
ManagementAPI,
RuntimeFrameworkInfo,
RuntimeFrameworkUser,
} from './adapter_types';
interface IInjector {
get(injectable: string): any;
}
export class KibanaFrameworkAdapter implements FrameworkAdapter {
public appState: object;
private management: any;
private adapterService: KibanaAdapterServiceProvider;
private rootComponent: React.ReactElement<any> | null = null;
private uiModule: IModule;
private routes: any;
private XPackInfoProvider: any;
private xpackInfo: null | any;
private chrome: any;
private shieldUser: any;
constructor(
uiModule: IModule,
management: any,
routes: any,
chrome: any,
XPackInfoProvider: any
) {
this.adapterService = new KibanaAdapterServiceProvider();
this.management = management;
this.uiModule = uiModule;
this.routes = routes;
this.chrome = chrome;
this.XPackInfoProvider = XPackInfoProvider;
this.appState = {};
public get info() {
if (this.xpackInfo) {
return this.xpackInfo;
} else {
throw new Error('framework adapter must have init called before anything else');
}
}
public get baseURLPath(): string {
return this.chrome.getBasePath();
public get currentUser() {
return this.shieldUser!;
}
private xpackInfo: FrameworkInfo | null = null;
private adapterService: KibanaAdapterServiceProvider;
private shieldUser: FrameworkUser | null = null;
private settingSubscription: any;
constructor(
private readonly PLUGIN_ID: string,
private readonly management: ManagementAPI,
private readonly routes: UIRoutes,
private readonly getBasePath: () => string,
private readonly onKibanaReady: () => Promise<IInjector>,
private readonly XPackInfoProvider: unknown,
private readonly uiSettings: any
) {
this.adapterService = new KibanaAdapterServiceProvider();
this.settingSubscription = uiSettings.getUpdate$().subscribe({
next: ({ key, newValue }: { key: string; newValue: boolean }) => {
if (key === 'k7design' && this.xpackInfo) {
this.xpackInfo.k7Design = newValue;
}
},
});
}
// We dont really want to have this, but it's needed to conditionaly render for k7 due to
// when that data is needed.
public getUISetting(key: 'k7design'): boolean {
return this.uiSettings.get(key);
}
public setUISettings = (key: string, value: any) => {
@ -55,71 +70,137 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter {
});
};
public render = (component: React.ReactElement<any>) => {
this.rootComponent = component;
};
public async waitUntilFrameworkReady(): Promise<void> {
const $injector = await this.onKibanaReady();
const Private: any = $injector.get('Private');
public hasValidLicense() {
if (!this.xpackInfo) {
return false;
}
return this.xpackInfo.get('features.beats_management.licenseValid', false);
}
public licenseExpired() {
if (!this.xpackInfo) {
return false;
}
return this.xpackInfo.get('features.beats_management.licenseExpired', false);
}
public securityEnabled() {
if (!this.xpackInfo) {
return false;
}
return this.xpackInfo.get('features.beats_management.securityEnabled', false);
}
public getDefaultUserRoles() {
if (!this.xpackInfo) {
return [];
}
return this.xpackInfo.get('features.beats_management.defaultUserRoles');
}
public getCurrentUser() {
let xpackInfo: any;
try {
return this.shieldUser;
xpackInfo = Private(this.XPackInfoProvider);
} catch (e) {
return null;
xpackInfo = false;
}
let xpackInfoUnpacked: FrameworkInfo;
try {
xpackInfoUnpacked = {
basePath: this.getBasePath(),
k7Design: this.uiSettings.get('k7design'),
license: {
type: xpackInfo ? xpackInfo.getLicense().type : 'oss',
expired: xpackInfo ? !xpackInfo.getLicense().isActive : false,
expiry_date_in_millis: xpackInfo ? xpackInfo.getLicense().expiryDateInMillis : 0,
},
security: {
enabled: xpackInfo
? xpackInfo.get(`features.${this.PLUGIN_ID}.security.enabled`, false)
: false,
available: xpackInfo
? xpackInfo.get(`features.${this.PLUGIN_ID}.security.available`, false)
: false,
},
settings: xpackInfo ? xpackInfo.get(`features.${this.PLUGIN_ID}.settings`) : {},
};
} catch (e) {
throw new Error(`Unexpected data structure from XPackInfoProvider, ${JSON.stringify(e)}`);
}
const assertData = RuntimeFrameworkInfo.decode(xpackInfoUnpacked);
if (assertData.isLeft()) {
throw new Error(
`Error parsing xpack info in ${this.PLUGIN_ID}, ${PathReporter.report(assertData)[0]}`
);
}
this.xpackInfo = xpackInfoUnpacked;
try {
this.shieldUser = await $injector.get('ShieldUser').getCurrent().$promise;
const assertUser = RuntimeFrameworkUser.decode(this.shieldUser);
if (assertUser.isLeft()) {
throw new Error(
`Error parsing user info in ${this.PLUGIN_ID}, ${PathReporter.report(assertUser)[0]}`
);
}
} catch (e) {
this.shieldUser = null;
}
}
public registerManagementSection(pluginId: string, displayName: string, basePath: string) {
this.register(this.uiModule);
this.hookAngular(() => {
if (this.hasValidLicense()) {
const registerSection = () =>
this.management.register(pluginId, {
display: i18n.translate('xpack.beatsManagement.beatsDislayName', {
defaultMessage: 'Beats',
}), // TODO these need to be config options not hard coded in the adapter
icon: 'logoBeats',
order: 30,
});
const getSection = () => this.management.getSection(pluginId);
const section = this.management.hasItem(pluginId) ? getSection() : registerSection();
section.register(pluginId, {
visible: true,
display: displayName,
order: 30,
url: `#${basePath}`,
});
public renderUIAtPath(
path: string,
component: React.ReactElement<any>,
toController: 'management' | 'self' = 'self'
) {
const DOM_ELEMENT_NAME = this.PLUGIN_ID.replace('_', '-');
const adapter = this;
this.routes.when(
`${path}${[...Array(6)].map((e, n) => `/:arg${n}?`).join('')}`, // Hack because angular 1 does not support wildcards
{
template:
toController === 'self'
? `<${DOM_ELEMENT_NAME}><div id="${DOM_ELEMENT_NAME}ReactRoot"></div></${DOM_ELEMENT_NAME}>`
: `<kbn-management-app section="${this.PLUGIN_ID.replace('_', '-')}">
<div id="${DOM_ELEMENT_NAME}ReactRoot" />
</kbn-management-app>`,
// tslint:disable-next-line: max-classes-per-file
controller: ($scope: any, $route: any) => {
try {
$scope.$$postDigest(() => {
const elem = document.getElementById(`${DOM_ELEMENT_NAME}ReactRoot`);
ReactDOM.render(component, elem);
adapter.manageAngularLifecycle($scope, $route, elem);
});
$scope.$onInit = () => {
$scope.topNavMenu = [];
};
} catch (e) {
throw new Error(`Error rendering Beats CM to the dom, ${e.message}`);
}
},
}
);
}
public registerManagementSection(settings: {
id?: string;
name: string;
iconName: string;
order?: number;
}) {
const sectionId = settings.id || this.PLUGIN_ID;
if (!this.management.hasItem(sectionId)) {
this.management.register(sectionId, {
display: settings.name,
icon: settings.iconName,
order: settings.order || 30,
});
}
}
public registerManagementUI(settings: {
sectionId?: string;
name: string;
basePath: string;
visable?: boolean;
order?: number;
}) {
const sectionId = settings.sectionId || this.PLUGIN_ID;
if (!this.management.hasItem(sectionId)) {
throw new Error(
`registerManagementUI was called with a sectionId of ${sectionId}, and that is is not yet regestered as a section`
);
}
const section = this.management.getSection(sectionId);
section.register(sectionId, {
visible: settings.visable || true,
display: settings.name,
order: settings.order || 30,
url: `#${settings.basePath}`,
});
}
@ -131,58 +212,27 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter {
if (lastRoute.$$route.template === currentRoute.$$route.template) {
// this prevents angular from destroying scope
$route.current = lastRoute;
} else {
if (elem) {
ReactDOM.unmountComponentAtNode(elem);
elem.remove();
this.settingSubscription.unsubscribe();
}
}
});
$scope.$on('$destroy', () => {
if (deregister) {
deregister();
}
// manually unmount component when scope is destroyed
if (elem) {
ReactDOM.unmountComponentAtNode(elem);
elem.remove();
this.settingSubscription.unsubscribe();
}
});
}
private hookAngular(done: () => any) {
this.chrome.dangerouslyGetActiveInjector().then(async ($injector: any) => {
const Private = $injector.get('Private');
const xpackInfo = Private(this.XPackInfoProvider);
this.xpackInfo = xpackInfo;
if (this.securityEnabled()) {
try {
this.shieldUser = await $injector.get('ShieldUser').getCurrent().$promise;
} catch (e) {
// errors when security disabled, even though we check first because angular
}
}
done();
});
}
private register = (adapterModule: IModule) => {
const adapter = this;
this.routes.when(`/management/beats_management/:view?/:id?/:other?/:other2?`, {
template:
'<beats-cm><div id="beatsReactRoot" style="flex-grow: 1; height: 100vh; background: #f5f5f5"></div></beats-cm>',
controllerAs: 'beatsManagement',
// tslint:disable-next-line: max-classes-per-file
controller: class BeatsManagementController {
constructor($scope: any, $route: any) {
$scope.$$postDigest(() => {
const elem = document.getElementById('beatsReactRoot');
ReactDOM.render(adapter.rootComponent as React.ReactElement<any>, elem);
adapter.manageAngularLifecycle($scope, $route, elem);
});
$scope.$onInit = () => {
$scope.topNavMenu = [];
};
}
},
});
};
}
// tslint:disable-next-line: max-classes-per-file

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FlatObject } from '../../../app';
import { FlatObject } from '../../../frontend_types';
export interface RestAPIAdapter {
get<ResponseData>(url: string, query?: FlatObject<object>): Promise<ResponseData>;

View file

@ -5,7 +5,7 @@
*/
import axios, { AxiosInstance } from 'axios';
import { FlatObject } from '../../../app';
import { FlatObject } from '../../../frontend_types';
import { RestAPIAdapter } from './adapter_types';
let globalAPI: AxiosInstance;

View file

@ -8,6 +8,6 @@ import { BeatTag } from '../../../../common/domain_types';
export interface CMTagsAdapter {
getTagsWithIds(tagIds: string[]): Promise<BeatTag[]>;
delete(tagIds: string[]): Promise<boolean>;
getAll(): Promise<BeatTag[]>;
getAll(ESQuery?: string): Promise<BeatTag[]>;
upsertTag(tag: BeatTag): Promise<BeatTag | null>;
}

View file

@ -23,7 +23,7 @@ export class MemoryTagsAdapter implements CMTagsAdapter {
return true;
}
public async getAll() {
public async getAll(ESQuery?: string) {
return this.tagsDB;
}

View file

@ -16,8 +16,8 @@ export class RestTagsAdapter implements CMTagsAdapter {
return tags;
}
public async getAll(): Promise<BeatTag[]> {
return await this.REST.get<BeatTag[]>(`/api/beats/tags`);
public async getAll(ESQuery: string): Promise<BeatTag[]> {
return await this.REST.get<BeatTag[]>(`/api/beats/tags`, { ESQuery });
}
public async delete(tagIds: string[]): Promise<boolean> {

View file

@ -12,7 +12,7 @@ import {
CMAssignmentReturn,
CMBeatsAdapter,
} from './adapters/beats/adapter_types';
import { FrontendDomainLibs } from './lib';
import { FrontendDomainLibs } from './types';
export class BeatsLib {
constructor(
@ -20,44 +20,58 @@ export class BeatsLib {
private readonly libs: { tags: FrontendDomainLibs['tags'] }
) {}
/** Get a single beat using it's ID for lookup */
public async get(id: string): Promise<CMPopulatedBeat | null> {
const beat = await this.adapter.get(id);
return beat ? (await this.mergeInTags([beat]))[0] : null;
}
public async getBeatWithToken(enrollmentToken: string): Promise<CMBeat | null> {
/** Get a single beat using the token it was enrolled in for lookup */
public getBeatWithToken = async (enrollmentToken: string): Promise<CMBeat | null> => {
const beat = await this.adapter.getBeatWithToken(enrollmentToken);
return beat;
}
};
public async getBeatsWithTag(tagId: string): Promise<CMPopulatedBeat[]> {
/** Get an array of beats that have a given tag id assigned to it */
public getBeatsWithTag = async (tagId: string): Promise<CMPopulatedBeat[]> => {
const beats = await this.adapter.getBeatsWithTag(tagId);
return await this.mergeInTags(beats);
}
};
public async getAll(ESQuery?: any): Promise<CMPopulatedBeat[]> {
// FIXME: This needs to be paginated https://github.com/elastic/kibana/issues/26022
/** Get an array of all enrolled beats. */
public getAll = async (ESQuery?: string): Promise<CMPopulatedBeat[]> => {
const beats = await this.adapter.getAll(ESQuery);
return await this.mergeInTags(beats);
}
};
public async update(id: string, beatData: Partial<CMBeat>): Promise<boolean> {
/** Update a given beat via it's ID */
public update = async (id: string, beatData: Partial<CMBeat>): Promise<boolean> => {
return await this.adapter.update(id, beatData);
}
};
public async removeTagsFromBeats(removals: BeatsTagAssignment[]): Promise<BeatsRemovalReturn[]> {
/** unassign tags from beats using an array of tags and beats */
public removeTagsFromBeats = async (
removals: BeatsTagAssignment[]
): Promise<BeatsRemovalReturn[]> => {
return await this.adapter.removeTagsFromBeats(removals);
}
};
public async assignTagsToBeats(assignments: BeatsTagAssignment[]): Promise<CMAssignmentReturn[]> {
/** assign tags from beats using an array of tags and beats */
public assignTagsToBeats = async (
assignments: BeatsTagAssignment[]
): Promise<CMAssignmentReturn[]> => {
return await this.adapter.assignTagsToBeats(assignments);
}
};
private async mergeInTags(beats: CMBeat[]): Promise<CMPopulatedBeat[]> {
/** method user to join tags to beats, thus fully populating the beats */
private mergeInTags = async (beats: CMBeat[]): Promise<CMPopulatedBeat[]> => {
const tagIds = flatten(beats.map(b => b.tags || []));
const tags = await this.libs.tags.getTagsWithIds(tagIds);
// TODO the filter should not be needed, if the data gets into a bad state, we should error
// and inform the user they need to delte the tag, or else we should auto delete it
// and inform the user they need to delete the tag, or else we should auto delete it
// https://github.com/elastic/kibana/issues/26021
const mergedBeats: CMPopulatedBeat[] = beats.map(
b =>
({
@ -66,5 +80,5 @@ export class BeatsLib {
} as CMPopulatedBeat)
);
return mergedBeats;
}
};
}

View file

@ -4,19 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
// @ts-ignore
// @ts-ignore not typed yet
import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info';
// @ts-ignore
import 'ui/autoload/all';
// @ts-ignore: path dynamic for kibana
import chrome from 'ui/chrome';
// @ts-ignore: path dynamic for kibana
// @ts-ignore not typed yet
import { management } from 'ui/management';
// @ts-ignore: path dynamic for kibana
import { uiModules } from 'ui/modules';
// @ts-ignore: path dynamic for kibana
import routes from 'ui/routes';
import { INDEX_NAMES } from '../../../common/constants/index_names';
import { getSupportedConfig } from '../../config_schemas_translations_map';
import { RestBeatsAdapter } from '../adapters/beats/rest_beats_adapter';
@ -27,8 +21,13 @@ import { RestTagsAdapter } from '../adapters/tags/rest_tags_adapter';
import { RestTokensAdapter } from '../adapters/tokens/rest_tokens_adapter';
import { BeatsLib } from '../beats';
import { ElasticsearchLib } from '../elasticsearch';
import { FrontendDomainLibs, FrontendLibs } from '../lib';
import { TagsLib } from '../tags';
import { FrontendLibs } from '../types';
import { PLUGIN } from './../../../common/constants/plugin';
import { FrameworkLib } from './../framework';
// A super early spot in kibana loading that we can use to hook before most other things
const onKibanaReady = chrome.dangerouslyGetActiveInjector;
export function compose(): FrontendLibs {
const api = new AxiosRestAPIAdapter(chrome.getXsrfToken(), chrome.getBasePath());
@ -40,25 +39,24 @@ export function compose(): FrontendLibs {
tags,
});
const domainLibs: FrontendDomainLibs = {
tags,
tokens,
beats,
};
const pluginUIModule = uiModules.get('app/beats_management');
const framework = new KibanaFrameworkAdapter(
pluginUIModule,
management,
routes,
chrome,
XPackInfoProvider
const framework = new FrameworkLib(
new KibanaFrameworkAdapter(
PLUGIN.ID,
management,
routes,
chrome.getBasePath,
onKibanaReady,
XPackInfoProvider,
chrome.getUiSettingsClient()
)
);
const libs: FrontendLibs = {
framework,
elasticsearch: new ElasticsearchLib(esAdapter),
...domainLibs,
tags,
tokens,
beats,
};
return libs;
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import 'ui/autoload/all';
// @ts-ignore: path dynamic for kibana
import { management } from 'ui/management';
@ -11,21 +12,21 @@ import { management } from 'ui/management';
import { uiModules } from 'ui/modules';
// @ts-ignore: path dynamic for kibana
import routes from 'ui/routes';
import { getSupportedConfig } from '../../config_schemas_translations_map';
// @ts-ignore: path dynamic for kibana
import { MemoryBeatsAdapter } from '../adapters/beats/memory_beats_adapter';
import { KibanaFrameworkAdapter } from '../adapters/framework/kibana_framework_adapter';
import { MemoryTagsAdapter } from '../adapters/tags/memory_tags_adapter';
import { MemoryTokensAdapter } from '../adapters/tokens/memory_tokens_adapter';
import { BeatsLib } from '../beats';
import { FrontendDomainLibs, FrontendLibs } from '../lib';
import { AutocompleteSuggestion } from 'ui/autocomplete_providers';
import { getSupportedConfig } from '../../config_schemas_translations_map';
import { FrameworkLib } from '../framework';
import { TagsLib } from '../tags';
import { FrontendLibs } from '../types';
import { MemoryElasticsearchAdapter } from './../adapters/elasticsearch/memory';
import { ElasticsearchLib } from './../elasticsearch';
const onKibanaReady = uiModules.get('kibana').run;
export function compose(
mockIsKueryValid: (kuery: string) => boolean,
mockKueryToEsQuery: (kuery: string) => string,
@ -40,18 +41,25 @@ export function compose(
const tokens = new MemoryTokensAdapter();
const beats = new BeatsLib(new MemoryBeatsAdapter([]), { tags });
const domainLibs: FrontendDomainLibs = {
const pluginUIModule = uiModules.get('app/beats_management');
const framework = new FrameworkLib(
new KibanaFrameworkAdapter(
pluginUIModule,
management,
routes,
() => '',
onKibanaReady,
null,
null
)
);
const libs: FrontendLibs = {
framework,
elasticsearch: new ElasticsearchLib(esAdapter),
tags,
tokens,
beats,
};
const pluginUIModule = uiModules.get('app/beats_management');
const framework = new KibanaFrameworkAdapter(pluginUIModule, management, routes, null, null);
const libs: FrontendLibs = {
...domainLibs,
elasticsearch: new ElasticsearchLib(esAdapter),
framework,
};
return libs;
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { difference, get } from 'lodash';
import { LICENSES, LicenseType } from '../../common/constants/security';
import { FrameworkAdapter } from './adapters/framework/adapter_types';
export class FrameworkLib {
public waitUntilFrameworkReady = this.adapter.waitUntilFrameworkReady.bind(this.adapter);
public renderUIAtPath = this.adapter.renderUIAtPath.bind(this.adapter);
public registerManagementSection = this.adapter.registerManagementSection.bind(this.adapter);
public registerManagementUI = this.adapter.registerManagementUI.bind(this.adapter);
public setUISettings = this.adapter.setUISettings.bind(this.adapter);
public getUISetting = this.adapter.getUISetting.bind(this.adapter);
constructor(private readonly adapter: FrameworkAdapter) {}
public get currentUser() {
return this.adapter.currentUser;
}
public get info() {
return this.adapter.info;
}
public licenseIsAtLeast(type: LicenseType) {
return (
LICENSES.indexOf(get(this.adapter.info, 'license.type', 'oss')) >= LICENSES.indexOf(type)
);
}
public currentUserHasOneOfRoles(roles: string[]) {
// If the user has at least one of the roles requested, the returnd difference will be less
// then the orig array size. difference only compares based on the left side arg
return difference(roles, get<string[]>(this.currentUser, 'roles', [])).length < roles.length;
}
}

View file

@ -20,8 +20,10 @@ export class TagsLib {
public async delete(tagIds: string[]): Promise<boolean> {
return await this.adapter.delete(tagIds);
}
public async getAll(): Promise<BeatTag[]> {
return this.jsonConfigToUserYaml(await this.adapter.getAll());
// FIXME: This needs to be paginated https://github.com/elastic/kibana/issues/26022
public async getAll(ESQuery?: string): Promise<BeatTag[]> {
return this.jsonConfigToUserYaml(await this.adapter.getAll(ESQuery));
}
public async upsertTag(tag: BeatTag): Promise<BeatTag | null> {
tag.id = tag.id.replace(' ', '-');

View file

@ -6,10 +6,11 @@
import { IModule, IScope } from 'angular';
import { AxiosRequestConfig } from 'axios';
import React from 'react';
import { FrameworkAdapter } from './adapters/framework/adapter_types';
import { CMTokensAdapter } from './adapters/tokens/adapter_types';
import { BeatsLib } from './beats';
import { ElasticsearchLib } from './elasticsearch';
import { FrameworkLib } from './framework';
import { TagsLib } from './tags';
export interface FrontendDomainLibs {
@ -20,7 +21,7 @@ export interface FrontendDomainLibs {
export interface FrontendLibs extends FrontendDomainLibs {
elasticsearch: ElasticsearchLib;
framework: FrameworkAdapter;
framework: FrameworkLib;
}
export interface YamlConfigSchema {
@ -39,35 +40,11 @@ export interface YamlConfigSchema {
parseValidResult?: (value: any) => any;
}
export interface FrameworkAdapter {
// Instance vars
appState?: object;
kbnVersion?: string;
baseURLPath: string;
registerManagementSection(pluginId: string, displayName: string, basePath: string): void;
getDefaultUserRoles(): string[];
// Methods
getCurrentUser(): {
email: string | null;
enabled: boolean;
full_name: string | null;
metadata: { _reserved: true };
roles: string[];
scope: string[];
username: string;
};
licenseExpired(): boolean;
securityEnabled(): boolean;
hasValidLicense(): boolean;
setUISettings(key: string, value: any): void;
render(component: React.ReactElement<any>): void;
}
export interface FramworkAdapterConstructable {
new (uiModule: IModule): FrameworkAdapter;
}
// TODO: replace AxiosRequestConfig with something more defined
// FIXME: replace AxiosRequestConfig with something more defined
export type RequestConfig = AxiosRequestConfig;
export interface ApiAdapter {

View file

@ -1,75 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { first, sortByOrder } from 'lodash';
import moment from 'moment';
import React from 'react';
import { CMPopulatedBeat } from '../../../common/domain_types';
interface BeatDetailsActionSectionProps {
beat: CMPopulatedBeat | undefined;
}
export const BeatDetailsActionSection = ({ beat }: BeatDetailsActionSectionProps) => (
<div>
{beat ? (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiText size="xs">
<FormattedMessage
id="xpack.beatsManagement.beat.actionSectionTypeLabel"
defaultMessage="Type: {beatType}."
values={{ beatType: <strong>{beat.type}</strong> }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
<FormattedMessage
id="xpack.beatsManagement.beat.actionSectionVersionLabel"
defaultMessage="Version: {beatVersion}."
values={{ beatVersion: <strong>{beat.version}</strong> }}
/>
</EuiText>
</EuiFlexItem>
{/* TODO: We need a populated field before we can run this code
<EuiFlexItem grow={false}>
<EuiText size="xs">
Uptime: <strong>12min.</strong>
</EuiText>
</EuiFlexItem> */}
{beat.full_tags && beat.full_tags.length > 0 && (
<EuiFlexItem grow={false}>
<EuiText size="xs">
<FormattedMessage
id="xpack.beatsManagement.beat.lastConfigUpdateMessage"
defaultMessage="Last Config Update: {lastUpdateTime}."
values={{
lastUpdateTime: (
<strong>
{moment(
first(sortByOrder(beat.full_tags, 'last_updated')).last_updated
).fromNow()}
</strong>
),
}}
/>
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
) : (
<div>
<FormattedMessage
id="xpack.beatsManagement.beat.beatNotFoundMessage"
defaultMessage="Beat not found"
/>
</div>
)}
</div>
);

View file

@ -1,22 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { FrontendLibs } from '../../lib/lib';
interface BeatActivityPageProps {
libs: FrontendLibs;
}
export const BeatActivityPage = (props: BeatActivityPageProps) => (
<div>
<FormattedMessage
id="xpack.beatsManagement.beat.beatActivityViewTitle"
defaultMessage="Beat Activity View"
/>
</div>
);

View file

@ -10,21 +10,24 @@ import {
// @ts-ignore EuiInMemoryTable typings not yet available
EuiInMemoryTable,
EuiLink,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { flatten, get } from 'lodash';
import React from 'react';
import { TABLE_CONFIG } from '../../../common/constants';
import { BeatTag, CMPopulatedBeat, ConfigurationBlock } from '../../../common/domain_types';
import { ConnectedLink } from '../../components/connected_link';
import { Breadcrumb } from '../../components/navigation/breadcrumb';
import { ConnectedLink } from '../../components/navigation/connected_link';
import { TagBadge } from '../../components/tag';
import { ConfigView } from '../../components/tag/config_view/index';
import { getSupportedConfig } from '../../config_schemas_translations_map';
interface PageProps {
beat: CMPopulatedBeat | undefined;
beat: CMPopulatedBeat;
intl: InjectedIntl;
}
@ -45,12 +48,10 @@ class BeatDetailPageUi extends React.PureComponent<PageProps, PageState> {
const { beat, intl } = props;
if (!beat) {
return (
<div>
<FormattedMessage
id="xpack.beatsManagement.beat.beatNotFoundErrorTitle"
defaultMessage="Beat not found"
/>
</div>
<FormattedMessage
id="xpack.beatsManagement.beat.beatNotFoundErrorTitle"
defaultMessage="Beat not found"
/>
);
}
const configurationBlocks = flatten(
@ -126,6 +127,14 @@ class BeatDetailPageUi extends React.PureComponent<PageProps, PageState> {
];
return (
<React.Fragment>
<Breadcrumb
title={i18n.translate('xpack.beatsManagement.breadcrumb.beatDetails', {
defaultMessage: 'Beat details for: {beatId}',
values: { beatId: beat.id },
})}
path={`/beat/${beat.id}/details`}
/>
<EuiSpacer />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">

View file

@ -5,53 +5,40 @@
*/
import {
EuiSpacer,
// @ts-ignore types for EuiTab not currently available
EuiFlexGroup,
EuiFlexItem,
// @ts-ignore
EuiTab,
// @ts-ignore types for EuiTabs not currently available
// @ts-ignore
EuiTabs,
EuiText,
} from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { first, sortByOrder } from 'lodash';
import moment from 'moment';
import React from 'react';
import { Route, Switch } from 'react-router-dom';
import { Redirect, Route, Switch } from 'react-router-dom';
import { CMPopulatedBeat } from '../../../common/domain_types';
import { AppURLState } from '../../app';
import { PrimaryLayout } from '../../components/layouts/primary';
import { URLStateProps, withUrlState } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
import { BeatDetailsActionSection } from './action_section';
import { BeatActivityPage } from './activity';
import { BeatDetailPage } from './detail';
import { BeatTagsPage } from './tags';
import { Breadcrumb } from '../../components/navigation/breadcrumb';
import { ChildRoutes } from '../../components/navigation/child_routes';
import { AppPageProps } from '../../frontend_types';
interface Match {
params: any;
}
interface BeatDetailsPageProps extends URLStateProps<AppURLState> {
location: any;
history: any;
libs: FrontendLibs;
match: Match;
interface PageProps extends AppPageProps {
intl: InjectedIntl;
}
interface BeatDetailsPageState {
interface PageState {
beat: CMPopulatedBeat | undefined;
beatId: string;
isLoading: boolean;
}
class BeatDetailsPageComponent extends React.PureComponent<
BeatDetailsPageProps,
BeatDetailsPageState
> {
constructor(props: BeatDetailsPageProps) {
class BeatDetailsPageComponent extends React.PureComponent<PageProps, PageState> {
constructor(props: PageProps) {
super(props);
this.state = {
beat: undefined,
beatId: this.props.match.params.beatId,
beatId: props.match.params.beatId,
isLoading: true,
};
this.loadBeat();
@ -64,16 +51,66 @@ class BeatDetailsPageComponent extends React.PureComponent<
});
};
public renderActionSection(beat?: CMPopulatedBeat) {
return beat ? (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiText size="xs">
<FormattedMessage
id="xpack.beatsManagement.beat.actionSectionTypeLabel"
defaultMessage="Type: {beatType}."
values={{ beatType: <strong>{beat.type}</strong> }}
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="xs">
<FormattedMessage
id="xpack.beatsManagement.beat.actionSectionVersionLabel"
defaultMessage="Version: {beatVersion}."
values={{ beatVersion: <strong>{beat.version}</strong> }}
/>
</EuiText>
</EuiFlexItem>
{beat.full_tags && beat.full_tags.length > 0 && (
<EuiFlexItem grow={false}>
<EuiText size="xs">
<FormattedMessage
id="xpack.beatsManagement.beat.lastConfigUpdateMessage"
defaultMessage="Last Config Update: {lastUpdateTime}."
values={{
lastUpdateTime: (
<strong>
{moment(
first(sortByOrder(beat.full_tags, 'last_updated')).last_updated
).fromNow()}
</strong>
),
}}
/>
</EuiText>
</EuiFlexItem>
)}
</EuiFlexGroup>
) : (
<FormattedMessage
id="xpack.beatsManagement.beat.beatNotFoundMessage"
defaultMessage="Beat not found"
/>
);
}
public render() {
const { intl } = this.props;
const { beat } = this.state;
let id;
let id: string | undefined;
let name;
if (beat) {
id = beat.id;
name = beat.name;
}
const title = this.state.isLoading
? intl.formatMessage({
id: 'xpack.beatsManagement.beat.loadingTitle',
@ -95,77 +132,57 @@ class BeatDetailsPageComponent extends React.PureComponent<
}
);
const tabs = [
{
id: `/beat/${id}`,
name: intl.formatMessage({
id: 'xpack.beatsManagement.beat.configTabLabel',
defaultMessage: 'Config',
}),
disabled: false,
},
// {
// id: `/beat/${id}/activity`,
// name: 'Beat Activity',
// disabled: false,
// },
{
id: `/beat/${id}/tags`,
name: intl.formatMessage({
id: 'xpack.beatsManagement.beat.configurationTagsTabLabel',
defaultMessage: 'Configuration Tags',
}),
disabled: false,
},
];
return (
<PrimaryLayout title={title} actionSection={<BeatDetailsActionSection beat={beat} />}>
<EuiTabs>
{tabs.map((tab, index) => (
<PrimaryLayout
title={title}
actionSection={this.renderActionSection(beat)}
hideBreadcrumbs={this.props.libs.framework.info.k7Design}
>
<React.Fragment>
<Breadcrumb title={`Enrolled Beats`} path={`/overview/enrolled_beats`} />
<EuiTabs>
<EuiTab
disabled={tab.disabled}
key={index}
isSelected={tab.id === this.props.history.location.pathname}
onClick={() => {
this.props.history.push({
pathname: tab.id,
search: this.props.location.search,
});
}}
isSelected={`/beat/${id}/details` === this.props.history.location.pathname}
onClick={this.onTabClicked(`/beat/${id}/details`)}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
<EuiSpacer size="l" />
<Switch>
<Route
path="/beat/:beatId/activity"
render={(props: any) => <BeatActivityPage libs={this.props.libs} {...props} />}
/>
<Route
path="/beat/:beatId/tags"
render={(props: any) => (
<BeatTagsPage
beatId={this.state.beatId}
libs={this.props.libs}
refreshBeat={() => this.loadBeat()}
{...props}
<FormattedMessage
id="xpack.beatsManagement.beat.configTabLabel"
defaultMessage="Config"
/>
)}
/>
<Route
path="/beat/:beatId"
render={(props: any) => (
<BeatDetailPage beat={this.state.beat} libs={this.props.libs} {...props} />
)}
/>
</Switch>
</EuiTab>
<EuiTab
isSelected={`/beat/${id}/tags` === this.props.history.location.pathname}
onClick={this.onTabClicked(`/beat/${id}/tags`)}
>
<FormattedMessage
id="xpack.beatsManagement.beat.configurationTagsTabLabel"
defaultMessage="Configuration tags"
/>
</EuiTab>
</EuiTabs>
{!this.state.beat && <div>Beat not found</div>}
{this.state.beat && (
<Switch>
<ChildRoutes
routes={this.props.routes}
{...this.props}
beat={this.state.beat}
useSwitch={false}
/>
{id && <Route render={() => <Redirect to={`/beat/${id}/details`} />} />}
</Switch>
)}
</React.Fragment>
</PrimaryLayout>
);
}
private onTabClicked = (path: string) => {
return () => {
this.props.goTo(path);
};
};
private async loadBeat() {
const { intl } = this.props;
const { beatId } = this.props.match.params;
@ -186,6 +203,5 @@ class BeatDetailsPageComponent extends React.PureComponent<
this.setState({ beat, isLoading: false });
}
}
const BeatDetailsPageUi = withUrlState<BeatDetailsPageProps>(BeatDetailsPageComponent);
export const BeatDetailsPage = injectI18n(BeatDetailsPageUi);
export const BeatDetailsPage = injectI18n(BeatDetailsPageComponent);

View file

@ -5,19 +5,20 @@
*/
import { EuiGlobalToastList } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { CMPopulatedBeat } from '../../../common/domain_types';
import { Breadcrumb } from '../../components/navigation/breadcrumb';
import { BeatDetailTagsTable, Table } from '../../components/table';
import { FrontendLibs } from '../../lib/lib';
import { FrontendLibs } from '../../lib/types';
interface BeatTagsPageProps {
beatId: string;
beat: CMPopulatedBeat;
libs: FrontendLibs;
refreshBeat(): void;
}
interface BeatTagsPageState {
beat: CMPopulatedBeat | null;
notifications: any[];
}
@ -27,19 +28,21 @@ export class BeatTagsPage extends React.PureComponent<BeatTagsPageProps, BeatTag
super(props);
this.state = {
beat: null,
notifications: [],
};
}
public async componentWillMount() {
await this.getBeat();
}
public render() {
const { beat } = this.state;
const { beat } = this.props;
return (
<div>
<React.Fragment>
<Breadcrumb
title={i18n.translate('xpack.beatsManagement.breadcrumb.beatTags', {
defaultMessage: 'Beat tags for: {beatId}',
values: { beatId: beat.id },
})}
path={`/beat/${beat.id}/tags`}
/>
<Table
hideTableControls={true}
items={beat ? beat.full_tags : []}
@ -52,16 +55,7 @@ export class BeatTagsPage extends React.PureComponent<BeatTagsPageProps, BeatTag
dismissToast={() => this.setState({ notifications: [] })}
toastLifeTimeMs={5000}
/>
</div>
</React.Fragment>
);
}
private getBeat = async () => {
try {
const beat = await this.props.libs.beats.get(this.props.beatId);
this.setState({ beat });
} catch (e) {
throw new Error(e);
}
};
}

View file

@ -6,7 +6,7 @@
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import * as React from 'react';
import { NoDataLayout } from '../components/layouts/no_data';
import { NoDataLayout } from '../../components/layouts/no_data';
export const EnforceSecurityPage = injectI18n(({ intl }) => (
<NoDataLayout

View file

@ -6,7 +6,7 @@
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import * as React from 'react';
import { NoDataLayout } from '../components/layouts/no_data';
import { NoDataLayout } from '../../components/layouts/no_data';
export const InvalidLicensePage = injectI18n(({ intl }) => (
<NoDataLayout

View file

@ -6,7 +6,7 @@
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import * as React from 'react';
import { NoDataLayout } from '../components/layouts/no_data';
import { NoDataLayout } from '../../components/layouts/no_data';
export const NoAccessPage = injectI18n(({ intl }) => (
<NoDataLayout

View file

@ -1,21 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
export class ActivityPage extends React.PureComponent {
public render() {
return (
<div>
<FormattedMessage
id="xpack.beatsManagement.activityLogsViewTitle"
defaultMessage="activity logs view"
/>
</div>
);
}
}

View file

@ -1,304 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
// @ts-ignore
EuiTab,
// @ts-ignore
EuiTabs,
} from '@elastic/eui';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { CMPopulatedBeat } from '../../../common/domain_types';
import { AppURLState } from '../../app';
import { ConnectedLink } from '../../components/connected_link';
import { NoDataLayout } from '../../components/layouts/no_data';
import { PrimaryLayout } from '../../components/layouts/primary';
import { WalkthroughLayout } from '../../components/layouts/walkthrough';
import { RouteWithBreadcrumb } from '../../components/route_with_breadcrumb';
import { URLStateProps, withUrlState } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
import { ActivityPage } from './activity';
import { BeatsPage } from './beats';
import { CreateTagPageFragment } from './create_tag_fragment';
import { EnrollBeatPage } from './enroll_fragment';
import { FinishWalkthroughPage } from './finish_walkthrough';
import { TagsPage } from './tags';
interface MainPagesProps extends URLStateProps<AppURLState> {
libs: FrontendLibs;
location: any;
intl: InjectedIntl;
}
interface MainPagesState {
enrollBeat?: {
enrollmentToken: string;
} | null;
beats: CMPopulatedBeat[];
unfilteredBeats: CMPopulatedBeat[];
loadedBeatsAtLeastOnce: boolean;
}
class MainPagesComponent extends React.PureComponent<MainPagesProps, MainPagesState> {
private mounted: boolean = false;
constructor(props: MainPagesProps) {
super(props);
this.state = {
loadedBeatsAtLeastOnce: false,
beats: [],
unfilteredBeats: [],
};
}
public onSelectedTabChanged = (id: string) => {
this.props.goTo(id);
};
public componentDidMount() {
this.mounted = true;
this.loadBeats();
}
public componentWillUnmount() {
this.mounted = false;
}
public render() {
const { intl } = this.props;
if (
this.state.loadedBeatsAtLeastOnce &&
this.state.unfilteredBeats.length === 0 &&
!this.props.location.pathname.includes('/overview/initial')
) {
return <Redirect to="/overview/initial/help" />;
}
const tabs = [
{
id: '/overview/beats',
name: (
<FormattedMessage
id="xpack.beatsManagement.beats.enrolledBeatsTabTitle"
defaultMessage="Enrolled Beats"
/>
),
disabled: false,
},
// {
// id: '/overview/activity',
// name: 'Beats Activity',
// disabled: false,
// },
{
id: '/overview/tags',
name: (
<FormattedMessage
id="xpack.beatsManagement.beats.configurationTagsTabTitle"
defaultMessage="Configuration tags"
/>
),
disabled: false,
},
];
const walkthroughSteps = [
{
id: '/overview/initial/beats',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.enrollBeatStepLabel',
defaultMessage: 'Enroll Beat',
}),
disabled: false,
page: EnrollBeatPage,
},
{
id: '/overview/initial/tag',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.createTagStepLabel',
defaultMessage: 'Create tag',
}),
disabled: false,
page: CreateTagPageFragment,
},
{
id: '/overview/initial/finish',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.finishStepLabel',
defaultMessage: 'Finish',
}),
disabled: false,
page: FinishWalkthroughPage,
},
];
if (this.props.location.pathname === '/overview/initial/help') {
return (
<NoDataLayout
title={intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.beatsCentralManagementTitle',
defaultMessage: 'Beats central management (Beta)',
})}
actionSection={
<ConnectedLink path="/overview/initial/beats">
<EuiButton color="primary" fill>
<FormattedMessage
id="xpack.beatsManagement.enrollBeat.enrollBeatButtonLabel"
defaultMessage="Enroll Beat"
/>
</EuiButton>
</ConnectedLink>
}
>
<p>
<FormattedMessage
id="xpack.beatsManagement.enrollBeat.beatsCentralManagementDescription"
defaultMessage="Manage your configurations in a central location."
/>
</p>
</NoDataLayout>
);
}
if (this.props.location.pathname.includes('/overview/initial')) {
return (
<WalkthroughLayout
title={intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.getStartedBeatsCentralManagementTitle',
defaultMessage: 'Get started with Beats central management',
})}
walkthroughSteps={walkthroughSteps}
goTo={this.props.goTo}
activePath={this.props.location.pathname}
>
<Switch>
{walkthroughSteps.map(step => (
<Route
path={step.id}
render={(props: any) => (
<step.page
{...this.props}
{...props}
libs={this.props.libs}
loadBeats={this.loadBeats}
/>
)}
/>
))}
</Switch>
</WalkthroughLayout>
);
}
const renderedTabs = tabs.map((tab, index) => (
<EuiTab
onClick={() => this.onSelectedTabChanged(tab.id)}
isSelected={tab.id === this.props.location.pathname}
disabled={tab.disabled}
key={index}
>
{tab.name}
</EuiTab>
));
return (
<PrimaryLayout
title={intl.formatMessage({
id: 'xpack.beatsManagement.beatsRouteTitle',
defaultMessage: 'Beats',
})}
actionSection={
<Switch>
<Route
path="/overview/beats/:action?/:enrollmentToken?"
render={(props: any) => (
<BeatsPage.ActionArea {...this.props} {...props} libs={this.props.libs} />
)}
/>
<Route
path="/overview/tags"
render={(props: any) => (
<TagsPage.ActionArea {...this.props} {...props} libs={this.props.libs} />
)}
/>
</Switch>
}
>
<EuiTabs>{renderedTabs}</EuiTabs>
<RouteWithBreadcrumb
title={intl.formatMessage({
id: 'xpack.beatsManagement.beatsListRouteTitle',
defaultMessage: 'Beats List',
})}
path="/overview/beats/:action?/:enrollmentToken?"
render={(props: any) => (
<BeatsPage
{...this.props}
libs={this.props.libs}
{...props}
loadBeats={this.loadBeats}
beats={this.state.beats}
/>
)}
/>
<RouteWithBreadcrumb
title={intl.formatMessage({
id: 'xpack.beatsManagement.activityOverviewRouteTitle',
defaultMessage: 'Activity Overview',
})}
path="/overview/activity"
exact={true}
render={(props: any) => (
<ActivityPage {...this.props} libs={this.props.libs} {...props} />
)}
/>
<RouteWithBreadcrumb
title={intl.formatMessage({
id: 'xpack.beatsManagement.tagsListRouteTitle',
defaultMessage: 'Tags List',
})}
path="/overview/tags"
exact={true}
render={(props: any) => <TagsPage {...this.props} libs={this.props.libs} {...props} />}
/>
</PrimaryLayout>
);
}
private loadBeats = async () => {
let query;
if (this.props.urlState.beatsKBar) {
query = await this.props.libs.elasticsearch.convertKueryToEsQuery(
this.props.urlState.beatsKBar
);
}
let beats: CMPopulatedBeat[];
let unfilteredBeats: CMPopulatedBeat[];
try {
[beats, unfilteredBeats] = await Promise.all([
this.props.libs.beats.getAll(query),
this.props.libs.beats.getAll(),
]);
} catch (e) {
beats = [];
unfilteredBeats = [];
}
if (this.mounted) {
this.setState({
loadedBeatsAtLeastOnce: true,
beats,
unfilteredBeats,
});
}
};
}
const MainPagesUi = withUrlState<MainPagesProps>(MainPagesComponent);
export const MainPages = injectI18n(MainPagesUi);

View file

@ -1,126 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { BeatTag } from '../../../common/domain_types';
import { AppURLState } from '../../app';
import { AssignmentActionType, Table, TagsTableType } from '../../components/table';
import { tagListAssignmentOptions } from '../../components/table/assignment_schema';
import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion';
import { URLStateProps } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
interface TagsPageProps extends URLStateProps<AppURLState> {
libs: FrontendLibs;
intl: InjectedIntl;
}
interface TagsPageState {
tags: BeatTag[];
tableRef: any;
}
class TagsPageUi extends React.PureComponent<TagsPageProps, TagsPageState> {
public static ActionArea = ({ goTo }: TagsPageProps) => (
<EuiButton
size="s"
color="primary"
onClick={async () => {
goTo('/tag/create');
}}
>
<FormattedMessage
id="xpack.beatsManagement.tags.addTagButtonLabel"
defaultMessage="Add Tag"
/>
</EuiButton>
);
constructor(props: TagsPageProps) {
super(props);
this.state = {
tags: [],
tableRef: React.createRef(),
};
this.loadTags();
}
public render() {
return (
<WithKueryAutocompletion libs={this.props.libs} fieldPrefix="tag">
{autocompleteProps => (
<Table
kueryBarProps={{
...autocompleteProps,
filterQueryDraft: 'false', // todo
isValid: this.props.libs.elasticsearch.isKueryValid(
this.props.urlState.tagsKBar || ''
),
onChange: (value: any) => this.props.setUrlState({ tagsKBar: value }),
onSubmit: () => null, // todo
value: this.props.urlState.tagsKBar || '',
}}
assignmentOptions={{
schema: tagListAssignmentOptions,
type: 'primary',
items: [],
actionHandler: this.handleTagsAction,
}}
ref={this.state.tableRef}
items={this.state.tags}
type={TagsTableType}
/>
)}
</WithKueryAutocompletion>
);
}
private handleTagsAction = async (action: AssignmentActionType, payload: any) => {
const { intl } = this.props;
switch (action) {
case AssignmentActionType.Delete:
const tags = this.getSelectedTags().map((tag: BeatTag) => tag.id);
const success = await this.props.libs.tags.delete(tags);
if (!success) {
alert(
intl.formatMessage({
id: 'xpack.beatsManagement.tags.someTagsMightBeAssignedToBeatsTitle',
defaultMessage:
'Some of these tags might be assigned to beats. Please ensure tags being removed are not activly assigned',
})
);
} else {
this.loadTags();
if (this.state.tableRef && this.state.tableRef.current) {
this.state.tableRef.current.resetSelection();
}
}
break;
}
this.loadTags();
};
private getSelectedTags = () => {
return this.state.tableRef.current ? this.state.tableRef.current.state.selection : [];
};
private async loadTags() {
const tags = await this.props.libs.tags.getAll();
this.setState({
tags,
});
}
}
const TagsPageWrapped = injectI18n(TagsPageUi);
export const TagsPage = TagsPageWrapped as typeof TagsPageWrapped & {
ActionArea: typeof TagsPageUi['ActionArea'];
};

View file

@ -0,0 +1,126 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { BeatTag } from '../../../common/domain_types';
import { Breadcrumb } from '../../components/navigation/breadcrumb';
import { AssignmentActionType, Table, TagsTableType } from '../../components/table';
import { tagListAssignmentOptions } from '../../components/table/assignment_schema';
import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion';
import { AppPageProps } from '../../frontend_types';
interface PageProps extends AppPageProps {
renderAction: (area: () => JSX.Element) => void;
intl: InjectedIntl;
}
interface PageState {
tableRef: any;
}
class TagsPageComponent extends React.PureComponent<PageProps, PageState> {
constructor(props: PageProps) {
super(props);
this.state = {
tableRef: React.createRef(),
};
if (props.urlState.tagsKBar) {
props.containers.tags.reload(props.urlState.tagsKBar);
}
props.renderAction(this.renderActionArea);
}
public renderActionArea = () => (
<EuiButton
size="s"
color="primary"
onClick={async () => {
this.props.goTo('/tag/create');
}}
>
<FormattedMessage
id="xpack.beatsManagement.tags.addTagButtonLabel"
defaultMessage="Add Tag"
/>
</EuiButton>
);
public render() {
return (
<React.Fragment>
<Breadcrumb
title={i18n.translate('xpack.beatsManagement.breadcrumb.configurationTags', {
defaultMessage: 'Configuration tags',
})}
path={`/overview/configuration_tags`}
/>
<WithKueryAutocompletion libs={this.props.libs} fieldPrefix="tag">
{autocompleteProps => (
<Table
kueryBarProps={{
...autocompleteProps,
filterQueryDraft: 'false', // todo
isValid: this.props.libs.elasticsearch.isKueryValid(
this.props.urlState.tagsKBar || ''
),
onChange: (value: any) => {
this.props.setUrlState({ tagsKBar: value });
this.props.containers.tags.reload(this.props.urlState.tagsKBar);
},
onSubmit: () => null, // todo
value: this.props.urlState.tagsKBar || '',
}}
assignmentOptions={{
schema: tagListAssignmentOptions,
type: 'primary',
items: [],
actionHandler: this.handleTagsAction,
}}
ref={this.state.tableRef}
items={this.props.containers.tags.state.list}
type={TagsTableType}
/>
)}
</WithKueryAutocompletion>
</React.Fragment>
);
}
private handleTagsAction = async (action: AssignmentActionType) => {
const { intl } = this.props;
switch (action) {
case AssignmentActionType.Delete:
const tags = this.getSelectedTags().map((tag: BeatTag) => tag.id);
const success = await this.props.containers.tags.delete(tags);
if (!success) {
alert(
intl.formatMessage({
id: 'xpack.beatsManagement.tags.someTagsMightBeAssignedToBeatsTitle',
defaultMessage:
'Some of these tags might be assigned to beats. Please ensure tags being removed are not activly assigned',
})
);
} else {
if (this.state.tableRef && this.state.tableRef.current) {
this.state.tableRef.current.resetSelection();
}
}
break;
}
};
private getSelectedTags = () => {
return this.state.tableRef.current ? this.state.tableRef.current.state.selection : [];
};
}
export const TagsPage = injectI18n(TagsPageComponent);

View file

@ -14,43 +14,48 @@ import {
EuiModalHeaderTitle,
EuiOverlayMask,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { flatten, intersection, sortBy } from 'lodash';
import moment from 'moment';
import React from 'react';
import { RouteComponentProps } from 'react-router';
import { UNIQUENESS_ENFORCING_TYPES } from 'x-pack/plugins/beats_management/common/constants';
import { BeatTag, CMPopulatedBeat, ConfigurationBlock } from '../../../common/domain_types';
import { BeatsTagAssignment } from '../../../server/lib/adapters/beats/adapter_types';
import { AppURLState } from '../../app';
import { EnrollBeat } from '../../components/enroll_beats';
import { Breadcrumb } from '../../components/navigation/breadcrumb';
import { BeatsTableType, Table } from '../../components/table';
import { beatsListAssignmentOptions } from '../../components/table/assignment_schema';
import { AssignmentActionType } from '../../components/table/table';
import { WithKueryAutocompletion } from '../../containers/with_kuery_autocompletion';
import { URLStateProps } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
import { EnrollBeatPage } from './enroll_fragment';
import { AppPageProps } from '../../frontend_types';
interface BeatsPageProps extends URLStateProps<AppURLState> {
interface PageProps extends AppPageProps {
renderAction: (area: () => JSX.Element) => void;
intl: InjectedIntl;
libs: FrontendLibs;
location: any;
beats: CMPopulatedBeat[];
loadBeats: () => any;
}
interface BeatsPageState {
interface PageState {
notifications: any[];
tableRef: any;
tags: any[] | null;
tags: BeatTag[] | null;
}
interface ActionAreaProps extends URLStateProps<AppURLState>, RouteComponentProps<any> {
libs: FrontendLibs;
}
class BeatsPageComponent extends React.PureComponent<PageProps, PageState> {
private tableRef: React.RefObject<any> = React.createRef();
constructor(props: PageProps) {
super(props);
class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
public static ActionArea = (props: ActionAreaProps) => (
this.state = {
notifications: [],
tags: null,
};
if (props.urlState.beatsKBar) {
props.containers.beats.reload(props.urlState.beatsKBar);
}
props.renderAction(this.renderActionArea);
}
public renderActionArea = () => (
<React.Fragment>
<EuiButtonEmpty
onClick={() => {
@ -71,7 +76,7 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
size="s"
color="primary"
onClick={async () => {
props.goTo(`/overview/beats/enroll`);
this.props.goTo(`/overview/enrolled_beats/enroll`);
}}
>
<FormattedMessage
@ -80,11 +85,14 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
/>
</EuiButton>
{props.location.pathname === '/overview/beats/enroll' && (
{this.props.location.pathname === '/overview/enrolled_beats/enroll' && (
<EuiOverlayMask>
<EuiModal
onClose={() => {
props.goTo(`/overview/beats`);
this.props.setUrlState({
enrollmentToken: '',
});
this.props.goTo(`/overview/enrolled_beats`);
}}
style={{ width: '640px' }}
>
@ -97,31 +105,52 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EnrollBeatPage {...props} />
<EnrollBeat
frameworkBasePath={this.props.libs.framework.info.basePath}
enrollmentToken={this.props.urlState.enrollmentToken}
getBeatWithToken={this.props.containers.beats.getBeatWithToken}
createEnrollmentToken={async () => {
const enrollmentToken = await this.props.libs.tokens.createEnrollmentToken();
this.props.setUrlState({
enrollmentToken,
});
}}
onBeatEnrolled={() => {
this.props.setUrlState({
enrollmentToken: '',
});
}}
/>
{!this.props.urlState.enrollmentToken && (
<React.Fragment>
<EuiButton
size="s"
color="primary"
style={{ marginLeft: 10 }}
onClick={async () => {
this.props.goTo('/overview/enrolled_beats');
}}
>
Done
</EuiButton>
</React.Fragment>
)}
</EuiModalBody>
</EuiModal>
</EuiOverlayMask>
)}
</React.Fragment>
);
constructor(props: BeatsPageProps) {
super(props);
this.state = {
notifications: [],
tableRef: React.createRef(),
tags: null,
};
}
public componentDidUpdate(prevProps: any) {
if (this.props.location !== prevProps.location) {
this.props.loadBeats();
}
}
public render() {
return (
<div>
<React.Fragment>
<Breadcrumb
title={i18n.translate('xpack.beatsManagement.breadcrumb.enrolledBeats', {
defaultMessage: 'Enrolled Beats',
})}
path={`/overview/enrolled_beats`}
/>
<WithKueryAutocompletion libs={this.props.libs} fieldPrefix="beat">
{autocompleteProps => (
<Table
@ -131,18 +160,38 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
isValid: this.props.libs.elasticsearch.isKueryValid(
this.props.urlState.beatsKBar || ''
), // todo check if query converts to es query correctly
onChange: (value: any) => this.props.setUrlState({ beatsKBar: value }), // todo
onChange: (value: any) => {
this.props.setUrlState({ beatsKBar: value });
this.props.containers.beats.reload(this.props.urlState.beatsKBar);
}, // todo
onSubmit: () => null, // todo
value: this.props.urlState.beatsKBar || '',
}}
assignmentOptions={{
items: this.filterSelectedBeatTags(),
items: this.filterTags(this.props.containers.tags.state.list),
schema: beatsListAssignmentOptions,
type: 'assignment',
actionHandler: this.handleBeatsActions,
actionHandler: async (action: AssignmentActionType, payload: any) => {
switch (action) {
case AssignmentActionType.Assign:
const status = await this.props.containers.beats.toggleTagAssignment(
payload,
this.getSelectedBeats()
);
this.notifyUpdatedTagAssociation(status, this.getSelectedBeats(), payload);
break;
case AssignmentActionType.Delete:
this.props.containers.beats.deactivate(this.getSelectedBeats());
this.notifyBeatDisenrolled(this.getSelectedBeats());
break;
case AssignmentActionType.Reload:
this.props.containers.tags.reload();
break;
}
},
}}
items={sortBy(this.props.beats, 'id') || []}
ref={this.state.tableRef}
items={sortBy(this.props.containers.beats.state.list, 'id') || []}
ref={this.tableRef}
type={BeatsTableType}
/>
)}
@ -152,84 +201,11 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
dismissToast={() => this.setState({ notifications: [] })}
toastLifeTimeMs={5000}
/>
</div>
</React.Fragment>
);
}
private handleBeatsActions = (action: AssignmentActionType, payload: any) => {
switch (action) {
case AssignmentActionType.Assign:
this.handleBeatTagAssignment(payload);
break;
case AssignmentActionType.Edit:
// TODO: navigate to edit page
break;
case AssignmentActionType.Delete:
this.deleteSelected();
break;
case AssignmentActionType.Reload:
this.loadTags();
break;
}
this.props.loadBeats();
};
private handleBeatTagAssignment = async (tagId: string) => {
const selected = this.getSelectedBeats();
if (selected.some(beat => beat.full_tags.some(({ id }) => id === tagId))) {
await this.removeTagsFromBeats(selected, tagId);
} else {
await this.assignTagsToBeats(selected, tagId);
}
};
private deleteSelected = async () => {
const selected = this.getSelectedBeats();
for (const beat of selected) {
await this.props.libs.beats.update(beat.id, { active: false });
}
this.notifyBeatUnenrolled(selected);
// because the compile code above has a very minor race condition, we wait,
// the max race condition time is really 10ms but doing 100 to be safe
setTimeout(async () => {
await this.props.loadBeats();
}, 100);
};
private loadTags = async () => {
const tags = await this.props.libs.tags.getAll();
this.setState({
tags,
});
};
private createBeatTagAssignments = (
beats: CMPopulatedBeat[],
tagId: string
): BeatsTagAssignment[] => beats.map(({ id }) => ({ beatId: id, tag: tagId }));
private removeTagsFromBeats = async (beats: CMPopulatedBeat[], tagId: string) => {
if (beats.length) {
const assignments = this.createBeatTagAssignments(beats, tagId);
await this.props.libs.beats.removeTagsFromBeats(assignments);
await this.refreshData();
this.notifyUpdatedTagAssociation('remove', assignments, tagId);
}
};
private assignTagsToBeats = async (beats: CMPopulatedBeat[], tagId: string) => {
if (beats.length) {
const assignments = this.createBeatTagAssignments(beats, tagId);
await this.props.libs.beats.assignTagsToBeats(assignments);
await this.refreshData();
this.notifyUpdatedTagAssociation('add', assignments, tagId);
}
};
private notifyBeatUnenrolled = async (beats: CMPopulatedBeat[]) => {
private notifyBeatDisenrolled = async (beats: CMPopulatedBeat[]) => {
const { intl } = this.props;
let title;
let text;
@ -237,7 +213,7 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
title = intl.formatMessage(
{
id: 'xpack.beatsManagement.beats.beatDisenrolledNotificationTitle',
defaultMessage: '{firstBeatNameOrId} unenrolled',
defaultMessage: '{firstBeatNameOrId} disenrolled',
},
{
firstBeatNameOrId: `"${beats[0].name || beats[0].id}"`,
@ -246,7 +222,7 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
text = intl.formatMessage(
{
id: 'xpack.beatsManagement.beats.beatDisenrolledNotificationDescription',
defaultMessage: 'Beat with ID {firstBeatId} was unenrolled.',
defaultMessage: 'Beat with ID {firstBeatId} was disenrolled.',
},
{
firstBeatId: `"${beats[0].id}"`,
@ -256,7 +232,7 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
title = intl.formatMessage(
{
id: 'xpack.beatsManagement.beats.disenrolledBeatsNotificationTitle',
defaultMessage: '{beatsLength} beats unenrolled',
defaultMessage: '{beatsLength} beats disenrolled',
},
{
beatsLength: beats.length,
@ -267,7 +243,7 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
this.setState({
notifications: this.state.notifications.concat({
color: 'warning',
id: `unenroll_${new Date()}`,
id: `disenroll_${new Date()}`,
title,
text,
}),
@ -275,13 +251,13 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
};
private notifyUpdatedTagAssociation = (
action: 'add' | 'remove',
assignments: BeatsTagAssignment[],
action: 'added' | 'removed',
beats: CMPopulatedBeat[],
tag: string
) => {
const { intl } = this.props;
const notificationMessage =
action === 'remove'
action === 'removed'
? intl.formatMessage(
{
id: 'xpack.beatsManagement.beats.removedNotificationDescription',
@ -290,8 +266,8 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
},
{
tag: `"${tag}"`,
assignmentsLength: assignments.length,
beatName: `"${this.getNameForBeatId(assignments[0].beatId)}"`,
assignmentsLength: beats.length,
beatName: `"${beats[0].name || beats[0].id}"`,
}
)
: intl.formatMessage(
@ -302,19 +278,19 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
},
{
tag: `"${tag}"`,
assignmentsLength: assignments.length,
beatName: `"${this.getNameForBeatId(assignments[0].beatId)}"`,
assignmentsLength: beats.length,
beatName: `"${beats[0].name || beats[0].id}"`,
}
);
const notificationTitle =
action === 'remove'
action === 'removed'
? intl.formatMessage(
{
id: 'xpack.beatsManagement.beats.removedNotificationTitle',
defaultMessage: '{assignmentsLength, plural, one {Tag} other {Tags}} removed',
},
{
assignmentsLength: assignments.length,
assignmentsLength: beats.length,
}
)
: intl.formatMessage(
@ -323,9 +299,10 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
defaultMessage: '{assignmentsLength, plural, one {Tag} other {Tags}} added',
},
{
assignmentsLength: assignments.length,
assignmentsLength: beats.length,
}
);
this.setState({
notifications: this.state.notifications.concat({
color: 'success',
@ -336,25 +313,14 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
});
};
private getNameForBeatId = (beatId: string) => {
const beat = this.props.beats.find(b => b.id === beatId);
if (beat) {
return beat.name;
}
return null;
};
private refreshData = async () => {
await this.loadTags();
await this.props.loadBeats();
this.state.tableRef.current.setSelection(this.getSelectedBeats());
};
private getSelectedBeats = (): CMPopulatedBeat[] => {
const selectedIds = this.state.tableRef.current.state.selection.map((beat: any) => beat.id);
if (!this.tableRef.current) {
return [];
}
const selectedIds = this.tableRef.current.state.selection.map((beat: any) => beat.id);
const beats: CMPopulatedBeat[] = [];
selectedIds.forEach((id: any) => {
const beat: CMPopulatedBeat | undefined = this.props.beats.find(b => b.id === id);
const beat = this.props.containers.beats.state.list.find(b => b.id === id);
if (beat) {
beats.push(beat);
}
@ -362,13 +328,10 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
return beats;
};
private filterSelectedBeatTags = () => {
if (!this.state.tags) {
return [];
}
private filterTags = (tags: BeatTag[]) => {
return this.selectedBeatConfigsRequireUniqueness()
? this.state.tags.map(this.disableTagForUniquenessEnforcement)
: this.state.tags;
? tags.map(this.disableTagForUniquenessEnforcement)
: tags;
};
private configBlocksRequireUniqueness = (configurationBlocks: ConfigurationBlock[]) =>
@ -391,7 +354,4 @@ class BeatsPageUi extends React.PureComponent<BeatsPageProps, BeatsPageState> {
.reduce((acc, cur) => acc || cur, false);
}
const BeatsPageWrapped = injectI18n(BeatsPageUi);
export const BeatsPage = BeatsPageWrapped as typeof BeatsPageWrapped & {
ActionArea: typeof BeatsPageUi['ActionArea'];
};
export const BeatsPage = injectI18n(BeatsPageComponent);

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
// @ts-ignore types for EuiTabs not currently available
EuiTab,
// @ts-ignore types for EuiTab not currently available
EuiTabs,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import { Subscribe } from 'unstated';
import { CMPopulatedBeat } from '../../../common/domain_types';
import { PrimaryLayout } from '../../components/layouts/primary';
import { ChildRoutes } from '../../components/navigation/child_routes';
import { BeatsContainer } from '../../containers/beats';
import { TagsContainer } from '../../containers/tags';
import { withUrlState } from '../../containers/with_url_state';
import { AppPageProps } from '../../frontend_types';
interface MainPagesState {
enrollBeat?: {
enrollmentToken: string;
} | null;
beats: CMPopulatedBeat[];
loadedBeatsAtLeastOnce: boolean;
}
class MainPageComponent extends React.PureComponent<AppPageProps, MainPagesState> {
constructor(props: AppPageProps) {
super(props);
this.state = {
loadedBeatsAtLeastOnce: false,
beats: [],
};
}
public onTabClicked = (path: string) => {
return () => {
this.props.goTo(path);
};
};
public render() {
return (
<PrimaryLayout title="Beats" hideBreadcrumbs={this.props.libs.framework.info.k7Design}>
{(renderAction: any) => (
<Subscribe to={[BeatsContainer, TagsContainer]}>
{(beats: BeatsContainer, tags: TagsContainer) => (
<React.Fragment>
<EuiTabs>
<EuiTab
isSelected={`/overview/enrolled_beats` === this.props.history.location.pathname}
onClick={this.onTabClicked(`/overview/enrolled_beats`)}
>
<FormattedMessage
id="xpack.beatsManagement.beats.enrolledBeatsTabTitle"
defaultMessage="Enrolled Beats"
/>
</EuiTab>
<EuiTab
isSelected={
`/overview/configuration_tags` === this.props.history.location.pathname
}
onClick={this.onTabClicked(`/overview/configuration_tags`)}
>
<FormattedMessage
id="xpack.beatsManagement.beats.configurationTagsTabTitle"
defaultMessage="Configuration tags"
/>
</EuiTab>
</EuiTabs>
<ChildRoutes
routes={this.props.routes}
renderAction={renderAction}
{...this.props}
beatsContainer={beats}
tagsContainer={tags}
/>
</React.Fragment>
)}
</Subscribe>
)}
</PrimaryLayout>
);
}
}
export const MainPage = withUrlState<AppPageProps>(MainPageComponent);

View file

@ -4,35 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import euiVars from '@elastic/eui/dist/eui_theme_k6_light.json';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import 'brace/mode/yaml';
import 'brace/theme/github';
import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import * as euiVars from '@elastic/eui/dist/eui_theme_k6_light.json';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { sample } from 'lodash';
import React from 'react';
import { UNIQUENESS_ENFORCING_TYPES } from 'x-pack/plugins/beats_management/common/constants';
import { BeatTag, CMPopulatedBeat } from '../../../common/domain_types';
import { AppURLState } from '../../app';
import { PrimaryLayout } from '../../components/layouts/primary';
import { TagEdit } from '../../components/tag';
import { URLStateProps, withUrlState } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
interface TagPageProps extends URLStateProps<AppURLState> {
libs: FrontendLibs;
match: any;
intl: InjectedIntl;
}
import { BeatTag, CMBeat, CMPopulatedBeat } from '../../common/domain_types';
import { PrimaryLayout } from '../components/layouts/primary';
import { TagEdit } from '../components/tag';
import { AppPageProps } from '../frontend_types';
interface TagPageState {
showFlyout: boolean;
attachedBeats: CMPopulatedBeat[] | null;
tag: BeatTag;
}
export class TagPageComponent extends React.PureComponent<TagPageProps, TagPageState> {
class TagPageComponent extends React.PureComponent<
AppPageProps & {
intl: InjectedIntl;
},
TagPageState
> {
private mode: 'edit' | 'create' = 'create';
constructor(props: TagPageProps) {
constructor(props: AppPageProps & { intl: InjectedIntl }) {
super(props);
const randomColor = sample(
Object.keys(euiVars)
@ -82,21 +79,23 @@ export class TagPageComponent extends React.PureComponent<TagPageProps, TagPageS
<div>
<TagEdit
tag={this.state.tag}
mode={this.mode}
onDetachBeat={async (beatIds: string[]) => {
await this.props.libs.beats.removeTagsFromBeats(
beatIds.map(id => {
return { beatId: id, tag: this.state.tag.id };
})
);
await this.loadAttachedBeats();
}}
onDetachBeat={
this.mode === 'edit'
? async (beatIds: string[]) => {
await this.props.containers.beats.removeTagsFromBeats(
beatIds,
this.state.tag.id
);
await this.loadAttachedBeats();
}
: undefined
}
onTagChange={(field: string, value: string | number) =>
this.setState(oldState => ({
tag: { ...oldState.tag, [field]: value },
}))
}
attachedBeats={this.state.attachedBeats}
attachedBeats={this.state.attachedBeats as CMBeat[]}
/>
<EuiSpacer size="m" />
<EuiFlexGroup>
@ -117,7 +116,7 @@ export class TagPageComponent extends React.PureComponent<TagPageProps, TagPageS
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => this.props.goTo('/overview/tags')}>
<EuiButtonEmpty onClick={() => this.props.goTo('/overview/configuration_tags')}>
<FormattedMessage
id="xpack.beatsManagement.tag.cancelButtonLabel"
defaultMessage="Cancel"
@ -143,7 +142,7 @@ export class TagPageComponent extends React.PureComponent<TagPageProps, TagPageS
private loadTag = async () => {
const tags = await this.props.libs.tags.getTagsWithIds([this.props.match.params.tagid]);
if (tags.length === 0) {
// TODO do something to error
// TODO do something to error https://github.com/elastic/kibana/issues/26023
}
this.setState({
tag: tags[0],
@ -159,13 +158,12 @@ export class TagPageComponent extends React.PureComponent<TagPageProps, TagPageS
};
private saveTag = async () => {
await this.props.libs.tags.upsertTag(this.state.tag as BeatTag);
this.props.goTo(`/overview/tags`);
this.props.goTo(`/overview/configuration_tags`);
};
private getNumExclusiveConfigurationBlocks = () =>
this.state.tag.configuration_blocks
.map(({ type }) => UNIQUENESS_ENFORCING_TYPES.some(uniqueType => uniqueType === type))
.reduce((acc, cur) => (cur ? acc + 1 : acc), 0);
}
export const TagPageUi = withUrlState<TagPageProps>(TagPageComponent);
export const TagPage = injectI18n(TagPageUi);
export const TagPage = injectI18n(TagPageComponent);

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton } from '@elastic/eui';
import React, { Component } from 'react';
import { EnrollBeat } from '../../../components/enroll_beats';
import { AppPageProps } from '../../../frontend_types';
interface ComponentState {
readyToContinue: boolean;
}
export class BeatsInitialEnrollmentPage extends Component<AppPageProps, ComponentState> {
constructor(props: AppPageProps) {
super(props);
this.state = {
readyToContinue: false,
};
}
public onBeatEnrolled = () => {
this.setState({
readyToContinue: true,
});
};
public createEnrollmentToken = async () => {
const enrollmentToken = await this.props.libs.tokens.createEnrollmentToken();
this.props.setUrlState({
enrollmentToken,
});
};
public render() {
return (
<React.Fragment>
<EnrollBeat
frameworkBasePath={this.props.libs.framework.info.basePath}
enrollmentToken={this.props.urlState.enrollmentToken || ''}
getBeatWithToken={this.props.libs.beats.getBeatWithToken}
createEnrollmentToken={this.createEnrollmentToken}
onBeatEnrolled={this.onBeatEnrolled}
/>
{this.state.readyToContinue && (
<React.Fragment>
<EuiButton
size="s"
color="primary"
style={{ marginLeft: 10 }}
onClick={async () => {
this.props.goTo('/walkthrough/initial/tag');
}}
>
Continue
</EuiButton>
</React.Fragment>
)}
</React.Fragment>
);
}
}

View file

@ -6,19 +6,23 @@
import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiPageContent } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React from 'react';
import { RouteComponentProps } from 'react-router';
import { BeatTag, CMBeat } from '../../../common/domain_types';
import { BeatsTagAssignment } from '../../../server/lib/adapters/beats/adapter_types';
import { AppURLState } from '../../app';
import { URLStateProps, withUrlState } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
interface PageProps extends URLStateProps<AppURLState>, RouteComponentProps<any> {
loadBeats: any;
libs: FrontendLibs;
intl: InjectedIntl;
import { CMPopulatedBeat } from '../../../../common/domain_types';
import { AppPageProps } from '../../../frontend_types';
interface PageState {
assigned: boolean;
}
export class FinishWalkthrough extends React.Component<PageProps, any> {
constructor(props: PageProps) {
class FinishWalkthrough extends React.Component<
AppPageProps & {
intl: InjectedIntl;
},
PageState
> {
constructor(
props: AppPageProps & {
intl: InjectedIntl;
}
) {
super(props);
this.state = {
@ -28,8 +32,6 @@ export class FinishWalkthrough extends React.Component<PageProps, any> {
public componentDidMount() {
setTimeout(async () => {
await this.props.loadBeats();
const done = await this.assignTagToBeat();
if (done) {
@ -72,7 +74,7 @@ export class FinishWalkthrough extends React.Component<PageProps, any> {
fill
disabled={!this.state.assigned}
onClick={async () => {
goTo('/overview/beats');
goTo('/overview/enrolled_beats');
}}
>
<FormattedMessage
@ -88,9 +90,6 @@ export class FinishWalkthrough extends React.Component<PageProps, any> {
);
}
private createBeatTagAssignments = (beats: CMBeat[], tag: BeatTag): BeatsTagAssignment[] =>
beats.map(({ id }) => ({ beatId: id, tag: tag.id }));
private assignTagToBeat = async () => {
const { intl } = this.props;
if (!this.props.urlState.enrollmentToken) {
@ -119,10 +118,11 @@ export class FinishWalkthrough extends React.Component<PageProps, any> {
})
);
}
const tags = await this.props.libs.tags.getTagsWithIds([this.props.urlState.createdTag]);
const assignments = this.createBeatTagAssignments([beat], tags[0]);
await this.props.libs.beats.assignTagsToBeats(assignments);
await this.props.containers.beats.assignTagsToBeats(
[beat as CMPopulatedBeat],
this.props.urlState.createdTag
);
this.props.setUrlState({
createdTag: '',
enrollmentToken: '',
@ -131,6 +131,4 @@ export class FinishWalkthrough extends React.Component<PageProps, any> {
};
}
const FinishWalkthroughPageUi = withUrlState<PageProps>(FinishWalkthrough);
export const FinishWalkthroughPage = injectI18n(FinishWalkthroughPageUi);
export const FinishWalkthroughPage = injectI18n(FinishWalkthrough);

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { NoDataLayout } from '../../../components/layouts/no_data';
import { WalkthroughLayout } from '../../../components/layouts/walkthrough';
import { ChildRoutes } from '../../../components/navigation/child_routes';
import { ConnectedLink } from '../../../components/navigation/connected_link';
import { AppPageProps } from '../../../frontend_types';
class InitialWalkthroughPageComponent extends Component<
AppPageProps & {
intl: InjectedIntl;
}
> {
public render() {
const { intl } = this.props;
if (this.props.location.pathname === '/walkthrough/initial') {
return (
<NoDataLayout
title="Beats central management"
actionSection={
<ConnectedLink path="/walkthrough/initial/beat">
<EuiButton color="primary" fill>
<FormattedMessage
id="xpack.beatsManagement.enrollBeat.enrollBeatButtonLabel"
defaultMessage="Enroll Beat"
/>{' '}
</EuiButton>
</ConnectedLink>
}
>
<p>
<FormattedMessage
id="xpack.beatsManagement.enrollBeat.beatsCentralManagementDescription"
defaultMessage="Manage your configurations in a central location."
/>
</p>
</NoDataLayout>
);
}
return (
<WalkthroughLayout
title={intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.getStartedBeatsCentralManagementTitle',
defaultMessage: 'Get started with Beats central management',
})}
walkthroughSteps={[
{
id: '/walkthrough/initial/beat',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.enrollBeatStepLabel',
defaultMessage: 'Enroll Beat',
}),
},
{
id: '/walkthrough/initial/tag',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.createTagStepLabel',
defaultMessage: 'Create tag',
}),
},
{
id: '/walkthrough/initial/finish',
name: intl.formatMessage({
id: 'xpack.beatsManagement.enrollBeat.finishStepLabel',
defaultMessage: 'Finish',
}),
},
]}
goTo={() => {
// FIXME implament goto
}}
activePath={this.props.location.pathname}
>
<ChildRoutes routes={this.props.routes} {...this.props} />
</WalkthroughLayout>
);
}
}
export const InitialWalkthroughPage = injectI18n(InitialWalkthroughPageComponent);

View file

@ -3,36 +3,22 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { BeatTag } from '../../../../common/domain_types';
import { TagEdit } from '../../../components/tag/tag_edit';
import { AppPageProps } from '../../../frontend_types';
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import 'brace/mode/yaml';
import 'brace/theme/github';
import React from 'react';
import { BeatTag } from '../../../common/domain_types';
import { AppURLState } from '../../app';
import { TagEdit } from '../../components/tag';
import { URLStateProps, withUrlState } from '../../containers/with_url_state';
import { FrontendLibs } from '../../lib/lib';
interface TagPageProps extends URLStateProps<AppURLState> {
libs: FrontendLibs;
match: any;
intl: InjectedIntl;
}
interface TagPageState {
showFlyout: boolean;
interface PageState {
tag: BeatTag;
}
class CreateTagFragment extends React.PureComponent<TagPageProps, TagPageState> {
private mode: 'edit' | 'create' = 'create';
constructor(props: TagPageProps) {
export class InitialTagPage extends Component<AppPageProps, PageState> {
constructor(props: AppPageProps) {
super(props);
this.state = {
showFlyout: false,
tag: {
id: props.urlState.createdTag ? props.urlState.createdTag : '',
color: '#DD0A73',
@ -42,7 +28,6 @@ class CreateTagFragment extends React.PureComponent<TagPageProps, TagPageState>
};
if (props.urlState.createdTag) {
this.mode = 'edit';
this.loadTag();
}
}
@ -52,24 +37,12 @@ class CreateTagFragment extends React.PureComponent<TagPageProps, TagPageState>
<React.Fragment>
<TagEdit
tag={this.state.tag}
mode={this.mode}
onDetachBeat={(beatIds: string[]) => {
this.props.libs.beats.removeTagsFromBeats(
beatIds.map(id => {
return { beatId: id, tag: this.state.tag.id };
})
);
}}
onTagChange={(field: string, value: string | number) =>
this.setState(oldState => ({
tag: { ...oldState.tag, [field]: value },
}))
}
attachedBeats={null}
/>
<EuiSpacer />
<EuiHorizontalRule />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
@ -102,12 +75,10 @@ class CreateTagFragment extends React.PureComponent<TagPageProps, TagPageState>
};
private saveTag = async () => {
const { intl } = this.props;
const newTag = await this.props.libs.tags.upsertTag(this.state.tag as BeatTag);
if (!newTag) {
return alert(
intl.formatMessage({
id: 'xpack.beatsManagement.createTag.errorSavingTagTitle',
i18n.translate('xpack.beatsManagement.createTag.errorSavingTagTitle', {
defaultMessage: 'error saving tag',
})
);
@ -115,9 +86,6 @@ class CreateTagFragment extends React.PureComponent<TagPageProps, TagPageState>
this.props.setUrlState({
createdTag: newTag.id,
});
this.props.goTo(`/overview/initial/finish`);
this.props.goTo(`/walkthrough/initial/finish`);
};
}
const CreateTagPageFragmentUi = withUrlState<TagPageProps>(CreateTagFragment);
export const CreateTagPageFragment = injectI18n(CreateTagPageFragmentUi);

View file

@ -4,103 +4,135 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { HashRouter, Redirect, Route, Switch } from 'react-router-dom';
import { Header } from './components/layouts/header';
import { BreadcrumbConsumer, RouteWithBreadcrumb } from './components/route_with_breadcrumb';
import { FrontendLibs } from './lib/lib';
import { BeatDetailsPage } from './pages/beat';
import { EnforceSecurityPage } from './pages/enforce_security';
import { InvalidLicensePage } from './pages/invalid_license';
import { MainPages } from './pages/main';
import { NoAccessPage } from './pages/no_access';
import { TagPage } from './pages/tag';
import { get } from 'lodash';
import React, { Component } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { Loading } from './components/loading';
import { ChildRoutes } from './components/navigation/child_routes';
import { BeatsContainer } from './containers/beats';
import { TagsContainer } from './containers/tags';
import { URLStateProps, WithURLState } from './containers/with_url_state';
import { FrontendLibs } from './lib/types';
import { RouteTreeBuilder } from './utils/page_loader/page_loader';
export const PageRouter: React.SFC<{ libs: FrontendLibs }> = ({ libs }) => {
return (
<HashRouter basename="/management/beats_management">
<div>
<BreadcrumbConsumer>
{({ breadcrumbs }) => (
<Header
breadcrumbs={[
{
href: '#/management',
text: i18n.translate('xpack.beatsManagement.router.managementTitle', {
defaultMessage: 'Management',
}),
},
{
href: '#/management/beats_management',
text: i18n.translate('xpack.beatsManagement.router.beatsTitle', {
defaultMessage: 'Beats',
}),
},
...breadcrumbs,
]}
// See ./utils/page_loader/readme.md for details on how this works
// suffice to to say it dynamicly creates routes and pages based on the filesystem
// This is to ensure that the patterns are followed and types assured
// @ts-ignore
const requirePages = require.context('./pages', true, /\.tsx$/);
const routeTreeBuilder = new RouteTreeBuilder(requirePages);
const routesFromFilesystem = routeTreeBuilder.routeTreeFromPaths(requirePages.keys(), {
'/tag': ['action', 'tagid?'],
'/beat': ['beatId'],
});
interface RouterProps {
libs: FrontendLibs;
tagsContainer: TagsContainer;
beatsContainer: BeatsContainer;
}
interface RouterState {
loadingStatus: 'loading' | 'loaded:empty' | 'loaded';
}
export class AppRouter extends Component<RouterProps, RouterState> {
constructor(props: RouterProps) {
super(props);
this.state = {
loadingStatus: 'loading',
};
}
public async componentWillMount() {
if (this.state.loadingStatus === 'loading') {
await this.props.beatsContainer.reload();
await this.props.tagsContainer.reload();
const countOfEverything =
this.props.beatsContainer.state.list.length + this.props.tagsContainer.state.list.length;
this.setState({
loadingStatus: countOfEverything > 0 ? 'loaded' : 'loaded:empty',
});
}
}
public render() {
if (this.state.loadingStatus === 'loading') {
return <Loading />;
}
return (
<React.Fragment>
{/* Redirects mapping */}
<Switch>
{/* License check (UI displays when license exists but is expired) */}
{get(this.props.libs.framework.info, 'license.expired', true) && (
<Route
render={props =>
!props.location.pathname.includes('/error') ? (
<Redirect to="/error/invalid_license" />
) : null
}
/>
)}
</BreadcrumbConsumer>
<Switch>
{libs.framework.licenseExpired() && <Route render={() => <InvalidLicensePage />} />}
{!libs.framework.securityEnabled() && <Route render={() => <EnforceSecurityPage />} />}
{!libs.framework.getCurrentUser() ||
(!libs.framework.getCurrentUser().roles.includes('beats_admin') &&
!libs.framework
.getDefaultUserRoles()
.some(r => libs.framework.getCurrentUser().roles.includes(r)) && (
<Route render={() => <NoAccessPage />} />
))}
<Route
path="/"
exact={true}
render={() => <Redirect from="/" exact={true} to="/overview/beats" />}
/>
<Route path="/overview" render={(props: any) => <MainPages {...props} libs={libs} />} />
<RouteWithBreadcrumb
title={params => {
return i18n.translate('xpack.beatsManagement.router.beatTitle', {
defaultMessage: 'Beats: {beatId}',
values: { beatId: params.beatId },
});
}}
parentBreadcrumbs={[
{
text: i18n.translate('xpack.beatsManagement.router.beatsListTitle', {
defaultMessage: 'Beats List',
}),
href: '#/management/beats_management/overview/beats',
},
]}
path="/beat/:beatId"
render={(props: any) => <BeatDetailsPage {...props} libs={libs} />}
/>
<RouteWithBreadcrumb
title={params => {
if (params.action === 'create') {
return i18n.translate('xpack.beatsManagement.router.createTagTitle', {
defaultMessage: 'Create Tag',
});
{/* Ensure security is eanabled for elastic and kibana */}
{!get(this.props.libs.framework.info, 'security.enabled', true) && (
<Route
render={props =>
!props.location.pathname.includes('/error') ? (
<Redirect to="/error/enforce_security" />
) : null
}
return i18n.translate('xpack.beatsManagement.router.tagTitle', {
defaultMessage: 'Tag: {tagId}',
values: { tagId: params.tagid },
});
}}
parentBreadcrumbs={[
{
text: i18n.translate('xpack.beatsManagement.router.tagsListTitle', {
defaultMessage: 'Tags List',
}),
href: '#/management/beats_management/overview/tags',
},
]}
path="/tag/:action/:tagid?"
render={(props: any) => <TagPage {...props} libs={libs} />}
/>
/>
)}
{/* Make sure the user has correct permissions */}
{!this.props.libs.framework.currentUserHasOneOfRoles(
['beats_admin'].concat(this.props.libs.framework.info.settings.defaultUserRoles)
) && (
<Route
render={props =>
!props.location.pathname.includes('/error') ? (
<Redirect to="/error/no_access" />
) : null
}
/>
)}
{/* If there are no beats or tags yet, redirect to the walkthrough */}
{this.state.loadingStatus === 'loaded:empty' && (
<Route
render={props =>
!props.location.pathname.includes('/walkthrough') ? (
<Redirect to="/walkthrough/initial" />
) : null
}
/>
)}
{/* This app does not make use of a homepage. The mainpage is overview/enrolled_beats */}
<Route path="/" exact={true} render={() => <Redirect to="/overview/enrolled_beats" />} />
</Switch>
</div>
</HashRouter>
);
};
{/* Render routes from the FS */}
<WithURLState>
{(URLProps: URLStateProps) => (
<ChildRoutes
routes={routesFromFilesystem}
{...URLProps}
{...{
libs: this.props.libs,
containers: {
beats: this.props.beatsContainer,
tags: this.props.tagsContainer,
},
}}
/>
)}
</WithURLState>
</React.Fragment>
);
}
}

View file

@ -0,0 +1,143 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RouteTreeBuilder routeTreeFromPaths Should create a route tree 1`] = `
Array [
Object {
"component": null,
"path": "/tag",
},
Object {
"component": null,
"path": "/beat",
"routes": Array [
Object {
"component": null,
"path": "/beat/detail",
},
Object {
"component": null,
"path": "/beat/tags",
},
],
},
Object {
"component": null,
"path": "/error/enforce_security",
},
Object {
"component": null,
"path": "/error/invalid_license",
},
Object {
"component": null,
"path": "/error/no_access",
},
Object {
"component": null,
"path": "/overview",
"routes": Array [
Object {
"component": null,
"path": "/overview/enrolled_beats",
},
Object {
"component": null,
"path": "/overview/tag_configurations",
},
],
},
Object {
"component": null,
"path": "/walkthrough/initial",
"routes": Array [
Object {
"component": null,
"path": "/walkthrough/initial/beat",
},
Object {
"component": null,
"path": "/walkthrough/initial/finish",
},
Object {
"component": null,
"path": "/walkthrough/initial/tag",
},
],
},
Object {
"component": null,
"path": "*",
},
]
`;
exports[`RouteTreeBuilder routeTreeFromPaths Should create a route tree, with top level route having params 1`] = `
Array [
Object {
"component": null,
"path": "/tag/:action/:tagid?",
},
Object {
"component": null,
"path": "/beat",
"routes": Array [
Object {
"component": null,
"path": "/beat/detail",
},
Object {
"component": null,
"path": "/beat/tags",
},
],
},
Object {
"component": null,
"path": "/error/enforce_security",
},
Object {
"component": null,
"path": "/error/invalid_license",
},
Object {
"component": null,
"path": "/error/no_access",
},
Object {
"component": null,
"path": "/overview",
"routes": Array [
Object {
"component": null,
"path": "/overview/enrolled_beats",
},
Object {
"component": null,
"path": "/overview/tag_configurations",
},
],
},
Object {
"component": null,
"path": "/walkthrough/initial",
"routes": Array [
Object {
"component": null,
"path": "/walkthrough/initial/beat",
},
Object {
"component": null,
"path": "/walkthrough/initial/finish",
},
Object {
"component": null,
"path": "/walkthrough/initial/tag",
},
],
},
Object {
"component": null,
"path": "*",
},
]
`;

View file

@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RouteTreeBuilder } from './page_loader';
const pages = [
'./_404.tsx',
'./beat/detail.tsx',
'./beat/index.tsx',
'./beat/tags.tsx',
'./error/enforce_security.tsx',
'./error/invalid_license.tsx',
'./error/no_access.tsx',
'./overview/enrolled_beats.tsx',
'./overview/index.tsx',
'./overview/tag_configurations.tsx',
'./tag.tsx',
'./walkthrough/initial/beat.tsx',
'./walkthrough/initial/finish.tsx',
'./walkthrough/initial/index.tsx',
'./walkthrough/initial/tag.tsx',
];
describe('RouteTreeBuilder', () => {
describe('routeTreeFromPaths', () => {
it('Should fail to create a route tree due to no exported *Page component', () => {
const mockRequire = jest.fn(path => ({
path,
testComponent: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
expect(() => {
treeBuilder.routeTreeFromPaths(pages);
}).toThrowError(/in the pages folder does not include an exported/);
});
it('Should create a route tree', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
let tree;
expect(() => {
tree = treeBuilder.routeTreeFromPaths(pages);
}).not.toThrow();
expect(tree).toMatchSnapshot();
});
it('Should fail to create a route tree due to no exported custom *Component component', () => {
const mockRequire = jest.fn(path => ({
path,
testComponent: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire, /Component$/);
expect(() => {
treeBuilder.routeTreeFromPaths(pages);
}).not.toThrow();
});
it('Should create a route tree, with top level route having params', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages, {
'/tag': ['action', 'tagid?'],
});
expect(tree).toMatchSnapshot();
});
it('Should create a route tree, with a nested route having params', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages, {
'/beat': ['beatId'],
});
expect(tree[1].path).toEqual('/beat/:beatId');
});
});
it('Should create a route tree, with a deep nested route having params', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages, {
'/beat': ['beatId'],
'/beat/detail': ['other'],
});
expect(tree[1].path).toEqual('/beat/:beatId');
expect(tree[1].routes![0].path).toEqual('/beat/:beatId/detail/:other');
expect(tree[1].routes![1].path).toEqual('/beat/:beatId/tags');
});
it('Should throw an error on invalid mapped path', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
expect(() => {
treeBuilder.routeTreeFromPaths(pages, {
'/non-existant-path': ['beatId'],
});
}).toThrowError(/Invalid overrideMap provided to 'routeTreeFromPaths', \/non-existant-path /);
});
it('Should rended 404.tsx as a 404 route not /404', () => {
const mockRequire = jest.fn(path => ({
path,
testPage: null,
}));
const treeBuilder = new RouteTreeBuilder(mockRequire);
const tree = treeBuilder.routeTreeFromPaths(pages);
const firstPath = tree[0].path;
const lastPath = tree[tree.length - 1].path;
expect(firstPath).not.toBe('/_404');
expect(lastPath).toBe('*');
});
});

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { difference, flatten, last } from 'lodash';
interface PathTree {
[path: string]: string[];
}
export interface RouteConfig {
path: string;
component: React.ComponentType<any>;
routes?: RouteConfig[];
}
interface RouteParamsMap {
[path: string]: string[];
}
export class RouteTreeBuilder {
constructor(
private readonly requireWithContext: any,
private readonly pageComponentPattern: RegExp = /Page$/
) {}
public routeTreeFromPaths(paths: string[], mapParams: RouteParamsMap = {}): RouteConfig[] {
const pathTree = this.buildTree('./', paths);
const allRoutes = Object.keys(pathTree).reduce((routes: any[], filePath) => {
if (pathTree[filePath].includes('index.tsx')) {
routes.push(this.buildRouteWithChildren(filePath, pathTree[filePath], mapParams));
} else {
routes.concat(
pathTree[filePath].map(file => routes.push(this.buildRoute(filePath, file, mapParams)))
);
}
return routes;
}, []);
// Check that no overide maps are ignored due to being invalid
const flatRoutes = this.flatpackRoutes(allRoutes);
const mappedPaths = Object.keys(mapParams);
const invalidOverrides = difference(mappedPaths, flatRoutes);
if (invalidOverrides.length > 0 && flatRoutes.length > 0) {
throw new Error(
`Invalid overrideMap provided to 'routeTreeFromPaths', ${
invalidOverrides[0]
} is not a valid route. Only the following are: ${flatRoutes.join(', ')}`
);
}
// 404 route MUST be last or it gets used first in a switch
return allRoutes.sort((a: RouteConfig) => {
return a.path === '*' ? 1 : 0;
});
}
private flatpackRoutes(arr: RouteConfig[], pre: string = ''): string[] {
return flatten(
[].concat.apply(
[],
arr.map(item => {
const path = (pre + item.path).trim();
// The flattened route based on files without params added
const route = item.path.includes('/:')
? item.path
.split('/')
.filter(s => s.charAt(0) !== ':')
.join('/')
: item.path;
return item.routes ? [route, this.flatpackRoutes(item.routes, path)] : route;
})
)
);
}
private buildRouteWithChildren(dir: string, files: string[], mapParams: RouteParamsMap) {
const childFiles = files.filter(f => f !== 'index.tsx');
const parentConfig = this.buildRoute(dir, 'index.tsx', mapParams);
parentConfig.routes = childFiles.map(cf => this.buildRoute(dir, cf, mapParams));
return parentConfig;
}
private buildRoute(dir: string, file: string, mapParams: RouteParamsMap): RouteConfig {
// Remove the file extension as we dont want that in the URL... also index resolves to parent route
// so remove that... e.g. /beats/index is not the url we want, /beats should resolve to /beats/index
// just like how files resolve in node
const filePath = `${mapParams[dir] || dir}${file.replace('.tsx', '')}`.replace('/index', '');
const page = this.requireWithContext(`.${dir}${file}`);
const cleanDir = dir.replace(/\/$/, '');
// Make sure the expored variable name matches a pattern. By default it will choose the first
// exported variable that matches *Page
const componentExportName = Object.keys(page).find(varName =>
this.pageComponentPattern.test(varName)
);
if (!componentExportName) {
throw new Error(
`${dir}${file} in the pages folder does not include an exported \`${this.pageComponentPattern.toString()}\` component`
);
}
// _404 route is special and maps to a 404 page
if (filePath === '/_404') {
return {
path: '*',
component: page[componentExportName],
};
}
// mapped route has a parent with mapped params, so we map it here too
// e.g. /beat has a beatid param, so /beat/detail, a child of /beat
// should also have that param resulting in /beat/:beatid/detail/:other
if (mapParams[cleanDir] && filePath !== cleanDir) {
const dirWithParams = `${cleanDir}/:${mapParams[cleanDir].join('/:')}`;
const path = `${dirWithParams}/${file.replace('.tsx', '')}${
mapParams[filePath] ? '/:' : ''
}${(mapParams[filePath] || []).join('/:')}`;
return {
path,
component: page[componentExportName],
};
}
// route matches a mapped param exactly
// e.g. /beat has a beatid param, so it becomes /beat/:beatid
if (mapParams[filePath]) {
return {
path: `${filePath}/:${mapParams[filePath].join('/:')}`,
component: page[componentExportName],
};
}
return {
path: filePath,
component: page[componentExportName],
};
}
// Build tree recursively
private buildTree(basePath: string, paths: string[]): PathTree {
return paths.reduce(
(dir: any, p) => {
const path = {
dir:
p
.replace(basePath, '/') // make path absolute
.split('/')
.slice(0, -1) // remove file from path
.join('/')
.replace(/^\/\//, '') + '/', // should end in a slash but not be only //
file: last(p.split('/')),
};
// take each, remove the file name
if (dir[path.dir]) {
dir[path.dir].push(path.file);
} else {
dir[path.dir] = [path.file];
}
return dir;
},
{}
);
}
}

View file

@ -0,0 +1,21 @@
# Page loader
Routing in React is not easy, nether is ensuring a clean and simple api within pages.
This solves for both without massive config files. It also ensure URL paths match our files to make things easier to find
It works like this...
```ts
// Create a webpack context, ensureing all pages in the pages dir are included in the build
const requirePages = require.context('./pages', true, /\.tsx$/);
// Pass the context based require into the RouteTreeBuilder for require the files as-needed
const routeTreeBuilder = new RouteTreeBuilder(requirePages);
// turn the array of file paths from the require context into a nested tree of routes based on folder structure
const routesFromFilesystem = routeTreeBuilder.routeTreeFromPaths(requirePages.keys(), {
'/tag': ['action', 'tagid?'], // add params to a page. In this case /tag turns into /tag/:action/:tagid?
'/beat': ['beatId'],
'/beat/detail': ['action'], // it nests too, in this case, because of the above line, this is /beat/:beatId/detail/:action
});
```
In the above example to allow for flexability, the `./pages/beat.tsx` page would receve a prop of `routes` that is an array of sub-pages

View file

@ -8,7 +8,6 @@ import { flatten, get as _get, omit } from 'lodash';
import { INDEX_NAMES } from '../../../../common/constants';
import { CMBeat } from '../../../../common/domain_types';
import { DatabaseAdapter } from '../database/adapter_types';
import { FrameworkUser } from '../framework/adapter_types';
import { BeatsTagAssignment, CMBeatsAdapter } from './adapter_types';
@ -71,7 +70,6 @@ export class ElasticsearchBeatsAdapter implements CMBeatsAdapter {
const ids = beatIds.map(beatId => `beat:${beatId}`);
const params = {
_sourceInclude: ['beat.id', 'beat.verified_on'],
body: {
ids,
},

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FrameworkRequest, FrameworkUser } from '../framework/adapter_types';
export interface DatabaseAdapter {
putTemplate(user: FrameworkUser, params: DatabasePutTemplateParams): Promise<any>;
get<Source>(
@ -63,7 +64,7 @@ export interface DatabaseSearchParams extends DatabaseGenericParams {
sort?: DatabaseNameList;
_source?: DatabaseNameList;
_sourceExclude?: DatabaseNameList;
_sourceInclude?: DatabaseNameList;
_source_includes?: DatabaseNameList;
terminateAfter?: number;
stats?: DatabaseNameList;
suggestField?: string;
@ -142,7 +143,7 @@ export interface DatabaseBulkIndexDocumentsParams extends DatabaseGenericParams
fields?: DatabaseNameList;
_source?: DatabaseNameList;
_sourceExclude?: DatabaseNameList;
_sourceInclude?: DatabaseNameList;
_source_includes?: DatabaseNameList;
pipeline?: string;
index?: string;
}
@ -154,7 +155,7 @@ export interface DatabaseMGetParams extends DatabaseGenericParams {
refresh?: boolean;
_source?: DatabaseNameList;
_sourceExclude?: DatabaseNameList;
_sourceInclude?: DatabaseNameList;
_source_includes?: DatabaseNameList;
index: string;
type?: string;
}
@ -273,7 +274,7 @@ export interface DatabaseGetParams extends DatabaseGenericParams {
routing?: string;
_source?: DatabaseNameList;
_sourceExclude?: DatabaseNameList;
_sourceInclude?: DatabaseNameList;
_source_includes?: DatabaseNameList;
version?: number;
versionType?: DatabaseVersionType;
id: string;

View file

@ -3,8 +3,8 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { internalAuthData } from '../../../utils/wrap_request';
import { FrameworkUser } from '../framework/adapter_types';
import { internalAuthData } from './../framework/adapter_types';
import {
DatabaseAdapter,
DatabaseBulkIndexDocumentsParams,

View file

@ -7,10 +7,12 @@
// @ts-ignore
import { createEsTestCluster } from '@kbn/test';
import { config as beatsPluginConfig, configPrefix } from '../../../../..';
import { config as beatsPluginConfig } from '../../../../..';
// @ts-ignore
import * as kbnTestServer from '../../../../../../../../src/test_utils/kbn_server';
import { KibanaBackendFrameworkAdapter } from '../kibana_framework_adapter';
import { PLUGIN } from './../../../../../common/constants/plugin';
import { CONFIG_PREFIX } from './../../../../../common/constants/plugin';
import { contractTests } from './test_contract';
const kbnServer = kbnTestServer.createRootWithCorePlugins({ server: { maxPayloadBytes: 100 } });
@ -21,7 +23,7 @@ contractTests('Kibana Framework Adapter', {
await kbnServer.start();
const config = legacyServer.server.config();
config.extendSchema(beatsPluginConfig, {}, configPrefix);
config.extendSchema(beatsPluginConfig, {}, CONFIG_PREFIX);
config.set('xpack.beats.encryptionKey', 'foo');
},
@ -29,6 +31,6 @@ contractTests('Kibana Framework Adapter', {
await kbnServer.shutdown();
},
adapterSetup: () => {
return new KibanaBackendFrameworkAdapter(legacyServer.server);
return new KibanaBackendFrameworkAdapter(PLUGIN.ID, legacyServer.server);
},
});

View file

@ -4,21 +4,118 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Lifecycle, ResponseToolkit } from 'hapi';
import { internalAuthData } from '../../../utils/wrap_request';
import * as t from 'io-ts';
import { LicenseType } from '../../../../common/constants/security';
export const internalAuthData = Symbol('internalAuthData');
export const internalUser: FrameworkInternalUser = {
kind: 'internal',
};
export interface XpackInfo {
license: {
getType: () => LicenseType;
/** Is the license expired */
isActive: () => boolean;
getExpiryDateInMillis: () => number;
};
feature: (pluginId: string) => any;
isAvailable: () => boolean;
}
export interface BackendFrameworkAdapter {
internalUser: FrameworkInternalUser;
version: string;
info: null | FrameworkInfo;
log(text: string): void;
on(event: 'xpack.status.green', cb: () => void): void;
getSetting(settingPath: string): any;
exposeStaticDir(urlPath: string, dir: string): void;
registerRoute<
RouteRequest extends FrameworkWrappableRequest,
RouteResponse extends FrameworkResponse
>(
registerRoute<RouteRequest extends FrameworkRequest, RouteResponse extends FrameworkResponse>(
route: FrameworkRouteOptions<RouteRequest, RouteResponse>
): void;
}
export interface KibanaLegacyServer {
plugins: {
xpack_main: {
status: {
once: (status: 'green' | 'yellow' | 'red', callback: () => void) => void;
};
info: XpackInfo;
};
kibana: {
status: {
plugin: {
version: string;
};
};
};
security: {
getUser: (request: KibanaServerRequest) => any;
};
elasticsearch: {
getCluster: () => any;
};
beats_management: {};
};
config: () => any;
route: (routeConfig: any) => void;
log: (message: string) => void;
}
export const RuntimeFrameworkInfo = t.interface(
{
kibana: t.type({
version: t.string,
}),
license: t.type({
type: t.union(
['oss', 'trial', 'standard', 'basic', 'gold', 'platinum'].map(s => t.literal(s))
),
expired: t.boolean,
expiry_date_in_millis: t.number,
}),
security: t.type({
enabled: t.boolean,
available: t.boolean,
}),
watcher: t.type({
enabled: t.boolean,
available: t.boolean,
}),
},
'FrameworkInfo'
);
export interface FrameworkInfo extends t.TypeOf<typeof RuntimeFrameworkInfo> {}
export const RuntimeKibanaServerRequest = t.interface(
{
params: t.object,
payload: t.object,
query: t.object,
headers: t.type({
authorization: t.union([t.string, t.null]),
}),
info: t.type({
remoteAddress: t.string,
}),
},
'KibanaServerRequest'
);
export interface KibanaServerRequest extends t.TypeOf<typeof RuntimeKibanaServerRequest> {}
export const RuntimeKibanaUser = t.interface(
{
username: t.string,
roles: t.array(t.string),
full_name: t.union([t.null, t.string]),
email: t.union([t.null, t.string]),
enabled: t.boolean,
},
'KibanaUser'
);
export interface KibanaUser extends t.TypeOf<typeof RuntimeKibanaUser> {}
export interface FrameworkAuthenticatedUser<AuthDataType = any> {
kind: 'authenticated';
[internalAuthData]: AuthDataType;
@ -26,9 +123,6 @@ export interface FrameworkAuthenticatedUser<AuthDataType = any> {
roles: string[];
full_name: string | null;
email: string | null;
metadata: {
[key: string]: any;
};
enabled: boolean;
}
@ -45,46 +139,32 @@ export type FrameworkUser<AuthDataType = any> =
| FrameworkUnAuthenticatedUser
| FrameworkInternalUser;
export interface FrameworkRequest<
InternalRequest extends FrameworkWrappableRequest = FrameworkWrappableRequest
KibanaServerRequestGenaric extends Partial<KibanaServerRequest> = any
> {
user: FrameworkUser<InternalRequest['headers']>;
headers: InternalRequest['headers'];
info: InternalRequest['info'];
payload: InternalRequest['payload'];
params: InternalRequest['params'];
query: InternalRequest['query'];
user: FrameworkUser<KibanaServerRequestGenaric['headers']>;
headers: KibanaServerRequestGenaric['headers'];
info: KibanaServerRequest['info'];
payload: KibanaServerRequestGenaric['payload'];
params: KibanaServerRequestGenaric['params'];
query: KibanaServerRequestGenaric['query'];
}
export interface FrameworkRouteOptions<
RouteRequest extends FrameworkWrappableRequest,
RouteResponse extends FrameworkResponse
RouteRequest extends FrameworkRequest = FrameworkRequest,
RouteResponse extends FrameworkResponse = any
> {
path: string;
method: string | string[];
vhost?: string;
licenseRequired?: boolean;
licenseRequired?: string[];
requiredRoles?: string[];
handler: FrameworkRouteHandler<RouteRequest, RouteResponse>;
config?: {};
}
export type FrameworkRouteHandler<
RouteRequest extends FrameworkWrappableRequest,
RouteRequest extends KibanaServerRequest,
RouteResponse extends FrameworkResponse
> = (request: FrameworkRequest<RouteRequest>, h: ResponseToolkit) => void;
export interface FrameworkWrappableRequest<
Payload = any,
Params = any,
Query = any,
Headers = any,
Info = any
> {
headers: Headers;
info: Info;
payload: Payload;
params: Params;
query: Query;
}
export type FrameworkResponse = Lifecycle.ReturnValue;

View file

@ -4,13 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { wrapRequest } from '../../../utils/wrap_request';
import { FrameworkInternalUser } from './adapter_types';
import { LicenseType } from './../../../../common/constants/security';
import { KibanaServerRequest } from './adapter_types';
import {
BackendFrameworkAdapter,
FrameworkInfo,
FrameworkRequest,
FrameworkResponse,
FrameworkRouteOptions,
FrameworkWrappableRequest,
internalAuthData,
internalUser,
} from './adapter_types';
interface TestSettings {
@ -19,10 +22,9 @@ interface TestSettings {
}
export class HapiBackendFrameworkAdapter implements BackendFrameworkAdapter {
public readonly internalUser: FrameworkInternalUser = {
kind: 'internal',
};
public version: string;
public info: null | FrameworkInfo = null;
public readonly internalUser = internalUser;
private settings: TestSettings;
private server: any;
@ -31,13 +33,40 @@ export class HapiBackendFrameworkAdapter implements BackendFrameworkAdapter {
encryptionKey: 'something_who_cares',
enrollmentTokensTtlInSeconds: 10 * 60, // 10 minutes
},
hapiServer?: any
hapiServer?: any,
license: LicenseType = 'trial',
securityEnabled: boolean = true,
licenseActive: boolean = true
) {
this.server = hapiServer;
this.settings = settings;
this.version = 'testing';
}
const now = new Date();
this.info = {
kibana: {
version: 'unknown',
},
license: {
type: license,
expired: !licenseActive,
expiry_date_in_millis: new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime(),
},
security: {
enabled: securityEnabled,
available: securityEnabled,
},
watcher: {
enabled: true,
available: true,
},
};
}
public log(text: string) {
this.server.log(text);
}
public on(event: 'xpack.status.green', cb: () => void) {
cb();
}
public getSetting(settingPath: string) {
switch (settingPath) {
case 'xpack.beats.enrollmentTokensTtlInSeconds':
@ -63,18 +92,18 @@ export class HapiBackendFrameworkAdapter implements BackendFrameworkAdapter {
}
public registerRoute<
RouteRequest extends FrameworkWrappableRequest,
RouteRequest extends FrameworkRequest,
RouteResponse extends FrameworkResponse
>(route: FrameworkRouteOptions<RouteRequest, RouteResponse>) {
if (!this.server) {
throw new Error('Must pass a hapi server into the adapter to use registerRoute');
}
const wrappedHandler = (licenseRequired: boolean) => (request: any, h: any) => {
return route.handler(wrapRequest(request), h);
const wrappedHandler = (licenseRequired: string[]) => (request: any, h: any) => {
return route.handler(this.wrapRequest(request), h);
};
this.server.route({
handler: wrappedHandler(route.licenseRequired || false),
handler: wrappedHandler(route.licenseRequired || []),
method: route.method,
path: route.path,
config: {
@ -87,4 +116,33 @@ export class HapiBackendFrameworkAdapter implements BackendFrameworkAdapter {
public async injectRequstForTesting({ method, url, headers, payload }: any) {
return await this.server.inject({ method, url, headers, payload });
}
private wrapRequest<InternalRequest extends KibanaServerRequest>(
req: InternalRequest
): FrameworkRequest<InternalRequest> {
const { params, payload, query, headers, info } = req;
const isAuthenticated = headers.authorization != null;
return {
user: isAuthenticated
? {
kind: 'authenticated',
[internalAuthData]: headers,
username: 'elastic',
roles: ['superuser'],
full_name: null,
email: null,
enabled: true,
}
: {
kind: 'unauthenticated',
},
headers,
info,
params,
payload,
query,
};
}
}

View file

@ -4,61 +4,64 @@
* you may not use this file except in compliance with the Elastic License.
*/
// @ts-ignore
import Boom from 'boom';
import { difference } from 'lodash';
import { ResponseToolkit } from 'hapi';
import { PathReporter } from 'io-ts/lib/PathReporter';
import { get } from 'lodash';
// @ts-ignore
import { mirrorPluginStatus } from '../../../../../../server/lib/mirror_plugin_status';
import { PLUGIN } from '../../../../common/constants/plugin';
import { wrapRequest } from '../../../utils/wrap_request';
import { FrameworkRequest } from './adapter_types';
import {
BackendFrameworkAdapter,
FrameworkInternalUser,
FrameworkInfo,
FrameworkRequest,
FrameworkResponse,
FrameworkRouteOptions,
FrameworkWrappableRequest,
internalAuthData,
internalUser,
KibanaLegacyServer,
KibanaServerRequest,
KibanaUser,
RuntimeFrameworkInfo,
RuntimeKibanaUser,
XpackInfo,
} from './adapter_types';
export class KibanaBackendFrameworkAdapter implements BackendFrameworkAdapter {
public readonly internalUser: FrameworkInternalUser = {
kind: 'internal',
};
public version: string;
private server: any;
private cryptoHash: string | null;
public readonly internalUser = internalUser;
public info: null | FrameworkInfo = null;
constructor(hapiServer: any) {
this.server = hapiServer;
if (hapiServer.plugins.kibana) {
this.version = hapiServer.plugins.kibana.status.plugin.version;
} else {
this.version = 'unknown';
}
this.cryptoHash = null;
this.validateConfig();
const xpackMainPlugin = hapiServer.plugins.xpack_main;
const thisPlugin = hapiServer.plugins.beats_management;
constructor(
private readonly PLUGIN_ID: string,
private readonly server: KibanaLegacyServer,
private readonly CONFIG_PREFIX?: string
) {
const xpackMainPlugin = this.server.plugins.xpack_main;
const thisPlugin = this.server.plugins.beats_management;
mirrorPluginStatus(xpackMainPlugin, thisPlugin);
xpackMainPlugin.status.once('green', () => {
this.xpackInfoWasUpdatedHandler(xpackMainPlugin.info);
// Register a function that is called whenever the xpack info changes,
// to re-compute the license check results for this plugin
xpackMainPlugin.info
.feature(PLUGIN.ID)
.registerLicenseCheckResultsGenerator((xPackInfo: any) => this.checkLicense(xPackInfo));
.feature(this.PLUGIN_ID)
.registerLicenseCheckResultsGenerator(this.xpackInfoWasUpdatedHandler);
});
}
// TODO make base path a constructor level param
public getSetting(settingPath: string) {
// TODO type check server properly
if (settingPath === 'xpack.beats.encryptionKey') {
// @ts-ignore
return this.server.config().get(settingPath) || this.cryptoHash;
public on(event: 'xpack.status.green', cb: () => void) {
switch (event) {
case 'xpack.status.green':
this.server.plugins.xpack_main.status.once('green', cb);
}
// @ts-ignore
return this.server.config().get(settingPath) || this.cryptoHash;
}
public getSetting(settingPath: string) {
return this.server.config().get(settingPath);
}
public log(text: string) {
this.server.log(text);
}
public exposeStaticDir(urlPath: string, dir: string): void {
@ -74,136 +77,120 @@ export class KibanaBackendFrameworkAdapter implements BackendFrameworkAdapter {
}
public registerRoute<
RouteRequest extends FrameworkWrappableRequest,
RouteRequest extends FrameworkRequest,
RouteResponse extends FrameworkResponse
>(route: FrameworkRouteOptions<RouteRequest, RouteResponse>) {
const hasAny = (roles: string[], requiredRoles: string[]) =>
requiredRoles.some(r => roles.includes(r));
const wrappedHandler = (licenseRequired: boolean, requiredRoles?: string[]) => async (
request: any,
h: any
) => {
const xpackMainPlugin = this.server.plugins.xpack_main;
const licenseCheckResults = xpackMainPlugin.info.feature(PLUGIN.ID).getLicenseCheckResults();
if (licenseRequired && !licenseCheckResults.licenseValid) {
return Boom.forbidden(licenseCheckResults.message);
}
const wrappedRequest = wrapRequest(request);
if (requiredRoles) {
if (wrappedRequest.user.kind !== 'authenticated') {
return h.response().code(403);
}
wrappedRequest.user = {
...wrappedRequest.user,
...(await this.getUser(request)),
};
if (
wrappedRequest.user.kind === 'authenticated' &&
(!hasAny(wrappedRequest.user.roles, this.getSetting('xpack.beats.defaultUserRoles')) ||
!wrappedRequest.user.roles) &&
difference(requiredRoles, wrappedRequest.user.roles).length !== 0
) {
return h.response().code(403);
}
}
return route.handler(wrappedRequest, h);
};
this.server.route({
handler: wrappedHandler(route.licenseRequired || false, route.requiredRoles),
handler: async (request: KibanaServerRequest, h: ResponseToolkit) => {
// Note, RuntimeKibanaServerRequest is avalaible to validate request, and its type *is* KibanaServerRequest
// but is not used here for perf reasons. It's value here is not high enough...
return await route.handler(await this.wrapRequest<RouteRequest>(request), h);
},
method: route.method,
path: route.path,
config: route.config,
});
}
private async getUser(request: FrameworkRequest) {
private async wrapRequest<InternalRequest extends KibanaServerRequest>(
req: KibanaServerRequest
): Promise<FrameworkRequest<InternalRequest>> {
const { params, payload, query, headers, info } = req;
let isAuthenticated = headers.authorization != null;
let user;
if (isAuthenticated) {
user = await this.getUser(req);
if (!user) {
isAuthenticated = false;
}
}
return {
user:
isAuthenticated && user
? {
kind: 'authenticated',
[internalAuthData]: headers,
...user,
}
: {
kind: 'unauthenticated',
},
headers,
info,
params,
payload,
query,
};
}
private async getUser(request: KibanaServerRequest): Promise<KibanaUser | null> {
let user;
try {
return await this.server.plugins.security.getUser(request);
user = await this.server.plugins.security.getUser(request);
} catch (e) {
return null;
}
}
// TODO make key a param
private validateConfig() {
// @ts-ignore
const config = this.server.config();
const encryptionKey = config.get('xpack.beats.encryptionKey');
if (!encryptionKey) {
this.server.log(
'Using a default encryption key for xpack.beats.encryptionKey. It is recommended that you set xpack.beats.encryptionKey in kibana.yml with a unique token'
const assertKibanaUser = RuntimeKibanaUser.decode(user);
if (assertKibanaUser.isLeft()) {
throw new Error(
`Error parsing user info in ${this.PLUGIN_ID}, ${
PathReporter.report(assertKibanaUser)[0]
}`
);
this.cryptoHash = 'xpack_beats_default_encryptionKey';
}
return user;
}
// TODO this should NOT be in an adapter, break up and move validation to a lib
private checkLicense(xPackInfo: any) {
private xpackInfoWasUpdatedHandler = (xpackInfo: XpackInfo) => {
let xpackInfoUnpacked: FrameworkInfo;
// If, for some reason, we cannot get the license information
// from Elasticsearch, assume worst case and disable the Logstash pipeline UI
if (!xPackInfo || !xPackInfo.isAvailable()) {
return {
securityEnabled: false,
licenseValid: false,
message:
'You cannot manage Beats central management because license information is not available at this time.',
};
// from Elasticsearch, assume worst case and disable
if (!xpackInfo || !xpackInfo.isAvailable()) {
this.info = null;
return;
}
const VALID_LICENSE_MODES = ['trial', 'standard', 'gold', 'platinum'];
const isLicenseValid = xPackInfo.license.isOneOf(VALID_LICENSE_MODES);
const isLicenseActive = xPackInfo.license.isActive();
const licenseType = xPackInfo.license.getType();
const isSecurityEnabled = xPackInfo.feature('security').isEnabled();
// License is not valid
if (!isLicenseValid) {
return {
defaultUserRoles: this.getSetting('xpack.beats.defaultUserRoles'),
securityEnabled: true,
licenseValid: false,
licenseExpired: false,
message: `Your ${licenseType} license does not support Beats central management features. Please upgrade your license.`,
try {
xpackInfoUnpacked = {
kibana: {
version: get(this.server, 'plugins.kibana.status.plugin.version', 'unknown'),
},
license: {
type: xpackInfo.license.getType(),
expired: !xpackInfo.license.isActive(),
expiry_date_in_millis:
xpackInfo.license.getExpiryDateInMillis() !== undefined
? xpackInfo.license.getExpiryDateInMillis()
: -1,
},
security: {
enabled: !!xpackInfo.feature('security') && xpackInfo.feature('security').isEnabled(),
available: !!xpackInfo.feature('security'),
},
watcher: {
enabled: !!xpackInfo.feature('watcher') && xpackInfo.feature('watcher').isEnabled(),
available: !!xpackInfo.feature('watcher'),
},
};
} catch (e) {
this.server.log(`Error accessing required xPackInfo in ${this.PLUGIN_ID} Kibana adapter`);
throw e;
}
// License is valid but not active, we go into a read-only mode.
if (!isLicenseActive) {
return {
defaultUserRoles: this.getSetting('xpack.beats.defaultUserRoles'),
securityEnabled: true,
licenseValid: true,
licenseExpired: true,
message: `You cannot edit, create, or delete your Beats central management configurations because your ${licenseType} license has expired.`,
};
const assertData = RuntimeFrameworkInfo.decode(xpackInfoUnpacked);
if (assertData.isLeft()) {
throw new Error(
`Error parsing xpack info in ${this.PLUGIN_ID}, ${PathReporter.report(assertData)[0]}`
);
}
this.info = xpackInfoUnpacked;
// Security is not enabled in ES
if (!isSecurityEnabled) {
const message =
'Security must be enabled in order to use Beats central management features.' +
' Please set xpack.security.enabled: true in your elasticsearch.yml.';
return {
defaultUserRoles: this.getSetting('xpack.beats.defaultUserRoles'),
securityEnabled: false,
licenseValid: true,
licenseExpired: false,
message,
};
}
// License is valid and active
return {
defaultUserRoles: this.getSetting('xpack.beats.defaultUserRoles'),
securityEnabled: true,
licenseValid: true,
licenseExpired: false,
security: xpackInfoUnpacked.security,
settings: this.getSetting(this.CONFIG_PREFIX || this.PLUGIN_ID),
};
}
};
}

View file

@ -21,6 +21,7 @@ export class ElasticsearchTagsAdapter implements CMTagsAdapter {
public async getAll(user: FrameworkUser, ESQuery?: any) {
const params = {
ignore: [404],
_source: true,
size: 10000,
index: INDEX_NAMES.BEATS,
@ -112,6 +113,7 @@ export class ElasticsearchTagsAdapter implements CMTagsAdapter {
const ids = tagIds.map(tag => `tag:${tag}`);
const params = {
ignore: [404],
_source: true,
body: {
ids,

View file

@ -11,7 +11,7 @@ export interface TokenEnrollmentData {
}
export interface CMTokensAdapter {
deleteEnrollmentToken(enrollmentToken: string): Promise<void>;
getEnrollmentToken(enrollmentToken: string): Promise<TokenEnrollmentData>;
deleteEnrollmentToken(user: FrameworkUser, enrollmentToken: string): Promise<void>;
getEnrollmentToken(user: FrameworkUser, enrollmentToken: string): Promise<TokenEnrollmentData>;
upsertTokens(user: FrameworkUser, tokens: TokenEnrollmentData[]): Promise<TokenEnrollmentData[]>;
}

View file

@ -7,29 +7,26 @@
import { flatten, get } from 'lodash';
import { INDEX_NAMES } from '../../../../common/constants';
import { DatabaseAdapter } from '../database/adapter_types';
import { BackendFrameworkAdapter, FrameworkUser } from '../framework/adapter_types';
import { FrameworkUser } from '../framework/adapter_types';
import { CMTokensAdapter, TokenEnrollmentData } from './adapter_types';
export class ElasticsearchTokensAdapter implements CMTokensAdapter {
private database: DatabaseAdapter;
private framework: BackendFrameworkAdapter;
constructor(private readonly database: DatabaseAdapter) {}
constructor(database: DatabaseAdapter, framework: BackendFrameworkAdapter) {
this.database = database;
this.framework = framework;
}
public async deleteEnrollmentToken(enrollmentToken: string) {
public async deleteEnrollmentToken(user: FrameworkUser, enrollmentToken: string) {
const params = {
id: `enrollment_token:${enrollmentToken}`,
index: INDEX_NAMES.BEATS,
type: '_doc',
};
await this.database.delete(this.framework.internalUser, params);
await this.database.delete(user, params);
}
public async getEnrollmentToken(tokenString: string): Promise<TokenEnrollmentData> {
public async getEnrollmentToken(
user: FrameworkUser,
tokenString: string
): Promise<TokenEnrollmentData> {
const params = {
id: `enrollment_token:${tokenString}`,
ignore: [404],
@ -37,7 +34,7 @@ export class ElasticsearchTokensAdapter implements CMTokensAdapter {
type: '_doc',
};
const response = await this.database.get(this.framework.internalUser, params);
const response = await this.database.get(user, params);
const tokenDetails = get<TokenEnrollmentData>(response, '_source.enrollment_token', {
expires_on: '0',
token: null,

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FrameworkAuthenticatedUser } from '../framework/adapter_types';
import { FrameworkAuthenticatedUser, FrameworkUser } from '../framework/adapter_types';
import { CMTokensAdapter, TokenEnrollmentData } from './adapter_types';
export class MemoryTokensAdapter implements CMTokensAdapter {
@ -14,7 +14,7 @@ export class MemoryTokensAdapter implements CMTokensAdapter {
this.tokenDB = tokenDB;
}
public async deleteEnrollmentToken(enrollmentToken: string) {
public async deleteEnrollmentToken(user: FrameworkUser, enrollmentToken: string) {
const index = this.tokenDB.findIndex(token => token.token === enrollmentToken);
if (index > -1) {
@ -22,7 +22,10 @@ export class MemoryTokensAdapter implements CMTokensAdapter {
}
}
public async getEnrollmentToken(tokenString: string): Promise<TokenEnrollmentData> {
public async getEnrollmentToken(
user: FrameworkUser,
tokenString: string
): Promise<TokenEnrollmentData> {
return new Promise<TokenEnrollmentData>(resolve => {
return resolve(this.tokenDB.find(token => token.token === tokenString));
});

View file

@ -6,26 +6,26 @@
import { uniq } from 'lodash';
import moment from 'moment';
import { findNonExistentItems } from '../../utils/find_non_existent_items';
import { findNonExistentItems } from '../utils/find_non_existent_items';
import { CMBeat } from '../../../common/domain_types';
import { BeatsTagAssignment, CMBeatsAdapter } from '../adapters/beats/adapter_types';
import { FrameworkUser } from '../adapters/framework/adapter_types';
import { CMBeat } from '../../common/domain_types';
import { BeatsTagAssignment, CMBeatsAdapter } from './adapters/beats/adapter_types';
import { FrameworkUser } from './adapters/framework/adapter_types';
import { CMAssignmentReturn } from '../adapters/beats/adapter_types';
import { BeatsRemovalReturn } from '../adapters/beats/adapter_types';
import { BeatEnrollmentStatus, CMDomainLibs, CMServerLibs, UserOrToken } from '../lib';
import { CMAssignmentReturn } from './adapters/beats/adapter_types';
import { BeatsRemovalReturn } from './adapters/beats/adapter_types';
import { BeatEnrollmentStatus, CMServerLibs, UserOrToken } from './types';
export class CMBeatsDomain {
private tags: CMDomainLibs['tags'];
private tokens: CMDomainLibs['tokens'];
private tags: CMServerLibs['tags'];
private tokens: CMServerLibs['tokens'];
private framework: CMServerLibs['framework'];
constructor(
private readonly adapter: CMBeatsAdapter,
libs: {
tags: CMDomainLibs['tags'];
tokens: CMDomainLibs['tokens'];
tags: CMServerLibs['tags'];
tokens: CMServerLibs['tokens'];
framework: CMServerLibs['framework'];
}
) {
@ -60,7 +60,7 @@ export class CMBeatsDomain {
public async update(userOrToken: UserOrToken, beatId: string, beatData: Partial<CMBeat>) {
const beat = await this.adapter.get(this.framework.internalUser, beatId);
// TODO make return type enum
// FIXME make return type enum
if (beat === null) {
return 'beat-not-found';
}
@ -83,7 +83,6 @@ export class CMBeatsDomain {
});
}
// TODO more strongly type this
public async enrollBeat(
enrollmentToken: string,
beatId: string,
@ -148,7 +147,7 @@ export class CMBeatsDomain {
'removals'
);
// TODO abstract this
// FIXME abstract this
const validRemovals = removals
.map((removal, idxInRequest) => ({
beatId: removal.beatId,
@ -180,8 +179,8 @@ export class CMBeatsDomain {
const nonExistentBeatIds = findNonExistentItems(beats, beatIds);
const nonExistentTags = findNonExistentItems(tags, tagIds);
// TODO break out back into route / function response
// TODO causes function to error if a beat or tag does not exist
// FIXME break out back into route / function response
// FIXME causes function to error if a beat or tag does not exist
addNonExistentItemToResponse(
response,
assignments,
@ -190,7 +189,7 @@ export class CMBeatsDomain {
'assignments'
);
// TODO abstract this
// FIXME abstract this
const validAssignments = assignments
.map((assignment, idxInRequest) => ({
beatId: assignment.beatId,
@ -209,7 +208,7 @@ export class CMBeatsDomain {
}
}
// TODO abstract to the route, also the key arg is a temp fix
// FIXME abstract to the route, also the key arg is a temp fix
function addNonExistentItemToResponse(
response: any,
assignments: any,

View file

@ -10,19 +10,26 @@ import { ElasticsearchTagsAdapter } from '../adapters/tags/elasticsearch_tags_ad
import { ElasticsearchTokensAdapter } from '../adapters/tokens/elasticsearch_tokens_adapter';
import { KibanaBackendFrameworkAdapter } from '../adapters/framework/kibana_framework_adapter';
import { BackendFrameworkLib } from './../framework';
import { CMBeatsDomain } from '../domains/beats';
import { CMTagsDomain } from '../domains/tags';
import { CMTokensDomain } from '../domains/tokens';
import { CMBeatsDomain } from '../beats';
import { CMTagsDomain } from '../tags';
import { CMTokensDomain } from '../tokens';
import { CMDomainLibs, CMServerLibs } from '../lib';
import { PLUGIN } from 'x-pack/plugins/beats_management/common/constants';
import { CONFIG_PREFIX } from 'x-pack/plugins/beats_management/common/constants/plugin';
import { DatabaseKbnESPlugin } from '../adapters/database/adapter_types';
import { KibanaLegacyServer } from '../adapters/framework/adapter_types';
import { CMServerLibs } from '../types';
export function compose(server: any): CMServerLibs {
const framework = new KibanaBackendFrameworkAdapter(server);
const database = new KibanaDatabaseAdapter(server.plugins.elasticsearch);
export function compose(server: KibanaLegacyServer): CMServerLibs {
const framework = new BackendFrameworkLib(
new KibanaBackendFrameworkAdapter(PLUGIN.ID, server, CONFIG_PREFIX)
);
const database = new KibanaDatabaseAdapter(server.plugins.elasticsearch as DatabaseKbnESPlugin);
const tags = new CMTagsDomain(new ElasticsearchTagsAdapter(database));
const tokens = new CMTokensDomain(new ElasticsearchTokensAdapter(database, framework), {
const tokens = new CMTokensDomain(new ElasticsearchTokensAdapter(database), {
framework,
});
const beats = new CMBeatsDomain(new ElasticsearchBeatsAdapter(database), {
@ -31,17 +38,13 @@ export function compose(server: any): CMServerLibs {
framework,
});
const domainLibs: CMDomainLibs = {
const libs: CMServerLibs = {
framework,
database,
beats,
tags,
tokens,
};
const libs: CMServerLibs = {
framework,
database,
...domainLibs,
};
return libs;
}

View file

@ -10,14 +10,15 @@ import { MemoryTokensAdapter } from '../adapters/tokens/memory_tokens_adapter';
import { HapiBackendFrameworkAdapter } from '../adapters/framework/hapi_framework_adapter';
import { CMBeatsDomain } from '../domains/beats';
import { CMTagsDomain } from '../domains/tags';
import { CMTokensDomain } from '../domains/tokens';
import { CMBeatsDomain } from '../beats';
import { CMTagsDomain } from '../tags';
import { CMTokensDomain } from '../tokens';
import { CMDomainLibs, CMServerLibs } from '../lib';
import { BackendFrameworkLib } from '../framework';
import { CMServerLibs } from '../types';
export function compose(server: any): CMServerLibs {
const framework = new HapiBackendFrameworkAdapter(undefined, server);
const framework = new BackendFrameworkLib(new HapiBackendFrameworkAdapter(undefined, server));
const tags = new CMTagsDomain(new MemoryTagsAdapter(server.tagsDB || []));
const tokens = new CMTokensDomain(new MemoryTokensAdapter(server.tokensDB || []), {
@ -29,16 +30,12 @@ export function compose(server: any): CMServerLibs {
framework,
});
const domainLibs: CMDomainLibs = {
const libs: CMServerLibs = {
framework,
beats,
tags,
tokens,
};
const libs: CMServerLibs = {
framework,
...domainLibs,
};
return libs;
}

View file

@ -1,237 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FrameworkInternalUser } from '../../../adapters/framework/adapter_types';
import { MemoryBeatsAdapter } from '../../../adapters/beats/memory_beats_adapter';
import { HapiBackendFrameworkAdapter } from '../../../adapters/framework/hapi_framework_adapter';
import { MemoryTagsAdapter } from '../../../adapters/tags/memory_tags_adapter';
import { MemoryTokensAdapter } from '../../../adapters/tokens/memory_tokens_adapter';
import { BeatTag, CMBeat } from '../../../../../common/domain_types';
import { CMBeatsDomain } from '../../beats';
import { CMTagsDomain } from '../../tags';
import { CMTokensDomain } from '../../tokens';
import Chance from 'chance';
const seed = Date.now();
const chance = new Chance(seed);
const internalUser: FrameworkInternalUser = { kind: 'internal' };
const settings = {
encryptionKey: 'something_who_cares',
enrollmentTokensTtlInSeconds: 10 * 60, // 10 minutes
};
describe('Beats Domain Lib', () => {
let beatsLib: CMBeatsDomain;
let beatsDB: CMBeat[] = [];
let tagsDB: BeatTag[] = [];
describe('assign_tags_to_beats', () => {
beforeEach(async () => {
beatsDB = [
{
access_token: '9a6c99ae0fd84b068819701169cd8a4b',
config_status: 'OK',
active: true,
enrollment_token: '23423423423',
host_ip: '1.2.3.4',
host_name: 'foo.bar.com',
id: 'qux',
type: 'filebeat',
},
{
access_token: '188255eb560a4448b72656c5e99cae6f',
active: true,
config_status: 'OK',
enrollment_token: 'reertrte',
host_ip: '22.33.11.44',
host_name: 'baz.bar.com',
id: 'baz',
type: 'metricbeat',
},
{
access_token: '93c4a4dd08564c189a7ec4e4f046b975',
active: true,
enrollment_token: '23s423423423',
config_status: 'OK',
host_ip: '1.2.3.4',
host_name: 'foo.bar.com',
id: 'foo',
tags: ['production', 'qa'],
type: 'metricbeat',
verified_on: '2018-05-15T16:25:38.924Z',
},
{
access_token: '3c4a4dd08564c189a7ec4e4f046b9759',
enrollment_token: 'gdfsgdf',
active: true,
config_status: 'OK',
host_ip: '11.22.33.44',
host_name: 'foo.com',
id: 'bar',
type: 'filebeat',
},
];
tagsDB = [
{
configuration_blocks: [],
id: 'production',
last_updated: new Date(),
},
{
configuration_blocks: [],
id: 'development',
last_updated: new Date(),
},
{
configuration_blocks: [],
id: 'qa',
last_updated: new Date(),
},
];
const framework = new HapiBackendFrameworkAdapter(settings);
const tokensLib = new CMTokensDomain(new MemoryTokensAdapter([]), {
framework,
});
const tagsLib = new CMTagsDomain(new MemoryTagsAdapter(tagsDB));
beatsLib = new CMBeatsDomain(new MemoryBeatsAdapter(beatsDB), {
tags: tagsLib,
tokens: tokensLib,
framework,
});
});
it('should add a single tag to a single beat', async () => {
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: 'bar', tag: 'production' },
]);
expect(apiResponse.assignments).toEqual([{ status: 200, result: 'updated' }]);
});
it('should not re-add an existing tag to a beat', async () => {
const tags = ['production'];
let beat = beatsDB.find(b => b.id === 'foo') as any;
expect(beat.tags).toEqual([...tags, 'qa']);
// Adding the existing tag
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: 'foo', tag: 'production' },
]);
expect(apiResponse.assignments).toEqual([{ status: 200, result: 'updated' }]);
beat = beatsDB.find(b => b.id === 'foo') as any;
expect(beat.tags).toEqual([...tags, 'qa']);
});
it('should add a single tag to a multiple beats', async () => {
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: 'foo', tag: 'development' },
{ beatId: 'bar', tag: 'development' },
]);
expect(apiResponse.assignments).toEqual([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
]);
let beat = beatsDB.find(b => b.id === 'foo') as any;
expect(beat.tags).toEqual(['production', 'qa', 'development']); // as beat 'foo' already had 'production' and 'qa' tags attached to it
beat = beatsDB.find(b => b.id === 'bar') as any;
expect(beat.tags).toEqual(['development']);
});
it('should add multiple tags to a single beat', async () => {
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: 'bar', tag: 'development' },
{ beatId: 'bar', tag: 'production' },
]);
expect(apiResponse.assignments).toEqual([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
]);
const beat = beatsDB.find(b => b.id === 'bar') as any;
expect(beat.tags).toEqual(['development', 'production']);
});
it('should add multiple tags to a multiple beats', async () => {
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: 'foo', tag: 'development' },
{ beatId: 'bar', tag: 'production' },
]);
expect(apiResponse.assignments).toEqual([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
]);
let beat = beatsDB.find(b => b.id === 'foo') as any;
expect(beat.tags).toEqual(['production', 'qa', 'development']); // as beat 'foo' already had 'production' and 'qa' tags attached to it
beat = beatsDB.find(b => b.id === 'bar') as any;
expect(beat.tags).toEqual(['production']);
});
it('should return errors for non-existent beats', async () => {
const nonExistentBeatId = chance.word();
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: nonExistentBeatId, tag: 'production' },
]);
expect(apiResponse.assignments).toEqual([
{ status: 404, result: `Beat ${nonExistentBeatId} not found` },
]);
});
it('should return errors for non-existent tags', async () => {
const nonExistentTag = chance.word();
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: 'bar', tag: nonExistentTag },
]);
expect(apiResponse.assignments).toEqual([
{ status: 404, result: `Tag ${nonExistentTag} not found` },
]);
const beat = beatsDB.find(b => b.id === 'bar') as any;
expect(beat).not.toHaveProperty('tags');
});
it('should return errors for non-existent beats and tags', async () => {
const nonExistentBeatId = chance.word();
const nonExistentTag = chance.word();
const apiResponse = await beatsLib.assignTagsToBeats(internalUser, [
{ beatId: nonExistentBeatId, tag: nonExistentTag },
]);
expect(apiResponse.assignments).toEqual([
{
result: `Beat ${nonExistentBeatId} and tag ${nonExistentTag} not found`,
status: 404,
},
]);
const beat = beatsDB.find(b => b.id === 'bar') as any;
expect(beat).not.toHaveProperty('tags');
});
});
});

View file

@ -1,137 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { MemoryBeatsAdapter } from '../../../adapters/beats/memory_beats_adapter';
import { HapiBackendFrameworkAdapter } from '../../../adapters/framework/hapi_framework_adapter';
import { MemoryTagsAdapter } from '../../../adapters/tags/memory_tags_adapter';
import { MemoryTokensAdapter } from '../../../adapters/tokens/memory_tokens_adapter';
import { BeatEnrollmentStatus } from '../../../lib';
import { BeatTag, CMBeat } from '../../../../../common/domain_types';
import { TokenEnrollmentData } from '../../../adapters/tokens/adapter_types';
import { CMBeatsDomain } from '../../beats';
import { CMTagsDomain } from '../../tags';
import { CMTokensDomain } from '../../tokens';
import Chance from 'chance';
import { sign as signToken } from 'jsonwebtoken';
import { omit } from 'lodash';
import moment from 'moment';
const seed = Date.now();
const chance = new Chance(seed);
const settings = {
encryptionKey: 'something_who_cares',
enrollmentTokensTtlInSeconds: 10 * 60, // 10 minutes
};
describe('Beats Domain Lib', () => {
let beatsLib: CMBeatsDomain;
let tokensLib: CMTokensDomain;
let beatsDB: CMBeat[] = [];
let tagsDB: BeatTag[] = [];
let tokensDB: TokenEnrollmentData[] = [];
let validEnrollmentToken: string;
let beatId: string;
let beat: Partial<CMBeat>;
describe('enroll_beat', () => {
beforeEach(async () => {
validEnrollmentToken = chance.word();
beatId = chance.word();
beatsDB = [];
tagsDB = [];
tokensDB = [
{
expires_on: moment()
.add(4, 'hours')
.toJSON(),
token: validEnrollmentToken,
},
];
const version =
chance.integer({ min: 1, max: 10 }) +
'.' +
chance.integer({ min: 1, max: 10 }) +
'.' +
chance.integer({ min: 1, max: 10 });
beat = {
host_name: 'foo.bar.com',
type: 'filebeat',
version,
};
const framework = new HapiBackendFrameworkAdapter(settings);
tokensLib = new CMTokensDomain(new MemoryTokensAdapter(tokensDB), {
framework,
});
const tagsLib = new CMTagsDomain(new MemoryTagsAdapter(tagsDB));
beatsLib = new CMBeatsDomain(new MemoryBeatsAdapter(beatsDB), {
tags: tagsLib,
tokens: tokensLib,
framework,
});
});
it('should enroll beat, returning an access token', async () => {
const { token } = await tokensLib.getEnrollmentToken(validEnrollmentToken);
expect(token).toEqual(validEnrollmentToken);
const { accessToken, status } = await beatsLib.enrollBeat(
validEnrollmentToken,
beatId,
'192.168.1.1',
omit(beat, 'enrollment_token')
);
expect(status).toEqual(BeatEnrollmentStatus.Success);
expect(beatsDB.length).toEqual(1);
expect(beatsDB[0]).toHaveProperty('host_ip');
expect(beatsDB[0]).toHaveProperty('verified_on');
expect(accessToken).toEqual(beatsDB[0].access_token);
await tokensLib.deleteEnrollmentToken(validEnrollmentToken);
expect(tokensDB.length).toEqual(0);
});
it('should reject an invalid enrollment token', async () => {
const { token } = await tokensLib.getEnrollmentToken(chance.word());
expect(token).toEqual(null);
});
it('should reject an expired enrollment token', async () => {
const { token } = await tokensLib.getEnrollmentToken(
signToken({}, settings.encryptionKey, {
expiresIn: '-1min',
})
);
expect(token).toEqual(null);
});
it('should delete the given enrollment token so it may not be reused', async () => {
expect(tokensDB[0].token).toEqual(validEnrollmentToken);
await tokensLib.deleteEnrollmentToken(validEnrollmentToken);
expect(tokensDB.length).toEqual(0);
const { token } = await tokensLib.getEnrollmentToken(validEnrollmentToken);
expect(token).toEqual(null);
});
});
});

View file

@ -1,112 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { BeatTag, CMBeat } from '../../../../../common/domain_types';
import { FrameworkInternalUser } from '../../../adapters/framework/adapter_types';
import { compose } from '../../../compose/testing';
import { CMServerLibs } from '../../../lib';
const internalUser: FrameworkInternalUser = { kind: 'internal' };
describe('Beats Domain Lib', () => {
let libs: CMServerLibs;
let beatsDB: Array<Partial<CMBeat>> = [];
let tagsDB: BeatTag[] = [];
describe('remove_tags_from_beats', () => {
beforeEach(async () => {
beatsDB = [
{
access_token: '9a6c99ae0fd84b068819701169cd8a4b',
active: true,
enrollment_token: '123kuil;4',
host_ip: '1.2.3.4',
host_name: 'foo.bar.com',
id: 'qux',
type: 'filebeat',
},
{
access_token: '188255eb560a4448b72656c5e99cae6f',
active: true,
enrollment_token: '12fghjyu34',
host_ip: '22.33.11.44',
host_name: 'baz.bar.com',
id: 'baz',
type: 'metricbeat',
},
{
access_token: '93c4a4dd08564c189a7ec4e4f046b975',
active: true,
enrollment_token: '12nfhgj34',
host_ip: '1.2.3.4',
host_name: 'foo.bar.com',
id: 'foo',
tags: ['production', 'qa'],
type: 'metricbeat',
verified_on: '2018-05-15T16:25:38.924Z',
},
{
access_token: '3c4a4dd08564c189a7ec4e4f046b9759',
active: true,
enrollment_token: '123sfd4',
host_ip: '11.22.33.44',
host_name: 'foo.com',
id: 'bar',
type: 'filebeat',
},
];
tagsDB = [
{
configuration_blocks: [],
id: 'production',
last_updated: new Date(),
},
{
configuration_blocks: [],
id: 'development',
last_updated: new Date(),
},
{
configuration_blocks: [],
id: 'qa',
last_updated: new Date(),
},
];
libs = compose({
tagsDB,
beatsDB,
});
});
it('should remove a single tag from a single beat', async () => {
const apiResponse = await libs.beats.removeTagsFromBeats(internalUser, [
{ beatId: 'foo', tag: 'production' },
]);
expect(apiResponse.removals).toEqual([{ status: 200, result: 'updated' }]);
// @ts-ignore
expect(beatsDB.find(b => b.id === 'foo').tags).toEqual(['qa']);
});
it('should remove a single tag from a multiple beats', async () => {
const apiResponse = await libs.beats.removeTagsFromBeats(internalUser, [
{ beatId: 'foo', tag: 'development' },
{ beatId: 'bar', tag: 'development' },
]);
expect(apiResponse.removals).toEqual([
{ status: 200, result: 'updated' },
{ status: 200, result: 'updated' },
]);
// @ts-ignore
expect(beatsDB.find(b => b.id === 'foo').tags).toEqual(['production', 'qa']);
expect(beatsDB.find(b => b.id === 'bar')).not.toHaveProperty('tags');
});
});
});

View file

@ -1,118 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Chance from 'chance';
import { BeatTag, CMBeat } from '../../../../../common/domain_types';
import { MemoryBeatsAdapter } from '../../../adapters/beats/memory_beats_adapter';
import { HapiBackendFrameworkAdapter } from '../../../adapters/framework/hapi_framework_adapter';
import { MemoryTagsAdapter } from '../../../adapters/tags/memory_tags_adapter';
import { TokenEnrollmentData } from '../../../adapters/tokens/adapter_types';
import { MemoryTokensAdapter } from '../../../adapters/tokens/memory_tokens_adapter';
import { CMBeatsDomain } from '../../beats';
import { CMTagsDomain } from '../../tags';
import { CMTokensDomain } from '../../tokens';
const seed = Date.now();
const chance = new Chance(seed);
const settings = {
encryptionKey: `it's_a_secret`,
enrollmentTokensTtlInSeconds: 10 * 60, // 10 minutes
};
describe('Beats Domain lib', () => {
describe('update_beat', () => {
let beatsLib: CMBeatsDomain;
let tokensLib: CMTokensDomain;
let token: TokenEnrollmentData;
let beatsDB: CMBeat[] = [];
let tagsDB: BeatTag[] = [];
let tokensDB: TokenEnrollmentData[];
let beatId: string;
let beat: Partial<CMBeat>;
const getBeatsLib = async () => {
const framework = new HapiBackendFrameworkAdapter(settings);
tokensLib = new CMTokensDomain(new MemoryTokensAdapter(tokensDB), { framework });
const tagsLib = new CMTagsDomain(new MemoryTagsAdapter(tagsDB));
beatsLib = new CMBeatsDomain(new MemoryBeatsAdapter(beatsDB), {
framework,
tags: tagsLib,
tokens: tokensLib,
});
await tokensLib.createEnrollmentTokens(framework.internalUser, 1);
token = tokensDB[0];
};
beforeEach(async () => {
beatId = chance.word();
beat = {
host_name: 'foo.bar.com',
type: 'filebeat',
version: '6.4.0',
};
beatsDB = [];
tagsDB = [];
tokensDB = [];
getBeatsLib();
});
it('should return a not-found message if beat does not exist', async () => {
const tokenString = token.token || '';
const result = await beatsLib.update(tokenString, beatId, beat);
expect(result).toBe('beat-not-found');
});
it('should return an invalid message if token validation fails', async () => {
const beatToFind: CMBeat = {
id: beatId,
config_status: 'OK',
enrollment_token: '',
active: true,
access_token: token.token || '',
type: 'filebeat',
host_ip: 'localhost',
host_name: 'foo.bar.com',
};
beatsDB = [beatToFind];
getBeatsLib();
const result = await beatsLib.update('something_invalid', beatId, beat);
expect(result).toBe('invalid-access-token');
});
it('should update the beat when a valid token is provided', async () => {
const beatToFind: CMBeat = {
id: beatId,
config_status: 'OK',
enrollment_token: '',
active: true,
access_token: token.token || '',
type: 'metricbeat',
host_ip: 'localhost',
host_name: 'bar.foo.com',
version: '6.3.5',
};
beatsDB = [beatToFind];
getBeatsLib();
// @ts-ignore
await beatsLib.update(token, beatId, beat);
expect(beatsDB).toHaveLength(1);
const updatedBeat = beatsDB[0];
expect(updatedBeat.id).toBe(beatId);
expect(updatedBeat.host_name).toBe('foo.bar.com');
expect(updatedBeat.version).toBe('6.4.0');
expect(updatedBeat.type).toBe('filebeat');
});
});
});

View file

@ -1,76 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { HapiBackendFrameworkAdapter } from '../../adapters/framework/hapi_framework_adapter';
import { TokenEnrollmentData } from '../../adapters/tokens/adapter_types';
import { MemoryTokensAdapter } from '../../adapters/tokens/memory_tokens_adapter';
import { CMTokensDomain } from '../tokens';
import Chance from 'chance';
import moment from 'moment';
import { BackendFrameworkAdapter } from '../../adapters/framework/adapter_types';
const seed = Date.now();
const chance = new Chance(seed);
const settings = {
encryptionKey: 'something_who_cares',
enrollmentTokensTtlInSeconds: 10 * 60, // 10 minutes
};
describe('Token Domain Lib', () => {
let tokensLib: CMTokensDomain;
let tokensDB: TokenEnrollmentData[] = [];
let framework: BackendFrameworkAdapter;
beforeEach(async () => {
tokensDB = [];
framework = new HapiBackendFrameworkAdapter(settings);
tokensLib = new CMTokensDomain(new MemoryTokensAdapter(tokensDB), {
framework,
});
});
it('should generate webtokens with a qty of 1', async () => {
const tokens = await tokensLib.createEnrollmentTokens(framework.internalUser, 1);
expect(tokens.length).toBe(1);
expect(typeof tokens[0]).toBe('string');
});
it('should create the specified number of tokens', async () => {
const numTokens = chance.integer({ min: 1, max: 20 });
const tokensFromApi = await tokensLib.createEnrollmentTokens(framework.internalUser, numTokens);
expect(tokensFromApi.length).toEqual(numTokens);
expect(tokensFromApi).toEqual(tokensDB.map((t: TokenEnrollmentData) => t.token));
});
it('should set token expiration to 10 minutes from now by default', async () => {
await tokensLib.createEnrollmentTokens(framework.internalUser, 1);
const token = tokensDB[0];
// We do a fuzzy check to see if the token expires between 9 and 10 minutes
// from now because a bit of time has elapsed been the creation of the
// tokens and this check.
const tokenExpiresOn = moment(token.expires_on).valueOf();
// Because sometimes the test runs so fast it it equal, and we dont use expect.js version that has toBeLessThanOrEqualTo
const tenMinutesFromNow = moment()
.add('10', 'minutes')
.add('1', 'seconds')
.valueOf();
const almostTenMinutesFromNow = moment(tenMinutesFromNow)
.subtract('2', 'seconds')
.valueOf();
expect(tokenExpiresOn).toBeLessThan(tenMinutesFromNow);
expect(tokenExpiresOn).toBeGreaterThan(almostTenMinutesFromNow);
});
});

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import { difference } from 'lodash';
import { FrameworkRouteHandler } from './adapters/framework/adapter_types';
import { FrameworkRequest } from './adapters/framework/adapter_types';
import {
BackendFrameworkAdapter,
FrameworkResponse,
FrameworkRouteOptions,
} from './adapters/framework/adapter_types';
export class BackendFrameworkLib {
public exposeStaticDir = this.adapter.exposeStaticDir;
public internalUser = this.adapter.internalUser;
constructor(private readonly adapter: BackendFrameworkAdapter) {
this.validateConfig();
}
public registerRoute<
RouteRequest extends FrameworkRequest,
RouteResponse extends FrameworkResponse
>(route: FrameworkRouteOptions<RouteRequest, RouteResponse>) {
this.adapter.registerRoute({
...route,
handler: this.wrapRouteWithSecurity(
route.handler,
route.licenseRequired || [],
route.requiredRoles
),
});
}
public getSetting(setting: 'encryptionKey'): string;
public getSetting(setting: 'enrollmentTokensTtlInSeconds'): number;
public getSetting(setting: 'defaultUserRoles'): string[];
public getSetting(
setting: 'encryptionKey' | 'enrollmentTokensTtlInSeconds' | 'defaultUserRoles'
) {
return this.adapter.getSetting(`xpack.beats.${setting}`);
}
/**
* Expired `null` happens when we have no xpack info
*/
get license() {
return {
type: this.adapter.info ? this.adapter.info.license.type : 'unknown',
expired: this.adapter.info ? this.adapter.info.license.expired : null,
};
}
get securityIsEnabled() {
return this.adapter.info ? this.adapter.info.security.enabled : false;
}
private validateConfig() {
const encryptionKey = this.adapter.getSetting('xpack.beats.encryptionKey');
if (!encryptionKey) {
this.adapter.log(
'Using a default encryption key for xpack.beats.encryptionKey. It is recommended that you set xpack.beats.encryptionKey in kibana.yml with a unique token'
);
}
}
private wrapRouteWithSecurity(
handler: FrameworkRouteHandler<any, any>,
requiredLicense: string[],
requiredRoles?: string[]
) {
return async (request: FrameworkRequest, h: any) => {
if (
requiredLicense.length > 0 &&
(this.license.expired || !requiredLicense.includes(this.license.type))
) {
return Boom.forbidden(
`Your ${
this.license
} license does not support this API or is expired. Please upgrade your license.`
);
}
if (requiredRoles) {
if (request.user.kind !== 'authenticated') {
return h.response().code(403);
}
if (
request.user.kind === 'authenticated' &&
!request.user.roles.includes('superuser') &&
difference(requiredRoles, request.user.roles).length !== 0
) {
return h.response().code(403);
}
}
return await handler(request, h);
};
}
}

View file

@ -5,12 +5,12 @@
*/
import { intersection, uniq, values } from 'lodash';
import { UNIQUENESS_ENFORCING_TYPES } from '../../../common/constants';
import { ConfigurationBlock } from '../../../common/domain_types';
import { FrameworkUser } from '../adapters/framework/adapter_types';
import { UNIQUENESS_ENFORCING_TYPES } from '../../common/constants';
import { ConfigurationBlock } from '../../common/domain_types';
import { FrameworkUser } from './adapters/framework/adapter_types';
import { entries } from '../../utils/polyfills';
import { CMTagsAdapter } from '../adapters/tags/adapter_types';
import { entries } from '../utils/polyfills';
import { CMTagsAdapter } from './adapters/tags/adapter_types';
export class CMTagsDomain {
constructor(private readonly adapter: CMTagsAdapter) {}

Some files were not shown because too many files have changed in this diff Show more