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)

![Screenshot 2024-08-15 at 13 00
11](https://github.com/user-attachments/assets/adeabf78-e3d3-4cfa-adc3-76a32ede595b)


## 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:
Anton Dosov 2024-08-21 20:08:49 +02:00 committed by GitHub
parent 6db6a8d7a6
commit 64e1116b4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 1173 additions and 176 deletions

2
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -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",

View file

@ -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>

View file

@ -21,7 +21,7 @@ type CommonProps = Pick<
| 'onCancel'
| 'entityName'
| 'customValidators'
| 'showActivityView'
| 'appendRows'
>;
export type Props = CommonProps;

View file

@ -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>
);
};

View file

@ -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);
});
});
});

View file

@ -21,7 +21,7 @@ export type OpenContentEditorParams = Pick<
| 'readonlyReason'
| 'entityName'
| 'customValidators'
| 'showActivityView'
| 'appendRows'
>;
export function useOpenContentEditor() {

View file

@ -30,7 +30,6 @@
"@kbn/test-jest-helpers",
"@kbn/react-kibana-mount",
"@kbn/content-management-user-profiles",
"@kbn/user-profile-components"
],
"exclude": [
"target/**/*"

View 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 });
```

View file

@ -0,0 +1,3 @@
# @kbn/content-management-content-insights-public
Refer to [README](../README.mdx)

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/content-management-content-insights-public",
"owner": "@elastic/appex-sharedux"
}

View file

@ -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"
}

View file

@ -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);
}
}

View file

@ -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>
);
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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';

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 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';

View file

@ -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>
);
};

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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');
});
});

View file

@ -0,0 +1,125 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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]);
}

View file

@ -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",
]
`);
});
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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;
}

View file

@ -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;

View file

@ -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",
]
}

View file

@ -0,0 +1,3 @@
# @kbn/content-management-content-insights-server
Refer to [README](../README.mdx)

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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';

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-server",
"id": "@kbn/content-management-content-insights-server",
"owner": "@elastic/appex-sharedux"
}

View file

@ -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"
}

View file

@ -0,0 +1,147 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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 });
}
);
};

View file

@ -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",
]
}

View file

@ -0,0 +1,48 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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>
);
};

View file

@ -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>

View file

@ -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',
},

View file

@ -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,
});
}

View file

@ -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": [

View file

@ -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 };
};

View file

@ -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}>

View file

@ -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

View file

@ -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) }),

View file

@ -283,7 +283,6 @@ export const useDashboardListingTable = ({
isReadonly: !showWriteControls,
onSave: updateItemMeta,
customValidators: contentEditorValidators,
showActivityView: true,
},
createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem,
deleteItems: !showWriteControls ? undefined : deleteItems,

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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(),
},
};
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 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,
};
};

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
* 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;
}

View file

@ -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),
};

View file

@ -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),
};

View file

@ -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;
}

View file

@ -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)
);

View file

@ -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",

View file

@ -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');

View file

@ -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);
});
});
});
}

View file

@ -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"],

View file

@ -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",

View file

@ -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: [

View file

@ -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.",

View file

@ -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": "名前が必要です。",

View file

@ -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": "名称必填。",

View file

@ -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",

View file

@ -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 ""