feat(ui): Partial support for Chart usage (#5473)

This commit is contained in:
John Joyce 2022-07-22 15:19:04 -07:00 committed by GitHub
parent 935b423452
commit f8697ba54f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 294 additions and 1 deletions

View File

@ -82,6 +82,7 @@ import com.linkedin.datahub.graphql.resolvers.auth.ListAccessTokensResolver;
import com.linkedin.datahub.graphql.resolvers.auth.RevokeAccessTokenResolver;
import com.linkedin.datahub.graphql.resolvers.browse.BrowsePathsResolver;
import com.linkedin.datahub.graphql.resolvers.browse.BrowseResolver;
import com.linkedin.datahub.graphql.resolvers.chart.ChartStatsSummaryResolver;
import com.linkedin.datahub.graphql.resolvers.config.AppConfigResolver;
import com.linkedin.datahub.graphql.resolvers.container.ContainerEntitiesResolver;
import com.linkedin.datahub.graphql.resolvers.container.ParentContainersResolver;
@ -1080,6 +1081,7 @@ public class GmsGraphQLEngine {
})
)
.dataFetcher("parentContainers", new ParentContainersResolver(entityClient))
.dataFetcher("statsSummary", new ChartStatsSummaryResolver(this.timeseriesAspectService))
);
builder.type("ChartInfo", typeWiring -> typeWiring
.dataFetcher("inputs", new LoadableTypeBatchResolver<>(datasetType,

View File

@ -0,0 +1,34 @@
package com.linkedin.datahub.graphql.resolvers.chart;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.generated.ChartStatsSummary;
import com.linkedin.metadata.timeseries.TimeseriesAspectService;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ChartStatsSummaryResolver implements DataFetcher<CompletableFuture<ChartStatsSummary>> {
private final TimeseriesAspectService timeseriesAspectService;
private final Cache<Urn, ChartStatsSummary> summaryCache;
public ChartStatsSummaryResolver(final TimeseriesAspectService timeseriesAspectService) {
this.timeseriesAspectService = timeseriesAspectService;
this.summaryCache = CacheBuilder.newBuilder()
.maximumSize(10000)
.expireAfterWrite(6, TimeUnit.HOURS)
.build();
}
@Override
public CompletableFuture<ChartStatsSummary> get(DataFetchingEnvironment environment) throws Exception {
// Not yet implemented
return CompletableFuture.completedFuture(null);
}
}

View File

@ -4139,7 +4139,7 @@ type Dashboard implements EntityWithRelationships & Entity {
usageStats(startTimeMillis: Long, endTimeMillis: Long, limit: Int): DashboardUsageQueryResult
"""
Experimental - Summary operational & usage statistics about a Dataset
Experimental - Summary operational & usage statistics about a Dashboard
"""
statsSummary: DashboardStatsSummary
@ -4396,6 +4396,13 @@ type Chart implements EntityWithRelationships & Entity {
"""
dataPlatformInstance: DataPlatformInstance
"""
Not yet implemented.
Experimental - Summary operational & usage statistics about a Chart
"""
statsSummary: ChartStatsSummary
"""
Granular API for querying edges extending from this entity
"""
@ -5467,6 +5474,32 @@ type DashboardStatsSummary {
}
"""
Experimental - subject to change. A summary of usage metrics about a Chart.
"""
type ChartStatsSummary {
"""
The total view count for the chart
"""
viewCount: Int
"""
The view count in the last 30 days
"""
viewCountLast30Days: Int
"""
The unique user count in the past 30 days
"""
uniqueUserCountLast30Days: Int
"""
The top users in the past 30 days
"""
topUsersLast30Days: [CorpUser!]
}
"""
The duration of a fixed window of time
"""

View File

@ -17,6 +17,7 @@ import { getDataForEntityType } from '../shared/containers/profile/utils';
import { SidebarDomainSection } from '../shared/containers/profile/sidebar/Domain/SidebarDomainSection';
import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown';
import { LineageTab } from '../shared/tabs/Lineage/LineageTab';
import { ChartStatsSummarySubHeader } from './profile/stats/ChartStatsSummarySubHeader';
/**
* Definition of the DataHub Chart entity.
@ -71,6 +72,9 @@ export class ChartEntity implements Entity<Chart> {
useUpdateQuery={useUpdateChartMutation}
getOverrideProperties={this.getOverridePropertiesFromEntity}
headerDropdownItems={new Set([EntityMenuItems.COPY_URL, EntityMenuItems.UPDATE_DEPRECATION])}
subHeader={{
component: ChartStatsSummarySubHeader,
}}
tabs={[
{
name: 'Documentation',
@ -176,6 +180,10 @@ export class ChartEntity implements Entity<Chart> {
logoUrl={data?.platform?.properties?.logoUrl || ''}
domain={data.domain?.domain}
deprecation={data.deprecation}
statsSummary={data.statsSummary}
lastUpdatedMs={data.properties?.lastModified?.time}
createdMs={data.properties?.created?.time}
externalUrl={data.properties?.externalUrl}
/>
);
};

View File

@ -10,11 +10,13 @@ import {
SearchInsight,
ParentContainersResult,
Deprecation,
ChartStatsSummary,
} from '../../../../types.generated';
import DefaultPreviewCard from '../../../preview/DefaultPreviewCard';
import { useEntityRegistry } from '../../../useEntityRegistry';
import { capitalizeFirstLetter } from '../../../shared/textUtil';
import { IconStyleType } from '../../Entity';
import { ChartStatsSummary as ChartStatsSummaryView } from '../shared/ChartStatsSummary';
export const ChartPreview = ({
urn,
@ -31,6 +33,10 @@ export const ChartPreview = ({
insights,
logoUrl,
deprecation,
statsSummary,
lastUpdatedMs,
createdMs,
externalUrl,
parentContainers,
}: {
urn: string;
@ -47,6 +53,10 @@ export const ChartPreview = ({
insights?: Array<SearchInsight> | null;
logoUrl?: string | null;
deprecation?: Deprecation | null;
statsSummary?: ChartStatsSummary | null;
lastUpdatedMs?: number | null;
createdMs?: number | null;
externalUrl?: string | null;
parentContainers?: ParentContainersResult | null;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
@ -71,6 +81,15 @@ export const ChartPreview = ({
insights={insights}
parentContainers={parentContainers}
deprecation={deprecation}
externalUrl={externalUrl}
subHeader={
<ChartStatsSummaryView
viewCount={statsSummary?.viewCount}
uniqueUserCountLast30Days={statsSummary?.uniqueUserCountLast30Days}
lastUpdatedMs={lastUpdatedMs}
createdMs={createdMs}
/>
}
/>
);
};

View File

@ -0,0 +1,24 @@
import React from 'react';
import { ChartStatsSummary as ChartStatsSummaryObj } from '../../../../../types.generated';
import { useBaseEntity } from '../../../shared/EntityContext';
import { GetChartQuery } from '../../../../../graphql/chart.generated';
import { ChartStatsSummary } from '../../shared/ChartStatsSummary';
export const ChartStatsSummarySubHeader = () => {
const result = useBaseEntity<GetChartQuery>();
const chart = result?.chart;
const maybeStatsSummary = chart?.statsSummary as ChartStatsSummaryObj;
const viewCount = maybeStatsSummary?.viewCount;
const uniqueUserCountLast30Days = maybeStatsSummary?.uniqueUserCountLast30Days;
const lastUpdatedMs = chart?.properties?.lastModified?.time;
const createdMs = chart?.properties?.created?.time;
return (
<ChartStatsSummary
viewCount={viewCount}
uniqueUserCountLast30Days={uniqueUserCountLast30Days}
lastUpdatedMs={lastUpdatedMs}
createdMs={createdMs}
/>
);
};

View File

@ -0,0 +1,79 @@
import React from 'react';
import styled from 'styled-components';
import { Popover, Tooltip } from 'antd';
import { ClockCircleOutlined, EyeOutlined, TeamOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { formatNumberWithoutAbbreviation } from '../../../shared/formatNumber';
import { ANTD_GRAY } from '../../shared/constants';
import { toLocalDateTimeString, toRelativeTimeString } from '../../../shared/time/timeUtils';
import { StatsSummary } from '../../shared/components/styled/StatsSummary';
const StatText = styled.span`
color: ${ANTD_GRAY[8]};
`;
const HelpIcon = styled(QuestionCircleOutlined)`
color: ${ANTD_GRAY[7]};
padding-left: 4px;
`;
type Props = {
chartCount?: number | null;
viewCount?: number | null;
uniqueUserCountLast30Days?: number | null;
lastUpdatedMs?: number | null;
createdMs?: number | null;
};
export const ChartStatsSummary = ({
chartCount,
viewCount,
uniqueUserCountLast30Days,
lastUpdatedMs,
createdMs,
}: Props) => {
const statsViews = [
(!!chartCount && (
<StatText>
<b>{chartCount}</b> charts
</StatText>
)) ||
undefined,
(!!viewCount && (
<StatText>
<EyeOutlined style={{ marginRight: 8, color: ANTD_GRAY[7] }} />
<b>{formatNumberWithoutAbbreviation(viewCount)}</b> views
</StatText>
)) ||
undefined,
(!!uniqueUserCountLast30Days && (
<StatText>
<TeamOutlined style={{ marginRight: 8, color: ANTD_GRAY[7] }} />
<b>{formatNumberWithoutAbbreviation(uniqueUserCountLast30Days)}</b> unique users
</StatText>
)) ||
undefined,
(!!lastUpdatedMs && (
<Popover
content={
<>
{createdMs && <div>Created on {toLocalDateTimeString(createdMs)}.</div>}
<div>
Changed on {toLocalDateTimeString(lastUpdatedMs)}.{' '}
<Tooltip title="The time at which the chart was last changed in the source platform">
<HelpIcon />
</Tooltip>
</div>
</>
}
>
<StatText>
<ClockCircleOutlined style={{ marginRight: 8, color: ANTD_GRAY[7] }} />
Changed {toRelativeTimeString(lastUpdatedMs)}
</StatText>
</Popover>
)) ||
undefined,
].filter((stat) => stat !== undefined);
return <>{statsViews.length > 0 && <StatsSummary stats={statsViews} />}</>;
};

View File

@ -71,6 +71,25 @@ query getChart($urn: String!) {
dataPlatformInstance {
...dataPlatformInstanceFields
}
statsSummary {
viewCount
uniqueUserCountLast30Days
topUsersLast30Days {
urn
type
username
properties {
displayName
firstName
lastName
fullName
}
editableProperties {
displayName
pictureLink
}
}
}
}
}

View File

@ -385,6 +385,9 @@ fragment searchResultFields on Entity {
lastModified {
time
}
created {
time
}
}
ownership {
...ownershipFields
@ -413,6 +416,25 @@ fragment searchResultFields on Entity {
parentContainers {
...parentContainersFields
}
statsSummary {
viewCount
uniqueUserCountLast30Days
topUsersLast30Days {
urn
type
username
properties {
displayName
firstName
lastName
fullName
}
editableProperties {
displayName
pictureLink
}
}
}
}
... on DataFlow {
flowId

View File

@ -0,0 +1,33 @@
namespace com.linkedin.chart
import com.linkedin.timeseries.TimeseriesAspectBase
/**
* Experimental (Subject to breaking change) -- Stats corresponding to chart's usage.
*
* If this aspect represents the latest snapshot of the statistics about a Chart, the eventGranularity field should be null.
* If this aspect represents a bucketed window of usage statistics (e.g. over a day), then the eventGranularity field should be set accordingly.
*/
@Aspect = {
"name": "chartUsageStatistics",
"type": "timeseries",
}
record ChartUsageStatistics includes TimeseriesAspectBase {
/**
* The total number of times chart has been viewed
*/
@TimeseriesField = {}
viewsCount: optional int
/**
* Unique user count
*/
@TimeseriesField = {}
uniqueUserCount: optional int
/**
* Users within this bucket, with frequency counts
*/
@TimeseriesFieldCollection = {"key":"user"}
userCounts: optional array[ChartUserUsageCounts]
}

View File

@ -0,0 +1,19 @@
namespace com.linkedin.chart
import com.linkedin.common.Urn
/**
* Records a single user's usage counts for a given resource
*/
record ChartUserUsageCounts {
/**
* The unique id of the user.
*/
user: Urn
/**
* The number of times the user has viewed the chart
*/
@TimeseriesField = {}
viewsCount: optional int
}

View File

@ -99,6 +99,7 @@ public class Constants {
public static final String CHART_INFO_ASPECT_NAME = "chartInfo";
public static final String EDITABLE_CHART_PROPERTIES_ASPECT_NAME = "editableChartProperties";
public static final String CHART_QUERY_ASPECT_NAME = "chartQuery";
public static final String CHART_USAGE_STATISTICS_ASPECT_NAME = "chartUsageStatistics";
// Dashboard
public static final String DASHBOARD_KEY_ASPECT_NAME = "dashboardKey";