mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-10-30 14:49:14 +00:00
503 lines
14 KiB
TypeScript
503 lines
14 KiB
TypeScript
import {Component} from 'react';
|
|
import type {Theme} from '@emotion/react';
|
|
import {withTheme} from '@emotion/react';
|
|
import styled from '@emotion/styled';
|
|
import type {Location} from 'history';
|
|
import isEqual from 'lodash/isEqual';
|
|
import throttle from 'lodash/throttle';
|
|
|
|
import {Button} from 'sentry/components/button';
|
|
import type {BarChartSeries} from 'sentry/components/charts/barChart';
|
|
import {BarChart} from 'sentry/components/charts/barChart';
|
|
import BarChartZoom from 'sentry/components/charts/barChartZoom';
|
|
import MarkLine from 'sentry/components/charts/components/markLine';
|
|
import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
|
|
import Placeholder from 'sentry/components/placeholder';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {Organization} from 'sentry/types';
|
|
import {trackAnalytics} from 'sentry/utils/analytics';
|
|
import type EventView from 'sentry/utils/discover/eventView';
|
|
import {getAggregateAlias} from 'sentry/utils/discover/fields';
|
|
import type {WebVital} from 'sentry/utils/fields';
|
|
import {formatAbbreviatedNumber, formatFloat, getDuration} from 'sentry/utils/formatters';
|
|
import getDynamicText from 'sentry/utils/getDynamicText';
|
|
import type {DataFilter, HistogramData} from 'sentry/utils/performance/histogram/types';
|
|
import {
|
|
computeBuckets,
|
|
formatHistogramData,
|
|
} from 'sentry/utils/performance/histogram/utils';
|
|
import type {Vital} from 'sentry/utils/performance/vitals/types';
|
|
import type {VitalData} from 'sentry/utils/performance/vitals/vitalsCardsDiscoverQuery';
|
|
import {MutableSearch} from 'sentry/utils/tokenizeSearch';
|
|
import {EventsDisplayFilterName} from 'sentry/views/performance/transactionSummary/transactionEvents/utils';
|
|
|
|
import {VitalBar} from '../../landing/vitalsCards';
|
|
import {
|
|
VitalState,
|
|
vitalStateColors,
|
|
webVitalMeh,
|
|
webVitalPoor,
|
|
} from '../../vitalDetail/utils';
|
|
|
|
import {NUM_BUCKETS, PERCENTILE} from './constants';
|
|
import {Card, CardSectionHeading, CardSummary, Description, StatNumber} from './styles';
|
|
import type {Rectangle} from './types';
|
|
import {asPixelRect, findNearestBucketIndex, getRefRect, mapPoint} from './utils';
|
|
|
|
type Props = {
|
|
chartData: HistogramData;
|
|
colors: [string];
|
|
error: boolean;
|
|
eventView: EventView;
|
|
isLoading: boolean;
|
|
location: Location;
|
|
organization: Organization;
|
|
summaryData: VitalData | null;
|
|
theme: Theme;
|
|
vital: WebVital;
|
|
vitalDetails: Vital;
|
|
dataFilter?: DataFilter;
|
|
max?: number;
|
|
min?: number;
|
|
precision?: number;
|
|
};
|
|
|
|
type State = {
|
|
/**
|
|
* This is a pair of reference points on the graph that we can use to map any
|
|
* other points to their pixel coordinates on the graph.
|
|
*
|
|
* The x values here are the index of the cooresponding bucket and the y value
|
|
* are the respective counts.
|
|
*
|
|
* Invariances:
|
|
* - refDataRect.point1.x < refDataRect.point2.x
|
|
* - refDataRect.point1.y < refDataRect.point2.y
|
|
*/
|
|
refDataRect: Rectangle | null;
|
|
/**
|
|
* This is the cooresponding pixel coordinate of the references points from refDataRect.
|
|
*
|
|
* ECharts' pixel coordinates are relative to the top left whereas the axis coordinates
|
|
* used here are relative to the bottom right. Because of this and the invariances imposed
|
|
* on refDataRect, these points have the difference invariances.
|
|
*
|
|
* Invariances:
|
|
* - refPixelRect.point1.x < refPixelRect.point2.x
|
|
* - refPixelRect.point1.y > refPixelRect.point2.y
|
|
*/
|
|
refPixelRect: Rectangle | null;
|
|
};
|
|
|
|
class VitalCard extends Component<Props, State> {
|
|
state: State = {
|
|
refDataRect: null,
|
|
refPixelRect: null,
|
|
};
|
|
|
|
static getDerivedStateFromProps(nextProps: Readonly<Props>, prevState: State) {
|
|
const {isLoading, error, chartData} = nextProps;
|
|
|
|
if (isLoading || error === null) {
|
|
return {...prevState};
|
|
}
|
|
|
|
const refDataRect = getRefRect(chartData);
|
|
if (
|
|
prevState.refDataRect === null ||
|
|
(refDataRect !== null && !isEqual(refDataRect, prevState.refDataRect))
|
|
) {
|
|
return {
|
|
...prevState,
|
|
refDataRect,
|
|
};
|
|
}
|
|
|
|
return {...prevState};
|
|
}
|
|
|
|
trackOpenInDiscoverClicked = () => {
|
|
const {organization} = this.props;
|
|
const {vitalDetails: vital} = this.props;
|
|
trackAnalytics('performance_views.vitals.open_in_discover', {
|
|
organization,
|
|
vital: vital.slug,
|
|
});
|
|
};
|
|
|
|
trackOpenAllEventsClicked = () => {
|
|
const {organization} = this.props;
|
|
const {vitalDetails: vital} = this.props;
|
|
trackAnalytics('performance_views.vitals.open_all_events', {
|
|
organization,
|
|
vital: vital.slug,
|
|
});
|
|
};
|
|
|
|
get summary() {
|
|
const {summaryData} = this.props;
|
|
return summaryData?.p75 ?? null;
|
|
}
|
|
|
|
get failureRate() {
|
|
const {summaryData} = this.props;
|
|
const numerator = summaryData?.poor ?? 0;
|
|
const denominator = summaryData?.total ?? 0;
|
|
return denominator <= 0 ? 0 : numerator / denominator;
|
|
}
|
|
|
|
getFormattedStatNumber() {
|
|
const {vitalDetails: vital} = this.props;
|
|
const summary = this.summary;
|
|
const {type} = vital;
|
|
|
|
return summary === null
|
|
? '\u2014'
|
|
: type === 'duration'
|
|
? getDuration(summary / 1000, 2, true)
|
|
: formatFloat(summary, 2);
|
|
}
|
|
|
|
renderSummary() {
|
|
const {
|
|
vitalDetails: vital,
|
|
eventView,
|
|
organization,
|
|
min,
|
|
max,
|
|
dataFilter,
|
|
} = this.props;
|
|
const {slug, name, description} = vital;
|
|
|
|
const column = `measurements.${slug}`;
|
|
|
|
const newEventView = eventView
|
|
.withColumns([
|
|
{kind: 'field', field: 'transaction'},
|
|
{
|
|
kind: 'function',
|
|
function: ['percentile', column, PERCENTILE.toString(), undefined],
|
|
},
|
|
{kind: 'function', function: ['count', '', '', undefined]},
|
|
])
|
|
.withSorts([
|
|
{
|
|
kind: 'desc',
|
|
field: getAggregateAlias(`percentile(${column},${PERCENTILE.toString()})`),
|
|
},
|
|
]);
|
|
|
|
const query = new MutableSearch(newEventView.query ?? '');
|
|
query.addFilterValues('has', [column]);
|
|
// add in any range constraints if any
|
|
if (min !== undefined || max !== undefined) {
|
|
if (min !== undefined) {
|
|
query.addFilterValues(column, [`>=${min}`]);
|
|
}
|
|
if (max !== undefined) {
|
|
query.addFilterValues(column, [`<=${max}`]);
|
|
}
|
|
}
|
|
newEventView.query = query.formatString();
|
|
|
|
return (
|
|
<CardSummary>
|
|
<SummaryHeading>
|
|
<CardSectionHeading>{`${name} (${slug.toUpperCase()})`}</CardSectionHeading>
|
|
</SummaryHeading>
|
|
<StatNumber>
|
|
{getDynamicText({
|
|
value: this.getFormattedStatNumber(),
|
|
fixed: '\u2014',
|
|
})}
|
|
</StatNumber>
|
|
<Description>{description}</Description>
|
|
<div>
|
|
<Button
|
|
size="xs"
|
|
to={newEventView
|
|
.withColumns([{kind: 'field', field: column}])
|
|
.withSorts([{kind: 'desc', field: column}])
|
|
.getPerformanceTransactionEventsViewUrlTarget(organization.slug, {
|
|
showTransactions:
|
|
dataFilter === 'all'
|
|
? EventsDisplayFilterName.P100
|
|
: EventsDisplayFilterName.P75,
|
|
webVital: column as WebVital,
|
|
})}
|
|
onClick={this.trackOpenAllEventsClicked}
|
|
>
|
|
{t('View Sampled Events')}
|
|
</Button>
|
|
</div>
|
|
</CardSummary>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* This callback happens everytime ECharts renders. This is NOT when ECharts
|
|
* finishes rendering, so it can be called quite frequently. The calculations
|
|
* here can get expensive if done frequently, furthermore, this can trigger a
|
|
* state change leading to a re-render. So slow down the updates here as they
|
|
* do not need to be updated every single time.
|
|
*/
|
|
handleRendered = throttle(
|
|
(_, chartRef) => {
|
|
const {chartData} = this.props;
|
|
const {refDataRect} = this.state;
|
|
|
|
if (refDataRect === null || chartData.length < 1) {
|
|
return;
|
|
}
|
|
|
|
const refPixelRect =
|
|
refDataRect === null ? null : asPixelRect(chartRef, refDataRect!);
|
|
if (refPixelRect !== null && !isEqual(refPixelRect, this.state.refPixelRect)) {
|
|
this.setState({refPixelRect});
|
|
}
|
|
},
|
|
200,
|
|
{leading: true}
|
|
);
|
|
|
|
handleDataZoomCancelled = () => {};
|
|
|
|
renderHistogram() {
|
|
const {
|
|
theme,
|
|
location,
|
|
isLoading,
|
|
chartData,
|
|
summaryData,
|
|
error,
|
|
colors,
|
|
vital,
|
|
vitalDetails,
|
|
precision = 0,
|
|
} = this.props;
|
|
const {slug} = vitalDetails;
|
|
|
|
const series = this.getSeries();
|
|
|
|
const xAxis = {
|
|
type: 'category' as const,
|
|
truncate: true,
|
|
axisTick: {
|
|
alignWithLabel: true,
|
|
},
|
|
};
|
|
|
|
const values = series.data.map(point => point.value);
|
|
const max = values.length ? Math.max(...values) : undefined;
|
|
|
|
const yAxis = {
|
|
type: 'value' as const,
|
|
max,
|
|
axisLabel: {
|
|
color: theme.chartLabel,
|
|
formatter: (value: string | number) => formatAbbreviatedNumber(value),
|
|
},
|
|
};
|
|
|
|
const allSeries = [series];
|
|
if (!isLoading && !error) {
|
|
const baselineSeries = this.getBaselineSeries();
|
|
if (baselineSeries !== null) {
|
|
allSeries.push(baselineSeries);
|
|
}
|
|
}
|
|
|
|
const vitalData =
|
|
!isLoading && !error && summaryData !== null ? {[vital]: summaryData} : {};
|
|
|
|
return (
|
|
<BarChartZoom
|
|
minZoomWidth={10 ** -precision * NUM_BUCKETS}
|
|
location={location}
|
|
paramStart={`${slug}Start`}
|
|
paramEnd={`${slug}End`}
|
|
xAxisIndex={[0]}
|
|
buckets={computeBuckets(chartData)}
|
|
onDataZoomCancelled={this.handleDataZoomCancelled}
|
|
>
|
|
{zoomRenderProps => (
|
|
<Container>
|
|
<TransparentLoadingMask visible={isLoading} />
|
|
<PercentContainer>
|
|
<VitalBar
|
|
isLoading={isLoading}
|
|
data={vitalData}
|
|
vital={vital}
|
|
showBar={false}
|
|
showStates={false}
|
|
showVitalPercentNames={false}
|
|
showVitalThresholds={false}
|
|
showDurationDetail={false}
|
|
/>
|
|
</PercentContainer>
|
|
{getDynamicText({
|
|
value: (
|
|
<BarChart
|
|
series={allSeries}
|
|
xAxis={xAxis}
|
|
yAxis={yAxis}
|
|
colors={colors}
|
|
onRendered={this.handleRendered}
|
|
grid={{
|
|
left: space(3),
|
|
right: space(3),
|
|
top: space(3),
|
|
bottom: space(1.5),
|
|
}}
|
|
stacked
|
|
{...zoomRenderProps}
|
|
/>
|
|
),
|
|
fixed: <Placeholder testId="skeleton-ui" height="200px" />,
|
|
})}
|
|
</Container>
|
|
)}
|
|
</BarChartZoom>
|
|
);
|
|
}
|
|
|
|
bucketWidth() {
|
|
const {chartData} = this.props;
|
|
// We can assume that all buckets are of equal width, use the first two
|
|
// buckets to get the width. The value of each histogram function indicates
|
|
// the beginning of the bucket.
|
|
return chartData.length >= 2 ? chartData[1].bin - chartData[0].bin : 0;
|
|
}
|
|
|
|
getSeries() {
|
|
const {theme, chartData, precision, vitalDetails, vital} = this.props;
|
|
|
|
const additionalFieldsFn = bucket => {
|
|
return {
|
|
itemStyle: {color: theme[this.getVitalsColor(vital, bucket)]},
|
|
};
|
|
};
|
|
|
|
const data = formatHistogramData(chartData, {
|
|
precision: precision === 0 ? undefined : precision,
|
|
type: vitalDetails.type,
|
|
additionalFieldsFn,
|
|
});
|
|
|
|
return {
|
|
seriesName: t('Count'),
|
|
data,
|
|
};
|
|
}
|
|
|
|
getVitalsColor(vital: WebVital, value: number) {
|
|
const poorThreshold = webVitalPoor[vital];
|
|
const mehThreshold = webVitalMeh[vital];
|
|
|
|
if (value >= poorThreshold) {
|
|
return vitalStateColors[VitalState.POOR];
|
|
}
|
|
if (value >= mehThreshold) {
|
|
return vitalStateColors[VitalState.MEH];
|
|
}
|
|
return vitalStateColors[VitalState.GOOD];
|
|
}
|
|
|
|
getBaselineSeries(): BarChartSeries | null {
|
|
const {theme, chartData} = this.props;
|
|
const summary = this.summary;
|
|
if (summary === null || this.state.refPixelRect === null) {
|
|
return null;
|
|
}
|
|
|
|
const summaryBucket = findNearestBucketIndex(chartData, summary);
|
|
if (summaryBucket === null || summaryBucket === -1) {
|
|
return null;
|
|
}
|
|
|
|
const thresholdPixelBottom = mapPoint(
|
|
{
|
|
// subtract 0.5 from the x here to ensure that the threshold lies between buckets
|
|
x: summaryBucket - 0.5,
|
|
y: 0,
|
|
},
|
|
this.state.refDataRect!,
|
|
this.state.refPixelRect!
|
|
);
|
|
if (thresholdPixelBottom === null) {
|
|
return null;
|
|
}
|
|
|
|
const thresholdPixelTop = mapPoint(
|
|
{
|
|
// subtract 0.5 from the x here to ensure that the threshold lies between buckets
|
|
x: summaryBucket - 0.5,
|
|
y: Math.max(...chartData.map(data => data.count)) || 1,
|
|
},
|
|
this.state.refDataRect!,
|
|
this.state.refPixelRect!
|
|
);
|
|
if (thresholdPixelTop === null) {
|
|
return null;
|
|
}
|
|
|
|
const markLine = MarkLine({
|
|
animationDuration: 200,
|
|
data: [[thresholdPixelBottom, thresholdPixelTop] as any],
|
|
label: {
|
|
show: false,
|
|
},
|
|
lineStyle: {
|
|
color: theme.textColor,
|
|
type: 'solid',
|
|
},
|
|
tooltip: {
|
|
formatter: () => {
|
|
return [
|
|
'<div class="tooltip-series tooltip-series-solo">',
|
|
'<span class="tooltip-label">',
|
|
`<strong>${t('p75')}</strong>`,
|
|
'</span>',
|
|
'</div>',
|
|
'<div class="tooltip-arrow"></div>',
|
|
].join('');
|
|
},
|
|
},
|
|
});
|
|
|
|
return {
|
|
seriesName: t('p75'),
|
|
data: [],
|
|
markLine,
|
|
};
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<Card>
|
|
{this.renderSummary()}
|
|
{this.renderHistogram()}
|
|
</Card>
|
|
);
|
|
}
|
|
}
|
|
|
|
const SummaryHeading = styled('div')`
|
|
display: flex;
|
|
justify-content: space-between;
|
|
`;
|
|
|
|
const Container = styled('div')`
|
|
position: relative;
|
|
`;
|
|
|
|
const PercentContainer = styled('div')`
|
|
position: absolute;
|
|
top: ${space(2)};
|
|
right: ${space(3)};
|
|
z-index: 2;
|
|
`;
|
|
|
|
export default withTheme(VitalCard);
|