[Ingest] Datastream list: add icons and dashboard links (#65048)

* Read package saved objects in data stream handler.

* Render package icon.

* Make TableRowAction more generic

* Add Actions column to data stream list

* Disable dashboard link if no dashboards present.

* Data stream list: link to first dashbord found

* Update i18n strings

* Add nested context menu to link to dashboards

* introduces a separate TableRowActionsNested component
* moves TableRowActions back into agent config components

* Fix i18n label.

* Re-add translated strings removed by mistake

* Fix i18n issues

* Add helper to read a saved object installed by EPM

* Display titles from within dashboard saved objects
This commit is contained in:
Sonja Krause-Harder 2020-05-05 13:54:22 +02:00 committed by GitHub
parent ba3534eca3
commit 4142f575e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 242 additions and 10 deletions

View file

@ -10,6 +10,11 @@ export interface DataStream {
namespace: string;
type: string;
package: string;
package_version: string;
last_activity: string;
size_in_bytes: number;
dashboards: Array<{
id: string;
title: string;
}>;
}

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useCallback, useState } from 'react';
import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu';
export const TableRowActionsNested = React.memo<{ panels: EuiContextMenuProps['panels'] }>(
({ panels }) => {
const [isOpen, setIsOpen] = useState(false);
const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]);
const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]);
return (
<EuiPopover
anchorPosition="downRight"
panelPaddingSize="none"
button={
<EuiButtonIcon
iconType="boxesHorizontal"
onClick={handleToggleMenu}
aria-label={i18n.translate('xpack.ingestManager.genericActionsMenuText', {
defaultMessage: 'Open',
})}
/>
}
isOpen={isOpen}
closePopover={handleCloseMenu}
>
<EuiContextMenu panels={panels} initialPanelId={0} />
</EuiPopover>
);
}
);

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.
*/
import { useCore } from './';
const BASE_PATH = '/app/kibana';
export function useKibanaLink(path: string = '/') {
const core = useCore();
return core.http.basePath.prepend(`${BASE_PATH}#${path}`);
}

View file

@ -0,0 +1,82 @@
/*
* 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, { memo } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
import { useKibanaLink } from '../../../../hooks/use_kibana_link';
import { DataStream } from '../../../../types';
import { TableRowActionsNested } from '../../../../components/table_row_actions_nested';
export const DataStreamRowActions = memo<{ datastream: DataStream }>(({ datastream }) => {
const { dashboards } = datastream;
const panels = [];
const actionNameSingular = (
<FormattedMessage
id="xpack.ingestManager.dataStreamList.viewDashboardActionText"
defaultMessage="View dashboard"
/>
);
const actionNamePlural = (
<FormattedMessage
id="xpack.ingestManager.dataStreamList.viewDashboardsActionText"
defaultMessage="View dashboards"
/>
);
const panelTitle = i18n.translate('xpack.ingestManager.dataStreamList.viewDashboardsPanelTitle', {
defaultMessage: 'View dashboards',
});
if (!dashboards || dashboards.length === 0) {
panels.push({
id: 0,
items: [
{
icon: 'dashboardApp',
disabled: true,
name: actionNameSingular,
},
],
});
} else if (dashboards.length === 1) {
panels.push({
id: 0,
items: [
{
icon: 'dashboardApp',
href: useKibanaLink(`/dashboard/${dashboards[0].id || ''}`),
name: actionNameSingular,
},
],
});
} else {
panels.push({
id: 0,
items: [
{
icon: 'dashboardApp',
panel: 1,
name: actionNamePlural,
},
],
});
panels.push({
id: 1,
title: panelTitle,
items: dashboards.map(dashboard => {
return {
icon: 'dashboardApp',
href: useKibanaLink(`/dashboard/${dashboard.id || ''}`),
name: dashboard.title,
};
}),
});
}
return <TableRowActionsNested panels={panels} />;
});

View file

@ -20,6 +20,8 @@ import { FormattedMessage, FormattedDate } from '@kbn/i18n/react';
import { DataStream } from '../../../types';
import { WithHeaderLayout } from '../../../layouts';
import { useGetDataStreams, useStartDeps, usePagination } from '../../../hooks';
import { PackageIcon } from '../../../components/package_icon';
import { DataStreamRowActions } from './components/data_stream_row_actions';
const DataStreamListPageLayout: React.FunctionComponent = ({ children }) => (
<WithHeaderLayout
@ -59,7 +61,7 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => {
const { pagination, pageSizeOptions } = usePagination();
// Fetch agent configs
// Fetch data streams
const { isLoading, data: dataStreamsData, sendRequest } = useGetDataStreams();
// Some configs retrieved, set up table props
@ -102,6 +104,23 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => {
name: i18n.translate('xpack.ingestManager.dataStreamList.integrationColumnTitle', {
defaultMessage: 'Integration',
}),
render(pkg: DataStream['package'], datastream: DataStream) {
return (
<EuiFlexGroup gutterSize="s" alignItems="center">
{datastream.package_version && (
<EuiFlexItem grow={false}>
<PackageIcon
packageName={pkg}
version={datastream.package_version}
size="m"
tryApi={true}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>{pkg}</EuiFlexItem>
</EuiFlexGroup>
);
},
},
{
field: 'last_activity',
@ -135,6 +154,16 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => {
}
},
},
{
name: i18n.translate('xpack.ingestManager.dataStreamList.actionsColumnTitle', {
defaultMessage: 'Actions',
}),
actions: [
{
render: (datastream: DataStream) => <DataStreamRowActions datastream={datastream} />,
},
],
},
];
return cols;
}, [fieldFormats]);

View file

@ -3,9 +3,10 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { RequestHandler } from 'src/core/server';
import { RequestHandler, SavedObjectsClientContract } from 'src/core/server';
import { DataStream } from '../../types';
import { GetDataStreamsResponse } from '../../../common';
import { GetDataStreamsResponse, KibanaAssetType } from '../../../common';
import { getPackageSavedObjects, getKibanaSavedObject } from '../../services/epm/packages/get';
const DATA_STREAM_INDEX_PATTERN = 'logs-*-*,metrics-*-*';
@ -100,7 +101,10 @@ export const getListHandler: RequestHandler = async (context, request, response)
index: { buckets: indexResults },
} = aggregations;
const dataStreams: DataStream[] = (indexResults as any[]).map(result => {
const packageSavedObjects = await getPackageSavedObjects(context.core.savedObjects.client);
const packageMetadata: any = {};
const dataStreamsPromises = (indexResults as any[]).map(async result => {
const {
key: indexName,
dataset: { buckets: datasetBuckets },
@ -109,17 +113,46 @@ export const getListHandler: RequestHandler = async (context, request, response)
package: { buckets: packageBuckets },
last_activity: { value_as_string: lastActivity },
} = result;
const pkg = packageBuckets.length ? packageBuckets[0].key : '';
const pkgSavedObject = packageSavedObjects.saved_objects.filter(p => p.id === pkg);
// if
// - the datastream is associated with a package
// - and the package has been installed through EPM
// - and we didn't pick the metadata in an earlier iteration of this map()
if (pkg !== '' && pkgSavedObject.length > 0 && !packageMetadata[pkg]) {
// then pick the dashboards from the package saved object
const dashboards =
pkgSavedObject[0].attributes?.installed?.filter(
o => o.type === KibanaAssetType.dashboard
) || [];
// and then pick the human-readable titles from the dashboard saved objects
const enhancedDashboards = await getEnhancedDashboards(
context.core.savedObjects.client,
dashboards
);
packageMetadata[pkg] = {
version: pkgSavedObject[0].attributes?.version || '',
dashboards: enhancedDashboards,
};
}
return {
index: indexName,
dataset: datasetBuckets.length ? datasetBuckets[0].key : '',
namespace: namespaceBuckets.length ? namespaceBuckets[0].key : '',
type: typeBuckets.length ? typeBuckets[0].key : '',
package: packageBuckets.length ? packageBuckets[0].key : '',
package: pkg,
package_version: packageMetadata[pkg] ? packageMetadata[pkg].version : '',
last_activity: lastActivity,
size_in_bytes: indexStats[indexName] ? indexStats[indexName].total.store.size_in_bytes : 0,
dashboards: packageMetadata[pkg] ? packageMetadata[pkg].dashboards : [],
};
});
const dataStreams: DataStream[] = await Promise.all(dataStreamsPromises);
body.data_streams = dataStreams;
return response.ok({
@ -132,3 +165,21 @@ export const getListHandler: RequestHandler = async (context, request, response)
});
}
};
const getEnhancedDashboards = async (
savedObjectsClient: SavedObjectsClientContract,
dashboards: any[]
) => {
const dashboardsPromises = dashboards.map(async db => {
const dbSavedObject: any = await getKibanaSavedObject(
savedObjectsClient,
KibanaAssetType.dashboard,
db.id
);
return {
id: db.id,
title: dbSavedObject.attributes?.title || db.id,
};
});
return await Promise.all(dashboardsPromises);
};

View file

@ -6,7 +6,7 @@
import { SavedObjectsClientContract } from 'src/core/server';
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants';
import { Installation, InstallationStatus, PackageInfo } from '../../../types';
import { Installation, InstallationStatus, PackageInfo, KibanaAssetType } from '../../../types';
import * as Registry from '../registry';
import { createInstallableFrom } from './index';
@ -32,11 +32,10 @@ export async function getPackages(
);
});
// get the installed packages
const results = await savedObjectsClient.find<Installation>({
type: PACKAGES_SAVED_OBJECT_TYPE,
});
const packageSavedObjects = await getPackageSavedObjects(savedObjectsClient);
// filter out any internal packages
const savedObjectsVisible = results.saved_objects.filter(o => !o.attributes.internal);
const savedObjectsVisible = packageSavedObjects.saved_objects.filter(o => !o.attributes.internal);
const packageList = registryItems
.map(item =>
createInstallableFrom(
@ -48,6 +47,12 @@ export async function getPackages(
return packageList;
}
export async function getPackageSavedObjects(savedObjectsClient: SavedObjectsClientContract) {
return savedObjectsClient.find<Installation>({
type: PACKAGES_SAVED_OBJECT_TYPE,
});
}
export async function getPackageKeysByStatus(
savedObjectsClient: SavedObjectsClientContract,
status: InstallationStatus
@ -114,3 +119,11 @@ function sortByName(a: { name: string }, b: { name: string }) {
return 0;
}
}
export async function getKibanaSavedObject(
savedObjectsClient: SavedObjectsClientContract,
type: KibanaAssetType,
id: string
) {
return savedObjectsClient.get(type, id);
}