diff --git a/client/shared/src/highlight/contributions.ts b/client/shared/src/highlight/contributions.ts index df22c4bfde0..9c2b992394e 100644 --- a/client/shared/src/highlight/contributions.ts +++ b/client/shared/src/highlight/contributions.ts @@ -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 */ } diff --git a/client/web/dev/utils/create-js-context.ts b/client/web/dev/utils/create-js-context.ts index 974bb137af3..7e73fb789c3 100644 --- a/client/web/dev/utils/create-js-context.ts +++ b/client/web/dev/utils/create-js-context.ts @@ -26,6 +26,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st allowSignup: true, batchChangesEnabled: true, batchChangesDisableWebhooksWarning: false, + batchChangesWebhookLogsEnabled: true, codeIntelAutoIndexingEnabled: false, codeIntelAutoIndexingAllowGlobalPolicies: false, externalServicesUserMode: 'public', diff --git a/client/web/src/SourcegraphWebApp.tsx b/client/web/src/SourcegraphWebApp.tsx index d5f9b20fc6c..c6563ffb8ad 100644 --- a/client/web/src/SourcegraphWebApp.tsx +++ b/client/web/src/SourcegraphWebApp.tsx @@ -456,6 +456,9 @@ export class SourcegraphWebApp extends React.Component 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 { diff --git a/client/web/src/enterprise/site-admin/sidebaritems.ts b/client/web/src/enterprise/site-admin/sidebaritems.ts index 5bd77ea0d33..cbecf8125ee 100644 --- a/client/web/src/enterprise/site-admin/sidebaritems.ts +++ b/client/web/src/enterprise/site-admin/sidebaritems.ts @@ -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, } diff --git a/client/web/src/integration/jscontext.ts b/client/web/src/integration/jscontext.ts index 24c7bf09ba7..5dcb12c9ec7 100644 --- a/client/web/src/integration/jscontext.ts +++ b/client/web/src/integration/jscontext.ts @@ -18,6 +18,7 @@ export const createJsContext = ({ sourcegraphBaseUrl }: { sourcegraphBaseUrl: st allowSignup: false, batchChangesEnabled: true, batchChangesDisableWebhooksWarning: false, + batchChangesWebhookLogsEnabled: true, codeIntelAutoIndexingEnabled: true, codeIntelAutoIndexingAllowGlobalPolicies: true, externalServicesUserMode: 'disabled', diff --git a/client/web/src/jscontext.ts b/client/web/src/jscontext.ts index d98e11b52df..a1961fa42ed 100644 --- a/client/web/src/jscontext.ts +++ b/client/web/src/jscontext.ts @@ -98,6 +98,8 @@ export interface SourcegraphContext extends Pick, 'e * Changes. */ batchChangesDisableWebhooksWarning: boolean + batchChangesWebhookLogsEnabled: boolean + /** Whether the code intel auto-indexer feature is enabled on the site. */ codeIntelAutoIndexingEnabled: boolean diff --git a/client/web/src/nav/GlobalNavbar.story.tsx b/client/web/src/nav/GlobalNavbar.story.tsx index 46a2b7af41f..21e43279c59 100644 --- a/client/web/src/nav/GlobalNavbar.story.tsx +++ b/client/web/src/nav/GlobalNavbar.story.tsx @@ -62,6 +62,7 @@ const defaultProps = ( searchContextsEnabled: true, batchChangesEnabled: true, batchChangesExecutionEnabled: true, + batchChangesWebhookLogsEnabled: true, enableCodeMonitoring: true, activation: undefined, routes: [], diff --git a/client/web/src/nav/GlobalNavbar.test.tsx b/client/web/src/nav/GlobalNavbar.test.tsx index fd6d2b3bcac..2049b3bccda 100644 --- a/client/web/src/nav/GlobalNavbar.test.tsx +++ b/client/web/src/nav/GlobalNavbar.test.tsx @@ -39,6 +39,7 @@ const PROPS: React.ComponentProps = { settingsCascade: NOOP_SETTINGS_CASCADE, batchChangesEnabled: false, batchChangesExecutionEnabled: false, + batchChangesWebhookLogsEnabled: false, enableCodeMonitoring: false, telemetryService: {} as any, isExtensionAlertAnimating: false, diff --git a/client/web/src/org/area/OrgArea.tsx b/client/web/src/org/area/OrgArea.tsx index c73a5db0311..a7ffd786527 100644 --- a/client/web/src/org/area/OrgArea.tsx +++ b/client/web/src/org/area/OrgArea.tsx @@ -234,6 +234,7 @@ export class OrgArea extends React.Component { 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, diff --git a/client/web/src/org/area/OrgHeader.tsx b/client/web/src/org/area/OrgHeader.tsx index b0dae3831d9..4a6fd1aa4db 100644 --- a/client/web/src/org/area/OrgHeader.tsx +++ b/client/web/src/org/area/OrgHeader.tsx @@ -27,6 +27,7 @@ export interface OrgAreaHeaderNavItem extends NavItemWithIconDescriptor = ({ batchChangesEnabled, batchChangesExecutionEnabled, + batchChangesWebhookLogsEnabled, org, navItems, match, @@ -63,6 +64,7 @@ export const OrgHeader: React.FunctionComponent = ({ condition({ batchChangesEnabled, batchChangesExecutionEnabled, + batchChangesWebhookLogsEnabled, org, isSourcegraphDotCom, }) && ( diff --git a/client/web/src/site-admin/SiteAdminArea.tsx b/client/web/src/site-admin/SiteAdminArea.tsx index 27a5bc73ba6..d5efdfb663f 100644 --- a/client/web/src/site-admin/SiteAdminArea.tsx +++ b/client/web/src/site-admin/SiteAdminArea.tsx @@ -88,6 +88,7 @@ const AuthenticatedSiteAdminArea: React.FunctionComponent = 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 = isSourcegraphDotCom={props.isSourcegraphDotCom} batchChangesEnabled={props.batchChangesEnabled} batchChangesExecutionEnabled={props.batchChangesExecutionEnabled} + batchChangesWebhookLogsEnabled={props.batchChangesWebhookLogsEnabled} />
diff --git a/client/web/src/site-admin/SiteAdminSidebar.story.tsx b/client/web/src/site-admin/SiteAdminSidebar.story.tsx index 6f89378bcbe..eba0312c19d 100644 --- a/client/web/src/site-admin/SiteAdminSidebar.story.tsx +++ b/client/web/src/site-admin/SiteAdminSidebar.story.tsx @@ -22,6 +22,7 @@ add( isSourcegraphDotCom={false} batchChangesEnabled={false} batchChangesExecutionEnabled={false} + batchChangesWebhookLogsEnabled={false} /> )} diff --git a/client/web/src/site-admin/webhooks/MessagePanel.module.scss b/client/web/src/site-admin/webhooks/MessagePanel.module.scss new file mode 100644 index 00000000000..0ea13a4d109 --- /dev/null +++ b/client/web/src/site-admin/webhooks/MessagePanel.module.scss @@ -0,0 +1,3 @@ +.message-body { + max-height: 25rem; +} diff --git a/client/web/src/site-admin/webhooks/MessagePanel.story.tsx b/client/web/src/site-admin/webhooks/MessagePanel.story.tsx new file mode 100644 index 00000000000..9a4e55cab10 --- /dev/null +++ b/client/web/src/site-admin/webhooks/MessagePanel.story.tsx @@ -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 =>
{story()}
) + .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`, () => ( + + {() => ( + + )} + + )) + + add(`${name} response`, () => ( + + {() => ( + + )} + + )) +} diff --git a/client/web/src/site-admin/webhooks/MessagePanel.tsx b/client/web/src/site-admin/webhooks/MessagePanel.tsx new file mode 100644 index 00000000000..1d68e49b352 --- /dev/null +++ b/client/web/src/site-admin/webhooks/MessagePanel.tsx @@ -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 = ({ 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 ( +
+ + +
+ ) +} diff --git a/client/web/src/site-admin/webhooks/PerformanceGauge.module.scss b/client/web/src/site-admin/webhooks/PerformanceGauge.module.scss new file mode 100644 index 00000000000..1e0298a5eaf --- /dev/null +++ b/client/web/src/site-admin/webhooks/PerformanceGauge.module.scss @@ -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; +} diff --git a/client/web/src/site-admin/webhooks/PerformanceGauge.story.tsx b/client/web/src/site-admin/webhooks/PerformanceGauge.story.tsx new file mode 100644 index 00000000000..58e188d3626 --- /dev/null +++ b/client/web/src/site-admin/webhooks/PerformanceGauge.story.tsx @@ -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 =>
{story()}
) + .addParameters({ + chromatic: { + viewports: [576], + }, + }) + +add('loading', () => {() => }) +add('zero', () => {() => }) +add('zero with explicit plural', () => ( + {() => } +)) +add('one', () => {() => }) +add('many', () => {() => }) +add('many with explicit plural', () => ( + {() => } +)) +add('class overrides', () => {() => }) diff --git a/client/web/src/site-admin/webhooks/PerformanceGauge.tsx b/client/web/src/site-admin/webhooks/PerformanceGauge.tsx new file mode 100644 index 00000000000..d992defbb27 --- /dev/null +++ b/client/web/src/site-admin/webhooks/PerformanceGauge.tsx @@ -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 = ({ + count, + className, + countClassName, + label, + labelClassName, + plural, +}) => ( +
+ {count === undefined ? ( + + ) : ( + {count} + )} + {pluralize(label, count ?? 0, plural)} +
+) diff --git a/client/web/src/site-admin/webhooks/StatusCode.story.tsx b/client/web/src/site-admin/webhooks/StatusCode.story.tsx new file mode 100644 index 00000000000..bab08dfedb0 --- /dev/null +++ b/client/web/src/site-admin/webhooks/StatusCode.story.tsx @@ -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 =>
{story()}
) + .addParameters({ + chromatic: { + viewports: [576], + }, + }) + +add('success', () => {() => }) +add('failure', () => {() => }) diff --git a/client/web/src/site-admin/webhooks/StatusCode.tsx b/client/web/src/site-admin/webhooks/StatusCode.tsx new file mode 100644 index 00000000000..2072e17222e --- /dev/null +++ b/client/web/src/site-admin/webhooks/StatusCode.tsx @@ -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 = ({ code }) => ( + + + {code < 400 ? ( + + ) : ( + + )} + + {code} + +) diff --git a/client/web/src/site-admin/webhooks/WebhookLogNode.module.scss b/client/web/src/site-admin/webhooks/WebhookLogNode.module.scss new file mode 100644 index 00000000000..298d0bec3eb --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogNode.module.scss @@ -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%; + } + } +} diff --git a/client/web/src/site-admin/webhooks/WebhookLogNode.story.tsx b/client/web/src/site-admin/webhooks/WebhookLogNode.story.tsx new file mode 100644 index 00000000000..ee400ff5451 --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogNode.story.tsx @@ -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 => ( + +
{story()}
+
+ )) + .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', () => ( + + {() => ( + + )} + +)) +add('expanded request', () => ( + {() => } +)) +add('expanded response', () => ( + {() => } +)) diff --git a/client/web/src/site-admin/webhooks/WebhookLogNode.tsx b/client/web/src/site-admin/webhooks/WebhookLogNode.tsx new file mode 100644 index 00000000000..22aa8bb57a7 --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogNode.tsx @@ -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 = ({ + initiallyExpanded, + initialTabIndex, + node: { externalService, receivedAt, request, response, statusCode }, +}) => { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded === true) + const toggleExpanded = useCallback(() => setIsExpanded(!isExpanded), [isExpanded]) + + return ( + <> + + + + + + + + + {externalService ? externalService.displayName : Unmatched} + + {format(Date.parse(receivedAt), 'Ppp')} + + + + {isExpanded && ( +
+ + + Request + Response + + + + + + + + + + +
+ )} + + ) +} diff --git a/client/web/src/site-admin/webhooks/WebhookLogPage.module.scss b/client/web/src/site-admin/webhooks/WebhookLogPage.module.scss new file mode 100644 index 00000000000..2e39f0118f5 --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogPage.module.scss @@ -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; + } +} diff --git a/client/web/src/site-admin/webhooks/WebhookLogPage.story.tsx b/client/web/src/site-admin/webhooks/WebhookLogPage.story.tsx new file mode 100644 index 00000000000..1105a0c83aa --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogPage.story.tsx @@ -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 =>
{story()}
) + .addParameters({ + chromatic: { + viewports: [320, 576, 978, 1440], + }, + }) + +const buildQueryWebhookLogs: (logs: WebhookLogFields[]) => typeof queryWebhookLogs = logs => ( + { first, after }: Pick, + 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', () => ( + + {props => ( + + + + )} + +)) + +add('one page of logs', () => ( + + {props => ( + + + + )} + +)) + +add('two pages of logs', () => ( + + {props => ( + + + + )} + +)) diff --git a/client/web/src/site-admin/webhooks/WebhookLogPage.tsx b/client/web/src/site-admin/webhooks/WebhookLogPage.tsx new file mode 100644 index 00000000000..853e31d2af0 --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogPage.tsx @@ -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 { + queryWebhookLogs?: typeof _queryWebhookLogs +} + +export const WebhookLogPage: React.FunctionComponent = ({ + history, + location, + queryWebhookLogs = _queryWebhookLogs, +}) => { + const [onlyErrors, setOnlyErrors] = useState(false) + const [externalService, setExternalService] = useState('all') + + const query = useCallback( + ({ first, after }: FilteredConnectionQueryArguments) => + queryWebhookLogs( + { + first: first ?? null, + after: after ?? null, + }, + externalService, + onlyErrors + ), + [externalService, onlyErrors, queryWebhookLogs] + ) + + return ( + <> + + + + + No webhook logs found
} + /> + + + ) +} + +const Header: React.FunctionComponent<{}> = () => ( + <> + +
Status code
+
External service
+
Received at
+ +) diff --git a/client/web/src/site-admin/webhooks/WebhookLogPageHeader.module.scss b/client/web/src/site-admin/webhooks/WebhookLogPageHeader.module.scss new file mode 100644 index 00000000000..a7d91aacde6 --- /dev/null +++ b/client/web/src/site-admin/webhooks/WebhookLogPageHeader.module.scss @@ -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 onSelect(value)} + value={externalService} + > + + + {data?.externalServices.nodes.map(({ displayName, id }) => ( + + ))} + + +
+ +
+ + ) +} diff --git a/client/web/src/site-admin/webhooks/backend.ts b/client/web/src/site-admin/webhooks/backend.ts new file mode 100644 index 00000000000..f9e2103cf7c --- /dev/null +++ b/client/web/src/site-admin/webhooks/backend.ts @@ -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, + externalService: SelectedExternalService, + onlyErrors: boolean +): Observable => { + 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( + 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( + 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 + } +` diff --git a/client/web/src/site-admin/webhooks/story/StyledPerformanceGauge.module.scss b/client/web/src/site-admin/webhooks/story/StyledPerformanceGauge.module.scss new file mode 100644 index 00000000000..f7bd0ede38b --- /dev/null +++ b/client/web/src/site-admin/webhooks/story/StyledPerformanceGauge.module.scss @@ -0,0 +1,7 @@ +.count { + color: var(--danger); +} + +.label { + font-style: italic; +} diff --git a/client/web/src/site-admin/webhooks/story/StyledPerformanceGauge.tsx b/client/web/src/site-admin/webhooks/story/StyledPerformanceGauge.tsx new file mode 100644 index 00000000000..a1d6b384473 --- /dev/null +++ b/client/web/src/site-admin/webhooks/story/StyledPerformanceGauge.tsx @@ -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 => diff --git a/client/web/src/site-admin/webhooks/story/fixtures.ts b/client/web/src/site-admin/webhooks/story/fixtures.ts new file mode 100644 index 00000000000..b3c334c4306 --- /dev/null +++ b/client/web/src/site-admin/webhooks/story/fixtures.ts @@ -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 => ({ + 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[] => [ + { + request: { query: getDocumentNode(WEBHOOK_LOG_PAGE_HEADER) }, + result: { + data: { + externalServices: { + totalCount: externalServiceCount, + nodes: buildExternalServices(externalServiceCount), + }, + webhookLogs: { + totalCount: number('errored webhook count', webhookLogCount), + }, + }, + }, + }, +] diff --git a/client/web/src/user/settings/UserSettingsSidebar.tsx b/client/web/src/user/settings/UserSettingsSidebar.tsx index 656232ec4b9..f50e7f9f792 100644 --- a/client/web/src/user/settings/UserSettingsSidebar.tsx +++ b/client/web/src/user/settings/UserSettingsSidebar.tsx @@ -50,6 +50,7 @@ export const UserSettingsSidebar: React.FunctionComponent