Merge branch 'feature-integrations-manager' of github.com:elastic/kibana into feature-ingest

This commit is contained in:
Matt Apperson 2019-11-16 16:21:47 -05:00
commit c7aa2ed5e8
101 changed files with 5461 additions and 9 deletions

5
docs/epm/index.asciidoc Normal file
View file

@ -0,0 +1,5 @@
[role="xpack"]
[[epm]]
== Elastic Package Manager
These are the docs for the Elastic Package Manager (EPM).

View file

@ -131,6 +131,8 @@
"@types/react-grid-layout": "^0.16.7",
"@types/recompose": "^0.30.5",
"@use-it/interval": "^0.1.3",
"@types/tar": "^4.0.0",
"@types/yauzl": "^2.9.1",
"JSONStream": "1.3.5",
"abortcontroller-polyfill": "^1.3.0",
"angular": "^1.7.8",
@ -422,6 +424,7 @@
"listr": "^0.14.1",
"load-grunt-config": "^3.0.1",
"mocha": "6.2.1",
"mock-http-server": "1.3.0",
"multistream": "^2.1.1",
"murmurhash3js": "3.0.1",
"mutation-observer": "^1.0.3",

View file

@ -289,6 +289,22 @@
'@types/lodash.clonedeep',
],
},
{
groupSlug: 'tar',
groupName: 'tar related packages',
packageNames: [
'tar',
'@types/tar',
],
},
{
groupSlug: 'yauzl',
groupName: 'yauzl related packages',
packageNames: [
'yauzl',
'@types/yauzl',
],
},
{
groupSlug: 'bluebird',
groupName: 'bluebird related packages',
@ -681,6 +697,14 @@
'@types/git-url-parse',
],
},
{
groupSlug: 'js-search',
groupName: 'js-search related packages',
packageNames: [
'js-search',
'@types/js-search',
],
},
{
groupSlug: 'jsdom',
groupName: 'jsdom related packages',

View file

@ -9,6 +9,7 @@
"xpack.canvas": "legacy/plugins/canvas",
"xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication",
"xpack.dashboardMode": "legacy/plugins/dashboard_mode",
"xpack.epm":"legacy/plugins/integrations_manager",
"xpack.features": "plugins/features",
"xpack.fileUpload": "legacy/plugins/file_upload",
"xpack.fleet": "legacy/plugins/fleet",

View file

@ -42,6 +42,7 @@ import { snapshotRestore } from './legacy/plugins/snapshot_restore';
import { transform } from './legacy/plugins/transform';
import { actions } from './legacy/plugins/actions';
import { alerting } from './legacy/plugins/alerting';
import { epm } from './legacy/plugins/integrations_manager';
import { lens } from './legacy/plugins/lens';
import { ingest } from './legacy/plugins/ingest';
import { fleet } from './legacy/plugins/fleet';
@ -89,5 +90,6 @@ module.exports = function (kibana) {
alerting(kibana),
ingest(kibana),
fleet(kibana),
epm(kibana),
];
};

View file

@ -0,0 +1,53 @@
# Integrations Manager
## Development
### Branch
We're using a long-running feature branch [`feature-integrations-manager`](https://github.com/elastic/kibana/tree/feature-integrations-manager).
<details>
<summary>Keeping up to date with upstream kibana</summary>
```bash
## checkout feature branch to your fork
git checkout -B feature-integrations-manager origin/feature-integrations-manager
## make sure your feature branch is current with upstream feature branch
git pull upstream feature-integrations-manager
## pull in changes from upstream master
git pull upstream master
## push changes to your remote
git push origin
# Open a **DRAFT PR**. Normal PRs will re-notify authors of commits already merged
# Draft PR will trigger CI run. Once CI is green ...
## push your changes to upstream feature branch
git push upstream
```
</details>
### Feature development
In your own fork of `elastic/kibana`, create a feature branch based on `feature-integrations-manager`.
```
git checkout -b 1234-feature-description feature-integrations-manager
# ... git commits for feature
open https://github.com/elastic/kibana/compare/feature-integrations-manager...yourgithubname:1234-feature-description
```
See https://github.com/elastic/kibana/pull/37950 for an example.
### Getting started
See the Kibana docs for [how to set up your dev environment](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#setting-up-your-development-environment), [run Elasticsearch](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-elasticsearch), and [start Kibana](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#running-kibana).
One common workflow is:
1. `yarn es snapshot`
1. In another shell: `yarn start --no-base-path`
### Plugin architecture
Follows the `common`, `server`, `public` structure from the [Architecture Style Guide
](https://github.com/elastic/kibana/blob/master/style_guides/architecture_style_guide.md#file-and-folder-structure).
We use New Platform approach (structure, APIs, etc) where possible. There's a `kibana.json` manifest, and the server uses the `server/{index,plugin}.ts` approach from [`MIGRATION.md`](https://github.com/elastic/kibana/blob/master/src/core/MIGRATION.md#architecture). The client code we author is using New Platform shape & APIs, but the Manager deals with external systems which are at their own stages of migration.

View file

@ -0,0 +1,17 @@
{
"title": "Asset",
"type": "object",
"description": "Item installed for Kibana (e.g. dashboard, visualization), Elasticsearch (e.g. ingest pipeline, ILM policy), or a Kibana plugin (e.g. ML job)",
"properties": {
"id": {
"type": "string"
},
"type": {
"$ref": "./asset_type.v1.json"
}
},
"required": [
"id",
"type"
]
}

View file

@ -0,0 +1,13 @@
{
"type": "string",
"title": "AssetType",
"description": "Types of assets which can be installed/removed",
"enum": [
"index-template",
"ingest-pipeline",
"ilm-policy",
"rollup-job",
"ml-job",
"data-frame-transform"
]
}

View file

@ -0,0 +1,31 @@
{
"title": "Datasource",
"type": "object",
"description": "A logical grouping of places where data is coming from, such as \"Production\", \"Staging\", \"Production East-1\", \"Metrics Cluster\", etc. -- these groupings are user-defined. We store information collected from the user about this logical grouping such as a name and any other information we need about it to generate the associated config. A package defines its own data source templates that can use user-provided values to generate the data source config. A single data source will typically enable users to collect both logs and metrics. A data source can be in multiple policies at the same time. A datasource can have multiple streams.",
"properties": {
"name": {
"type": "string"
},
"package": {
"$ref": "./package.v1.json"
},
"streams": {
"type": "array",
"items": {
"$ref": "./stream.v1.json"
}
},
"id": {
"type": "string",
"format": "uuid"
},
"read_alias": {
"type": "string"
}
},
"required": [
"name",
"package",
"streams"
]
}

View file

@ -0,0 +1,50 @@
{
"title": "Input",
"type": "object",
"description": "Where the data comes from",
"properties": {
"type": {
"type": "string",
"enum": [
"log",
"metric/system",
"metric/docker",
"etc"
]
},
"config": {
"type": "object",
"example": "{paths: \"/var/log/*.log\"} or {metricsets: [\"container\", \"cpu\"]} or {username: \"elastic\", password: \"changeme\"}",
"description": "Mix of configurable and required properties still TBD. Object for now might become string"
},
"ingest_pipelines": {
"type": "array",
"description": "Need a distinction for \"main\" ingest pipeline. Should be handled during install. Likely by package/manifest format",
"items": {
"type": "string"
}
},
"id": {
"type": "string",
"format": "uuid"
},
"index_template": {
"type": "string"
},
"ilm_policy": {
"type": "string"
},
"fields": {
"type": "array",
"description": "",
"items": {
"type": "object",
"description": "contents from fields.yml"
}
}
},
"required": [
"type",
"config"
]
}

View file

@ -0,0 +1,47 @@
{
"title": "Output",
"type": "object",
"description": "Where to send the data",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string",
"example": "\"default\" or \"infosec1\""
},
"type": {
"type": "string",
"enum": [
"elasticsearch",
"something",
"else"
]
},
"url": {
"type": "string",
"format": "uri"
},
"api_token": {
"type": "string"
},
"index_name": {
"type": "string",
"example": "metrics-mysql-prod_west-access",
"description": "unique alias with write index"
},
"ingest_pipeline": {
"type": "string",
"example": "metrics-mysql-prod_west-access"
},
"config": {
"type": "object",
"description": "contains everything not otherwise specified (e.g. TLS, etc)"
}
},
"required": [
"id",
"name",
"type"
]
}

View file

@ -0,0 +1,34 @@
{
"title": "Package",
"type": "object",
"description": "A group of items related to a data ingestion source (e.g. MySQL, nginx, AWS). Can include Kibana assets, ES assets, data source configuration templates, manual install steps, etc.",
"properties": {
"name": {
"type": "string",
"example": "coredns"
},
"version": {
"type": "string",
"example": "1.0.1, 1.3.1"
},
"description": {
"type": "string",
"example": "CoreDNS logs and metrics integration.\nThe CoreDNS integrations allows to gather logs and metrics from the CoreDNS DNS server to get better insights.\n"
},
"title": {
"type": "string",
"example": "CoreDNS"
},
"assets": {
"type": "array",
"items": {
"$ref": "./asset.v1.json"
}
}
},
"required": [
"name",
"version",
"assets"
]
}

View file

@ -0,0 +1,37 @@
{
"title": "Policy",
"type": "object",
"description": "The entire config for the Beats agent, including all assigned data source config outputs along with agent-wide configuration values",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"use_case": {
"type": "string"
},
"datasources": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "./datasource.v1.json"
}
},
"description": {
"type": "string"
},
"status": {
"type": "string",
"enum": [
"active",
"inactive"
]
}
},
"required": [
"id",
"status"
]
}

View file

@ -0,0 +1,31 @@
{
"title": "Stream",
"type": "object",
"description": "A combination of an input type, the required config, an output, and any processors",
"properties": {
"id": {
"type": "string"
},
"input": {
"$ref": "./input.v1.json"
},
"config": {
"type": "object",
"example": "{paths: \"/var/log/*.log\"} or {metricsets: [\"container\", \"cpu\"]} or {username: \"elastic\", password: \"changeme\"}"
},
"output": {
"$ref": "./output.v1.json"
},
"processors": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"id",
"input",
"output"
]
}

View file

@ -0,0 +1,160 @@
{
"openapi": "3.0.0",
"info": {
"title": "Ingest",
"version": "0.2",
"description": "Strawman API for fka Ingest Plugin",
"license": {
"url": "https://raw.githubusercontent.com/elastic/elasticsearch/master/licenses/ELASTIC-LICENSE.txt",
"name": "Elastic"
},
"contact": {
"name": "Elastic Observability Team"
}
},
"servers": [
{
"url": "http://localhost:5601/api/epm",
"description": "EPM API Root"
}
],
"paths": {
"/policies": {
"get": {
"summary": "Get policies",
"tags": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "./models/policy.v1.json"
}
}
}
}
}
},
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "datasource"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "use_case"
},
{
"$ref": "#/components/parameters/pageIndexParam"
},
{
"$ref": "#/components/parameters/pageSizeParam"
}
],
"description": "Return one or many polices based on the given filter",
"operationId": "getPolicies"
},
"parameters": []
},
"/datasources": {
"get": {
"summary": "Get datasources",
"tags": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "./models/datasource.v1.json"
}
}
}
}
}
},
"parameters": [
{
"schema": {
"type": "string"
},
"in": "query",
"name": "policy_id"
},
{
"$ref": "#/components/parameters/pageIndexParam"
},
{
"$ref": "#/components/parameters/pageSizeParam"
}
],
"description": "Return datasources",
"operationId": "getDatasources"
}
},
"/policy/{policy_id}": {
"parameters": [
{
"name": "policy_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"get": {
"summary": "Get policy by id",
"tags": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "./models/policy.v1.json"
}
}
}
}
},
"description": "Get Policy by id",
"operationId": "getPolicyById"
}
}
},
"components": {
"parameters": {
"pageSizeParam": {
"name": "per_page",
"in": "query",
"description": "The number of items to return",
"required": false,
"schema": {
"type": "integer",
"default": 50
}
},
"pageIndexParam": {
"name": "page",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 1
}
}
}
}
}

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.
*/
/**
* The entire config for the Beats agent, including all assigned data source config outputs
* along with agent-wide configuration values
*/
export interface Policy {
datasources?: Datasource[];
description?: string;
id: string;
name?: string;
status: Status;
use_case?: string;
}
/**
* A logical grouping of places where data is coming from, such as "Production", "Staging",
* "Production East-1", "Metrics Cluster", etc. -- these groupings are user-defined. We
* store information collected from the user about this logical grouping such as a name and
* any other information we need about it to generate the associated config. A package
* defines its own data source templates that can use user-provided values to generate the
* data source config. A single data source will typically enable users to collect both logs
* and metrics. A data source can be in multiple policies at the same time. A datasource can
* have multiple streams.
*/
export interface Datasource {
id?: string;
name: string;
package: Package;
read_alias?: string;
streams: Stream[];
}
/**
* A group of items related to a data ingestion source (e.g. MySQL, nginx, AWS). Can include
* Kibana assets, ES assets, data source configuration templates, manual install steps, etc.
*/
export interface Package {
assets: Asset[];
description?: string;
name: string;
title?: string;
version: string;
}
/**
* Item installed for Kibana (e.g. dashboard, visualization), Elasticsearch (e.g. ingest
* pipeline, ILM policy), or a Kibana plugin (e.g. ML job)
*/
export interface Asset {
id: string;
type: AssetType;
}
/**
* Types of assets which can be installed/removed
*/
export enum AssetType {
DataFrameTransform = 'data-frame-transform',
IlmPolicy = 'ilm-policy',
IndexTemplate = 'index-template',
IngestPipeline = 'ingest-pipeline',
MlJob = 'ml-job',
RollupJob = 'rollup-job',
}
/**
* A combination of an input type, the required config, an output, and any processors
*/
export interface Stream {
config?: { [key: string]: any };
id: string;
input: Input;
output: Output;
processors?: string[];
}
/**
* Where the data comes from
*/
export interface Input {
/**
* Mix of configurable and required properties still TBD. Object for now might become string
*/
config: { [key: string]: any };
fields?: Array<{ [key: string]: any }>;
id?: string;
ilm_policy?: string;
index_template?: string;
/**
* Need a distinction for "main" ingest pipeline. Should be handled during install. Likely
* by package/manifest format
*/
ingest_pipelines?: string[];
type: InputType;
}
export enum InputType {
Etc = 'etc',
Log = 'log',
MetricDocker = 'metric/docker',
MetricSystem = 'metric/system',
}
/**
* Where to send the data
*/
export interface Output {
api_token?: string;
/**
* contains everything not otherwise specified (e.g. TLS, etc)
*/
config?: { [key: string]: any };
id: string;
/**
* unique alias with write index
*/
index_name?: string;
ingest_pipeline?: string;
name: string;
type: OutputType;
url?: string;
}
export enum OutputType {
Elasticsearch = 'elasticsearch',
Else = 'else',
Something = 'something',
}
export enum Status {
Active = 'active',
Inactive = 'inactive',
}

View file

@ -0,0 +1,20 @@
/*
* 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 { i18n } from '@kbn/i18n';
import manifest from '../kibana.json';
export const PLUGIN = {
ID: manifest.id,
TITLE: i18n.translate('xpack.epm.pluginTitle', {
defaultMessage: 'Elastic Package Manager',
}),
DESCRIPTION: 'Install and manage your Elastic data ingest packages',
ICON: 'merge',
CONFIG_PREFIX: 'xpack.epm',
};
export const SAVED_OBJECT_TYPE = 'epm';

View file

@ -0,0 +1,41 @@
/*
* 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 { PLUGIN } from './constants';
import { CategoryId } from './types';
export const API_ROOT = `/api/${PLUGIN.ID}`;
export const API_LIST_PATTERN = `${API_ROOT}/list`;
export const API_INFO_PATTERN = `${API_ROOT}/package/{pkgkey}`;
export const API_INSTALL_PATTERN = `${API_ROOT}/install/{pkgkey}`;
export const API_DELETE_PATTERN = `${API_ROOT}/delete/{pkgkey}`;
export const API_CATEGORIES_PATTERN = `${API_ROOT}/categories`;
export interface ListParams {
category?: CategoryId;
}
export function getCategoriesPath() {
return API_CATEGORIES_PATTERN;
}
export function getListPath() {
return API_LIST_PATTERN;
}
export function getInfoPath(pkgkey: string) {
return API_INFO_PATTERN.replace('{pkgkey}', pkgkey);
}
export function getFilePath(filePath: string) {
return `${API_ROOT}${filePath}`;
}
export function getInstallPath(pkgkey: string) {
return API_INSTALL_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash
}
export function getRemovePath(pkgkey: string) {
return API_DELETE_PATTERN.replace('{pkgkey}', pkgkey).replace(/\/$/, ''); // trim trailing slash
}

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
// Calling Object.entries(PackagesGroupedByStatus) gave `status: string`
// which causes a "string is not assignable to type InstallationStatus` error
// see https://github.com/Microsoft/TypeScript/issues/20322
// and https://github.com/Microsoft/TypeScript/pull/12253#issuecomment-263132208
// and https://github.com/Microsoft/TypeScript/issues/21826#issuecomment-479851685
export const entries = Object.entries as <T>(o: T) => Array<[keyof T, T[keyof T]]>;

View file

@ -0,0 +1,109 @@
/*
* 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 { SavedObject, SavedObjectAttributes, SavedObjectReference } from 'src/core/server';
export { Request, ResponseToolkit, Server, ServerRoute } from 'hapi';
export type InstallationStatus = Installed['status'] | NotInstalled['status'];
export type AssetType = KibanaAssetType | ElasticsearchAssetType;
export type KibanaAssetType = 'dashboard' | 'visualization' | 'search' | 'index-pattern';
export type ElasticsearchAssetType = 'ingest-pipeline' | 'index-template' | 'ilm-policy';
// Registry's response types
// from /search
// https://github.com/elastic/integrations-registry/blob/master/docs/api/search.json
export type RegistryList = RegistryListItem[];
export interface RegistryListItem {
description: string;
download: string;
icon: string;
name: string;
version: string;
title?: string;
}
export interface ScreenshotItem {
src: string;
title?: string;
}
// from /package/{name}
// https://github.com/elastic/integrations-registry/blob/master/docs/api/package.json
export type ServiceName = 'kibana' | 'elasticsearch';
export type RequirementVersion = string;
export interface ServiceRequirements {
version: RequirementVersionRange;
}
export interface RequirementVersionRange {
min: RequirementVersion;
max: RequirementVersion;
}
// from /categories
// https://github.com/elastic/integrations-registry/blob/master/docs/api/categories.json
export type CategorySummaryList = CategorySummaryItem[];
export type CategoryId = string;
export interface CategorySummaryItem {
id: CategoryId;
title: string;
count: number;
}
export type RequirementsByServiceName = Record<ServiceName, ServiceRequirements>;
export interface AssetParts {
pkgkey: string;
service: ServiceName;
type: AssetType;
file: string;
}
export type AssetTypeToParts = Record<AssetType, AssetParts[]>;
export type AssetsGroupedByServiceByType = Record<ServiceName, AssetTypeToParts>;
export interface RegistryPackage {
name: string;
version: string;
description: string;
readme?: string;
icon: string;
requirement: RequirementsByServiceName;
title?: string;
screenshots?: ScreenshotItem[];
assets: string[];
}
// Managers public HTTP response types
export type PackageList = PackageListItem[];
// add title here until it's a part of registry response
export type PackageListItem = Installable<Required<RegistryListItem>>;
export type PackagesGroupedByStatus = Record<InstallationStatus, PackageList>;
// add title here until it's a part of registry response
export type PackageInfo = Installable<
Required<RegistryPackage> & { assets: AssetsGroupedByServiceByType }
>;
export type Installation = SavedObject<InstallationAttributes>;
export interface InstallationAttributes extends SavedObjectAttributes {
installed: AssetReference[];
}
export type Installable<T> = Installed<T> | NotInstalled<T>;
export type Installed<T = {}> = T & {
status: 'installed';
savedObject: Installation;
};
export type NotInstalled<T = {}> = T & {
status: 'not_installed';
};
export type AssetReference = Pick<SavedObjectReference, 'id' | 'type'>;

View file

@ -0,0 +1,53 @@
/*
* 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 { resolve } from 'path';
import JoiNamespace from 'joi';
import { Legacy } from 'kibana';
import { LegacyPluginInitializer, LegacyPluginOptions } from 'src/legacy/types';
import { PLUGIN } from './common/constants';
import manifest from './kibana.json';
import { getConfigSchema } from './server/config';
import { Plugin, createSetupShim } from './server/plugin';
import { mappings, savedObjectSchemas } from './server/saved_objects';
const ROOT = `plugins/${PLUGIN.ID}`;
const pluginOptions: LegacyPluginOptions = {
id: PLUGIN.ID,
require: manifest.requiredPlugins,
version: manifest.version,
kibanaVersion: manifest.kibanaVersion,
uiExports: {
app: {
title: PLUGIN.TITLE,
description: PLUGIN.TITLE,
main: `${ROOT}/index`,
euiIconType: PLUGIN.ICON,
order: 8100,
},
// This defines what shows up in the registry found at /app/kibana#/home and /app/kibana#/home/feature_directory
home: [`${ROOT}/register_feature`],
mappings,
savedObjectSchemas,
},
configPrefix: PLUGIN.CONFIG_PREFIX,
publicDir: resolve(__dirname, 'public'),
config(Joi: typeof JoiNamespace) {
return getConfigSchema(Joi);
},
deprecations: undefined,
preInit: undefined,
init(server: Legacy.Server) {
const { initializerContext, coreSetup, pluginsSetup } = createSetupShim(server);
const plugin = new Plugin(initializerContext);
plugin.setup(coreSetup, pluginsSetup);
},
postInit: undefined,
isEnabled: false,
};
export const epm: LegacyPluginInitializer = kibana => new kibana.Plugin(pluginOptions);

View file

@ -0,0 +1,9 @@
{
"id": "epm",
"version": "0.0.2",
"kibanaVersion": "kibana",
"optionalPlugins": ["feature_catalogue"],
"requiredPlugins": ["kibana", "elasticsearch", "xpack_main"],
"server": true,
"ui": true
}

View file

@ -0,0 +1,11 @@
{
"author": "Elastic",
"name": "epm",
"version": "8.0.0",
"private": true,
"license": "Elastic-License",
"dependencies": {
"react-markdown": "^4.2.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View file

@ -0,0 +1,100 @@
/*
* 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, { Fragment } from 'react';
import {
EuiAccordion,
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiIcon,
EuiNotificationBadge,
EuiText,
EuiTitle,
EuiSpacer,
} from '@elastic/eui';
import styled from 'styled-components';
import { entries } from '../../common/type_utils';
import { AssetsGroupedByServiceByType, KibanaAssetType } from '../../common/types';
import { AssetIcons, AssetTitleMap, ServiceIcons, ServiceTitleMap } from '../constants';
import { useCore } from '../hooks/use_core';
export function AssetAccordion({ assets }: { assets: AssetsGroupedByServiceByType }) {
const { theme } = useCore();
const FlexGroup = styled(EuiFlexGroup)`
margin: ${theme.eui.ruleMargins.marginSmall};
`;
return (
<Fragment>
{entries(assets).map(([service, typeToParts], assetIndex) => {
return (
<Fragment key={service}>
<FlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={ServiceIcons[service]} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle key={service}>
<EuiText>
<h4>{ServiceTitleMap[service]} Assets</h4>
</EuiText>
</EuiTitle>
</EuiFlexItem>
</FlexGroup>
<EuiHorizontalRule margin="none" />
{entries(typeToParts).map(([type, parts], typeIndex, typeEntries) => {
let iconType = null;
if (type in AssetIcons) {
// only kibana assets have icons
iconType = AssetIcons[type as KibanaAssetType];
}
// @types/styled-components@3 does yet support `defaultProps`, which EuiAccordion uses
// Ref: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/31903
// we're a major version behind; nearly 2
return (
<Fragment key={type}>
<EuiAccordion
style={{ margin: theme.eui.euiFormControlPadding }}
id={type}
buttonContent={
<EuiFlexGroup gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
{iconType ? <EuiIcon type={iconType} size="s" /> : ''}
</EuiFlexItem>
<EuiFlexItem>
<EuiText color="secondary">{AssetTitleMap[type]}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
}
paddingSize="m"
extraAction={
<EuiNotificationBadge color="subdued" size="m">
{parts.length}
</EuiNotificationBadge>
}
>
<EuiText>
<span role="img" aria-label="woman shrugging">
🤷
</span>
</EuiText>
</EuiAccordion>
{typeIndex < typeEntries.length - 1 ? <EuiHorizontalRule margin="none" /> : ''}
</Fragment>
);
})}
<EuiSpacer size="l" />
</Fragment>
);
})}
</Fragment>
);
}

View file

@ -0,0 +1,107 @@
/*
* 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, { Fragment } from 'react';
import {
EuiFacetButton,
EuiFacetGroup,
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import { entries } from '../../common/type_utils';
import {
DisplayedAssets,
AssetIcons,
AssetTitleMap,
ServiceIcons,
ServiceTitleMap,
} from '../constants';
import {
AssetsGroupedByServiceByType,
AssetTypeToParts,
KibanaAssetType,
} from '../../common/types';
import { useCore } from '../hooks/use_core';
export function AssetsFacetGroup({ assets }: { assets: AssetsGroupedByServiceByType }) {
const { theme } = useCore();
const FirstHeaderRow = styled(EuiFlexGroup)`
padding: 0 0 ${theme.eui.paddingSizes.m} 0;
`;
const HeaderRow = styled(EuiFlexGroup)`
padding: ${theme.eui.paddingSizes.m} 0;
`;
const FacetGroup = styled(EuiFacetGroup)`
flex-grow: 0;
`;
return (
<Fragment>
{entries(assets).map(([service, typeToParts], index) => {
const Header = index === 0 ? FirstHeaderRow : HeaderRow;
// filter out assets we are not going to display
const filteredTypes: AssetTypeToParts = entries(typeToParts).reduce(
(acc: any, [asset, value]) => {
if (DisplayedAssets[service].includes(asset)) acc[asset] = value;
return acc;
},
{}
);
return (
<Fragment key={service}>
<Header gutterSize="s" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={ServiceIcons[service]} />
</EuiFlexItem>
<EuiFlexItem>
<EuiTitle key={service} size="xs">
<EuiText>
<h4>{ServiceTitleMap[service]} Assets</h4>
</EuiText>
</EuiTitle>
</EuiFlexItem>
</Header>
<FacetGroup>
{entries(filteredTypes).map(([type, parts]) => {
let iconType = null;
if (type in AssetIcons) {
// only kibana assets have icons
iconType = AssetIcons[type as KibanaAssetType];
}
const iconNode = iconType ? <EuiIcon type={iconType} size="s" /> : '';
const FacetButton = styled(EuiFacetButton)`
padding: '${theme.eui.paddingSizes.xs} 0';
height: 'unset';
`;
return (
<FacetButton
key={type}
quantity={parts.length}
icon={iconNode}
// https://github.com/elastic/eui/issues/2216
buttonRef={() => {}}
>
<EuiTextColor color="subdued">{AssetTitleMap[type]}</EuiTextColor>
</FacetButton>
);
})}
</FacetGroup>
</Fragment>
);
})}
</Fragment>
);
}

View file

@ -0,0 +1,96 @@
/*
* 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, { useState, useRef, useLayoutEffect, Fragment, useCallback } from 'react';
import { EuiButtonEmpty, EuiSpacer, EuiButton, EuiHorizontalRule } from '@elastic/eui';
import euiStyled from '../../../../common/eui_styled_components';
const BottomFade = euiStyled.div`
width: 100%;
background: ${props =>
`linear-gradient(${props.theme.eui.euiColorEmptyShade}00 0%, ${props.theme.eui.euiColorEmptyShade} 100%)`};
margin-top: -${props => parseInt(props.theme.eui.spacerSizes.xl, 10) * 2}px;
height: ${props => parseInt(props.theme.eui.spacerSizes.xl, 10) * 2}px;
position: absolute;
`;
const ContentCollapseContainer = euiStyled.div`
position: relative;
`;
const CollapseButtonContainer = euiStyled.div`
display: inline-block;
background-color: ${props => props.theme.eui.euiColorGhost};
position: absolute;
left: 50%;
transform: translateX(-50%);
top: ${props => parseInt(props.theme.eui.euiButtonHeight, 10) / 2}px;
`;
const CollapseButtonTop = euiStyled(EuiButtonEmpty)`
float: right;
`;
const CollapseButton = ({
open,
toggleCollapse,
}: {
open: boolean;
toggleCollapse: () => void;
}) => {
return (
<div style={{ position: 'relative' }}>
<EuiSpacer size="m" />
<EuiHorizontalRule />
<CollapseButtonContainer>
<EuiButton onClick={toggleCollapse} iconType={`arrow${open ? 'Up' : 'Down'}`}>
{open ? 'Collapse' : 'Read more'}
</EuiButton>
</CollapseButtonContainer>
</div>
);
};
export const ContentCollapse = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = useState<boolean>(false);
const [height, setHeight] = useState<number | string>('auto');
const [collapsible, setCollapsible] = useState<boolean>(true);
const contentEl = useRef<HTMLDivElement>(null);
const collapsedHeight = 360;
// if content is too small, don't collapse
useLayoutEffect(
() =>
contentEl.current && contentEl.current.clientHeight < collapsedHeight
? setCollapsible(false)
: setHeight(collapsedHeight),
[]
);
const clickOpen = useCallback(() => {
setOpen(!open);
}, [open]);
return (
<Fragment>
{collapsible ? (
<ContentCollapseContainer>
<div
ref={contentEl}
style={{ height: `${open ? 'auto' : `${height}px`}`, overflow: 'hidden' }}
>
{open && (
<CollapseButtonTop onClick={clickOpen} iconType={`arrow${open ? 'Up' : 'Down'}`}>
Collapse
</CollapseButtonTop>
)}
{children}
</div>
{!open && <BottomFade />}
<CollapseButton open={open} toggleCollapse={clickOpen} />
</ContentCollapseContainer>
) : (
<div>{children}</div>
)}
</Fragment>
);
};

View file

@ -0,0 +1,33 @@
/*
* 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 from 'react';
import styled from 'styled-components';
import { EuiIcon, EuiPanel, IconType } from '@elastic/eui';
import { useCore } from '../hooks/use_core';
export function IconPanel({ iconType }: { iconType: IconType }) {
const { theme } = useCore();
const Panel = styled(EuiPanel)`
/* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */
&&& {
position: absolute;
text-align: center;
vertical-align: middle;
padding: ${theme.eui.spacerSizes.xl};
svg {
height: ${theme.eui.euiKeyPadMenuSize};
width: ${theme.eui.euiKeyPadMenuSize};
}
}
`;
return (
<Panel>
<EuiIcon type={iconType} size="original" />
</Panel>
);
}

View file

@ -0,0 +1,65 @@
/*
* 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 from 'react';
import { EuiCard, EuiIcon, ICON_TYPES } from '@elastic/eui';
import styled from 'styled-components';
import { useLinks } from '../hooks';
import { PackageListItem, PackageInfo } from '../../common/types';
export interface BadgeProps {
showInstalledBadge?: boolean;
}
type PackageCardProps = (PackageListItem | PackageInfo) & BadgeProps;
// adding the `href` causes EuiCard to use a `a` instead of a `button`
// `a` tags use `euiLinkColor` which results in blueish Badge text
const Card = styled(EuiCard)`
color: inherit;
`;
export function PackageCard({
description,
name,
title,
version,
icon: iconUrl,
showInstalledBadge,
status,
}: PackageCardProps) {
const { toDetailView } = useLinks();
const url = toDetailView({ name, version });
// try to find a logo in EUI
const iconType = ICON_TYPES.find(key => key.toLowerCase() === `logo${name}`);
let optionalIcon;
if (iconType) {
optionalIcon = <EuiIcon type={iconType} size="l" />;
} else if (iconUrl) {
// skipping b/c images from registry are Not Good
// https://github.com/elastic/integrations-registry/issues/45
// optionalIcon = (
// <img
// width="24"
// height="24"
// src={`http://package-registry.app.elstc.co${iconUrl}`}
// alt={`${name} icon`}
// />
// );
}
return (
<Card
betaBadgeLabel={showInstalledBadge && status === 'installed' ? 'Installed' : ''}
layout="horizontal"
title={title}
description={description}
icon={optionalIcon}
href={url}
/>
);
}

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 React, { Fragment, ReactNode } from 'react';
import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui';
import { PackageList } from '../../common/types';
import { PackageCard, BadgeProps } from './package_card';
type ListProps = {
controls?: ReactNode;
title: string;
list: PackageList;
} & BadgeProps;
export function PackageListGrid({ controls, title, list, showInstalledBadge }: ListProps) {
const controlsContent = <ControlsColumn title={title} controls={controls} />;
const gridContent = <GridColumn list={list} showInstalledBadge={showInstalledBadge} />;
return (
<EuiFlexGroup>
<EuiFlexItem grow={1}>{controlsContent}</EuiFlexItem>
<EuiFlexItem grow={3}>{gridContent}</EuiFlexItem>
</EuiFlexGroup>
);
}
interface ControlsColumnProps {
controls: ReactNode;
title: string;
}
function ControlsColumn({ controls, title }: ControlsColumnProps) {
return (
<Fragment>
<EuiText>
<h2>{title}</h2>
</EuiText>
<EuiSpacer size="l" />
<EuiFlexGroup>
<EuiFlexItem grow={2}>{controls}</EuiFlexItem>
<EuiFlexItem grow={1} />
</EuiFlexGroup>
</Fragment>
);
}
type GridColumnProps = {
list: PackageList;
} & BadgeProps;
function GridColumn({ list, showInstalledBadge }: GridColumnProps) {
return (
<EuiFlexGrid gutterSize="l" columns={3}>
{list.map(item => (
<EuiFlexItem key={`${item.name}-${item.version}`}>
<PackageCard {...item} />
</EuiFlexItem>
))}
</EuiFlexGrid>
);
}

View file

@ -0,0 +1,51 @@
/*
* 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, { Fragment } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiTextColor, EuiTitle } from '@elastic/eui';
import styled from 'styled-components';
import { entries } from '../../common/type_utils';
import { RequirementsByServiceName } from '../../common/types';
import { ServiceTitleMap } from '../constants';
import { useCore } from '../hooks/use_core';
import { VersionBadge } from './version_badge';
export interface RequirementsProps {
requirements: RequirementsByServiceName;
}
export function Requirements(props: RequirementsProps) {
const { requirements } = props;
const { theme } = useCore();
const Text = styled.span`
padding-bottom: ${theme.eui.paddingSizes.m};
`;
return (
<Fragment>
<EuiTitle size="xs">
<Text>Compatibility</Text>
</EuiTitle>
{entries(requirements).map(([service, requirement]) => (
<EuiFlexGroup key={service}>
<EuiFlexItem grow={true}>
<EuiTextColor color="subdued" key={service}>
{ServiceTitleMap[service]}:
</EuiTextColor>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
<VersionBadge version={requirement.version.min} />
<span>{' - '}</span>
<VersionBadge version={requirement.version.max} />
</div>
</EuiFlexItem>
</EuiFlexGroup>
))}
</Fragment>
);
}

View file

@ -0,0 +1,13 @@
/*
* 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 from 'react';
import { EuiBadge } from '@elastic/eui';
import { RequirementVersion } from '../../common/types';
export function VersionBadge({ version }: { version: RequirementVersion }) {
return <EuiBadge color="hollow">v{version}</EuiBadge>;
}

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 { IconType } from '@elastic/eui';
import { KibanaAssetType, AssetType, ServiceName } from '../common/types';
// TODO: figure out how to allow only corresponding asset types (KibanaAssetType, ElasticsearchAssetType)
export const DisplayedAssets: Record<ServiceName, AssetType[]> = {
kibana: ['index-pattern', 'visualization', 'search', 'dashboard'],
elasticsearch: ['index-template', 'ingest-pipeline', 'ilm-policy'],
};
export const AssetTitleMap: Record<AssetType, string> = {
dashboard: 'Dashboard',
'ilm-policy': 'ILM Policy',
'ingest-pipeline': 'Ingest Pipeline',
'index-pattern': 'Index Pattern',
'index-template': 'Index Template',
search: 'Saved Search',
visualization: 'Visualization',
};
export const ServiceTitleMap: Record<ServiceName, string> = {
elasticsearch: 'Elasticsearch',
kibana: 'Kibana',
};
export const AssetIcons: Record<KibanaAssetType, IconType> = {
dashboard: 'dashboardApp',
'index-pattern': 'indexPatternApp',
search: 'searchProfilerApp',
visualization: 'visualizeApp',
};
export const ServiceIcons: Record<ServiceName, IconType> = {
elasticsearch: 'logoElasticsearch',
kibana: 'logoKibana',
};

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 React, { createContext } from 'react';
import { PluginCore } from '../plugin';
const CoreContext = createContext<PluginCore>({} as PluginCore);
const CoreProvider: React.SFC<{ core: PluginCore }> = props => {
const { core, ...restProps } = props;
return <CoreContext.Provider value={core} {...restProps} />;
};
export { CoreContext, CoreProvider };

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 { HttpHandler } from 'src/core/public';
import {
getCategoriesPath,
getInfoPath,
getInstallPath,
getListPath,
getRemovePath,
ListParams,
getFilePath,
} from '../common/routes';
import {
CategorySummaryList,
PackageInfo,
PackageList,
PackagesGroupedByStatus,
} from '../common/types';
const defaultClient: HttpHandler = (path, options?) => fetch(path, options).then(res => res.json());
let _fetch: HttpHandler = defaultClient;
export function setClient(client: HttpHandler): void {
_fetch = client;
}
export async function getCategories(): Promise<CategorySummaryList> {
const path = getCategoriesPath();
return _fetch(path);
}
export async function getPackages(params?: ListParams): Promise<PackageList> {
const path = getListPath();
const options = params ? { query: { ...params } } : undefined;
return _fetch(path, options);
}
export async function getPackagesGroupedByStatus() {
const path = getListPath();
const list: PackageList = await _fetch(path);
const initialValue: PackagesGroupedByStatus = {
installed: [],
not_installed: [],
};
const groupedByStatus = list.reduce((grouped, item) => {
if (!grouped[item.status]) {
grouped[item.status] = [];
}
grouped[item.status].push(item);
return grouped;
}, initialValue);
return groupedByStatus;
}
export async function getPackageInfoByKey(pkgkey: string): Promise<PackageInfo> {
const path = getInfoPath(pkgkey);
return _fetch(path);
}
export async function installPackage(pkgkey: string): Promise<PackageInfo> {
const path = getInstallPath(pkgkey);
return _fetch(path);
}
export async function removePackage(pkgkey: string): Promise<PackageInfo> {
const path = getRemovePath(pkgkey);
return _fetch(path);
}
export async function getFileByPath(filePath: string): Promise<string> {
const path = getFilePath(filePath);
return _fetch(path);
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { useBreadcrumbs } from './use_breadcrumbs';
export { useCore } from './use_core';
export { useLinks } from './use_links';

View file

@ -0,0 +1,13 @@
/*
* 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 { ChromeBreadcrumb } from 'src/core/public';
import { useCore } from '.';
export function useBreadcrumbs(newBreadcrumbs: ChromeBreadcrumb[]) {
const { chrome } = useCore();
return chrome.setBreadcrumbs(newBreadcrumbs);
}

View file

@ -0,0 +1,12 @@
/*
* 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 { useContext } from 'react';
import { CoreContext } from '../contexts/core';
export function useCore() {
return useContext(CoreContext);
}

View file

@ -0,0 +1,57 @@
/*
* 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 { generatePath } from 'react-router-dom';
import { PLUGIN } from '../../common/constants';
import { getFilePath, getInfoPath } from '../../common/routes';
import { patterns } from '../routes';
import { useCore } from '.';
import { DetailViewPanelName } from '..';
// TODO: get this from server/packages/handlers.ts (move elsewhere?)
// seems like part of the name@version change
interface DetailParams {
name: string;
version: string;
panel?: DetailViewPanelName;
}
const removeRelativePath = (relativePath: string): string =>
new URL(relativePath, 'http://example.com').pathname;
export function useLinks() {
const { http } = useCore();
function appRoot(path: string) {
// include '#' because we're using HashRouter
return http.basePath.prepend(patterns.APP_ROOT + '#' + path);
}
return {
toAssets: (path: string) => http.basePath.prepend(`/plugins/${PLUGIN.ID}/assets/${path}`),
toImage: (path: string) => http.basePath.prepend(getFilePath(path)),
toRelativeImage: ({
path,
packageName,
version,
}: {
path: string;
packageName: string;
version: string;
}) => {
const imagePath = removeRelativePath(path);
const pkgkey = `${packageName}-${version}`;
const filePath = `${getInfoPath(pkgkey)}/${imagePath}`;
return http.basePath.prepend(filePath);
},
toListView: () => appRoot(patterns.LIST_VIEW),
toDetailView: ({ name, version, panel }: DetailParams) => {
// panel is optional, but `generatePath` won't accept `path: undefined`
// so use this to pass `{ pkgkey }` or `{ pkgkey, panel }`
const params = Object.assign({ pkgkey: `${name}-${version}` }, panel ? { panel } : {});
return appRoot(generatePath(patterns.DETAIL_VIEW, params));
},
};
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import euiLight from '@elastic/eui/dist/eui_theme_light.json';
import euiDark from '@elastic/eui/dist/eui_theme_dark.json';
import chrome from 'ui/chrome';
import { npSetup, npStart } from 'ui/new_platform';
import { Plugin, PluginInitializerContext } from './plugin';
import { routes } from './routes';
// create './types' later and move there?
export type DetailViewPanelName = 'overview' | 'assets' | 'data-sources';
const REACT_APP_ROOT_ID = 'epm__root';
const template = `<div id="${REACT_APP_ROOT_ID}" style="flex-grow: 1; display: flex; flex-direction: column"></div>`;
const getRootEl = () => document.getElementById(REACT_APP_ROOT_ID);
export const plugin = (initializerContext: PluginInitializerContext = {}) =>
new Plugin(initializerContext);
const epmPlugin = plugin();
epmPlugin.setup(npSetup.core);
// @ts-ignore
chrome.setRootTemplate(template);
waitForElement(getRootEl).then(element => {
const isDarkMode = npStart.core.uiSettings.get('theme:darkMode');
epmPlugin.start({
...npStart.core,
routes,
theme: { eui: isDarkMode ? euiDark : euiLight },
renderTo: element,
});
});
function waitForElement(fn: () => HTMLElement | null): Promise<HTMLElement> {
return new Promise(resolve => {
const element = fn();
if (element) {
resolve(element);
} else {
setTimeout(() => resolve(waitForElement(fn)), 10);
}
});
}

View file

@ -0,0 +1,64 @@
/*
* 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 from 'react';
import ReactDOM from 'react-dom';
import { HashRouter, Switch } from 'react-router-dom';
import { ThemeProvider } from 'styled-components';
import { EuiErrorBoundary } from '@elastic/eui';
import euiLight from '@elastic/eui/dist/eui_theme_light.json';
import { ChromeStart, CoreSetup, HttpStart, I18nStart } from 'src/core/public';
import { CoreProvider } from './contexts/core';
import { setClient } from './data';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginInitializerContext {}
export type PluginSetup = ReturnType<Plugin['setup']>;
export type PluginStart = ReturnType<Plugin['start']>;
export interface PluginTheme {
eui: typeof euiLight;
}
export interface PluginCore {
chrome: ChromeStart;
http: HttpStart;
i18n: I18nStart;
routes: JSX.Element[];
theme: PluginTheme;
renderTo: HTMLElement;
}
export class Plugin {
constructor(initializerContext: PluginInitializerContext) {}
// called when plugin is setting up during Kibana's startup sequence
public setup(core: CoreSetup) {
setClient(core.http.fetch);
}
// called after all plugins are set up
public start(core: PluginCore) {
ReactDOM.render(<App core={core} />, core.renderTo);
}
}
function App(props: { core: PluginCore }) {
const { i18n, routes } = props.core;
return (
<EuiErrorBoundary>
<CoreProvider core={props.core}>
<i18n.Context>
<ThemeProvider theme={props.core.theme}>
<HashRouter>
<Switch>{routes}</Switch>
</HashRouter>
</ThemeProvider>
</i18n.Context>
</CoreProvider>
</EuiErrorBoundary>
);
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { npSetup } from 'ui/new_platform';
import { FeatureCatalogueCategory } from '../../../../../src/plugins/feature_catalogue/public';
import { PLUGIN } from '../common/constants';
import { patterns } from './routes';
// This defines what shows up in the registry found at /app/kibana#/home and /app/kibana#/home/feature_directory
if (npSetup.plugins.feature_catalogue) {
npSetup.plugins.feature_catalogue.register({
id: PLUGIN.ID,
title: PLUGIN.TITLE,
description: PLUGIN.DESCRIPTION,
icon: PLUGIN.ICON,
path: patterns.APP_ROOT,
showOnHomePage: true,
category: FeatureCatalogueCategory.DATA,
});
}

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 React from 'react';
import { Route } from 'react-router-dom';
import { Detail, DetailProps } from './screens/detail';
import { Home } from './screens/home';
import { PLUGIN } from '../common/constants';
// patterns are used by React Router and are relative to `APP_ROOT`
export const patterns = {
APP_ROOT: `/app/${PLUGIN.ID}`,
LIST_VIEW: '/',
DETAIL_VIEW: '/detail/:pkgkey/:panel?',
};
export const routes = [
<Route key="home" path={patterns.LIST_VIEW} exact={true} component={Home} />,
<Route
key="detail"
path={patterns.DETAIL_VIEW}
exact={true}
render={(props: DetailMatch) => <Detail {...props.match.params} />}
/>,
];
interface DetailMatch {
match: {
params: DetailProps;
};
}

View file

@ -0,0 +1,89 @@
/*
* 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 from 'react';
import styled from 'styled-components';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, EuiTitle } from '@elastic/eui';
import { SideNavLinks } from './side_nav_links';
import { PackageInfo } from '../../../common/types';
import { AssetAccordion } from '../../components/asset_accordion';
import { AssetsFacetGroup } from '../../components/assets_facet_group';
import { Requirements } from '../../components/requirements';
import { CenterColumn, LeftColumn, RightColumn } from './layout';
import { OverviewPanel } from './overview_panel';
import { DEFAULT_PANEL, DetailProps } from '.';
type ContentProps = PackageInfo & Pick<DetailProps, 'panel'> & { hasIconPanel: boolean };
export function Content(props: ContentProps) {
const { hasIconPanel, name, panel, version } = props;
const SideNavColumn = hasIconPanel
? styled(LeftColumn)`
/* 🤢🤷 https://www.styled-components.com/docs/faqs#how-can-i-override-styles-with-higher-specificity */
&&& {
margin-top: 77px;
}
`
: LeftColumn;
// fixes IE11 problem with nested flex items
const ContentFlexGroup = styled(EuiFlexGroup)`
flex: 0 0 auto !important;
`;
return (
<ContentFlexGroup>
<SideNavColumn>
<SideNavLinks name={name} version={version} active={panel || DEFAULT_PANEL} />
</SideNavColumn>
<CenterColumn>
<ContentPanel {...props} />
</CenterColumn>
<RightColumn>
<RightColumnContent {...props} />
</RightColumn>
</ContentFlexGroup>
);
}
type ContentPanelProps = PackageInfo & Pick<DetailProps, 'panel'>;
export function ContentPanel(props: ContentPanelProps) {
const { assets, panel } = props;
switch (panel) {
case 'assets':
return <AssetAccordion assets={assets} />;
case 'data-sources':
return (
<EuiTitle size="xs">
<span>Data Sources</span>
</EuiTitle>
);
case 'overview':
default:
return <OverviewPanel {...props} />;
}
}
type RightColumnContentProps = PackageInfo & Pick<DetailProps, 'panel'>;
function RightColumnContent(props: RightColumnContentProps) {
const { assets, requirement, panel } = props;
switch (panel) {
case 'overview':
return (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem grow={false}>
<Requirements requirements={requirement} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="xl" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AssetsFacetGroup assets={assets} />
</EuiFlexItem>
</EuiFlexGroup>
);
default:
return <EuiSpacer />;
}
}

View file

@ -0,0 +1,123 @@
/*
* 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, { Fragment, useCallback, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiButtonEmptyProps,
EuiFlexGroup,
EuiFlexItem,
EuiPage,
EuiTitle,
IconType,
} from '@elastic/eui';
import styled from 'styled-components';
// TODO: either
// * import from a shared point
// * duplicate inside our folder
import { useTrackedPromise } from '../../../../infra/public/utils/use_tracked_promise';
import { installPackage } from '../../data';
import { PLUGIN } from '../../../common/constants';
import { PackageInfo } from '../../../common/types';
import { VersionBadge } from '../../components/version_badge';
import { IconPanel } from '../../components/icon_panel';
import { useBreadcrumbs, useCore, useLinks } from '../../hooks';
import { CenterColumn, LeftColumn, RightColumn } from './layout';
type HeaderProps = PackageInfo & { iconType?: IconType };
export function Header(props: HeaderProps) {
const { iconType, title, version } = props;
const { theme } = useCore();
const { toListView } = useLinks();
useBreadcrumbs([{ text: PLUGIN.TITLE, href: toListView() }, { text: title }]);
const FullWidthNavRow = styled(EuiPage)`
/* no left padding so link is against column left edge */
padding-left: 0;
`;
const Text = styled.span`
margin-right: ${theme.eui.spacerSizes.xl};
`;
return (
<Fragment>
<FullWidthNavRow>
<NavButtonBack />
</FullWidthNavRow>
<EuiFlexGroup>
{iconType ? (
<LeftColumn>
<IconPanel iconType={iconType} />
</LeftColumn>
) : null}
<CenterColumn>
<EuiTitle size="l">
<h1>
<Text>{title}</Text>
<VersionBadge version={version} />
</h1>
</EuiTitle>
</CenterColumn>
<RightColumn>
<EuiFlexGroup direction="column" alignItems="flexEnd">
<EuiFlexItem grow={false}>
<InstallationButton {...props} />
</EuiFlexItem>
</EuiFlexGroup>
</RightColumn>
</EuiFlexGroup>
</Fragment>
);
}
function NavButtonBack() {
const { toListView } = useLinks();
const { theme } = useCore();
const ButtonEmpty = styled(EuiButtonEmpty).attrs<EuiButtonEmptyProps>({
href: toListView(),
})`
margin-right: ${theme.eui.spacerSizes.xl};
`;
return (
<ButtonEmpty iconType="arrowLeft" size="xs" flush="left">
Browse Packages
</ButtonEmpty>
);
}
const installedButton = (
<EuiButtonEmpty iconType="check" disabled>
This package is installed
</EuiButtonEmpty>
);
function InstallationButton(props: PackageInfo) {
const [isInstalled, setInstalled] = useState<boolean>(props.status === 'installed');
const [installationRequest, attemptInstallation] = useTrackedPromise(
{
createPromise: async () => {
const pkgkey = `${props.name}-${props.version}`;
return installPackage(pkgkey);
},
onResolve: (value: PackageInfo) => setInstalled(value.status === 'installed'),
},
[props.name, props.version, installPackage]
);
const isLoading = installationRequest.state === 'pending';
const handleClickInstall = useCallback(attemptInstallation, [attemptInstallation]);
const installButton = (
<EuiButton isLoading={isLoading} fill={true} onClick={handleClickInstall}>
{isLoading ? 'Installing' : 'Install package'}
</EuiButton>
);
return isInstalled ? installedButton : installButton;
}

View file

@ -0,0 +1,70 @@
/*
* 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, { Fragment, useState, useEffect } from 'react';
import { EuiPage, EuiPageBody, EuiPageWidthProps, ICON_TYPES } from '@elastic/eui';
import styled from 'styled-components';
import { PackageInfo } from '../../../common/types';
import { DetailViewPanelName } from '../../';
import { getPackageInfoByKey } from '../../data';
import { useCore } from '../../hooks/use_core';
import { Header } from './header';
import { Content } from './content';
export const DEFAULT_PANEL: DetailViewPanelName = 'overview';
export interface DetailProps {
pkgkey: string;
panel?: DetailViewPanelName;
}
export function Detail({ pkgkey, panel = DEFAULT_PANEL }: DetailProps) {
const [info, setInfo] = useState<PackageInfo | null>(null);
useEffect(() => {
getPackageInfoByKey(pkgkey).then(response => {
const { title } = response;
setInfo({ ...response, title });
});
}, [pkgkey]);
// don't have designs for loading/empty states
if (!info) return null;
return <DetailLayout restrictWidth={1200} {...info} panel={panel} />;
}
type LayoutProps = PackageInfo & Pick<DetailProps, 'panel'> & EuiPageWidthProps;
export function DetailLayout(props: LayoutProps) {
const { name, restrictWidth } = props;
const { theme } = useCore();
const iconType = ICON_TYPES.find(key => key.toLowerCase() === `logo${name}`);
const FullWidthHeader = styled(EuiPage)`
border-bottom: ${theme.eui.euiBorderThin}
padding-bottom: ${theme.eui.paddingSizes.xl};
`;
const paddingSizeTop: number = parseInt(theme.eui.paddingSizes.xl, 10) * 1.25;
const FullWidthContent = styled(EuiPage)`
background-color: ${theme.eui.euiColorEmptyShade};
padding-top: ${paddingSizeTop}px;
flex-grow: 1;
`;
return (
<Fragment>
<FullWidthHeader>
<EuiPageBody restrictWidth={restrictWidth}>
<Header iconType={iconType} {...props} />
</EuiPageBody>
</FullWidthHeader>
<FullWidthContent>
<EuiPageBody restrictWidth={restrictWidth}>
<Content hasIconPanel={!!iconType} {...props} />
</EuiPageBody>
</FullWidthContent>
</Fragment>
);
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FunctionComponent, ReactNode } from 'react';
import { EuiFlexItem } from '@elastic/eui';
interface ColumnProps {
children?: ReactNode;
className?: string;
}
export const LeftColumn: FunctionComponent<ColumnProps> = ({ children, ...rest }) => {
return (
<EuiFlexItem grow={2} {...rest}>
{children}
</EuiFlexItem>
);
};
export const CenterColumn: FunctionComponent<ColumnProps> = ({ children, ...rest }) => {
return (
<EuiFlexItem grow={7} {...rest}>
{children}
</EuiFlexItem>
);
};
export const RightColumn: FunctionComponent<ColumnProps> = ({ children, ...rest }) => {
return (
<EuiFlexItem grow={3} {...rest}>
{children}
</EuiFlexItem>
);
};

View file

@ -0,0 +1,70 @@
/*
* 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 {
EuiText,
EuiCodeBlock,
EuiTableHeaderCell,
EuiTableRow,
EuiTableRowCell,
EuiLink,
} from '@elastic/eui';
import React from 'react';
/** prevents links to the new pages from accessing `window.opener` */
const REL_NOOPENER = 'noopener';
/** prevents search engine manipulation by noting the linked document is not trusted or endorsed by us */
const REL_NOFOLLOW = 'nofollow';
/** prevents the browser from sending the current address as referrer via the Referer HTTP header */
const REL_NOREFERRER = 'noreferrer';
export const markdownRenderers = {
root: ({ children }: { children: React.ReactNode[] }) => (
<EuiText grow={true}>{children}</EuiText>
),
table: ({ children }: { children: React.ReactNode[] }) => (
<table className="euiTable euiTable--responsive" style={{ tableLayout: 'auto' }}>
{children}
</table>
),
tableRow: ({ children }: { children: React.ReactNode[] }) => (
<EuiTableRow>{children}</EuiTableRow>
),
tableCell: ({ isHeader, children }: { isHeader: boolean; children: React.ReactNode[] }) => {
return isHeader ? (
<EuiTableHeaderCell>{children}</EuiTableHeaderCell>
) : (
<EuiTableRowCell>{children}</EuiTableRowCell>
);
},
// the headings used in markdown don't match our page so mapping them to the appropriate one
heading: ({ level, children }: { level: number; children: React.ReactNode[] }) => {
switch (level) {
case 1:
return <h3>{children}</h3>;
case 2:
return <h4>{children}</h4>;
case 3:
return <h5>{children}</h5>;
default:
return <h6>{children}</h6>;
}
},
link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => (
<EuiLink href={href} target="_blank" rel={`${REL_NOOPENER} ${REL_NOFOLLOW} ${REL_NOREFERRER}`}>
{children}
</EuiLink>
),
code: ({ language, value }: { language: string; value: string }) => {
return (
<EuiCodeBlock language={language} isCopyable>
{value}
</EuiCodeBlock>
);
},
};

View file

@ -0,0 +1,21 @@
/*
* 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, { Fragment } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { PackageInfo } from '../../../common/types';
import { Screenshots } from './screenshots';
import { Readme } from './readme';
export function OverviewPanel(props: PackageInfo) {
const { screenshots, readme, name, version } = props;
return (
<Fragment>
{readme && <Readme readmePath={readme} packageName={name} version={version} />}
<EuiSpacer size="xl" />
{screenshots && <Screenshots images={screenshots} />}
</Fragment>
);
}

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState, Fragment } from 'react';
import { EuiLoadingContent, EuiText } from '@elastic/eui';
import ReactMarkdown from 'react-markdown';
import { getFileByPath } from '../../data';
import { markdownRenderers } from './markdown_renderers';
import { useLinks } from '../../hooks';
import { ContentCollapse } from '../../components/content_collapse';
export function Readme({
readmePath,
packageName,
version,
}: {
readmePath: string;
packageName: string;
version: string;
}) {
const [markdown, setMarkdown] = useState<string | undefined>(undefined);
const { toRelativeImage } = useLinks();
const handleImageUri = React.useCallback(
(uri: string) => {
const isRelative =
uri.indexOf('http://') === 0 || uri.indexOf('https://') === 0 ? false : true;
const fullUri = isRelative ? toRelativeImage({ packageName, version, path: uri }) : uri;
return fullUri;
},
[toRelativeImage, packageName, version]
);
useEffect(() => {
getFileByPath(readmePath).then(res => {
setMarkdown(res);
});
}, [readmePath]);
return (
<Fragment>
{markdown !== undefined ? (
<ContentCollapse>
<ReactMarkdown
transformImageUri={handleImageUri}
renderers={markdownRenderers}
source={markdown}
/>
</ContentCollapse>
) : (
<EuiText>
{/* simulates a long page of text loading */}
<p>
<EuiLoadingContent lines={5} />
</p>
<p>
<EuiLoadingContent lines={6} />
</p>
<p>
<EuiLoadingContent lines={4} />
</p>
</EuiText>
)}
</Fragment>
);
}

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { EuiSpacer, EuiText, EuiTitle, EuiImage, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
import { ScreenshotItem } from '../../../common/types';
import { useLinks, useCore } from '../../hooks';
interface ScreenshotProps {
images: ScreenshotItem[];
}
export function Screenshots(props: ScreenshotProps) {
const { theme } = useCore();
const { toImage } = useLinks();
const { images } = props;
// for now, just get first image
const image = images[0];
const hasCaption = image.title ? true : false;
const horizontalPadding: number = parseInt(theme.eui.paddingSizes.xl, 10) * 2;
const verticalPadding: number = parseInt(theme.eui.paddingSizes.xl, 10) * 1.75;
const padding = hasCaption
? `${theme.eui.paddingSizes.xl} ${horizontalPadding}px ${verticalPadding}px`
: `${verticalPadding}px ${horizontalPadding}px`;
const ScreenshotsContainer = styled(EuiFlexGroup)`
background: linear-gradient(360deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0) 100%),
${theme.eui.euiColorPrimary};
padding: ${padding};
flex: 0 0 auto;
border-radius: ${theme.eui.euiBorderRadius};
`;
// fixes ie11 problems with nested flex items
const NestedEuiFlexItem = styled(EuiFlexItem)`
flex: 0 0 auto !important;
`;
return (
<Fragment>
<EuiTitle size="s">
<h3>Screenshots</h3>
</EuiTitle>
<EuiSpacer size="m" />
<ScreenshotsContainer gutterSize="none" direction="column" alignItems="center">
{hasCaption && (
<NestedEuiFlexItem>
<EuiText color="ghost" aria-label="screenshot image caption">
{image.title}
</EuiText>
<EuiSpacer />
</NestedEuiFlexItem>
)}
<NestedEuiFlexItem>
{/* By default EuiImage sets width to 100% and Figure to 22.5rem for size=l images,
set image to same width. Will need to update if size changes.
*/}
<EuiImage
url={toImage(image.src)}
alt="screenshot image preview"
size="l"
allowFullScreen
style={{ width: '22.5rem', maxWidth: '100%' }}
/>
</NestedEuiFlexItem>
</ScreenshotsContainer>
</Fragment>
);
}

View file

@ -0,0 +1,47 @@
/*
* 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, { Fragment } from 'react';
import { EuiButtonEmpty, EuiButtonEmptyProps } from '@elastic/eui';
import styled from 'styled-components';
import { PackageInfo } from '../../../common/types';
import { entries } from '../../../common/type_utils';
import { DetailViewPanelName } from '../../';
import { useLinks } from '../../hooks';
export type NavLinkProps = Pick<PackageInfo, 'name' | 'version'> & {
active: DetailViewPanelName;
};
const PanelDisplayNames: Record<DetailViewPanelName, string> = {
overview: 'Overview',
assets: 'Assets',
'data-sources': 'Data Sources',
};
export function SideNavLinks({ name, version, active }: NavLinkProps) {
const { toDetailView } = useLinks();
return (
<Fragment>
{entries(PanelDisplayNames).map(([panel, display]) => {
const Link = styled(EuiButtonEmpty).attrs<EuiButtonEmptyProps>({
href: toDetailView({ name, version, panel }),
})`
font-weight: ${p =>
active === panel
? p.theme.eui.euiFontWeightSemiBold
: p.theme.eui.euiFontWeightRegular};
`;
return (
<div key={panel}>
<Link>{display}</Link>
</div>
);
})}
</Fragment>
);
}

View file

@ -0,0 +1,36 @@
/*
* 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 from 'react';
import { EuiFacetButton, EuiFacetGroup } from '@elastic/eui';
import { CategorySummaryItem, CategorySummaryList } from '../../../common/types';
export function CategoryFacets({
categories,
selectedCategory,
onCategoryChange,
}: {
categories: CategorySummaryList;
selectedCategory: string;
onCategoryChange: (category: CategorySummaryItem) => unknown;
}) {
const controls = (
<EuiFacetGroup>
{categories.map(category => (
<EuiFacetButton
isSelected={category.id === selectedCategory}
key={category.id}
id={category.id}
quantity={category.count}
onClick={() => onCategoryChange(category)}
>
{category.title}
</EuiFacetButton>
))}
</EuiFacetGroup>
);
return controls;
}

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useState } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
// @ts-ignore (elastic/eui#1557) & (elastic/eui#1262) EuiImage is not exported yet
EuiImage,
EuiPage,
EuiPageBody,
EuiPageWidthProps,
// @ts-ignore
EuiSearchBar,
EuiSpacer,
EuiText,
EuiTitle,
} from '@elastic/eui';
import styled from 'styled-components';
import { useCore, useLinks } from '../../hooks';
export type HeaderProps = EuiPageWidthProps & {
onSearch: (userInput: string) => unknown;
};
const Page = styled(EuiPage)`
padding: 0;
`;
export function Header({ restrictWidth, onSearch }: HeaderProps) {
const [searchTerm, setSearchTerm] = useState('');
const searchBar = (
<EuiSearchBar
query={searchTerm}
key="search-input"
box={{
placeholder: 'Find a new package, or one you already use.',
incremental: true,
}}
onChange={({ queryText: userInput }: { queryText: string }) => {
setSearchTerm(userInput);
onSearch(userInput);
}}
/>
);
const left = (
<EuiFlexGroup alignItems="center" gutterSize="none">
<EuiFlexItem>
<HeroCopy />
<EuiSpacer size="xl" />
<EuiFlexGroup>
<EuiFlexItem grow={4}>{searchBar}</EuiFlexItem>
<EuiFlexItem grow={2} />
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
const right = <HeroImage />;
return (
<Page>
<EuiPageBody restrictWidth={restrictWidth}>
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={1}>{left}</EuiFlexItem>
<EuiFlexItem grow={1}>{right}</EuiFlexItem>
</EuiFlexGroup>
</EuiPageBody>
</Page>
);
}
function HeroCopy() {
const { theme } = useCore();
const Subtitle = styled(EuiText)`
color: ${theme.eui.euiColorDarkShade};
`;
return (
<Fragment>
<EuiTitle size="l">
<h1>Add Your Data</h1>
</EuiTitle>
<Subtitle>Some creative copy about packages goes here.</Subtitle>
</Fragment>
);
}
function HeroImage() {
const { toAssets } = useLinks();
const FlexGroup = styled(EuiFlexGroup)`
margin-bottom: -2px; // puts image directly on EuiHorizontalRule
`;
return (
<FlexGroup gutterSize="none" justifyContent="flexEnd">
<EuiImage
alt="Illustration of computer"
url={toAssets('illustration_kibana_getting_started@2x.png')}
/>
</FlexGroup>
);
}

View file

@ -0,0 +1,71 @@
/*
* 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 { useEffect, useRef, useState } from 'react';
import { CategorySummaryList, PackageList } from '../../../common/types';
import { getCategories, getPackages } from '../../data';
import { LocalSearch, fieldsToSearch, searchIdField } from './search_packages';
export function useCategories() {
const [categories, setCategories] = useState<CategorySummaryList>([]);
useEffect(() => {
getCategories().then(setCategories);
}, []);
return [categories, setCategories] as [typeof categories, typeof setCategories];
}
export function useCategoryPackages(selectedCategory: string) {
const [categoryPackages, setCategoryPackages] = useState<PackageList>([]);
useEffect(() => {
getPackages({ category: selectedCategory }).then(setCategoryPackages);
}, [selectedCategory]);
return [categoryPackages, setCategoryPackages] as [
typeof categoryPackages,
typeof setCategoryPackages
];
}
export function useAllPackages(selectedCategory: string, categoryPackages: PackageList) {
const [allPackages, setAllPackages] = useState<PackageList>([]);
useEffect(() => {
if (!selectedCategory) setAllPackages(categoryPackages);
}, [selectedCategory, categoryPackages]);
return [allPackages, setAllPackages] as [typeof allPackages, typeof setAllPackages];
}
export function useLocalSearch(allPackages: PackageList) {
const localSearchRef = useRef<LocalSearch | null>(null);
useEffect(() => {
if (!allPackages.length) return;
const localSearch = new LocalSearch(searchIdField);
fieldsToSearch.forEach(field => localSearch.addIndex(field));
localSearch.addDocuments(allPackages);
localSearchRef.current = localSearch;
}, [allPackages]);
return localSearchRef;
}
export function useInstalledPackages(allPackages: PackageList) {
const [installedPackages, setInstalledPackages] = useState<PackageList>([]);
useEffect(() => {
setInstalledPackages(allPackages.filter(({ status }) => status === 'installed'));
}, [allPackages]);
return [installedPackages, setInstalledPackages] as [
typeof installedPackages,
typeof setInstalledPackages
];
}

View file

@ -0,0 +1,122 @@
/*
* 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, { Fragment, useState } from 'react';
import { EuiHorizontalRule, EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui';
import styled from 'styled-components';
import { PLUGIN } from '../../../common/constants';
import { CategorySummaryItem, PackageList } from '../../../common/types';
import { PackageListGrid } from '../../components/package_list_grid';
import { useBreadcrumbs, useLinks } from '../../hooks';
import { CategoryFacets } from './category_facets';
import { Header } from './header';
import {
useCategories,
useCategoryPackages,
useAllPackages,
useLocalSearch,
useInstalledPackages,
} from './hooks';
import { SearchPackages } from './search_packages';
export const FullBleedPage = styled(EuiPage)`
padding: 0;
`;
export function Home() {
const maxContentWidth = 1200;
const { toListView } = useLinks();
useBreadcrumbs([{ text: PLUGIN.TITLE, href: toListView() }]);
const state = useHomeState();
const body = state.searchTerm ? (
<SearchPackages
searchTerm={state.searchTerm}
localSearchRef={state.localSearchRef}
allPackages={state.allPackages}
/>
) : (
<Fragment>
{state.installedPackages.length ? (
<Fragment>
<InstalledPackages list={state.installedPackages} />
<EuiHorizontalRule margin="xxl" />
</Fragment>
) : null}
<AvailablePackages {...state} />
</Fragment>
);
return (
<Fragment>
<Header restrictWidth={maxContentWidth} onSearch={state.setSearchTerm} />
<EuiHorizontalRule margin="none" />
<EuiSpacer size="xxl" />
<FullBleedPage>
<EuiPageBody restrictWidth={maxContentWidth}>{body}</EuiPageBody>
</FullBleedPage>
</Fragment>
);
}
type HomeState = ReturnType<typeof useHomeState>;
export function useHomeState() {
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [categories, setCategories] = useCategories();
const [categoryPackages, setCategoryPackages] = useCategoryPackages(selectedCategory);
const [allPackages, setAllPackages] = useAllPackages(selectedCategory, categoryPackages);
const localSearchRef = useLocalSearch(allPackages);
const [installedPackages, setInstalledPackages] = useInstalledPackages(allPackages);
return {
searchTerm,
setSearchTerm,
selectedCategory,
setSelectedCategory,
categories,
setCategories,
allPackages,
setAllPackages,
installedPackages,
localSearchRef,
setInstalledPackages,
categoryPackages,
setCategoryPackages,
};
}
function InstalledPackages({ list }: { list: PackageList }) {
const title = 'Your Packages';
return <PackageListGrid title={title} list={list} />;
}
function AvailablePackages({
allPackages,
categories,
categoryPackages,
selectedCategory,
setSelectedCategory,
}: HomeState) {
const title = 'Available Packages';
const noFilter = {
id: '',
title: 'All',
count: allPackages.length,
};
const controls = (
<CategoryFacets
categories={[noFilter, ...categories]}
selectedCategory={selectedCategory}
onCategoryChange={({ id }: CategorySummaryItem) => setSelectedCategory(id)}
/>
);
return <PackageListGrid title={title} controls={controls} list={categoryPackages} />;
}

View file

@ -0,0 +1,33 @@
/*
* 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 from 'react';
import { Search as LocalSearch } from 'js-search';
import { PackageList, PackageListItem } from '../../../common/types';
import { SearchResults } from './search_results';
export { LocalSearch };
export type SearchField = keyof PackageListItem;
export const searchIdField: SearchField = 'name';
export const fieldsToSearch: SearchField[] = ['description', 'name', 'title'];
interface SearchPackagesProps {
searchTerm: string;
localSearchRef: React.MutableRefObject<LocalSearch | null>;
allPackages: PackageList;
}
export function SearchPackages({ searchTerm, localSearchRef, allPackages }: SearchPackagesProps) {
// this means the search index hasn't been built yet.
// i.e. the intial fetch of all packages hasn't finished
if (!localSearchRef.current) return <div>Still fetching matches. Try again in a moment.</div>;
const matches = localSearchRef.current.search(searchTerm) as PackageList;
const matchingIds = matches.map(match => match[searchIdField]);
const filtered = allPackages.filter(item => matchingIds.includes(item[searchIdField]));
return <SearchResults term={searchTerm} results={filtered} />;
}

View file

@ -0,0 +1,33 @@
/*
* 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 from 'react';
import { EuiText, EuiTitle } from '@elastic/eui';
import { PackageList } from '../../../common/types';
import { PackageListGrid } from '../../components/package_list_grid';
interface SearchResultsProps {
term: string;
results: PackageList;
}
export function SearchResults({ term, results }: SearchResultsProps) {
const title = 'Search results';
return (
<PackageListGrid
title={title}
list={results}
showInstalledBadge={true}
controls={
<EuiTitle>
<EuiText>
{results.length} results for &quot;{term}&quot;
</EuiText>
</EuiTitle>
}
/>
);
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { schema, TypeOf } from '@kbn/config-schema';
import JoiNamespace from 'joi';
export const config = {
schema: schema.object({
enabled: schema.boolean(),
registryUrl: schema.string(),
}),
};
export type EPMConfigSchema = TypeOf<typeof config.schema>;
// This is needed for the legacy plugin / NP shim setup in ../index
// It has been moved here to keep the two config schemas as close together as possible.
// Once we've moved to NP, the Joi version will disappear, so we're not trying to generate
// these two config schemas from a common definition or from each other.
export const getConfigSchema = (Joi: typeof JoiNamespace) => {
const EPMConfigSchema = Joi.object({
enabled: Joi.boolean().default(true),
registryUrl: Joi.string()
.uri()
.default(),
}).default();
return EPMConfigSchema;
};
const DEFAULT_CONFIG = {
enabled: true,
registryUrl: 'http://package-registry.app.elstc.co',
};
// As of 2019, this is a Singleton because of the way JavaScript modules are specified.
// Every module that imports this file will have access to the same object.
// This is meant to be only updated from the config$ Observable's subscription
// (see the Plugin class constructor in server/plugin.ts) but this is not enforced.
let _config: EPMConfigSchema = DEFAULT_CONFIG;
export const epmConfigStore = {
updateConfig(newConfig: EPMConfigSchema) {
_config = Object.assign({}, _config, newConfig);
},
getConfig() {
return _config;
},
};

View file

@ -0,0 +1,36 @@
/*
* 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 { PLUGIN } from '../common/constants';
import { Feature } from '../../../../plugins/features/server';
export const feature: Feature = {
id: PLUGIN.ID,
name: PLUGIN.TITLE,
icon: PLUGIN.ICON,
navLinkId: PLUGIN.ID,
app: [PLUGIN.ID, 'kibana'],
catalogue: [PLUGIN.ID],
privileges: {
all: {
api: [PLUGIN.ID],
catalogue: [PLUGIN.ID],
savedObject: {
all: [],
read: [],
},
ui: ['show', 'save'],
},
read: {
api: [PLUGIN.ID],
catalogue: [PLUGIN.ID],
savedObject: {
all: [],
read: [],
},
ui: ['show'],
},
},
};

View file

@ -0,0 +1,13 @@
/*
* 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 { Plugin, EPMPluginInitializerContext } from './plugin';
// Kibana NP needs config to be exported from here, see https://github.com/elastic/kibana/pull/45299/files#r323254805
export { config } from './config';
export function plugin(initializerContext: EPMPluginInitializerContext) {
return new Plugin(initializerContext);
}

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { rewriteIngestPipeline } from './ingest_pipelines';
import { readFileSync } from 'fs';
import path from 'path';
test('a json-format pipeline with pipeline references is correctly rewritten', () => {
const inputStandard = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/real_input_standard.json'),
'utf-8'
);
const inputBeats = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/real_input_beats.json'),
'utf-8'
);
const output = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/real_output.json')
).toString('utf-8');
const substitutions = [
{
source: 'pipeline-json',
target: 'new-pipeline-json',
templateFunction: 'IngestPipeline',
},
{
source: 'pipeline-plaintext',
target: 'new-pipeline-plaintext',
templateFunction: 'IngestPipeline',
},
];
expect(rewriteIngestPipeline(inputStandard, substitutions)).toBe(output);
expect(rewriteIngestPipeline(inputBeats, substitutions)).toBe(output);
});
test('a yml-format pipeline with pipeline references is correctly rewritten', () => {
const inputStandard = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/real_input_standard.yml')
).toString('utf-8');
const inputBeats = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/real_input_beats.yml')
).toString('utf-8');
const output = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/real_output.yml')
).toString('utf-8');
const substitutions = [
{
source: 'pipeline-json',
target: 'new-pipeline-json',
templateFunction: 'IngestPipeline',
},
{
source: 'pipeline-plaintext',
target: 'new-pipeline-plaintext',
templateFunction: 'IngestPipeline',
},
];
expect(rewriteIngestPipeline(inputStandard, substitutions)).toBe(output);
expect(rewriteIngestPipeline(inputBeats, substitutions)).toBe(output);
});
test('a json-format pipeline with no pipeline references stays unchanged', () => {
const input = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/no_replacement.json')
).toString('utf-8');
const substitutions = [
{
source: 'pipeline-json',
target: 'new-pipeline-json',
templateFunction: 'IngestPipeline',
},
{
source: 'pipeline-plaintext',
target: 'new-pipeline-plaintext',
templateFunction: 'IngestPipeline',
},
];
expect(rewriteIngestPipeline(input, substitutions)).toBe(input);
});
test('a yml-format pipeline with no pipeline references stays unchanged', () => {
const input = readFileSync(
path.join(__dirname, '/tests/ingest_pipelines/no_replacement.yml')
).toString('utf-8');
const substitutions = [
{
source: 'pipeline-json',
target: 'new-pipeline-json',
templateFunction: 'IngestPipeline',
},
{
source: 'pipeline-plaintext',
target: 'new-pipeline-plaintext',
templateFunction: 'IngestPipeline',
},
];
expect(rewriteIngestPipeline(input, substitutions)).toBe(input);
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export function rewriteIngestPipeline(
pipeline: string,
substitutions: Array<{
source: string;
target: string;
templateFunction: string;
}>
): string {
substitutions.forEach(sub => {
const { source, target, templateFunction } = sub;
// This fakes the use of the golang text/template expression {{SomeTemplateFunction 'some-param'}}
// cf. https://github.com/elastic/beats/blob/master/filebeat/fileset/fileset.go#L294
// "Standard style" uses '{{' and '}}' as delimiters
const matchStandardStyle = `{{\\s?${templateFunction}\\s+['"]${source}['"]\\s?}}`;
// "Beats style" uses '{<' and '>}' as delimiters because this is current practice in the beats project
const matchBeatsStyle = `{<\\s?${templateFunction}\\s+['"]${source}['"]\\s?>}`;
const regexStandardStyle = new RegExp(matchStandardStyle);
const regexBeatsStyle = new RegExp(matchBeatsStyle);
pipeline = pipeline.replace(regexStandardStyle, target).replace(regexBeatsStyle, target);
});
return pipeline;
}

View file

@ -0,0 +1,13 @@
/*
* 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 { getTemplate } from './template';
test('get template', () => {
const pattern = 'logs-nginx-access-abcd-*';
const template = getTemplate(pattern);
expect(template.index_patterns).toStrictEqual([pattern]);
});

View file

@ -0,0 +1,110 @@
/*
* 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.
*/
/**
* getTemplate retrieves the default template but overwrites the index pattern with the given value.
*
* @param indexPattern String with the index pattern
*/
export function getTemplate(indexPattern: string): Template {
const template = getBaseTemplate();
template.index_patterns = [indexPattern];
return template;
}
export interface Template {
order: number;
index_patterns: string[];
settings: object;
mappings: object;
aliases: object;
}
function getBaseTemplate(): Template {
return {
// We need to decide which order we use for the templates
order: 1,
// To be completed with the correct index patterns
index_patterns: [],
settings: {
index: {
// ILM Policy must be added here
lifecycle: {
name: 'logs-default',
rollover_alias: 'logs-nginx-access-abcd',
},
// What should be our default for the compression?
codec: 'best_compression',
// W
mapping: {
total_fields: {
limit: '10000',
},
},
// This is the default from Beats? So far seems to be a good value
refresh_interval: '5s',
// Default in the stack now, still good to have it in
number_of_shards: '1',
// All the default fields which should be queried have to be added here.
// So far we add all keyword and text fields here.
query: {
default_field: ['message'],
},
// We are setting 30 because it can be devided by several numbers. Useful when shrinking.
number_of_routing_shards: '30',
},
},
mappings: {
// To be filled with interesting information about this specific index
_meta: {
package: 'foo',
},
// All the dynamic field mappings
dynamic_templates: [
// This makes sure all mappings are keywords by default
{
strings_as_keyword: {
mapping: {
ignore_above: 1024,
type: 'keyword',
},
match_mapping_type: 'string',
},
},
// Example of a dynamic template
{
labels: {
path_match: 'labels.*',
mapping: {
type: 'keyword',
},
match_mapping_type: 'string',
},
},
],
// As we define fields ahead, we don't need any automatic field detection
// This makes sure all the fields are mapped to keyword by default to prevent mapping conflicts
date_detection: false,
// All the properties we know from the fields.yml file
properties: {
container: {
properties: {
image: {
properties: {
name: {
ignore_above: 1024,
type: 'keyword',
},
},
},
},
},
},
},
// To be filled with the aliases that we need
aliases: {},
};
}

View file

@ -0,0 +1,101 @@
{
"description": "Pipeline for normalizing Kubernetes coredns logs",
"processors": [
{
"pipeline": {
"if": "ctx.message.charAt(0) == (char)(\"{\")",
"name": "{{IngestPipeline 'pipeline-json' }}"
}
},
{
"pipeline": {
"if": "ctx.message.charAt(0) != (char)(\"{\")",
"name": "{{IngestPipeline 'pipeline-plaintext' }}"
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');",
"ignore_failure" : true
}
},
{
"script": {
"lang": "painless",
"source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');",
"if": "ctx.temp?.source != null"
}
},
{
"set": {
"field": "source.ip",
"value": "{{source.address}}",
"if": "ctx.source?.address != null"
}
},
{
"convert" : {
"field" : "source.port",
"type": "integer"
}
},
{
"convert" : {
"field" : "coredns.duration",
"type": "double"
}
},
{
"convert" : {
"field" : "coredns.query.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.response.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.dnssec_ok",
"type": "boolean"
}
},
{
"uppercase": {
"field": "coredns.response.flags"
}
},
{
"split": {
"field": "coredns.response.flags",
"separator": ","
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)",
"params": {
"scale": 1000000000
},
"if": "ctx.coredns?.duration != null"
}
},
{
"remove": {
"field": "coredns.duration",
"ignore_missing": true
}
}
],
"on_failure" : [{
"set" : {
"field" : "error.message",
"value" : "{{ _ingest.on_failure_message }}"
}
}]
}

View file

@ -0,0 +1,49 @@
{
"description": "Pipeline for dissecting message field in JSON logs",
"processors": [
{
"json" : {
"field" : "message",
"target_field" : "json"
}
},
{
"dissect": {
"field": "json.message",
"pattern": "%{timestamp} [%{log.level}] %{temp.source} - %{coredns.id} \"%{coredns.query.type} %{coredns.query.class} %{coredns.query.name} %{network.transport} %{coredns.query.size} %{coredns.dnssec_ok} %{bufsize}\" %{coredns.response.code} %{coredns.response.flags} %{coredns.response.size} %{coredns.duration}s"
}
},
{
"remove": {
"field": ["message"],
"ignore_failure" : true
}
},
{
"rename": {
"field": "json.message",
"target_field": "message",
"ignore_failure" : true
}
},
{
"rename": {
"field": "json.kubernetes",
"target_field": "kubernetes",
"ignore_failure" : true
}
},
{
"remove": {
"field": ["json", "bufsize"],
"ignore_failure" : true
}
}
],
"on_failure" : [{
"set" : {
"field" : "error.message",
"value" : "{{ _ingest.on_failure_message }}"
}
}]
}

View file

@ -0,0 +1,51 @@
description: Pipeline for Cisco IOS logs.
processors:
# IP Geolocation Lookup
- geoip:
field: source.ip
target_field: source.geo
ignore_missing: true
- geoip:
field: destination.ip
target_field: destination.geo
ignore_missing: true
# IP Autonomous System (AS) Lookup
- geoip:
database_file: GeoLite2-ASN.mmdb
field: source.ip
target_field: source.as
properties:
- asn
- organization_name
ignore_missing: true
- geoip:
database_file: GeoLite2-ASN.mmdb
field: destination.ip
target_field: destination.as
properties:
- asn
- organization_name
ignore_missing: true
- rename:
field: source.as.asn
target_field: source.as.number
ignore_missing: true
- rename:
field: source.as.organization_name
target_field: source.as.organization.name
ignore_missing: true
- rename:
field: destination.as.asn
target_field: destination.as.number
ignore_missing: true
- rename:
field: destination.as.organization_name
target_field: destination.as.organization.name
ignore_missing: true
on_failure:
- set:
field: error.message
value: '{{ _ingest.on_failure_message }}'

View file

@ -0,0 +1,101 @@
{
"description": "Pipeline for normalizing Kubernetes coredns logs",
"processors": [
{
"pipeline": {
"if": "ctx.message.charAt(0) == (char)(\"{\")",
"name": "{<IngestPipeline 'pipeline-json' >}"
}
},
{
"pipeline": {
"if": "ctx.message.charAt(0) != (char)(\"{\")",
"name": "{<IngestPipeline 'pipeline-plaintext' >}"
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');",
"ignore_failure" : true
}
},
{
"script": {
"lang": "painless",
"source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');",
"if": "ctx.temp?.source != null"
}
},
{
"set": {
"field": "source.ip",
"value": "{{source.address}}",
"if": "ctx.source?.address != null"
}
},
{
"convert" : {
"field" : "source.port",
"type": "integer"
}
},
{
"convert" : {
"field" : "coredns.duration",
"type": "double"
}
},
{
"convert" : {
"field" : "coredns.query.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.response.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.dnssec_ok",
"type": "boolean"
}
},
{
"uppercase": {
"field": "coredns.response.flags"
}
},
{
"split": {
"field": "coredns.response.flags",
"separator": ","
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)",
"params": {
"scale": 1000000000
},
"if": "ctx.coredns?.duration != null"
}
},
{
"remove": {
"field": "coredns.duration",
"ignore_missing": true
}
}
],
"on_failure" : [{
"set" : {
"field" : "error.message",
"value" : "{{ _ingest.on_failure_message }}"
}
}]
}

View file

@ -0,0 +1,113 @@
---
description: Pipeline for normalizing Kubernetes CoreDNS logs.
processors:
- pipeline:
if: ctx.message.charAt(0) == (char)("{")
name: '{<IngestPipeline "pipeline-json" >}'
- pipeline:
if: ctx.message.charAt(0) != (char)("{")
name: '{<IngestPipeline "pipeline-plaintext" >}'
- script:
lang: painless
source: >
ctx.event.created = ctx['@timestamp'];
ctx['@timestamp'] = ctx['timestamp'];
ctx.remove('timestamp');
ignore_failure: true
- script:
lang: painless
if: ctx.temp?.source != null
source: >
ctx['source'] = new HashMap();
if (ctx.temp.source.charAt(0) == (char)("[")) {
def p = ctx.temp.source.indexOf (']');
def l = ctx.temp.source.length();
ctx.source.address = ctx.temp.source.substring(1, p);
ctx.source.port = ctx.temp.source.substring(p+2, l);
} else {
def p = ctx.temp.source.indexOf(':');
def l = ctx.temp.source.length();
ctx.source.address = ctx.temp.source.substring(0, p);
ctx.source.port = ctx.temp.source.substring(p+1, l);
}
ctx.remove('temp');
- set:
field: source.ip
value: "{{source.address}}"
if: ctx.source?.address != null
- convert:
field: source.port
type: integer
- convert:
field: coredns.duration
type: double
- convert:
field: coredns.query.size
type: long
- convert:
field: coredns.response.size
type: long
- convert:
field: coredns.dnssec_ok
type: boolean
- uppercase:
field: dns.header_flags
- split:
field: dns.header_flags
separator: ","
- append:
if: ctx.coredns?.dnssec_ok
field: dns.header_flags
value: DO
- script:
lang: painless
source: ctx.event.duration = Math.round(ctx.coredns.duration * params.scale);
params:
scale: 1000000000
if: ctx.coredns?.duration != null
- remove:
field:
- coredns.duration
ignore_missing: true
# The following copies values from dns namespace (ECS) to the coredns
# namespace to avoid introducing breaking change. This should be removed
# for 8.0.0. Additionally coredns.dnssec_ok can be removed.
- set:
if: ctx.dns?.id != null
field: coredns.id
value: '{{dns.id}}'
- set:
if: ctx.dns?.question?.class != null
field: coredns.query.class
value: '{{dns.question.class}}'
- set:
if: ctx.dns?.question?.name != null
field: coredns.query.name
value: '{{dns.question.name}}'
- set:
if: ctx.dns?.question?.type != null
field: coredns.query.type
value: '{{dns.question.type}}'
- set:
if: ctx.dns?.response_code != null
field: coredns.response.code
value: '{{dns.response_code}}'
- script:
if: ctx.dns?.header_flags != null
lang: painless
source: >
ctx.coredns.response.flags = ctx.dns.header_flags;
# Right trim the trailing dot from domain names.
- script:
if: ctx.dns?.question?.name != null
lang: painless
source: >
def q = ctx.dns.question.name;
def end = q.length() - 1;
if (q.charAt(end) == (char) '.') {
ctx.dns.question.name = q.substring(0, end);
}
on_failure:
- set:
field: error.message
value: "{{ _ingest.on_failure_message }}"

View file

@ -0,0 +1,101 @@
{
"description": "Pipeline for normalizing Kubernetes coredns logs",
"processors": [
{
"pipeline": {
"if": "ctx.message.charAt(0) == (char)(\"{\")",
"name": "{{IngestPipeline 'pipeline-json' }}"
}
},
{
"pipeline": {
"if": "ctx.message.charAt(0) != (char)(\"{\")",
"name": "{{IngestPipeline 'pipeline-plaintext' }}"
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');",
"ignore_failure" : true
}
},
{
"script": {
"lang": "painless",
"source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');",
"if": "ctx.temp?.source != null"
}
},
{
"set": {
"field": "source.ip",
"value": "{{source.address}}",
"if": "ctx.source?.address != null"
}
},
{
"convert" : {
"field" : "source.port",
"type": "integer"
}
},
{
"convert" : {
"field" : "coredns.duration",
"type": "double"
}
},
{
"convert" : {
"field" : "coredns.query.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.response.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.dnssec_ok",
"type": "boolean"
}
},
{
"uppercase": {
"field": "coredns.response.flags"
}
},
{
"split": {
"field": "coredns.response.flags",
"separator": ","
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)",
"params": {
"scale": 1000000000
},
"if": "ctx.coredns?.duration != null"
}
},
{
"remove": {
"field": "coredns.duration",
"ignore_missing": true
}
}
],
"on_failure" : [{
"set" : {
"field" : "error.message",
"value" : "{{ _ingest.on_failure_message }}"
}
}]
}

View file

@ -0,0 +1,113 @@
---
description: Pipeline for normalizing Kubernetes CoreDNS logs.
processors:
- pipeline:
if: ctx.message.charAt(0) == (char)("{")
name: '{{IngestPipeline "pipeline-json" }}'
- pipeline:
if: ctx.message.charAt(0) != (char)("{")
name: '{{IngestPipeline "pipeline-plaintext" }}'
- script:
lang: painless
source: >
ctx.event.created = ctx['@timestamp'];
ctx['@timestamp'] = ctx['timestamp'];
ctx.remove('timestamp');
ignore_failure: true
- script:
lang: painless
if: ctx.temp?.source != null
source: >
ctx['source'] = new HashMap();
if (ctx.temp.source.charAt(0) == (char)("[")) {
def p = ctx.temp.source.indexOf (']');
def l = ctx.temp.source.length();
ctx.source.address = ctx.temp.source.substring(1, p);
ctx.source.port = ctx.temp.source.substring(p+2, l);
} else {
def p = ctx.temp.source.indexOf(':');
def l = ctx.temp.source.length();
ctx.source.address = ctx.temp.source.substring(0, p);
ctx.source.port = ctx.temp.source.substring(p+1, l);
}
ctx.remove('temp');
- set:
field: source.ip
value: "{{source.address}}"
if: ctx.source?.address != null
- convert:
field: source.port
type: integer
- convert:
field: coredns.duration
type: double
- convert:
field: coredns.query.size
type: long
- convert:
field: coredns.response.size
type: long
- convert:
field: coredns.dnssec_ok
type: boolean
- uppercase:
field: dns.header_flags
- split:
field: dns.header_flags
separator: ","
- append:
if: ctx.coredns?.dnssec_ok
field: dns.header_flags
value: DO
- script:
lang: painless
source: ctx.event.duration = Math.round(ctx.coredns.duration * params.scale);
params:
scale: 1000000000
if: ctx.coredns?.duration != null
- remove:
field:
- coredns.duration
ignore_missing: true
# The following copies values from dns namespace (ECS) to the coredns
# namespace to avoid introducing breaking change. This should be removed
# for 8.0.0. Additionally coredns.dnssec_ok can be removed.
- set:
if: ctx.dns?.id != null
field: coredns.id
value: '{{dns.id}}'
- set:
if: ctx.dns?.question?.class != null
field: coredns.query.class
value: '{{dns.question.class}}'
- set:
if: ctx.dns?.question?.name != null
field: coredns.query.name
value: '{{dns.question.name}}'
- set:
if: ctx.dns?.question?.type != null
field: coredns.query.type
value: '{{dns.question.type}}'
- set:
if: ctx.dns?.response_code != null
field: coredns.response.code
value: '{{dns.response_code}}'
- script:
if: ctx.dns?.header_flags != null
lang: painless
source: >
ctx.coredns.response.flags = ctx.dns.header_flags;
# Right trim the trailing dot from domain names.
- script:
if: ctx.dns?.question?.name != null
lang: painless
source: >
def q = ctx.dns.question.name;
def end = q.length() - 1;
if (q.charAt(end) == (char) '.') {
ctx.dns.question.name = q.substring(0, end);
}
on_failure:
- set:
field: error.message
value: "{{ _ingest.on_failure_message }}"

View file

@ -0,0 +1,101 @@
{
"description": "Pipeline for normalizing Kubernetes coredns logs",
"processors": [
{
"pipeline": {
"if": "ctx.message.charAt(0) == (char)(\"{\")",
"name": "new-pipeline-json"
}
},
{
"pipeline": {
"if": "ctx.message.charAt(0) != (char)(\"{\")",
"name": "new-pipeline-plaintext"
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.created = ctx['@timestamp']; ctx['@timestamp'] = ctx['timestamp']; ctx.remove('timestamp');",
"ignore_failure" : true
}
},
{
"script": {
"lang": "painless",
"source": "ctx['source'] = new HashMap(); if (ctx.temp.source.charAt(0) == (char)(\"[\")) { def p = ctx.temp.source.indexOf (']'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(1, p); ctx.source.port = ctx.temp.source.substring(p+2, l);} else { def p = ctx.temp.source.indexOf (':'); def l = ctx.temp.source.length(); ctx.source.address = ctx.temp.source.substring(0, p); ctx.source.port = ctx.temp.source.substring(p+1, l);} ctx.remove('temp');",
"if": "ctx.temp?.source != null"
}
},
{
"set": {
"field": "source.ip",
"value": "{{source.address}}",
"if": "ctx.source?.address != null"
}
},
{
"convert" : {
"field" : "source.port",
"type": "integer"
}
},
{
"convert" : {
"field" : "coredns.duration",
"type": "double"
}
},
{
"convert" : {
"field" : "coredns.query.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.response.size",
"type": "long"
}
},
{
"convert" : {
"field" : "coredns.dnssec_ok",
"type": "boolean"
}
},
{
"uppercase": {
"field": "coredns.response.flags"
}
},
{
"split": {
"field": "coredns.response.flags",
"separator": ","
}
},
{
"script": {
"lang": "painless",
"source": "ctx.event.duration = Math.round(ctx.coredns.duration * params.scale)",
"params": {
"scale": 1000000000
},
"if": "ctx.coredns?.duration != null"
}
},
{
"remove": {
"field": "coredns.duration",
"ignore_missing": true
}
}
],
"on_failure" : [{
"set" : {
"field" : "error.message",
"value" : "{{ _ingest.on_failure_message }}"
}
}]
}

View file

@ -0,0 +1,113 @@
---
description: Pipeline for normalizing Kubernetes CoreDNS logs.
processors:
- pipeline:
if: ctx.message.charAt(0) == (char)("{")
name: 'new-pipeline-json'
- pipeline:
if: ctx.message.charAt(0) != (char)("{")
name: 'new-pipeline-plaintext'
- script:
lang: painless
source: >
ctx.event.created = ctx['@timestamp'];
ctx['@timestamp'] = ctx['timestamp'];
ctx.remove('timestamp');
ignore_failure: true
- script:
lang: painless
if: ctx.temp?.source != null
source: >
ctx['source'] = new HashMap();
if (ctx.temp.source.charAt(0) == (char)("[")) {
def p = ctx.temp.source.indexOf (']');
def l = ctx.temp.source.length();
ctx.source.address = ctx.temp.source.substring(1, p);
ctx.source.port = ctx.temp.source.substring(p+2, l);
} else {
def p = ctx.temp.source.indexOf(':');
def l = ctx.temp.source.length();
ctx.source.address = ctx.temp.source.substring(0, p);
ctx.source.port = ctx.temp.source.substring(p+1, l);
}
ctx.remove('temp');
- set:
field: source.ip
value: "{{source.address}}"
if: ctx.source?.address != null
- convert:
field: source.port
type: integer
- convert:
field: coredns.duration
type: double
- convert:
field: coredns.query.size
type: long
- convert:
field: coredns.response.size
type: long
- convert:
field: coredns.dnssec_ok
type: boolean
- uppercase:
field: dns.header_flags
- split:
field: dns.header_flags
separator: ","
- append:
if: ctx.coredns?.dnssec_ok
field: dns.header_flags
value: DO
- script:
lang: painless
source: ctx.event.duration = Math.round(ctx.coredns.duration * params.scale);
params:
scale: 1000000000
if: ctx.coredns?.duration != null
- remove:
field:
- coredns.duration
ignore_missing: true
# The following copies values from dns namespace (ECS) to the coredns
# namespace to avoid introducing breaking change. This should be removed
# for 8.0.0. Additionally coredns.dnssec_ok can be removed.
- set:
if: ctx.dns?.id != null
field: coredns.id
value: '{{dns.id}}'
- set:
if: ctx.dns?.question?.class != null
field: coredns.query.class
value: '{{dns.question.class}}'
- set:
if: ctx.dns?.question?.name != null
field: coredns.query.name
value: '{{dns.question.name}}'
- set:
if: ctx.dns?.question?.type != null
field: coredns.query.type
value: '{{dns.question.type}}'
- set:
if: ctx.dns?.response_code != null
field: coredns.response.code
value: '{{dns.response_code}}'
- script:
if: ctx.dns?.header_flags != null
lang: painless
source: >
ctx.coredns.response.flags = ctx.dns.header_flags;
# Right trim the trailing dot from domain names.
- script:
if: ctx.dns?.question?.name != null
lang: painless
source: >
def q = ctx.dns.question.name;
def end = q.length() - 1;
if (q.charAt(end) == (char) '.') {
ctx.dns.question.name = q.substring(0, end);
}
on_failure:
- set:
field: error.message
value: "{{ _ingest.on_failure_message }}"

View file

@ -0,0 +1,92 @@
/*
* 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 { SavedObjectsClientContract } from 'src/core/server/';
import { SAVED_OBJECT_TYPE } from '../../common/constants';
import { InstallationAttributes } from '../../common/types';
import * as Registry from '../registry';
import { createInstallableFrom } from './index';
export { SearchParams, fetchFile as getFile } from '../registry';
function nameAsTitle(name: string) {
return name.charAt(0).toUpperCase() + name.substr(1).toLowerCase();
}
export async function getCategories() {
return Registry.fetchCategories();
}
export async function getPackages(
options: {
savedObjectsClient: SavedObjectsClientContract;
} & Registry.SearchParams
) {
const { savedObjectsClient } = options;
const registryItems = await Registry.fetchList({ category: options.category }).then(items => {
return items.map(item =>
Object.assign({}, item, { title: item.title || nameAsTitle(item.name) })
);
});
const searchObjects = registryItems.map(({ name, version }) => ({
type: SAVED_OBJECT_TYPE,
id: `${name}-${version}`,
}));
const results = await savedObjectsClient.bulkGet<InstallationAttributes>(searchObjects);
const savedObjects = results.saved_objects.filter(o => !o.error); // ignore errors for now
const packageList = registryItems
.map(item =>
createInstallableFrom(
item,
savedObjects.find(({ id }) => id === `${item.name}-${item.version}`)
)
)
.sort(sortByName);
return packageList;
}
export async function getPackageInfo(options: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
}) {
const { savedObjectsClient, pkgkey } = options;
const [item, savedObject] = await Promise.all([
Registry.fetchInfo(pkgkey),
getInstallationObject({ savedObjectsClient, pkgkey }),
Registry.getArchiveInfo(pkgkey),
] as const);
// adding `as const` due to regression in TS 3.7.2
// see https://github.com/microsoft/TypeScript/issues/34925#issuecomment-550021453
// and https://github.com/microsoft/TypeScript/pull/33707#issuecomment-550718523
// add properties that aren't (or aren't yet) on Registry response
const updated = {
...item,
title: item.title || nameAsTitle(item.name),
assets: Registry.groupPathsByService(item.assets),
};
return createInstallableFrom(updated, savedObject);
}
export async function getInstallationObject(options: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
}) {
const { savedObjectsClient, pkgkey } = options;
return savedObjectsClient
.get<InstallationAttributes>(SAVED_OBJECT_TYPE, pkgkey)
.catch(e => undefined);
}
function sortByName(a: { name: string }, b: { name: string }) {
if (a.name > b.name) {
return 1;
} else if (a.name < b.name) {
return -1;
} else {
return 0;
}
}

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 { SavedObject, SavedObjectsBulkCreateObject } from 'src/core/server/';
import * as Registry from '../registry';
type ArchiveAsset = Pick<SavedObject, 'attributes' | 'migrationVersion' | 'references'>;
type SavedObjectToBe = Required<SavedObjectsBulkCreateObject>;
export async function getObjects(
pkgkey: string,
filter = (entry: Registry.ArchiveEntry): boolean => true
): Promise<SavedObjectToBe[]> {
// Create a Map b/c some values, especially index-patterns, are referenced multiple times
const objects: Map<string, SavedObjectToBe> = new Map();
// Get paths which match the given filter
const paths = await Registry.getArchiveInfo(pkgkey, filter);
// Get all objects which matched filter. Add them to the Map
const rootObjects = paths.map(getObject);
rootObjects.forEach(obj => objects.set(obj.id, obj));
// Each of those objects might have `references` property like [{id, type, name}]
for (const object of rootObjects) {
// For each of those objects, if they have references
for (const reference of object.references) {
// Get the referenced objects. Call same function with a new filter
const referencedObjects = await getObjects(pkgkey, (entry: Registry.ArchiveEntry) => {
// Skip anything we've already stored
if (objects.has(reference.id)) return false;
// Is the archive entry the reference we want?
const { type, file } = Registry.pathParts(entry.path);
const isType = type === reference.type;
const isJson = file === `${reference.id}.json`;
return isType && isJson;
});
// Add referenced objects to the Map
referencedObjects.forEach(ro => objects.set(ro.id, ro));
}
}
// return the array of unique objects
return Array.from(objects.values());
}
// the assets from the registry are malformed
// https://github.com/elastic/integrations-registry/issues/42
function ensureJsonValues(obj: SavedObjectToBe) {
const { attributes } = obj;
if (
attributes.kibanaSavedObjectMeta &&
typeof attributes.kibanaSavedObjectMeta.searchSourceJSON !== 'string'
) {
attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(
attributes.kibanaSavedObjectMeta.searchSourceJSON
);
}
['optionsJSON', 'panelsJSON', 'uiStateJSON', 'visState']
.filter(key => typeof attributes[key] !== 'string')
.forEach(key => (attributes[key] = JSON.stringify(attributes[key])));
return obj;
}
function getObject(key: string) {
const buffer = Registry.getAsset(key);
// cache values are buffers. convert to string / JSON
const json = buffer.toString('utf8');
// convert that to an object & address issues with the formatting of some parts
const asset: ArchiveAsset = ensureJsonValues(JSON.parse(json));
const { type, file } = Registry.pathParts(key);
const savedObject: SavedObjectToBe = {
type,
id: file.replace('.json', ''),
attributes: asset.attributes,
references: asset.references || [],
migrationVersion: asset.migrationVersion || {},
};
return savedObject;
}

View file

@ -0,0 +1,89 @@
/*
* 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 { AssetType, Request, ResponseToolkit } from '../../common/types';
import { API_ROOT } from '../../common/routes';
import { PluginContext } from '../plugin';
import { getClient } from '../saved_objects';
import {
SearchParams,
getCategories,
getClusterAccessor,
getFile,
getPackageInfo,
getPackages,
installPackage,
removeInstallation,
} from './index';
interface Extra extends ResponseToolkit {
context: PluginContext;
}
interface ListPackagesRequest extends Request {
query: Request['query'] & SearchParams;
}
interface PackageInfoRequest extends Request {
params: {
pkgkey: string;
};
}
interface InstallDeletePackageRequest extends Request {
params: {
pkgkey: string;
asset: AssetType;
};
}
export async function handleGetCategories(req: Request, extra: Extra) {
return getCategories();
}
export async function handleGetList(req: ListPackagesRequest, extra: Extra) {
const savedObjectsClient = getClient(req);
return getPackages({
savedObjectsClient,
category: req.query.category,
});
}
export async function handleGetInfo(req: PackageInfoRequest, extra: Extra) {
const { pkgkey } = req.params;
const savedObjectsClient = getClient(req);
return getPackageInfo({ savedObjectsClient, pkgkey });
}
export const handleGetFile = async (req: Request, extra: Extra) => {
if (!req.url.path) throw new Error('path is required');
const filePath = req.url.path.replace(API_ROOT, '');
const registryResponse = await getFile(filePath);
const epmResponse = extra.response(registryResponse.body);
const proxiedHeaders = ['Content-Type'];
proxiedHeaders.forEach(key => {
const value = registryResponse.headers.get(key);
if (value !== null) epmResponse.header(key, value);
});
return epmResponse;
};
export async function handleRequestInstall(req: InstallDeletePackageRequest, extra: Extra) {
const { pkgkey } = req.params;
const savedObjectsClient = getClient(req);
return installPackage({
savedObjectsClient,
pkgkey,
});
}
export async function handleRequestDelete(req: InstallDeletePackageRequest, extra: Extra) {
const { pkgkey } = req.params;
const savedObjectsClient = getClient(req);
const callCluster = getClusterAccessor(extra.context.esClient, req);
return removeInstallation({ savedObjectsClient, pkgkey, callCluster });
}

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IClusterClient, ScopedClusterClient } from 'src/core/server/';
import { AssetType, Installable, Installation, Request } from '../../common/types';
export * from './get';
export * from './install';
export * from './remove';
export * from './handlers';
export type CallESAsCurrentUser = ScopedClusterClient['callAsCurrentUser'];
export const SAVED_OBJECT_TYPES = new Set<AssetType>([
'dashboard',
'ilm-policy',
'index-pattern',
'index-template',
'ingest-pipeline',
'search',
'visualization',
]);
export function getClusterAccessor(esClient: IClusterClient, req: Request) {
return esClient.asScoped(req).callAsCurrentUser;
}
export function createInstallableFrom<T>(from: T, savedObject?: Installation): Installable<T> {
return savedObject
? {
...from,
status: 'installed',
savedObject,
}
: {
...from,
status: 'not_installed',
};
}
export function assetUsesObjects(assetType: AssetType) {
return SAVED_OBJECT_TYPES.has(assetType);
}

View file

@ -0,0 +1,111 @@
/*
* 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 { SavedObject, SavedObjectsClientContract } from 'src/core/server/';
import { SAVED_OBJECT_TYPE } from '../../common/constants';
import { AssetReference, AssetType, InstallationAttributes } from '../../common/types';
import * as Registry from '../registry';
import { getInstallationObject, getPackageInfo } from './index';
import { getObjects } from './get_objects';
export async function installPackage(options: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
}) {
const { savedObjectsClient, pkgkey } = options;
const toSave = await installAssets({
savedObjectsClient,
pkgkey,
});
if (toSave.length) {
// Save those references in the integration manager's state saved object
await saveInstallationReferences({
savedObjectsClient,
pkgkey,
toSave,
});
}
return getPackageInfo({ savedObjectsClient, pkgkey });
}
// the function which how to install each of the various asset types
// TODO: make it an exhaustive list
// e.g. switch statement with cases for each enum key returning `never` for default case
export async function installAssets(options: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
}) {
const { savedObjectsClient, pkgkey } = options;
// Only install certain Kibana assets during package installation.
// All other asset types need special handling
const typesToInstall: AssetType[] = ['visualization', 'dashboard', 'search'];
const installationPromises = typesToInstall.map(async assetType =>
installKibanaSavedObjects({ savedObjectsClient, pkgkey, assetType })
);
// installKibanaSavedObjects returns AssetReference[], so .map creates AssetReference[][]
// call .flat to flatten into one dimensional array
return Promise.all(installationPromises).then(results => results.flat());
}
export async function saveInstallationReferences(options: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
toSave: AssetReference[];
}) {
const { savedObjectsClient, pkgkey, toSave } = options;
const savedObject = await getInstallationObject({ savedObjectsClient, pkgkey });
const savedRefs = savedObject && savedObject.attributes.installed;
const mergeRefsReducer = (current: AssetReference[], pending: AssetReference) => {
const hasRef = current.find(c => c.id === pending.id && c.type === pending.type);
if (!hasRef) current.push(pending);
return current;
};
const toInstall = toSave.reduce(mergeRefsReducer, savedRefs || []);
return savedObjectsClient.create<InstallationAttributes>(
SAVED_OBJECT_TYPE,
{ installed: toInstall },
{ id: pkgkey, overwrite: true }
);
}
async function installKibanaSavedObjects({
savedObjectsClient,
pkgkey,
assetType,
}: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
assetType: AssetType;
}) {
const isSameType = ({ path }: Registry.ArchiveEntry) =>
assetType === Registry.pathParts(path).type;
const toBeSavedObjects = await getObjects(pkgkey, isSameType);
if (toBeSavedObjects.length === 0) {
return [];
} else {
const createResults = await savedObjectsClient.bulkCreate(toBeSavedObjects, {
overwrite: true,
});
const createdObjects = createResults.saved_objects;
const installed = createdObjects.map(toAssetReference);
return installed;
}
}
function toAssetReference({ id, type }: SavedObject) {
const reference: AssetReference = { id, type };
return reference;
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsClientContract } from 'src/core/server/';
import { SAVED_OBJECT_TYPE } from '../../common/constants';
import {
getInstallationObject,
assetUsesObjects,
CallESAsCurrentUser,
getPackageInfo,
} from './index';
import { AssetType } from '../../common/types';
export async function removeInstallation(options: {
savedObjectsClient: SavedObjectsClientContract;
pkgkey: string;
callCluster: CallESAsCurrentUser;
}) {
const { savedObjectsClient, pkgkey, callCluster } = options;
const installation = await getInstallationObject({ savedObjectsClient, pkgkey });
const installedObjects = (installation && installation.attributes.installed) || [];
// Delete the manager saved object with references to the asset objects
// could also update with [] or some other state
await savedObjectsClient.delete(SAVED_OBJECT_TYPE, pkgkey);
// Delete the installed assets
const deletePromises = installedObjects.map(async ({ id, type }) => {
const assetType = type as AssetType;
if (assetUsesObjects(assetType)) {
savedObjectsClient.delete(assetType, id);
} else if (assetType === 'ingest-pipeline') {
deletePipeline(callCluster, id);
}
});
await Promise.all(deletePromises);
// successful delete's in SO client return {}. return something more useful
const packageInfo = await getPackageInfo({ savedObjectsClient, pkgkey });
return packageInfo;
}
async function deletePipeline(callCluster: CallESAsCurrentUser, id: string): Promise<void> {
// '*' shouldn't ever appear here, but it still would delete all ingest pipelines
if (id && id !== '*') {
await callCluster('ingest.deletePipeline', { id });
}
}

View file

@ -0,0 +1,76 @@
/*
* 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 { Observable } from 'rxjs';
import { CoreSetup, CoreStart, IClusterClient, PluginInitializerContext } from 'src/core/server';
import { PLUGIN } from '../common/constants';
import { Server } from '../common/types';
import { EPMConfigSchema, epmConfigStore } from './config';
import { feature } from './feature';
import { fetchList } from './registry';
import { routes } from './routes';
import { PluginSetupContract } from '../../../../plugins/features/server';
export { createSetupShim } from './shim';
export type EPMPluginInitializerContext = Pick<PluginInitializerContext, 'config'>;
export interface EPMCoreSetup {
elasticsearch: CoreSetup['elasticsearch'];
hapiServer: Server;
}
export type PluginSetup = ReturnType<Plugin['setup']>;
export type PluginStart = ReturnType<Plugin['start']>;
export interface PluginContext {
esClient: IClusterClient;
}
export interface PluginsSetup {
features: PluginSetupContract;
}
export class Plugin {
public config$: Observable<EPMConfigSchema>;
constructor(initializerContext: EPMPluginInitializerContext) {
this.config$ = initializerContext.config.create<EPMConfigSchema>();
this.config$.subscribe(configValue => {
epmConfigStore.updateConfig(configValue);
});
}
public setup(core: EPMCoreSetup, plugins: PluginsSetup) {
const { elasticsearch, hapiServer } = core;
const pluginContext: PluginContext = {
esClient: elasticsearch.createClient(PLUGIN.ID),
};
// make pluginContext entries available to handlers via h.context
// https://github.com/hapijs/hapi/blob/master/API.md#route.options.bind
// aligns closely with approach proposed in handler RFC
// https://github.com/epixa/kibana/blob/rfc-handlers/rfcs/text/0003_handler_interface.md
const routesWithContext = routes.map(function injectRouteContext(route) {
// merge route.options.bind, defined or otherwise, into pluginContext
// routes can add extra values or override pluginContext values (e.g. spies, etc)
if (!route.options) route.options = {};
route.options.bind = Object.assign({}, pluginContext, route.options.bind);
return route;
});
// map routes to handlers
hapiServer.route(routesWithContext);
// register the plugin
plugins.features.registerFeature(feature);
// the JS API for other consumers
return {
getList: fetchList,
};
}
public start(core: CoreStart) {}
}

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.
*/
const cache: Map<string, Buffer> = new Map();
export const cacheGet = (key: string) => cache.get(key);
export const cacheSet = (key: string, value: Buffer) => cache.set(key, value);
export const cacheHas = (key: string) => cache.has(key);

View file

@ -0,0 +1,32 @@
/*
* 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 tar from 'tar';
import { bufferToStream, streamToBuffer } from './streams';
export interface ArchiveEntry {
path: string;
buffer?: Buffer;
}
export async function untarBuffer(
buffer: Buffer,
filter = (entry: ArchiveEntry): boolean => true,
onEntry = (entry: ArchiveEntry): void => {}
): Promise<void> {
const deflatedStream = bufferToStream(buffer);
// use tar.list vs .extract to avoid writing to disk
const inflateStream = tar.list().on('entry', (entry: tar.FileStat) => {
const path = entry.header.path || '';
if (!filter({ path })) return;
streamToBuffer(entry).then(entryBuffer => onEntry({ buffer: entryBuffer, path }));
});
return new Promise((resolve, reject) => {
inflateStream.on('end', resolve).on('error', reject);
deflatedStream.pipe(inflateStream);
});
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { pathParts } from './index';
import { AssetParts } from '../../common/types';
const testPaths = [
{
path: 'foo-1.1.0/service/type/file.yml',
assetParts: {
file: 'file.yml',
path: 'foo-1.1.0/service/type/file.yml',
pkgkey: 'foo-1.1.0',
service: 'service',
type: 'type',
},
},
{
path: 'iptables-1.0.4/kibana/visualization/683402b0-1f29-11e9-8ec4-cf5d91a864b3-ecs.json',
assetParts: {
file: '683402b0-1f29-11e9-8ec4-cf5d91a864b3-ecs.json',
path: 'iptables-1.0.4/kibana/visualization/683402b0-1f29-11e9-8ec4-cf5d91a864b3-ecs.json',
pkgkey: 'iptables-1.0.4',
service: 'kibana',
type: 'visualization',
},
},
];
test('testPathParts', () => {
for (const value of testPaths) {
expect(pathParts(value.path)).toStrictEqual(value.assetParts as AssetParts);
}
});

View file

@ -0,0 +1,134 @@
/*
* 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 { URL } from 'url';
import { Response } from 'node-fetch';
import {
AssetsGroupedByServiceByType,
AssetParts,
CategoryId,
CategorySummaryList,
RegistryList,
RegistryPackage,
} from '../../common/types';
import { cacheGet, cacheSet } from './cache';
import { ArchiveEntry, untarBuffer } from './extract';
import { fetchUrl, getResponseStream, getResponse } from './requests';
import { streamToBuffer } from './streams';
import { epmConfigStore } from '../config';
export { ArchiveEntry } from './extract';
export interface SearchParams {
category?: CategoryId;
}
export async function fetchList(params?: SearchParams): Promise<RegistryList> {
const { registryUrl } = epmConfigStore.getConfig();
const url = new URL(`${registryUrl}/search`);
if (params && params.category) {
url.searchParams.set('category', params.category);
}
return fetchUrl(url.toString()).then(JSON.parse);
}
export async function fetchInfo(key: string): Promise<RegistryPackage> {
const { registryUrl } = epmConfigStore.getConfig();
return fetchUrl(`${registryUrl}/package/${key}`).then(JSON.parse);
}
export async function fetchFile(filePath: string): Promise<Response> {
const { registryUrl } = epmConfigStore.getConfig();
return getResponse(`${registryUrl}${filePath}`);
}
export async function fetchCategories(): Promise<CategorySummaryList> {
const { registryUrl } = epmConfigStore.getConfig();
return fetchUrl(`${registryUrl}/categories`).then(JSON.parse);
}
export async function getArchiveInfo(
pkgkey: string,
filter = (entry: ArchiveEntry): boolean => true
): Promise<string[]> {
// assume .tar.gz for now. add support for .zip if/when we need it
const key = `${pkgkey}.tar.gz`;
const paths: string[] = [];
const onEntry = (entry: ArchiveEntry) => {
const { path, buffer } = entry;
const { file } = pathParts(path);
if (!file) return;
if (buffer) {
cacheSet(path, buffer);
paths.push(path);
}
};
await extract(key, filter, onEntry);
return paths;
}
export function pathParts(path: string): AssetParts {
const [pkgkey, service, type, file] = path.split('/');
const parts = { pkgkey, service, type, file, path } as AssetParts;
return parts;
}
async function extract(
key: string,
filter = (entry: ArchiveEntry): boolean => true,
onEntry: (entry: ArchiveEntry) => void
) {
const archiveBuffer = await getOrFetchArchiveBuffer(key);
return untarBuffer(archiveBuffer, filter, onEntry);
}
async function getOrFetchArchiveBuffer(key: string): Promise<Buffer> {
let buffer = cacheGet(key);
if (!buffer) {
buffer = await fetchArchiveBuffer(key);
cacheSet(key, buffer);
}
if (buffer) {
return buffer;
} else {
throw new Error(`no archive buffer for ${key}`);
}
}
async function fetchArchiveBuffer(key: string): Promise<Buffer> {
const { registryUrl } = epmConfigStore.getConfig();
return getResponseStream(`${registryUrl}/package/${key}`).then(streamToBuffer);
}
export function getAsset(key: string) {
const buffer = cacheGet(key);
if (buffer === undefined) throw new Error(`Cannot find asset ${key}`);
return buffer;
}
export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByType {
// ASK: best way, if any, to avoid `any`?
const assets = paths.reduce((map: any, path) => {
const parts = pathParts(path.replace(/^\/package\//, ''));
if (!map[parts.service]) map[parts.service] = {};
if (!map[parts.service][parts.type]) map[parts.service][parts.type] = [];
map[parts.service][parts.type].push(parts);
return map;
}, {});
return {
kibana: assets.kibana,
elasticsearch: assets.elasticsearch,
};
}

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import Boom from 'boom';
import fetch, { Response } from 'node-fetch';
import { streamToString } from './streams';
export async function getResponse(url: string): Promise<Response> {
try {
const response = await fetch(url);
if (response.ok) {
return response;
} else {
throw new Boom(response.statusText, { statusCode: response.status });
}
} catch (e) {
throw Boom.boomify(e);
}
}
export async function getResponseStream(url: string): Promise<NodeJS.ReadableStream> {
const res = await getResponse(url);
return res.body;
}
export async function fetchUrl(url: string): Promise<string> {
return getResponseStream(url).then(streamToString);
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PassThrough } from 'stream';
export function bufferToStream(buffer: Buffer): PassThrough {
const stream = new PassThrough();
stream.end(buffer);
return stream;
}
export function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
const body: string[] = [];
stream.on('data', (chunk: string) => body.push(chunk));
stream.on('end', () => resolve(body.join('')));
stream.on('error', reject);
});
}
export function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', chunk => chunks.push(Buffer.from(chunk)));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}

View file

@ -0,0 +1,49 @@
/*
* 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 { PLUGIN } from '../common/constants';
import { ServerRoute } from '../common/types';
import * as CommonRoutes from '../common/routes';
import * as Packages from './packages/handlers';
// Manager public API paths
export const routes: ServerRoute[] = [
{
method: 'GET',
path: CommonRoutes.API_CATEGORIES_PATTERN,
options: { tags: [`access:${PLUGIN.ID}`], json: { space: 2 } },
handler: Packages.handleGetCategories,
},
{
method: 'GET',
path: CommonRoutes.API_LIST_PATTERN,
options: { tags: [`access:${PLUGIN.ID}`], json: { space: 2 } },
handler: Packages.handleGetList,
},
{
method: 'GET',
path: `${CommonRoutes.API_INFO_PATTERN}/{filePath*}`,
options: { tags: [`access:${PLUGIN.ID}`] },
handler: Packages.handleGetFile,
},
{
method: 'GET',
path: CommonRoutes.API_INFO_PATTERN,
options: { tags: [`access:${PLUGIN.ID}`], json: { space: 2 } },
handler: Packages.handleGetInfo,
},
{
method: 'GET',
path: CommonRoutes.API_INSTALL_PATTERN,
options: { tags: [`access:${PLUGIN.ID}`], json: { space: 2 } },
handler: Packages.handleRequestInstall,
},
{
method: 'GET',
path: CommonRoutes.API_DELETE_PATTERN,
options: { tags: [`access:${PLUGIN.ID}`], json: { space: 2 } },
handler: Packages.handleRequestDelete,
},
];

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SAVED_OBJECT_TYPE } from '../common/constants';
import { Request } from '../common/types';
export const getClient = (req: Request) => req.getSavedObjectsClient();
export const mappings = {
[SAVED_OBJECT_TYPE]: {
properties: {
installed: {
type: 'nested',
properties: {
id: { type: 'keyword' },
type: { type: 'keyword' },
},
},
},
},
};
export const savedObjectSchemas = {
[SAVED_OBJECT_TYPE]: {
isNamespaceAgnostic: true,
},
};

View file

@ -0,0 +1,42 @@
/*
* 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 { Legacy } from 'kibana';
import { BehaviorSubject } from 'rxjs';
import { PLUGIN } from '../common/constants';
import { EPMCoreSetup, EPMPluginInitializerContext, PluginsSetup } from './plugin';
// yes, any. See https://github.com/elastic/kibana/blob/master/x-pack/legacy/plugins/infra/server/lib/adapters/configuration/kibana_configuration_adapter.ts#L49-L58
// for a way around it, but this is Legacy Platform and I'm not sure these hoops are worth jumping through.
export const createSetupShim = (server: any) => {
const newPlatform: Legacy.Server['newPlatform'] = server.newPlatform;
const npSetup = newPlatform.setup;
const getConfig$ = () =>
new BehaviorSubject(server.config().get(PLUGIN.CONFIG_PREFIX)).asObservable();
const initializerContext: EPMPluginInitializerContext = {
config: {
create: getConfig$,
createIfExists: getConfig$,
},
};
const coreSetup: EPMCoreSetup = {
elasticsearch: npSetup.core.elasticsearch,
hapiServer: newPlatform.__internals.hapiServer,
};
const pluginsSetup = {
// @ts-ignore: New Platform not typed
features: npSetup.plugins.features as PluginsSetup['features'],
};
return {
initializerContext,
coreSetup,
pluginsSetup,
};
};

View file

@ -69,6 +69,7 @@
"@types/history": "^4.7.3",
"@types/jest": "^24.0.18",
"@types/joi": "^13.4.2",
"@types/js-search": "^1.4.0",
"@types/js-yaml": "^3.11.1",
"@types/jsdom": "^12.2.4",
"@types/json-stable-stringify": "^1.0.32",
@ -264,6 +265,7 @@
"isbinaryfile": "4.0.2",
"joi": "^13.5.2",
"jquery": "^3.4.1",
"js-search": "^1.4.3",
"js-yaml": "3.13.1",
"json-stable-stringify": "^1.0.1",
"jsonwebtoken": "^8.5.1",

View file

@ -38,4 +38,5 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/ui_capabilities/security_only/config'),
require.resolve('../test/ui_capabilities/spaces_only/config'),
require.resolve('../test/upgrade_assistant_integration/config'),
require.resolve('../test/epm_api_integration/config'),
]);

View file

@ -112,6 +112,7 @@ export default function({ getService }: FtrProviderContext) {
'apm',
'canvas',
'infrastructure',
'epm',
'logs',
'maps',
'uptime',

View file

@ -38,6 +38,7 @@ export default function({ getService }: FtrProviderContext) {
apm: ['all', 'read'],
siem: ['all', 'read'],
fleet: ['all', 'read'],
epm: ['all', 'read'],
},
global: ['all', 'read'],
space: ['all', 'read'],

View file

@ -0,0 +1,147 @@
/*
* 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 ServerMock from 'mock-http-server';
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
export default function({ getService }: FtrProviderContext) {
describe('package file', () => {
const server = new ServerMock({ host: 'localhost', port: 6666 });
beforeEach(() => {
server.start(() => {});
});
afterEach(() => {
server.stop(() => {});
});
it('fetches a .png screenshot image', async () => {
server.on({
method: 'GET',
path: '/package/auditd-2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png',
reply: {
headers: { 'content-type': 'image/png' },
},
});
const supertest = getService('supertest');
await supertest
.get('/api/epm/package/auditd-2.0.4/img/screenshots/auditbeat-file-integrity-dashboard.png')
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'image/png')
.expect(200);
});
it('fetches an .svg icon image', async () => {
server.on({
method: 'GET',
path: '/package/auditd-2.0.4/img/icon.svg',
reply: {
headers: { 'content-type': 'image/svg' },
},
});
const supertest = getService('supertest');
await supertest
.get('/api/epm/package/auditd-2.0.4/img/icon.svg')
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'image/svg');
});
it('fetches an auditbeat .conf rule file', async () => {
server.on({
method: 'GET',
path: '/package/auditd-2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf',
});
const supertest = getService('supertest');
await supertest
.get('/api/epm/package/auditd-2.0.4/auditbeat/rules/sample-rules-linux-32bit.conf')
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200);
});
it('fetches an auditbeat .yml config file', async () => {
server.on({
method: 'GET',
path: '/package/auditd-2.0.4/auditbeat/config/config.yml',
reply: {
headers: { 'content-type': 'text/yaml; charset=UTF-8' },
},
});
const supertest = getService('supertest');
await supertest
.get('/api/epm/package/auditd-2.0.4/auditbeat/config/config.yml')
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'text/yaml; charset=UTF-8')
.expect(200);
});
it('fetches a .json kibana visualization file', async () => {
server.on({
method: 'GET',
path:
'/package/auditd-2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json',
});
const supertest = getService('supertest');
await supertest
.get(
'/api/epm/package/auditd-2.0.4/kibana/visualization/b21e0c70-c252-11e7-8692-232bd1143e8a-ecs.json'
)
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200);
});
it('fetches a .json kibana dashboard file', async () => {
server.on({
method: 'GET',
path:
'/package/auditd-2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json',
});
const supertest = getService('supertest');
await supertest
.get(
'/api/epm/package/auditd-2.0.4/kibana/dashboard/7de391b0-c1ca-11e7-8995-936807a28b16-ecs.json'
)
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200);
});
it('fetches an .json index pattern file', async () => {
server.on({
method: 'GET',
path: '/package/auditd-2.0.4/kibana/index-pattern/auditbeat-*.json',
});
const supertest = getService('supertest');
await supertest
.get('/api/epm/package/auditd-2.0.4/kibana/index-pattern/auditbeat-*.json')
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200);
});
it('fetches a .json search file', async () => {
server.on({
method: 'GET',
path: '/package/auditd-2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json',
});
const supertest = getService('supertest');
await supertest
.get(
'/api/epm/package/auditd-2.0.4/kibana/search/0f10c430-c1c3-11e7-8995-936807a28b16-ecs.json'
)
.set('kbn-xsrf', 'xxx')
.expect('Content-Type', 'application/json; charset=utf-8')
.expect(200);
});
});
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export default function ({ loadTestFile }) {
describe('EPM Endpoints', function () {
this.tags('ciGroup7');
loadTestFile(require.resolve('./list'));
loadTestFile(require.resolve('./file'));
loadTestFile(require.resolve('./template'));
});
}

View file

@ -0,0 +1,125 @@
/*
* 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 expect from '@kbn/expect';
import ServerMock from 'mock-http-server';
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
export default function({ getService }: FtrProviderContext) {
describe('list', () => {
const server = new ServerMock({ host: 'localhost', port: 6666 });
beforeEach(() => {
server.start(() => {});
});
afterEach(() => {
server.stop(() => {});
});
it('lists all packages from the registry', async () => {
const searchResponse = [
{
description: 'First integration package',
download: '/package/first-1.0.1.tar.gz',
name: 'first',
title: 'First',
type: 'integration',
version: '1.0.1',
},
{
description: 'Second integration package',
download: '/package/second-2.0.4.tar.gz',
icons: [
{
src: '/package/second-2.0.4/img/icon.svg',
type: 'image/svg+xml',
},
],
name: 'second',
title: 'Second',
type: 'integration',
version: '2.0.4',
},
];
server.on({
method: 'GET',
path: '/search',
reply: {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(searchResponse),
},
});
const supertest = getService('supertest');
const fetchPackageList = async () => {
const response = await supertest
.get('/api/epm/list')
.set('kbn-xsrf', 'xxx')
.expect(200);
return response.body;
};
const listResponse = await fetchPackageList();
expect(listResponse.length).to.be(2);
expect(listResponse[0]).to.eql({ ...searchResponse[0], status: 'not_installed' });
expect(listResponse[1]).to.eql({ ...searchResponse[1], status: 'not_installed' });
});
it('sorts the packages even if the registry sends them unsorted', async () => {
const searchResponse = [
{
description: 'BBB integration package',
download: '/package/bbb-1.0.1.tar.gz',
name: 'bbb',
title: 'BBB',
type: 'integration',
version: '1.0.1',
},
{
description: 'CCC integration package',
download: '/package/ccc-2.0.4.tar.gz',
name: 'ccc',
title: 'CCC',
type: 'integration',
version: '2.0.4',
},
{
description: 'AAA integration package',
download: '/package/aaa-0.0.1.tar.gz',
name: 'aaa',
title: 'AAA',
type: 'integration',
version: '0.0.1',
},
];
server.on({
method: 'GET',
path: '/search',
reply: {
status: 200,
headers: { 'content-type': 'application/json' },
body: JSON.stringify(searchResponse),
},
});
const supertest = getService('supertest');
const fetchPackageList = async () => {
const response = await supertest
.get('/api/epm/list')
.set('kbn-xsrf', 'xxx')
.expect(200);
return response.body;
};
const listResponse = await fetchPackageList();
expect(listResponse.length).to.be(3);
expect(listResponse[0].name).to.eql('aaa');
expect(listResponse[1].name).to.eql('bbb');
expect(listResponse[2].name).to.eql('ccc');
});
});
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
// No types for mock-http-server available, but we don't need them.
declare module 'mock-http-server';

View file

@ -0,0 +1,29 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../api_integration/ftr_provider_context';
import { getTemplate } from '../../../legacy/plugins/integrations_manager/server/lib/template/template';
export default function({ getService }: FtrProviderContext) {
// This test was inspired by https://github.com/elastic/kibana/blob/master/x-pack/test/api_integration/apis/monitoring/common/mappings_exist.js
describe('load template', async () => {
const indexPattern = 'foo';
const templateName = 'bar';
const es = getService('es');
const body = getTemplate(indexPattern);
const response = await es.indices.putTemplate({
name: templateName,
body,
});
expect(response).to.eql({ acknowledged: true });
const indexTemplate = await es.indices.getTemplate({ name: templateName });
expect(indexTemplate[templateName].index_patterns).to.eql([indexPattern]);
});
}

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
export default async function({ readConfigFile }: FtrConfigProviderContext) {
const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js'));
return {
testFiles: [require.resolve('./apis')],
servers: xPackAPITestsConfig.get('servers'),
services: {
supertest: xPackAPITestsConfig.get('services.supertest'),
es: xPackAPITestsConfig.get('services.es'),
},
junit: {
reportName: 'X-Pack EPM API Integration Tests',
},
esTestCluster: {
...xPackAPITestsConfig.get('esTestCluster'),
},
kbnTestServer: {
...xPackAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
'--xpack.epm.registryUrl=http://localhost:6666',
],
},
};
}

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