admin-analytics: add "insights" page (#41489)

This commit is contained in:
Erzhan Torokulov 2022-09-13 08:15:45 +06:00 committed by GitHub
parent 7fedbd9370
commit 4e103ac59d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 371 additions and 51 deletions

View File

@ -95,6 +95,10 @@ export const analyticsGroup: SiteAdminSideBarGroup = {
label: 'Users',
to: '/site-admin/analytics/users',
},
{
label: 'Insights',
to: '/site-admin/analytics/code-insights',
},
{
label: 'Batch changes',
to: '/site-admin/analytics/batch-changes',
@ -111,10 +115,6 @@ export const analyticsGroup: SiteAdminSideBarGroup = {
label: 'Feedback survey',
to: '/site-admin/surveys',
},
{
label: 'Code insights (soon)',
to: '/site-admin/analytics/code-insights',
},
],
}
@ -141,7 +141,7 @@ export const analyticsRoutes: readonly SiteAdminAreaRoute[] = [
},
{
path: '/analytics/code-insights',
render: lazyComponent(() => import('./analytics/AnalyticsComingSoonPage'), 'AnalyticsComingSoonPage'),
render: lazyComponent(() => import('./analytics/AnalyticsCodeInsightsPage'), 'AnalyticsCodeInsightsPage'),
exact: true,
},
{
@ -154,11 +154,6 @@ export const analyticsRoutes: readonly SiteAdminAreaRoute[] = [
render: lazyComponent(() => import('./analytics/AnalyticsNotebooksPage'), 'AnalyticsNotebooksPage'),
exact: true,
},
{
path: '/analytics/extensions',
render: lazyComponent(() => import('./analytics/AnalyticsComingSoonPage'), 'AnalyticsComingSoonPage'),
exact: true,
},
{
path: '/',
render: lazyComponent(() => import('./analytics/AnalyticsOverviewPage'), 'AnalyticsOverviewPage'),

View File

@ -0,0 +1,189 @@
import React, { useMemo, useEffect } from 'react'
import { startCase } from 'lodash'
import { RouteComponentProps } from 'react-router'
import { useQuery } from '@sourcegraph/http-client'
import { Card, LoadingSpinner, Text, LineChart, Series, H2 } from '@sourcegraph/wildcard'
import { InsightsStatisticsResult, InsightsStatisticsVariables } from '../../../graphql-operations'
import { eventLogger } from '../../../tracking/eventLogger'
import { AnalyticsPageTitle } from '../components/AnalyticsPageTitle'
import { ChartContainer } from '../components/ChartContainer'
import { HorizontalSelect } from '../components/HorizontalSelect'
import { ToggleSelect } from '../components/ToggleSelect'
import { ValueLegendList, ValueLegendListProps } from '../components/ValueLegendList'
import { useChartFilters } from '../useChartFilters'
import { StandardDatum } from '../utils'
import { INSIGHTS_STATISTICS } from './queries'
/**
* Minutes saved constants for code insights.
*/
const MinutesSaved = {
SearchSeries: 150,
LanguageSeries: 3,
ComputeSeries: 1,
}
/**
* Calculates the total time saved in minutes for a given series.
*
* This is used to in "Analytics / Overview" page.
*/
export const calculateMinutesSaved = (data: typeof MinutesSaved): number =>
data.SearchSeries * MinutesSaved.SearchSeries +
data.LanguageSeries * MinutesSaved.LanguageSeries +
data.ComputeSeries * MinutesSaved.ComputeSeries
export const AnalyticsCodeInsightsPage: React.FunctionComponent<RouteComponentProps> = () => {
const { dateRange, aggregation, grouping } = useChartFilters({ name: 'Insights', aggregation: 'count' })
const { data, error, loading } = useQuery<InsightsStatisticsResult, InsightsStatisticsVariables>(
INSIGHTS_STATISTICS,
{
variables: {
dateRange: dateRange.value,
grouping: grouping.value,
},
}
)
useEffect(() => {
eventLogger.logPageView('AdminAnalyticsCodeInsights')
}, [])
const legends = useMemo(() => {
if (!data) {
return []
}
const { insightHovers, insightDataPointClicks } = data.site.analytics.codeInsights
const { insightViews, insightsDashboards } = data
const legends: ValueLegendListProps['items'] = [
{
value:
aggregation.selected === 'count'
? insightHovers.summary.totalCount
: insightHovers.summary.totalRegisteredUsers,
description: aggregation.selected === 'count' ? 'Insight hovers' : 'Users hovering insights',
color: 'var(--orange)',
tooltip:
aggregation.selected === 'count'
? 'The number of insight datapoint hovers during the timeframe.'
: 'The number of users hovering over insight data points during the timeframe.',
},
{
value:
aggregation.selected === 'count'
? insightDataPointClicks.summary.totalCount
: insightDataPointClicks.summary.totalRegisteredUsers,
description: aggregation.selected === 'count' ? 'Datapoint clicks' : 'Users clicking datapoints',
color: 'var(--purple)',
tooltip:
aggregation.selected === 'count'
? 'The number of insight datapoint clicks during the timeframe.'
: 'The number of users clicking on insight data points during the timeframe.',
},
{
value: insightViews.nodes.length,
description: 'Total insights',
position: 'right',
tooltip: 'The number of currently existing insights.',
},
{
value: insightsDashboards.nodes.length,
description: 'Total dashboards',
position: 'right',
tooltip: 'The number of currently existing insight dashboards.',
},
]
return legends
}, [aggregation.selected, data])
const activities = useMemo(() => {
if (!data) {
return []
}
const { insightHovers, insightDataPointClicks } = data.site.analytics.codeInsights
const activities: Series<StandardDatum>[] = [
{
id: 'insight-hovers',
name: aggregation.selected === 'count' ? 'Insight hovers' : 'Users hovering insights',
color: 'var(--orange)',
data: insightHovers.nodes.map(
node => ({
date: new Date(node.date),
value: node[aggregation.selected],
}),
dateRange.value
),
getXValue: ({ date }) => date,
getYValue: ({ value }) => value,
},
{
id: 'datapoint-clicks',
name: aggregation.selected === 'count' ? 'Datapoint clicks' : 'Users clicking datapoints',
color: 'var(--purple)',
data: insightDataPointClicks.nodes.map(
node => ({
date: new Date(node.date),
value: node[aggregation.selected],
}),
dateRange.value
),
getXValue: ({ date }) => date,
getYValue: ({ value }) => value,
},
]
return activities
}, [data, aggregation.selected, dateRange.value])
if (error) {
throw error
}
if (loading) {
return <LoadingSpinner />
}
const groupingLabel = startCase(grouping.value.toLowerCase())
return (
<>
<AnalyticsPageTitle>Insights</AnalyticsPageTitle>
<Card className="p-3">
<div className="d-flex justify-content-end align-items-stretch mb-2 text-nowrap">
<HorizontalSelect<typeof dateRange.value> {...dateRange} />
</div>
{legends && <ValueLegendList className="mb-3" items={legends} />}
{activities && (
<div>
<ChartContainer
title={
aggregation.selected === 'count'
? `${groupingLabel} activity`
: `${groupingLabel} unique users`
}
labelX="Time"
labelY={aggregation.selected === 'count' ? 'Activity' : 'Unique users'}
>
{width => <LineChart width={width} height={300} series={activities} />}
</ChartContainer>
<div className="d-flex justify-content-end align-items-stretch mb-4 text-nowrap">
<HorizontalSelect<typeof grouping.value> {...grouping} className="mr-4" />
<ToggleSelect<typeof aggregation.selected> {...aggregation} />
</div>
</div>
)}
<H2 className="my-3">Total time saved</H2>
<Text>Coming soon...</Text>
</Card>
<Text className="font-italic text-center mt-2">
Some metrics are generated from entries in the event logs table and are updated every 24 hours.
</Text>
</>
)
}

View File

@ -0,0 +1,43 @@
import { gql } from '@sourcegraph/http-client'
const analyticsStatItemFragment = gql`
fragment AnalyticsStatItemFragment on AnalyticsStatItem {
nodes {
date
count
registeredUsers
}
summary {
totalCount
totalRegisteredUsers
}
}
`
export const INSIGHTS_STATISTICS = gql`
query InsightsStatistics($dateRange: AnalyticsDateRange!, $grouping: AnalyticsGrouping!) {
site {
analytics {
codeInsights(dateRange: $dateRange, grouping: $grouping) {
insightHovers {
...AnalyticsStatItemFragment
}
insightDataPointClicks {
...AnalyticsStatItemFragment
}
}
}
}
insightViews {
nodes {
id
}
}
insightsDashboards {
nodes {
id
}
}
}
${analyticsStatItemFragment}
`

View File

@ -1,4 +0,0 @@
.large-icon {
width: 6rem !important;
height: 6rem !important;
}

View File

@ -1,34 +0,0 @@
import React, { useMemo } from 'react'
import { mdiChartTimelineVariantShimmer } from '@mdi/js'
import classNames from 'classnames'
import { upperFirst } from 'lodash'
import { RouteComponentProps } from 'react-router'
import { H3, Text, Icon } from '@sourcegraph/wildcard'
import { AnalyticsPageTitle } from '../components/AnalyticsPageTitle'
import styles from './index.module.scss'
export const AnalyticsComingSoonPage: React.FunctionComponent<RouteComponentProps<{}>> = props => {
const title = useMemo(() => {
const title = props.match.path.split('/').filter(Boolean)[2] ?? 'Overview'
return upperFirst(title.replace('-', ' '))
}, [props.match.path])
return (
<>
<AnalyticsPageTitle>{title}</AnalyticsPageTitle>
<div className="d-flex flex-column justify-content-center align-items-center p-5">
<Icon
svgPath={mdiChartTimelineVariantShimmer}
aria-label="Home analytics icon"
className={classNames(styles.largeIcon, 'm-3')}
/>
<H3>Coming soon</H3>
<Text>We are working on making this live.</Text>
</div>
</>
)
}

View File

@ -1,10 +1,10 @@
import React from 'react'
import { mdiMagnify, mdiSitemap, mdiBookOutline, mdiPuzzleOutline } from '@mdi/js'
import { mdiMagnify, mdiSitemap, mdiBookOutline, mdiPuzzleOutline, mdiPoll } from '@mdi/js'
import classNames from 'classnames'
import { useQuery } from '@sourcegraph/http-client'
import { H2, H3, Text, LoadingSpinner, Link, Icon } from '@sourcegraph/wildcard'
import { H2, H3, Text, LoadingSpinner, Link, Icon, Tooltip } from '@sourcegraph/wildcard'
import { BatchChangesIconNav } from '../../../batches/icons'
import {
@ -264,6 +264,26 @@ export const DevTimeSaved: React.FunctionComponent<DevTimeSavedProps> = ({ showA
{formatNumber(totalExtensionsHoursSaved)}
</Text>
</tr>
<tr>
<td className="text-left">
<Link to="/site-admin/analytics/code-insights">
<Text as="span" className="d-flex align-items-center">
<Icon svgPath={mdiPoll} size="md" aria-label="Extensions" className="mr-1" />
Code insights
</Text>
</Link>
</td>
<Tooltip content="Coming soon">
<Text className="cursor-pointer" as="td" weight="bold">
...*
</Text>
</Tooltip>
<Tooltip content="Coming soon">
<Text className="cursor-pointer" as="td" weight="bold">
...*
</Text>
</Tooltip>
</tr>
</tbody>
</table>
</div>

View File

@ -24,6 +24,7 @@ interface TimeSavedCalculatorGroupProps {
page: string
color: string
value: number
itemsLabel?: string
label: string
description: string
dateRange: AnalyticsDateRange
@ -43,6 +44,7 @@ export const TimeSavedCalculatorGroup: React.FunctionComponent<TimeSavedCalculat
items,
color,
value,
itemsLabel = 'Events',
description,
label,
dateRange,
@ -173,7 +175,7 @@ export const TimeSavedCalculatorGroup: React.FunctionComponent<TimeSavedCalculat
</Text>
) : (
<Text as="span" alignment="center" className="text-muted">
Events
{itemsLabel}
</Text>
)}
<Text as="span" className="text-nowrap text-muted">

View File

@ -6411,6 +6411,19 @@ type AnalyticsExtensionsResult {
browser: AnalyticsStatItem!
}
"""
Code insights analytics.
"""
type AnalyticsCodesInsightsResult {
"""
Insights hovers statistics.
"""
insightHovers: AnalyticsStatItem!
"""
Insights data point clicks statistics.
"""
insightDataPointClicks: AnalyticsStatItem!
}
"""
Analytics describes a new site statistics.
"""
@ -6451,6 +6464,10 @@ type Analytics {
Extensions statistics
"""
extensions(dateRange: AnalyticsDateRange, grouping: AnalyticsGrouping): AnalyticsExtensionsResult!
"""
Code insights statistics
"""
codeInsights(dateRange: AnalyticsDateRange, grouping: AnalyticsGrouping): AnalyticsCodesInsightsResult!
}
"""

View File

@ -107,3 +107,12 @@ func (r *siteAnalyticsResolver) Extensions(ctx context.Context, args *struct {
}) *adminanalytics.Extensions {
return &adminanalytics.Extensions{DateRange: *args.DateRange, Grouping: *args.Grouping, DB: r.db, Cache: r.cache}
}
/* Insights */
func (r *siteAnalyticsResolver) CodeInsights(ctx context.Context, args *struct {
DateRange *string
Grouping *string
}) *adminanalytics.CodeInsights {
return &adminanalytics.CodeInsights{DateRange: *args.DateRange, Grouping: *args.Grouping, DB: r.db, Cache: r.cache}
}

View File

@ -107,6 +107,7 @@ func refreshAnalyticsCache(ctx context.Context, db database.DB) error {
&Repos{DB: db, Cache: true},
&BatchChanges{Grouping: groupBy, DateRange: dateRange, DB: db, Cache: true},
&Extensions{Grouping: groupBy, DateRange: dateRange, DB: db, Cache: true},
&CodeInsights{Grouping: groupBy, DateRange: dateRange, DB: db, Cache: true},
}
for _, store := range stores {
if err := store.CacheAll(ctx); err != nil {

View File

@ -0,0 +1,82 @@
package adminanalytics
import (
"context"
"github.com/sourcegraph/sourcegraph/internal/database"
)
type CodeInsights struct {
DateRange string
Grouping string
DB database.DB
Cache bool
}
// Insights:Hovers
func (c *CodeInsights) InsightHovers() (*AnalyticsFetcher, error) {
nodesQuery, summaryQuery, err := makeEventLogsQueries(
c.DateRange,
c.Grouping,
[]string{"InsightHover"},
)
if err != nil {
return nil, err
}
return &AnalyticsFetcher{
db: c.DB,
dateRange: c.DateRange,
grouping: c.Grouping,
nodesQuery: nodesQuery,
summaryQuery: summaryQuery,
group: "Insights:InsightHovers",
cache: c.Cache,
}, nil
}
// Insights:DataPointClicks
func (c *CodeInsights) InsightDataPointClicks() (*AnalyticsFetcher, error) {
nodesQuery, summaryQuery, err := makeEventLogsQueries(
c.DateRange,
c.Grouping,
[]string{"InsightDataPointClick"},
)
if err != nil {
return nil, err
}
return &AnalyticsFetcher{
db: c.DB,
dateRange: c.DateRange,
grouping: c.Grouping,
nodesQuery: nodesQuery,
summaryQuery: summaryQuery,
group: "Insights:InsightDataPointClicks",
cache: c.Cache,
}, nil
}
// Insights caching job entrypoint
func (c *CodeInsights) CacheAll(ctx context.Context) error {
fetcherBuilders := []func() (*AnalyticsFetcher, error){c.InsightHovers, c.InsightDataPointClicks}
for _, buildFetcher := range fetcherBuilders {
fetcher, err := buildFetcher()
if err != nil {
return err
}
if _, err := fetcher.Nodes(ctx); err != nil {
return err
}
if _, err := fetcher.Summary(ctx); err != nil {
return err
}
}
return nil
}