projectsEditTable.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. import type React from 'react';
  2. import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import Panel from 'sentry/components/panels/panel';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import useProjects from 'sentry/utils/useProjects';
  10. import {OrganizationSampleRateInput} from 'sentry/views/settings/dynamicSampling/organizationSampleRateInput';
  11. import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable';
  12. import {SamplingBreakdown} from 'sentry/views/settings/dynamicSampling/samplingBreakdown';
  13. import {mapArrayToObject} from 'sentry/views/settings/dynamicSampling/utils';
  14. import {formatPercent} from 'sentry/views/settings/dynamicSampling/utils/formatPercent';
  15. import {parsePercent} from 'sentry/views/settings/dynamicSampling/utils/parsePercent';
  16. import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
  17. import {scaleSampleRates} from 'sentry/views/settings/dynamicSampling/utils/scaleSampleRates';
  18. import type {
  19. ProjectionSamplePeriod,
  20. ProjectSampleCount,
  21. } from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
  22. interface Props {
  23. actions: React.ReactNode;
  24. editMode: 'single' | 'bulk';
  25. isLoading: boolean;
  26. onEditModeChange: (mode: 'single' | 'bulk') => void;
  27. period: ProjectionSamplePeriod;
  28. sampleCounts: ProjectSampleCount[];
  29. }
  30. const {useFormField} = projectSamplingForm;
  31. const EMPTY_ARRAY: any = [];
  32. export function ProjectsEditTable({
  33. actions,
  34. isLoading: isLoadingProp,
  35. sampleCounts,
  36. editMode,
  37. period,
  38. onEditModeChange,
  39. }: Props) {
  40. const {projects, fetching} = useProjects();
  41. const {value, initialValue, error, onChange} = useFormField('projectRates');
  42. const [isBulkEditEnabled, setIsBulkEditEnabled] = useState(false);
  43. const [orgRate, setOrgRate] = useState<string>('');
  44. const projectRateSnapshotRef = useRef<Record<string, string>>({});
  45. const dataByProjectId = useMemo(
  46. () =>
  47. mapArrayToObject({
  48. array: sampleCounts,
  49. keySelector: item => item.project.id,
  50. valueSelector: item => item,
  51. }),
  52. [sampleCounts]
  53. );
  54. const handleProjectChange = useCallback(
  55. (projectId: string, newRate: string) => {
  56. onChange(prev => ({
  57. ...prev,
  58. [projectId]: newRate,
  59. }));
  60. onEditModeChange('single');
  61. },
  62. [onChange, onEditModeChange]
  63. );
  64. const handleOrgChange = useCallback(
  65. (newRate: string) => {
  66. // Editing the org rate will transition the logic to bulk edit mode
  67. // On the first edit, we need to snapshot the current project rates as scaling baseline
  68. // to avoid rounding errors when scaling the sample rates up and down
  69. if (editMode === 'single') {
  70. projectRateSnapshotRef.current = value;
  71. }
  72. const cappedOrgRate = parsePercent(newRate, 1);
  73. const scalingItems = Object.entries(projectRateSnapshotRef.current)
  74. .map(([projectId, rate]) => ({
  75. id: projectId,
  76. sampleRate: rate ? parsePercent(rate) : 0,
  77. count: dataByProjectId[projectId]?.count ?? 0,
  78. }))
  79. // We do not wan't to bulk edit inactive projects as they have no effect on the outcome
  80. .filter(item => item.count !== 0);
  81. const {scaledItems} = scaleSampleRates({
  82. items: scalingItems,
  83. sampleRate: cappedOrgRate,
  84. });
  85. const newProjectValues = mapArrayToObject({
  86. array: scaledItems,
  87. keySelector: item => item.id,
  88. valueSelector: item => formatPercent(item.sampleRate),
  89. });
  90. // Update the form state (project values) with the new sample rates
  91. onChange(prev => {
  92. return {...prev, ...newProjectValues};
  93. });
  94. setOrgRate(newRate);
  95. onEditModeChange('bulk');
  96. },
  97. [dataByProjectId, editMode, onChange, onEditModeChange, value]
  98. );
  99. const handleBulkEditChange = useCallback((newIsActive: boolean) => {
  100. setIsBulkEditEnabled(newIsActive);
  101. // On exiting the bulk edit mode, we need to ensure the displayed org rate is a valid percentage
  102. if (newIsActive === false) {
  103. setOrgRate(rate => (parsePercent(rate, 1) * 100).toString());
  104. }
  105. }, []);
  106. const items = useMemo(
  107. () =>
  108. projects.map(project => {
  109. const item = dataByProjectId[project.id];
  110. return {
  111. id: project.slug,
  112. name: project.slug,
  113. count: item?.count || 0,
  114. ownCount: item?.ownCount || 0,
  115. subProjects: item?.subProjects ?? EMPTY_ARRAY,
  116. project,
  117. initialSampleRate: initialValue[project.id]!,
  118. sampleRate: value[project.id]!,
  119. error: error?.[project.id],
  120. };
  121. }),
  122. [dataByProjectId, error, initialValue, projects, value]
  123. );
  124. const totalSpanCount = useMemo(
  125. () => items.reduce((acc, item) => acc + item.count, 0),
  126. [items]
  127. );
  128. // In bulk edit mode, we display the org rate from the input state
  129. // In single edit mode, we display the estimated org rate based on the current sample rates
  130. const displayedOrgRate = useMemo(() => {
  131. if (editMode === 'bulk') {
  132. return orgRate;
  133. }
  134. const totalSampledSpans = items.reduce(
  135. (acc, item) => acc + item.count * parsePercent(value[item.project.id], 1),
  136. 0
  137. );
  138. return formatPercent(totalSampledSpans / totalSpanCount);
  139. }, [editMode, items, orgRate, totalSpanCount, value]);
  140. const initialOrgRate = useMemo(() => {
  141. const totalSampledSpans = items.reduce(
  142. (acc, item) => acc + item.count * parsePercent(initialValue[item.project.id], 1),
  143. 0
  144. );
  145. return formatPercent(totalSampledSpans / totalSpanCount);
  146. }, [initialValue, items, totalSpanCount]);
  147. const breakdownSampleRates = useMemo(
  148. () =>
  149. mapArrayToObject({
  150. array: Object.entries(value),
  151. keySelector: ([projectId, _]) => projectId,
  152. valueSelector: ([_, rate]) => parsePercent(rate),
  153. }),
  154. [value]
  155. );
  156. const isLoading = fetching || isLoadingProp;
  157. return (
  158. <Fragment>
  159. <SamplingBreakdown
  160. sampleCounts={sampleCounts}
  161. sampleRates={breakdownSampleRates}
  162. isLoading={isLoading}
  163. />
  164. <Panel>
  165. {isLoading ? (
  166. <LoadingIndicator
  167. css={css`
  168. margin: 60px 0;
  169. `}
  170. />
  171. ) : (
  172. <Fragment>
  173. <OrganizationSampleRateInput
  174. label={t('Estimated Organization Rate')}
  175. help={t('An estimate of the combined sample rate for all projects.')}
  176. value={displayedOrgRate}
  177. isBulkEditEnabled
  178. isBulkEditActive={isBulkEditEnabled}
  179. onBulkEditChange={handleBulkEditChange}
  180. onChange={handleOrgChange}
  181. showPreviousValue={initialOrgRate !== displayedOrgRate}
  182. previousValue={initialOrgRate}
  183. />
  184. <ProjectsTable
  185. rateHeader={t('Target Rate')}
  186. canEdit={!isBulkEditEnabled}
  187. onChange={handleProjectChange}
  188. emptyMessage={t('No active projects found in the selected period.')}
  189. period={period}
  190. isLoading={isLoading}
  191. items={items}
  192. />
  193. <Footer>{actions}</Footer>
  194. </Fragment>
  195. )}
  196. </Panel>
  197. </Fragment>
  198. );
  199. }
  200. const Footer = styled('div')`
  201. border-top: 1px solid ${p => p.theme.innerBorder};
  202. display: flex;
  203. justify-content: flex-end;
  204. gap: ${space(2)};
  205. padding: ${space(1.5)} ${space(2)};
  206. `;