Webhook log frontend (#27179)

This commit is contained in:
Adam Harvey 2021-11-17 11:18:38 +01:00 committed by GitHub
parent f75ccb58c3
commit ab24484a85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1196 additions and 3 deletions

View File

@ -54,6 +54,8 @@ export function registerHighlightContributions(): void {
registerLanguage('graphql', graphQLLanguage)
// Apex is not supported by highlight.js, but it's very close to Java.
registerLanguage('apex', require('highlight.js/lib/languages/java'))
// We use HTTP to render incoming webhook deliveries.
registerLanguage('http', require('highlight.js/lib/languages/http'))
/* eslint-enable @typescript-eslint/no-require-imports */
/* eslint-enable @typescript-eslint/no-var-requires */
}

View File

@ -26,6 +26,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st
allowSignup: true,
batchChangesEnabled: true,
batchChangesDisableWebhooksWarning: false,
batchChangesWebhookLogsEnabled: true,
codeIntelAutoIndexingEnabled: false,
codeIntelAutoIndexingAllowGlobalPolicies: false,
externalServicesUserMode: 'public',

View File

@ -456,6 +456,9 @@ export class SourcegraphWebApp extends React.Component<SourcegraphWebAppProps, S
batchChangesExecutionEnabled={isBatchChangesExecutionEnabled(
this.state.settingsCascade
)}
batchChangesWebhookLogsEnabled={
window.context.batchChangesWebhookLogsEnabled
}
// Search query
fetchHighlightedFileLineRanges={fetchHighlightedFileLineRanges}
parsedSearchQuery={this.state.parsedSearchQuery}

View File

@ -19,4 +19,5 @@ export const isBatchChangesExecutionEnabled = (settingsCascade: SettingsCascadeO
export interface BatchChangesProps {
batchChangesExecutionEnabled: boolean
batchChangesEnabled: boolean
batchChangesWebhookLogsEnabled: boolean
}

View File

@ -94,6 +94,13 @@ export const enterpriseSiteAdminAreaRoutes: readonly SiteAdminAreaRoute[] = [
condition: ({ batchChangesEnabled, batchChangesExecutionEnabled }) =>
batchChangesEnabled && batchChangesExecutionEnabled,
},
{
path: '/batch-changes/webhook-logs',
exact: true,
render: lazyComponent(() => import('../../site-admin/webhooks/WebhookLogPage'), 'WebhookLogPage'),
condition: ({ batchChangesEnabled, batchChangesWebhookLogsEnabled }) =>
batchChangesEnabled && batchChangesWebhookLogsEnabled,
},
// Code intelligence upload routes
{

View File

@ -71,6 +71,11 @@ export const batchChangesGroup: SiteAdminSideBarGroup = {
to: '/site-admin/batch-changes/specs',
condition: props => props.batchChangesExecutionEnabled,
},
{
label: 'Incoming webhooks',
to: '/site-admin/batch-changes/webhook-logs',
condition: props => props.batchChangesWebhookLogsEnabled,
},
],
condition: ({ batchChangesEnabled }) => batchChangesEnabled,
}

View File

@ -18,6 +18,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st
allowSignup: false,
batchChangesEnabled: true,
batchChangesDisableWebhooksWarning: false,
batchChangesWebhookLogsEnabled: true,
codeIntelAutoIndexingEnabled: true,
codeIntelAutoIndexingAllowGlobalPolicies: true,
externalServicesUserMode: 'disabled',

View File

@ -98,6 +98,8 @@ export interface SourcegraphContext extends Pick<Required<SiteConfiguration>, 'e
* Changes. */
batchChangesDisableWebhooksWarning: boolean
batchChangesWebhookLogsEnabled: boolean
/** Whether the code intel auto-indexer feature is enabled on the site. */
codeIntelAutoIndexingEnabled: boolean

View File

@ -62,6 +62,7 @@ const defaultProps = (
searchContextsEnabled: true,
batchChangesEnabled: true,
batchChangesExecutionEnabled: true,
batchChangesWebhookLogsEnabled: true,
enableCodeMonitoring: true,
activation: undefined,
routes: [],

View File

@ -39,6 +39,7 @@ const PROPS: React.ComponentProps<typeof GlobalNavbar> = {
settingsCascade: NOOP_SETTINGS_CASCADE,
batchChangesEnabled: false,
batchChangesExecutionEnabled: false,
batchChangesWebhookLogsEnabled: false,
enableCodeMonitoring: false,
telemetryService: {} as any,
isExtensionAlertAnimating: false,

View File

@ -234,6 +234,7 @@ export class OrgArea extends React.Component<Props> {
isSourcegraphDotCom: this.props.isSourcegraphDotCom,
batchChangesEnabled: this.props.batchChangesEnabled,
batchChangesExecutionEnabled: this.props.batchChangesExecutionEnabled,
batchChangesWebhookLogsEnabled: this.props.batchChangesWebhookLogsEnabled,
breadcrumbs: this.props.breadcrumbs,
setBreadcrumb: this.state.setBreadcrumb,
useBreadcrumb: this.state.useBreadcrumb,

View File

@ -27,6 +27,7 @@ export interface OrgAreaHeaderNavItem extends NavItemWithIconDescriptor<OrgAreaH
export const OrgHeader: React.FunctionComponent<Props> = ({
batchChangesEnabled,
batchChangesExecutionEnabled,
batchChangesWebhookLogsEnabled,
org,
navItems,
match,
@ -63,6 +64,7 @@ export const OrgHeader: React.FunctionComponent<Props> = ({
condition({
batchChangesEnabled,
batchChangesExecutionEnabled,
batchChangesWebhookLogsEnabled,
org,
isSourcegraphDotCom,
}) && (

View File

@ -88,6 +88,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent<SiteAdminAreaProps> =
isSourcegraphDotCom: props.isSourcegraphDotCom,
batchChangesEnabled: props.batchChangesEnabled,
batchChangesExecutionEnabled: props.batchChangesExecutionEnabled,
batchChangesWebhookLogsEnabled: props.batchChangesWebhookLogsEnabled,
activation: props.activation,
site: { __typename: 'Site' as const, id: window.context.siteGQLID },
overviewComponents: props.overviewComponents,
@ -104,6 +105,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent<SiteAdminAreaProps> =
isSourcegraphDotCom={props.isSourcegraphDotCom}
batchChangesEnabled={props.batchChangesEnabled}
batchChangesExecutionEnabled={props.batchChangesExecutionEnabled}
batchChangesWebhookLogsEnabled={props.batchChangesWebhookLogsEnabled}
/>
<div className="flex-bounded">
<ErrorBoundary location={props.location}>

View File

@ -22,6 +22,7 @@ add(
isSourcegraphDotCom={false}
batchChangesEnabled={false}
batchChangesExecutionEnabled={false}
batchChangesWebhookLogsEnabled={false}
/>
)}
</WebStory>

View File

@ -0,0 +1,3 @@
.message-body {
max-height: 25rem;
}

View File

@ -0,0 +1,56 @@
import { number } from '@storybook/addon-knobs'
import { storiesOf } from '@storybook/react'
import React from 'react'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { MessagePanel } from './MessagePanel'
import { BODY_JSON, BODY_PLAIN, HEADERS_JSON, HEADERS_PLAIN } from './story/fixtures'
const { add } = storiesOf('web/site-admin/webhooks/MessagePanel', module)
.addDecorator(story => <div className="p-3 container">{story()}</div>)
.addParameters({
chromatic: {
viewports: [576, 1440],
},
})
for (const [name, { headers, body }] of Object.entries({
JSON: { headers: HEADERS_JSON, body: BODY_JSON },
plain: { headers: HEADERS_PLAIN, body: BODY_PLAIN },
})) {
add(`${name} request`, () => (
<WebStory>
{() => (
<MessagePanel
message={{
headers,
body,
}}
requestOrStatusCode={{
method: 'POST',
url: '/my/url',
version: 'HTTP/1.1',
}}
/>
)}
</WebStory>
))
add(`${name} response`, () => (
<WebStory>
{() => (
<MessagePanel
message={{
headers,
body,
}}
requestOrStatusCode={number('status code', 200, {
min: 100,
max: 599,
})}
/>
)}
</WebStory>
))
}

View File

@ -0,0 +1,65 @@
import { getReasonPhrase } from 'http-status-codes'
import React, { useMemo } from 'react'
import { CodeSnippet } from '@sourcegraph/branded/src/components/CodeSnippet'
import { WebhookLogMessageFields, WebhookLogRequestFields } from '../../graphql-operations'
import styles from './MessagePanel.module.scss'
export interface Props {
className?: string
message: WebhookLogMessageFields
// A HTTP message can be either a request or a response; if it's a response,
// then we're only interested in the status code here to render the first
// line of the "response".
requestOrStatusCode: WebhookLogRequestFields | number
}
export const MessagePanel: React.FunctionComponent<Props> = ({ className, message, requestOrStatusCode }) => {
const [headers, language, body] = useMemo(() => {
const headers = []
let language = 'nohighlight'
let body = message.body
for (const header of message.headers) {
if (
header.name.toLowerCase() === 'content-type' &&
header.values.find(value => value.includes('/json')) !== undefined
) {
language = 'json'
body = JSON.stringify(JSON.parse(message.body), null, 2)
}
headers.push(...header.values.map(value => `${header.name}: ${value}`))
}
// Since the headers aren't in any useful order when they're returned
// from the backend, let's just sort them alphabetically.
headers.sort()
// We want to prepend either the request line or the status line,
// depending on what type of message this is.
if (typeof requestOrStatusCode === 'number') {
let reason
try {
reason = ' ' + getReasonPhrase(requestOrStatusCode)
} catch {
reason = ''
}
headers.unshift(`HTTP/1.1 ${requestOrStatusCode}${reason}`)
} else {
headers.unshift(`${requestOrStatusCode.method} ${requestOrStatusCode.url} ${requestOrStatusCode.version}`)
}
return [headers.join('\n'), language, body]
}, [message.body, message.headers, requestOrStatusCode])
return (
<div className={className}>
<CodeSnippet language="http" code={headers} />
<CodeSnippet className={styles.messageBody} language={language} code={body} />
</div>
)
}

View File

@ -0,0 +1,11 @@
.gauge {
border: solid 1px var(--border-color);
border-radius: var(--border-radius);
}
.count {
font-size: 1.625rem;
font-weight: 600;
line-height: 1;
margin-right: 0.714rem;
}

View File

@ -0,0 +1,27 @@
import { storiesOf } from '@storybook/react'
import React from 'react'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { PerformanceGauge } from './PerformanceGauge'
import { StyledPerformanceGauge } from './story/StyledPerformanceGauge'
const { add } = storiesOf('web/site-admin/webhooks/PerformanceGauge', module)
.addDecorator(story => <div className="p-3 container">{story()}</div>)
.addParameters({
chromatic: {
viewports: [576],
},
})
add('loading', () => <WebStory>{() => <PerformanceGauge label="dog" />}</WebStory>)
add('zero', () => <WebStory>{() => <PerformanceGauge count={0} label="dog" />}</WebStory>)
add('zero with explicit plural', () => (
<WebStory>{() => <PerformanceGauge count={0} label="wolf" plural="wolves" />}</WebStory>
))
add('one', () => <WebStory>{() => <PerformanceGauge count={1} label="dog" />}</WebStory>)
add('many', () => <WebStory>{() => <PerformanceGauge count={42} label="dog" />}</WebStory>)
add('many with explicit plural', () => (
<WebStory>{() => <PerformanceGauge count={42} label="wolf" plural="wolves" />}</WebStory>
))
add('class overrides', () => <WebStory>{() => <StyledPerformanceGauge count={42} label="dog" />}</WebStory>)

View File

@ -0,0 +1,38 @@
import classNames from 'classnames'
import React from 'react'
import { pluralize } from '@sourcegraph/shared/src/util/strings'
import styles from './PerformanceGauge.module.scss'
export interface Props {
count?: number
label: string
plural?: string
className?: string
countClassName?: string
labelClassName?: string
}
/**
* A performance gauge is a component that renders a numeric value with a label
* in a way that focuses attention on the numeric value.
*/
export const PerformanceGauge: React.FunctionComponent<Props> = ({
count,
className,
countClassName,
label,
labelClassName,
plural,
}) => (
<div className={classNames(styles.gauge, 'px-4', 'py-3', 'd-flex', 'align-items-center', className)}>
{count === undefined ? (
<span className={classNames(styles.count, 'text-muted', countClassName)}>&hellip;</span>
) : (
<span className={classNames(styles.count, countClassName)}>{count}</span>
)}
<span className={classNames('text-muted', labelClassName)}>{pluralize(label, count ?? 0, plural)}</span>
</div>
)

View File

@ -0,0 +1,18 @@
import { number } from '@storybook/addon-knobs'
import { storiesOf } from '@storybook/react'
import React from 'react'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { StatusCode } from './StatusCode'
const { add } = storiesOf('web/site-admin/webhooks/StatusCode', module)
.addDecorator(story => <div className="p-3 container">{story()}</div>)
.addParameters({
chromatic: {
viewports: [576],
},
})
add('success', () => <WebStory>{() => <StatusCode code={number('code', 204, { min: 100, max: 399 })} />}</WebStory>)
add('failure', () => <WebStory>{() => <StatusCode code={number('code', 418, { min: 400, max: 599 })} />}</WebStory>)

View File

@ -0,0 +1,21 @@
import classNames from 'classnames'
import AlertCircleIcon from 'mdi-react/AlertCircleIcon'
import CheckIcon from 'mdi-react/CheckIcon'
import React from 'react'
export interface Props {
code: number
}
export const StatusCode: React.FunctionComponent<Props> = ({ code }) => (
<span>
<span className={classNames('mr-1')}>
{code < 400 ? (
<CheckIcon className="text-success icon-inline" />
) : (
<AlertCircleIcon className="text-danger icon-inline" />
)}
</span>
{code}
</span>
)

View File

@ -0,0 +1,52 @@
@import 'wildcard/src/global-styles/breakpoints';
.separator {
grid-column: 1 / -1;
border-top: 1px solid var(--border-color-2);
}
.expanded {
grid-column: 1 / -1;
background: var(--body-bg);
margin-top: 0.5rem;
// Override the grid gap at the bottom of the expanded element so the
// background runs all the way to the next border.
margin-bottom: -0.5rem;
@media (--sm-breakpoint-down) {
padding: 0 !important;
background: transparent;
overflow-x: auto;
}
}
.status-code {
text-align: center;
@media (--sm-breakpoint-down) {
text-align: left;
}
}
.status-code,
.received-at {
white-space: nowrap;
}
.details-button {
@media (--sm-breakpoint-down) {
display: none;
}
}
.sm-details-button {
display: none;
@media (--sm-breakpoint-down) {
display: block;
> button {
width: 100%;
}
}
}

View File

@ -0,0 +1,50 @@
import { storiesOf } from '@storybook/react'
import classNames from 'classnames'
import React from 'react'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { Container } from '@sourcegraph/wildcard'
import { webhookLogNode } from './story/fixtures'
import { WebhookLogNode } from './WebhookLogNode'
import gridStyles from './WebhookLogPage.module.scss'
const { add } = storiesOf('web/site-admin/webhooks/WebhookLogNode', module)
.addDecorator(story => (
<Container>
<div className={classNames('p-3', 'container', gridStyles.logs)}>{story()}</div>
</Container>
))
.addParameters({
chromatic: {
viewports: [320, 576, 978, 1440],
},
})
// Most of the components of WebhookLogNode are more thoroughly tested elsewhere
// in the storybook, so this is just a limited number of cases to ensure the
// expando behaviour is correct, the date formatting does something useful, and
// the external service name is handled properly when there isn't an external
// service.
//
// Some bonus knobs are provided for the tinkerers.
add('collapsed', () => (
<WebStory>
{() => (
<WebhookLogNode
node={webhookLogNode({
externalService: {
displayName: 'GitLab',
},
})}
/>
)}
</WebStory>
))
add('expanded request', () => (
<WebStory>{() => <WebhookLogNode node={webhookLogNode()} initiallyExpanded={true} />}</WebStory>
))
add('expanded response', () => (
<WebStory>{() => <WebhookLogNode node={webhookLogNode()} initiallyExpanded={true} initialTabIndex={1} />}</WebStory>
))

View File

@ -0,0 +1,85 @@
import classNames from 'classnames'
import { format } from 'date-fns'
import ChevronDownIcon from 'mdi-react/ChevronDownIcon'
import ChevronRightIcon from 'mdi-react/ChevronRightIcon'
import React, { useCallback, useState } from 'react'
import { Button, Tab, TabList, TabPanel, TabPanels, Tabs } from '@sourcegraph/wildcard'
import { WebhookLogFields } from '../../graphql-operations'
import { MessagePanel } from './MessagePanel'
import { StatusCode } from './StatusCode'
import styles from './WebhookLogNode.module.scss'
export interface Props {
node: WebhookLogFields
// For storybook purposes only:
initiallyExpanded?: boolean
initialTabIndex?: number
}
export const WebhookLogNode: React.FunctionComponent<Props> = ({
initiallyExpanded,
initialTabIndex,
node: { externalService, receivedAt, request, response, statusCode },
}) => {
const [isExpanded, setIsExpanded] = useState(initiallyExpanded === true)
const toggleExpanded = useCallback(() => setIsExpanded(!isExpanded), [isExpanded])
return (
<>
<span className={styles.separator} />
<span className={styles.detailsButton}>
<button
type="button"
className="btn btn-icon"
aria-label={isExpanded ? 'Collapse section' : 'Expand section'}
onClick={toggleExpanded}
>
{isExpanded ? (
<ChevronDownIcon className="icon-inline" aria-label="Close section" />
) : (
<ChevronRightIcon className="icon-inline" aria-label="Expand section" />
)}
</button>
</span>
<span className={styles.statusCode}>
<StatusCode code={statusCode} />
</span>
<span>
{externalService ? externalService.displayName : <span className="text-danger">Unmatched</span>}
</span>
<span className={styles.receivedAt}>{format(Date.parse(receivedAt), 'Ppp')}</span>
<span className={styles.smDetailsButton}>
<Button onClick={toggleExpanded} outline={true} variant="secondary">
{isExpanded ? (
<ChevronDownIcon className="icon-inline" aria-label="Close section" />
) : (
<ChevronRightIcon className="icon-inline" aria-label="Expand section" />
)}{' '}
{isExpanded ? 'Hide' : 'Show'} details
</Button>
</span>
{isExpanded && (
<div className={classNames('px-4', 'pt-3', 'pb-2', styles.expanded)}>
<Tabs index={initialTabIndex} size="small">
<TabList>
<Tab>Request</Tab>
<Tab>Response</Tab>
</TabList>
<TabPanels>
<TabPanel>
<MessagePanel className="pt-2" message={request} requestOrStatusCode={request} />
</TabPanel>
<TabPanel>
<MessagePanel className="pt-2" message={response} requestOrStatusCode={statusCode} />
</TabPanel>
</TabPanels>
</Tabs>
</div>
)}
</>
)
}

View File

@ -0,0 +1,13 @@
@import 'wildcard/src/global-styles/breakpoints';
.logs {
display: grid;
grid-template-columns: [caret] min-content [status] min-content [service] minmax(min-content, 1fr) [timestamp] min-content;
align-items: center;
column-gap: 2rem;
row-gap: 1rem;
@media (--sm-breakpoint-down) {
grid-template-columns: 1fr;
}
}

View File

@ -0,0 +1,130 @@
import { storiesOf } from '@storybook/react'
import { addMinutes, formatRFC3339 } from 'date-fns'
import React from 'react'
import { of } from 'rxjs'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { WebhookLogFields, WebhookLogsVariables } from '../../graphql-operations'
import { queryWebhookLogs, SelectedExternalService } from './backend'
import { BODY_JSON, BODY_PLAIN, buildHeaderMock, HEADERS_JSON, HEADERS_PLAIN } from './story/fixtures'
import { WebhookLogPage } from './WebhookLogPage'
const { add } = storiesOf('web/site-admin/webhooks/WebhookLogPage', module)
.addDecorator(story => <div className="p-3 container">{story()}</div>)
.addParameters({
chromatic: {
viewports: [320, 576, 978, 1440],
},
})
const buildQueryWebhookLogs: (logs: WebhookLogFields[]) => typeof queryWebhookLogs = logs => (
{ first, after }: Pick<WebhookLogsVariables, 'first' | 'after'>,
externalService: SelectedExternalService,
onlyErrors: boolean
) => {
const filtered = logs.filter(log => {
if (onlyErrors && log.statusCode < 400) {
return false
}
if (externalService === 'unmatched' && log.externalService) {
return false
}
if (
externalService !== 'all' &&
externalService !== 'unmatched' &&
externalService !== log.externalService?.displayName
) {
return false
}
return true
})
first = first ?? 20
const afterNumber = after?.length ? +after : 0
const page = filtered.slice(afterNumber, afterNumber + first)
const cursor = afterNumber + first
return of({
nodes: page,
pageInfo: {
hasNextPage: logs.length > cursor,
endCursor: cursor.toString(),
},
totalCount: logs.length,
})
}
const buildWebhookLogs = (count: number, externalServiceCount: number): WebhookLogFields[] => {
const logs: WebhookLogFields[] = []
const time = new Date(2021, 10, 8, 16, 40, 30)
for (let index = 0; index < count; index++) {
const externalServiceID = index % (externalServiceCount + 1)
const statusCode =
index % 3 === 0
? 200 + Math.floor(index / 3)
: index % 3 === 1
? 400 + Math.floor(index / 3)
: 500 + Math.floor(index / 3)
logs.push({
id: index.toString(),
receivedAt: formatRFC3339(addMinutes(time, index)),
externalService:
externalServiceID === externalServiceCount
? null
: {
displayName: `External service ${externalServiceID}`,
},
statusCode,
request: {
headers: HEADERS_JSON,
body: BODY_JSON,
method: 'POST',
url: '/my/url',
version: 'HTTP/1.1',
},
response: {
headers: HEADERS_PLAIN,
body: BODY_PLAIN,
},
})
}
return logs
}
add('no logs', () => (
<WebStory>
{props => (
<MockedTestProvider mocks={buildHeaderMock(2, 2)}>
<WebhookLogPage {...props} queryWebhookLogs={buildQueryWebhookLogs([])} />
</MockedTestProvider>
)}
</WebStory>
))
add('one page of logs', () => (
<WebStory>
{props => (
<MockedTestProvider mocks={buildHeaderMock(2, 2)}>
<WebhookLogPage {...props} queryWebhookLogs={buildQueryWebhookLogs(buildWebhookLogs(20, 2))} />
</MockedTestProvider>
)}
</WebStory>
))
add('two pages of logs', () => (
<WebStory>
{props => (
<MockedTestProvider mocks={buildHeaderMock(2, 2)}>
<WebhookLogPage {...props} queryWebhookLogs={buildQueryWebhookLogs(buildWebhookLogs(40, 2))} />
</MockedTestProvider>
)}
</WebStory>
))

View File

@ -0,0 +1,80 @@
import classNames from 'classnames'
import React, { useCallback, useState } from 'react'
import { RouteComponentProps } from 'react-router'
import { Container, PageHeader } from '@sourcegraph/wildcard'
import { FilteredConnection, FilteredConnectionQueryArguments } from '../../components/FilteredConnection'
import { PageTitle } from '../../components/PageTitle'
import { queryWebhookLogs as _queryWebhookLogs, SelectedExternalService } from './backend'
import { WebhookLogNode } from './WebhookLogNode'
import styles from './WebhookLogPage.module.scss'
import { WebhookLogPageHeader } from './WebhookLogPageHeader'
export interface Props extends Pick<RouteComponentProps, 'history' | 'location'> {
queryWebhookLogs?: typeof _queryWebhookLogs
}
export const WebhookLogPage: React.FunctionComponent<Props> = ({
history,
location,
queryWebhookLogs = _queryWebhookLogs,
}) => {
const [onlyErrors, setOnlyErrors] = useState(false)
const [externalService, setExternalService] = useState<SelectedExternalService>('all')
const query = useCallback(
({ first, after }: FilteredConnectionQueryArguments) =>
queryWebhookLogs(
{
first: first ?? null,
after: after ?? null,
},
externalService,
onlyErrors
),
[externalService, onlyErrors, queryWebhookLogs]
)
return (
<>
<PageTitle title="Incoming webhook logs" />
<PageHeader
headingElement="h2"
path={[{ text: 'Incoming webhook logs' }]}
description="Use these logs of received webhooks to debug integrations"
className="mb-3"
/>
<Container>
<WebhookLogPageHeader
onlyErrors={onlyErrors}
onSetOnlyErrors={setOnlyErrors}
externalService={externalService}
onSelectExternalService={setExternalService}
/>
<FilteredConnection
history={history}
location={location}
queryConnection={query}
nodeComponent={WebhookLogNode}
noun="webhook log"
pluralNoun="webhook logs"
hideSearch={true}
headComponent={Header}
listClassName={classNames('mt-3', styles.logs)}
emptyElement={<div className="m-4 w-100 text-center">No webhook logs found</div>}
/>
</Container>
</>
)
}
const Header: React.FunctionComponent<{}> = () => (
<>
<span className="d-none d-md-block" />
<h5 className="d-none d-md-block text-uppercase text-center text-nowrap">Status code</h5>
<h5 className="d-none d-md-block text-uppercase text-nowrap">External service</h5>
<h5 className="d-none d-md-block text-uppercase text-center text-nowrap">Received at</h5>
</>
)

View File

@ -0,0 +1,68 @@
@import 'wildcard/src/global-styles/breakpoints';
.grid {
display: grid;
grid-template-columns: max-content max-content 1fr max-content max-content;
column-gap: 1rem;
row-gap: 1rem;
> * {
grid-row: 1 / 2;
}
@media (--sm-breakpoint-down) {
grid-template-columns: 1fr max-content;
}
}
.icon {
fill: var(--danger) !important;
}
.icon.enabled {
fill: var(--light-text) !important;
}
.errors {
grid-column: 1 / 2;
@media (--sm-breakpoint-down) {
grid-column: 1 / 3;
grid-row: 1 / 2;
}
}
.services {
grid-column: 2 / 3;
@media (--sm-breakpoint-down) {
grid-column: 1 / 3;
grid-row: 2 / 3;
}
}
.select-service {
> div {
// The .form-group container on <Select /> brings in a 1em bottom
// margin, so we need to override that.
margin-bottom: 0;
}
grid-column: 4 / 5;
align-self: end;
@media (--sm-breakpoint-down) {
grid-column: 1 / 2;
grid-row: 3 / 4;
}
}
.error-button {
grid-column: 5 / 6;
align-self: end;
@media (--sm-breakpoint-down) {
grid-column: 2 / 3;
grid-row: 3 / 4;
}
}

View File

@ -0,0 +1,104 @@
import { number } from '@storybook/addon-knobs'
import { storiesOf } from '@storybook/react'
import React, { useState } from 'react'
import { MockedTestProvider } from '@sourcegraph/shared/src/testing/apollo'
import { WebStory } from '@sourcegraph/web/src/components/WebStory'
import { Container } from '@sourcegraph/wildcard'
import { SelectedExternalService } from './backend'
import { buildHeaderMock } from './story/fixtures'
import { WebhookLogPageHeader } from './WebhookLogPageHeader'
const { add } = storiesOf('web/site-admin/webhooks/WebhookLogPageHeader', module)
.addDecorator(story => (
<Container>
<div className="p-3 container">{story()}</div>
</Container>
))
.addParameters({
chromatic: {
viewports: [320, 576, 978, 1440],
},
})
// Create a component to handle the minimum state management required for a
// WebhookLogPageHeader.
const WebhookLogPageHeaderContainer: React.FunctionComponent<{
initialExternalService?: SelectedExternalService
initialOnlyErrors?: boolean
}> = ({ initialExternalService, initialOnlyErrors }) => {
const [onlyErrors, setOnlyErrors] = useState(initialOnlyErrors === true)
const [externalService, setExternalService] = useState(initialExternalService ?? 'all')
return (
<WebhookLogPageHeader
externalService={externalService}
onlyErrors={onlyErrors}
onSelectExternalService={setExternalService}
onSetOnlyErrors={setOnlyErrors}
/>
)
}
add('all zeroes', () => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(0, 0)}>
<WebhookLogPageHeaderContainer />
</MockedTestProvider>
)}
</WebStory>
))
add('external services', () => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(10, 0)}>
<WebhookLogPageHeaderContainer />
</MockedTestProvider>
)}
</WebStory>
))
add('external services and errors', () => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(20, 500)}>
<WebhookLogPageHeaderContainer />
</MockedTestProvider>
)}
</WebStory>
))
add('only errors turned on', () => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(20, 500)}>
<WebhookLogPageHeaderContainer initialOnlyErrors={true} />
</MockedTestProvider>
)}
</WebStory>
))
add('specific external service selected', () => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(20, 500)}>
<WebhookLogPageHeaderContainer
initialExternalService={number('selected external service', 2, { min: 0, max: 19 }).toString()}
/>
</MockedTestProvider>
)}
</WebStory>
))
add('unmatched external service selected', () => (
<WebStory>
{() => (
<MockedTestProvider mocks={buildHeaderMock(20, 500)}>
<WebhookLogPageHeaderContainer initialExternalService="unmatched" />
</MockedTestProvider>
)}
</WebStory>
))

View File

@ -0,0 +1,79 @@
import classNames from 'classnames'
import AlertCircleIcon from 'mdi-react/AlertCircleIcon'
import React, { useCallback } from 'react'
import { useQuery } from '@sourcegraph/shared/src/graphql/graphql'
import { Button, Select } from '@sourcegraph/wildcard'
import { WebhookLogPageHeaderResult } from '../../graphql-operations'
import { SelectedExternalService, WEBHOOK_LOG_PAGE_HEADER } from './backend'
import { PerformanceGauge } from './PerformanceGauge'
import styles from './WebhookLogPageHeader.module.scss'
export interface Props {
externalService: SelectedExternalService
onlyErrors: boolean
onSelectExternalService: (externalService: SelectedExternalService) => void
onSetOnlyErrors: (onlyErrors: boolean) => void
}
export const WebhookLogPageHeader: React.FunctionComponent<Props> = ({
externalService,
onlyErrors,
onSelectExternalService: onExternalServiceSelected,
onSetOnlyErrors: onSetErrors,
}) => {
const onErrorToggle = useCallback(() => onSetErrors(!onlyErrors), [onlyErrors, onSetErrors])
const onSelect = useCallback(
(value: string) => {
onExternalServiceSelected(value)
},
[onExternalServiceSelected]
)
const { data } = useQuery<WebhookLogPageHeaderResult>(WEBHOOK_LOG_PAGE_HEADER, {})
const errorCount = data?.webhookLogs.totalCount ?? 0
return (
<div className={styles.grid}>
<div className={styles.errors}>
<PerformanceGauge
count={data?.webhookLogs.totalCount}
countClassName={errorCount > 0 ? 'text-danger' : undefined}
label="recent error"
/>
</div>
<div className={styles.services}>
<PerformanceGauge count={data?.externalServices.totalCount} label="external service" />
</div>
<div className={styles.selectService}>
<Select
aria-label="External service"
className="mb-0"
onChange={({ target: { value } }) => onSelect(value)}
value={externalService}
>
<option key="all" value="all">
All webhooks
</option>
<option key="unmatched" value="unmatched">
Unmatched webhooks
</option>
{data?.externalServices.nodes.map(({ displayName, id }) => (
<option key={id} value={id}>
{displayName}
</option>
))}
</Select>
</div>
<div className={styles.errorButton}>
<Button variant="danger" onClick={onErrorToggle} outline={!onlyErrors}>
<AlertCircleIcon className={classNames('icon-inline', styles.icon, onlyErrors && styles.enabled)} />
<span className="ml-1">Only errors</span>
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { dataOrThrowErrors, gql } from '@sourcegraph/shared/src/graphql/graphql'
import { requestGraphQL } from '../../backend/graphql'
import {
Scalars,
ServiceWebhookLogsResult,
ServiceWebhookLogsVariables,
WebhookLogConnectionFields,
WebhookLogsResult,
WebhookLogsVariables,
} from '../../graphql-operations'
export type SelectedExternalService = 'unmatched' | 'all' | Scalars['ID']
export const queryWebhookLogs = (
{ first, after }: Pick<WebhookLogsVariables, 'first' | 'after'>,
externalService: SelectedExternalService,
onlyErrors: boolean
): Observable<WebhookLogConnectionFields> => {
const fragment = gql`
fragment WebhookLogConnectionFields on WebhookLogConnection {
nodes {
...WebhookLogFields
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
fragment WebhookLogFields on WebhookLog {
id
receivedAt
externalService {
displayName
}
statusCode
request {
...WebhookLogMessageFields
...WebhookLogRequestFields
}
response {
...WebhookLogMessageFields
}
}
fragment WebhookLogMessageFields on WebhookLogMessage {
headers {
name
values
}
body
}
fragment WebhookLogRequestFields on WebhookLogRequest {
method
url
version
}
`
if (externalService === 'all' || externalService === 'unmatched') {
return requestGraphQL<WebhookLogsResult, WebhookLogsVariables>(
gql`
query WebhookLogs($first: Int, $after: String, $onlyErrors: Boolean!, $onlyUnmatched: Boolean!) {
webhookLogs(first: $first, after: $after, onlyErrors: $onlyErrors, onlyUnmatched: $onlyUnmatched) {
...WebhookLogConnectionFields
}
}
${fragment}
`,
{
first,
after,
onlyErrors,
onlyUnmatched: externalService === 'unmatched',
}
).pipe(
map(dataOrThrowErrors),
map((result: WebhookLogsResult) => result.webhookLogs)
)
}
return requestGraphQL<ServiceWebhookLogsResult, ServiceWebhookLogsVariables>(
gql`
query ServiceWebhookLogs($first: Int, $after: String, $id: ID!, $onlyErrors: Boolean!) {
node(id: $id) {
... on ExternalService {
__typename
webhookLogs(first: $first, after: $after, onlyErrors: $onlyErrors) {
...WebhookLogConnectionFields
}
}
}
}
${fragment}
`,
{
first: first ?? null,
after: after ?? null,
onlyErrors,
id: externalService,
}
).pipe(
map(dataOrThrowErrors),
map(result => {
if (result.node?.__typename === 'ExternalService') {
return result.node.webhookLogs
}
throw new Error('unexpected non ExternalService node')
})
)
}
export const WEBHOOK_LOG_PAGE_HEADER = gql`
query WebhookLogPageHeader {
externalServices {
nodes {
...WebhookLogPageHeaderExternalService
}
totalCount
}
webhookLogs(onlyErrors: true) {
totalCount
}
}
fragment WebhookLogPageHeaderExternalService on ExternalService {
id
displayName
}
`

View File

@ -0,0 +1,7 @@
.count {
color: var(--danger);
}
.label {
font-style: italic;
}

View File

@ -0,0 +1,9 @@
import React from 'react'
import { PerformanceGauge, Props } from '../PerformanceGauge'
import styles from './StyledPerformanceGauge.module.scss'
export const StyledPerformanceGauge: React.FunctionComponent<
Exclude<Props, 'countClassName' | 'labelClassName'>
> = props => <PerformanceGauge countClassName={styles.count} labelClassName={styles.label} {...props} />

View File

@ -0,0 +1,98 @@
import { MockedResponse } from '@apollo/client/testing'
import { number, text } from '@storybook/addon-knobs'
import { getDocumentNode } from '@sourcegraph/shared/src/graphql/apollo'
import {
WebhookLogFields,
WebhookLogPageHeaderExternalService,
WebhookLogPageHeaderResult,
} from '../../../graphql-operations'
import { WEBHOOK_LOG_PAGE_HEADER } from '../backend'
export const BODY_JSON = '{"this is":"valid JSON","that should be":["re","indented"]}'
export const BODY_PLAIN = 'this is definitely not valid JSON\n\tand should not be reformatted in any way'
export const HEADERS_JSON = [
{
name: 'Content-Type',
values: ['application/json; charset=utf-8'],
},
{
name: 'Content-Length',
values: [BODY_JSON.length.toString()],
},
{
name: 'X-Complex-Header',
values: ['value 1', 'value 2'],
},
]
export const HEADERS_PLAIN = [
{
name: 'Content-Type',
values: ['text/plain'],
},
{
name: 'Content-Length',
values: [BODY_PLAIN.length.toString()],
},
{
name: 'X-Complex-Header',
values: ['value 1', 'value 2'],
},
]
export const webhookLogNode = (overrides?: Partial<WebhookLogFields>): WebhookLogFields => ({
id: overrides?.id ?? 'ID',
receivedAt: overrides?.receivedAt ?? text('received at', '2021-11-07T19:31:00Z'),
statusCode: overrides?.statusCode ?? number('status code', 204, { min: 100, max: 599 }),
externalService: overrides?.externalService ?? null,
request: overrides?.request ?? {
headers: HEADERS_JSON,
body: BODY_JSON,
method: 'POST',
url: '/my/url',
version: 'HTTP/1.1',
},
response: overrides?.response ?? {
headers: HEADERS_PLAIN,
body: BODY_PLAIN,
},
})
export const buildExternalServices = (count: number): WebhookLogPageHeaderExternalService[] => {
const services: WebhookLogPageHeaderExternalService[] = []
count = number('external service count', count)
for (let index = 0; index < count; index++) {
const name = `External service ${index}`
services.push({
__typename: 'ExternalService',
id: name,
displayName: name,
})
}
return services
}
export const buildHeaderMock = (
externalServiceCount: number,
webhookLogCount: number
): MockedResponse<WebhookLogPageHeaderResult>[] => [
{
request: { query: getDocumentNode(WEBHOOK_LOG_PAGE_HEADER) },
result: {
data: {
externalServices: {
totalCount: externalServiceCount,
nodes: buildExternalServices(externalServiceCount),
},
webhookLogs: {
totalCount: number('errored webhook count', webhookLogCount),
},
},
},
},
]

View File

@ -50,6 +50,7 @@ export const UserSettingsSidebar: React.FunctionComponent<UserSettingsSidebarPro
const context: UserSettingsSidebarItemConditionContext = {
batchChangesEnabled: props.batchChangesEnabled,
batchChangesExecutionEnabled: props.batchChangesExecutionEnabled,
batchChangesWebhookLogsEnabled: props.batchChangesWebhookLogsEnabled,
user: props.user,
authenticatedUser: props.authenticatedUser,
isSourcegraphDotCom: props.isSourcegraphDotCom,

View File

@ -19,6 +19,7 @@ import (
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/app/assetsutil"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/auth/userpasswd"
"github.com/sourcegraph/sourcegraph/cmd/frontend/internal/siteid"
"github.com/sourcegraph/sourcegraph/cmd/frontend/webhooks"
"github.com/sourcegraph/sourcegraph/internal/actor"
"github.com/sourcegraph/sourcegraph/internal/conf"
"github.com/sourcegraph/sourcegraph/internal/database/dbconn"
@ -88,6 +89,7 @@ type JSContext struct {
BatchChangesEnabled bool `json:"batchChangesEnabled"`
BatchChangesDisableWebhooksWarning bool `json:"batchChangesDisableWebhooksWarning"`
BatchChangesWebhookLogsEnabled bool `json:"batchChangesWebhookLogsEnabled"`
CodeIntelAutoIndexingEnabled bool `json:"codeIntelAutoIndexingEnabled"`
CodeIntelAutoIndexingAllowGlobalPolicies bool `json:"codeIntelAutoIndexingAllowGlobalPolicies"`
@ -188,6 +190,7 @@ func NewJSContextFromRequest(req *http.Request) JSContext {
BatchChangesEnabled: enterprise.BatchChangesEnabledForUser(req.Context(), dbconn.Global) == nil,
BatchChangesDisableWebhooksWarning: conf.Get().BatchChangesDisableWebhooksWarning,
BatchChangesWebhookLogsEnabled: webhooks.LoggingEnabled(conf.Get()),
CodeIntelAutoIndexingEnabled: conf.CodeIntelAutoIndexingEnabled(),
CodeIntelAutoIndexingAllowGlobalPolicies: conf.CodeIntelAutoIndexingAllowGlobalPolicies(),

View File

@ -39,7 +39,7 @@ func (mw *LogMiddleware) Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// If logging is disabled, we'll immediately forward to the next
// handler, turning this middleware into a no-op.
if !loggingEnabled(conf.Get()) {
if !LoggingEnabled(conf.Get()) {
next.ServeHTTP(w, r)
return
}
@ -143,7 +143,7 @@ func loggingEnabledByDefault(keys *schema.EncryptionKeys) bool {
return true
}
func loggingEnabled(c *conf.Unified) bool {
func LoggingEnabled(c *conf.Unified) bool {
if logging := c.WebhookLogging; logging != nil && logging.Enabled != nil {
return *logging.Enabled
}

View File

@ -171,7 +171,7 @@ func TestLoggingEnabled(t *testing.T) {
},
} {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tc.want, loggingEnabled(tc.c))
assert.Equal(t, tc.want, LoggingEnabled(tc.c))
})
}
}

View File

@ -376,6 +376,7 @@
"graphiql": "^1.3.2",
"highlight.js": "^10.5.0",
"highlightjs-graphql": "^1.0.2",
"http-status-codes": "^2.1.4",
"is-absolute-url": "^3.0.3",
"iterare": "^1.2.1",
"js-cookie": "^2.2.1",

View File

@ -13470,6 +13470,11 @@ http-signature@~1.2.0:
jsprim "^1.2.2"
sshpk "^1.7.0"
http-status-codes@^2.1.4:
version "2.1.4"
resolved "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.1.4.tgz#453d99b4bd9424254c4f6a9a3a03715923052798"
integrity sha512-MZVIsLKGVOVE1KEnldppe6Ij+vmemMuApDfjhVSLzyYP+td0bREEYyAoIw9yFePoBXManCuBqmiNP5FqJS5Xkg==
http2-wrapper@^1.0.0-beta.5.0:
version "1.0.0-beta.5.2"
resolved "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.0-beta.5.2.tgz#8b923deb90144aea65cf834b016a340fc98556f3"