mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
Site admin: Remove legacy overview admin UI pages (#45499)
* Remove legacy overview admin UI pages * Remove legacy API handlers * Remove legacy dependencies * fix build and lint problems * Simplify analytics routing * Remove user-management-disabled feature flag * Fix generated types and styles imports
This commit is contained in:
parent
61d5544660
commit
e9c6dc68eb
@ -20,7 +20,11 @@ const SHARED_DOCUMENTS_GLOB = [
|
||||
`!${SHARED_FOLDER}/src/schema.ts`,
|
||||
]
|
||||
|
||||
const WEB_DOCUMENTS_GLOB = [`${WEB_FOLDER}/src/**/*.{ts,tsx}`, `!${WEB_FOLDER}/src/end-to-end/**/*.*`]
|
||||
const WEB_DOCUMENTS_GLOB = [
|
||||
`${WEB_FOLDER}/src/**/*.{ts,tsx}`,
|
||||
`${WEB_FOLDER}/src/regression/**/*.*`,
|
||||
`!${WEB_FOLDER}/src/end-to-end/**/*.*`,
|
||||
]
|
||||
|
||||
const BROWSER_DOCUMENTS_GLOB = [
|
||||
`${BROWSER_FOLDER}/src/**/*.{ts,tsx}`,
|
||||
|
||||
@ -10,7 +10,6 @@ import { repoContainerRoutes, repoRevisionContainerRoutes } from './repo/routes'
|
||||
import { repoSettingsAreaRoutes } from './repo/settings/routes'
|
||||
import { repoSettingsSideBarGroups } from './repo/settings/sidebaritems'
|
||||
import { routes } from './routes'
|
||||
import { siteAdminOverviewComponents } from './site-admin/overview/overviewComponents'
|
||||
import { siteAdminAreaRoutes } from './site-admin/routes'
|
||||
import { siteAdminSidebarGroups } from './site-admin/sidebaritems'
|
||||
import { SourcegraphWebApp } from './SourcegraphWebApp'
|
||||
@ -25,7 +24,7 @@ export const OpenSourceWebApp: React.FunctionComponent<React.PropsWithChildren<u
|
||||
<SourcegraphWebApp
|
||||
siteAdminAreaRoutes={siteAdminAreaRoutes}
|
||||
siteAdminSideBarGroups={siteAdminSidebarGroups}
|
||||
siteAdminOverviewComponents={siteAdminOverviewComponents}
|
||||
siteAdminOverviewComponents={[]}
|
||||
userAreaRoutes={userAreaRoutes}
|
||||
userAreaHeaderNavItems={userAreaHeaderNavItems}
|
||||
userSettingsSideBarItems={userSettingsSideBarItems}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
.d3-bar-chart {
|
||||
overflow: visible !important;
|
||||
margin: 4rem 0 4rem 0;
|
||||
|
||||
text,
|
||||
tspan {
|
||||
fill: currentColor;
|
||||
font-size: 0.5625rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
:global(.axis) path,
|
||||
:global(.axis) :global(.tick) line {
|
||||
stroke: var(--border-color);
|
||||
}
|
||||
}
|
||||
@ -1,215 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { axisBottom, AxisContainerElement } from 'd3-axis'
|
||||
import { scaleBand, scaleLinear, scaleOrdinal } from 'd3-scale'
|
||||
import { select, Selection } from 'd3-selection'
|
||||
import { stack } from 'd3-shape'
|
||||
import { isEqual } from 'lodash'
|
||||
|
||||
import { ThemeProps } from '@sourcegraph/shared/src/theme'
|
||||
|
||||
import styles from './BarChart.module.scss'
|
||||
|
||||
interface BarChartSeries {
|
||||
[key: string]: null
|
||||
}
|
||||
|
||||
interface BarChartDatum<T extends BarChartSeries> {
|
||||
xLabel: string
|
||||
yValues: { [key in keyof T]: number }
|
||||
}
|
||||
|
||||
interface Props<T extends BarChartSeries> extends ThemeProps {
|
||||
/**
|
||||
* Bar chart data.
|
||||
* One datum for each column, with each datum containing values for each series in the given column.
|
||||
*/
|
||||
data: BarChartDatum<T>[]
|
||||
/**
|
||||
* Initial width (chart will be automatically resized to fit container).
|
||||
*/
|
||||
width: number
|
||||
/**
|
||||
* Initial height (chart will be automatically resized to fit container).
|
||||
*/
|
||||
height: number
|
||||
/**
|
||||
* Display column totals labels.
|
||||
*/
|
||||
showLabels?: boolean
|
||||
/**
|
||||
* Display legend.
|
||||
*/
|
||||
showLegend?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export class BarChart<T extends BarChartSeries> extends React.Component<Props<T>> {
|
||||
private svgRef: SVGSVGElement | null = null
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.drawChart()
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
this.drawChart()
|
||||
}
|
||||
|
||||
public shouldComponentUpdate(nextProps: Props<T>): boolean {
|
||||
return !isEqual(this.props, nextProps)
|
||||
}
|
||||
|
||||
private drawChart = (): void => {
|
||||
if (!this.svgRef) {
|
||||
return
|
||||
}
|
||||
const { width, height } = this.props
|
||||
|
||||
const data = this.props.data.reverse()
|
||||
const barColors = this.props.isLightTheme ? ['#a2b0cd', '#cad2e2'] : ['#566e9f', '#a2b0cd']
|
||||
const series = Object.keys(data[0].yValues)
|
||||
const xLabels = data.map(({ xLabel }) => xLabel)
|
||||
const yValues = data.map(({ yValues }) => yValues)
|
||||
const yHeights = data.map(({ yValues }) =>
|
||||
Object.keys(yValues).reduce((accumulator, key) => accumulator + yValues[key], 0)
|
||||
)
|
||||
|
||||
if (data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const columns = xLabels.length
|
||||
|
||||
const xScaleBand = scaleBand().domain(xLabels).rangeRound([0, width])
|
||||
const yScaleBand = scaleLinear()
|
||||
.domain([0, Math.max(...yHeights)])
|
||||
.range([height, 0])
|
||||
const zScaleOrdinal = scaleOrdinal<string, string>().domain(series).range(barColors)
|
||||
const xAxis = axisBottom(xScaleBand)
|
||||
|
||||
const svg = select(this.svgRef)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const barWidth = width / columns - 2
|
||||
|
||||
const barHolder = svg
|
||||
.classed(`${styles.d3BarChart} ${this.props.className || ''}`, true)
|
||||
.attr('preserveAspectRatio', 'xMinYMin')
|
||||
.append('g')
|
||||
.classed('bar-holder', true)
|
||||
|
||||
const stackData = stack()
|
||||
.keys(series)
|
||||
.value((data, key) => data[key])(yValues, series)
|
||||
|
||||
// Generate bars.
|
||||
barHolder
|
||||
.append('g')
|
||||
.selectAll('g')
|
||||
.data(stackData)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('fill', data => zScaleOrdinal(data.key))
|
||||
.selectAll('rect')
|
||||
.data(data => data)
|
||||
.enter()
|
||||
.append('rect')
|
||||
.classed('bar', true)
|
||||
.attr('x', (data, index) => xScaleBand(xLabels[index]) || 0 + 1)
|
||||
.attr('y', data => yScaleBand(data[1]))
|
||||
.attr('width', barWidth)
|
||||
.attr('height', data => yScaleBand(data[0]) - yScaleBand(data[1]))
|
||||
.attr('title', data => `${data[1] - data[0]} users`)
|
||||
|
||||
if (this.props.showLabels) {
|
||||
// Generate value labels on top of each column.
|
||||
barHolder
|
||||
.append('g')
|
||||
.selectAll('text')
|
||||
.data(data)
|
||||
.enter()
|
||||
.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('x', data => xScaleBand(data.xLabel) || 0)
|
||||
.attr('dx', barWidth / 2)
|
||||
.attr('y', (data, index) => yScaleBand(yHeights[index]))
|
||||
.attr('dy', '-0.5em')
|
||||
.text((data, index) => yHeights[index])
|
||||
}
|
||||
|
||||
// Generate x-axis and labels.
|
||||
barHolder
|
||||
.append<AxisContainerElement>('g')
|
||||
.classed('axis', true)
|
||||
.attr('transform', `translate(0, ${height})`)
|
||||
.call(xAxis)
|
||||
.selectAll('.tick text')
|
||||
.call(wrapLabel, barWidth)
|
||||
|
||||
if (this.props.showLegend) {
|
||||
// Generate a legend.
|
||||
const legend = barHolder
|
||||
.append('svg')
|
||||
.attr('y', '-5em')
|
||||
.append('g')
|
||||
.attr('text-anchor', 'end')
|
||||
.selectAll('g')
|
||||
.data(series.slice().reverse())
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('transform', (data, index) => `translate(0,${index * 20})`)
|
||||
legend
|
||||
.append('rect')
|
||||
.attr('x', width - 19)
|
||||
.attr('width', 19)
|
||||
.attr('height', 19)
|
||||
.attr('fill', zScaleOrdinal)
|
||||
legend
|
||||
.append('text')
|
||||
.attr('x', width - 24)
|
||||
.attr('y', 9.5)
|
||||
.attr('dy', '0.32em')
|
||||
.text(data => data)
|
||||
}
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const { width, height } = this.props
|
||||
return <svg viewBox={`0 0 ${width} ${height}`} ref={reference => (this.svgRef = reference)} />
|
||||
}
|
||||
}
|
||||
|
||||
// Source: Mike Bostock's "Wrapping Long Labels": https://bl.ocks.org/mbostock/7555321
|
||||
function wrapLabel(text: Selection<any, any, any, any>, width: number): void {
|
||||
text.each(function (): void {
|
||||
const text = select(this)
|
||||
const words = text.text().split(/\s+/).reverse()
|
||||
const lineHeight = 1.1
|
||||
const yAttribute = text.attr('y')
|
||||
const dyAttribute = parseFloat(text.attr('dy'))
|
||||
let lineNumber = 0
|
||||
let currentWord
|
||||
// currentLine holds the line as it grows, until it overflows.
|
||||
let currentLine: string[] = []
|
||||
// tspan holds the current <tspan> element as it grows, until it overflows.
|
||||
let tspan = text.text(null).append('tspan').attr('x', 0).attr('y', yAttribute).attr('dy', `${dyAttribute}em`)
|
||||
|
||||
while (words.length) {
|
||||
currentWord = words.pop() || ''
|
||||
currentLine.push(currentWord)
|
||||
tspan.text(currentLine.join(' '))
|
||||
if ((tspan.node() as SVGTextContentElement).getComputedTextLength() > width) {
|
||||
currentLine.pop()
|
||||
tspan.text(currentLine.join(' '))
|
||||
// Start a new line and generate a new tspan element.
|
||||
currentLine = [currentWord]
|
||||
tspan = text
|
||||
.append('tspan')
|
||||
.attr('x', 0)
|
||||
.attr('y', yAttribute)
|
||||
.attr('dy', `${++lineNumber * lineHeight + dyAttribute}em`)
|
||||
.text(currentWord)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -2,9 +2,6 @@ import React from 'react'
|
||||
|
||||
import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
|
||||
|
||||
import { siteAdminOverviewComponents } from '../../../site-admin/overview/overviewComponents'
|
||||
|
||||
export const enterpriseSiteAdminOverviewComponents: readonly React.ComponentType<React.PropsWithChildren<any>>[] = [
|
||||
...siteAdminOverviewComponents,
|
||||
lazyComponent(() => import('../productSubscription/ProductSubscriptionStatus'), 'ProductSubscriptionStatus'),
|
||||
]
|
||||
|
||||
@ -5,9 +5,9 @@ import PackageVariantIcon from 'mdi-react/PackageVariantIcon'
|
||||
import { BatchChangesIcon } from '../../batches/icons'
|
||||
import {
|
||||
apiConsoleGroup,
|
||||
analyticsGroup,
|
||||
configurationGroup as ossConfigurationGroup,
|
||||
maintenanceGroup as ossMaintenanceGroup,
|
||||
overviewGroup,
|
||||
repositoriesGroup as ossRepositoriesGroup,
|
||||
usersGroup,
|
||||
} from '../../site-admin/sidebaritems'
|
||||
@ -128,7 +128,7 @@ const repositoriesGroup: SiteAdminSideBarGroup = {
|
||||
}
|
||||
|
||||
export const enterpriseSiteAdminSidebarGroups: SiteAdminSideBarGroups = [
|
||||
overviewGroup,
|
||||
analyticsGroup,
|
||||
configurationGroup,
|
||||
repositoriesGroup,
|
||||
codeIntelGroup,
|
||||
|
||||
@ -11,10 +11,8 @@ export type FeatureFlagName =
|
||||
| 'ab-visitor-tour-with-notebooks'
|
||||
| 'ab-email-verification-alert'
|
||||
| 'contrast-compliant-syntax-highlighting'
|
||||
| 'admin-analytics-disabled'
|
||||
| 'admin-analytics-cache-disabled'
|
||||
| 'search-input-show-history'
|
||||
| 'user-management-disabled'
|
||||
| 'search-results-keyboard-navigation'
|
||||
| 'enable-streaming-git-blame'
|
||||
| 'plg-enable-add-codehost-widget'
|
||||
|
||||
@ -7,10 +7,10 @@ import { map, tap, retryWhen, delayWhen, take, mergeMap } from 'rxjs/operators'
|
||||
|
||||
import { isErrorLike, createAggregateError, logger } from '@sourcegraph/common'
|
||||
import {
|
||||
gql,
|
||||
dataOrThrowErrors,
|
||||
createInvalidGraphQLMutationResponseError,
|
||||
isErrorGraphQLResult,
|
||||
gql,
|
||||
} from '@sourcegraph/http-client'
|
||||
import {
|
||||
CloneInProgressError,
|
||||
@ -26,7 +26,6 @@ import {
|
||||
AddExternalServiceInput,
|
||||
ExternalServiceKind,
|
||||
UpdateExternalServiceInput,
|
||||
DeleteUserResult,
|
||||
Scalars,
|
||||
DeleteOrganizationResult,
|
||||
SearchPatternType,
|
||||
@ -38,34 +37,33 @@ import {
|
||||
CreateUserResult,
|
||||
UpdateExternalServiceResult,
|
||||
UpdateExternalServiceVariables,
|
||||
ResolveRevResult,
|
||||
ResolveRevVariables,
|
||||
OrganizationsVariables,
|
||||
addExternalServiceVariables,
|
||||
SearchResult,
|
||||
SearchVariables,
|
||||
SearchVersion,
|
||||
ExternalServicesRegressionVariables,
|
||||
ExternalServicesRegressionResult,
|
||||
ExternalServiceNodeFields,
|
||||
SiteProductVersionResult,
|
||||
SiteProductVersionVariables,
|
||||
SetUserEmailVerifiedResult,
|
||||
SetUserEmailVerifiedVariables,
|
||||
DeleteOrganizationVariables,
|
||||
CreateOrganizationVariables,
|
||||
CreateUserVariables,
|
||||
UserVariables,
|
||||
UserResult,
|
||||
SetUserIsSiteAdminResult,
|
||||
SetUserIsSiteAdminVariables,
|
||||
SetTosAcceptedResult,
|
||||
SetTosAcceptedVariables,
|
||||
DeleteExternalServiceResult,
|
||||
DeleteExternalServiceVariables,
|
||||
UpdateSiteConfigurationVariables,
|
||||
ResolveRevResult,
|
||||
ResolveRevVariables,
|
||||
ExternalServicesRegressionResult,
|
||||
ExternalServicesRegressionVariables,
|
||||
ExternalServiceNodeFields,
|
||||
UserResult,
|
||||
DeleteUserResult,
|
||||
DeleteUserVariables,
|
||||
SetTosAcceptedResult,
|
||||
SetTosAcceptedVariables,
|
||||
SiteProductVersionResult,
|
||||
SiteProductVersionVariables,
|
||||
UserVariables,
|
||||
addExternalServiceResult,
|
||||
addExternalServiceVariables,
|
||||
SearchResult,
|
||||
SearchVariables,
|
||||
} from '../../graphql-operations'
|
||||
|
||||
import { GraphQLClient } from './GraphQlClient'
|
||||
@ -468,35 +466,6 @@ export function currentProductVersion(gqlClient: GraphQLClient): Promise<string>
|
||||
.toPromise()
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(beyang): remove this after the corresponding API in the main code has been updated to use a
|
||||
* dependency-injected `requestGraphQL`.
|
||||
*/
|
||||
export async function setUserEmailVerified(
|
||||
gqlClient: GraphQLClient,
|
||||
username: string,
|
||||
email: string,
|
||||
verified: boolean
|
||||
): Promise<void> {
|
||||
const user = await getUser(gqlClient, username)
|
||||
if (!user) {
|
||||
throw new Error(`User ${username} does not exist`)
|
||||
}
|
||||
await gqlClient
|
||||
.mutateGraphQL<SetUserEmailVerifiedResult, SetUserEmailVerifiedVariables>(
|
||||
gql`
|
||||
mutation SetUserEmailVerified($user: ID!, $email: String!, $verified: Boolean!) {
|
||||
setUserEmailVerified(user: $user, email: $email, verified: $verified) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ user: user.id, email, verified }
|
||||
)
|
||||
.pipe(map(dataOrThrowErrors))
|
||||
.toPromise()
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(beyang): remove this after the corresponding API in the main code has been updated to use a
|
||||
* dependency-injected `requestGraphQL`.
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
import { withFeatureFlag } from '../../featureFlags/withFeatureFlag'
|
||||
|
||||
import { UsersManagement } from './UserManagement'
|
||||
|
||||
import { SiteAdminAllUsersPage } from '.'
|
||||
|
||||
export const FeatureFlaggedUsersPage = withFeatureFlag(
|
||||
'admin-analytics-disabled',
|
||||
SiteAdminAllUsersPage,
|
||||
UsersManagement
|
||||
)
|
||||
@ -1,431 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { mdiCog, mdiPlus } from '@mdi/js'
|
||||
import * as H from 'history'
|
||||
import { isEqual } from 'lodash'
|
||||
import { RouteComponentProps } from 'react-router'
|
||||
import { merge, of, Subject, Subscription } from 'rxjs'
|
||||
import { catchError, distinctUntilChanged, map, switchMap } from 'rxjs/operators'
|
||||
|
||||
import { asError, logger } from '@sourcegraph/common'
|
||||
import { Button, Link, Alert, Icon, H2, Text, Tooltip, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
|
||||
import { AuthenticatedUser } from '../../auth'
|
||||
import { CopyableText } from '../../components/CopyableText'
|
||||
import { FilteredConnection } from '../../components/FilteredConnection'
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
import { UserNodeFields } from '../../graphql-operations'
|
||||
import { eventLogger } from '../../tracking/eventLogger'
|
||||
import { userURL } from '../../user'
|
||||
import { setUserEmailVerified } from '../../user/settings/backend'
|
||||
import {
|
||||
deleteUser,
|
||||
fetchAllUsers,
|
||||
randomizeUserPassword,
|
||||
setUserIsSiteAdmin,
|
||||
invalidateSessionsByID,
|
||||
setUserTag,
|
||||
} from '../backend'
|
||||
|
||||
const CREATE_ORG_TAG = 'CreateOrg'
|
||||
|
||||
interface UserNodeProps {
|
||||
/**
|
||||
* The user to display in this list item.
|
||||
*/
|
||||
node: UserNodeFields
|
||||
|
||||
/**
|
||||
* The currently authenticated user.
|
||||
*/
|
||||
authenticatedUser: AuthenticatedUser
|
||||
|
||||
/**
|
||||
* Called when the user is updated by an action in this list item.
|
||||
*/
|
||||
onDidUpdate?: () => void
|
||||
history: H.History
|
||||
}
|
||||
|
||||
interface UserNodeState {
|
||||
loading: boolean
|
||||
errorDescription?: string
|
||||
resetPasswordURL?: string | null
|
||||
}
|
||||
|
||||
const nukeDetails = `
|
||||
- When deleting a user normally, the user and ALL associated data is marked as deleted in the DB and never served again. You could undo this by running DB commands manually.
|
||||
- By deleting a user forever, the user and ALL associated data will be permanently removed from the DB (you CANNOT undo this). When deleting data at a user's request, "Delete forever" is used.
|
||||
|
||||
Beware this includes e.g. deleting extensions authored by the user, deleting ANY settings authored or updated by the user, etc.
|
||||
|
||||
For more information about what data is deleted, see https://github.com/sourcegraph/sourcegraph/blob/main/doc/admin/user_data_deletion.md
|
||||
|
||||
Are you ABSOLUTELY certain you wish to delete this user and all associated data?`
|
||||
|
||||
class UserNode extends React.PureComponent<UserNodeProps, UserNodeState> {
|
||||
public state: UserNodeState = {
|
||||
loading: false,
|
||||
}
|
||||
|
||||
private emailVerificationClicks = new Subject<{ email: string; verified: boolean }>()
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.subscriptions.add(
|
||||
this.emailVerificationClicks
|
||||
.pipe(
|
||||
distinctUntilChanged((a, b) => isEqual(a, b)),
|
||||
switchMap(({ email, verified }) =>
|
||||
merge(
|
||||
of({
|
||||
errorDescription: undefined,
|
||||
resetPasswordURL: undefined,
|
||||
loading: true,
|
||||
}),
|
||||
setUserEmailVerified(this.props.node.id, email, verified).pipe(
|
||||
map(() => ({ loading: false })),
|
||||
catchError(error => [{ loading: false, errorDescription: asError(error).message }])
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.subscribe(
|
||||
stateUpdate => {
|
||||
this.setState(stateUpdate)
|
||||
if (this.props.onDidUpdate) {
|
||||
this.props.onDidUpdate()
|
||||
}
|
||||
},
|
||||
error => logger.error(error)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const orgCreationLabel =
|
||||
window.context.sourcegraphDotComMode && this.props.node.tags?.includes(CREATE_ORG_TAG)
|
||||
? 'Disable'
|
||||
: 'Enable'
|
||||
|
||||
return (
|
||||
<li className="list-group-item py-2">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
{window.context.sourcegraphDotComMode ? (
|
||||
<strong>{this.props.node.username}</strong>
|
||||
) : (
|
||||
<Link to={`/users/${this.props.node.username}`}>
|
||||
<strong>{this.props.node.username}</strong>
|
||||
</Link>
|
||||
)}
|
||||
<br />
|
||||
<span className="text-muted">{this.props.node.displayName}</span>
|
||||
</div>
|
||||
<div>
|
||||
{window.context.sourcegraphDotComMode && (
|
||||
<>
|
||||
<Tooltip content={`${orgCreationLabel} user tag to allow user to create organizations`}>
|
||||
<Button
|
||||
onClick={() => this.toggleOrgCreationTag(orgCreationLabel === 'Enable')}
|
||||
disabled={this.state.loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
{orgCreationLabel} org creation
|
||||
</Button>
|
||||
</Tooltip>{' '}
|
||||
</>
|
||||
)}
|
||||
{!window.context.sourcegraphDotComMode && (
|
||||
<Button
|
||||
to={`${userURL(this.props.node.username)}/settings`}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
as={Link}
|
||||
>
|
||||
<Icon aria-hidden={true} svgPath={mdiCog} /> Settings
|
||||
</Button>
|
||||
) &&
|
||||
' '}
|
||||
{this.props.node.id !== this.props.authenticatedUser.id && (
|
||||
<Tooltip content="Force the user to re-authenticate on their next request">
|
||||
<Button
|
||||
onClick={this.invalidateSessions}
|
||||
disabled={this.state.loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Force sign-out
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}{' '}
|
||||
{window.context.resetPasswordEnabled && (
|
||||
<Button
|
||||
onClick={this.randomizePassword}
|
||||
disabled={this.state.loading || !!this.state.resetPasswordURL}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Reset password
|
||||
</Button>
|
||||
)}{' '}
|
||||
{this.props.node.id !== this.props.authenticatedUser.id &&
|
||||
(this.props.node.siteAdmin ? (
|
||||
<Button
|
||||
onClick={this.demoteFromSiteAdmin}
|
||||
disabled={this.state.loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Revoke site admin
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key="promote"
|
||||
onClick={this.promoteToSiteAdmin}
|
||||
disabled={this.state.loading}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
Promote to site admin
|
||||
</Button>
|
||||
))}{' '}
|
||||
{this.props.node.id !== this.props.authenticatedUser.id && (
|
||||
<Button onClick={this.deleteUser} disabled={this.state.loading} variant="danger" size="sm">
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
{this.props.node.id !== this.props.authenticatedUser.id && (
|
||||
<Button
|
||||
className="ml-1"
|
||||
onClick={this.nukeUser}
|
||||
disabled={this.state.loading}
|
||||
variant="danger"
|
||||
size="sm"
|
||||
>
|
||||
Delete forever
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{this.state.errorDescription && <ErrorAlert className="mt-2" error={this.state.errorDescription} />}
|
||||
{this.state.resetPasswordURL && (
|
||||
<Alert className="mt-2" variant="success">
|
||||
<Text>
|
||||
Password was reset. You must manually send <strong>{this.props.node.username}</strong> this
|
||||
reset link:
|
||||
</Text>
|
||||
<CopyableText text={this.state.resetPasswordURL} size={40} />
|
||||
</Alert>
|
||||
)}
|
||||
{this.state.resetPasswordURL === null && (
|
||||
<Alert className="mt-2" variant="success">
|
||||
Password was reset. The reset link was sent to the primary email of the user:{' '}
|
||||
<strong>{this.props.node.emails.find(item => item.isPrimary)?.email}</strong>
|
||||
</Alert>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
private promoteToSiteAdmin = (): void => this.setSiteAdmin(true)
|
||||
private demoteFromSiteAdmin = (): void => this.setSiteAdmin(false)
|
||||
|
||||
private setSiteAdmin(siteAdmin: boolean): void {
|
||||
if (
|
||||
!window.confirm(
|
||||
siteAdmin
|
||||
? `Promote user ${this.props.node.username} to site admin?`
|
||||
: `Revoke site admin status from user ${this.props.node.username}?`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errorDescription: undefined,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
setUserIsSiteAdmin(this.props.node.id, siteAdmin)
|
||||
.toPromise()
|
||||
.then(
|
||||
() => {
|
||||
this.setState({ loading: false })
|
||||
if (this.props.onDidUpdate) {
|
||||
this.props.onDidUpdate()
|
||||
}
|
||||
},
|
||||
error => this.setState({ loading: false, errorDescription: asError(error).message })
|
||||
)
|
||||
}
|
||||
|
||||
private randomizePassword = (): void => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Reset the password for ${this.props.node.username} to a random password? The user must reset their password to sign in again.`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errorDescription: undefined,
|
||||
resetPasswordURL: undefined,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
randomizeUserPassword(this.props.node.id)
|
||||
.toPromise()
|
||||
.then(
|
||||
({ resetPasswordURL }) => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
resetPasswordURL,
|
||||
})
|
||||
},
|
||||
error => this.setState({ loading: false, errorDescription: asError(error).message })
|
||||
)
|
||||
}
|
||||
|
||||
private invalidateSessions = (): void => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Revoke all active sessions for ${this.props.node.username}? The user will need to re-authenticate on their next request or visit to Sourcegraph.`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({ loading: true })
|
||||
invalidateSessionsByID(this.props.node.id)
|
||||
.toPromise()
|
||||
.then(
|
||||
() => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
})
|
||||
},
|
||||
error => this.setState({ loading: false, errorDescription: asError(error).message })
|
||||
)
|
||||
}
|
||||
|
||||
private deleteUser = (): void => this.doDeleteUser(false)
|
||||
private nukeUser = (): void => this.doDeleteUser(true)
|
||||
|
||||
private doDeleteUser = (hard: boolean): void => {
|
||||
let message = `Delete the user ${this.props.node.username}?`
|
||||
if (hard) {
|
||||
message = `Delete the user ${this.props.node.username} forever?${nukeDetails}`
|
||||
}
|
||||
if (!window.confirm(message)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
errorDescription: undefined,
|
||||
resetPasswordURL: undefined,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
deleteUser(this.props.node.id, hard)
|
||||
.toPromise()
|
||||
.then(
|
||||
() => {
|
||||
this.setState({ loading: false })
|
||||
if (this.props.onDidUpdate) {
|
||||
this.props.onDidUpdate()
|
||||
}
|
||||
},
|
||||
error => this.setState({ loading: false, errorDescription: asError(error).message })
|
||||
)
|
||||
}
|
||||
|
||||
private toggleOrgCreationTag = (newValue: boolean): void => {
|
||||
this.setState({
|
||||
errorDescription: undefined,
|
||||
resetPasswordURL: undefined,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
setUserTag(this.props.node.id, CREATE_ORG_TAG, newValue)
|
||||
.toPromise()
|
||||
.then(() => {
|
||||
this.setState({ loading: false })
|
||||
if (this.props.onDidUpdate) {
|
||||
this.props.onDidUpdate()
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({ loading: false, errorDescription: asError(error).message })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface Props extends RouteComponentProps<{}> {
|
||||
authenticatedUser: AuthenticatedUser
|
||||
history: H.History
|
||||
}
|
||||
|
||||
interface State {
|
||||
users?: UserNodeFields[]
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A page displaying the users on this site.
|
||||
*/
|
||||
export class SiteAdminAllUsersPage extends React.Component<Props, State> {
|
||||
public state: State = {}
|
||||
|
||||
private userUpdates = new Subject<void>()
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
public componentDidMount(): void {
|
||||
eventLogger.logViewEvent('SiteAdminAllUsers')
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
const nodeProps: Omit<UserNodeProps, 'node'> = {
|
||||
authenticatedUser: this.props.authenticatedUser,
|
||||
onDidUpdate: this.onDidUpdateUser,
|
||||
history: this.props.history,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="site-admin-all-users-page">
|
||||
<PageTitle title="Users - Admin" />
|
||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
||||
<H2 className="mb-0">Users</H2>
|
||||
<div>
|
||||
<Button to="/site-admin/users/new" variant="primary" as={Link}>
|
||||
<Icon aria-hidden={true} svgPath={mdiPlus} /> Create user account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FilteredConnection<UserNodeFields, Omit<UserNodeProps, 'node'>>
|
||||
className="list-group list-group-flush mt-3"
|
||||
noun="user"
|
||||
pluralNoun="users"
|
||||
queryConnection={fetchAllUsers}
|
||||
nodeComponent={UserNode}
|
||||
nodeComponentProps={nodeProps}
|
||||
updates={this.userUpdates}
|
||||
history={this.props.history}
|
||||
location={this.props.location}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onDidUpdateUser = (): void => this.userUpdates.next()
|
||||
}
|
||||
@ -1,8 +1,6 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
import classNames from 'classnames'
|
||||
import { isEqual } from 'lodash'
|
||||
import ChartLineVariantIcon from 'mdi-react/ChartLineVariantIcon'
|
||||
import MapSearchIcon from 'mdi-react/MapSearchIcon'
|
||||
import { Route, RouteComponentProps, Switch } from 'react-router'
|
||||
|
||||
@ -10,7 +8,6 @@ import { SiteSettingFields } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { PlatformContextProps } from '@sourcegraph/shared/src/platform/context'
|
||||
import { SettingsCascadeProps } from '@sourcegraph/shared/src/settings/settings'
|
||||
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
|
||||
import { lazyComponent } from '@sourcegraph/shared/src/util/lazyComponent'
|
||||
import { PageHeader, LoadingSpinner } from '@sourcegraph/wildcard'
|
||||
|
||||
import { AuthenticatedUser } from '../auth'
|
||||
@ -19,11 +16,9 @@ import { BatchChangesProps } from '../batches'
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary'
|
||||
import { HeroPage } from '../components/HeroPage'
|
||||
import { Page } from '../components/Page'
|
||||
import { useFeatureFlag } from '../featureFlags/useFeatureFlag'
|
||||
import { RouteDescriptor } from '../util/contributions'
|
||||
|
||||
import { overviewGroup } from './sidebaritems'
|
||||
import { SiteAdminSidebar, SiteAdminSideBarGroup, SiteAdminSideBarGroups } from './SiteAdminSidebar'
|
||||
import { SiteAdminSidebar, SiteAdminSideBarGroups } from './SiteAdminSidebar'
|
||||
|
||||
import styles from './SiteAdminArea.module.scss'
|
||||
|
||||
@ -69,112 +64,9 @@ interface SiteAdminAreaProps
|
||||
isSourcegraphDotCom: boolean
|
||||
}
|
||||
|
||||
export const analyticsGroup: SiteAdminSideBarGroup = {
|
||||
header: {
|
||||
label: 'Analytics',
|
||||
icon: ChartLineVariantIcon,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: 'Overview',
|
||||
to: '/site-admin/',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
to: '/site-admin/analytics/search',
|
||||
},
|
||||
{
|
||||
label: 'Code navigation',
|
||||
to: '/site-admin/analytics/code-intel',
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
to: '/site-admin/analytics/users',
|
||||
},
|
||||
{
|
||||
label: 'Insights',
|
||||
to: '/site-admin/analytics/code-insights',
|
||||
},
|
||||
{
|
||||
label: 'Batch changes',
|
||||
to: '/site-admin/analytics/batch-changes',
|
||||
},
|
||||
{
|
||||
label: 'Notebooks',
|
||||
to: '/site-admin/analytics/notebooks',
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
to: '/site-admin/analytics/extensions',
|
||||
},
|
||||
{
|
||||
label: 'Feedback survey',
|
||||
to: '/site-admin/surveys',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const analyticsRoutes: readonly SiteAdminAreaRoute[] = [
|
||||
{
|
||||
path: '/analytics/search',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsSearchPage'), 'AnalyticsSearchPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/code-intel',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsCodeIntelPage'), 'AnalyticsCodeIntelPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/extensions',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsExtensionsPage'), 'AnalyticsExtensionsPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/users',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsUsersPage'), 'AnalyticsUsersPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/code-insights',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsCodeInsightsPage'), 'AnalyticsCodeInsightsPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/batch-changes',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsBatchChangesPage'), 'AnalyticsBatchChangesPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/notebooks',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsNotebooksPage'), 'AnalyticsNotebooksPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsOverviewPage'), 'AnalyticsOverviewPage'),
|
||||
exact: true,
|
||||
},
|
||||
]
|
||||
|
||||
const AuthenticatedSiteAdminArea: React.FunctionComponent<React.PropsWithChildren<SiteAdminAreaProps>> = props => {
|
||||
const reference = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isAdminAnalyticsDisabled] = useFeatureFlag('admin-analytics-disabled', false)
|
||||
|
||||
const adminSideBarGroups = useMemo(() => {
|
||||
if (isAdminAnalyticsDisabled) {
|
||||
return props.sideBarGroups
|
||||
}
|
||||
return [analyticsGroup, ...props.sideBarGroups.filter(group => !isEqual(group, overviewGroup))]
|
||||
}, [isAdminAnalyticsDisabled, props.sideBarGroups])
|
||||
|
||||
const routes = useMemo(
|
||||
() => (!isAdminAnalyticsDisabled ? [...analyticsRoutes, ...props.routes] : props.routes),
|
||||
[isAdminAnalyticsDisabled, props.routes]
|
||||
)
|
||||
|
||||
// If not site admin, redirect to sign in.
|
||||
if (!props.authenticatedUser.siteAdmin) {
|
||||
return <NotSiteAdminPage />
|
||||
@ -204,7 +96,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent<React.PropsWithChildre
|
||||
<div className="d-flex my-3 flex-column flex-sm-row" ref={reference}>
|
||||
<SiteAdminSidebar
|
||||
className={classNames('flex-0 mr-3 mb-4', styles.sidebar)}
|
||||
groups={adminSideBarGroups}
|
||||
groups={props.sideBarGroups}
|
||||
isSourcegraphDotCom={props.isSourcegraphDotCom}
|
||||
batchChangesEnabled={props.batchChangesEnabled}
|
||||
batchChangesExecutionEnabled={props.batchChangesExecutionEnabled}
|
||||
@ -214,7 +106,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent<React.PropsWithChildre
|
||||
<ErrorBoundary location={props.location}>
|
||||
<React.Suspense fallback={<LoadingSpinner className="m-2" />}>
|
||||
<Switch>
|
||||
{routes.map(
|
||||
{props.routes.map(
|
||||
({ render, path, exact, condition = () => true }) =>
|
||||
condition(context) && (
|
||||
<Route
|
||||
|
||||
@ -4,6 +4,7 @@ import classNames from 'classnames'
|
||||
import { RouteComponentProps } from 'react-router'
|
||||
import { Subscription } from 'rxjs'
|
||||
|
||||
import { UserActivePeriod } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import {
|
||||
Badge,
|
||||
BADGE_VARIANTS,
|
||||
@ -21,7 +22,7 @@ import {
|
||||
Card,
|
||||
} from '@sourcegraph/wildcard'
|
||||
|
||||
import { FilteredConnection } from '../components/FilteredConnection'
|
||||
import { FilteredConnection, FilteredConnectionFilter } from '../components/FilteredConnection'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { Timestamp } from '../components/time/Timestamp'
|
||||
import {
|
||||
@ -39,10 +40,43 @@ import { eventLogger } from '../tracking/eventLogger'
|
||||
import { userURL } from '../user'
|
||||
|
||||
import { ValueLegendItem } from './analytics/components/ValueLegendList'
|
||||
import { USER_ACTIVITY_FILTERS } from './SiteAdminUsageStatisticsPage'
|
||||
|
||||
import styles from './SiteAdminSurveyResponsesPage.module.scss'
|
||||
|
||||
const USER_ACTIVITY_FILTERS: FilteredConnectionFilter[] = [
|
||||
{
|
||||
label: '',
|
||||
type: 'radio',
|
||||
id: 'user-activity-filters',
|
||||
values: [
|
||||
{
|
||||
label: 'All users',
|
||||
value: 'all',
|
||||
tooltip: 'Show all users',
|
||||
args: { activePeriod: UserActivePeriod.ALL_TIME },
|
||||
},
|
||||
{
|
||||
label: 'Active today',
|
||||
value: 'today',
|
||||
tooltip: 'Show users active since this morning at 00:00 UTC',
|
||||
args: { activePeriod: UserActivePeriod.TODAY },
|
||||
},
|
||||
{
|
||||
label: 'Active this week',
|
||||
value: 'week',
|
||||
tooltip: 'Show users active since Monday at 00:00 UTC',
|
||||
args: { activePeriod: UserActivePeriod.THIS_WEEK },
|
||||
},
|
||||
{
|
||||
label: 'Active this month',
|
||||
value: 'month',
|
||||
tooltip: 'Show users active since the first day of the month at 00:00 UTC',
|
||||
args: { activePeriod: UserActivePeriod.THIS_MONTH },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface SurveyResponseNodeProps {
|
||||
/**
|
||||
* The survey response to display in this list item.
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
.date-column {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.tz-note {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
@ -1,315 +0,0 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { mdiFileDownload } from '@mdi/js'
|
||||
import format from 'date-fns/format'
|
||||
import { RouteComponentProps } from 'react-router'
|
||||
import { Subscription } from 'rxjs'
|
||||
|
||||
import { UserActivePeriod } from '@sourcegraph/shared/src/graphql-operations'
|
||||
import { Icon, H2, H3, Tooltip, Button, AnchorLink, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
|
||||
import { BarChart } from '../components/d3/BarChart'
|
||||
import { FilteredConnection, FilteredConnectionFilter } from '../components/FilteredConnection'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { RadioButtons } from '../components/RadioButtons'
|
||||
import { Timestamp } from '../components/time/Timestamp'
|
||||
import { CustomersResult, SiteUsageStatisticsResult, UserUsageStatisticsResult } from '../graphql-operations'
|
||||
import { eventLogger } from '../tracking/eventLogger'
|
||||
|
||||
import { fetchSiteUsageStatistics, fetchUserUsageStatistics } from './backend'
|
||||
|
||||
import styles from './SiteAdminUsageStatisticsPage.module.scss'
|
||||
|
||||
interface ChartData {
|
||||
label: string
|
||||
dateFormat: string
|
||||
}
|
||||
|
||||
type ChartOptions = Record<'daus' | 'waus' | 'maus', ChartData>
|
||||
|
||||
const chartGeneratorOptions: ChartOptions = {
|
||||
daus: { label: 'Daily unique users', dateFormat: 'E, MMM d' },
|
||||
waus: { label: 'Weekly unique users', dateFormat: 'E, MMM d' },
|
||||
maus: { label: 'Monthly unique users', dateFormat: 'MMMM yyyy' },
|
||||
}
|
||||
|
||||
const CHART_ID_KEY = 'latest-usage-statistics-chart-id'
|
||||
|
||||
interface UsageChartPageProps {
|
||||
isLightTheme: boolean
|
||||
stats: SiteUsageStatisticsResult['site']['usageStatistics']
|
||||
chartID: keyof ChartOptions
|
||||
header?: JSX.Element
|
||||
showLegend?: boolean
|
||||
}
|
||||
|
||||
export const UsageChart: React.FunctionComponent<UsageChartPageProps> = (props: UsageChartPageProps) => (
|
||||
<div>
|
||||
{props.header ? props.header : <H3>{chartGeneratorOptions[props.chartID].label}</H3>}
|
||||
<BarChart
|
||||
showLabels={true}
|
||||
showLegend={props.showLegend === undefined ? true : props.showLegend}
|
||||
width={500}
|
||||
height={200}
|
||||
isLightTheme={props.isLightTheme}
|
||||
data={props.stats[props.chartID].map(usagePeriod => ({
|
||||
xLabel: format(
|
||||
Date.parse(usagePeriod.startTime) + 1000 * 60 * 60 * 24,
|
||||
chartGeneratorOptions[props.chartID].dateFormat
|
||||
),
|
||||
yValues: {
|
||||
Registered: usagePeriod.registeredUserCount,
|
||||
'Deleted or anonymous': usagePeriod.anonymousUserCount,
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
<small className={styles.tzNote}>
|
||||
<i>GMT/UTC time</i>
|
||||
</small>
|
||||
</div>
|
||||
)
|
||||
|
||||
type UserUsageStatisticsResultUser = UserUsageStatisticsResult['users']['nodes']
|
||||
|
||||
interface UserUsageStatisticsHeaderFooterProps {
|
||||
nodes: UserUsageStatisticsResultUser
|
||||
}
|
||||
|
||||
class UserUsageStatisticsHeader extends React.PureComponent<UserUsageStatisticsHeaderFooterProps> {
|
||||
public render(): JSX.Element | null {
|
||||
return (
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Page views</th>
|
||||
<th>Search queries</th>
|
||||
<th>Code navigation actions</th>
|
||||
<th className={styles.dateColumn}>Last active</th>
|
||||
<th className={styles.dateColumn}>Last active in code host or code review</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class UserUsageStatisticsFooter extends React.PureComponent<UserUsageStatisticsHeaderFooterProps> {
|
||||
public render(): JSX.Element | null {
|
||||
return (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
<td>
|
||||
{this.props.nodes.reduce(
|
||||
(count, node) => count + (node.usageStatistics ? node.usageStatistics.pageViews : 0),
|
||||
0
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{this.props.nodes.reduce(
|
||||
(count, node) => count + (node.usageStatistics ? node.usageStatistics.searchQueries : 0),
|
||||
0
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{this.props.nodes.reduce(
|
||||
(count, node) =>
|
||||
count + (node.usageStatistics ? node.usageStatistics.codeIntelligenceActions : 0),
|
||||
0
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.dateColumn} />
|
||||
<td className={styles.dateColumn} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface UserUsageStatisticsNodeProps {
|
||||
/**
|
||||
* The user to display in this list item.
|
||||
*/
|
||||
node: UserUsageStatisticsResultUser[number]
|
||||
}
|
||||
|
||||
class UserUsageStatisticsNode extends React.PureComponent<UserUsageStatisticsNodeProps> {
|
||||
public render(): JSX.Element | null {
|
||||
return (
|
||||
<tr>
|
||||
<td>{this.props.node.username}</td>
|
||||
<td>{this.props.node.usageStatistics ? this.props.node.usageStatistics.pageViews : 'n/a'}</td>
|
||||
<td>{this.props.node.usageStatistics ? this.props.node.usageStatistics.searchQueries : 'n/a'}</td>
|
||||
<td>
|
||||
{this.props.node.usageStatistics ? this.props.node.usageStatistics.codeIntelligenceActions : 'n/a'}
|
||||
</td>
|
||||
<td className={styles.dateColumn}>
|
||||
{this.props.node.usageStatistics?.lastActiveTime ? (
|
||||
<Timestamp date={this.props.node.usageStatistics.lastActiveTime} />
|
||||
) : (
|
||||
'not available'
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.dateColumn}>
|
||||
{this.props.node.usageStatistics?.lastActiveCodeHostIntegrationTime ? (
|
||||
<Timestamp date={this.props.node.usageStatistics.lastActiveCodeHostIntegrationTime} />
|
||||
) : (
|
||||
'not available'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class FilteredUserConnection extends FilteredConnection<UserUsageStatisticsResultUser[number], {}> {}
|
||||
export const USER_ACTIVITY_FILTERS: FilteredConnectionFilter[] = [
|
||||
{
|
||||
label: '',
|
||||
type: 'radio',
|
||||
id: 'user-activity-filters',
|
||||
values: [
|
||||
{
|
||||
label: 'All users',
|
||||
value: 'all',
|
||||
tooltip: 'Show all users',
|
||||
args: { activePeriod: UserActivePeriod.ALL_TIME },
|
||||
},
|
||||
{
|
||||
label: 'Active today',
|
||||
value: 'today',
|
||||
tooltip: 'Show users active since this morning at 00:00 UTC',
|
||||
args: { activePeriod: UserActivePeriod.TODAY },
|
||||
},
|
||||
{
|
||||
label: 'Active this week',
|
||||
value: 'week',
|
||||
tooltip: 'Show users active since Monday at 00:00 UTC',
|
||||
args: { activePeriod: UserActivePeriod.THIS_WEEK },
|
||||
},
|
||||
{
|
||||
label: 'Active this month',
|
||||
value: 'month',
|
||||
tooltip: 'Show users active since the first day of the month at 00:00 UTC',
|
||||
args: { activePeriod: UserActivePeriod.THIS_MONTH },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface SiteAdminUsageStatisticsPageProps extends RouteComponentProps<{}> {
|
||||
isLightTheme: boolean
|
||||
}
|
||||
|
||||
interface SiteAdminUsageStatisticsPageState {
|
||||
users?: CustomersResult['users']
|
||||
stats?: SiteUsageStatisticsResult['site']['usageStatistics']
|
||||
error?: Error
|
||||
chartID: keyof ChartOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* A page displaying usage statistics for the site.
|
||||
*/
|
||||
export class SiteAdminUsageStatisticsPage extends React.Component<
|
||||
SiteAdminUsageStatisticsPageProps,
|
||||
SiteAdminUsageStatisticsPageState
|
||||
> {
|
||||
public state: SiteAdminUsageStatisticsPageState = {
|
||||
chartID: this.loadLatestChartFromStorage(),
|
||||
}
|
||||
|
||||
private subscriptions = new Subscription()
|
||||
|
||||
private loadLatestChartFromStorage(): keyof ChartOptions {
|
||||
const latest = localStorage.getItem(CHART_ID_KEY)
|
||||
return latest && latest in chartGeneratorOptions ? (latest as keyof ChartOptions) : 'daus'
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
eventLogger.logViewEvent('SiteAdminUsageStatistics')
|
||||
|
||||
this.subscriptions.add(
|
||||
fetchSiteUsageStatistics().subscribe(
|
||||
stats => this.setState({ stats }),
|
||||
error => this.setState({ error })
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public componentDidUpdate(): void {
|
||||
localStorage.setItem(CHART_ID_KEY, this.state.chartID)
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.subscriptions.unsubscribe()
|
||||
}
|
||||
|
||||
public render(): JSX.Element | null {
|
||||
return (
|
||||
<div>
|
||||
<PageTitle title="Usage statistics - Admin" />
|
||||
<H2>Usage statistics</H2>
|
||||
{this.state.error && <ErrorAlert className="mb-3" error={this.state.error} />}
|
||||
|
||||
<Tooltip content="Download usage stats archive">
|
||||
<Button
|
||||
to="/site-admin/usage-statistics/archive"
|
||||
download="true"
|
||||
variant="secondary"
|
||||
as={AnchorLink}
|
||||
>
|
||||
<Icon aria-hidden={true} svgPath={mdiFileDownload} /> Download usage stats archive
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{this.state.stats && (
|
||||
<>
|
||||
<RadioButtons
|
||||
nodes={Object.entries(chartGeneratorOptions).map(([key, { label }]) => ({
|
||||
label,
|
||||
id: key,
|
||||
}))}
|
||||
name="chart-options"
|
||||
onChange={this.onChartIndexChange}
|
||||
selected={this.state.chartID}
|
||||
/>
|
||||
<UsageChart {...this.props} chartID={this.state.chartID} stats={this.state.stats} />
|
||||
</>
|
||||
)}
|
||||
<H3 className="mt-4">All registered users</H3>
|
||||
{!this.state.error && (
|
||||
<FilteredUserConnection
|
||||
listComponent="table"
|
||||
className="table"
|
||||
hideSearch={false}
|
||||
filters={USER_ACTIVITY_FILTERS}
|
||||
noShowMore={false}
|
||||
noun="user"
|
||||
pluralNoun="users"
|
||||
queryConnection={fetchUserUsageStatistics}
|
||||
nodeComponent={UserUsageStatisticsNode}
|
||||
headComponent={UserUsageStatisticsHeader}
|
||||
footComponent={UserUsageStatisticsFooter}
|
||||
history={this.props.history}
|
||||
location={this.props.location}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
private onChartIndexChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
switch (event.target.value as keyof ChartOptions) {
|
||||
case 'daus':
|
||||
eventLogger.log('DAUsChartSelected')
|
||||
break
|
||||
case 'waus':
|
||||
eventLogger.log('WAUsChartSelected')
|
||||
break
|
||||
case 'maus':
|
||||
eventLogger.log('MAUsChartSelected')
|
||||
break
|
||||
}
|
||||
this.setState({ chartID: event.target.value as keyof ChartOptions })
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { BrandedStory } from '@sourcegraph/branded/src/components/BrandedStory'
|
||||
|
||||
import { DateRangeSelect } from './DateRangeSelect'
|
||||
|
||||
import webStyles from '../../../../SourcegraphWebApp.scss'
|
||||
import webStyles from '../../../SourcegraphWebApp.scss'
|
||||
|
||||
const decorator: DecoratorFn = story => (
|
||||
<BrandedStory styles={webStyles}>{() => <div className="container mt-3">{story()}</div>}</BrandedStory>
|
||||
@ -39,8 +39,8 @@ import {
|
||||
SiteUserOrderBy,
|
||||
UsersManagementUsersListResult,
|
||||
UsersManagementUsersListVariables,
|
||||
} from '../../../../graphql-operations'
|
||||
import { useURLSyncedState } from '../../../../hooks'
|
||||
} from '../../../graphql-operations'
|
||||
import { useURLSyncedState } from '../../../hooks'
|
||||
import { USERS_MANAGEMENT_USERS_LIST } from '../queries'
|
||||
|
||||
import { Table } from './Table'
|
||||
@ -4,8 +4,8 @@ import { logger } from '@sourcegraph/common'
|
||||
import { useMutation } from '@sourcegraph/http-client'
|
||||
import { Text } from '@sourcegraph/wildcard'
|
||||
|
||||
import { CopyableText } from '../../../../components/CopyableText'
|
||||
import { randomizeUserPassword, setUserIsSiteAdmin } from '../../../backend'
|
||||
import { CopyableText } from '../../../components/CopyableText'
|
||||
import { randomizeUserPassword, setUserIsSiteAdmin } from '../../backend'
|
||||
import { DELETE_USERS, DELETE_USERS_FOREVER, FORCE_SIGN_OUT_USERS } from '../queries'
|
||||
|
||||
import { UseUserListActionReturnType, SiteUser, getUsernames } from './UsersList'
|
||||
@ -6,9 +6,9 @@ import { RouteComponentProps } from 'react-router'
|
||||
import { useQuery } from '@sourcegraph/http-client'
|
||||
import { H1, Card, Text, Icon, Button, Link, Alert, LoadingSpinner, AnchorLink } from '@sourcegraph/wildcard'
|
||||
|
||||
import { UsersManagementSummaryResult, UsersManagementSummaryVariables } from '../../../graphql-operations'
|
||||
import { eventLogger } from '../../../tracking/eventLogger'
|
||||
import { ValueLegendList, ValueLegendListProps } from '../../analytics/components/ValueLegendList'
|
||||
import { UsersManagementSummaryResult, UsersManagementSummaryVariables } from '../../graphql-operations'
|
||||
import { eventLogger } from '../../tracking/eventLogger'
|
||||
import { ValueLegendList, ValueLegendListProps } from '../analytics/components/ValueLegendList'
|
||||
|
||||
import { UsersList } from './components/UsersList'
|
||||
import { USERS_MANAGEMENT_SUMMARY } from './queries'
|
||||
@ -24,14 +24,10 @@ import {
|
||||
CreateUserResult,
|
||||
DeleteOrganizationResult,
|
||||
DeleteOrganizationVariables,
|
||||
DeleteUserResult,
|
||||
DeleteUserVariables,
|
||||
ExternalServiceKind,
|
||||
FeatureFlagFields,
|
||||
FeatureFlagsResult,
|
||||
FeatureFlagsVariables,
|
||||
InvalidateSessionsByIDResult,
|
||||
InvalidateSessionsByIDVariables,
|
||||
OrganizationsConnectionFields,
|
||||
OrganizationsResult,
|
||||
OrganizationsVariables,
|
||||
@ -49,8 +45,6 @@ import {
|
||||
ScheduleRepositoryPermissionsSyncVariables,
|
||||
SetUserIsSiteAdminResult,
|
||||
SetUserIsSiteAdminVariables,
|
||||
SetUserTagResult,
|
||||
SetUserTagVariables,
|
||||
SiteAdminAccessTokenConnectionFields,
|
||||
SiteAdminAccessTokensResult,
|
||||
SiteAdminAccessTokensVariables,
|
||||
@ -59,12 +53,8 @@ import {
|
||||
SiteResult,
|
||||
SiteUpdateCheckResult,
|
||||
SiteUpdateCheckVariables,
|
||||
SiteUsageStatisticsResult,
|
||||
UpdateSiteConfigurationResult,
|
||||
UpdateSiteConfigurationVariables,
|
||||
UserActivePeriod,
|
||||
UsersResult,
|
||||
UserUsageStatisticsResult,
|
||||
WebhookByIdResult,
|
||||
WebhookByIdVariables,
|
||||
WebhookFields,
|
||||
@ -80,49 +70,6 @@ import { accessTokenFragment } from '../settings/tokens/AccessTokenNode'
|
||||
|
||||
import { WEBHOOK_LOGS_BY_ID } from './webhooks/backend'
|
||||
|
||||
/**
|
||||
* Fetches all users.
|
||||
*/
|
||||
export function fetchAllUsers(args: { first?: number; query?: string }): Observable<UsersResult['users']> {
|
||||
return queryGraphQL<UsersResult>(
|
||||
gql`
|
||||
query Users($first: Int, $query: String) {
|
||||
users(first: $first, query: $query) {
|
||||
nodes {
|
||||
...UserNodeFields
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
fragment UserNodeFields on User {
|
||||
id
|
||||
username
|
||||
displayName
|
||||
emails {
|
||||
email
|
||||
verified
|
||||
verificationPending
|
||||
viewerCanManuallyVerify
|
||||
isPrimary
|
||||
}
|
||||
createdAt
|
||||
siteAdmin
|
||||
organizations {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
tags
|
||||
}
|
||||
`,
|
||||
args
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.users)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all organizations.
|
||||
*/
|
||||
@ -207,15 +154,6 @@ const siteAdminRepositoryFieldsFragment = gql`
|
||||
}
|
||||
`
|
||||
|
||||
export const SiteUsagePeriodFragment = gql`
|
||||
fragment SiteUsagePeriodFields on SiteUsagePeriod {
|
||||
userCount
|
||||
registeredUserCount
|
||||
anonymousUserCount
|
||||
startTime
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Fetches all repositories.
|
||||
*
|
||||
@ -407,75 +345,6 @@ export const RECLONE_REPOSITORY_MUTATION = gql`
|
||||
}
|
||||
`
|
||||
|
||||
/**
|
||||
* Fetches usage statistics for all users.
|
||||
*
|
||||
* @returns Observable that emits the list of users and their usage data
|
||||
*/
|
||||
export function fetchUserUsageStatistics(args: {
|
||||
activePeriod?: UserActivePeriod
|
||||
query?: string
|
||||
first?: number
|
||||
}): Observable<UserUsageStatisticsResult['users']> {
|
||||
return queryGraphQL<UserUsageStatisticsResult>(
|
||||
gql`
|
||||
query UserUsageStatistics($activePeriod: UserActivePeriod, $query: String, $first: Int) {
|
||||
users(activePeriod: $activePeriod, query: $query, first: $first) {
|
||||
nodes {
|
||||
id
|
||||
username
|
||||
usageStatistics {
|
||||
...UserUsageStatisticsFields
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
|
||||
fragment UserUsageStatisticsFields on UserUsageStatistics {
|
||||
searchQueries
|
||||
pageViews
|
||||
codeIntelligenceActions
|
||||
lastActiveTime
|
||||
lastActiveCodeHostIntegrationTime
|
||||
}
|
||||
`,
|
||||
args
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.users)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches site-wide usage statitics.
|
||||
*
|
||||
* @returns Observable that emits the list of users and their usage data
|
||||
*/
|
||||
export function fetchSiteUsageStatistics(): Observable<SiteUsageStatisticsResult['site']['usageStatistics']> {
|
||||
return queryGraphQL<SiteUsageStatisticsResult>(gql`
|
||||
query SiteUsageStatistics {
|
||||
site {
|
||||
usageStatistics {
|
||||
daus {
|
||||
...SiteUsagePeriodFields
|
||||
}
|
||||
waus {
|
||||
...SiteUsagePeriodFields
|
||||
}
|
||||
maus {
|
||||
...SiteUsagePeriodFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${SiteUsagePeriodFragment}
|
||||
`).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.site.usageStatistics)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the site and its configuration.
|
||||
*
|
||||
@ -670,22 +539,6 @@ export function setUserIsSiteAdmin(userID: Scalars['ID'], siteAdmin: boolean): O
|
||||
)
|
||||
}
|
||||
|
||||
export function invalidateSessionsByID(userID: Scalars['ID']): Observable<void> {
|
||||
return requestGraphQL<InvalidateSessionsByIDResult, InvalidateSessionsByIDVariables>(
|
||||
gql`
|
||||
mutation InvalidateSessionsByID($userID: ID!) {
|
||||
invalidateSessionsByID(userID: $userID) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ userID }
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(() => undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export function randomizeUserPassword(
|
||||
user: Scalars['ID']
|
||||
): Observable<RandomizeUserPasswordResult['randomizeUserPassword']> {
|
||||
@ -704,26 +557,6 @@ export function randomizeUserPassword(
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteUser(user: Scalars['ID'], hard?: boolean): Observable<void> {
|
||||
return requestGraphQL<DeleteUserResult, DeleteUserVariables>(
|
||||
gql`
|
||||
mutation DeleteUser($user: ID!, $hard: Boolean) {
|
||||
deleteUser(user: $user, hard: $hard) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ user, hard: hard ?? null }
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => {
|
||||
if (!data.deleteUser) {
|
||||
throw createInvalidGraphQLMutationResponseError('DeleteUser')
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function createUser(username: string, email: string | undefined): Observable<CreateUserResult['createUser']> {
|
||||
return mutateGraphQL<CreateUserResult>(
|
||||
gql`
|
||||
@ -740,26 +573,6 @@ export function createUser(username: string, email: string | undefined): Observa
|
||||
)
|
||||
}
|
||||
|
||||
export function setUserTag(node: string, tag: string, present: boolean = true): Observable<void> {
|
||||
return requestGraphQL<SetUserTagResult, SetUserTagVariables>(
|
||||
gql`
|
||||
mutation SetUserTag($node: ID!, $tag: String!, $present: Boolean!) {
|
||||
setTag(node: $node, tag: $tag, present: $present) {
|
||||
alwaysNil
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ node, tag, present }
|
||||
).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => {
|
||||
if (!data.setTag) {
|
||||
throw createInvalidGraphQLMutationResponseError('SetUserTag')
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteOrganization(organization: Scalars['ID'], hard?: boolean): Promise<void> {
|
||||
return requestGraphQL<DeleteOrganizationResult, DeleteOrganizationVariables>(
|
||||
gql`
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
.admin-overview-menu-text {
|
||||
line-height: var(--line-height-typography);
|
||||
font-size: calc(var(--font-size-base) * 1.25);
|
||||
}
|
||||
@ -1,95 +0,0 @@
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import * as H from 'history'
|
||||
import { of } from 'rxjs'
|
||||
|
||||
import { renderWithBrandedContext } from '@sourcegraph/wildcard/src/testing'
|
||||
|
||||
import { SiteUsagePeriodFields } from '../../graphql-operations'
|
||||
|
||||
import { SiteAdminOverviewPage } from './SiteAdminOverviewPage'
|
||||
|
||||
describe('SiteAdminOverviewPage', () => {
|
||||
const baseProps = {
|
||||
history: H.createMemoryHistory(),
|
||||
isLightTheme: true,
|
||||
overviewComponents: [],
|
||||
}
|
||||
|
||||
test('< 2 users', async () => {
|
||||
const component = renderWithBrandedContext(
|
||||
<SiteAdminOverviewPage
|
||||
{...baseProps}
|
||||
_fetchOverview={() =>
|
||||
of({
|
||||
repositories: 100,
|
||||
repositoryStats: {
|
||||
gitDirBytes: '1825299556',
|
||||
indexedLinesCount: '2616264',
|
||||
},
|
||||
users: 1,
|
||||
orgs: 1,
|
||||
surveyResponses: {
|
||||
totalCount: 1,
|
||||
averageScore: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
_fetchWeeklyActiveUsers={() =>
|
||||
of({
|
||||
__typename: 'SiteUsageStatistics',
|
||||
daus: [],
|
||||
waus: [],
|
||||
maus: [],
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
// ensure the hooks ran and the "API response" has been received
|
||||
await waitFor(() => expect(component.asFragment()).toMatchSnapshot())
|
||||
})
|
||||
|
||||
test('>= 2 users', async () => {
|
||||
const usageStat: SiteUsagePeriodFields = {
|
||||
__typename: 'SiteUsagePeriod',
|
||||
userCount: 10,
|
||||
registeredUserCount: 8,
|
||||
anonymousUserCount: 2,
|
||||
startTime: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Do this mock for resolving issue
|
||||
// `Error: Uncaught [TypeError: tspan.node(...).getComputedTextLength is not a function`
|
||||
// from `client/web/src/components/d3/BarChart.tsx`
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||
;(window.SVGElement as any).prototype.getComputedTextLength = () => 500
|
||||
|
||||
const component = renderWithBrandedContext(
|
||||
<SiteAdminOverviewPage
|
||||
{...baseProps}
|
||||
_fetchOverview={() =>
|
||||
of({
|
||||
repositories: 100,
|
||||
repositoryStats: {
|
||||
gitDirBytes: '1825299556',
|
||||
indexedLinesCount: '2616264',
|
||||
},
|
||||
users: 10,
|
||||
orgs: 5,
|
||||
surveyResponses: {
|
||||
totalCount: 100,
|
||||
averageScore: 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
_fetchWeeklyActiveUsers={() =>
|
||||
of({
|
||||
__typename: 'SiteUsageStatistics',
|
||||
waus: [usageStat, usageStat],
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
// ensure the hooks ran and the "API response" has been received
|
||||
await waitFor(() => expect(component.asFragment()).toMatchSnapshot())
|
||||
})
|
||||
})
|
||||
@ -1,274 +0,0 @@
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
|
||||
import { mdiOpenInNew } from '@mdi/js'
|
||||
import classNames from 'classnames'
|
||||
import { Observable, of } from 'rxjs'
|
||||
import { map, catchError } from 'rxjs/operators'
|
||||
|
||||
import { ErrorLike, asError, isErrorLike, numberWithCommas, pluralize } from '@sourcegraph/common'
|
||||
import { dataOrThrowErrors, gql } from '@sourcegraph/http-client'
|
||||
import { ThemeProps } from '@sourcegraph/shared/src/theme'
|
||||
import { LoadingSpinner, useObservable, Button, Link, Icon, H3, Heading, ErrorAlert } from '@sourcegraph/wildcard'
|
||||
|
||||
import { queryGraphQL } from '../../backend/graphql'
|
||||
import { Collapsible } from '../../components/Collapsible'
|
||||
import { PageTitle } from '../../components/PageTitle'
|
||||
import { OverviewResult, Scalars, WAUsResult } from '../../graphql-operations'
|
||||
import { eventLogger } from '../../tracking/eventLogger'
|
||||
import { SiteUsagePeriodFragment } from '../backend'
|
||||
import { UsageChart } from '../SiteAdminUsageStatisticsPage'
|
||||
|
||||
import styles from './SiteAdminOverviewPage.module.scss'
|
||||
|
||||
interface Props extends ThemeProps {
|
||||
overviewComponents: readonly React.ComponentType<React.PropsWithChildren<unknown>>[]
|
||||
|
||||
/** For testing only */
|
||||
_fetchOverview?: () => Observable<{
|
||||
repositories: number | null
|
||||
repositoryStats: {
|
||||
gitDirBytes: Scalars['BigInt']
|
||||
indexedLinesCount: Scalars['BigInt']
|
||||
}
|
||||
users: number
|
||||
orgs: number
|
||||
surveyResponses: {
|
||||
totalCount: number
|
||||
averageScore: number
|
||||
}
|
||||
}>
|
||||
/** For testing only */
|
||||
_fetchWeeklyActiveUsers?: () => Observable<WAUsResult['site']['usageStatistics']>
|
||||
}
|
||||
|
||||
const fetchOverview = (): Observable<{
|
||||
repositories: number | null
|
||||
repositoryStats: {
|
||||
gitDirBytes: Scalars['BigInt']
|
||||
indexedLinesCount: Scalars['BigInt']
|
||||
}
|
||||
users: number
|
||||
orgs: number
|
||||
surveyResponses: {
|
||||
totalCount: number
|
||||
averageScore: number
|
||||
}
|
||||
}> =>
|
||||
queryGraphQL<OverviewResult>(gql`
|
||||
query Overview {
|
||||
repositories {
|
||||
totalCount(precise: true)
|
||||
}
|
||||
repositoryStats {
|
||||
gitDirBytes
|
||||
indexedLinesCount
|
||||
}
|
||||
users {
|
||||
totalCount
|
||||
}
|
||||
organizations {
|
||||
totalCount
|
||||
}
|
||||
surveyResponses {
|
||||
totalCount
|
||||
averageScore
|
||||
}
|
||||
}
|
||||
`).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => ({
|
||||
repositories: data.repositories.totalCount,
|
||||
repositoryStats: data.repositoryStats,
|
||||
users: data.users.totalCount,
|
||||
orgs: data.organizations.totalCount,
|
||||
surveyResponses: data.surveyResponses,
|
||||
}))
|
||||
)
|
||||
|
||||
const fetchWeeklyActiveUsers = (): Observable<WAUsResult['site']['usageStatistics']> =>
|
||||
queryGraphQL<WAUsResult>(gql`
|
||||
query WAUs {
|
||||
site {
|
||||
usageStatistics {
|
||||
waus {
|
||||
...SiteUsagePeriodFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${SiteUsagePeriodFragment}
|
||||
`).pipe(
|
||||
map(dataOrThrowErrors),
|
||||
map(data => data.site.usageStatistics)
|
||||
)
|
||||
|
||||
/**
|
||||
* A page displaying an overview of site admin information.
|
||||
*/
|
||||
export const SiteAdminOverviewPage: React.FunctionComponent<React.PropsWithChildren<Props>> = ({
|
||||
isLightTheme,
|
||||
overviewComponents,
|
||||
_fetchOverview = fetchOverview,
|
||||
_fetchWeeklyActiveUsers = fetchWeeklyActiveUsers,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
eventLogger.logViewEvent('SiteAdminOverview')
|
||||
}, [])
|
||||
|
||||
const info = useObservable(
|
||||
useMemo(() => _fetchOverview().pipe(catchError(error => of<ErrorLike>(asError(error)))), [_fetchOverview])
|
||||
)
|
||||
|
||||
const stats = useObservable(
|
||||
useMemo(
|
||||
() =>
|
||||
_fetchWeeklyActiveUsers().pipe(
|
||||
map(({ waus }) => ({ waus, daus: [], maus: [] })),
|
||||
catchError(error => of<ErrorLike>(asError(error)))
|
||||
),
|
||||
[_fetchWeeklyActiveUsers]
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="site-admin-overview-page">
|
||||
<PageTitle title="Overview - Admin" />
|
||||
{overviewComponents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
{overviewComponents.map((Component, index) => (
|
||||
<Component key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{info === undefined && <LoadingSpinner />}
|
||||
<div className="list-group">
|
||||
{info && !isErrorLike(info) && (
|
||||
<>
|
||||
{info.repositories !== null && (
|
||||
<Link
|
||||
to="/site-admin/repositories"
|
||||
className={classNames(
|
||||
'list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
>
|
||||
{numberWithCommas(info.repositories)}{' '}
|
||||
{pluralize('repository', info.repositories, 'repositories')}
|
||||
</Link>
|
||||
)}
|
||||
{info.repositoryStats !== null && (
|
||||
<Link
|
||||
to="/site-admin/repositories"
|
||||
className={classNames(
|
||||
'list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
>
|
||||
{BigInt(info.repositoryStats.gitDirBytes).toLocaleString()}{' '}
|
||||
{pluralize('byte stored', BigInt(info.repositoryStats.gitDirBytes), 'bytes stored')}
|
||||
</Link>
|
||||
)}
|
||||
{info.repositoryStats !== null && (
|
||||
<Link
|
||||
to="/site-admin/repositories"
|
||||
className={classNames(
|
||||
'list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
>
|
||||
{BigInt(info.repositoryStats.indexedLinesCount).toLocaleString()}{' '}
|
||||
{pluralize(
|
||||
'line of code indexed',
|
||||
BigInt(info.repositoryStats.indexedLinesCount),
|
||||
'lines of code indexed'
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{info.users > 1 && (
|
||||
<Link
|
||||
to="/site-admin/users"
|
||||
className={classNames(
|
||||
'list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
>
|
||||
{numberWithCommas(info.users)} {pluralize('user', info.users)}
|
||||
</Link>
|
||||
)}
|
||||
{info.orgs > 1 && (
|
||||
<Link
|
||||
to="/site-admin/organizations"
|
||||
className={classNames(
|
||||
'list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
>
|
||||
{numberWithCommas(info.orgs)} {pluralize('organization', info.orgs)}
|
||||
</Link>
|
||||
)}
|
||||
{info.users > 1 && (
|
||||
<Link
|
||||
to="/site-admin/surveys"
|
||||
className={classNames(
|
||||
'list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
>
|
||||
{numberWithCommas(info.surveyResponses.totalCount)}{' '}
|
||||
{pluralize('user survey response', info.surveyResponses.totalCount)}
|
||||
</Link>
|
||||
)}
|
||||
{info.users > 1 &&
|
||||
stats !== undefined &&
|
||||
(isErrorLike(stats) ? (
|
||||
<ErrorAlert className="mb-3" error={stats} />
|
||||
) : (
|
||||
<Collapsible
|
||||
title={
|
||||
<>
|
||||
{stats.waus[1].userCount}{' '}
|
||||
{pluralize('active user', stats.waus[1].userCount)} last week
|
||||
</>
|
||||
}
|
||||
defaultExpanded={true}
|
||||
className="list-group-item"
|
||||
titleClassName={classNames(
|
||||
'mb-0 font-weight-normal p-2',
|
||||
styles.adminOverviewMenuText
|
||||
)}
|
||||
titleAtStart={true}
|
||||
>
|
||||
{stats && (
|
||||
<UsageChart
|
||||
isLightTheme={isLightTheme}
|
||||
stats={stats}
|
||||
chartID="waus"
|
||||
showLegend={false}
|
||||
header={
|
||||
<div className="site-admin-overview-page__detail-header">
|
||||
<Heading as="h3" styleAs="h2">
|
||||
Weekly unique users
|
||||
</Heading>
|
||||
<H3>
|
||||
<Button
|
||||
to="/site-admin/usage-statistics"
|
||||
variant="secondary"
|
||||
as={Link}
|
||||
>
|
||||
View all usage statistics{' '}
|
||||
<Icon aria-hidden={true} svgPath={mdiOpenInNew} />
|
||||
</Button>
|
||||
</H3>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Collapsible>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,282 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`SiteAdminOverviewPage < 2 users 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="site-admin-overview-page"
|
||||
>
|
||||
<div
|
||||
class="list-group"
|
||||
>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/repositories"
|
||||
>
|
||||
100 repositories
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/repositories"
|
||||
>
|
||||
1,825,299,556 bytes stored
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/repositories"
|
||||
>
|
||||
2,616,264 lines of code indexed
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`SiteAdminOverviewPage >= 2 users 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="site-admin-overview-page"
|
||||
>
|
||||
<div
|
||||
class="list-group"
|
||||
>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/repositories"
|
||||
>
|
||||
100 repositories
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/repositories"
|
||||
>
|
||||
1,825,299,556 bytes stored
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/repositories"
|
||||
>
|
||||
2,616,264 lines of code indexed
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/users"
|
||||
>
|
||||
10 users
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/organizations"
|
||||
>
|
||||
5 organizations
|
||||
</a>
|
||||
<a
|
||||
class="anchorLink list-group-item list-group-item-action mb-0 font-weight-normal py-2 px-3 adminOverviewMenuText"
|
||||
href="/site-admin/surveys"
|
||||
>
|
||||
100 user survey responses
|
||||
</a>
|
||||
<div
|
||||
class="list-group-item"
|
||||
>
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center position-relative"
|
||||
>
|
||||
<span
|
||||
class="mb-0 font-weight-normal p-2 adminOverviewMenuText"
|
||||
>
|
||||
10 active users last week
|
||||
</span>
|
||||
<button
|
||||
aria-label="Collapse section"
|
||||
class="btn btnIcon d-flex expandBtn stretched-link"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mdi-icon iconInline"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="site-admin-overview-page__detail-header"
|
||||
>
|
||||
<h3
|
||||
class="h2"
|
||||
>
|
||||
Weekly unique users
|
||||
</h3>
|
||||
<h3
|
||||
class="h3"
|
||||
>
|
||||
<a
|
||||
class="anchorLink btn btnSecondary"
|
||||
href="/site-admin/usage-statistics"
|
||||
>
|
||||
View all usage statistics
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="mdi-icon iconInline"
|
||||
fill="currentColor"
|
||||
height="24"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
<path
|
||||
d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<svg
|
||||
class="d3BarChart"
|
||||
preserveAspectRatio="xMinYMin"
|
||||
viewBox="0 0 500 200"
|
||||
>
|
||||
<g
|
||||
class="bar-holder"
|
||||
>
|
||||
<g>
|
||||
<g
|
||||
fill="#a2b0cd"
|
||||
>
|
||||
<rect
|
||||
class="bar"
|
||||
height="160"
|
||||
title="8 users"
|
||||
width="248"
|
||||
x="1"
|
||||
y="39.99999999999999"
|
||||
/>
|
||||
<rect
|
||||
class="bar"
|
||||
height="160"
|
||||
title="8 users"
|
||||
width="248"
|
||||
x="1"
|
||||
y="39.99999999999999"
|
||||
/>
|
||||
</g>
|
||||
<g
|
||||
fill="#cad2e2"
|
||||
>
|
||||
<rect
|
||||
class="bar"
|
||||
height="39.99999999999999"
|
||||
title="2 users"
|
||||
width="248"
|
||||
x="1"
|
||||
y="0"
|
||||
/>
|
||||
<rect
|
||||
class="bar"
|
||||
height="39.99999999999999"
|
||||
title="2 users"
|
||||
width="248"
|
||||
x="1"
|
||||
y="0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<text
|
||||
dx="124"
|
||||
dy="-0.5em"
|
||||
text-anchor="middle"
|
||||
x="0"
|
||||
y="0"
|
||||
>
|
||||
10
|
||||
</text>
|
||||
<text
|
||||
dx="124"
|
||||
dy="-0.5em"
|
||||
text-anchor="middle"
|
||||
x="0"
|
||||
y="0"
|
||||
>
|
||||
10
|
||||
</text>
|
||||
</g>
|
||||
<g
|
||||
class="axis"
|
||||
fill="none"
|
||||
font-family="sans-serif"
|
||||
font-size="10"
|
||||
text-anchor="middle"
|
||||
transform="translate(0, 200)"
|
||||
>
|
||||
<path
|
||||
class="domain"
|
||||
d="M0.5,6V0.5H500.5V6"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<g
|
||||
class="tick"
|
||||
opacity="1"
|
||||
transform="translate(250.5,0)"
|
||||
>
|
||||
<line
|
||||
stroke="currentColor"
|
||||
y2="6"
|
||||
/>
|
||||
<text
|
||||
dy="0.71em"
|
||||
fill="currentColor"
|
||||
y="9"
|
||||
>
|
||||
<tspan
|
||||
dy="0.71em"
|
||||
x="0"
|
||||
y="9"
|
||||
/>
|
||||
<tspan
|
||||
dy="1.81em"
|
||||
x="0"
|
||||
y="9"
|
||||
>
|
||||
Tue,
|
||||
</tspan>
|
||||
<tspan
|
||||
dy="2.91em"
|
||||
x="0"
|
||||
y="9"
|
||||
>
|
||||
Jan
|
||||
</tspan>
|
||||
<tspan
|
||||
dy="4.01em"
|
||||
x="0"
|
||||
y="9"
|
||||
>
|
||||
3
|
||||
</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<small
|
||||
class="tzNote"
|
||||
>
|
||||
<i>
|
||||
GMT/UTC time
|
||||
</i>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
@ -1,6 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Additional components to render on the SiteAdminOverviewPage.
|
||||
*/
|
||||
export const siteAdminOverviewComponents: readonly React.ComponentType<React.PropsWithChildren<unknown>>[] = []
|
||||
@ -4,9 +4,43 @@ import { SiteAdminAreaRoute } from './SiteAdminArea'
|
||||
|
||||
export const siteAdminAreaRoutes: readonly SiteAdminAreaRoute[] = [
|
||||
{
|
||||
// Render empty page if no page selected
|
||||
path: '',
|
||||
render: lazyComponent(() => import('./overview/SiteAdminOverviewPage'), 'SiteAdminOverviewPage'),
|
||||
path: '/',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsOverviewPage'), 'AnalyticsOverviewPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/search',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsSearchPage'), 'AnalyticsSearchPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/code-intel',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsCodeIntelPage'), 'AnalyticsCodeIntelPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/extensions',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsExtensionsPage'), 'AnalyticsExtensionsPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/users',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsUsersPage'), 'AnalyticsUsersPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/code-insights',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsCodeInsightsPage'), 'AnalyticsCodeInsightsPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/batch-changes',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsBatchChangesPage'), 'AnalyticsBatchChangesPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
path: '/analytics/notebooks',
|
||||
render: lazyComponent(() => import('./analytics/AnalyticsNotebooksPage'), 'AnalyticsNotebooksPage'),
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
@ -36,10 +70,7 @@ export const siteAdminAreaRoutes: readonly SiteAdminAreaRoute[] = [
|
||||
{
|
||||
path: '/users',
|
||||
exact: true,
|
||||
render: lazyComponent(
|
||||
() => import('./SiteAdminAllUsersPage/FeatureFlaggedUsersPage'),
|
||||
'FeatureFlaggedUsersPage'
|
||||
),
|
||||
render: lazyComponent(() => import('./UserManagement'), 'UsersManagement'),
|
||||
},
|
||||
{
|
||||
path: '/users/new',
|
||||
@ -51,11 +82,6 @@ export const siteAdminAreaRoutes: readonly SiteAdminAreaRoute[] = [
|
||||
exact: true,
|
||||
render: lazyComponent(() => import('./SiteAdminTokensPage'), 'SiteAdminTokensPage'),
|
||||
},
|
||||
{
|
||||
path: '/usage-statistics',
|
||||
exact: true,
|
||||
render: lazyComponent(() => import('./SiteAdminUsageStatisticsPage'), 'SiteAdminUsageStatisticsPage'),
|
||||
},
|
||||
{
|
||||
path: '/updates',
|
||||
render: lazyComponent(() => import('./SiteAdminUpdatesPage'), 'SiteAdminUpdatesPage'),
|
||||
|
||||
@ -1,26 +1,50 @@
|
||||
import AccountMultipleIcon from 'mdi-react/AccountMultipleIcon'
|
||||
import ChartLineVariantIcon from 'mdi-react/ChartLineVariantIcon'
|
||||
import CogsIcon from 'mdi-react/CogsIcon'
|
||||
import ConsoleIcon from 'mdi-react/ConsoleIcon'
|
||||
import EarthIcon from 'mdi-react/EarthIcon'
|
||||
import MonitorStarIcon from 'mdi-react/MonitorStarIcon'
|
||||
import SourceRepositoryIcon from 'mdi-react/SourceRepositoryIcon'
|
||||
|
||||
import { SiteAdminSideBarGroup, SiteAdminSideBarGroups } from './SiteAdminSidebar'
|
||||
|
||||
export const overviewGroup: SiteAdminSideBarGroup = {
|
||||
export const analyticsGroup: SiteAdminSideBarGroup = {
|
||||
header: {
|
||||
label: 'Statistics',
|
||||
icon: EarthIcon,
|
||||
label: 'Analytics',
|
||||
icon: ChartLineVariantIcon,
|
||||
},
|
||||
items: [
|
||||
{
|
||||
label: 'Overview',
|
||||
to: '/site-admin',
|
||||
to: '/site-admin/',
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
label: 'Usage stats',
|
||||
to: '/site-admin/usage-statistics',
|
||||
label: 'Search',
|
||||
to: '/site-admin/analytics/search',
|
||||
},
|
||||
{
|
||||
label: 'Code navigation',
|
||||
to: '/site-admin/analytics/code-intel',
|
||||
},
|
||||
{
|
||||
label: 'Users',
|
||||
to: '/site-admin/analytics/users',
|
||||
},
|
||||
{
|
||||
label: 'Insights',
|
||||
to: '/site-admin/analytics/code-insights',
|
||||
},
|
||||
{
|
||||
label: 'Batch changes',
|
||||
to: '/site-admin/analytics/batch-changes',
|
||||
},
|
||||
{
|
||||
label: 'Notebooks',
|
||||
to: '/site-admin/analytics/notebooks',
|
||||
},
|
||||
{
|
||||
label: 'Extensions',
|
||||
to: '/site-admin/analytics/extensions',
|
||||
},
|
||||
{
|
||||
label: 'Feedback survey',
|
||||
@ -156,7 +180,7 @@ export const apiConsoleGroup: SiteAdminSideBarGroup = {
|
||||
}
|
||||
|
||||
export const siteAdminSidebarGroups: SiteAdminSideBarGroups = [
|
||||
overviewGroup,
|
||||
analyticsGroup,
|
||||
configurationGroup,
|
||||
repositoriesGroup,
|
||||
usersGroup,
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"github.com/sourcegraph/sourcegraph/internal/auth"
|
||||
"github.com/sourcegraph/sourcegraph/internal/database"
|
||||
"github.com/sourcegraph/sourcegraph/internal/featureflag"
|
||||
"github.com/sourcegraph/sourcegraph/lib/errors"
|
||||
)
|
||||
|
||||
type siteAnalyticsResolver struct {
|
||||
@ -21,10 +20,6 @@ func (r *siteResolver) Analytics(ctx context.Context) (*siteAnalyticsResolver, e
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if featureflag.FromContext(ctx).GetBoolOr("admin-analytics-disabled", false) {
|
||||
return nil, errors.New("'admin-analytics-disabled' feature flag is enabled")
|
||||
}
|
||||
|
||||
cache := !featureflag.FromContext(ctx).GetBoolOr("admin-analytics-cache-disabled", false)
|
||||
|
||||
return &siteAnalyticsResolver{r.db, cache}, nil
|
||||
|
||||
@ -153,10 +153,8 @@ func StartAnalyticsCacheRefresh(ctx context.Context, db database.DB) {
|
||||
|
||||
const delay = 24 * time.Hour
|
||||
for {
|
||||
if !featureflag.FromContext(ctx).GetBoolOr("admin-analytics-disabled", false) {
|
||||
if err := refreshAnalyticsCache(ctx, db); err != nil {
|
||||
logger.Error("Error refreshing admin analytics cache", log.Error(err))
|
||||
}
|
||||
if err := refreshAnalyticsCache(ctx, db); err != nil {
|
||||
logger.Error("Error refreshing admin analytics cache", log.Error(err))
|
||||
}
|
||||
|
||||
// Randomize sleep to prevent thundering herds.
|
||||
|
||||
@ -166,11 +166,7 @@
|
||||
"@types/compression": "1.7.2",
|
||||
"@types/compression-webpack-plugin": "9.1.1",
|
||||
"@types/connect-history-api-fallback": "1.3.4",
|
||||
"@types/d3-axis": "1.0.12",
|
||||
"@types/d3-format": "2.0.0",
|
||||
"@types/d3-scale": "2.2.0",
|
||||
"@types/d3-selection": "1.4.1",
|
||||
"@types/d3-shape": "1.3.2",
|
||||
"@types/d3-time-format": "3.0.0",
|
||||
"@types/escape-html": "^1.0.1",
|
||||
"@types/expect": "24.3.0",
|
||||
@ -397,11 +393,7 @@
|
||||
"comlink": "^4.3.0",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.8.2",
|
||||
"d3-axis": "^1.0.12",
|
||||
"d3-format": "^2.0.0",
|
||||
"d3-scale": "^3.2.1",
|
||||
"d3-selection": "^1.4.1",
|
||||
"d3-shape": "^1.3.7",
|
||||
"d3-time-format": "^3.0.0",
|
||||
"date-fns": "^2.16.1",
|
||||
"delay": "^4.4.1",
|
||||
|
||||
45
yarn.lock
45
yarn.lock
@ -7427,15 +7427,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-axis@npm:1.0.12":
|
||||
version: 1.0.12
|
||||
resolution: "@types/d3-axis@npm:1.0.12"
|
||||
dependencies:
|
||||
"@types/d3-selection": "*"
|
||||
checksum: 63fb27d0c8a552c0745ff5d926b45c94e426fea3ef356de35f7ad7791f412c208bfa86002a177c0cb2d931c4a520bc26d63a903cb718ed4d906ee21a1b9a6411
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-color@npm:^1":
|
||||
version: 1.4.1
|
||||
resolution: "@types/d3-color@npm:1.4.1"
|
||||
@ -7466,15 +7457,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-scale@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@types/d3-scale@npm:2.2.0"
|
||||
dependencies:
|
||||
"@types/d3-time": "*"
|
||||
checksum: 988c3f0e779a25c0d1df41e960a6edda88da05f2b5596a80eb0b7c68981c7e5ee10d665ea76bda44ee2b4eece3a43c2bb0efa059c1accc3fc7f3a4d28761128f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-scale@npm:^3.3.0":
|
||||
version: 3.3.2
|
||||
resolution: "@types/d3-scale@npm:3.3.2"
|
||||
@ -7484,14 +7466,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-selection@npm:*, @types/d3-selection@npm:1.4.1":
|
||||
version: 1.4.1
|
||||
resolution: "@types/d3-selection@npm:1.4.1"
|
||||
checksum: 5751a4eb9a0fcd1d6980f888c6e1c5ff536299f60e0d0f408db94413c44353f10bd850ee9396ad5af3393f8e3af5f76888d60da576c27287b1929850f562b5d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-shape@npm:1.3.2, @types/d3-shape@npm:^1, @types/d3-shape@npm:^1.3.1":
|
||||
"@types/d3-shape@npm:^1, @types/d3-shape@npm:^1.3.1":
|
||||
version: 1.3.2
|
||||
resolution: "@types/d3-shape@npm:1.3.2"
|
||||
dependencies:
|
||||
@ -7507,7 +7482,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/d3-time@npm:*, @types/d3-time@npm:^2, @types/d3-time@npm:^2.0.0":
|
||||
"@types/d3-time@npm:^2, @types/d3-time@npm:^2.0.0":
|
||||
version: 2.1.1
|
||||
resolution: "@types/d3-time@npm:2.1.1"
|
||||
checksum: 115048d0cd312a3172ef7c03615dfbdbd8b92a93fd7b6d9ca93c49c704fcdb9575f4c57955eb54eb757b9834acaaf47fc52eae103d06246c59ae120de4559cbc
|
||||
@ -13361,7 +13336,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-axis@npm:1, d3-axis@npm:^1.0.12":
|
||||
"d3-axis@npm:1":
|
||||
version: 1.0.12
|
||||
resolution: "d3-axis@npm:1.0.12"
|
||||
checksum: b1cf820fb6e95cc3371b340353b05272dba16ce6ad4fe9a0992d075ab48a08810f87f5e6c7cbb6c63fca1ee1e9b7c822307a1590187daa7627f45728a747c746
|
||||
@ -13773,7 +13748,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-scale@npm:^3.2.1, d3-scale@npm:^3.3.0":
|
||||
"d3-scale@npm:^3.3.0":
|
||||
version: 3.3.0
|
||||
resolution: "d3-scale@npm:3.3.0"
|
||||
dependencies:
|
||||
@ -13786,7 +13761,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-selection@npm:1, d3-selection@npm:^1.1.0, d3-selection@npm:^1.4.1":
|
||||
"d3-selection@npm:1, d3-selection@npm:^1.1.0":
|
||||
version: 1.4.2
|
||||
resolution: "d3-selection@npm:1.4.2"
|
||||
checksum: 2484b392259b087a98f546f2610e6a11c90f38dae6b6b20a3fc85171038fcab4c72e702788b1960a4fece88bed2e36f268096358b5b48d3c7f0d35cfbe305da6
|
||||
@ -13800,7 +13775,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"d3-shape@npm:1, d3-shape@npm:^1.0.6, d3-shape@npm:^1.2.0, d3-shape@npm:^1.3.7":
|
||||
"d3-shape@npm:1, d3-shape@npm:^1.0.6, d3-shape@npm:^1.2.0":
|
||||
version: 1.3.7
|
||||
resolution: "d3-shape@npm:1.3.7"
|
||||
dependencies:
|
||||
@ -27574,11 +27549,7 @@ __metadata:
|
||||
"@types/compression": 1.7.2
|
||||
"@types/compression-webpack-plugin": 9.1.1
|
||||
"@types/connect-history-api-fallback": 1.3.4
|
||||
"@types/d3-axis": 1.0.12
|
||||
"@types/d3-format": 2.0.0
|
||||
"@types/d3-scale": 2.2.0
|
||||
"@types/d3-selection": 1.4.1
|
||||
"@types/d3-shape": 1.3.2
|
||||
"@types/d3-time-format": 3.0.0
|
||||
"@types/escape-html": ^1.0.1
|
||||
"@types/expect": 24.3.0
|
||||
@ -27678,11 +27649,7 @@ __metadata:
|
||||
cross-env: ^7.0.2
|
||||
css-loader: ^6.7.2
|
||||
css-minimizer-webpack-plugin: ^4.2.2
|
||||
d3-axis: ^1.0.12
|
||||
d3-format: ^2.0.0
|
||||
d3-scale: ^3.2.1
|
||||
d3-selection: ^1.4.1
|
||||
d3-shape: ^1.3.7
|
||||
d3-time-format: ^3.0.0
|
||||
date-fns: ^2.16.1
|
||||
delay: ^4.4.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user