mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Dashboard insights flyout with dashboard views (#187993)
## Summary close https://github.com/elastic/kibana/issues/183687 ## Feature - Implement dashboard view stats UI on top of usage counter that counts dashboard views for last 90 day and shows weekly histogram. - (Even if there is not a lot of data, we still show it as a weekly histogram, so it can be pretty empty intially)  ## Implementation ### Server side Dashboard plugin registers new routes to increase the view count and get stats. Routes are protected for users with dashboard access only. The implementation is located in `@kbn/content-management-content-insights-server` and internally uses usage counters. The retention is 90 days, so we can only show stats for last 90 days. ### Client side - Dashboard uses the client from `@kbn/content-management-content-insights-public` to increase the view count every time a user opens a dashboard. - TableListView opens the flyout from `@kbn/content-management-content-insights-public`to display the stats ## How to test - For new views just open a dashboard and check that view stat is increased - For old views you can populate the usage counters with historic data. I used the following script: https://gist.github.com/Dosant/425042fcf75d5e40e5a46374f6234a54
This commit is contained in:
parent
6db6a8d7a6
commit
64e1116b4b
61 changed files with 1173 additions and 176 deletions
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
|
@ -99,6 +99,8 @@ packages/kbn-config-mocks @elastic/kibana-core
|
|||
packages/kbn-config-schema @elastic/kibana-core
|
||||
src/plugins/console @elastic/kibana-management
|
||||
packages/content-management/content_editor @elastic/appex-sharedux
|
||||
packages/content-management/content_insights/content_insights_public @elastic/appex-sharedux
|
||||
packages/content-management/content_insights/content_insights_server @elastic/appex-sharedux
|
||||
examples/content_management_examples @elastic/appex-sharedux
|
||||
packages/content-management/favorites/favorites_public @elastic/appex-sharedux
|
||||
packages/content-management/favorites/favorites_server @elastic/appex-sharedux
|
||||
|
|
|
@ -222,6 +222,8 @@
|
|||
"@kbn/config-schema": "link:packages/kbn-config-schema",
|
||||
"@kbn/console-plugin": "link:src/plugins/console",
|
||||
"@kbn/content-management-content-editor": "link:packages/content-management/content_editor",
|
||||
"@kbn/content-management-content-insights-public": "link:packages/content-management/content_insights/content_insights_public",
|
||||
"@kbn/content-management-content-insights-server": "link:packages/content-management/content_insights/content_insights_server",
|
||||
"@kbn/content-management-examples-plugin": "link:examples/content_management_examples",
|
||||
"@kbn/content-management-favorites-public": "link:packages/content-management/favorites/favorites_public",
|
||||
"@kbn/content-management-favorites-server": "link:packages/content-management/favorites/favorites_server",
|
||||
|
|
|
@ -28,7 +28,6 @@ import type { Item } from '../types';
|
|||
import { MetadataForm } from './metadata_form';
|
||||
import { useMetadataForm } from './use_metadata_form';
|
||||
import type { CustomValidators } from './use_metadata_form';
|
||||
import { ActivityView } from './activity_view';
|
||||
|
||||
const getI18nTexts = ({ entityName }: { entityName: string }) => ({
|
||||
saveButtonLabel: i18n.translate('contentManagement.contentEditor.saveButtonLabel', {
|
||||
|
@ -56,7 +55,7 @@ export interface Props {
|
|||
}) => Promise<void>;
|
||||
customValidators?: CustomValidators;
|
||||
onCancel: () => void;
|
||||
showActivityView?: boolean;
|
||||
appendRows?: React.ReactNode;
|
||||
}
|
||||
|
||||
const capitalize = (str: string) => `${str.charAt(0).toLocaleUpperCase()}${str.substring(1)}`;
|
||||
|
@ -70,7 +69,7 @@ export const ContentEditorFlyoutContent: FC<Props> = ({
|
|||
onSave,
|
||||
onCancel,
|
||||
customValidators,
|
||||
showActivityView,
|
||||
appendRows,
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
@ -151,7 +150,7 @@ export const ContentEditorFlyoutContent: FC<Props> = ({
|
|||
TagList={TagList}
|
||||
TagSelector={TagSelector}
|
||||
>
|
||||
{showActivityView && <ActivityView item={item} />}
|
||||
{appendRows}
|
||||
</MetadataForm>
|
||||
</EuiFlyoutBody>
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ type CommonProps = Pick<
|
|||
| 'onCancel'
|
||||
| 'entityName'
|
||||
| 'customValidators'
|
||||
| 'showActivityView'
|
||||
| 'appendRows'
|
||||
>;
|
||||
|
||||
export type Props = CommonProps;
|
||||
|
|
|
@ -6,32 +6,30 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader } from '@elastic/eui';
|
||||
import type { Props } from './editor_flyout_content_container';
|
||||
|
||||
const ContentEditorFlyoutContentContainer = React.lazy(() =>
|
||||
import('./editor_flyout_content_container').then(
|
||||
({ ContentEditorFlyoutContentContainer: _ContentEditorFlyoutContentContainer }) => ({
|
||||
default: _ContentEditorFlyoutContentContainer,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
export const ContentEditorLoader: React.FC<Props> = (props) => {
|
||||
const [Editor, setEditor] = useState<React.ComponentType<Props> | null>(null);
|
||||
|
||||
const loadEditor = useCallback(async () => {
|
||||
const { ContentEditorFlyoutContentContainer } = await import(
|
||||
'./editor_flyout_content_container'
|
||||
);
|
||||
setEditor(() => ContentEditorFlyoutContentContainer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// On mount: load the editor asynchronously
|
||||
loadEditor();
|
||||
}, [loadEditor]);
|
||||
|
||||
return Editor ? (
|
||||
<Editor {...props} />
|
||||
) : (
|
||||
<>
|
||||
<EuiFlyoutHeader />
|
||||
<EuiFlyoutBody />
|
||||
<EuiFlyoutFooter />
|
||||
</>
|
||||
return (
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<>
|
||||
<EuiFlyoutHeader />
|
||||
<EuiFlyoutBody />
|
||||
<EuiFlyoutFooter />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ContentEditorFlyoutContentContainer {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -262,22 +262,5 @@ describe('<ContentEditorFlyoutContent />', () => {
|
|||
tags: ['id-3', 'id-4'], // New selection
|
||||
});
|
||||
});
|
||||
|
||||
test('should render activity view', async () => {
|
||||
await act(async () => {
|
||||
testBed = await setup({ showActivityView: true });
|
||||
});
|
||||
const { find, component } = testBed!;
|
||||
|
||||
expect(find('activityView').exists()).toBe(true);
|
||||
expect(find('activityView.createdByCard').exists()).toBe(true);
|
||||
expect(find('activityView.updatedByCard').exists()).toBe(false);
|
||||
|
||||
testBed.setProps({
|
||||
item: { ...savedObjectItem, updatedAt: '2021-01-01T00:00:00Z' },
|
||||
});
|
||||
component.update();
|
||||
expect(find('activityView.updatedByCard').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,7 +21,7 @@ export type OpenContentEditorParams = Pick<
|
|||
| 'readonlyReason'
|
||||
| 'entityName'
|
||||
| 'customValidators'
|
||||
| 'showActivityView'
|
||||
| 'appendRows'
|
||||
>;
|
||||
|
||||
export function useOpenContentEditor() {
|
||||
|
|
|
@ -30,7 +30,6 @@
|
|||
"@kbn/test-jest-helpers",
|
||||
"@kbn/react-kibana-mount",
|
||||
"@kbn/content-management-user-profiles",
|
||||
"@kbn/user-profile-components"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
|
|
64
packages/content-management/content_insights/README.mdx
Normal file
64
packages/content-management/content_insights/README.mdx
Normal file
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
id: sharedUX/ContentInsights
|
||||
slug: /shared-ux/content-insights
|
||||
title: Content Insights
|
||||
description: A set of Content Management services and component to provide insights on the content of Kibana.
|
||||
tags: ['shared-ux', 'component']
|
||||
date: 2024-08-06
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
The Content Insights is a set of Content Management services and components to provide insights on the content of Kibana.
|
||||
Currently, it allows to track the usage of your content and display the stats of it.
|
||||
|
||||
- The service can count the following events:
|
||||
- `viewed`
|
||||
- It provides the api for registering the routes to increase the count and to get the stats.
|
||||
- It provides the client to increase the count and to get the stats.
|
||||
- It provides a flyout and a component to display the stats as a total count and a weekly chart.
|
||||
- Internally it uses the usage collection plugin to store and search the data.
|
||||
|
||||
## API
|
||||
|
||||
// server side
|
||||
|
||||
```ts
|
||||
import { registerContentInsights } from '@kbn/content-management-content-insights-server';
|
||||
|
||||
if (plugins.usageCollection) {
|
||||
// Registers routes for tracking and fetching dashboard views
|
||||
registerContentInsights(
|
||||
{
|
||||
usageCollection: plugins.usageCollection,
|
||||
http: core.http,
|
||||
getStartServices: () =>
|
||||
core.getStartServices().then(([_, start]) => ({
|
||||
usageCollection: start.usageCollection!,
|
||||
})),
|
||||
},
|
||||
{
|
||||
domainId: 'dashboard',
|
||||
// makes sure that only users with read/all access to dashboard app can access the routes
|
||||
routeTags: ['access:dashboardUsageStats'],
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
// client side
|
||||
|
||||
```ts
|
||||
import { ContentInsightsClient } from '@kbn/content-management-content-insights-public';
|
||||
|
||||
const contentInsightsClient = new ContentInsightsClient(
|
||||
{ http: params.coreStart.http },
|
||||
{ domainId: 'dashboard' }
|
||||
);
|
||||
|
||||
contentInsightsClient.track(dashboardId, 'viewed');
|
||||
|
||||
// wrap component in `ContentInsightsProvider` and use the hook to open an insights flyout
|
||||
const openInsightsFlyout = useOpenInsightsFlyout();
|
||||
openInsightsFlyout({ item });
|
||||
```
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/content-management-content-insights-public
|
||||
|
||||
Refer to [README](../README.mdx)
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export {
|
||||
ContentInsightsProvider,
|
||||
type ContentInsightsServices,
|
||||
useServices as useContentInsightsServices,
|
||||
} from './src/services';
|
||||
|
||||
export {
|
||||
type ContentInsightsClientPublic,
|
||||
ContentInsightsClient,
|
||||
type ContentInsightsEventTypes,
|
||||
} from './src/client';
|
||||
|
||||
export { ActivityView, ViewsStats, type ActivityViewProps } from './src/components';
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/content-management/content_insights/content_insights_public'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/content-management-content-insights-public",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/content-management-content-insights-public",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { HttpStart } from '@kbn/core-http-browser';
|
||||
import type {
|
||||
ContentInsightsStats,
|
||||
ContentInsightsStatsResponse,
|
||||
} from '@kbn/content-management-content-insights-server';
|
||||
|
||||
export type ContentInsightsEventTypes = 'viewed';
|
||||
|
||||
/**
|
||||
* Public interface of the Content Management Insights service.
|
||||
*/
|
||||
export interface ContentInsightsClientPublic {
|
||||
track(id: string, eventType: ContentInsightsEventTypes): void;
|
||||
getStats(id: string, eventType: ContentInsightsEventTypes): Promise<ContentInsightsStats>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client for the Content Management Insights service.
|
||||
*/
|
||||
export class ContentInsightsClient implements ContentInsightsClientPublic {
|
||||
constructor(
|
||||
private readonly deps: { http: HttpStart },
|
||||
private readonly config: { domainId: string }
|
||||
) {}
|
||||
|
||||
track(id: string, eventType: ContentInsightsEventTypes) {
|
||||
this.deps.http
|
||||
.post(`/internal/content_management/insights/${this.config.domainId}/${id}/${eventType}`)
|
||||
.catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Could not track ${eventType} event for ${id}`, e);
|
||||
});
|
||||
}
|
||||
|
||||
async getStats(id: string, eventType: ContentInsightsEventTypes) {
|
||||
return this.deps.http
|
||||
.get<ContentInsightsStatsResponse>(
|
||||
`/internal/content_management/insights/${this.config.domainId}/${id}/${eventType}/stats`
|
||||
)
|
||||
.then((response) => response.result);
|
||||
}
|
||||
}
|
|
@ -6,15 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiFormRow,
|
||||
EuiIconTip,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React from 'react';
|
||||
|
@ -30,7 +22,7 @@ import { getUserDisplayName } from '@kbn/user-profile-components';
|
|||
import { Item } from '../types';
|
||||
|
||||
export interface ActivityViewProps {
|
||||
item: Pick<Item, 'createdBy' | 'createdAt' | 'updatedBy' | 'updatedAt' | 'managed'>;
|
||||
item: Pick<Partial<Item>, 'createdBy' | 'createdAt' | 'updatedBy' | 'updatedAt' | 'managed'>;
|
||||
}
|
||||
|
||||
export const ActivityView = ({ item }: ActivityViewProps) => {
|
||||
|
@ -54,78 +46,53 @@ export const ActivityView = ({ item }: ActivityViewProps) => {
|
|||
);
|
||||
|
||||
return (
|
||||
<EuiFormRow
|
||||
label={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="contentManagement.contentEditor.metadataForm.activityLabel"
|
||||
defaultMessage="Activity"
|
||||
/>{' '}
|
||||
<EuiIconTip
|
||||
type={'iInCircle'}
|
||||
iconProps={{ style: { verticalAlign: 'bottom' } }}
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="contentManagement.contentEditor.activity.activityLabelHelpText"
|
||||
defaultMessage="Activity data is auto-generated and cannot be updated."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
fullWidth
|
||||
data-test-subj={'activityView'}
|
||||
>
|
||||
<>
|
||||
<EuiFlexGroup gutterSize={'s'}>
|
||||
<EuiFlexItem grow={1} css={{ flexBasis: '50%', minWidth: 0 }}>
|
||||
<ActivityCard
|
||||
what={i18n.translate('contentManagement.contentEditor.activity.createdByLabelText', {
|
||||
defaultMessage: 'Created by',
|
||||
})}
|
||||
who={
|
||||
item.createdBy ? (
|
||||
<UserLabel uid={item.createdBy} />
|
||||
) : item.managed ? (
|
||||
<>{ManagedUserLabel}</>
|
||||
) : (
|
||||
<>
|
||||
{UnknownUserLabel}
|
||||
<NoCreatorTip />
|
||||
</>
|
||||
)
|
||||
}
|
||||
when={item.createdAt}
|
||||
data-test-subj={'createdByCard'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1} css={{ flexBasis: '50%', minWidth: 0 }}>
|
||||
{showLastUpdated && (
|
||||
<ActivityCard
|
||||
what={i18n.translate(
|
||||
'contentManagement.contentEditor.activity.lastUpdatedByLabelText',
|
||||
{ defaultMessage: 'Last updated by' }
|
||||
)}
|
||||
who={
|
||||
item.updatedBy ? (
|
||||
<UserLabel uid={item.updatedBy} />
|
||||
) : item.managed ? (
|
||||
<>{ManagedUserLabel}</>
|
||||
) : (
|
||||
<>
|
||||
{UnknownUserLabel}
|
||||
<NoUpdaterTip />
|
||||
</>
|
||||
)
|
||||
}
|
||||
when={item.updatedAt}
|
||||
data-test-subj={'updatedByCard'}
|
||||
/>
|
||||
<EuiFlexGroup gutterSize={'s'} data-test-subj={'activityView'}>
|
||||
<EuiFlexItem grow={1} css={{ flexBasis: '50%', minWidth: 0 }}>
|
||||
<ActivityCard
|
||||
what={i18n.translate('contentManagement.contentEditor.activity.createdByLabelText', {
|
||||
defaultMessage: 'Created by',
|
||||
})}
|
||||
who={
|
||||
item.createdBy ? (
|
||||
<UserLabel uid={item.createdBy} />
|
||||
) : item.managed ? (
|
||||
<>{ManagedUserLabel}</>
|
||||
) : (
|
||||
<>
|
||||
{UnknownUserLabel}
|
||||
<NoCreatorTip />
|
||||
</>
|
||||
)
|
||||
}
|
||||
when={item.createdAt}
|
||||
data-test-subj={'createdByCard'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={1} css={{ flexBasis: '50%', minWidth: 0 }}>
|
||||
{showLastUpdated && (
|
||||
<ActivityCard
|
||||
what={i18n.translate(
|
||||
'contentManagement.contentEditor.activity.lastUpdatedByLabelText',
|
||||
{ defaultMessage: 'Last updated by' }
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
</EuiFormRow>
|
||||
who={
|
||||
item.updatedBy ? (
|
||||
<UserLabel uid={item.updatedBy} />
|
||||
) : item.managed ? (
|
||||
<>{ManagedUserLabel}</>
|
||||
) : (
|
||||
<>
|
||||
{UnknownUserLabel}
|
||||
<NoUpdaterTip />
|
||||
</>
|
||||
)
|
||||
}
|
||||
when={item.updatedAt}
|
||||
data-test-subj={'updatedByCard'}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { ActivityView, type ActivityViewProps } from './activity_view';
|
||||
export { ViewsStats } from './views_stats';
|
|
@ -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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { ViewsStats } from './views_stats';
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Chart, Settings, DARK_THEME, LIGHT_THEME, BarSeries, Axis } from '@elastic/charts';
|
||||
import { formatDate, useEuiTheme } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import moment from 'moment';
|
||||
|
||||
const dateFormatter = (d: Date) => formatDate(d, `MM/DD`);
|
||||
|
||||
const seriesName = i18n.translate('contentManagement.contentEditor.viewsStats.viewsLabel', {
|
||||
defaultMessage: 'Views',
|
||||
});
|
||||
|
||||
const weekOfFormatter = (date: Date) =>
|
||||
i18n.translate('contentManagement.contentEditor.viewsStats.weekOfLabel', {
|
||||
defaultMessage: 'Week of {date}',
|
||||
values: { date: dateFormatter(date) },
|
||||
});
|
||||
|
||||
export const ViewsChart = ({ data }: { data: Array<[week: number, views: number]> }) => {
|
||||
const { colorMode } = useEuiTheme();
|
||||
|
||||
const momentDow = moment().localeData().firstDayOfWeek(); // configured from advanced settings
|
||||
const isoDow = momentDow === 0 ? 7 : momentDow;
|
||||
|
||||
const momentTz = moment().tz(); // configured from advanced settings
|
||||
|
||||
return (
|
||||
<Chart size={{ height: 240 }}>
|
||||
<Settings
|
||||
baseTheme={colorMode === 'DARK' ? DARK_THEME : LIGHT_THEME}
|
||||
showLegend={false}
|
||||
dow={isoDow}
|
||||
/>
|
||||
<BarSeries
|
||||
id="viewsOverTime"
|
||||
name={seriesName}
|
||||
data={data}
|
||||
xAccessor={0}
|
||||
yAccessors={[1]}
|
||||
enableHistogramMode={true}
|
||||
yNice={true}
|
||||
minBarHeight={1}
|
||||
xScaleType="time"
|
||||
timeZone={momentTz}
|
||||
/>
|
||||
|
||||
<Axis
|
||||
id="time"
|
||||
position="bottom"
|
||||
tickFormat={weekOfFormatter}
|
||||
timeAxisLayerCount={2}
|
||||
style={{
|
||||
tickLabel: {
|
||||
visible: true,
|
||||
padding: 0,
|
||||
rotation: 0,
|
||||
alignment: {
|
||||
vertical: 'bottom',
|
||||
horizontal: 'left',
|
||||
},
|
||||
},
|
||||
tickLine: {
|
||||
visible: true,
|
||||
size: 0,
|
||||
padding: 4,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Axis id="views" position="left" maximumFractionDigits={0} />
|
||||
</Chart>
|
||||
);
|
||||
};
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, within } from '@testing-library/react';
|
||||
import { I18nProvider } from '@kbn/i18n-react';
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||
import { ContentInsightsProvider } from '../../services';
|
||||
|
||||
import { ViewsStats } from './views_stats';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-07-15T14:00:00.00Z'));
|
||||
});
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
afterAll(() => jest.useRealTimers());
|
||||
|
||||
const mockStats = jest.fn().mockResolvedValue({
|
||||
from: '2024-05-01T00:00:00.000Z',
|
||||
count: 10,
|
||||
daily: [
|
||||
{
|
||||
date: '2024-05-01T00:00:00.000Z',
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
date: '2024-06-01T00:00:00.000Z',
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const WrappedViewsStats = () => {
|
||||
const item = { id: '1' } as any;
|
||||
const client = {
|
||||
track: jest.fn(),
|
||||
getStats: mockStats,
|
||||
};
|
||||
return (
|
||||
<I18nProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ContentInsightsProvider contentInsightsClient={client}>
|
||||
<ViewsStats item={item} />
|
||||
</ContentInsightsProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('ViewsStats', () => {
|
||||
test('should render the total views and chart', async () => {
|
||||
const { getByTestId } = render(<WrappedViewsStats />);
|
||||
const totalViews = getByTestId('views-stats-total-views');
|
||||
expect(totalViews).toBeInTheDocument();
|
||||
await within(totalViews).findByText('Views (last 75 days)');
|
||||
await within(totalViews).findByText('10');
|
||||
});
|
||||
});
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiPanel, EuiStat, EuiSpacer, useEuiTheme, EuiIconTip } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { ContentInsightsStats } from '@kbn/content-management-content-insights-server';
|
||||
import { css } from '@emotion/react';
|
||||
import moment from 'moment';
|
||||
|
||||
import { Item } from '../../types';
|
||||
import { ViewsChart } from './views_chart';
|
||||
import { useServices } from '../../services';
|
||||
|
||||
export const ViewsStats = ({ item }: { item: Item }) => {
|
||||
const contentInsightsClient = useServices()?.contentInsightsClient;
|
||||
|
||||
if (!contentInsightsClient) {
|
||||
throw new Error('Content insights client is not available');
|
||||
}
|
||||
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const { data, isLoading } = useQuery(
|
||||
['content-insights:viewed', item.id],
|
||||
async () =>
|
||||
contentInsightsClient.getStats(item.id, 'viewed').then((response) => ({
|
||||
totalDays: getTotalDays(response),
|
||||
totalViews: response.count,
|
||||
chartData: getChartData(response),
|
||||
})),
|
||||
{
|
||||
staleTime: 0,
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiPanel hasBorder paddingSize={'s'}>
|
||||
<EuiStat
|
||||
titleSize={'s'}
|
||||
data-test-subj={'views-stats-total-views'}
|
||||
title={data?.totalViews ?? '–'}
|
||||
description={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="contentManagement.contentEditor.viewsStats.viewsLastNDaysLabel"
|
||||
defaultMessage="Views (last {n} days)"
|
||||
values={{ n: data?.totalDays }}
|
||||
/>
|
||||
<NoViewsTip />
|
||||
</>
|
||||
}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<EuiSpacer
|
||||
size={'s'}
|
||||
css={css`
|
||||
border-bottom: ${euiTheme.border.thin};
|
||||
`}
|
||||
/>
|
||||
<EuiSpacer size={'m'} />
|
||||
|
||||
<ViewsChart data={data?.chartData ?? []} />
|
||||
</EuiPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const NoViewsTip = () => (
|
||||
<EuiIconTip
|
||||
aria-label={i18n.translate('contentManagement.contentEditor.viewsStats.noViewsTipAriaLabel', {
|
||||
defaultMessage: 'Additional information',
|
||||
})}
|
||||
position="top"
|
||||
color="inherit"
|
||||
iconProps={{ style: { verticalAlign: 'text-bottom', marginLeft: 2 } }}
|
||||
css={{ textWrap: 'balance' }}
|
||||
type="questionInCircle"
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="contentManagement.contentEditor.viewsStats.noViewsTip"
|
||||
defaultMessage="Views are counted everytime someone opens a dashboard (after version 8.16)"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
export function getTotalDays(stats: ContentInsightsStats) {
|
||||
return moment.utc().diff(moment.utc(stats.from), 'days');
|
||||
}
|
||||
|
||||
export function getChartData(stats: ContentInsightsStats): Array<[week: number, views: number]> {
|
||||
// prepare a map of views by week starting from the first full week till the current week
|
||||
const viewsByWeek = new Map<string, number>();
|
||||
|
||||
// we use moment to handle weeks because it is configured with the correct first day of the week from advanced settings
|
||||
// by default it is sunday
|
||||
const thisWeek = moment().startOf('week');
|
||||
const firstFullWeek = moment(stats.from).add(7, 'day').startOf('week');
|
||||
|
||||
// fill the map with weeks starting from the first full week till the current week
|
||||
let current = firstFullWeek.clone();
|
||||
while (current.isSameOrBefore(thisWeek)) {
|
||||
viewsByWeek.set(current.toISOString(), 0);
|
||||
current = current.clone().add(1, 'week');
|
||||
}
|
||||
|
||||
// fill the map with views per week
|
||||
for (let i = 0; i < stats.daily.length; i++) {
|
||||
const week = moment(stats.daily[i].date).startOf('week').toISOString();
|
||||
if (viewsByWeek.has(week)) {
|
||||
viewsByWeek.set(week, viewsByWeek.get(week)! + stats.daily[i].count);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(viewsByWeek.entries())
|
||||
.sort((a, b) => (a[0] > b[0] ? 1 : -1))
|
||||
.map(([date, views]) => [new Date(date).getTime(), views]);
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import { getChartData, getTotalDays } from './views_stats';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date('2024-07-15T14:00:00.00Z'));
|
||||
moment.updateLocale('en', {
|
||||
week: {
|
||||
dow: 1, // test with Monday is the first day of the week.
|
||||
},
|
||||
});
|
||||
});
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
afterAll(() => jest.useRealTimers());
|
||||
|
||||
describe('getTotalDays', () => {
|
||||
test('should return the total days between the current date and the from date', () => {
|
||||
const totalDays = getTotalDays({
|
||||
from: '2024-07-01T00:00:00.000Z',
|
||||
daily: [],
|
||||
count: 0,
|
||||
});
|
||||
expect(totalDays).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChartData', () => {
|
||||
test('should return views bucketed by week', () => {
|
||||
const data = getChartData({
|
||||
from: '2024-05-01T00:00:00.000Z',
|
||||
daily: [],
|
||||
count: 0,
|
||||
});
|
||||
expect(data.every(([, count]) => count === 0)).toBe(true);
|
||||
|
||||
// moment is mocked with America/New_York timezone, hence +04:00 offset
|
||||
expect(data.map((d) => new Date(d[0]).toISOString())).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"2024-05-06T04:00:00.000Z",
|
||||
"2024-05-13T04:00:00.000Z",
|
||||
"2024-05-20T04:00:00.000Z",
|
||||
"2024-05-27T04:00:00.000Z",
|
||||
"2024-06-03T04:00:00.000Z",
|
||||
"2024-06-10T04:00:00.000Z",
|
||||
"2024-06-17T04:00:00.000Z",
|
||||
"2024-06-24T04:00:00.000Z",
|
||||
"2024-07-01T04:00:00.000Z",
|
||||
"2024-07-08T04:00:00.000Z",
|
||||
"2024-07-15T04:00:00.000Z",
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { FC, PropsWithChildren, useContext } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { ContentInsightsClientPublic } from './client';
|
||||
|
||||
/**
|
||||
* Abstract external services for this component.
|
||||
*/
|
||||
export interface ContentInsightsServices {
|
||||
contentInsightsClient: ContentInsightsClientPublic;
|
||||
}
|
||||
|
||||
const ContentInsightsContext = React.createContext<ContentInsightsServices | null>(null);
|
||||
|
||||
/**
|
||||
* Abstract external service Provider.
|
||||
*/
|
||||
export const ContentInsightsProvider: FC<PropsWithChildren<Partial<ContentInsightsServices>>> = ({
|
||||
children,
|
||||
...services
|
||||
}) => {
|
||||
if (!services.contentInsightsClient) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentInsightsContext.Provider
|
||||
value={{ contentInsightsClient: services.contentInsightsClient }}
|
||||
>
|
||||
{children}
|
||||
</ContentInsightsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* React hook for accessing pre-wired services.
|
||||
*/
|
||||
export function useServices() {
|
||||
const context = useContext(ContentInsightsContext);
|
||||
return context;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
|
||||
|
||||
export type Item = UserContentCommonSchema;
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node",
|
||||
"react",
|
||||
"@kbn/ambient-ui-types",
|
||||
"@kbn/ambient-storybook-types",
|
||||
"@emotion/react/types/css-prop",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/content-management-user-profiles",
|
||||
"@kbn/i18n-react",
|
||||
"@kbn/i18n",
|
||||
"@kbn/user-profile-components",
|
||||
"@kbn/core-http-browser",
|
||||
"@kbn/content-management-content-insights-server",
|
||||
"@kbn/content-management-table-list-view-common",
|
||||
]
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
# @kbn/content-management-content-insights-server
|
||||
|
||||
Refer to [README](../README.mdx)
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export {
|
||||
registerContentInsights,
|
||||
type ContentInsightsStatsResponse,
|
||||
type ContentInsightsStats,
|
||||
} from './src/register';
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
preset: '@kbn/test/jest_node',
|
||||
rootDir: '../../../..',
|
||||
roots: ['<rootDir>/packages/content-management/content_insights/content_insights_server'],
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-server",
|
||||
"id": "@kbn/content-management-content-insights-server",
|
||||
"owner": "@elastic/appex-sharedux"
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/content-management-content-insights-server",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type {
|
||||
UsageCollectionSetup,
|
||||
UsageCollectionStart,
|
||||
} from '@kbn/usage-collection-plugin/server';
|
||||
import type { CoreSetup } from '@kbn/core/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* Configuration for the usage counter
|
||||
*/
|
||||
export interface ContentInsightsConfig {
|
||||
/**
|
||||
* e.g. 'dashboard'
|
||||
* passed as a domainId to usage counter apis
|
||||
*/
|
||||
domainId: string;
|
||||
|
||||
/**
|
||||
* Can control created routes access via access tags
|
||||
*/
|
||||
routeTags?: string[];
|
||||
|
||||
/**
|
||||
* Retention period in days for usage counter data
|
||||
*/
|
||||
retentionPeriodDays?: number;
|
||||
}
|
||||
|
||||
export interface ContentInsightsDependencies {
|
||||
usageCollection: UsageCollectionSetup;
|
||||
http: CoreSetup['http'];
|
||||
getStartServices: () => Promise<{
|
||||
usageCollection: UsageCollectionStart;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ContentInsightsStatsResponse {
|
||||
result: ContentInsightsStats;
|
||||
}
|
||||
|
||||
export interface ContentInsightsStats {
|
||||
/**
|
||||
* The date from which the data is counted
|
||||
*/
|
||||
from: string;
|
||||
/**
|
||||
* Total count of events
|
||||
*/
|
||||
count: number;
|
||||
/**
|
||||
* Daily counts of events
|
||||
*/
|
||||
daily: Array<{
|
||||
date: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/*
|
||||
* Registers the content insights routes
|
||||
*/
|
||||
export const registerContentInsights = (
|
||||
{ usageCollection, http, getStartServices }: ContentInsightsDependencies,
|
||||
config: ContentInsightsConfig
|
||||
) => {
|
||||
const retentionPeriodDays = config.retentionPeriodDays ?? 90;
|
||||
const counter = usageCollection.createUsageCounter(config.domainId, {
|
||||
retentionPeriodDays,
|
||||
});
|
||||
|
||||
const router = http.createRouter();
|
||||
const validate = {
|
||||
params: schema.object({
|
||||
id: schema.string(),
|
||||
eventType: schema.literal('viewed'),
|
||||
}),
|
||||
};
|
||||
router.post(
|
||||
{
|
||||
path: `/internal/content_management/insights/${config.domainId}/{id}/{eventType}`,
|
||||
validate,
|
||||
options: {
|
||||
tags: config.routeTags,
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
const { id, eventType } = req.params;
|
||||
|
||||
counter.incrementCounter({
|
||||
counterName: id,
|
||||
counterType: eventType,
|
||||
namespace: (await context.core).savedObjects.client.getCurrentNamespace(),
|
||||
});
|
||||
return res.ok();
|
||||
}
|
||||
);
|
||||
router.get(
|
||||
{
|
||||
path: `/internal/content_management/insights/${config.domainId}/{id}/{eventType}/stats`,
|
||||
validate,
|
||||
options: {
|
||||
tags: config.routeTags,
|
||||
},
|
||||
},
|
||||
async (context, req, res) => {
|
||||
const { id, eventType } = req.params;
|
||||
const {
|
||||
usageCollection: { search },
|
||||
} = await getStartServices();
|
||||
|
||||
const startOfDay = moment.utc().startOf('day');
|
||||
const from = startOfDay.clone().subtract(retentionPeriodDays, 'days');
|
||||
|
||||
const result = await search({
|
||||
filters: {
|
||||
domainId: config.domainId,
|
||||
counterName: id,
|
||||
counterType: eventType,
|
||||
namespace: (await context.core).savedObjects.client.getCurrentNamespace(),
|
||||
from: from.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const response: ContentInsightsStatsResponse = {
|
||||
result: {
|
||||
from: from.toISOString(),
|
||||
count: result.counters[0]?.count ?? 0,
|
||||
daily: (result.counters[0]?.records ?? []).map((record) => ({
|
||||
date: record.updatedAt,
|
||||
count: record.count,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
return res.ok({ body: response });
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "target/types",
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*"
|
||||
],
|
||||
"kbn_references": [
|
||||
"@kbn/usage-collection-plugin",
|
||||
"@kbn/core",
|
||||
"@kbn/config-schema",
|
||||
]
|
||||
}
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiFormRow, EuiIconTip, EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import React, { FC } from 'react';
|
||||
import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
|
||||
import { ActivityView, ViewsStats } from '@kbn/content-management-content-insights-public';
|
||||
|
||||
/**
|
||||
* This component is used as an extension for the ContentEditor to render the ActivityView and ViewsStats inside the flyout without depending on them directly
|
||||
*/
|
||||
export const ContentEditorActivityRow: FC<{ item: UserContentCommonSchema }> = ({ item }) => {
|
||||
return (
|
||||
<EuiFormRow
|
||||
fullWidth
|
||||
label={
|
||||
<>
|
||||
<FormattedMessage
|
||||
id="contentManagement.tableList.contentEditor.activityLabel"
|
||||
defaultMessage="Activity"
|
||||
/>{' '}
|
||||
<EuiIconTip
|
||||
type={'iInCircle'}
|
||||
iconProps={{ style: { verticalAlign: 'bottom' } }}
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="contentManagement.tableList.contentEditor.activityLabelHelpText"
|
||||
defaultMessage="Activity data is auto-generated and cannot be updated."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<ActivityView item={item} />
|
||||
<EuiSpacer size={'s'} />
|
||||
<ViewsStats item={item} />
|
||||
</>
|
||||
</EuiFormRow>
|
||||
);
|
||||
};
|
|
@ -14,6 +14,10 @@ import {
|
|||
ContentEditorKibanaProvider,
|
||||
type SavedObjectsReference,
|
||||
} from '@kbn/content-management-content-editor';
|
||||
import {
|
||||
ContentInsightsClientPublic,
|
||||
ContentInsightsProvider,
|
||||
} from '@kbn/content-management-content-insights-public';
|
||||
import type { AnalyticsServiceStart } from '@kbn/core-analytics-browser';
|
||||
import type { I18nStart } from '@kbn/core-i18n-browser';
|
||||
import type { MountPoint, OverlayRef } from '@kbn/core-mount-utils-browser';
|
||||
|
@ -174,6 +178,11 @@ export interface TableListViewKibanaDependencies {
|
|||
* The favorites client to enable the favorites feature.
|
||||
*/
|
||||
favorites?: FavoritesClientPublic;
|
||||
|
||||
/**
|
||||
* Content insights client to enable content insights features.
|
||||
*/
|
||||
contentInsightsClient?: ContentInsightsClientPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -240,37 +249,42 @@ export const TableListViewKibanaProvider: FC<
|
|||
<RedirectAppLinksKibanaProvider coreStart={core}>
|
||||
<UserProfilesKibanaProvider core={core}>
|
||||
<ContentEditorKibanaProvider core={core} savedObjectsTagging={savedObjectsTagging}>
|
||||
<FavoritesContextProvider
|
||||
favoritesClient={services.favorites}
|
||||
notifyError={(title, text) => {
|
||||
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
|
||||
}}
|
||||
>
|
||||
<TableListViewProvider
|
||||
canEditAdvancedSettings={Boolean(application.capabilities.advancedSettings?.save)}
|
||||
getListingLimitSettingsUrl={() =>
|
||||
application.getUrlForApp('management', {
|
||||
path: `/kibana/settings?query=savedObjects:listingLimit`,
|
||||
})
|
||||
}
|
||||
<ContentInsightsProvider contentInsightsClient={services.contentInsightsClient}>
|
||||
<FavoritesContextProvider
|
||||
favoritesClient={services.favorites}
|
||||
notifyError={(title, text) => {
|
||||
notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text });
|
||||
}}
|
||||
searchQueryParser={searchQueryParser}
|
||||
DateFormatterComp={(props) => <FormattedRelative {...props} />}
|
||||
currentAppId$={application.currentAppId$}
|
||||
navigateToUrl={application.navigateToUrl}
|
||||
isTaggingEnabled={() => Boolean(savedObjectsTagging)}
|
||||
isFavoritesEnabled={() => Boolean(services.favorites)}
|
||||
getTagList={getTagList}
|
||||
TagList={TagList}
|
||||
itemHasTags={itemHasTags}
|
||||
getTagIdsFromReferences={getTagIdsFromReferences}
|
||||
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
|
||||
>
|
||||
{children}
|
||||
</TableListViewProvider>
|
||||
</FavoritesContextProvider>
|
||||
<TableListViewProvider
|
||||
canEditAdvancedSettings={Boolean(application.capabilities.advancedSettings?.save)}
|
||||
getListingLimitSettingsUrl={() =>
|
||||
application.getUrlForApp('management', {
|
||||
path: `/kibana/settings?query=savedObjects:listingLimit`,
|
||||
})
|
||||
}
|
||||
notifyError={(title, text) => {
|
||||
notifications.toasts.addDanger({
|
||||
title: toMountPoint(title, startServices),
|
||||
text,
|
||||
});
|
||||
}}
|
||||
searchQueryParser={searchQueryParser}
|
||||
DateFormatterComp={(props) => <FormattedRelative {...props} />}
|
||||
currentAppId$={application.currentAppId$}
|
||||
navigateToUrl={application.navigateToUrl}
|
||||
isTaggingEnabled={() => Boolean(savedObjectsTagging)}
|
||||
isFavoritesEnabled={() => Boolean(services.favorites)}
|
||||
getTagList={getTagList}
|
||||
TagList={TagList}
|
||||
itemHasTags={itemHasTags}
|
||||
getTagIdsFromReferences={getTagIdsFromReferences}
|
||||
getTagManagementUrl={() => core.http.basePath.prepend(TAG_MANAGEMENT_APP_URL)}
|
||||
>
|
||||
{children}
|
||||
</TableListViewProvider>
|
||||
</FavoritesContextProvider>
|
||||
</ContentInsightsProvider>
|
||||
</ContentEditorKibanaProvider>
|
||||
</UserProfilesKibanaProvider>
|
||||
</RedirectAppLinksKibanaProvider>
|
||||
|
|
|
@ -1052,7 +1052,7 @@ describe('TableListView', () => {
|
|||
});
|
||||
|
||||
describe('search', () => {
|
||||
const updatedAt = new Date('2023-07-15').toISOString();
|
||||
const updatedAt = moment('2023-07-15').toISOString();
|
||||
|
||||
const hits: UserContentCommonSchema[] = [
|
||||
{
|
||||
|
@ -1146,7 +1146,7 @@ describe('TableListView', () => {
|
|||
{
|
||||
id: 'item-from-search',
|
||||
type: 'dashboard',
|
||||
updatedAt: new Date('2023-07-01').toISOString(),
|
||||
updatedAt: moment('2023-07-01').toISOString(),
|
||||
attributes: {
|
||||
title: 'Item from search',
|
||||
},
|
||||
|
|
|
@ -38,6 +38,10 @@ import type {
|
|||
} from '@kbn/content-management-content-editor';
|
||||
import type { UserContentCommonSchema } from '@kbn/content-management-table-list-view-common';
|
||||
import type { RecentlyAccessed } from '@kbn/recently-accessed';
|
||||
import {
|
||||
ContentInsightsProvider,
|
||||
useContentInsightsServices,
|
||||
} from '@kbn/content-management-content-insights-public';
|
||||
|
||||
import {
|
||||
Table,
|
||||
|
@ -54,12 +58,10 @@ import { useTags } from './use_tags';
|
|||
import { useInRouterContext, useUrlState } from './use_url_state';
|
||||
import { RowActions, TableItemsRowActions } from './types';
|
||||
import { sortByRecentlyAccessed } from './components/table_sort_select';
|
||||
import { ContentEditorActivityRow } from './components/content_editor_activity_row';
|
||||
|
||||
interface ContentEditorConfig
|
||||
extends Pick<
|
||||
OpenContentEditorParams,
|
||||
'isReadonly' | 'onSave' | 'customValidators' | 'showActivityView'
|
||||
> {
|
||||
extends Pick<OpenContentEditorParams, 'isReadonly' | 'onSave' | 'customValidators'> {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
|
@ -371,6 +373,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
|
|||
} = useServices();
|
||||
|
||||
const openContentEditor = useOpenContentEditor();
|
||||
const contentInsightsServices = useContentInsightsServices();
|
||||
|
||||
const isInRouterContext = useInRouterContext();
|
||||
|
||||
|
@ -567,6 +570,12 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
|
|||
|
||||
close();
|
||||
}),
|
||||
appendRows: contentInsightsServices && (
|
||||
// have to "REWRAP" in the provider here because it will be rendered in a different context
|
||||
<ContentInsightsProvider {...contentInsightsServices}>
|
||||
<ContentEditorActivityRow item={item} />
|
||||
</ContentInsightsProvider>
|
||||
),
|
||||
});
|
||||
},
|
||||
[
|
||||
|
@ -576,6 +585,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
|
|||
contentEditor,
|
||||
tableItemsRowActions,
|
||||
fetchItems,
|
||||
contentInsightsServices,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -713,7 +723,7 @@ function TableListViewTableComp<T extends UserContentCommonSchema>({
|
|||
name: i18n.translate('contentManagement.tableList.listing.table.actionTitle', {
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
width: `${32 * actions.length}px`,
|
||||
width: `72px`,
|
||||
actions,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
"@kbn/react-kibana-mount",
|
||||
"@kbn/content-management-user-profiles",
|
||||
"@kbn/recently-accessed",
|
||||
"@kbn/content-management-content-insights-public",
|
||||
"@kbn/content-management-favorites-public"
|
||||
],
|
||||
"exclude": [
|
||||
|
|
|
@ -178,6 +178,7 @@ export const initializeDashboard = async ({
|
|||
query: queryService,
|
||||
search: { session },
|
||||
},
|
||||
dashboardContentInsights,
|
||||
} = pluginServices.getServices();
|
||||
const {
|
||||
queryString,
|
||||
|
@ -635,5 +636,13 @@ export const initializeDashboard = async ({
|
|||
});
|
||||
}
|
||||
|
||||
if (loadDashboardReturn.dashboardId && !incomingEmbeddable) {
|
||||
// We count a new view every time a user opens a dashboard, both in view or edit mode
|
||||
// We don't count views when a user is editing a dashboard and is returning from an editor after saving
|
||||
// however, there is an edge case that we now count a new view when a user is editing a dashboard and is returning from an editor by canceling
|
||||
// TODO: this should be revisited by making embeddable transfer support canceling logic https://github.com/elastic/kibana/issues/190485
|
||||
dashboardContentInsights.trackDashboardView(loadDashboardReturn.dashboardId);
|
||||
}
|
||||
|
||||
return { input: initialDashboardInput, searchSessionId: initialSearchSessionId };
|
||||
};
|
||||
|
|
|
@ -45,6 +45,7 @@ export const DashboardListing = ({
|
|||
savedObjectsTagging,
|
||||
coreContext: { executionContext },
|
||||
userProfile,
|
||||
dashboardContentInsights: { contentInsightsClient },
|
||||
dashboardFavorites,
|
||||
} = pluginServices.getServices();
|
||||
|
||||
|
@ -86,6 +87,7 @@ export const DashboardListing = ({
|
|||
savedObjectsTagging: savedObjectsTaggingFakePlugin,
|
||||
FormattedRelative,
|
||||
favorites: dashboardFavorites,
|
||||
contentInsightsClient,
|
||||
}}
|
||||
>
|
||||
<TableListView<DashboardSavedObjectUserContent> {...tableListViewTableProps}>
|
||||
|
|
|
@ -47,6 +47,7 @@ export const DashboardListingTable = ({
|
|||
coreContext: { executionContext },
|
||||
chrome: { theme },
|
||||
userProfile,
|
||||
dashboardContentInsights: { contentInsightsClient },
|
||||
} = pluginServices.getServices();
|
||||
|
||||
useExecutionContext(executionContext, {
|
||||
|
@ -98,6 +99,7 @@ export const DashboardListingTable = ({
|
|||
core={core}
|
||||
savedObjectsTagging={savedObjectsTaggingFakePlugin}
|
||||
FormattedRelative={FormattedRelative}
|
||||
contentInsightsClient={contentInsightsClient}
|
||||
>
|
||||
<>
|
||||
<DashboardUnsavedListing
|
||||
|
|
|
@ -154,7 +154,6 @@ describe('useDashboardListingTable', () => {
|
|||
onSave: expect.any(Function),
|
||||
isReadonly: false,
|
||||
customValidators: expect.any(Object),
|
||||
showActivityView: true,
|
||||
},
|
||||
createdByEnabled: true,
|
||||
recentlyAccessed: expect.objectContaining({ get: expect.any(Function) }),
|
||||
|
|
|
@ -283,7 +283,6 @@ export const useDashboardListingTable = ({
|
|||
isReadonly: !showWriteControls,
|
||||
onSave: updateItemMeta,
|
||||
customValidators: contentEditorValidators,
|
||||
showActivityView: true,
|
||||
},
|
||||
createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem,
|
||||
deleteItems: !showWriteControls ? undefined : deleteItems,
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public';
|
||||
import type { DashboardContentInsightsService } from './types';
|
||||
|
||||
type DashboardContentInsightsServiceFactory = PluginServiceFactory<DashboardContentInsightsService>;
|
||||
|
||||
export const dashboardContentInsightsServiceFactory: DashboardContentInsightsServiceFactory =
|
||||
() => {
|
||||
return {
|
||||
trackDashboardView: jest.fn(),
|
||||
contentInsightsClient: {
|
||||
track: jest.fn(),
|
||||
getStats: jest.fn(),
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public';
|
||||
import { ContentInsightsClient } from '@kbn/content-management-content-insights-public';
|
||||
import { DashboardStartDependencies } from '../../plugin';
|
||||
import { DashboardContentInsightsService } from './types';
|
||||
|
||||
export type DashboardContentInsightsServiceFactory = KibanaPluginServiceFactory<
|
||||
DashboardContentInsightsService,
|
||||
DashboardStartDependencies
|
||||
>;
|
||||
|
||||
export const dashboardContentInsightsServiceFactory: DashboardContentInsightsServiceFactory = (
|
||||
params
|
||||
) => {
|
||||
const contentInsightsClient = new ContentInsightsClient(
|
||||
{ http: params.coreStart.http },
|
||||
{ domainId: 'dashboard' }
|
||||
);
|
||||
|
||||
return {
|
||||
trackDashboardView: (dashboardId: string) => {
|
||||
contentInsightsClient.track(dashboardId, 'viewed');
|
||||
},
|
||||
contentInsightsClient,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 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 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { ContentInsightsClientPublic } from '@kbn/content-management-content-insights-public';
|
||||
|
||||
export interface DashboardContentInsightsService {
|
||||
trackDashboardView: (dashboardId: string) => void;
|
||||
contentInsightsClient: ContentInsightsClientPublic;
|
||||
}
|
|
@ -49,6 +49,7 @@ import { noDataPageServiceFactory } from './no_data_page/no_data_page_service.st
|
|||
import { uiActionsServiceFactory } from './ui_actions/ui_actions_service.stub';
|
||||
import { dashboardRecentlyAccessedServiceFactory } from './dashboard_recently_accessed/dashboard_recently_accessed.stub';
|
||||
import { dashboardFavoritesServiceFactory } from './dashboard_favorites/dashboard_favorites_service.stub';
|
||||
import { dashboardContentInsightsServiceFactory } from './dashboard_content_insights/dashboard_content_insights.stub';
|
||||
|
||||
export const providers: PluginServiceProviders<DashboardServices> = {
|
||||
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory),
|
||||
|
@ -85,6 +86,7 @@ export const providers: PluginServiceProviders<DashboardServices> = {
|
|||
userProfile: new PluginServiceProvider(userProfileServiceFactory),
|
||||
observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceStubFactory),
|
||||
dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedServiceFactory),
|
||||
dashboardContentInsights: new PluginServiceProvider(dashboardContentInsightsServiceFactory),
|
||||
dashboardFavorites: new PluginServiceProvider(dashboardFavoritesServiceFactory),
|
||||
};
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ import { observabilityAIAssistantServiceFactory } from './observability_ai_assis
|
|||
import { userProfileServiceFactory } from './user_profile/user_profile_service';
|
||||
import { dashboardRecentlyAccessedFactory } from './dashboard_recently_accessed/dashboard_recently_accessed';
|
||||
import { dashboardFavoritesServiceFactory } from './dashboard_favorites/dashboard_favorites_service';
|
||||
import { dashboardContentInsightsServiceFactory } from './dashboard_content_insights/dashboard_content_insights_service';
|
||||
|
||||
const providers: PluginServiceProviders<DashboardServices, DashboardPluginServiceParams> = {
|
||||
dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory, [
|
||||
|
@ -99,6 +100,7 @@ const providers: PluginServiceProviders<DashboardServices, DashboardPluginServic
|
|||
observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceFactory),
|
||||
userProfile: new PluginServiceProvider(userProfileServiceFactory),
|
||||
dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedFactory, ['http']),
|
||||
dashboardContentInsights: new PluginServiceProvider(dashboardContentInsightsServiceFactory),
|
||||
dashboardFavorites: new PluginServiceProvider(dashboardFavoritesServiceFactory),
|
||||
};
|
||||
|
||||
|
|
|
@ -44,6 +44,7 @@ import { DashboardUiActionsService } from './ui_actions/types';
|
|||
import { ObservabilityAIAssistantService } from './observability_ai_assistant/types';
|
||||
import { DashboardUserProfileService } from './user_profile/types';
|
||||
import { DashboardRecentlyAccessedService } from './dashboard_recently_accessed/types';
|
||||
import { DashboardContentInsightsService } from './dashboard_content_insights/types';
|
||||
import { DashboardFavoritesService } from './dashboard_favorites/types';
|
||||
|
||||
export type DashboardPluginServiceParams = KibanaPluginServiceParams<DashboardStartDependencies> & {
|
||||
|
@ -85,5 +86,6 @@ export interface DashboardServices {
|
|||
observabilityAIAssistant: ObservabilityAIAssistantService; // TODO: make this optional in follow up
|
||||
userProfile: DashboardUserProfileService;
|
||||
dashboardRecentlyAccessed: DashboardRecentlyAccessedService;
|
||||
dashboardContentInsights: DashboardContentInsightsService;
|
||||
dashboardFavorites: DashboardFavoritesService;
|
||||
}
|
||||
|
|
|
@ -11,9 +11,10 @@ import {
|
|||
TaskManagerStartContract,
|
||||
} from '@kbn/task-manager-plugin/server';
|
||||
import { EmbeddableSetup } from '@kbn/embeddable-plugin/server';
|
||||
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server';
|
||||
import { UsageCollectionSetup, UsageCollectionStart } from '@kbn/usage-collection-plugin/server';
|
||||
import { ContentManagementServerSetup } from '@kbn/content-management-plugin/server';
|
||||
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server';
|
||||
import { registerContentInsights } from '@kbn/content-management-content-insights-server';
|
||||
|
||||
import {
|
||||
initializeDashboardTelemetryTask,
|
||||
|
@ -31,13 +32,14 @@ import { dashboardPersistableStateServiceFactory } from './dashboard_container/d
|
|||
|
||||
interface SetupDeps {
|
||||
embeddable: EmbeddableSetup;
|
||||
usageCollection: UsageCollectionSetup;
|
||||
usageCollection?: UsageCollectionSetup;
|
||||
taskManager: TaskManagerSetupContract;
|
||||
contentManagement: ContentManagementServerSetup;
|
||||
}
|
||||
|
||||
interface StartDeps {
|
||||
taskManager: TaskManagerStartContract;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
}
|
||||
|
||||
export class DashboardPlugin
|
||||
|
@ -83,6 +85,25 @@ export class DashboardPlugin
|
|||
);
|
||||
}
|
||||
|
||||
if (plugins.usageCollection) {
|
||||
// Registers routes for tracking and fetching dashboard views
|
||||
registerContentInsights(
|
||||
{
|
||||
usageCollection: plugins.usageCollection,
|
||||
http: core.http,
|
||||
getStartServices: () =>
|
||||
core.getStartServices().then(([_, start]) => ({
|
||||
usageCollection: start.usageCollection!,
|
||||
})),
|
||||
},
|
||||
{
|
||||
domainId: 'dashboard',
|
||||
// makes sure that only users with read/all access to dashboard app can access the routes
|
||||
routeTags: ['access:dashboardUsageStats'],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
plugins.embeddable.registerEmbeddableFactory(
|
||||
dashboardPersistableStateServiceFactory(plugins.embeddable)
|
||||
);
|
||||
|
|
|
@ -83,6 +83,8 @@
|
|||
"@kbn/lens-embeddable-utils",
|
||||
"@kbn/lens-plugin",
|
||||
"@kbn/recently-accessed",
|
||||
"@kbn/content-management-content-insights-public",
|
||||
"@kbn/content-management-content-insights-server",
|
||||
"@kbn/managed-content-badge",
|
||||
"@kbn/content-management-favorites-public",
|
||||
"@kbn/core-http-browser-mocks",
|
||||
|
|
|
@ -28,7 +28,7 @@ export type {
|
|||
|
||||
export { serializeCounterKey, USAGE_COUNTERS_SAVED_OBJECT_TYPE } from './usage_counters';
|
||||
|
||||
export type { UsageCollectionSetup } from './plugin';
|
||||
export type { UsageCollectionSetup, UsageCollectionStart } from './plugin';
|
||||
export { config } from './config';
|
||||
export const plugin = async (initializerContext: PluginInitializerContext) => {
|
||||
const { UsageCollectionPlugin } = await import('./plugin');
|
||||
|
|
|
@ -15,6 +15,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
const browser = getService('browser');
|
||||
const listingTable = getService('listingTable');
|
||||
const dashboardAddPanel = getService('dashboardAddPanel');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
describe('dashboard listing page', function describeIndexTests() {
|
||||
const dashboardName = 'Dashboard Listing Test';
|
||||
|
@ -234,5 +235,43 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
expect(newPanelCount).to.equal(originalPanelCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insights', () => {
|
||||
const DASHBOARD_NAME = 'Insights Dashboard';
|
||||
|
||||
before(async () => {
|
||||
await PageObjects.dashboard.navigateToApp();
|
||||
await PageObjects.dashboard.clickNewDashboard();
|
||||
await PageObjects.dashboard.saveDashboard(DASHBOARD_NAME, {
|
||||
saveAsNew: true,
|
||||
waitDialogIsClosed: false,
|
||||
exitFromEditMode: false,
|
||||
});
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
});
|
||||
|
||||
it('shows the insights panel and counts the views', async () => {
|
||||
await listingTable.searchForItemWithName(DASHBOARD_NAME);
|
||||
|
||||
async function getViewsCount() {
|
||||
await listingTable.inspectVisualization();
|
||||
const totalViewsStats = await testSubjects.find('views-stats-total-views');
|
||||
const viewsStr = await (
|
||||
await totalViewsStats.findByCssSelector('.euiStat__title')
|
||||
).getVisibleText();
|
||||
await listingTable.closeInspector();
|
||||
return Number(viewsStr);
|
||||
}
|
||||
|
||||
const views1 = await getViewsCount();
|
||||
expect(views1).to.be(1);
|
||||
|
||||
await listingTable.clickItemLink('dashboard', DASHBOARD_NAME);
|
||||
await PageObjects.dashboard.waitForRenderComplete();
|
||||
await PageObjects.dashboard.gotoDashboardLandingPage();
|
||||
const views2 = await getViewsCount();
|
||||
expect(views2).to.be(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -192,6 +192,10 @@
|
|||
"@kbn/console-plugin/*": ["src/plugins/console/*"],
|
||||
"@kbn/content-management-content-editor": ["packages/content-management/content_editor"],
|
||||
"@kbn/content-management-content-editor/*": ["packages/content-management/content_editor/*"],
|
||||
"@kbn/content-management-content-insights-public": ["packages/content-management/content_insights/content_insights_public"],
|
||||
"@kbn/content-management-content-insights-public/*": ["packages/content-management/content_insights/content_insights_public/*"],
|
||||
"@kbn/content-management-content-insights-server": ["packages/content-management/content_insights/content_insights_server"],
|
||||
"@kbn/content-management-content-insights-server/*": ["packages/content-management/content_insights/content_insights_server/*"],
|
||||
"@kbn/content-management-examples-plugin": ["examples/content_management_examples"],
|
||||
"@kbn/content-management-examples-plugin/*": ["examples/content_management_examples/*"],
|
||||
"@kbn/content-management-favorites-public": ["packages/content-management/favorites/favorites_public"],
|
||||
|
|
|
@ -547,6 +547,7 @@ Array [
|
|||
},
|
||||
"api": Array [
|
||||
"bulkGetUserProfiles",
|
||||
"dashboardUsageStats",
|
||||
"store_search_session",
|
||||
],
|
||||
"app": Array [
|
||||
|
@ -603,6 +604,7 @@ Array [
|
|||
"privilege": Object {
|
||||
"api": Array [
|
||||
"bulkGetUserProfiles",
|
||||
"dashboardUsageStats",
|
||||
],
|
||||
"app": Array [
|
||||
"dashboards",
|
||||
|
@ -1167,6 +1169,7 @@ Array [
|
|||
},
|
||||
"api": Array [
|
||||
"bulkGetUserProfiles",
|
||||
"dashboardUsageStats",
|
||||
"store_search_session",
|
||||
],
|
||||
"app": Array [
|
||||
|
@ -1223,6 +1226,7 @@ Array [
|
|||
"privilege": Object {
|
||||
"api": Array [
|
||||
"bulkGetUserProfiles",
|
||||
"dashboardUsageStats",
|
||||
],
|
||||
"app": Array [
|
||||
"dashboards",
|
||||
|
|
|
@ -209,7 +209,7 @@ export const buildOSSFeatures = ({
|
|||
],
|
||||
},
|
||||
ui: ['createNew', 'show', 'showWriteControls', 'saveQuery'],
|
||||
api: ['bulkGetUserProfiles'],
|
||||
api: ['bulkGetUserProfiles', 'dashboardUsageStats'],
|
||||
},
|
||||
read: {
|
||||
app: ['dashboards', 'kibana'],
|
||||
|
@ -230,7 +230,7 @@ export const buildOSSFeatures = ({
|
|||
],
|
||||
},
|
||||
ui: ['show'],
|
||||
api: ['bulkGetUserProfiles'],
|
||||
api: ['bulkGetUserProfiles', 'dashboardUsageStats'],
|
||||
},
|
||||
},
|
||||
subFeatures: [
|
||||
|
|
|
@ -455,7 +455,6 @@
|
|||
"console.welcomePage.useVariables.step2": "Invoquez les variables dans les chemins et corps de vos requêtes autant de fois que souhaité.",
|
||||
"console.welcomePage.useVariablesDescription": "Définissez les variables dans la Console, puis utilisez-les dans vos requêtes sous la forme {variableName}.",
|
||||
"console.welcomePage.useVariablesTitle": "Réutiliser les valeurs avec les variables",
|
||||
"contentManagement.contentEditor.activity.activityLabelHelpText": "Les données liées à l'activité sont générées automatiquement et ne peuvent pas être mises à jour.",
|
||||
"contentManagement.contentEditor.activity.createdByLabelText": "Créé par",
|
||||
"contentManagement.contentEditor.activity.lastUpdatedByDateTime": "le {dateTime}",
|
||||
"contentManagement.contentEditor.activity.lastUpdatedByLabelText": "Dernière mise à jour par",
|
||||
|
@ -464,7 +463,6 @@
|
|||
"contentManagement.contentEditor.cancelButtonLabel": "Annuler",
|
||||
"contentManagement.contentEditor.flyoutTitle": "Détails de {entityName}",
|
||||
"contentManagement.contentEditor.flyoutWarningsTitle": "Continuez avec prudence !",
|
||||
"contentManagement.contentEditor.metadataForm.activityLabel": "Activité",
|
||||
"contentManagement.contentEditor.metadataForm.descriptionInputLabel": "Description",
|
||||
"contentManagement.contentEditor.metadataForm.nameInputLabel": "Nom",
|
||||
"contentManagement.contentEditor.metadataForm.nameIsEmptyError": "Nom obligatoire.",
|
||||
|
|
|
@ -455,7 +455,6 @@
|
|||
"console.welcomePage.useVariables.step2": "任意の回数だけリクエストのパスと本文で変数を参照します。",
|
||||
"console.welcomePage.useVariablesDescription": "コンソールで変数を定義し、{variableName}の形式でリクエストで使用します。",
|
||||
"console.welcomePage.useVariablesTitle": "変数で値を再利用",
|
||||
"contentManagement.contentEditor.activity.activityLabelHelpText": "アクティビティデータは自動生成されるため、更新できません。",
|
||||
"contentManagement.contentEditor.activity.createdByLabelText": "作成者",
|
||||
"contentManagement.contentEditor.activity.lastUpdatedByDateTime": "{dateTime}に",
|
||||
"contentManagement.contentEditor.activity.lastUpdatedByLabelText": "最終更新者",
|
||||
|
@ -464,7 +463,6 @@
|
|||
"contentManagement.contentEditor.cancelButtonLabel": "キャンセル",
|
||||
"contentManagement.contentEditor.flyoutTitle": "{entityName}詳細",
|
||||
"contentManagement.contentEditor.flyoutWarningsTitle": "十分ご注意ください!",
|
||||
"contentManagement.contentEditor.metadataForm.activityLabel": "アクティビティ",
|
||||
"contentManagement.contentEditor.metadataForm.descriptionInputLabel": "説明",
|
||||
"contentManagement.contentEditor.metadataForm.nameInputLabel": "名前",
|
||||
"contentManagement.contentEditor.metadataForm.nameIsEmptyError": "名前が必要です。",
|
||||
|
|
|
@ -454,7 +454,6 @@
|
|||
"console.welcomePage.useVariables.step2": "请参阅请求路径和正文中的变量,无论多少次均可。",
|
||||
"console.welcomePage.useVariablesDescription": "在控制台中定义变量,然后在请求中以 {variableName} 的形式使用它们。",
|
||||
"console.welcomePage.useVariablesTitle": "重复使用包含变量的值",
|
||||
"contentManagement.contentEditor.activity.activityLabelHelpText": "活动数据将自动生成并且无法进行更新。",
|
||||
"contentManagement.contentEditor.activity.createdByLabelText": "创建者",
|
||||
"contentManagement.contentEditor.activity.lastUpdatedByLabelText": "最后更新者",
|
||||
"contentManagement.contentEditor.activity.managedUserLabel": "系统",
|
||||
|
@ -462,7 +461,6 @@
|
|||
"contentManagement.contentEditor.cancelButtonLabel": "取消",
|
||||
"contentManagement.contentEditor.flyoutTitle": "{entityName} 详情",
|
||||
"contentManagement.contentEditor.flyoutWarningsTitle": "谨慎操作!",
|
||||
"contentManagement.contentEditor.metadataForm.activityLabel": "活动",
|
||||
"contentManagement.contentEditor.metadataForm.descriptionInputLabel": "描述",
|
||||
"contentManagement.contentEditor.metadataForm.nameInputLabel": "名称",
|
||||
"contentManagement.contentEditor.metadataForm.nameIsEmptyError": "名称必填。",
|
||||
|
|
|
@ -1918,6 +1918,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"all": Array [
|
||||
"login:",
|
||||
"api:bulkGetUserProfiles",
|
||||
"api:dashboardUsageStats",
|
||||
"api:store_search_session",
|
||||
"api:generateReport",
|
||||
"api:downloadCsv",
|
||||
|
@ -2100,6 +2101,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"minimal_all": Array [
|
||||
"login:",
|
||||
"api:bulkGetUserProfiles",
|
||||
"api:dashboardUsageStats",
|
||||
"app:dashboards",
|
||||
"app:kibana",
|
||||
"ui:catalogue/dashboard",
|
||||
|
@ -2251,6 +2253,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"minimal_read": Array [
|
||||
"login:",
|
||||
"api:bulkGetUserProfiles",
|
||||
"api:dashboardUsageStats",
|
||||
"app:dashboards",
|
||||
"app:kibana",
|
||||
"ui:catalogue/dashboard",
|
||||
|
@ -2349,6 +2352,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
"read": Array [
|
||||
"login:",
|
||||
"api:bulkGetUserProfiles",
|
||||
"api:dashboardUsageStats",
|
||||
"app:dashboards",
|
||||
"app:kibana",
|
||||
"ui:catalogue/dashboard",
|
||||
|
|
|
@ -3680,6 +3680,14 @@
|
|||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-content-insights-public@link:packages/content-management/content_insights/content_insights_public":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-content-insights-server@link:packages/content-management/content_insights/content_insights_server":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@kbn/content-management-examples-plugin@link:examples/content_management_examples":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue