Files
2024-02-15 17:01:50 +00:00

333 lines
9.9 KiB
TypeScript

import {Fragment, useState} from 'react';
import type {InjectedRouter} from 'react-router';
import {browserHistory} from 'react-router';
import styled from '@emotion/styled';
import type {Location} from 'history';
import omit from 'lodash/omit';
import type {Client} from 'sentry/api';
import Feature from 'sentry/components/acl/feature';
import {Alert} from 'sentry/components/alert';
import ButtonBar from 'sentry/components/buttonBar';
import {getInterval} from 'sentry/components/charts/utils';
import {CreateAlertFromViewButton} from 'sentry/components/createAlertButton';
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
import {DropdownMenu} from 'sentry/components/dropdownMenu';
import SearchBar from 'sentry/components/events/searchBar';
import * as Layout from 'sentry/components/layouts/thirds';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
import * as TeamKeyTransactionManager from 'sentry/components/performance/teamKeyTransactionsManager';
import {IconCheckmark, IconClose} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {Organization, Project} from 'sentry/types';
import {trackAnalytics} from 'sentry/utils/analytics';
import {getUtcToLocalDateObject} from 'sentry/utils/dates';
import type EventView from 'sentry/utils/discover/eventView';
import {WebVital} from 'sentry/utils/fields';
import {Browser} from 'sentry/utils/performance/vitals/constants';
import {decodeScalar} from 'sentry/utils/queryString';
import Teams from 'sentry/utils/teams';
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
import withProjects from 'sentry/utils/withProjects';
import Breadcrumb from '../breadcrumb';
import {getTransactionSearchQuery} from '../utils';
import Table from './table';
import {
vitalAbbreviations,
vitalAlertTypes,
vitalDescription,
vitalMap,
vitalSupportedBrowsers,
} from './utils';
import VitalChart from './vitalChart';
import VitalInfo from './vitalInfo';
const FRONTEND_VITALS = [WebVital.FCP, WebVital.LCP, WebVital.FID, WebVital.CLS];
type Props = {
api: Client;
eventView: EventView;
location: Location;
organization: Organization;
projects: Project[];
router: InjectedRouter;
vitalName: WebVital;
};
function getSummaryConditions(query: string) {
const parsed = new MutableSearch(query);
parsed.freeText = [];
return parsed.formatString();
}
function VitalDetailContent(props: Props) {
const [error, setError] = useState<string | undefined>(undefined);
function handleSearch(query: string) {
const {location} = props;
const queryParams = normalizeDateTimeParams({
...(location.query || {}),
query,
});
// do not propagate pagination when making a new search
const searchQueryParams = omit(queryParams, 'cursor');
browserHistory.push({
pathname: location.pathname,
query: searchQueryParams,
});
}
function renderCreateAlertButton() {
const {eventView, organization, projects, vitalName} = props;
return (
<CreateAlertFromViewButton
eventView={eventView}
organization={organization}
projects={projects}
aria-label={t('Create Alert')}
alertType={vitalAlertTypes[vitalName]}
referrer="performance"
/>
);
}
function renderVitalSwitcher() {
const {vitalName, location, organization} = props;
const position = FRONTEND_VITALS.indexOf(vitalName);
if (position < 0) {
return null;
}
const items: MenuItemProps[] = FRONTEND_VITALS.reduce(
(acc: MenuItemProps[], newVitalName) => {
const itemProps = {
key: newVitalName,
label: vitalAbbreviations[newVitalName],
onAction: function switchWebVital() {
browserHistory.push({
pathname: location.pathname,
query: {
...location.query,
vitalName: newVitalName,
cursor: undefined,
},
});
trackAnalytics('performance_views.vital_detail.switch_vital', {
organization,
from_vital: vitalAbbreviations[vitalName] ?? 'undefined',
to_vital: vitalAbbreviations[newVitalName] ?? 'undefined',
});
},
};
if (vitalName === newVitalName) {
acc.unshift(itemProps);
} else {
acc.push(itemProps);
}
return acc;
},
[]
);
return (
<DropdownMenu
items={items}
triggerLabel={vitalAbbreviations[vitalName]}
triggerProps={{
'aria-label': `Web Vitals: ${vitalAbbreviations[vitalName]}`,
prefix: t('Web Vitals'),
}}
position="bottom-start"
/>
);
}
function renderError() {
if (!error) {
return null;
}
return (
<Alert type="error" showIcon>
{error}
</Alert>
);
}
function renderContent(vital: WebVital) {
const {location, organization, eventView, projects} = props;
const {fields, start, end, statsPeriod, environment, project} = eventView;
const query = decodeScalar(location.query.query, '');
const orgSlug = organization.slug;
const localDateStart = start ? getUtcToLocalDateObject(start) : null;
const localDateEnd = end ? getUtcToLocalDateObject(end) : null;
const interval = getInterval(
{start: localDateStart, end: localDateEnd, period: statsPeriod},
'high'
);
const filterString = getTransactionSearchQuery(location);
const summaryConditions = getSummaryConditions(filterString);
return (
<Fragment>
<FilterActions>
<PageFilterBar condensed>
<ProjectPageFilter />
<EnvironmentPageFilter />
<DatePageFilter />
</PageFilterBar>
<SearchBar
searchSource="performance_vitals"
organization={organization}
projectIds={project}
query={query}
fields={fields}
onSearch={handleSearch}
/>
</FilterActions>
<VitalChart
organization={organization}
query={query}
project={project}
environment={environment}
start={localDateStart}
end={localDateEnd}
statsPeriod={statsPeriod}
interval={interval}
/>
<StyledVitalInfo>
<VitalInfo
orgSlug={orgSlug}
location={location}
vital={vital}
project={project}
environment={environment}
start={start}
end={end}
statsPeriod={statsPeriod}
/>
</StyledVitalInfo>
<Teams provideUserTeams>
{({teams, initiallyLoaded}) =>
initiallyLoaded ? (
<TeamKeyTransactionManager.Provider
organization={organization}
teams={teams}
selectedTeams={['myteams']}
selectedProjects={project.map(String)}
>
<Table
eventView={eventView}
projects={projects}
organization={organization}
location={location}
setError={setError}
summaryConditions={summaryConditions}
/>
</TeamKeyTransactionManager.Provider>
) : (
<LoadingIndicator />
)
}
</Teams>
</Fragment>
);
}
const {location, organization, vitalName} = props;
const vital = vitalName || WebVital.LCP;
return (
<Fragment>
<Layout.Header>
<Layout.HeaderContent>
<Breadcrumb organization={organization} location={location} vitalName={vital} />
<Layout.Title>{vitalMap[vital]}</Layout.Title>
</Layout.HeaderContent>
<Layout.HeaderActions>
<ButtonBar gap={1}>
{renderVitalSwitcher()}
<Feature organization={organization} features="incidents">
{({hasFeature}) => hasFeature && renderCreateAlertButton()}
</Feature>
</ButtonBar>
</Layout.HeaderActions>
</Layout.Header>
<Layout.Body>
{renderError()}
<Layout.Main fullWidth>
<StyledDescription>{vitalDescription[vitalName]}</StyledDescription>
<SupportedBrowsers>
{Object.values(Browser).map(browser => (
<BrowserItem key={browser}>
{vitalSupportedBrowsers[vitalName]?.includes(browser) ? (
<IconCheckmark color="successText" size="sm" />
) : (
<IconClose color="dangerText" size="sm" />
)}
{browser}
</BrowserItem>
))}
</SupportedBrowsers>
{renderContent(vital)}
</Layout.Main>
</Layout.Body>
</Fragment>
);
}
export default withProjects(VitalDetailContent);
const StyledDescription = styled('div')`
font-size: ${p => p.theme.fontSizeMedium};
margin-bottom: ${space(3)};
`;
const StyledVitalInfo = styled('div')`
margin-bottom: ${space(3)};
`;
const SupportedBrowsers = styled('div')`
display: inline-flex;
gap: ${space(2)};
margin-bottom: ${space(3)};
`;
const BrowserItem = styled('div')`
display: flex;
align-items: center;
gap: ${space(1)};
`;
const FilterActions = styled('div')`
display: grid;
gap: ${space(2)};
margin-bottom: ${space(2)};
@media (min-width: ${p => p.theme.breakpoints.small}) {
grid-template-columns: auto 1fr;
}
`;