diff --git a/client/web/src/site-admin/SiteAdminArea.tsx b/client/web/src/site-admin/SiteAdminArea.tsx index 96affa979cf..8510c0e1bba 100644 --- a/client/web/src/site-admin/SiteAdminArea.tsx +++ b/client/web/src/site-admin/SiteAdminArea.tsx @@ -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'), diff --git a/client/web/src/site-admin/analytics/AnalyticsCodeInsightsPage/index.tsx b/client/web/src/site-admin/analytics/AnalyticsCodeInsightsPage/index.tsx new file mode 100644 index 00000000000..053011248ab --- /dev/null +++ b/client/web/src/site-admin/analytics/AnalyticsCodeInsightsPage/index.tsx @@ -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 = () => { + const { dateRange, aggregation, grouping } = useChartFilters({ name: 'Insights', aggregation: 'count' }) + const { data, error, loading } = useQuery( + 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[] = [ + { + 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 + } + + const groupingLabel = startCase(grouping.value.toLowerCase()) + + return ( + <> + Insights + +
+ {...dateRange} /> +
+ {legends && } + {activities && ( +
+ + {width => } + +
+ {...grouping} className="mr-4" /> + {...aggregation} /> +
+
+ )} + +

Total time saved

+ Coming soon... +
+ + Some metrics are generated from entries in the event logs table and are updated every 24 hours. + + + ) +} diff --git a/client/web/src/site-admin/analytics/AnalyticsCodeInsightsPage/queries.ts b/client/web/src/site-admin/analytics/AnalyticsCodeInsightsPage/queries.ts new file mode 100644 index 00000000000..c7c0915ecfb --- /dev/null +++ b/client/web/src/site-admin/analytics/AnalyticsCodeInsightsPage/queries.ts @@ -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} +` diff --git a/client/web/src/site-admin/analytics/AnalyticsComingSoonPage/index.module.scss b/client/web/src/site-admin/analytics/AnalyticsComingSoonPage/index.module.scss deleted file mode 100644 index f72a2ca5f30..00000000000 --- a/client/web/src/site-admin/analytics/AnalyticsComingSoonPage/index.module.scss +++ /dev/null @@ -1,4 +0,0 @@ -.large-icon { - width: 6rem !important; - height: 6rem !important; -} diff --git a/client/web/src/site-admin/analytics/AnalyticsComingSoonPage/index.tsx b/client/web/src/site-admin/analytics/AnalyticsComingSoonPage/index.tsx deleted file mode 100644 index 8d3f930a825..00000000000 --- a/client/web/src/site-admin/analytics/AnalyticsComingSoonPage/index.tsx +++ /dev/null @@ -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> = props => { - const title = useMemo(() => { - const title = props.match.path.split('/').filter(Boolean)[2] ?? 'Overview' - return upperFirst(title.replace('-', ' ')) - }, [props.match.path]) - return ( - <> - {title} - -
- -

Coming soon

- We are working on making this live. -
- - ) -} diff --git a/client/web/src/site-admin/analytics/AnalyticsOverviewPage/DevTimeSaved.tsx b/client/web/src/site-admin/analytics/AnalyticsOverviewPage/DevTimeSaved.tsx index 1ddb87c899a..2c7769a84d9 100644 --- a/client/web/src/site-admin/analytics/AnalyticsOverviewPage/DevTimeSaved.tsx +++ b/client/web/src/site-admin/analytics/AnalyticsOverviewPage/DevTimeSaved.tsx @@ -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 = ({ showA {formatNumber(totalExtensionsHoursSaved)} + + + + + + Code insights + + + + + + ...* + + + + + ...* + + + diff --git a/client/web/src/site-admin/analytics/components/TimeSavedCalculatorGroup.tsx b/client/web/src/site-admin/analytics/components/TimeSavedCalculatorGroup.tsx index 476c550c456..1db6878da1d 100644 --- a/client/web/src/site-admin/analytics/components/TimeSavedCalculatorGroup.tsx +++ b/client/web/src/site-admin/analytics/components/TimeSavedCalculatorGroup.tsx @@ -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 ) : ( - Events + {itemsLabel} )} diff --git a/cmd/frontend/graphqlbackend/schema.graphql b/cmd/frontend/graphqlbackend/schema.graphql index 45beea637b4..6d34ca5044b 100755 --- a/cmd/frontend/graphqlbackend/schema.graphql +++ b/cmd/frontend/graphqlbackend/schema.graphql @@ -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! } """ diff --git a/cmd/frontend/graphqlbackend/site_analytics.go b/cmd/frontend/graphqlbackend/site_analytics.go index 4e1977ccf13..191bb2d2277 100644 --- a/cmd/frontend/graphqlbackend/site_analytics.go +++ b/cmd/frontend/graphqlbackend/site_analytics.go @@ -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} +} diff --git a/internal/adminanalytics/cache.go b/internal/adminanalytics/cache.go index 8c99882102b..3cfdb337095 100644 --- a/internal/adminanalytics/cache.go +++ b/internal/adminanalytics/cache.go @@ -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 { diff --git a/internal/adminanalytics/codeinsights.go b/internal/adminanalytics/codeinsights.go new file mode 100644 index 00000000000..64c16525c03 --- /dev/null +++ b/internal/adminanalytics/codeinsights.go @@ -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 +}