mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 17:31:43 +00:00
Webhook log frontend (#27179)
This commit is contained in:
parent
f75ccb58c3
commit
ab24484a85
@ -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 */
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st
|
||||
allowSignup: true,
|
||||
batchChangesEnabled: true,
|
||||
batchChangesDisableWebhooksWarning: false,
|
||||
batchChangesWebhookLogsEnabled: true,
|
||||
codeIntelAutoIndexingEnabled: false,
|
||||
codeIntelAutoIndexingAllowGlobalPolicies: false,
|
||||
externalServicesUserMode: 'public',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -19,4 +19,5 @@ export const isBatchChangesExecutionEnabled = (settingsCascade: SettingsCascadeO
|
||||
export interface BatchChangesProps {
|
||||
batchChangesExecutionEnabled: boolean
|
||||
batchChangesEnabled: boolean
|
||||
batchChangesWebhookLogsEnabled: boolean
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st
|
||||
allowSignup: false,
|
||||
batchChangesEnabled: true,
|
||||
batchChangesDisableWebhooksWarning: false,
|
||||
batchChangesWebhookLogsEnabled: true,
|
||||
codeIntelAutoIndexingEnabled: true,
|
||||
codeIntelAutoIndexingAllowGlobalPolicies: true,
|
||||
externalServicesUserMode: 'disabled',
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ const defaultProps = (
|
||||
searchContextsEnabled: true,
|
||||
batchChangesEnabled: true,
|
||||
batchChangesExecutionEnabled: true,
|
||||
batchChangesWebhookLogsEnabled: true,
|
||||
enableCodeMonitoring: true,
|
||||
activation: undefined,
|
||||
routes: [],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}) && (
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -22,6 +22,7 @@ add(
|
||||
isSourcegraphDotCom={false}
|
||||
batchChangesEnabled={false}
|
||||
batchChangesExecutionEnabled={false}
|
||||
batchChangesWebhookLogsEnabled={false}
|
||||
/>
|
||||
)}
|
||||
</WebStory>
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
.message-body {
|
||||
max-height: 25rem;
|
||||
}
|
||||
56
client/web/src/site-admin/webhooks/MessagePanel.story.tsx
Normal file
56
client/web/src/site-admin/webhooks/MessagePanel.story.tsx
Normal 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>
|
||||
))
|
||||
}
|
||||
65
client/web/src/site-admin/webhooks/MessagePanel.tsx
Normal file
65
client/web/src/site-admin/webhooks/MessagePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>)
|
||||
38
client/web/src/site-admin/webhooks/PerformanceGauge.tsx
Normal file
38
client/web/src/site-admin/webhooks/PerformanceGauge.tsx
Normal 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)}>…</span>
|
||||
) : (
|
||||
<span className={classNames(styles.count, countClassName)}>{count}</span>
|
||||
)}
|
||||
<span className={classNames('text-muted', labelClassName)}>{pluralize(label, count ?? 0, plural)}</span>
|
||||
</div>
|
||||
)
|
||||
18
client/web/src/site-admin/webhooks/StatusCode.story.tsx
Normal file
18
client/web/src/site-admin/webhooks/StatusCode.story.tsx
Normal 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>)
|
||||
21
client/web/src/site-admin/webhooks/StatusCode.tsx
Normal file
21
client/web/src/site-admin/webhooks/StatusCode.tsx
Normal 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>
|
||||
)
|
||||
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
50
client/web/src/site-admin/webhooks/WebhookLogNode.story.tsx
Normal file
50
client/web/src/site-admin/webhooks/WebhookLogNode.story.tsx
Normal 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>
|
||||
))
|
||||
85
client/web/src/site-admin/webhooks/WebhookLogNode.tsx
Normal file
85
client/web/src/site-admin/webhooks/WebhookLogNode.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
130
client/web/src/site-admin/webhooks/WebhookLogPage.story.tsx
Normal file
130
client/web/src/site-admin/webhooks/WebhookLogPage.story.tsx
Normal 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>
|
||||
))
|
||||
80
client/web/src/site-admin/webhooks/WebhookLogPage.tsx
Normal file
80
client/web/src/site-admin/webhooks/WebhookLogPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
))
|
||||
79
client/web/src/site-admin/webhooks/WebhookLogPageHeader.tsx
Normal file
79
client/web/src/site-admin/webhooks/WebhookLogPageHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
client/web/src/site-admin/webhooks/backend.ts
Normal file
139
client/web/src/site-admin/webhooks/backend.ts
Normal 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
|
||||
}
|
||||
`
|
||||
@ -0,0 +1,7 @@
|
||||
.count {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-style: italic;
|
||||
}
|
||||
@ -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} />
|
||||
98
client/web/src/site-admin/webhooks/story/fixtures.ts
Normal file
98
client/web/src/site-admin/webhooks/story/fixtures.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user