[streams][content pack] archive format and portable dashboards (#217288)

## Summary

Allows one to export and import content packs in archive format. The
format follows the integration content package's format so it becomes
possible to import existing integration packages.

Content packs only support dashboard assets at the moment.
A pattern replacement logic has been implemented for dashboards and
referenced data views:
- at export time, any pattern matching the source stream will be
replaced with a placeholder. Other patterns will remain as-is unless
user explicitly ask to replace them
- at import time, the placeholders are replaced with the target stream
pattern

For example, if a dashboard is first exported from stream `logs.nodejs`
and reads data from patterns `logs.nodejs` and `logs.nodejs.prod`, the
patterns will be updated to `logs.ruby` and `logs.ruby.prod` when
imported into `logs.ruby` stream.

The relevant UI components are hidden behind a feature flag, set the
following in `kibana.dev.yml` to enable them:
`feature_flags.overrides.featureFlagsStreams.contentPackUIEnabled: true`



https://github.com/user-attachments/assets/9fb07daf-9fb9-4c62-9f5b-387e1833eaf0

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: tommyers-elastic <106530686+tommyers-elastic@users.noreply.github.com>
This commit is contained in:
Kevin Lacabane 2025-04-22 10:27:52 +02:00 committed by GitHub
parent b936b4719e
commit 6a569398ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2045 additions and 159 deletions

1
.github/CODEOWNERS vendored
View file

@ -820,6 +820,7 @@ x-pack/platform/packages/shared/kbn-ai-assistant @elastic/search-kibana @elastic
x-pack/platform/packages/shared/kbn-alerting-comparators @elastic/response-ops
x-pack/platform/packages/shared/kbn-apm-types @elastic/obs-ux-infra_services-team
x-pack/platform/packages/shared/kbn-cloud-security-posture/common @elastic/kibana-cloud-security-posture
x-pack/platform/packages/shared/kbn-content-packs-schema @elastic/streams-program-team
x-pack/platform/packages/shared/kbn-data-forge @elastic/obs-ux-management-team
x-pack/platform/packages/shared/kbn-elastic-assistant @elastic/security-generative-ai
x-pack/platform/packages/shared/kbn-elastic-assistant-common @elastic/security-generative-ai

View file

@ -53795,22 +53795,74 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"additionalProperties": false,
"properties": {},
"type": "object"
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
},
{
"enum": [
"null"
],
"nullable": true
"include": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"objects": {
"additionalProperties": false,
"properties": {
"dashboards": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"dashboards"
],
"type": "object"
}
},
"required": [
"objects"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"all": {
"additionalProperties": false,
"properties": {},
"type": "object"
}
},
"required": [
"all"
],
"type": "object"
}
]
},
{
"not": {}
"name": {
"type": "string"
},
"replaced_patterns": {
"items": {
"type": "string"
},
"type": "array"
},
"version": {
"type": "string"
}
]
},
"required": [
"name",
"description",
"version",
"replaced_patterns",
"include"
],
"type": "object"
}
}
}
@ -53852,9 +53904,13 @@
"schema": {
"additionalProperties": false,
"properties": {
"content": {}
"content": {},
"include": {
"type": "string"
}
},
"required": [
"include",
"content"
],
"type": "object"

View file

@ -53386,22 +53386,74 @@
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"additionalProperties": false,
"properties": {},
"type": "object"
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
},
{
"enum": [
"null"
],
"nullable": true
"include": {
"anyOf": [
{
"additionalProperties": false,
"properties": {
"objects": {
"additionalProperties": false,
"properties": {
"dashboards": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"dashboards"
],
"type": "object"
}
},
"required": [
"objects"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"all": {
"additionalProperties": false,
"properties": {},
"type": "object"
}
},
"required": [
"all"
],
"type": "object"
}
]
},
{
"not": {}
"name": {
"type": "string"
},
"replaced_patterns": {
"items": {
"type": "string"
},
"type": "array"
},
"version": {
"type": "string"
}
]
},
"required": [
"name",
"description",
"version",
"replaced_patterns",
"include"
],
"type": "object"
}
}
}
@ -53443,9 +53495,13 @@
"schema": {
"additionalProperties": false,
"properties": {
"content": {}
"content": {},
"include": {
"type": "string"
}
},
"required": [
"include",
"content"
],
"type": "object"

View file

@ -48355,14 +48355,51 @@ paths:
content:
application/json:
schema:
anyOf:
- additionalProperties: false
type: object
properties: {}
- enum:
- 'null'
nullable: true
- not: {}
additionalProperties: false
type: object
properties:
description:
type: string
include:
anyOf:
- additionalProperties: false
type: object
properties:
objects:
additionalProperties: false
type: object
properties:
dashboards:
items:
type: string
type: array
required:
- dashboards
required:
- objects
- additionalProperties: false
type: object
properties:
all:
additionalProperties: false
type: object
properties: {}
required:
- all
name:
type: string
replaced_patterns:
items:
type: string
type: array
version:
type: string
required:
- name
- description
- version
- replaced_patterns
- include
responses: {}
summary: Export stream content
tags:
@ -48392,7 +48429,10 @@ paths:
type: object
properties:
content: {}
include:
type: string
required:
- include
- content
responses: {}
summary: Import content into a stream

View file

@ -51877,14 +51877,51 @@ paths:
content:
application/json:
schema:
anyOf:
- additionalProperties: false
type: object
properties: {}
- enum:
- 'null'
nullable: true
- not: {}
additionalProperties: false
type: object
properties:
description:
type: string
include:
anyOf:
- additionalProperties: false
type: object
properties:
objects:
additionalProperties: false
type: object
properties:
dashboards:
items:
type: string
type: array
required:
- dashboards
required:
- objects
- additionalProperties: false
type: object
properties:
all:
additionalProperties: false
type: object
properties: {}
required:
- all
name:
type: string
replaced_patterns:
items:
type: string
type: array
version:
type: string
required:
- name
- description
- version
- replaced_patterns
- include
responses: {}
summary: Export stream content
tags:
@ -51914,7 +51951,10 @@ paths:
type: object
properties:
content: {}
include:
type: string
required:
- include
- content
responses: {}
summary: Import content into a stream

View file

@ -253,6 +253,7 @@
"@kbn/content-management-table-list-view-table": "link:src/platform/packages/shared/content-management/table_list_view_table",
"@kbn/content-management-user-profiles": "link:src/platform/packages/shared/content-management/user_profiles",
"@kbn/content-management-utils": "link:src/platform/packages/shared/kbn-content-management-utils",
"@kbn/content-packs-schema": "link:x-pack/platform/packages/shared/kbn-content-packs-schema",
"@kbn/controls-example-plugin": "link:examples/controls_example",
"@kbn/controls-plugin": "link:src/platform/plugins/shared/controls",
"@kbn/core": "link:src/core",

View file

@ -40,6 +40,7 @@ export {
getValuesFromQueryField,
getESQLQueryVariables,
fixESQLQueryWithVariables,
replaceESQLQueryIndexPattern,
} from './src';
export { ENABLE_ESQL, FEEDBACK_LINK } from './constants';

View file

@ -46,3 +46,4 @@ export {
isESQLFieldGroupable,
} from './utils/esql_fields_utils';
export { sanitazeESQLInput } from './utils/sanitaze_input';
export { replaceESQLQueryIndexPattern } from './utils/replace_index_pattern';

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
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { replaceESQLQueryIndexPattern } from './replace_index_pattern';
describe('replaceESQLQueryIndexPattern', () => {
it('replaces index pattern', () => {
const query = replaceESQLQueryIndexPattern('FROM one, two | STATS COUNT(*) BY host.name', {
two: 'updated',
});
expect(query).toEqual('FROM one, updated | STATS COUNT(*) BY host.name');
});
it('replaces duplicates pattern', () => {
const query = replaceESQLQueryIndexPattern('FROM one, one | STATS COUNT(*) BY host.name', {
one: 'updated',
});
expect(query).toEqual('FROM updated | STATS COUNT(*) BY host.name');
});
it('replaces remote index pattern', () => {
const query = replaceESQLQueryIndexPattern(
'FROM remote:one, remote_two:one | STATS COUNT(*) BY host.name',
{
'remote:one': 'remote_updated:one',
}
);
expect(query).toEqual('FROM remote_two:one, remote_updated:one | STATS COUNT(*) BY host.name');
});
it('replaces remote index patterns if only the index matches', () => {
const query = replaceESQLQueryIndexPattern(
'FROM remote_one:one, remote_two:one | STATS COUNT(*) BY host.name',
{
one: 'remote_three:one',
}
);
expect(query).toEqual('FROM remote_three:one | STATS COUNT(*) BY host.name');
});
it('is a noop if no matching replacements', () => {
const query = replaceESQLQueryIndexPattern('FROM one, two | STATS COUNT(*) BY host.name', {
three: 'updated',
});
expect(query).toEqual('FROM one, two | STATS COUNT(*) BY host.name');
});
});

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { EsqlQuery, mutate } from '@kbn/esql-ast';
export function replaceESQLQueryIndexPattern(esql: string, replacements: Record<string, string>) {
const inputQuery = EsqlQuery.fromSrc(esql);
const outputQuery = EsqlQuery.fromSrc(esql);
for (const [source, target] of Object.entries(replacements)) {
const { index: sourceIndex, cluster: sourceCluster } = parseIndex(source);
const { index: targetIndex, cluster: targetCluster } = parseIndex(target);
while (mutate.commands.from.sources.remove(inputQuery.ast, sourceIndex, sourceCluster)) {
mutate.commands.from.sources.remove(outputQuery.ast, sourceIndex, sourceCluster);
mutate.commands.from.sources.upsert(outputQuery.ast, targetIndex, targetCluster);
}
}
return outputQuery.print();
}
function parseIndex(index: string): { index: string; cluster?: string } {
const split = index.split(':');
if (split.length === 2) {
return { index: split[1], cluster: split[0] };
}
return { index };
}

View file

@ -0,0 +1,62 @@
{
"attributes": {
"allowHidden": false,
"fieldAttrs": "{}",
"fieldFormatMap": "{}",
"fields": "[]",
"name": "logs",
"runtimeFieldMap": "{}",
"sourceFilters": "[]",
"timeFieldName": "@timestamp",
"title": "logs"
},
"coreMigrationVersion": "8.8.0",
"created_at": "2025-04-09T17:56:37.310Z",
"created_by": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0",
"id": "a7e3b27d-c804-474a-b3cd-141b9b07fd04",
"managed": false,
"references": [],
"type": "index-pattern",
"typeMigrationVersion": "8.0.0",
"updated_at": "2025-04-09T17:56:37.310Z",
"updated_by": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0",
"version": "WzExLDFd"
}
{
"attributes": {
"controlGroupInput": {
"chainingSystem": "HIERARCHICAL",
"controlStyle": "oneLine",
"ignoreParentSettingsJSON": "{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}",
"panelsJSON": "{}",
"showApplySelections": false
},
"description": "",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"}}"
},
"optionsJSON": "{\"useMargins\":true,\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}",
"panelsJSON": "[{\"type\":\"lens\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"attributes\":{\"title\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"a7e3b27d-c804-474a-b3cd-141b9b07fd04\",\"name\":\"indexpattern-datasource-layer-83bebabf-6399-45c3-962b-2b33aaea84fd\"}],\"state\":{\"visualization\":{\"title\":\"Empty XY chart\",\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"83bebabf-6399-45c3-962b-2b33aaea84fd\",\"accessors\":[\"59fe648e-fc10-4301-92ec-c8f0db37b9cd\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"colorMapping\":{\"assignments\":[],\"specialAssignments\":[{\"rule\":{\"type\":\"other\"},\"color\":{\"type\":\"loop\"},\"touched\":false}],\"paletteId\":\"default\",\"colorMode\":{\"type\":\"categorical\"}},\"xAccessor\":\"111134dc-9b12-4506-a22a-3c214a61101c\"}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"83bebabf-6399-45c3-962b-2b33aaea84fd\":{\"columns\":{\"111134dc-9b12-4506-a22a-3c214a61101c\":{\"label\":\"@timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"@timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"59fe648e-fc10-4301-92ec-c8f0db37b9cd\":{\"label\":\"Count of records\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true}}},\"columnOrder\":[\"111134dc-9b12-4506-a22a-3c214a61101c\",\"59fe648e-fc10-4301-92ec-c8f0db37b9cd\"],\"sampling\":1,\"ignoreGlobalFilters\":false,\"incompleteColumns\":{}}}},\"indexpattern\":{\"layers\":{}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}}},\"panelIndex\":\"1b439b9a-c7b9-4e0a-a44f-3a7d1c19dec0\",\"gridData\":{\"x\":0,\"y\":0,\"w\":24,\"h\":15,\"i\":\"1b439b9a-c7b9-4e0a-a44f-3a7d1c19dec0\"}},{\"type\":\"lens\",\"embeddableConfig\":{\"enhancements\":{\"dynamicActions\":{\"events\":[]}},\"syncColors\":false,\"syncCursor\":true,\"syncTooltips\":false,\"filters\":[],\"query\":{\"esql\":\"FROM logs | LIMIT 10\"},\"attributes\":{\"title\":\"Bar vertical stacked\",\"references\":[],\"state\":{\"datasourceStates\":{\"textBased\":{\"layers\":{\"4ea455e2-6c30-4a4b-8789-7d6bb25ede5f\":{\"index\":\"07aa17244c8e39c6db4cf18d211fde421969410cf83140590f66f41f734daea5\",\"query\":{\"esql\":\"FROM logs | STATS count=count(*) by message | sort count desc | LIMIT 10\"},\"columns\":[{\"columnId\":\"count\",\"fieldName\":\"count\",\"label\":\"count\",\"customLabel\":false,\"meta\":{\"type\":\"number\",\"esType\":\"long\"},\"inMetricDimension\":true},{\"columnId\":\"message\",\"fieldName\":\"message\",\"label\":\"message\",\"customLabel\":false,\"meta\":{\"type\":\"string\",\"esType\":\"text\"}}],\"timeField\":\"@timestamp\"}},\"indexPatternRefs\":[{\"id\":\"07aa17244c8e39c6db4cf18d211fde421969410cf83140590f66f41f734daea5\",\"title\":\"logs\",\"timeField\":\"@timestamp\"}]}},\"filters\":[],\"query\":{\"esql\":\"FROM logs | STATS count=count(*) by message | sort count desc | LIMIT 10\"},\"visualization\":{\"layerId\":\"4ea455e2-6c30-4a4b-8789-7d6bb25ede5f\",\"layerType\":\"data\",\"columns\":[{\"columnId\":\"count\"},{\"columnId\":\"message\"}]},\"adHocDataViews\":{\"07aa17244c8e39c6db4cf18d211fde421969410cf83140590f66f41f734daea5\":{\"id\":\"07aa17244c8e39c6db4cf18d211fde421969410cf83140590f66f41f734daea5\",\"title\":\"logs\",\"timeFieldName\":\"@timestamp\",\"sourceFilters\":[],\"type\":\"esql\",\"fieldFormats\":{},\"runtimeFieldMap\":{},\"allowNoIndex\":false,\"name\":\"logs\",\"allowHidden\":false}},\"needsRefresh\":false},\"visualizationType\":\"lnsDatatable\"}},\"panelIndex\":\"5dcf8705-378a-4b1d-a9da-9b2025cdf7fe\",\"gridData\":{\"x\":24,\"y\":0,\"w\":24,\"h\":15,\"i\":\"5dcf8705-378a-4b1d-a9da-9b2025cdf7fe\"}}]",
"timeRestore": false,
"title": "Logs count and top 10 messages",
"version": 3
},
"coreMigrationVersion": "8.8.0",
"created_at": "2025-04-09T17:58:56.268Z",
"created_by": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0",
"id": "c22ba8ed-fd4b-4864-a98c-3cba1d11cfb2",
"managed": false,
"references": [
{
"id": "a7e3b27d-c804-474a-b3cd-141b9b07fd04",
"name": "1b439b9a-c7b9-4e0a-a44f-3a7d1c19dec0:indexpattern-datasource-layer-83bebabf-6399-45c3-962b-2b33aaea84fd",
"type": "index-pattern"
}
],
"type": "dashboard",
"typeMigrationVersion": "10.2.0",
"updated_at": "2025-04-09T17:58:56.268Z",
"updated_by": "u_mGBROF_q5bmFCATbLXAcCwKa0k8JvONAwSruelyKA5E_0",
"version": "WzE1LDFd"
}

View file

@ -234,6 +234,8 @@
"@kbn/content-management-user-profiles/*": ["src/platform/packages/shared/content-management/user_profiles/*"],
"@kbn/content-management-utils": ["src/platform/packages/shared/kbn-content-management-utils"],
"@kbn/content-management-utils/*": ["src/platform/packages/shared/kbn-content-management-utils/*"],
"@kbn/content-packs-schema": ["x-pack/platform/packages/shared/kbn-content-packs-schema"],
"@kbn/content-packs-schema/*": ["x-pack/platform/packages/shared/kbn-content-packs-schema/*"],
"@kbn/controls-example-plugin": ["examples/controls_example"],
"@kbn/controls-example-plugin/*": ["examples/controls_example/*"],
"@kbn/controls-plugin": ["src/platform/plugins/shared/controls"],

View file

@ -5,14 +5,5 @@
* 2.0.
*/
import { z } from '@kbn/zod';
interface ContentPack {
content: string;
}
const contentPackSchema: z.Schema<ContentPack> = z.object({
content: z.string(),
});
export { contentPackSchema, type ContentPack };
export * from './src/helpers';
export * from './src/models';

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../../..',
roots: ['<rootDir>/x-pack/platform/packages/shared/kbn-content-packs-schema'],
};

View file

@ -0,0 +1,7 @@
{
"type": "shared-common",
"id": "@kbn/content-packs-schema",
"owner": "@elastic/streams-program-team",
"group": "platform",
"visibility": "shared"
}

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
DashboardAttributes,
SavedDashboardPanel,
} from '@kbn/dashboard-plugin/common/content_management/v2';
import { cloneDeep, mapValues, uniq } from 'lodash';
import { AggregateQuery, Query } from '@kbn/es-query';
import { getIndexPatternFromESQLQuery, replaceESQLQueryIndexPattern } from '@kbn/esql-utils';
import type { LensAttributes } from '@kbn/lens-embeddable-utils';
import type { IndexPatternRef } from '@kbn/lens-plugin/public/types';
import type { ContentPackSavedObject } from '../models';
export const INDEX_PLACEHOLDER = '<stream_name_placeholder>';
export const isIndexPlaceholder = (index: string) => index.startsWith(INDEX_PLACEHOLDER);
interface TraverseOptions {
esqlQuery(query: string): string;
indexPattern<T extends { name?: string; title?: string }>(pattern: T): T;
}
export function findIndexPatterns(savedObject: ContentPackSavedObject) {
const patterns: string[] = [];
locateIndexPatterns(savedObject, {
esqlQuery(query: string) {
patterns.push(...getIndexPatternFromESQLQuery(query).split(','));
return query;
},
indexPattern<T extends { title?: string }>(pattern: T) {
if (pattern.title) {
patterns.push(...pattern.title.split(','));
}
return pattern;
},
});
return uniq(patterns);
}
export function replaceIndexPatterns(
savedObject: ContentPackSavedObject,
replacements: Record<string, string>
) {
return locateIndexPatterns(cloneDeep(savedObject), {
esqlQuery(query: string) {
return replaceESQLQueryIndexPattern(query, replacements);
},
indexPattern<T extends { name?: string; title?: string }>(pattern: T) {
const updatedPattern = pattern.title
?.split(',')
.map((index) => replacements[index] ?? index)
.join(',');
return {
...pattern,
name: updatedPattern,
title: updatedPattern,
};
},
});
}
function locateIndexPatterns(
object: ContentPackSavedObject,
options: TraverseOptions
): ContentPackSavedObject {
const content = object;
if (content.type === 'index-pattern') {
content.attributes = options.indexPattern(content.attributes);
}
if (content.type === 'dashboard') {
const attributes = content.attributes as DashboardAttributes;
const panels = (JSON.parse(attributes.panelsJSON) as SavedDashboardPanel[]).map((panel) =>
traversePanel(panel, options)
);
attributes.panelsJSON = JSON.stringify(panels);
}
return object;
}
function traversePanel(panel: SavedDashboardPanel, options: TraverseOptions) {
if (panel.type === 'lens') {
const config = panel.embeddableConfig as {
query?: Query | AggregateQuery;
attributes?: LensAttributes;
};
if (config.query && 'esql' in config.query) {
config.query.esql = options.esqlQuery(config.query.esql);
}
if (config.attributes) {
traverseLensPanel(config.attributes as LensAttributes, options);
}
}
return panel;
}
function traverseLensPanel(panel: LensAttributes, options: TraverseOptions) {
const state = panel.state;
if (state.adHocDataViews) {
state.adHocDataViews = mapValues(state.adHocDataViews, (dataView) =>
options.indexPattern(dataView)
);
}
const {
query: stateQuery,
datasourceStates: { textBased },
} = state;
if (stateQuery && 'esql' in stateQuery) {
stateQuery.esql = options.esqlQuery(stateQuery.esql);
}
if (textBased) {
Object.values(textBased.layers).forEach((layer) => {
if (layer.query?.esql) {
layer.query.esql = options.esqlQuery(layer.query.esql);
}
});
if ('indexPatternRefs' in textBased) {
textBased.indexPatternRefs = (textBased.indexPatternRefs as IndexPatternRef[]).map((ref) =>
options.indexPattern(ref)
);
}
}
return panel;
}

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { z } from '@kbn/zod';
import type { SavedObject } from '@kbn/core/server';
import type { DashboardAttributes } from '@kbn/dashboard-plugin/common/content_management/v2';
import type { DataViewSavedObjectAttrs } from '@kbn/data-views-plugin/common/data_views';
export interface ContentPackManifest {
name: string;
description: string;
version: string;
}
export const contentPackManifestSchema: z.Schema<ContentPackManifest> = z.object({
name: z.string(),
description: z.string(),
version: z.string(),
});
export interface ContentPack extends ContentPackManifest {
entries: ContentPackEntry[];
}
type ContentPackDashboard = SavedObject<DashboardAttributes>;
type ContentPackDataView = SavedObject<DataViewSavedObjectAttrs>;
export type ContentPackSavedObject = ContentPackDashboard | ContentPackDataView;
export type ContentPackEntry = ContentPackSavedObject;
export interface ContentPackIncludeObjects {
objects: {
dashboards: string[];
};
}
export interface ContentPackIncludeAll {
all: {};
}
export type ContentPackIncludedObjects = ContentPackIncludeObjects | ContentPackIncludeAll;
const contentPackIncludeObjectsSchema = z.object({
objects: z.object({ dashboards: z.array(z.string()) }),
});
const contentPackIncludeAllSchema = z.object({ all: z.strictObject({}) });
export const isIncludeAll = (value: ContentPackIncludedObjects): value is ContentPackIncludeAll => {
return contentPackIncludeAllSchema.safeParse(value).success;
};
export const contentPackIncludedObjectsSchema: z.Schema<ContentPackIncludedObjects> = z.union([
contentPackIncludeObjectsSchema,
contentPackIncludeAllSchema,
]);

View file

@ -0,0 +1,26 @@
{
"extends": "../../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts"
],
"kbn_references": [
"@kbn/esql-utils",
"@kbn/dashboard-plugin",
"@kbn/lens-embeddable-utils",
"@kbn/core",
"@kbn/es-query",
"@kbn/lens-plugin",
"@kbn/zod",
"@kbn/data-views-plugin"
],
"exclude": [
"target/**/*"
]
}

View file

@ -12,5 +12,4 @@ export * from './core';
export * from './helpers';
export * from './group';
export * from './record_types';
export * from './content';
export * from './significant_events';

View file

@ -0,0 +1,83 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import YAML from 'yaml';
import {
ContentPack,
ContentPackEntry,
ContentPackManifest,
contentPackManifestSchema,
} from '@kbn/content-packs-schema';
import AdmZip from 'adm-zip';
import path from 'path';
import { Readable } from 'stream';
import { pick } from 'lodash';
export async function parseArchive(archive: Readable): Promise<ContentPack> {
const zip: AdmZip = await new Promise((resolve, reject) => {
const bufs: Buffer[] = [];
archive.on('data', (chunk: Buffer) => bufs.push(chunk));
archive.on('end', () => {
try {
resolve(new AdmZip(Buffer.concat(bufs)));
} catch (err) {
reject(new Error('Invalid content pack format'));
}
});
archive.on('error', (error) => reject(error));
});
let manifestEntry: AdmZip.IZipEntry | undefined;
const entries: ContentPackEntry[] = [];
zip.forEach((entry) => {
const filepath = path.join(...entry.entryName.split(path.sep).slice(1));
const dirname = path.dirname(filepath);
if (filepath === 'manifest.yml') {
manifestEntry = entry;
} else if (
dirname === path.join('kibana', 'dashboard') ||
dirname === path.join('kibana', 'index_pattern')
) {
entries.push(JSON.parse(entry.getData().toString()));
}
});
if (!manifestEntry) {
throw new Error('Missing content pack manifest');
}
const { data: manifestData, success } = contentPackManifestSchema.safeParse(
YAML.parse(manifestEntry.getData().toString())
);
if (!success) {
throw new Error('Invalid content pack manifest format');
}
return { ...manifestData, entries };
}
export async function generateArchive(manifest: ContentPackManifest, objects: ContentPackEntry[]) {
const zip = new AdmZip();
const rootDir = `${manifest.name}-${manifest.version}`;
objects.forEach((object: ContentPackEntry) => {
if (object.type === 'dashboard' || object.type === 'index-pattern') {
const dir = object.type === 'dashboard' ? 'dashboard' : 'index_pattern';
zip.addFile(
path.join(rootDir, 'kibana', dir, `${object.id}.json`),
Buffer.from(JSON.stringify(object, null, 2))
);
}
});
zip.addFile(
path.join(rootDir, 'manifest.yml'),
Buffer.from(YAML.stringify(pick(manifest, ['name', 'description', 'version'])))
);
return zip.toBufferPromise();
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export * from './archive';
export * from './saved_object';

View file

@ -0,0 +1,119 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { v4 } from 'uuid';
import {
ContentPackIncludedObjects,
ContentPackSavedObject,
INDEX_PLACEHOLDER,
findIndexPatterns,
isIncludeAll,
replaceIndexPatterns,
} from '@kbn/content-packs-schema';
import { compact, uniqBy } from 'lodash';
export function prepareForExport({
savedObjects,
source,
replacedPatterns = [],
}: {
savedObjects: ContentPackSavedObject[];
source: string;
replacedPatterns?: string[];
}) {
return savedObjects.map((object) => {
if (object.type === 'dashboard' || object.type === 'index-pattern') {
const patterns = findIndexPatterns(object);
const replacements = {
...replacedPatterns.reduce((acc, pattern) => {
acc[pattern] = INDEX_PLACEHOLDER;
return acc;
}, {} as Record<string, string>),
...patterns
.filter((pattern) => pattern.startsWith(source))
.reduce((acc, pattern) => {
acc[pattern] = pattern.replace(source, INDEX_PLACEHOLDER);
return acc;
}, {} as Record<string, string>),
};
return replaceIndexPatterns(object, replacements);
}
return object;
});
}
export function prepareForImport({
savedObjects,
include,
target,
}: {
savedObjects: ContentPackSavedObject[];
include: ContentPackIncludedObjects;
target: string;
}) {
const uniqObjects = uniqBy(
savedObjects
.filter(
(object) =>
object.type === 'dashboard' &&
(isIncludeAll(include) || include.objects.dashboards.includes(object.id))
)
.flatMap((object) => [
object,
...compact(
object.references.map((ref) =>
savedObjects.find(({ id, type }) => id === ref.id && type === ref.type)
)
),
]),
({ id }) => id
).map((object) => {
const patterns = findIndexPatterns(object);
const replacements = patterns
.filter((pattern) => pattern.startsWith(INDEX_PLACEHOLDER))
.reduce((acc, pattern) => {
acc[pattern] = pattern.replace(INDEX_PLACEHOLDER, target);
return acc;
}, {} as Record<string, string>);
return replaceIndexPatterns(object, replacements);
});
return updateIds(uniqObjects);
}
export function updateIds(savedObjects: ContentPackSavedObject[]) {
const idReplacements = savedObjects.reduce((acc, object) => {
acc[object.id] = v4();
return acc;
}, {} as Record<string, string>);
savedObjects.forEach((object) => {
object.id = idReplacements[object.id];
object.references.forEach((ref) => {
// only update the id if the reference is included in the content pack.
// a missing reference is not necessarily an error condition since it could
// point to a pre existing saved object, for example logs-* and metrics-*
// data views
if (savedObjects.find((so) => so.id === ref.id)) {
ref.id = idReplacements[ref.id];
}
});
});
return savedObjects;
}
export function referenceManagedIndexPattern(savedObjects: ContentPackSavedObject[]) {
return savedObjects.some((object) =>
object.references.some(
(ref) => ref.type === 'index-pattern' && (ref.id === 'metrics-*' || ref.id === 'logs-*')
)
);
}

View file

@ -5,21 +5,29 @@
* 2.0.
*/
import { createSavedObjectsStreamFromNdJson } from '@kbn/core-saved-objects-server-internal/src/routes/utils';
import { ContentPack, contentPackSchema } from '@kbn/streams-schema';
import {
createConcatStream,
createListStream,
createMapStream,
createPromiseFromStreams,
} from '@kbn/utils';
import { z } from '@kbn/zod';
import { Readable } from 'stream';
import { z } from '@kbn/zod';
import { createConcatStream, createListStream, createPromiseFromStreams } from '@kbn/utils';
import { installManagedIndexPattern } from '@kbn/fleet-plugin/server/services/epm/kibana/assets/install';
import {
ContentPackEntry,
contentPackIncludedObjectsSchema,
isIncludeAll,
} from '@kbn/content-packs-schema';
import { Asset } from '../../../common';
import { DashboardAsset, DashboardLink } from '../../../common/assets';
import { ASSET_ID, ASSET_TYPE } from '../../lib/streams/assets/fields';
import { StatusError } from '../../lib/streams/errors/status_error';
import { createServerRoute } from '../create_server_route';
import { StatusError } from '../../lib/streams/errors/status_error';
import { ASSET_ID, ASSET_TYPE } from '../../lib/streams/assets/fields';
import {
generateArchive,
parseArchive,
prepareForExport,
prepareForImport,
referenceManagedIndexPattern,
} from '../../lib/content';
const MAX_CONTENT_PACK_SIZE_BYTES = 1024 * 1024 * 5; // 5MB
const exportContentRoute = createServerRoute({
endpoint: 'POST /api/streams/{name}/content/export 2023-10-31',
@ -32,6 +40,13 @@ const exportContentRoute = createServerRoute({
path: z.object({
name: z.string(),
}),
body: z.object({
name: z.string(),
description: z.string(),
version: z.string(),
replaced_patterns: z.array(z.string()),
include: contentPackIncludedObjectsSchema,
}),
}),
security: {
authz: {
@ -45,13 +60,23 @@ const exportContentRoute = createServerRoute({
await streamsClient.ensureStream(params.path.name);
if (!isIncludeAll(params.body.include) && params.body.include.objects.dashboards.length === 0) {
throw new StatusError(`Content pack must include at least one object`, 400);
}
function isDashboard(asset: Asset): asset is DashboardAsset {
return asset[ASSET_TYPE] === 'dashboard';
}
const dashboards = (await assetClient.getAssets(params.path.name)).filter(isDashboard);
const dashboards = (await assetClient.getAssets(params.path.name))
.filter(isDashboard)
.filter(
(dashboard) =>
isIncludeAll(params.body.include) ||
params.body.include.objects.dashboards.includes(dashboard['asset.id'])
);
if (dashboards.length === 0) {
throw new StatusError(`No dashboards are linked to [${params.path.name}] stream`, 400);
throw new StatusError('No included objects were found', 400);
}
const exporter = (await context.core).savedObjects.getExporter(soClient);
@ -61,19 +86,24 @@ const exportContentRoute = createServerRoute({
includeReferencesDeep: true,
});
const savedObjects: string[] = await createPromiseFromStreams([
const savedObjects: ContentPackEntry[] = await createPromiseFromStreams([
exportStream,
createMapStream((savedObject) => {
return JSON.stringify(savedObject);
}),
createConcatStream([]),
]);
const archive = await generateArchive(
params.body,
prepareForExport({
savedObjects,
source: params.path.name,
replacedPatterns: params.body.replaced_patterns,
})
);
return response.ok({
body: { content: savedObjects.join('\n') },
body: archive,
headers: {
'Content-Disposition': `attachment; filename="content.json"`,
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${params.body.name}.zip"`,
'Content-Type': 'application/zip',
},
});
},
@ -87,6 +117,7 @@ const importContentRoute = createServerRoute({
description: 'Links content objects to a stream.',
body: {
accepts: 'multipart/form-data',
maxBytes: MAX_CONTENT_PACK_SIZE_BYTES,
output: 'stream',
},
},
@ -95,6 +126,9 @@ const importContentRoute = createServerRoute({
name: z.string(),
}),
body: z.object({
include: z
.string()
.transform((value) => contentPackIncludedObjectsSchema.parse(JSON.parse(value))),
content: z.instanceof(Readable),
}),
}),
@ -110,29 +144,28 @@ const importContentRoute = createServerRoute({
await streamsClient.ensureStream(params.path.name);
const body: ContentPack = await new Promise((resolve, reject) => {
let data = '';
params.body.content.on('data', (chunk) => (data += chunk));
params.body.content.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve(contentPackSchema.parse(parsed));
} catch (err) {
reject(new StatusError('Invalid content pack format', 400));
}
});
params.body.content.on('error', (error) => reject(error));
});
const updatedSavedObjectsStream = await createPromiseFromStreams([
await createSavedObjectsStreamFromNdJson(Readable.from(body.content)),
createConcatStream([]),
]);
const contentPack = await parseArchive(params.body.content);
const importer = (await context.core).savedObjects.getImporter(soClient);
const { successResults, errors } = await importer.import({
readStream: createListStream(updatedSavedObjectsStream),
createNewCopies: true,
const savedObjects = prepareForImport({
target: params.path.name,
include: params.body.include,
savedObjects: contentPack.entries,
});
if (referenceManagedIndexPattern(savedObjects)) {
// integration package's dashboards may reference pre-existing data views
// that we need to install before import
await installManagedIndexPattern({
savedObjectsClient: soClient,
savedObjectsImporter: importer,
});
}
const { successResults, errors = [] } = await importer.import({
readStream: createListStream(savedObjects),
createNewCopies: false,
overwrite: true,
});
@ -155,7 +188,39 @@ const importContentRoute = createServerRoute({
},
});
const previewContentRoute = createServerRoute({
endpoint: 'POST /internal/streams/{name}/content/preview',
options: {
access: 'internal',
summary: 'Preview a content pack',
description: 'Returns a json representation of a content pack.',
body: {
accepts: 'multipart/form-data',
maxBytes: MAX_CONTENT_PACK_SIZE_BYTES,
output: 'stream',
},
},
params: z.object({
path: z.object({
name: z.string(),
}),
body: z.object({
content: z.instanceof(Readable),
}),
}),
security: {
authz: {
enabled: false,
reason: 'This API does not use any user credentials.',
},
},
async handler({ params }) {
return await parseArchive(params.body.content);
},
});
export const contentRoutes = {
...exportContentRoute,
...importContentRoute,
...previewContentRoute,
};

View file

@ -41,8 +41,9 @@
"@kbn/es-query",
"@kbn/core-elasticsearch-client-server-internal",
"@kbn/utils",
"@kbn/core-saved-objects-server-internal",
"@kbn/core-analytics-server",
"@kbn/fleet-plugin",
"@kbn/content-packs-schema",
"@kbn/cloud-plugin",
"@kbn/es-types"
]

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const FeatureFlagStreamsContentPackUIEnabled = 'featureFlagsStreams.contentPackUIEnabled';

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ContentPack, ContentPackIncludedObjects } from '@kbn/content-packs-schema';
import { HttpSetup } from '@kbn/core/public';
import { IngestStreamGetResponse } from '@kbn/streams-schema';
export async function importContent({
file,
http,
definition,
include,
}: {
file: File;
http: HttpSetup;
definition: IngestStreamGetResponse;
include: ContentPackIncludedObjects;
}) {
const body = new FormData();
body.append('content', file);
body.append('include', JSON.stringify(include));
const response = await http.post(`/api/streams/${definition.stream.name}/content/import`, {
body,
headers: {
// Important to be undefined, it forces proper headers to be set for FormData
'Content-Type': undefined,
},
});
return response;
}
export async function previewContent({
http,
file,
definition,
}: {
http: HttpSetup;
file: File;
definition: IngestStreamGetResponse;
}) {
const body = new FormData();
body.append('content', file);
const contentPack = await http.post<ContentPack>(
`/internal/streams/${definition.stream.name}/content/preview`,
{
body,
headers: {
// Important to be undefined, it forces proper headers to be set for FormData
'Content-Type': undefined,
},
}
);
return contentPack;
}

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ContentPackManifest } from '@kbn/content-packs-schema';
import React from 'react';
import { EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
export function ContentPackMetadata({
manifest,
readonly,
onChange,
}: {
manifest: ContentPackManifest;
readonly?: boolean;
onChange?: (manifest: ContentPackManifest) => void;
}) {
return (
<>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={3}>
<EuiFieldText
readOnly={readonly}
prepend={'Name'}
fullWidth
value={manifest.name}
isInvalid={manifest.name.length === 0}
onChange={(e) => onChange?.({ ...manifest, name: e.target.value })}
/>
</EuiFlexItem>
<EuiFlexItem grow={1}>
<EuiFieldText
readOnly={readonly}
prepend={'Version'}
fullWidth
value={manifest.version}
onChange={(e) => onChange?.({ ...manifest, version: e.target.value })}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<EuiFlexItem grow={true}>
<EuiFieldText
readOnly={readonly}
prepend={'Description'}
fullWidth
value={manifest.description}
onChange={(e) => onChange?.({ ...manifest, description: e.target.value })}
/>
</EuiFlexItem>
</>
);
}

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiBadge, EuiBasicTable } from '@elastic/eui';
import React from 'react';
import { DashboardAttributes } from '@kbn/dashboard-plugin/common/content_management/v2';
import { capitalize } from 'lodash';
import { ContentPackEntry } from '@kbn/content-packs-schema';
export function ContentPackObjectsList({
objects,
onSelectionChange,
}: {
objects: ContentPackEntry[];
onSelectionChange: (objects: ContentPackEntry[]) => void;
}) {
return (
<EuiBasicTable
items={objects.filter(({ type }) => type === 'dashboard')}
itemId={(entry: ContentPackEntry) => entry.id}
columns={[
{
name: 'Asset name',
render: (entry: ContentPackEntry) => {
if (entry.type === 'dashboard') {
return (entry.attributes as DashboardAttributes).title;
}
return 'unknown object type';
},
truncateText: true,
},
{
name: 'Type',
render: (entry: ContentPackEntry) => {
const iconType = 'dashboardApp';
return (
<EuiBadge color="hollow" iconType={iconType} iconSide="left">
{capitalize(entry.type)}
</EuiBadge>
);
},
},
]}
rowHeader="objectName"
selection={{
onSelectionChange: (selectedObjects: ContentPackEntry[]) => {
onSelectionChange(selectedObjects);
},
}}
/>
);
}

View file

@ -0,0 +1,258 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
// @ts-expect-error
import { saveAs } from '@elastic/filesaver';
import { IngestStreamGetResponse } from '@kbn/streams-schema';
import {
ContentPackEntry,
ContentPackManifest,
findIndexPatterns,
isIndexPlaceholder,
} from '@kbn/content-packs-schema';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCheckboxGroup,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiLoadingSpinner,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { uniq } from 'lodash';
import { useKibana } from '../../hooks/use_kibana';
import { useStreamsAppFetch } from '../../hooks/use_streams_app_fetch';
import { ContentPackObjectsList } from './content_pack_objects_list';
import { previewContent } from './content/requests';
import { ContentPackMetadata } from './content_pack_manifest';
export function ExportContentPackFlyout({
definition,
onExport,
onClose,
}: {
definition: IngestStreamGetResponse;
onClose: () => void;
onExport: () => void;
}) {
const {
core: { http, notifications },
dependencies: {
start: {
streams: { streamsRepositoryClient },
},
},
} = useKibana();
const [manifest, setManifest] = useState<ContentPackManifest | undefined>();
const { value: exportResponse, loading: isLoadingContentPack } = useStreamsAppFetch(
async ({ signal }) => {
if (!definition) {
return;
}
const contentPackRaw = await streamsRepositoryClient.fetch(
'POST /api/streams/{name}/content/export 2023-10-31',
{
params: {
path: { name: definition.stream.name },
body: {
name: definition.stream.name,
description: '',
version: '1.0.0',
replaced_patterns: [],
include: { all: {} },
},
},
signal,
}
);
const contentPack = await previewContent({
http,
definition,
file: new File([contentPackRaw], 'archive.zip', { type: 'application/zip' }),
});
const indexPatterns = uniq(
contentPack.entries.flatMap((object) => findIndexPatterns(object))
).filter((index) => !isIndexPlaceholder(index));
setManifest({
name: contentPack.name,
version: contentPack.version,
description: contentPack.description,
});
return { contentPack, indexPatterns };
},
[definition, streamsRepositoryClient, http]
);
const [selectedContentPackObjects, setSelectedContentPackObjects] = useState<ContentPackEntry[]>(
[]
);
const [replacedIndexPatterns, setReplacedIndexPatterns] = useState<Record<string, boolean>>({});
const [isExporting, setIsExporting] = useState(false);
return (
<EuiFlyout onClose={onClose}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>
{i18n.translate('xpack.streams.streamDetailDashboard.exportContent', {
defaultMessage: 'Export content pack',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
{isLoadingContentPack ? (
<EuiLoadingSpinner />
) : !exportResponse ? null : exportResponse.contentPack.entries ? (
<>
{exportResponse.indexPatterns.length ? (
<>
<EuiCallOut>
<details>
<summary>
{i18n.translate('xpack.streams.exportContentFlyout.advancedSettings', {
defaultMessage: 'Advanced settings',
})}
</summary>
<EuiSpacer />
<p>
{i18n.translate('xpack.streams.exportContentFlyout.detectedIndexPatterns', {
defaultMessage:
'We detected index patterns that do not match {streamName}* in the content pack. Check the ones you want to replace with the target stream index on import.',
values: { streamName: definition.stream.name },
})}
</p>
{
<EuiCheckboxGroup
idToSelectedMap={replacedIndexPatterns}
onChange={(id) =>
setReplacedIndexPatterns({
...replacedIndexPatterns,
...{
[id]: !replacedIndexPatterns[id],
},
})
}
options={exportResponse.indexPatterns.map((index) => ({
id: index,
label: index,
}))}
/>
}
</details>
</EuiCallOut>
<EuiSpacer />
</>
) : null}
{manifest ? (
<ContentPackMetadata
manifest={manifest}
onChange={(updatedManifest) => {
setManifest(updatedManifest);
}}
/>
) : null}
<EuiSpacer />
<ContentPackObjectsList
objects={exportResponse.contentPack.entries}
onSelectionChange={(objects) => setSelectedContentPackObjects(objects)}
/>
</>
) : null}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => onClose()}>Cancel</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="streamsAppModalFooterButton"
isLoading={isExporting}
isDisabled={
isLoadingContentPack ||
selectedContentPackObjects.length === 0 ||
manifest?.name.length === 0
}
fill
onClick={async () => {
if (!exportResponse || !manifest || selectedContentPackObjects.length === 0) {
return;
}
setIsExporting(true);
const replacedPatterns = Object.entries(replacedIndexPatterns)
.filter(([, selected]) => selected)
.map(([pattern]) => pattern);
try {
const contentPack = await streamsRepositoryClient.fetch(
'POST /api/streams/{name}/content/export 2023-10-31',
{
params: {
path: { name: definition.stream.name },
body: {
...manifest,
replaced_patterns: replacedPatterns,
include: {
objects: { dashboards: selectedContentPackObjects.map(({ id }) => id) },
},
},
},
signal: new AbortController().signal,
}
);
saveAs(
new Blob([contentPack], { type: 'application/zip' }),
`${manifest.name}-${manifest.version}.zip`
);
onExport();
} catch (err) {
notifications.toasts.addError(err, {
title: i18n.translate('xpack.streams.failedToExportContentError', {
defaultMessage: 'Failed to export content pack',
}),
});
} finally {
setIsExporting(true);
}
}}
>
{i18n.translate('xpack.streams.exportContentPackFlyout.exportContentPack', {
defaultMessage: 'Export content pack',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState } from 'react';
import { IngestStreamGetResponse } from '@kbn/streams-schema';
import { ContentPackEntry, ContentPackManifest } from '@kbn/content-packs-schema';
import {
EuiButton,
EuiButtonEmpty,
EuiFilePicker,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { useKibana } from '../../hooks/use_kibana';
import { ContentPackObjectsList } from './content_pack_objects_list';
import { importContent, previewContent } from './content/requests';
import { ContentPackMetadata } from './content_pack_manifest';
export function ImportContentPackFlyout({
definition,
onImport,
onClose,
}: {
definition: IngestStreamGetResponse;
onClose: () => void;
onImport: () => void;
}) {
const {
core: { http, notifications },
} = useKibana();
const [isLoading, setIsLoading] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [contentPackObjects, setContentPackObjects] = useState<ContentPackEntry[]>([]);
const [selectedContentPackObjects, setSelectedContentPackObjects] = useState<ContentPackEntry[]>(
[]
);
const [manifest, setManifest] = useState<ContentPackManifest | undefined>();
return (
<EuiFlyout onClose={onClose}>
<EuiFlyoutHeader hasBorder>
<EuiTitle>
<h2>
{i18n.translate('xpack.streams.streamDetailDashboard.importContent', {
defaultMessage: 'Import content pack',
})}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiFilePicker
id={'streams-content-import'}
multiple={false}
initialPromptText="Select a streams content file"
fullWidth
onChange={async (files) => {
if (files?.length) {
const archiveFile = files.item(0);
if (!archiveFile) return;
setFile(archiveFile);
try {
const contentPackParsed = await previewContent({
http,
definition,
file: archiveFile,
});
setManifest({
name: contentPackParsed.name,
version: contentPackParsed.version,
description: contentPackParsed.description,
});
setContentPackObjects(contentPackParsed.entries);
} catch (err) {
setFile(null);
notifications.toasts.addError(err, {
title: i18n.translate('xpack.streams.failedToPreviewContentError', {
defaultMessage: 'Failed to preview content pack',
}),
});
}
} else {
setFile(null);
}
}}
display={'large'}
/>
{file && manifest ? (
<>
<EuiSpacer />
<ContentPackMetadata manifest={manifest} readonly={true} />
<EuiSpacer />
<ContentPackObjectsList
objects={contentPackObjects}
onSelectionChange={(objects) => setSelectedContentPackObjects(objects)}
/>
</>
) : null}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={() => onClose()}>Cancel</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="streamsAppModalFooterButton"
disabled={!file || selectedContentPackObjects.length === 0}
isLoading={isLoading}
fill
onClick={async () => {
if (!file) return;
setIsLoading(true);
try {
await importContent({
http,
file,
definition,
include: {
objects: { dashboards: selectedContentPackObjects.map(({ id }) => id) },
},
});
setIsLoading(false);
setContentPackObjects([]);
setFile(null);
onImport();
} catch (err) {
setIsLoading(false);
notifications.toasts.addError(err, {
title: i18n.translate('xpack.streams.failedToImportContentError', {
defaultMessage: 'Failed to import content pack',
}),
});
}
}}
>
{i18n.translate('xpack.streams.importContentPackFlyout.importToStream', {
defaultMessage: 'Import to stream',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
}

View file

@ -4,7 +4,16 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiSearchBar } from '@elastic/eui';
import {
EuiButton,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiPopover,
EuiSearchBar,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useMemo, useState } from 'react';
import type { SanitizedDashboardAsset } from '@kbn/streams-plugin/server/routes/dashboards/route';
@ -13,6 +22,10 @@ import { AddDashboardFlyout } from './add_dashboard_flyout';
import { DashboardsTable } from './dashboard_table';
import { useDashboardsApi } from '../../hooks/use_dashboards_api';
import { useDashboardsFetch } from '../../hooks/use_dashboards_fetch';
import { ImportContentPackFlyout } from './import_content_pack_flyout';
import { ExportContentPackFlyout } from './export_content_pack_flyout';
import { FeatureFlagStreamsContentPackUIEnabled } from '../../../common/feature_flags';
import { useKibana } from '../../hooks/use_kibana';
export function StreamDetailDashboardsView({
definition,
@ -22,6 +35,8 @@ export function StreamDetailDashboardsView({
const [query, setQuery] = useState('');
const [isAddDashboardFlyoutOpen, setIsAddDashboardFlyoutOpen] = useState(false);
const [isImportFlyoutOpen, setIsImportFlyoutOpen] = useState(false);
const [isExportFlyoutOpen, setIsExportFlyoutOpen] = useState(false);
const dashboardsFetch = useDashboardsFetch(definition?.stream.name);
const { addDashboards, removeDashboards } = useDashboardsApi(definition?.stream.name);
@ -38,6 +53,16 @@ export function StreamDetailDashboardsView({
}, [linkedDashboards, query]);
const [selectedDashboards, setSelectedDashboards] = useState<SanitizedDashboardAsset[]>([]);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const {
core: { featureFlags },
} = useKibana();
const renderContentPackItems = featureFlags.getBooleanValue(
FeatureFlagStreamsContentPackUIEnabled,
false
);
return (
<EuiFlexGroup direction="column">
@ -76,17 +101,96 @@ export function StreamDetailDashboardsView({
setQuery(nextQuery.queryText);
}}
/>
<EuiButton
data-test-subj="streamsAppStreamDetailAddDashboardButton"
iconType="plusInCircle"
onClick={() => {
setIsAddDashboardFlyoutOpen(true);
}}
>
{i18n.translate('xpack.streams.streamDetailDashboardView.addADashboardButtonLabel', {
defaultMessage: 'Add a dashboard',
})}
</EuiButton>
{renderContentPackItems && (
<EuiButton
data-test-subj="streamsAppStreamDetailExportContentPackButton"
iconType="exportAction"
isDisabled={linkedDashboards.length === 0}
onClick={() => {
setIsExportFlyoutOpen(true);
}}
>
{i18n.translate('xpack.streams.streamDetailDashboardView.exportContentPackButton', {
defaultMessage: 'Export content pack',
})}
</EuiButton>
)}
{renderContentPackItems ? (
<EuiPopover
button={
<EuiButton
iconType="importAction"
iconSide="left"
color="primary"
onClick={() => setIsPopoverOpen(true)}
>
{i18n.translate(
'xpack.streams.streamDetailDashboardView.addDashboardsButtonLabel',
{
defaultMessage: 'Add dashboards',
}
)}
</EuiButton>
}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
items={[
<EuiContextMenuItem
data-test-subj="streamsAppStreamDetailAddDashboardButton"
key="addDashboard"
icon="plusInCircle"
onClick={() => {
setIsPopoverOpen(false);
setIsAddDashboardFlyoutOpen(true);
}}
>
{i18n.translate(
'xpack.streams.streamDetailDashboardView.addADashboardButtonLabel',
{
defaultMessage: 'Add a dashboard',
}
)}
</EuiContextMenuItem>,
<EuiContextMenuItem
data-test-subj="streamsAppStreamDetailImportContentPackButton"
key="importContentPack"
icon="importAction"
onClick={() => {
setIsPopoverOpen(false);
setIsImportFlyoutOpen(true);
}}
>
{i18n.translate(
'xpack.streams.streamDetailDashboardView.importContentPackButtonLabel',
{
defaultMessage: 'Import from content pack',
}
)}
</EuiContextMenuItem>,
]}
/>
</EuiPopover>
) : (
<EuiButton
data-test-subj="streamsAppStreamDetailAddDashboardButton"
iconType="plusInCircle"
onClick={() => {
setIsAddDashboardFlyoutOpen(true);
}}
>
{i18n.translate('xpack.streams.streamDetailDashboardView.addADashboardButtonLabel', {
defaultMessage: 'Add a dashboard',
})}
</EuiButton>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
@ -111,6 +215,31 @@ export function StreamDetailDashboardsView({
}}
/>
) : null}
{definition && isImportFlyoutOpen ? (
<ImportContentPackFlyout
definition={definition}
onImport={() => {
dashboardsFetch.refresh();
setIsImportFlyoutOpen(false);
}}
onClose={() => {
setIsImportFlyoutOpen(false);
}}
/>
) : null}
{definition && isExportFlyoutOpen ? (
<ExportContentPackFlyout
definition={definition}
onExport={() => {
setIsExportFlyoutOpen(false);
}}
onClose={() => {
setIsExportFlyoutOpen(false);
}}
/>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -67,6 +67,7 @@
"@kbn/index-management-shared-types",
"@kbn/ingest-pipelines-plugin",
"@kbn/deeplinks-observability",
"@kbn/content-packs-schema",
"@kbn/charts-theme"
]
}

View file

@ -5,12 +5,13 @@
* 2.0.
*/
import expect from '@kbn/expect';
import { disableStreams, enableStreams, indexDocument } from '../helpers/requests';
import { disableStreams, enableStreams, indexDocument, linkDashboard } from '../helpers/requests';
import {
StreamsSupertestRepositoryClient,
createStreamsRepositoryAdminClient,
} from '../helpers/repository_client';
import { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
import { loadDashboards, unloadDashboards } from '../helpers/dashboards';
export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const roleScopedSupertest = getService('roleScopedSupertest');
@ -32,37 +33,6 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const BASIC_DASHBOARD_TITLE = 'Requests';
const TAG_ID = '00ad6a46-6ac3-4f6c-892c-2f72c54a5e7d';
async function loadDashboards() {
// clear out all lingering dashboards
const dashboards = await kibanaServer.savedObjects.find({
type: 'dashboard',
});
await kibanaServer.savedObjects.bulkDelete({
objects: dashboards.saved_objects.map((d) => ({ type: 'dashboard', id: d.id })),
});
for (const archive of ARCHIVES) {
await kibanaServer.importExport.load(archive, { space: SPACE_ID });
}
}
async function unloadDashboards() {
for (const archive of ARCHIVES) {
await kibanaServer.importExport.unload(archive, { space: SPACE_ID });
}
}
async function linkDashboard(id: string) {
const response = await apiClient.fetch(
'PUT /api/streams/{name}/dashboards/{dashboardId} 2023-10-31',
{
params: { path: { name: 'logs', dashboardId: id } },
}
);
expect(response.status).to.be(200);
}
async function unlinkDashboard(id: string) {
const response = await apiClient.fetch(
'DELETE /api/streams/{name}/dashboards/{dashboardId} 2023-10-31',
@ -129,14 +99,14 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
describe('after linking a dashboard', () => {
before(async () => {
await loadDashboards();
await loadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
await linkDashboard(SEARCH_DASHBOARD_ID);
await linkDashboard(apiClient, 'logs', SEARCH_DASHBOARD_ID);
});
after(async () => {
await unlinkDashboard(SEARCH_DASHBOARD_ID);
await unloadDashboards();
await unloadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
});
it('lists the dashboard in the stream response', async () => {
@ -177,7 +147,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
});
it('recovers on write and lists the linked dashboard ', async () => {
await linkDashboard(SEARCH_DASHBOARD_ID);
await linkDashboard(apiClient, 'logs', SEARCH_DASHBOARD_ID);
const response = await apiClient.fetch('GET /api/streams/{name}/dashboards 2023-10-31', {
params: { path: { name: 'logs' } },
@ -191,7 +161,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
describe('after deleting the dashboards', () => {
before(async () => {
await unloadDashboards();
await unloadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
});
it('no longer lists the dashboard as a linked asset', async () => {
@ -208,14 +178,14 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
describe('after using the bulk API', () => {
before(async () => {
await loadDashboards();
await loadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
await bulkLinkDashboard(SEARCH_DASHBOARD_ID, BASIC_DASHBOARD_ID);
});
after(async () => {
await bulkUnlinkDashboard(SEARCH_DASHBOARD_ID, BASIC_DASHBOARD_ID);
await unloadDashboards();
await unloadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
});
it('shows the linked dashboards', async () => {
@ -266,14 +236,14 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
describe('suggestions', () => {
before(async () => {
await loadDashboards();
await loadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
await linkDashboard(SEARCH_DASHBOARD_ID);
await linkDashboard(apiClient, 'logs', SEARCH_DASHBOARD_ID);
});
after(async () => {
await unlinkDashboard(SEARCH_DASHBOARD_ID);
await unloadDashboards();
await unloadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
});
describe('after creating multiple dashboards', () => {

View file

@ -0,0 +1,145 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { generateArchive, parseArchive } from '@kbn/streams-plugin/server/lib/content';
import { Readable } from 'stream';
import {
ContentPack,
ContentPackSavedObject,
INDEX_PLACEHOLDER,
findIndexPatterns,
} from '@kbn/content-packs-schema';
import { DeploymentAgnosticFtrProviderContext } from '../../../ftr_provider_context';
import {
StreamsSupertestRepositoryClient,
createStreamsRepositoryAdminClient,
} from './helpers/repository_client';
import {
disableStreams,
enableStreams,
linkDashboard,
exportContent,
importContent,
putStream,
getStream,
} from './helpers/requests';
import { loadDashboards, unloadDashboards } from './helpers/dashboards';
export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
const roleScopedSupertest = getService('roleScopedSupertest');
let apiClient: StreamsSupertestRepositoryClient;
const kibanaServer = getService('kibanaServer');
const SPACE_ID = 'default';
const ARCHIVES = [
// this archive contains a dashboard with esql panel and a lens panel referencing a data view
// both read from `logs`
'src/platform/test/api_integration/fixtures/kbn_archiver/saved_objects/content_pack_two_panels.json',
];
const TWO_PANELS_DASHBOARD_ID = 'c22ba8ed-fd4b-4864-a98c-3cba1d11cfb2';
describe('Content packs', () => {
let contentPack: ContentPack;
before(async () => {
apiClient = await createStreamsRepositoryAdminClient(roleScopedSupertest);
await enableStreams(apiClient);
});
after(async () => {
await disableStreams(apiClient);
});
describe('Export', () => {
before(async () => {
await loadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
await linkDashboard(apiClient, 'logs', TWO_PANELS_DASHBOARD_ID);
});
after(async () => {
await unloadDashboards(kibanaServer, ARCHIVES, SPACE_ID);
});
it('creates a content pack', async () => {
const response = await exportContent(apiClient, 'logs', {
name: 'logs-content_pack',
version: '1.0.0',
description: 'my content pack',
include: { all: {} },
replaced_patterns: [],
});
contentPack = await parseArchive(Readable.from([response]));
expect(contentPack.name).to.be('logs-content_pack');
expect(contentPack.version).to.be('1.0.0');
expect(contentPack.description).to.be('my content pack');
expect(contentPack.entries.length).to.eql(2);
expect(contentPack.entries.filter((entry) => entry.type === 'dashboard').length).to.be(1);
expect(contentPack.entries.filter((entry) => entry.type === 'index-pattern').length).to.be(
1
);
});
it('puts placeholders for patterns matching the source stream', async () => {
expect(contentPack.entries.length).to.eql(2);
contentPack.entries.forEach((entry) => {
const patterns = findIndexPatterns(entry);
expect(patterns).to.eql([INDEX_PLACEHOLDER]);
});
});
});
describe('Import', () => {
before(async () => {
await putStream(apiClient, 'logs.importstream', {
dashboards: [],
queries: [],
stream: {
ingest: {
processing: [],
wired: { fields: {}, routing: [] },
lifecycle: { inherit: {} },
},
},
});
});
it('imports a content pack', async () => {
const archive = await generateArchive(contentPack, contentPack.entries);
const response = await importContent(apiClient, 'logs.importstream', {
include: { all: {} },
content: Readable.from(archive),
});
expect(response.errors.length).to.be(0);
expect(response.created.length).to.be(1);
const stream = await getStream(apiClient, 'logs.importstream');
expect(stream.dashboards).to.eql([response.created[0]['asset.id']]);
});
it('replaces placeholders with target stream pattern', async () => {
const stream = await getStream(apiClient, 'logs.importstream');
const dashboard = await kibanaServer.savedObjects.get({
type: 'dashboard',
id: stream.dashboards[0],
});
expect(dashboard.references.length).to.eql(1);
const indexPattern = await kibanaServer.savedObjects.get({
type: 'index-pattern',
id: dashboard.references[0].id,
});
[dashboard, indexPattern].forEach((object) => {
const patterns = findIndexPatterns(object as ContentPackSavedObject);
expect(patterns).to.eql(['logs.importstream']);
});
});
});
});
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { KbnClient } from '@kbn/test';
export async function loadDashboards(kbnClient: KbnClient, archives: string[], spaceId: string) {
// clear out all lingering dashboards
const dashboards = await kbnClient.savedObjects.find({
type: 'dashboard',
});
await kbnClient.savedObjects.bulkDelete({
objects: dashboards.saved_objects.map((d) => ({ type: 'dashboard', id: d.id })),
});
for (const archive of archives) {
await kbnClient.importExport.load(archive, { space: spaceId });
}
}
export async function unloadDashboards(kbnClient: KbnClient, archives: string[], spaceId: string) {
for (const archive of archives) {
await kbnClient.importExport.unload(archive, { space: spaceId });
}
}

View file

@ -4,6 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Readable } from 'stream';
import { Client } from '@elastic/elasticsearch';
import { JsonObject } from '@kbn/utility-types';
import expect from '@kbn/expect';
@ -11,6 +13,7 @@ import { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import { StreamUpsertRequest } from '@kbn/streams-schema';
import { ClientRequestParamsOf } from '@kbn/server-route-repository-utils';
import { StreamsRouteRepository } from '@kbn/streams-plugin/server';
import { ContentPackIncludedObjects, ContentPackManifest } from '@kbn/content-packs-schema';
import { StreamsSupertestRepositoryClient } from './repository_client';
export async function enableStreams(client: StreamsSupertestRepositoryClient) {
@ -134,3 +137,63 @@ export async function getQueries(
.expect(expectStatusCode)
.then((response) => response.body);
}
export async function linkDashboard(
apiClient: StreamsSupertestRepositoryClient,
stream: string,
id: string
) {
const response = await apiClient.fetch(
'PUT /api/streams/{name}/dashboards/{dashboardId} 2023-10-31',
{
params: { path: { name: stream, dashboardId: id } },
}
);
expect(response.status).to.be(200);
}
export async function exportContent(
apiClient: StreamsSupertestRepositoryClient,
name: string,
body: ContentPackManifest & {
include: ContentPackIncludedObjects;
replaced_patterns: string[];
},
expectStatusCode: number = 200
) {
return await apiClient
.fetch('POST /api/streams/{name}/content/export 2023-10-31', {
params: {
path: { name },
body,
},
})
.responseType('blob')
.expect(expectStatusCode)
.then((response) => response.body);
}
export async function importContent(
apiClient: StreamsSupertestRepositoryClient,
name: string,
body: {
include: ContentPackIncludedObjects;
content: Readable;
},
expectStatusCode: number = 200
) {
return await apiClient
.sendFile('POST /api/streams/{name}/content/import 2023-10-31', {
params: {
path: { name },
body: {
include: JSON.stringify(body.include),
content: body.content,
},
},
file: { key: 'content', filename: 'content_pack.zip' },
})
.expect(expectStatusCode)
.then((response) => response.body);
}

View file

@ -22,5 +22,6 @@ export default function ({ loadTestFile }: DeploymentAgnosticFtrProviderContext)
loadTestFile(require.resolve('./significant_events'));
loadTestFile(require.resolve('./queries'));
loadTestFile(require.resolve('./discover'));
loadTestFile(require.resolve('./content'));
});
}

View file

@ -34,6 +34,13 @@ export interface RepositorySupertestClient<TServerRouteRepository extends Server
} & ClientRequestParamsOf<TServerRouteRepository, TEndpoint>
>
) => RepositorySupertestReturnOf<TServerRouteRepository, TEndpoint>;
sendFile: <TEndpoint extends EndpointOf<TServerRouteRepository>>(
endpoint: TEndpoint,
options: ClientRequestParamsOf<TServerRouteRepository, TEndpoint> & {
file: { key: string; filename: string };
}
) => RepositorySupertestReturnOf<TServerRouteRepository, TEndpoint>;
}
type RepositorySupertestReturnOf<
@ -94,6 +101,38 @@ async function getApiClient<TServerRouteRepository extends ServerRouteRepository
EndpointOf<TServerRouteRepository>
>;
},
sendFile: (endpoint, options) => {
const params = 'params' in options ? (options.params as Record<string, any>) : {};
const { method, pathname, version } = formatRequest(endpoint, params.path);
const url = format({ pathname, query: params.query });
const headers: Record<string, string> = {
'kbn-xsrf': 'foo',
'x-elastic-internal-origin': 'kibana',
};
if (version) {
headers['Elastic-Api-Version'] = version;
}
const fields: Array<[string, any]> = Object.entries(params.body);
const formDataRequest = supertestWithRoleScoped[method](url)
.set(headers)
.set('Content-type', 'multipart/form-data')
.attach(options.file.key, params.body[options.file.key], {
filename: options.file.filename,
});
for (const field of fields.filter(([key]) => key !== options.file.key)) {
void formDataRequest.field(field[0], field[1]);
}
return formDataRequest as RepositorySupertestReturnOf<
TServerRouteRepository,
EndpointOf<TServerRouteRepository>
>;
},
};
}

View file

@ -192,5 +192,6 @@
"@kbn/apm-sources-access-plugin",
"@kbn/aiops-change-point-detection",
"@kbn/es-errors",
"@kbn/content-packs-schema",
]
}

View file

@ -4158,6 +4158,10 @@
version "0.0.0"
uid ""
"@kbn/content-packs-schema@link:x-pack/platform/packages/shared/kbn-content-packs-schema":
version "0.0.0"
uid ""
"@kbn/controls-example-plugin@link:examples/controls_example":
version "0.0.0"
uid ""
@ -12747,9 +12751,9 @@
integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==
"@zip.js/zip.js@^2.7.53":
version "2.7.53"
resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.53.tgz#bf88e90d8eed562182c01339643bc405446b0578"
integrity sha512-G6Bl5wN9EXXVaTUIox71vIX5Z454zEBe+akKpV4m1tUboIctT5h7ID3QXCJd/Lfy2rSvmkTmZIucf1jGRR4f5A==
version "2.7.60"
resolved "https://registry.yarnpkg.com/@zip.js/zip.js/-/zip.js-2.7.60.tgz#0de96b93519cad804c82f96faebceda836cb24c0"
integrity sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==
a-sync-waterfall@^1.0.0:
version "1.0.1"
@ -29218,7 +29222,7 @@ string-replace-loader@^3.1.0:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -29236,6 +29240,15 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@ -29328,7 +29341,7 @@ stringify-object@^3.2.1:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -29342,6 +29355,13 @@ strip-ansi@^3.0.0, strip-ansi@^3.0.1:
dependencies:
ansi-regex "^2.0.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1, strip-ansi@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@ -32128,7 +32148,7 @@ workerpool@^6.5.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544"
integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -32154,6 +32174,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
@ -32264,7 +32293,7 @@ xpath@^0.0.33:
resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.33.tgz#5136b6094227c5df92002e7c3a13516a5074eb07"
integrity sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==
"xstate5@npm:xstate@^5.19.2", xstate@^5.19.2:
"xstate5@npm:xstate@^5.19.2":
version "5.19.2"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.19.2.tgz#db3f1ee614bbb6a49ad3f0c96ddbf98562d456ba"
integrity sha512-B8fL2aP0ogn5aviAXFzI5oZseAMqN00fg/TeDa3ZtatyDcViYLIfuQl4y8qmHCiKZgGEzmnTyNtNQL9oeJE2gw==
@ -32274,6 +32303,11 @@ xstate@^4.38.3:
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.38.3.tgz#4e15e7ad3aa0ca1eea2010548a5379966d8f1075"
integrity sha512-SH7nAaaPQx57dx6qvfcIgqKRXIh4L0A1iYEqim4s1u7c9VoCgzZc+63FY90AKU4ZzOC2cfJzTnpO4zK7fCUzzw==
xstate@^5.19.2:
version "5.19.2"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-5.19.2.tgz#db3f1ee614bbb6a49ad3f0c96ddbf98562d456ba"
integrity sha512-B8fL2aP0ogn5aviAXFzI5oZseAMqN00fg/TeDa3ZtatyDcViYLIfuQl4y8qmHCiKZgGEzmnTyNtNQL9oeJE2gw==
"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"