mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 15:51:43 +00:00
admin-analytics: add "insights" page (#41489)
This commit is contained in:
parent
7fedbd9370
commit
4e103ac59d
@ -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'),
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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}
|
||||
`
|
||||
@ -1,4 +0,0 @@
|
||||
.large-icon {
|
||||
width: 6rem !important;
|
||||
height: 6rem !important;
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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!
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
82
internal/adminanalytics/codeinsights.go
Normal file
82
internal/adminanalytics/codeinsights.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user