mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
483 lines
13 KiB
TypeScript
483 lines
13 KiB
TypeScript
import {Fragment} from 'react';
|
|
import styled from '@emotion/styled';
|
|
import * as Sentry from '@sentry/react';
|
|
import type {LocationDescriptorObject} from 'history';
|
|
import isEqual from 'lodash/isEqual';
|
|
|
|
import type {DateTimeObject} from 'sentry/components/charts/utils';
|
|
import {getSeriesApiInterval} from 'sentry/components/charts/utils';
|
|
import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
|
|
import type {Alignments, Directions} from 'sentry/components/gridEditable/sortLink';
|
|
import SortLink from 'sentry/components/gridEditable/sortLink';
|
|
import Pagination from 'sentry/components/pagination';
|
|
import SearchBar from 'sentry/components/searchBar';
|
|
import {DATA_CATEGORY_INFO, DEFAULT_STATS_PERIOD} from 'sentry/constants';
|
|
import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
|
|
import {t} from 'sentry/locale';
|
|
import {space} from 'sentry/styles/space';
|
|
import type {DataCategoryInfo, Organization, Project} from 'sentry/types';
|
|
import {Outcome} from 'sentry/types';
|
|
import withProjects from 'sentry/utils/withProjects';
|
|
|
|
import type {UsageSeries} from './types';
|
|
import type {TableStat} from './usageTable';
|
|
import UsageTable, {CellProject, CellStat} from './usageTable';
|
|
import {getOffsetFromCursor, getPaginationPageLink} from './utils';
|
|
|
|
type Props = {
|
|
dataCategory: DataCategoryInfo;
|
|
dataCategoryName: string;
|
|
dataDatetime: DateTimeObject;
|
|
getNextLocations: (project: Project) => Record<string, LocationDescriptorObject>;
|
|
handleChangeState: (
|
|
nextState: {
|
|
cursor?: string;
|
|
query?: string;
|
|
sort?: string;
|
|
},
|
|
options?: {willUpdateRouter?: boolean}
|
|
) => LocationDescriptorObject;
|
|
isSingleProject: boolean;
|
|
loadingProjects: boolean;
|
|
organization: Organization;
|
|
projectIds: number[];
|
|
projects: Project[];
|
|
tableCursor?: string;
|
|
tableQuery?: string;
|
|
tableSort?: string;
|
|
} & DeprecatedAsyncComponent['props'];
|
|
|
|
type State = {
|
|
projectStats: UsageSeries | undefined;
|
|
} & DeprecatedAsyncComponent['state'];
|
|
|
|
export enum SortBy {
|
|
PROJECT = 'project',
|
|
TOTAL = 'total',
|
|
ACCEPTED = 'accepted',
|
|
FILTERED = 'filtered',
|
|
DROPPED = 'dropped',
|
|
INVALID = 'invalid',
|
|
RATE_LIMITED = 'rate_limited',
|
|
}
|
|
|
|
class UsageStatsProjects extends DeprecatedAsyncComponent<Props, State> {
|
|
static MAX_ROWS_USAGE_TABLE = 25;
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
const {
|
|
dataDatetime: prevDateTime,
|
|
dataCategory: prevDataCategory,
|
|
projectIds: prevProjectIds,
|
|
} = prevProps;
|
|
const {
|
|
dataDatetime: currDateTime,
|
|
dataCategory: currDataCategory,
|
|
projectIds: currProjectIds,
|
|
} = this.props;
|
|
|
|
if (
|
|
prevDateTime.start !== currDateTime.start ||
|
|
prevDateTime.end !== currDateTime.end ||
|
|
prevDateTime.period !== currDateTime.period ||
|
|
prevDateTime.utc !== currDateTime.utc ||
|
|
prevDataCategory !== currDataCategory ||
|
|
!isEqual(prevProjectIds, currProjectIds)
|
|
) {
|
|
this.reloadData();
|
|
}
|
|
}
|
|
|
|
getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
|
|
return [['projectStats', this.endpointPath, {query: this.endpointQuery}]];
|
|
}
|
|
|
|
get endpointPath() {
|
|
const {organization} = this.props;
|
|
return `/organizations/${organization.slug}/stats_v2/`;
|
|
}
|
|
|
|
get endpointQuery() {
|
|
const {dataDatetime, dataCategory, projectIds, isSingleProject} = this.props;
|
|
|
|
const queryDatetime =
|
|
dataDatetime.start && dataDatetime.end
|
|
? {
|
|
start: dataDatetime.start,
|
|
end: dataDatetime.end,
|
|
utc: dataDatetime.utc,
|
|
}
|
|
: {
|
|
statsPeriod: dataDatetime.period || DEFAULT_STATS_PERIOD,
|
|
};
|
|
|
|
// We do not need more granularity in the data so interval is '1d'
|
|
return {
|
|
...queryDatetime,
|
|
interval: getSeriesApiInterval(dataDatetime),
|
|
groupBy: ['outcome', 'project'],
|
|
field: ['sum(quantity)'],
|
|
// If only one project is in selected, display the entire project list
|
|
project: isSingleProject ? [ALL_ACCESS_PROJECTS] : projectIds,
|
|
category: dataCategory.apiName,
|
|
};
|
|
}
|
|
|
|
get tableData() {
|
|
const {projectStats} = this.state;
|
|
|
|
return {
|
|
headers: this.tableHeader,
|
|
...this.mapSeriesToTable(projectStats),
|
|
};
|
|
}
|
|
|
|
get tableSort(): {
|
|
direction: number;
|
|
key: SortBy;
|
|
} {
|
|
const {tableSort} = this.props;
|
|
|
|
if (!tableSort) {
|
|
return {
|
|
key: SortBy.TOTAL,
|
|
direction: 1,
|
|
};
|
|
}
|
|
|
|
let key: string = tableSort;
|
|
let direction: number = -1;
|
|
|
|
if (tableSort.charAt(0) === '-') {
|
|
key = key.slice(1);
|
|
direction = 1;
|
|
}
|
|
|
|
switch (key) {
|
|
case SortBy.PROJECT:
|
|
case SortBy.TOTAL:
|
|
case SortBy.ACCEPTED:
|
|
case SortBy.FILTERED:
|
|
case SortBy.DROPPED:
|
|
return {key, direction};
|
|
default:
|
|
return {key: SortBy.ACCEPTED, direction: -1};
|
|
}
|
|
}
|
|
|
|
get tableOffset() {
|
|
const {tableCursor} = this.props;
|
|
return getOffsetFromCursor(tableCursor);
|
|
}
|
|
|
|
/**
|
|
* OrganizationStatsEndpointV2 does not have any performance issues. We use
|
|
* client-side pagination to limit the number of rows on the table so the
|
|
* page doesn't scroll too deeply for organizations with a lot of projects
|
|
*/
|
|
get pageLink() {
|
|
const offset = this.tableOffset;
|
|
const numRows = this.filteredProjects.length;
|
|
|
|
return getPaginationPageLink({
|
|
numRows,
|
|
pageSize: UsageStatsProjects.MAX_ROWS_USAGE_TABLE,
|
|
offset,
|
|
});
|
|
}
|
|
|
|
get projectSelectionFilter(): (p: Project) => boolean {
|
|
const {projectIds, isSingleProject} = this.props;
|
|
const selectedProjects = new Set(projectIds.map(id => `${id}`));
|
|
|
|
// If 'My Projects' or 'All Projects' are selected
|
|
return selectedProjects.size === 0 || selectedProjects.has('-1') || isSingleProject
|
|
? _p => true
|
|
: p => selectedProjects.has(p.id);
|
|
}
|
|
|
|
/**
|
|
* Filter projects if there's a query
|
|
*/
|
|
get filteredProjects() {
|
|
const {projects, tableQuery} = this.props;
|
|
return tableQuery
|
|
? projects.filter(
|
|
p =>
|
|
p.slug.includes(tableQuery) && p.hasAccess && this.projectSelectionFilter(p)
|
|
)
|
|
: projects.filter(p => p.hasAccess && this.projectSelectionFilter(p));
|
|
}
|
|
|
|
get tableHeader() {
|
|
const {key, direction} = this.tableSort;
|
|
|
|
const getArrowDirection = (linkKey: SortBy): Directions => {
|
|
if (linkKey !== key) {
|
|
return undefined;
|
|
}
|
|
|
|
return direction > 0 ? 'desc' : 'asc';
|
|
};
|
|
|
|
return [
|
|
{
|
|
key: SortBy.PROJECT,
|
|
title: t('Project'),
|
|
align: 'left',
|
|
direction: getArrowDirection(SortBy.PROJECT),
|
|
onClick: () => this.handleChangeSort(SortBy.PROJECT),
|
|
},
|
|
{
|
|
key: SortBy.TOTAL,
|
|
title: t('Total'),
|
|
align: 'right',
|
|
direction: getArrowDirection(SortBy.TOTAL),
|
|
onClick: () => this.handleChangeSort(SortBy.TOTAL),
|
|
},
|
|
{
|
|
key: SortBy.ACCEPTED,
|
|
title: t('Accepted'),
|
|
align: 'right',
|
|
direction: getArrowDirection(SortBy.ACCEPTED),
|
|
onClick: () => this.handleChangeSort(SortBy.ACCEPTED),
|
|
},
|
|
{
|
|
key: SortBy.FILTERED,
|
|
title: t('Filtered'),
|
|
align: 'right',
|
|
direction: getArrowDirection(SortBy.FILTERED),
|
|
onClick: () => this.handleChangeSort(SortBy.FILTERED),
|
|
},
|
|
{
|
|
key: SortBy.DROPPED,
|
|
title: t('Dropped'),
|
|
align: 'right',
|
|
direction: getArrowDirection(SortBy.DROPPED),
|
|
onClick: () => this.handleChangeSort(SortBy.DROPPED),
|
|
},
|
|
]
|
|
.map(h => {
|
|
const Cell = h.key === SortBy.PROJECT ? CellProject : CellStat;
|
|
|
|
return (
|
|
<Cell key={h.key}>
|
|
<SortLink
|
|
canSort
|
|
title={h.title}
|
|
align={h.align as Alignments}
|
|
direction={h.direction}
|
|
generateSortLink={h.onClick}
|
|
/>
|
|
</Cell>
|
|
);
|
|
})
|
|
.concat([<CellStat key="empty" />]); // Extra column for displaying buttons etc.
|
|
}
|
|
|
|
getProjectLink(project: Project) {
|
|
const {dataCategory, getNextLocations, organization} = this.props;
|
|
const {performance, projectDetail, settings} = getNextLocations(project);
|
|
|
|
if (
|
|
dataCategory === DATA_CATEGORY_INFO.transaction &&
|
|
organization.features.includes('performance-view')
|
|
) {
|
|
return {
|
|
projectLink: performance,
|
|
projectSettingsLink: settings,
|
|
};
|
|
}
|
|
|
|
return {
|
|
projectLink: projectDetail,
|
|
projectSettingsLink: settings,
|
|
};
|
|
}
|
|
|
|
handleChangeSort = (nextKey: SortBy) => {
|
|
const {handleChangeState} = this.props;
|
|
const {key, direction} = this.tableSort;
|
|
|
|
let nextDirection = 1; // Default to descending
|
|
|
|
if (key === nextKey) {
|
|
nextDirection = direction * -1; // Toggle if clicking on the same column
|
|
} else if (nextKey === SortBy.PROJECT) {
|
|
nextDirection = -1; // Default PROJECT to ascending
|
|
}
|
|
|
|
// The header uses SortLink, which takes a LocationDescriptor and pushes
|
|
// that to the router. As such, we do not need to update the router in
|
|
// handleChangeState
|
|
return handleChangeState(
|
|
{sort: `${nextDirection > 0 ? '-' : ''}${nextKey}`},
|
|
{willUpdateRouter: false}
|
|
);
|
|
};
|
|
|
|
handleSearch = (query: string) => {
|
|
const {handleChangeState, tableQuery} = this.props;
|
|
|
|
if (query === tableQuery) {
|
|
return;
|
|
}
|
|
|
|
if (!query) {
|
|
handleChangeState({query: undefined, cursor: undefined});
|
|
return;
|
|
}
|
|
|
|
handleChangeState({query, cursor: undefined});
|
|
};
|
|
|
|
mapSeriesToTable(projectStats?: UsageSeries): {
|
|
tableStats: TableStat[];
|
|
error?: Error;
|
|
} {
|
|
if (!projectStats) {
|
|
return {tableStats: []};
|
|
}
|
|
|
|
const stats: Record<number, object> = {};
|
|
|
|
try {
|
|
const baseStat: Partial<TableStat> = {
|
|
[SortBy.TOTAL]: 0,
|
|
[SortBy.ACCEPTED]: 0,
|
|
[SortBy.FILTERED]: 0,
|
|
[SortBy.DROPPED]: 0,
|
|
};
|
|
|
|
const projectList = this.filteredProjects;
|
|
const projectSet = new Set(projectList.map(p => p.id));
|
|
|
|
projectStats.groups.forEach(group => {
|
|
const {outcome, project: projectId} = group.by;
|
|
// Backend enum is singlar. Frontend enum is plural.
|
|
|
|
if (!projectSet.has(projectId.toString())) {
|
|
return;
|
|
}
|
|
|
|
if (!stats[projectId]) {
|
|
stats[projectId] = {...baseStat};
|
|
}
|
|
|
|
if (outcome !== Outcome.CLIENT_DISCARD) {
|
|
stats[projectId].total += group.totals['sum(quantity)'];
|
|
}
|
|
|
|
if (outcome === Outcome.ACCEPTED || outcome === Outcome.FILTERED) {
|
|
stats[projectId][outcome] += group.totals['sum(quantity)'];
|
|
} else if (
|
|
outcome === Outcome.RATE_LIMITED ||
|
|
outcome === Outcome.INVALID ||
|
|
outcome === Outcome.DROPPED
|
|
) {
|
|
stats[projectId][SortBy.DROPPED] += group.totals['sum(quantity)'];
|
|
}
|
|
});
|
|
|
|
// For projects without stats, fill in with zero
|
|
const tableStats: TableStat[] = projectList.map(proj => {
|
|
const stat = stats[proj.id] ?? {...baseStat};
|
|
return {
|
|
project: {...proj},
|
|
...this.getProjectLink(proj),
|
|
...stat,
|
|
};
|
|
});
|
|
|
|
const {key, direction} = this.tableSort;
|
|
tableStats.sort((a, b) => {
|
|
if (key === SortBy.PROJECT) {
|
|
return b.project.slug.localeCompare(a.project.slug) * direction;
|
|
}
|
|
|
|
return a[key] !== b[key]
|
|
? (b[key] - a[key]) * direction
|
|
: a.project.slug.localeCompare(b.project.slug);
|
|
});
|
|
|
|
const offset = this.tableOffset;
|
|
|
|
return {
|
|
tableStats: tableStats.slice(
|
|
offset,
|
|
offset + UsageStatsProjects.MAX_ROWS_USAGE_TABLE
|
|
),
|
|
};
|
|
} catch (err) {
|
|
Sentry.withScope(scope => {
|
|
scope.setContext('query', this.endpointQuery);
|
|
scope.setContext('body', {...projectStats});
|
|
Sentry.captureException(err);
|
|
});
|
|
|
|
return {
|
|
tableStats: [],
|
|
error: err,
|
|
};
|
|
}
|
|
}
|
|
|
|
renderComponent() {
|
|
const {error, errors, loading} = this.state;
|
|
const {dataCategory, loadingProjects, tableQuery, isSingleProject} = this.props;
|
|
const {headers, tableStats} = this.tableData;
|
|
return (
|
|
<Fragment>
|
|
{isSingleProject && (
|
|
<PanelHeading>
|
|
<Title>{t('All Projects')}</Title>
|
|
</PanelHeading>
|
|
)}
|
|
{!isSingleProject && (
|
|
<Container>
|
|
<SearchBar
|
|
defaultQuery=""
|
|
query={tableQuery}
|
|
placeholder={t('Filter your projects')}
|
|
onSearch={this.handleSearch}
|
|
/>
|
|
</Container>
|
|
)}
|
|
<Container data-test-id="usage-stats-table">
|
|
<UsageTable
|
|
isLoading={loading || loadingProjects}
|
|
isError={error}
|
|
errors={errors as any} // TODO(ts)
|
|
isEmpty={tableStats.length === 0}
|
|
headers={headers}
|
|
dataCategory={dataCategory}
|
|
usageStats={tableStats}
|
|
/>
|
|
<Pagination pageLinks={this.pageLink} />
|
|
</Container>
|
|
</Fragment>
|
|
);
|
|
}
|
|
}
|
|
|
|
export default withProjects(UsageStatsProjects);
|
|
|
|
const Container = styled('div')`
|
|
margin-bottom: ${space(2)};
|
|
`;
|
|
|
|
const Title = styled('div')`
|
|
font-weight: bold;
|
|
font-size: ${p => p.theme.fontSizeLarge};
|
|
color: ${p => p.theme.gray400};
|
|
display: flex;
|
|
flex: 1;
|
|
align-items: center;
|
|
`;
|
|
|
|
const PanelHeading = styled('div')`
|
|
display: flex;
|
|
margin-bottom: ${space(2)};
|
|
align-items: center;
|
|
`;
|