diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 113c79a2dc0a0..ba352582bd651 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -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 diff --git a/package.json b/package.json index 6ef8a524f4a3b..9ff4ac1707b18 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/content-management/content_editor/src/components/editor_flyout_content.tsx b/packages/content-management/content_editor/src/components/editor_flyout_content.tsx index a347dbb2c4d31..acc5f68d62b02 100644 --- a/packages/content-management/content_editor/src/components/editor_flyout_content.tsx +++ b/packages/content-management/content_editor/src/components/editor_flyout_content.tsx @@ -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; 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 = ({ onSave, onCancel, customValidators, - showActivityView, + appendRows, }) => { const { euiTheme } = useEuiTheme(); const [isSubmitting, setIsSubmitting] = useState(false); @@ -151,7 +150,7 @@ export const ContentEditorFlyoutContent: FC = ({ TagList={TagList} TagSelector={TagSelector} > - {showActivityView && } + {appendRows} diff --git a/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx b/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx index 18094bc04f084..49359cd1801f2 100644 --- a/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx +++ b/packages/content-management/content_editor/src/components/editor_flyout_content_container.tsx @@ -21,7 +21,7 @@ type CommonProps = Pick< | 'onCancel' | 'entityName' | 'customValidators' - | 'showActivityView' + | 'appendRows' >; export type Props = CommonProps; diff --git a/packages/content-management/content_editor/src/components/editor_loader.tsx b/packages/content-management/content_editor/src/components/editor_loader.tsx index b15009f3b4db1..6bfe88fa2c12a 100644 --- a/packages/content-management/content_editor/src/components/editor_loader.tsx +++ b/packages/content-management/content_editor/src/components/editor_loader.tsx @@ -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'; -export const ContentEditorLoader: React.FC = (props) => { - const [Editor, setEditor] = useState | null>(null); - - const loadEditor = useCallback(async () => { - const { ContentEditorFlyoutContentContainer } = await import( - './editor_flyout_content_container' - ); - setEditor(() => ContentEditorFlyoutContentContainer); - }, []); +const ContentEditorFlyoutContentContainer = React.lazy(() => + import('./editor_flyout_content_container').then( + ({ ContentEditorFlyoutContentContainer: _ContentEditorFlyoutContentContainer }) => ({ + default: _ContentEditorFlyoutContentContainer, + }) + ) +); - useEffect(() => { - // On mount: load the editor asynchronously - loadEditor(); - }, [loadEditor]); - - return Editor ? ( - - ) : ( - <> - - - - +export const ContentEditorLoader: React.FC = (props) => { + return ( + + + + + + } + > + + ); }; diff --git a/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx b/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx index c543acedbae5b..e4668d022d00d 100644 --- a/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx +++ b/packages/content-management/content_editor/src/components/inspector_flyout_content.test.tsx @@ -262,22 +262,5 @@ describe('', () => { 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); - }); }); }); diff --git a/packages/content-management/content_editor/src/open_content_editor.tsx b/packages/content-management/content_editor/src/open_content_editor.tsx index 89b73991ba5d6..2365aa9641e23 100644 --- a/packages/content-management/content_editor/src/open_content_editor.tsx +++ b/packages/content-management/content_editor/src/open_content_editor.tsx @@ -21,7 +21,7 @@ export type OpenContentEditorParams = Pick< | 'readonlyReason' | 'entityName' | 'customValidators' - | 'showActivityView' + | 'appendRows' >; export function useOpenContentEditor() { diff --git a/packages/content-management/content_editor/tsconfig.json b/packages/content-management/content_editor/tsconfig.json index b4f77e22f1f44..565535ec85b3e 100644 --- a/packages/content-management/content_editor/tsconfig.json +++ b/packages/content-management/content_editor/tsconfig.json @@ -30,7 +30,6 @@ "@kbn/test-jest-helpers", "@kbn/react-kibana-mount", "@kbn/content-management-user-profiles", - "@kbn/user-profile-components" ], "exclude": [ "target/**/*" diff --git a/packages/content-management/content_insights/README.mdx b/packages/content-management/content_insights/README.mdx new file mode 100644 index 0000000000000..a2a3894775a29 --- /dev/null +++ b/packages/content-management/content_insights/README.mdx @@ -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 }); +``` diff --git a/packages/content-management/content_insights/content_insights_public/README.md b/packages/content-management/content_insights/content_insights_public/README.md new file mode 100644 index 0000000000000..719e26238f12a --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-content-insights-public + +Refer to [README](../README.mdx) diff --git a/packages/content-management/content_insights/content_insights_public/index.ts b/packages/content-management/content_insights/content_insights_public/index.ts new file mode 100644 index 0000000000000..e1a0a67ec39bf --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/index.ts @@ -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'; diff --git a/packages/content-management/content_insights/content_insights_public/jest.config.js b/packages/content-management/content_insights/content_insights_public/jest.config.js new file mode 100644 index 0000000000000..b1844b25fcfca --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/jest.config.js @@ -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: ['/packages/content-management/content_insights/content_insights_public'], +}; diff --git a/packages/content-management/content_insights/content_insights_public/kibana.jsonc b/packages/content-management/content_insights/content_insights_public/kibana.jsonc new file mode 100644 index 0000000000000..fc4e12374faf9 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/content-management-content-insights-public", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/content-management/content_insights/content_insights_public/package.json b/packages/content-management/content_insights/content_insights_public/package.json new file mode 100644 index 0000000000000..ca78ba0f1e39d --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/content-management/content_insights/content_insights_public/src/client.ts b/packages/content-management/content_insights/content_insights_public/src/client.ts new file mode 100644 index 0000000000000..8f392ce50536d --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/client.ts @@ -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; +} + +/** + * 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( + `/internal/content_management/insights/${this.config.domainId}/${id}/${eventType}/stats` + ) + .then((response) => response.result); + } +} diff --git a/packages/content-management/content_editor/src/components/activity_view.test.tsx b/packages/content-management/content_insights/content_insights_public/src/components/activity_view.test.tsx similarity index 100% rename from packages/content-management/content_editor/src/components/activity_view.test.tsx rename to packages/content-management/content_insights/content_insights_public/src/components/activity_view.test.tsx diff --git a/packages/content-management/content_editor/src/components/activity_view.tsx b/packages/content-management/content_insights/content_insights_public/src/components/activity_view.tsx similarity index 50% rename from packages/content-management/content_editor/src/components/activity_view.tsx rename to packages/content-management/content_insights/content_insights_public/src/components/activity_view.tsx index eb413acb20e36..065a2ed0648a7 100644 --- a/packages/content-management/content_editor/src/components/activity_view.tsx +++ b/packages/content-management/content_insights/content_insights_public/src/components/activity_view.tsx @@ -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: Pick, 'createdBy' | 'createdAt' | 'updatedBy' | 'updatedAt' | 'managed'>; } export const ActivityView = ({ item }: ActivityViewProps) => { @@ -54,78 +46,53 @@ export const ActivityView = ({ item }: ActivityViewProps) => { ); return ( - - {' '} - + + + + ) : item.managed ? ( + <>{ManagedUserLabel} + ) : ( + <> + {UnknownUserLabel} + + + ) + } + when={item.createdAt} + data-test-subj={'createdByCard'} + /> + + + {showLastUpdated && ( + + ) : item.managed ? ( + <>{ManagedUserLabel} + ) : ( + <> + {UnknownUserLabel} + + + ) } + when={item.updatedAt} + data-test-subj={'updatedByCard'} /> - - } - fullWidth - data-test-subj={'activityView'} - > - <> - - - - ) : item.managed ? ( - <>{ManagedUserLabel} - ) : ( - <> - {UnknownUserLabel} - - - ) - } - when={item.createdAt} - data-test-subj={'createdByCard'} - /> - - - {showLastUpdated && ( - - ) : item.managed ? ( - <>{ManagedUserLabel} - ) : ( - <> - {UnknownUserLabel} - - - ) - } - when={item.updatedAt} - data-test-subj={'updatedByCard'} - /> - )} - - - - + )} + + ); }; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/index.ts b/packages/content-management/content_insights/content_insights_public/src/components/index.ts new file mode 100644 index 0000000000000..b018fca6c843e --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/index.ts @@ -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'; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts new file mode 100644 index 0000000000000..01fa00cd44537 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/index.ts @@ -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'; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx new file mode 100644 index 0000000000000..ff1675744b9a6 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_chart.tsx @@ -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 ( + + + + + + + + ); +}; diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx new file mode 100644 index 0000000000000..649c34da0f2f7 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.test.tsx @@ -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 ( + + + + + + + + ); +}; + +describe('ViewsStats', () => { + test('should render the total views and chart', async () => { + const { getByTestId } = render(); + const totalViews = getByTestId('views-stats-total-views'); + expect(totalViews).toBeInTheDocument(); + await within(totalViews).findByText('Views (last 75 days)'); + await within(totalViews).findByText('10'); + }); +}); diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx new file mode 100644 index 0000000000000..15138b55ba9b5 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats.tsx @@ -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 ( + + + + + + } + isLoading={isLoading} + /> + + + + + + ); +}; + +const NoViewsTip = () => ( + + } + /> +); + +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(); + + // 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]); +} diff --git a/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx new file mode 100644 index 0000000000000..0ad2b32430561 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/components/views_stats/views_stats_lib.test.tsx @@ -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", + ] + `); + }); +}); diff --git a/packages/content-management/content_insights/content_insights_public/src/services.tsx b/packages/content-management/content_insights/content_insights_public/src/services.tsx new file mode 100644 index 0000000000000..13d2ade797024 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/services.tsx @@ -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(null); + +/** + * Abstract external service Provider. + */ +export const ContentInsightsProvider: FC>> = ({ + children, + ...services +}) => { + if (!services.contentInsightsClient) { + return <>{children}; + } + + return ( + + {children} + + ); +}; + +/* + * React hook for accessing pre-wired services. + */ +export function useServices() { + const context = useContext(ContentInsightsContext); + return context; +} diff --git a/packages/content-management/content_insights/content_insights_public/src/types.ts b/packages/content-management/content_insights/content_insights_public/src/types.ts new file mode 100644 index 0000000000000..75e0ca561c9ae --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/src/types.ts @@ -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; diff --git a/packages/content-management/content_insights/content_insights_public/tsconfig.json b/packages/content-management/content_insights/content_insights_public/tsconfig.json new file mode 100644 index 0000000000000..27d479a15d6c9 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_public/tsconfig.json @@ -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", + ] +} diff --git a/packages/content-management/content_insights/content_insights_server/README.md b/packages/content-management/content_insights/content_insights_server/README.md new file mode 100644 index 0000000000000..00f54612cf532 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/README.md @@ -0,0 +1,3 @@ +# @kbn/content-management-content-insights-server + +Refer to [README](../README.mdx) diff --git a/packages/content-management/content_insights/content_insights_server/index.ts b/packages/content-management/content_insights/content_insights_server/index.ts new file mode 100644 index 0000000000000..fe78d0eb181ae --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/index.ts @@ -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'; diff --git a/packages/content-management/content_insights/content_insights_server/jest.config.js b/packages/content-management/content_insights/content_insights_server/jest.config.js new file mode 100644 index 0000000000000..7761f3fba8000 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/jest.config.js @@ -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: ['/packages/content-management/content_insights/content_insights_server'], +}; diff --git a/packages/content-management/content_insights/content_insights_server/kibana.jsonc b/packages/content-management/content_insights/content_insights_server/kibana.jsonc new file mode 100644 index 0000000000000..386c1a6bf1304 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-server", + "id": "@kbn/content-management-content-insights-server", + "owner": "@elastic/appex-sharedux" +} diff --git a/packages/content-management/content_insights/content_insights_server/package.json b/packages/content-management/content_insights/content_insights_server/package.json new file mode 100644 index 0000000000000..ff99762999828 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/package.json @@ -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" +} \ No newline at end of file diff --git a/packages/content-management/content_insights/content_insights_server/src/register.ts b/packages/content-management/content_insights/content_insights_server/src/register.ts new file mode 100644 index 0000000000000..06283cce089ac --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/src/register.ts @@ -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 }); + } + ); +}; diff --git a/packages/content-management/content_insights/content_insights_server/tsconfig.json b/packages/content-management/content_insights/content_insights_server/tsconfig.json new file mode 100644 index 0000000000000..3e2312c0278a2 --- /dev/null +++ b/packages/content-management/content_insights/content_insights_server/tsconfig.json @@ -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", + ] +} diff --git a/packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx b/packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx new file mode 100644 index 0000000000000..79a10d42f41e2 --- /dev/null +++ b/packages/content-management/table_list_view_table/src/components/content_editor_activity_row.tsx @@ -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 ( + + {' '} + + } + /> + + } + > + <> + + + + + + ); +}; diff --git a/packages/content-management/table_list_view_table/src/services.tsx b/packages/content-management/table_list_view_table/src/services.tsx index ebdbaf31b4f0e..b452bf916a525 100644 --- a/packages/content-management/table_list_view_table/src/services.tsx +++ b/packages/content-management/table_list_view_table/src/services.tsx @@ -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< - { - notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text }); - }} - > - - application.getUrlForApp('management', { - path: `/kibana/settings?query=savedObjects:listingLimit`, - }) - } + + { notifications.toasts.addDanger({ title: toMountPoint(title, startServices), text }); }} - searchQueryParser={searchQueryParser} - DateFormatterComp={(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} - - + + application.getUrlForApp('management', { + path: `/kibana/settings?query=savedObjects:listingLimit`, + }) + } + notifyError={(title, text) => { + notifications.toasts.addDanger({ + title: toMountPoint(title, startServices), + text, + }); + }} + searchQueryParser={searchQueryParser} + DateFormatterComp={(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} + + + diff --git a/packages/content-management/table_list_view_table/src/table_list_view.test.tsx b/packages/content-management/table_list_view_table/src/table_list_view.test.tsx index e56322099d5ff..f7ad968d78965 100644 --- a/packages/content-management/table_list_view_table/src/table_list_view.test.tsx +++ b/packages/content-management/table_list_view_table/src/table_list_view.test.tsx @@ -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', }, diff --git a/packages/content-management/table_list_view_table/src/table_list_view_table.tsx b/packages/content-management/table_list_view_table/src/table_list_view_table.tsx index 82894d7d8b6ef..c15462f88b585 100644 --- a/packages/content-management/table_list_view_table/src/table_list_view_table.tsx +++ b/packages/content-management/table_list_view_table/src/table_list_view_table.tsx @@ -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 { enabled?: boolean; } @@ -371,6 +373,7 @@ function TableListViewTableComp({ } = useServices(); const openContentEditor = useOpenContentEditor(); + const contentInsightsServices = useContentInsightsServices(); const isInRouterContext = useInRouterContext(); @@ -567,6 +570,12 @@ function TableListViewTableComp({ close(); }), + appendRows: contentInsightsServices && ( + // have to "REWRAP" in the provider here because it will be rendered in a different context + + + + ), }); }, [ @@ -576,6 +585,7 @@ function TableListViewTableComp({ contentEditor, tableItemsRowActions, fetchItems, + contentInsightsServices, ] ); @@ -713,7 +723,7 @@ function TableListViewTableComp({ name: i18n.translate('contentManagement.tableList.listing.table.actionTitle', { defaultMessage: 'Actions', }), - width: `${32 * actions.length}px`, + width: `72px`, actions, }); } diff --git a/packages/content-management/table_list_view_table/tsconfig.json b/packages/content-management/table_list_view_table/tsconfig.json index 7bd513f12f99e..a5530ee717e49 100644 --- a/packages/content-management/table_list_view_table/tsconfig.json +++ b/packages/content-management/table_list_view_table/tsconfig.json @@ -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": [ diff --git a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts index 708e3c8aac809..158fc638adc3d 100644 --- a/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts +++ b/src/plugins/dashboard/public/dashboard_container/embeddable/create/create_dashboard.ts @@ -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 }; }; diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx index 2904eb4648df1..1e0de2b72b2b5 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing.tsx @@ -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, }} > {...tableListViewTableProps}> diff --git a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx index 07ce8eeea771f..5a0f8caee1a9b 100644 --- a/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/dashboard_listing_table.tsx @@ -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} > <> { onSave: expect.any(Function), isReadonly: false, customValidators: expect.any(Object), - showActivityView: true, }, createdByEnabled: true, recentlyAccessed: expect.objectContaining({ get: expect.any(Function) }), diff --git a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx index 097fc5ea6d866..d3afbc61d2607 100644 --- a/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx +++ b/src/plugins/dashboard/public/dashboard_listing/hooks/use_dashboard_listing_table.tsx @@ -283,7 +283,6 @@ export const useDashboardListingTable = ({ isReadonly: !showWriteControls, onSave: updateItemMeta, customValidators: contentEditorValidators, - showActivityView: true, }, createItem: !showWriteControls || !showCreateDashboardButton ? undefined : createItem, deleteItems: !showWriteControls ? undefined : deleteItems, diff --git a/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts new file mode 100644 index 0000000000000..26abd7f65c768 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights.stub.ts @@ -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; + +export const dashboardContentInsightsServiceFactory: DashboardContentInsightsServiceFactory = + () => { + return { + trackDashboardView: jest.fn(), + contentInsightsClient: { + track: jest.fn(), + getStats: jest.fn(), + }, + }; + }; diff --git a/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts new file mode 100644 index 0000000000000..8877589d036ea --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_insights/dashboard_content_insights_service.ts @@ -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, + }; +}; diff --git a/src/plugins/dashboard/public/services/dashboard_content_insights/types.ts b/src/plugins/dashboard/public/services/dashboard_content_insights/types.ts new file mode 100644 index 0000000000000..ac709c725f879 --- /dev/null +++ b/src/plugins/dashboard/public/services/dashboard_content_insights/types.ts @@ -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; +} diff --git a/src/plugins/dashboard/public/services/plugin_services.stub.ts b/src/plugins/dashboard/public/services/plugin_services.stub.ts index 247a3f29e35aa..1bd37c98d6ece 100644 --- a/src/plugins/dashboard/public/services/plugin_services.stub.ts +++ b/src/plugins/dashboard/public/services/plugin_services.stub.ts @@ -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 = { dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory), @@ -85,6 +86,7 @@ export const providers: PluginServiceProviders = { userProfile: new PluginServiceProvider(userProfileServiceFactory), observabilityAIAssistant: new PluginServiceProvider(observabilityAIAssistantServiceStubFactory), dashboardRecentlyAccessed: new PluginServiceProvider(dashboardRecentlyAccessedServiceFactory), + dashboardContentInsights: new PluginServiceProvider(dashboardContentInsightsServiceFactory), dashboardFavorites: new PluginServiceProvider(dashboardFavoritesServiceFactory), }; diff --git a/src/plugins/dashboard/public/services/plugin_services.ts b/src/plugins/dashboard/public/services/plugin_services.ts index 8f554d33aeea4..592b494634432 100644 --- a/src/plugins/dashboard/public/services/plugin_services.ts +++ b/src/plugins/dashboard/public/services/plugin_services.ts @@ -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 = { dashboardContentManagement: new PluginServiceProvider(dashboardContentManagementServiceFactory, [ @@ -99,6 +100,7 @@ const providers: PluginServiceProviders & { @@ -85,5 +86,6 @@ export interface DashboardServices { observabilityAIAssistant: ObservabilityAIAssistantService; // TODO: make this optional in follow up userProfile: DashboardUserProfileService; dashboardRecentlyAccessed: DashboardRecentlyAccessedService; + dashboardContentInsights: DashboardContentInsightsService; dashboardFavorites: DashboardFavoritesService; } diff --git a/src/plugins/dashboard/server/plugin.ts b/src/plugins/dashboard/server/plugin.ts index e1626c2e72108..27747ca71bb8d 100644 --- a/src/plugins/dashboard/server/plugin.ts +++ b/src/plugins/dashboard/server/plugin.ts @@ -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) ); diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index a6011f2f67bec..f289f4f725fe5 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -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", diff --git a/src/plugins/usage_collection/server/index.ts b/src/plugins/usage_collection/server/index.ts index bdac41e9c04da..8de2a99d30e7d 100644 --- a/src/plugins/usage_collection/server/index.ts +++ b/src/plugins/usage_collection/server/index.ts @@ -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'); diff --git a/test/functional/apps/dashboard/group4/dashboard_listing.ts b/test/functional/apps/dashboard/group4/dashboard_listing.ts index 36e99d0e5c8c1..8a5e121f659cf 100644 --- a/test/functional/apps/dashboard/group4/dashboard_listing.ts +++ b/test/functional/apps/dashboard/group4/dashboard_listing.ts @@ -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); + }); + }); }); } diff --git a/tsconfig.base.json b/tsconfig.base.json index 5f45f95234a2b..fb98e0160cb76 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"], diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index d2d088defd966..8f70f79435843 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -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", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index ea9b19e6b60e9..90c997352e2ba 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -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: [ diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 9d1c16977a97c..94e34a5f25a7a 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -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.", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 238691c13a078..efa3a915278df 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -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": "名前が必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3b88e7dfcb306..dbf1786160150 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -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": "名称必填。", diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts index 0b1f665fa7f72..329b9be0de561 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts @@ -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", diff --git a/yarn.lock b/yarn.lock index ab818bc120054..baad89f95f4a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 ""