mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
512 lines
14 KiB
TypeScript
512 lines
14 KiB
TypeScript
import {Fragment} from 'react';
|
||
import type {RouteComponentProps} from 'react-router';
|
||
import styled from '@emotion/styled';
|
||
import startCase from 'lodash/startCase';
|
||
|
||
import Access from 'sentry/components/acl/access';
|
||
import type {AlertProps} from 'sentry/components/alert';
|
||
import {Alert} from 'sentry/components/alert';
|
||
import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
|
||
import EmptyMessage from 'sentry/components/emptyMessage';
|
||
import ExternalLink from 'sentry/components/links/externalLink';
|
||
import Panel from 'sentry/components/panels/panel';
|
||
import Tag from 'sentry/components/tag';
|
||
import {Tooltip} from 'sentry/components/tooltip';
|
||
import {IconClose, IconDocs, IconGeneric, IconGithub, IconProject} from 'sentry/icons';
|
||
import {t} from 'sentry/locale';
|
||
import PluginIcon from 'sentry/plugins/components/pluginIcon';
|
||
import {space} from 'sentry/styles/space';
|
||
import type {
|
||
IntegrationFeature,
|
||
IntegrationInstallationStatus,
|
||
IntegrationType,
|
||
Organization,
|
||
} from 'sentry/types';
|
||
import type {
|
||
IntegrationAnalyticsKey,
|
||
IntegrationEventParameters,
|
||
} from 'sentry/utils/analytics/integrations';
|
||
import {
|
||
getCategories,
|
||
getIntegrationFeatureGate,
|
||
trackIntegrationAnalytics,
|
||
} from 'sentry/utils/integrationUtil';
|
||
import marked, {singleLineRenderer} from 'sentry/utils/marked';
|
||
import BreadcrumbTitle from 'sentry/views/settings/components/settingsBreadcrumb/breadcrumbTitle';
|
||
|
||
import RequestIntegrationButton from './integrationRequest/RequestIntegrationButton';
|
||
import IntegrationStatus from './integrationStatus';
|
||
|
||
export type Tab = 'overview' | 'configurations' | 'features';
|
||
|
||
interface AlertType extends AlertProps {
|
||
text: string;
|
||
}
|
||
|
||
type State = {
|
||
tab: Tab;
|
||
} & DeprecatedAsyncComponent['state'];
|
||
|
||
type Props = {
|
||
organization: Organization;
|
||
} & RouteComponentProps<{integrationSlug: string}, {}> &
|
||
DeprecatedAsyncComponent['props'];
|
||
|
||
class AbstractIntegrationDetailedView<
|
||
P extends Props = Props,
|
||
S extends State = State,
|
||
> extends DeprecatedAsyncComponent<P, S> {
|
||
tabs: Tab[] = ['overview', 'configurations'];
|
||
|
||
componentDidMount() {
|
||
super.componentDidMount();
|
||
const {location} = this.props;
|
||
const value = location.query.tab === 'configurations' ? 'configurations' : 'overview';
|
||
// eslint-disable-next-line react/no-did-mount-set-state
|
||
this.setState({tab: value});
|
||
}
|
||
|
||
onLoadAllEndpointsSuccess() {
|
||
this.trackIntegrationAnalytics('integrations.integration_viewed', {
|
||
integration_tab: this.state.tab,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Abstract methods defined below
|
||
*/
|
||
|
||
// The analytics type used in analytics which is snake case
|
||
get integrationType(): IntegrationType {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
get description(): string {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
get author(): string | undefined {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
get alerts(): AlertType[] {
|
||
// default is no alerts
|
||
return [];
|
||
}
|
||
|
||
// Returns a list of the resources displayed at the bottom of the overview card
|
||
get resourceLinks(): Array<{title: string; url: string}> {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
get installationStatus(): IntegrationInstallationStatus | null {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
get integrationName(): string {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
// Checks to see if integration requires admin access to install, doc integrations don't
|
||
get requiresAccess(): boolean {
|
||
// default is integration requires access to install
|
||
return true;
|
||
}
|
||
|
||
// Returns an array of RawIntegrationFeatures which is used in feature gating
|
||
// and displaying what the integration does
|
||
get featureData(): IntegrationFeature[] {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
getIcon(title: string) {
|
||
switch (title) {
|
||
case 'View Source':
|
||
return <IconProject />;
|
||
case 'Report Issue':
|
||
return <IconGithub />;
|
||
case 'Documentation':
|
||
case 'Splunk Setup Instructions':
|
||
case 'Trello Setup Instructions':
|
||
return <IconDocs />;
|
||
default:
|
||
return <IconGeneric />;
|
||
}
|
||
}
|
||
|
||
onTabChange = (value: Tab) => {
|
||
this.trackIntegrationAnalytics('integrations.integration_tab_clicked', {
|
||
integration_tab: value,
|
||
});
|
||
this.setState({tab: value});
|
||
};
|
||
|
||
// Returns the string that is shown as the title of a tab
|
||
getTabDisplay(tab: Tab): string {
|
||
// default is return the tab
|
||
return tab;
|
||
}
|
||
|
||
// Render the button at the top which is usually just an installation button
|
||
renderTopButton(
|
||
_disabledFromFeatures: boolean, // from the feature gate
|
||
_userHasAccess: boolean // from user permissions
|
||
): React.ReactElement {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
// Returns the permission descriptions, only use by Sentry Apps
|
||
renderPermissions(): React.ReactElement | null {
|
||
// default is don't render permissions
|
||
return null;
|
||
}
|
||
|
||
renderEmptyConfigurations() {
|
||
return (
|
||
<Panel>
|
||
<EmptyMessage
|
||
title={t("You haven't set anything up yet")}
|
||
description={t(
|
||
'But that doesn’t have to be the case for long! Add an installation to get started.'
|
||
)}
|
||
action={this.renderAddInstallButton(true)}
|
||
/>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
// Returns the list of configurations for the integration
|
||
renderConfigurations() {
|
||
// Allow children to implement this
|
||
throw new Error('Not implemented');
|
||
}
|
||
|
||
/**
|
||
* Actually implemented methods below
|
||
*/
|
||
|
||
get integrationSlug() {
|
||
return this.props.params.integrationSlug;
|
||
}
|
||
|
||
// Wrapper around trackIntegrationAnalytics that automatically provides many fields and the org
|
||
trackIntegrationAnalytics = <T extends IntegrationAnalyticsKey>(
|
||
eventKey: IntegrationAnalyticsKey,
|
||
options?: Partial<IntegrationEventParameters[T]>
|
||
) => {
|
||
options = options || {};
|
||
// If we use this intermediate type we get type checking on the things we care about
|
||
trackIntegrationAnalytics(eventKey, {
|
||
view: 'integrations_directory_integration_detail',
|
||
integration: this.integrationSlug,
|
||
integration_type: this.integrationType,
|
||
already_installed: this.installationStatus !== 'Not Installed', // pending counts as installed here
|
||
organization: this.props.organization,
|
||
...options,
|
||
});
|
||
};
|
||
|
||
// Returns the props as needed by the hooks integrations:feature-gates
|
||
get featureProps() {
|
||
const {organization} = this.props;
|
||
const featureData = this.featureData;
|
||
|
||
// Prepare the features list
|
||
const features = featureData.map(f => ({
|
||
featureGate: f.featureGate,
|
||
description: (
|
||
<FeatureListItem
|
||
dangerouslySetInnerHTML={{__html: singleLineRenderer(f.description)}}
|
||
/>
|
||
),
|
||
}));
|
||
|
||
return {organization, features};
|
||
}
|
||
|
||
cleanTags() {
|
||
return getCategories(this.featureData);
|
||
}
|
||
|
||
renderAlert(): React.ReactNode {
|
||
return null;
|
||
}
|
||
|
||
renderAdditionalCTA(): React.ReactNode {
|
||
return null;
|
||
}
|
||
|
||
renderIntegrationIcon() {
|
||
return <PluginIcon pluginId={this.integrationSlug} size={50} />;
|
||
}
|
||
|
||
renderRequestIntegrationButton() {
|
||
return (
|
||
<RequestIntegrationButton
|
||
organization={this.props.organization}
|
||
name={this.integrationName}
|
||
slug={this.integrationSlug}
|
||
type={this.integrationType}
|
||
/>
|
||
);
|
||
}
|
||
|
||
renderAddInstallButton(hideButtonIfDisabled = false) {
|
||
const {IntegrationFeatures} = getIntegrationFeatureGate();
|
||
|
||
return (
|
||
<IntegrationFeatures {...this.featureProps}>
|
||
{({disabled, disabledReason}) => (
|
||
<DisableWrapper>
|
||
<Access access={['org:integrations']}>
|
||
{({hasAccess}) => (
|
||
<Tooltip
|
||
title={t(
|
||
'You must be an organization owner, manager or admin to install this.'
|
||
)}
|
||
disabled={hasAccess || !this.requiresAccess}
|
||
>
|
||
{!hideButtonIfDisabled && disabled ? (
|
||
<div />
|
||
) : (
|
||
this.renderTopButton(disabled, hasAccess)
|
||
)}
|
||
</Tooltip>
|
||
)}
|
||
</Access>
|
||
{disabled && <DisabledNotice reason={disabledReason} />}
|
||
</DisableWrapper>
|
||
)}
|
||
</IntegrationFeatures>
|
||
);
|
||
}
|
||
|
||
// Returns the content shown in the top section of the integration detail
|
||
renderTopSection() {
|
||
const tags = this.cleanTags();
|
||
|
||
return (
|
||
<TopSectionWrapper>
|
||
<Flex>
|
||
{this.renderIntegrationIcon()}
|
||
<NameContainer>
|
||
<Flex>
|
||
<Name>{this.integrationName}</Name>
|
||
<StatusWrapper>
|
||
{this.installationStatus && (
|
||
<IntegrationStatus status={this.installationStatus} />
|
||
)}
|
||
</StatusWrapper>
|
||
</Flex>
|
||
<Flex>
|
||
{tags.map(feature => (
|
||
<StyledTag key={feature}>{startCase(feature)}</StyledTag>
|
||
))}
|
||
</Flex>
|
||
</NameContainer>
|
||
</Flex>
|
||
<Flex>
|
||
{this.renderAddInstallButton()}
|
||
{this.renderAdditionalCTA()}
|
||
</Flex>
|
||
</TopSectionWrapper>
|
||
);
|
||
}
|
||
|
||
// Returns the tabs divider with the clickable tabs
|
||
renderTabs() {
|
||
// TODO: Convert to styled component
|
||
return (
|
||
<ul className="nav nav-tabs border-bottom" style={{paddingTop: '30px'}}>
|
||
{this.tabs.map(tabName => (
|
||
<li
|
||
key={tabName}
|
||
className={this.state.tab === tabName ? 'active' : ''}
|
||
onClick={() => this.onTabChange(tabName)}
|
||
>
|
||
<CapitalizedLink>{this.getTabDisplay(tabName)}</CapitalizedLink>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
);
|
||
}
|
||
|
||
// Returns the information about the integration description and features
|
||
renderInformationCard() {
|
||
const {FeatureList} = getIntegrationFeatureGate();
|
||
|
||
return (
|
||
<Fragment>
|
||
<Flex>
|
||
<FlexContainer>
|
||
<Description dangerouslySetInnerHTML={{__html: marked(this.description)}} />
|
||
<FeatureList
|
||
{...this.featureProps}
|
||
provider={{key: this.props.params.integrationSlug}}
|
||
/>
|
||
{this.renderPermissions()}
|
||
{this.alerts.map((alert, i) => (
|
||
<Alert key={i} type={alert.type} showIcon>
|
||
<span
|
||
dangerouslySetInnerHTML={{__html: singleLineRenderer(alert.text)}}
|
||
/>
|
||
</Alert>
|
||
))}
|
||
</FlexContainer>
|
||
<Metadata>
|
||
{!!this.author && (
|
||
<AuthorInfo>
|
||
<CreatedContainer>{t('Created By')}</CreatedContainer>
|
||
<div>{this.author}</div>
|
||
</AuthorInfo>
|
||
)}
|
||
{this.resourceLinks.map(({title, url}) => (
|
||
<ExternalLinkContainer key={url}>
|
||
{this.getIcon(title)}
|
||
<ExternalLink href={url}>{title}</ExternalLink>
|
||
</ExternalLinkContainer>
|
||
))}
|
||
</Metadata>
|
||
</Flex>
|
||
</Fragment>
|
||
);
|
||
}
|
||
|
||
renderBody() {
|
||
return (
|
||
<Fragment>
|
||
<BreadcrumbTitle routes={this.props.routes} title={this.integrationName} />
|
||
{this.renderAlert()}
|
||
{this.renderTopSection()}
|
||
{this.renderTabs()}
|
||
{this.state.tab === 'overview'
|
||
? this.renderInformationCard()
|
||
: this.renderConfigurations()}
|
||
</Fragment>
|
||
);
|
||
}
|
||
}
|
||
|
||
const Flex = styled('div')`
|
||
display: flex;
|
||
align-items: center;
|
||
`;
|
||
|
||
const FlexContainer = styled('div')`
|
||
flex: 1;
|
||
`;
|
||
|
||
const CapitalizedLink = styled('a')`
|
||
text-transform: capitalize;
|
||
`;
|
||
|
||
const StyledTag = styled(Tag)`
|
||
text-transform: none;
|
||
&:not(:first-child) {
|
||
margin-left: ${space(0.5)};
|
||
}
|
||
`;
|
||
|
||
const NameContainer = styled('div')`
|
||
display: flex;
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
padding-left: ${space(2)};
|
||
`;
|
||
|
||
const Name = styled('div')`
|
||
font-weight: bold;
|
||
font-size: 1.4em;
|
||
margin-bottom: ${space(0.5)};
|
||
`;
|
||
|
||
const IconCloseCircle = styled(IconClose)`
|
||
color: ${p => p.theme.dangerText};
|
||
margin-right: ${space(1)};
|
||
`;
|
||
|
||
export const DisabledNotice = styled(({reason, ...p}: {reason: React.ReactNode}) => (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
}}
|
||
{...p}
|
||
>
|
||
<IconCloseCircle isCircled />
|
||
<span>{reason}</span>
|
||
</div>
|
||
))`
|
||
padding-top: ${space(0.5)};
|
||
font-size: 0.9em;
|
||
`;
|
||
|
||
const FeatureListItem = styled('span')`
|
||
line-height: 24px;
|
||
`;
|
||
|
||
const Description = styled('div')`
|
||
li {
|
||
margin-bottom: 6px;
|
||
}
|
||
`;
|
||
|
||
const Metadata = styled(Flex)`
|
||
display: grid;
|
||
grid-auto-rows: max-content;
|
||
grid-auto-flow: row;
|
||
gap: ${space(1)};
|
||
font-size: 0.9em;
|
||
margin-left: ${space(4)};
|
||
margin-right: 100px;
|
||
align-self: flex-start;
|
||
`;
|
||
|
||
const AuthorInfo = styled('div')`
|
||
margin-bottom: ${space(3)};
|
||
`;
|
||
|
||
const ExternalLinkContainer = styled('div')`
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
gap: ${space(1)};
|
||
align-items: center;
|
||
`;
|
||
|
||
const StatusWrapper = styled('div')`
|
||
margin-bottom: ${space(0.5)};
|
||
padding-left: ${space(2)};
|
||
`;
|
||
|
||
const DisableWrapper = styled('div')`
|
||
margin-left: auto;
|
||
align-self: center;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
`;
|
||
|
||
const CreatedContainer = styled('div')`
|
||
text-transform: uppercase;
|
||
padding-bottom: ${space(1)};
|
||
color: ${p => p.theme.gray300};
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
`;
|
||
|
||
const TopSectionWrapper = styled('div')`
|
||
display: flex;
|
||
justify-content: space-between;
|
||
`;
|
||
|
||
export default AbstractIntegrationDetailedView;
|