mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
FSL-1.0-Apache-2.0 grants us the code, not the name. "Sentry" is Functional Software's trademark, so every place the running app says it to a user -- page titles, onboarding, empty states, toasts, settings copy, the self-hosted footer -- now says Sentinel. Attribution is the other half of the deal and stays untouched: upstream copyright headers, LICENSE, NOTICE. So does anything where the mark is a fact rather than a brand -- @sentry/* packages, sentry-cli, the wizard and bundler plugins, docs.sentry.io, the whole gettingStartedDocs tree. Renaming those would leave instructions that no longer work. Nothing structural moved. No import, identifier, route path, feature flag, API field or test id changed; the diff is 312 lines in and 312 out. Colocated specs were updated in lockstep with the copy they assert. Marketing and community links point at hanzo.ai, only where a real page exists to point at. Co-authored-by: Hanzo Dev <[email protected]>
360 lines
9.9 KiB
TypeScript
360 lines
9.9 KiB
TypeScript
import {Fragment} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import memoize from 'lodash/memoize';
|
|
import type moment from 'moment-timezone';
|
|
|
|
import {Button, StyledButton} from 'sentry/components/button';
|
|
import Checkbox from 'sentry/components/checkbox';
|
|
import {CompactSelect} from 'sentry/components/compactSelect';
|
|
import DateTime from 'sentry/components/dateTime';
|
|
import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
|
|
import EmptyMessage from 'sentry/components/emptyMessage';
|
|
import ExternalLink from 'sentry/components/links/externalLink';
|
|
import LoadingIndicator from 'sentry/components/loadingIndicator';
|
|
import Panel from 'sentry/components/panels/panel';
|
|
import PanelBody from 'sentry/components/panels/panelBody';
|
|
import PanelHeader from 'sentry/components/panels/panelHeader';
|
|
import PanelItem from 'sentry/components/panels/panelItem';
|
|
import Tag from 'sentry/components/tag';
|
|
import {IconChevron, IconFlag, IconOpen} from 'sentry/icons';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {
|
|
SentryApp,
|
|
SentryAppSchemaIssueLink,
|
|
SentryAppWebhookRequest,
|
|
} from 'sentry/types';
|
|
import {shouldUse24Hours} from 'sentry/utils/dates';
|
|
|
|
const ALL_EVENTS = t('All Events');
|
|
const MAX_PER_PAGE = 10;
|
|
const is24Hours = shouldUse24Hours();
|
|
|
|
const componentHasSelectUri = (issueLinkComponent: SentryAppSchemaIssueLink): boolean => {
|
|
const hasSelectUri = (fields: any[]): boolean =>
|
|
fields.some(field => field.type === 'select' && 'uri' in field);
|
|
|
|
const createHasSelectUri =
|
|
hasSelectUri(issueLinkComponent.create.required_fields) ||
|
|
hasSelectUri(issueLinkComponent.create.optional_fields || []);
|
|
|
|
const linkHasSelectUri =
|
|
hasSelectUri(issueLinkComponent.link.required_fields) ||
|
|
hasSelectUri(issueLinkComponent.link.optional_fields || []);
|
|
|
|
return createHasSelectUri || linkHasSelectUri;
|
|
};
|
|
|
|
const getEventTypes = memoize((app: SentryApp) => {
|
|
// TODO(nola): ideally this would be kept in sync with EXTENDED_VALID_EVENTS on the backend
|
|
|
|
let issueLinkEvents: string[] = [];
|
|
const issueLinkComponent = (app.schema.elements || []).find(
|
|
element => element.type === 'issue-link'
|
|
);
|
|
if (issueLinkComponent) {
|
|
issueLinkEvents = ['external_issue.created', 'external_issue.linked'];
|
|
if (componentHasSelectUri(issueLinkComponent as SentryAppSchemaIssueLink)) {
|
|
issueLinkEvents.push('select_options.requested');
|
|
}
|
|
}
|
|
|
|
const events = [
|
|
ALL_EVENTS,
|
|
// Internal apps don't have installation webhooks
|
|
...(app.status !== 'internal'
|
|
? ['installation.created', 'installation.deleted']
|
|
: []),
|
|
...(app.events.includes('error') ? ['error.created'] : []),
|
|
...(app.events.includes('issue')
|
|
? ['issue.created', 'issue.resolved', 'issue.ignored', 'issue.assigned']
|
|
: []),
|
|
...(app.isAlertable
|
|
? [
|
|
'event_alert.triggered',
|
|
'metric_alert.open',
|
|
'metric_alert.resolved',
|
|
'metric_alert.critical',
|
|
'metric_alert.warning',
|
|
]
|
|
: []),
|
|
...issueLinkEvents,
|
|
];
|
|
|
|
return events;
|
|
});
|
|
|
|
function ResponseCode({code}: {code: number}) {
|
|
let type: React.ComponentProps<typeof Tag>['type'] = 'error';
|
|
if (code <= 399 && code >= 300) {
|
|
type = 'warning';
|
|
} else if (code <= 299 && code >= 100) {
|
|
type = 'success';
|
|
}
|
|
|
|
return (
|
|
<Tags>
|
|
<StyledTag type={type}>{code === 0 ? 'timeout' : code}</StyledTag>
|
|
</Tags>
|
|
);
|
|
}
|
|
|
|
function TimestampLink({date, link}: {date: moment.MomentInput; link?: string}) {
|
|
return link ? (
|
|
<ExternalLink href={link}>
|
|
<DateTime date={date} />
|
|
<StyledIconOpen size="xs" />
|
|
</ExternalLink>
|
|
) : (
|
|
<DateTime date={date} format={is24Hours ? 'MMM D, YYYY HH:mm:ss z' : 'll LTS z'} />
|
|
);
|
|
}
|
|
|
|
type Props = DeprecatedAsyncComponent['props'] & {
|
|
app: SentryApp;
|
|
};
|
|
|
|
type State = DeprecatedAsyncComponent['state'] & {
|
|
currentPage: number;
|
|
errorsOnly: boolean;
|
|
eventType: string;
|
|
requests: SentryAppWebhookRequest[];
|
|
};
|
|
|
|
export default class RequestLog extends DeprecatedAsyncComponent<Props, State> {
|
|
shouldReload = true;
|
|
|
|
get hasNextPage() {
|
|
return (this.state.currentPage + 1) * MAX_PER_PAGE < this.state.requests.length;
|
|
}
|
|
|
|
get hasPrevPage() {
|
|
return this.state.currentPage > 0;
|
|
}
|
|
|
|
getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
|
|
const {slug} = this.props.app;
|
|
|
|
const query: any = {};
|
|
if (this.state) {
|
|
if (this.state.eventType !== ALL_EVENTS) {
|
|
query.eventType = this.state.eventType;
|
|
}
|
|
if (this.state.errorsOnly) {
|
|
query.errorsOnly = true;
|
|
}
|
|
}
|
|
|
|
return [['requests', `/sentry-apps/${slug}/requests/`, {query}]];
|
|
}
|
|
|
|
getDefaultState() {
|
|
return {
|
|
...super.getDefaultState(),
|
|
requests: [],
|
|
eventType: ALL_EVENTS,
|
|
errorsOnly: false,
|
|
currentPage: 0,
|
|
};
|
|
}
|
|
|
|
handleChangeEventType = (eventType: string) => {
|
|
this.setState(
|
|
{
|
|
eventType,
|
|
currentPage: 0,
|
|
},
|
|
this.remountComponent
|
|
);
|
|
};
|
|
|
|
handleChangeErrorsOnly = () => {
|
|
this.setState(
|
|
{
|
|
errorsOnly: !this.state.errorsOnly,
|
|
currentPage: 0,
|
|
},
|
|
this.remountComponent
|
|
);
|
|
};
|
|
|
|
handleNextPage = () => {
|
|
this.setState({
|
|
currentPage: this.state.currentPage + 1,
|
|
});
|
|
};
|
|
|
|
handlePrevPage = () => {
|
|
this.setState({
|
|
currentPage: this.state.currentPage - 1,
|
|
});
|
|
};
|
|
|
|
renderLoading() {
|
|
return this.renderBody();
|
|
}
|
|
|
|
renderBody() {
|
|
const {requests, eventType, errorsOnly, currentPage} = this.state;
|
|
const {app} = this.props;
|
|
|
|
const currentRequests = requests.slice(
|
|
currentPage * MAX_PER_PAGE,
|
|
(currentPage + 1) * MAX_PER_PAGE
|
|
);
|
|
|
|
return (
|
|
<Fragment>
|
|
<h5>{t('Request Log')}</h5>
|
|
|
|
<div>
|
|
<p>
|
|
{t(
|
|
'This log shows the status of any outgoing webhook requests from Sentinel to your integration.'
|
|
)}
|
|
</p>
|
|
|
|
<RequestLogFilters>
|
|
<CompactSelect
|
|
triggerLabel={eventType}
|
|
value={eventType}
|
|
options={getEventTypes(app).map(type => ({
|
|
value: type,
|
|
label: type,
|
|
}))}
|
|
onChange={opt => this.handleChangeEventType(opt?.value)}
|
|
/>
|
|
|
|
<StyledErrorsOnlyButton onClick={this.handleChangeErrorsOnly}>
|
|
<ErrorsOnlyCheckbox>
|
|
<Checkbox checked={errorsOnly} onChange={() => {}} />
|
|
{t('Errors Only')}
|
|
</ErrorsOnlyCheckbox>
|
|
</StyledErrorsOnlyButton>
|
|
</RequestLogFilters>
|
|
</div>
|
|
|
|
<Panel>
|
|
<PanelHeader>
|
|
<TableLayout hasOrganization={app.status !== 'internal'}>
|
|
<div>{t('Time')}</div>
|
|
<div>{t('Status Code')}</div>
|
|
{app.status !== 'internal' && <div>{t('Organization')}</div>}
|
|
<div>{t('Event Type')}</div>
|
|
<div>{t('Webhook URL')}</div>
|
|
</TableLayout>
|
|
</PanelHeader>
|
|
|
|
{!this.state.loading ? (
|
|
<PanelBody>
|
|
{currentRequests.length > 0 ? (
|
|
currentRequests.map((request, idx) => (
|
|
<PanelItem key={idx} data-test-id="request-item">
|
|
<TableLayout hasOrganization={app.status !== 'internal'}>
|
|
<TimestampLink date={request.date} link={request.errorUrl} />
|
|
<ResponseCode code={request.responseCode} />
|
|
{app.status !== 'internal' && (
|
|
<div>
|
|
{request.organization ? request.organization.name : null}
|
|
</div>
|
|
)}
|
|
<div>{request.eventType}</div>
|
|
<OverflowBox>{request.webhookUrl}</OverflowBox>
|
|
</TableLayout>
|
|
</PanelItem>
|
|
))
|
|
) : (
|
|
<EmptyMessage icon={<IconFlag size="xl" />}>
|
|
{t('No requests found in the last 30 days.')}
|
|
</EmptyMessage>
|
|
)}
|
|
</PanelBody>
|
|
) : (
|
|
<LoadingIndicator />
|
|
)}
|
|
</Panel>
|
|
|
|
<PaginationButtons>
|
|
<Button
|
|
icon={<IconChevron direction="left" />}
|
|
onClick={this.handlePrevPage}
|
|
disabled={!this.hasPrevPage}
|
|
aria-label={t('Previous page')}
|
|
/>
|
|
<Button
|
|
icon={<IconChevron direction="right" />}
|
|
onClick={this.handleNextPage}
|
|
disabled={!this.hasNextPage}
|
|
aria-label={t('Next page')}
|
|
/>
|
|
</PaginationButtons>
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
const TableLayout = styled('div')<{hasOrganization: boolean}>`
|
|
display: grid;
|
|
grid-template-columns: 1fr 0.5fr ${p => (p.hasOrganization ? '1fr' : '')} 1fr 1fr;
|
|
grid-column-gap: ${space(1.5)};
|
|
width: 100%;
|
|
align-items: center;
|
|
`;
|
|
|
|
const OverflowBox = styled('div')`
|
|
word-break: break-word;
|
|
`;
|
|
|
|
const PaginationButtons = styled('div')`
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
|
|
> :first-child {
|
|
border-top-right-radius: 0;
|
|
border-bottom-right-radius: 0;
|
|
}
|
|
|
|
> :nth-child(2) {
|
|
margin-left: -1px;
|
|
border-top-left-radius: 0;
|
|
border-bottom-left-radius: 0;
|
|
}
|
|
`;
|
|
|
|
const RequestLogFilters = styled('div')`
|
|
display: flex;
|
|
align-items: center;
|
|
padding-bottom: ${space(1)};
|
|
|
|
> :first-child ${StyledButton} {
|
|
border-radius: ${p => p.theme.borderRadiusLeft};
|
|
}
|
|
`;
|
|
|
|
const ErrorsOnlyCheckbox = styled('div')`
|
|
display: flex;
|
|
gap: ${space(1)};
|
|
align-items: center;
|
|
`;
|
|
|
|
const StyledErrorsOnlyButton = styled(Button)`
|
|
margin-left: -1px;
|
|
border-top-left-radius: 0;
|
|
border-bottom-left-radius: 0;
|
|
`;
|
|
|
|
const StyledIconOpen = styled(IconOpen)`
|
|
margin-left: 6px;
|
|
color: ${p => p.theme.subText};
|
|
`;
|
|
|
|
const Tags = styled('div')`
|
|
margin: -${space(0.5)};
|
|
`;
|
|
|
|
const StyledTag = styled(Tag)`
|
|
padding: ${space(0.5)};
|
|
display: inline-flex;
|
|
`;
|