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:
Vova Kulikov 2022-12-22 16:15:46 -03:00 committed by GitHub
parent 61d5544660
commit e9c6dc68eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 150 additions and 2099 deletions

View File

@ -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}`,

View File

@ -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}

View File

@ -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);
}
}

View File

@ -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)
}
}
})
}

View File

@ -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'),
]

View File

@ -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,

View File

@ -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'

View File

@ -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`.

View File

@ -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
)

View File

@ -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()
}

View File

@ -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

View File

@ -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.

View File

@ -1,8 +0,0 @@
.date-column {
width: 40%;
}
.tz-note {
display: block;
text-align: center;
}

View File

@ -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 })
}
}

View File

@ -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>

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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`

View File

@ -1,4 +0,0 @@
.admin-overview-menu-text {
line-height: var(--line-height-typography);
font-size: calc(var(--font-size-base) * 1.25);
}

View File

@ -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())
})
})

View File

@ -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>
)
}

View File

@ -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>
`;

View File

@ -1,6 +0,0 @@
import React from 'react'
/**
* Additional components to render on the SiteAdminOverviewPage.
*/
export const siteAdminOverviewComponents: readonly React.ComponentType<React.PropsWithChildren<unknown>>[] = []

View File

@ -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'),

View File

@ -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,

View File

@ -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

View File

@ -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.

View File

@ -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",

View File

@ -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