Browse Source

feat(dynamic-sampling): Add rate input for each project (#80177)

In manual sampling mode, display an input for changing the sample rate
of each project.

part of https://github.com/getsentry/projects/issues/190
ArthurKnaus 4 months ago
parent
commit
08e9e1e0c6

+ 9 - 3
static/app/views/settings/dynamicSampling/organizationSampleRateField.tsx

@@ -22,11 +22,10 @@ export function OrganizationSampleRateField({}) {
       help={t(
         'Sentry automatically adapts the sample rates of your projects based on this organization-wide target.'
       )}
-      error={field.error}
     >
       <InputWrapper
         css={css`
-          width: 150px;
+          width: 160px;
         `}
       >
         <Tooltip
@@ -48,7 +47,9 @@ export function OrganizationSampleRateField({}) {
             </InputGroup.TrailingItems>
           </InputGroup>
         </Tooltip>
-        {field.hasChanged ? (
+        {field.error ? (
+          <ErrorMessage>{field.error}</ErrorMessage>
+        ) : field.hasChanged ? (
           <PreviousValue>{t('previous: %f%%', field.initialValue)}</PreviousValue>
         ) : null}
       </InputWrapper>
@@ -61,6 +62,11 @@ const PreviousValue = styled('span')`
   color: ${p => p.theme.subText};
 `;
 
+const ErrorMessage = styled('span')`
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  color: ${p => p.theme.error};
+`;
+
 const InputWrapper = styled('div')`
   padding-top: 8px;
   height: 58px;

+ 82 - 49
static/app/views/settings/dynamicSampling/projectSampling.tsx

@@ -1,3 +1,4 @@
+import {useState} from 'react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
@@ -8,56 +9,92 @@ import Panel from 'sentry/components/panels/panel';
 import PanelBody from 'sentry/components/panels/panelBody';
 import PanelHeader from 'sentry/components/panels/panelHeader';
 import QuestionTooltip from 'sentry/components/questionTooltip';
+import {SegmentedControl} from 'sentry/components/segmentedControl';
+import {Tooltip} from 'sentry/components/tooltip';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
+import useProjects from 'sentry/utils/useProjects';
+import {ProjectsEditTable} from 'sentry/views/settings/dynamicSampling/projectsEditTable';
 import {SamplingModeField} from 'sentry/views/settings/dynamicSampling/samplingModeField';
+import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
+
+const {useFormState, FormProvider} = projectSamplingForm;
 
 export function ProjectSampling() {
+  const {projects} = useProjects();
+  const [period, setPeriod] = useState<'24h' | '30d'>('24h');
+
+  // TODO(aknaus): Fetch initial project rates from API
+  const fakeInitialProjectRates = projects.reduce(
+    (acc, project) => {
+      acc[project.id] = (Math.round(Math.random() * 10000) / 100).toString();
+      return acc;
+    },
+    {} as Record<string, string>
+  );
+
+  const formState = useFormState({
+    projectRates: fakeInitialProjectRates,
+  });
+
   return (
-    <form onSubmit={event => event.preventDefault()}>
-      <Panel>
-        <PanelHeader>{t('Manual Sampling')}</PanelHeader>
-        <PanelBody>
-          <FieldGroup
-            label={t('Sampling Mode')}
-            help={t('The current configuration mode for dynamic sampling.')}
-          >
-            <div
-              css={css`
-                display: flex;
-                align-items: center;
-                gap: ${space(1)};
-              `}
+    <FormProvider formState={formState}>
+      <form onSubmit={event => event.preventDefault()}>
+        <Panel>
+          <PanelHeader>{t('Manual Sampling')}</PanelHeader>
+          <PanelBody>
+            <FieldGroup
+              label={t('Sampling Mode')}
+              help={t('The current configuration mode for dynamic sampling.')}
             >
-              {t('Manual')}{' '}
-              <QuestionTooltip
-                size="sm"
-                isHoverable
-                title={tct(
-                  'Manual mode allows you to set fixed sample rates for each project. [link:Learn more]',
-                  {
-                    link: (
-                      <ExternalLink href="https://docs.sentry.io/product/performance/retention-priorities/" />
-                    ),
-                  }
-                )}
-              />
-            </div>
-          </FieldGroup>
-          <SamplingModeField />
-        </PanelBody>
-      </Panel>
-      <HeadingRow>
-        <h4>Customize Projects</h4>
-      </HeadingRow>
-      <CommingSoonPanel>Coming soon</CommingSoonPanel>
-      <FormActions>
-        <Button disabled>{t('Reset')}</Button>
-        <Button priority="primary" disabled>
-          {t('Apply Changes')}
-        </Button>
-      </FormActions>
-    </form>
+              <div
+                css={css`
+                  display: flex;
+                  align-items: center;
+                  gap: ${space(1)};
+                `}
+              >
+                {t('Manual')}{' '}
+                <QuestionTooltip
+                  size="sm"
+                  isHoverable
+                  title={tct(
+                    'Manual mode allows you to set fixed sample rates for each project. [link:Learn more]',
+                    {
+                      link: (
+                        <ExternalLink href="https://docs.sentry.io/product/performance/retention-priorities/" />
+                      ),
+                    }
+                  )}
+                />
+              </div>
+            </FieldGroup>
+            <SamplingModeField />
+          </PanelBody>
+        </Panel>
+        <HeadingRow>
+          <h4>{t('Customize Projects')}</h4>
+          <Tooltip
+            title={t(
+              'The time period for which the projected sample rates are calculated.'
+            )}
+          >
+            <SegmentedControl value={period} onChange={setPeriod} size="xs">
+              <SegmentedControl.Item key="24h">{t('24h')}</SegmentedControl.Item>
+              <SegmentedControl.Item key="30d">{t('30d')}</SegmentedControl.Item>
+            </SegmentedControl>
+          </Tooltip>
+        </HeadingRow>
+        <p>{t('Set custom rates for traces starting at each of your projects.')}</p>
+        <ProjectsEditTable period={period} />
+        <FormActions>
+          <Button disabled>{t('Reset')}</Button>
+          <Button priority="primary" disabled>
+            {t('Apply Changes')}
+          </Button>
+        </FormActions>
+      </form>
+    </FormProvider>
   );
 }
 
@@ -73,14 +110,10 @@ const HeadingRow = styled('div')`
   display: flex;
   align-items: center;
   justify-content: space-between;
+  padding-top: ${space(3)};
   padding-bottom: ${space(1.5)};
 
-  & > h4 {
+  & > * {
     margin: 0;
   }
 `;
-
-const CommingSoonPanel = styled(Panel)`
-  padding: ${space(2)};
-  color: ${p => p.theme.subText};
-`;

+ 81 - 0
static/app/views/settings/dynamicSampling/projectsEditTable.tsx

@@ -0,0 +1,81 @@
+import {useCallback, useMemo} from 'react';
+import partition from 'lodash/partition';
+
+import LoadingError from 'sentry/components/loadingError';
+import {t} from 'sentry/locale';
+import useProjects from 'sentry/utils/useProjects';
+import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable';
+import {projectSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/projectSamplingForm';
+import {useProjectSampleCounts} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
+
+interface Props {
+  period: '24h' | '30d';
+}
+
+const {useFormField} = projectSamplingForm;
+const EMPTY_ARRAY = [];
+
+export function ProjectsEditTable({period}: Props) {
+  const {projects, fetching} = useProjects();
+
+  const {value, initialValue, error, onChange} = useFormField('projectRates');
+
+  const {data, isPending, isError, refetch} = useProjectSampleCounts({period});
+
+  const dataByProjectId = data.reduce(
+    (acc, item) => {
+      acc[item.project.id] = item;
+      return acc;
+    },
+    {} as Record<string, (typeof data)[0]>
+  );
+
+  const items = useMemo(
+    () =>
+      projects.map(project => {
+        const item = dataByProjectId[project.id] as
+          | (typeof dataByProjectId)[string]
+          | undefined;
+        return {
+          id: project.slug,
+          name: project.slug,
+          count: item?.count || 0,
+          ownCount: item?.ownCount || 0,
+          subProjects: item?.subProjects ?? EMPTY_ARRAY,
+          project: project,
+          initialSampleRate: initialValue[project.id],
+          sampleRate: value[project.id],
+          error: error?.[project.id],
+        };
+      }),
+    [dataByProjectId, error, initialValue, projects, value]
+  );
+
+  const [activeItems, inactiveItems] = partition(items, item => item.count > 0);
+
+  const handleChange = useCallback(
+    (projectId: string, newRate: string) => {
+      onChange(prev => ({
+        ...prev,
+        [projectId]: newRate,
+      }));
+    },
+    [onChange]
+  );
+
+  if (isError) {
+    return <LoadingError onRetry={refetch} />;
+  }
+
+  return (
+    <ProjectsTable
+      canEdit
+      onChange={handleChange}
+      emptyMessage={t('No active projects found in the selected period.')}
+      isEmpty={!data.length}
+      isLoading={fetching || isPending}
+      items={activeItems}
+      inactiveItems={inactiveItems}
+    />
+  );
+}

+ 14 - 320
static/app/views/settings/dynamicSampling/projectsPreviewTable.tsx

@@ -1,23 +1,10 @@
-import {Fragment, memo, useCallback, useMemo, useState} from 'react';
-import {css} from '@emotion/react';
-import styled from '@emotion/styled';
+import {useMemo} from 'react';
 
-import {hasEveryAccess} from 'sentry/components/acl/access';
-import {LinkButton} from 'sentry/components/button';
-import ProjectBadge from 'sentry/components/idBadge/projectBadge';
-import {InputGroup} from 'sentry/components/inputGroup';
 import LoadingError from 'sentry/components/loadingError';
-import {PanelTable} from 'sentry/components/panels/panelTable';
-import {Tooltip} from 'sentry/components/tooltip';
-import {IconArrow, IconChevron, IconSettings} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import type {Project} from 'sentry/types/project';
-import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
 import {formatNumberWithDynamicDecimalPoints} from 'sentry/utils/number/formatNumberWithDynamicDecimalPoints';
-import oxfordizeArray from 'sentry/utils/oxfordizeArray';
 import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
-import useOrganization from 'sentry/utils/useOrganization';
+import {ProjectsTable} from 'sentry/views/settings/dynamicSampling/projectsTable';
 import {organizationSamplingForm} from 'sentry/views/settings/dynamicSampling/utils/organizationSamplingForm';
 import {balanceSampleRate} from 'sentry/views/settings/dynamicSampling/utils/rebalancing';
 import {useProjectSampleCounts} from 'sentry/views/settings/dynamicSampling/utils/useProjectSampleCounts';
@@ -29,7 +16,6 @@ interface Props {
 }
 
 export function ProjectsPreviewTable({period}: Props) {
-  const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
   const {value: targetSampleRate, initialValue: initialTargetSampleRate} =
     useFormField('targetSampleRate');
 
@@ -61,9 +47,16 @@ export function ProjectsPreviewTable({period}: Props) {
     }, {});
   }, [initialTargetSampleRate, data]);
 
-  const handleTableSort = useCallback(() => {
-    setTableSort(value => (value === 'asc' ? 'desc' : 'asc'));
-  }, []);
+  const itemsWithFormattedNumbers = useMemo(() => {
+    return balancedItems.map(item => ({
+      ...item,
+      sampleRate: formatNumberWithDynamicDecimalPoints(item.sampleRate * 100, 2),
+      initialSampleRate: formatNumberWithDynamicDecimalPoints(
+        initialSampleRatesBySlug[item.project.slug] * 100,
+        2
+      ),
+    }));
+  }, [balancedItems, initialSampleRatesBySlug]);
 
   if (isError) {
     return <LoadingError onRetry={refetch} />;
@@ -75,306 +68,7 @@ export function ProjectsPreviewTable({period}: Props) {
       emptyMessage={t('No active projects found in the selected period.')}
       isEmpty={!data.length}
       isLoading={isPending}
-      headers={[
-        t('Project'),
-        <SortableHeader key="spans" onClick={handleTableSort}>
-          {t('Spans')}
-          <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
-        </SortableHeader>,
-        <RateHeaderCell key="projectedRate">{t('Projected Rate')}</RateHeaderCell>,
-      ]}
-    >
-      {balancedItems
-        .toSorted((a, b) => {
-          if (tableSort === 'asc') {
-            return a.count - b.count;
-          }
-          return b.count - a.count;
-        })
-        .map(({id, project, count, ownCount, sampleRate, subProjects}) => (
-          <TableRow
-            key={id}
-            project={project}
-            count={count}
-            ownCount={ownCount}
-            sampleRate={sampleRate}
-            initialSampleRate={initialSampleRatesBySlug[project.slug]}
-            subProjects={subProjects}
-          />
-        ))}
-    </ProjectsTable>
+      items={itemsWithFormattedNumbers}
+    />
   );
 }
-
-interface SubProject {
-  count: number;
-  slug: string;
-}
-
-function getSubProjectContent(
-  ownSlug: string,
-  subProjects: SubProject[],
-  isExpanded: boolean
-) {
-  let subProjectContent: React.ReactNode = t('No distributed traces');
-  if (subProjects.length > 1) {
-    const truncatedSubProjects = subProjects.slice(0, MAX_PROJECTS_COLLAPSED);
-    const overflowCount = subProjects.length - MAX_PROJECTS_COLLAPSED;
-    const moreTranslation = t('+%d more', overflowCount);
-    const stringifiedSubProjects =
-      overflowCount > 0
-        ? `${truncatedSubProjects.map(p => p.slug).join(', ')}, ${moreTranslation}`
-        : oxfordizeArray(truncatedSubProjects.map(p => p.slug));
-
-    subProjectContent = isExpanded ? (
-      <Fragment>
-        <div>{ownSlug}</div>
-        {subProjects.map(subProject => (
-          <div key={subProject.slug}>{subProject.slug}</div>
-        ))}
-      </Fragment>
-    ) : (
-      t('Including spans in ') + stringifiedSubProjects
-    );
-  }
-
-  return subProjectContent;
-}
-
-function getSubSpansContent(
-  ownCount: number,
-  subProjects: SubProject[],
-  isExpanded: boolean
-) {
-  let subSpansContent: React.ReactNode = '';
-  if (subProjects.length > 1) {
-    const subProjectSum = subProjects.reduce(
-      (acc, subProject) => acc + subProject.count,
-      0
-    );
-
-    subSpansContent = isExpanded ? (
-      <Fragment>
-        <div>{formatAbbreviatedNumber(ownCount, 2)}</div>
-        {subProjects.map(subProject => (
-          <div key={subProject.slug}>{formatAbbreviatedNumber(subProject.count)}</div>
-        ))}
-      </Fragment>
-    ) : (
-      formatAbbreviatedNumber(subProjectSum)
-    );
-  }
-
-  return subSpansContent;
-}
-
-const MAX_PROJECTS_COLLAPSED = 3;
-const TableRow = memo(function TableRow({
-  project,
-  count,
-  ownCount,
-  sampleRate,
-  initialSampleRate,
-  subProjects,
-}: {
-  count: number;
-  initialSampleRate: number;
-  ownCount: number;
-  project: Project;
-  sampleRate: number;
-  subProjects: SubProject[];
-}) {
-  const organization = useOrganization();
-  const [isExpanded, setIsExpanded] = useState(false);
-
-  const isExpandable = subProjects.length > 0;
-  const hasAccess = hasEveryAccess(['project:write'], {organization, project});
-
-  const subProjectContent = getSubProjectContent(project.slug, subProjects, isExpanded);
-  const subSpansContent = getSubSpansContent(ownCount, subProjects, isExpanded);
-
-  return (
-    <Fragment key={project.slug}>
-      <Cell>
-        <FirstCellLine data-has-chevron={isExpandable}>
-          <HiddenButton
-            disabled={!isExpandable}
-            aria-label={isExpanded ? t('Collapse') : t('Expand')}
-            onClick={() => setIsExpanded(value => !value)}
-          >
-            {isExpandable && (
-              <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
-            )}
-            <ProjectBadge project={project} disableLink avatarSize={16} />
-          </HiddenButton>
-          {hasAccess && (
-            <SettingsButton
-              title={t('Open Project Settings')}
-              aria-label={t('Open Project Settings')}
-              size="xs"
-              priority="link"
-              icon={<IconSettings />}
-              to={`/organizations/${organization.slug}/settings/projects/${project.slug}/performance`}
-            />
-          )}
-        </FirstCellLine>
-        <SubProjects>{subProjectContent}</SubProjects>
-      </Cell>
-      <Cell>
-        <FirstCellLine data-align="right">{formatAbbreviatedNumber(count)}</FirstCellLine>
-        <SubSpans>{subSpansContent}</SubSpans>
-      </Cell>
-      <Cell>
-        <FirstCellLine>
-          <Tooltip
-            title={t('To edit project sample rates, switch to manual sampling mode.')}
-          >
-            <InputGroup
-              css={css`
-                width: 150px;
-              `}
-            >
-              <InputGroup.Input
-                disabled
-                size="sm"
-                value={formatNumberWithDynamicDecimalPoints(sampleRate * 100, 3)}
-              />
-              <InputGroup.TrailingItems>
-                <TrailingPercent>%</TrailingPercent>
-              </InputGroup.TrailingItems>
-            </InputGroup>
-          </Tooltip>
-        </FirstCellLine>
-        {sampleRate !== initialSampleRate && (
-          <SmallPrint>
-            {t(
-              'previous: %s%%',
-              formatNumberWithDynamicDecimalPoints(initialSampleRate * 100, 3)
-            )}
-          </SmallPrint>
-        )}
-      </Cell>
-    </Fragment>
-  );
-});
-
-const ProjectsTable = styled(PanelTable)`
-  grid-template-columns: 1fr max-content max-content;
-`;
-
-const SmallPrint = styled('span')`
-  font-size: ${p => p.theme.fontSizeExtraSmall};
-  color: ${p => p.theme.subText};
-  line-height: 1.5;
-  text-align: right;
-`;
-
-const SortableHeader = styled('button')`
-  border: none;
-  background: none;
-  cursor: pointer;
-  display: flex;
-  text-transform: inherit;
-  align-items: center;
-  gap: ${space(0.5)};
-`;
-
-const RateHeaderCell = styled('div')`
-  display: flex;
-  justify-content: space-between;
-`;
-
-const Cell = styled('div')`
-  display: flex;
-  flex-direction: column;
-  gap: ${space(0.25)};
-`;
-
-const FirstCellLine = styled('div')`
-  display: flex;
-  align-items: center;
-  height: 32px;
-  & > * {
-    flex-shrink: 0;
-  }
-  &[data-align='right'] {
-    justify-content: flex-end;
-  }
-  &[data-has-chevron='false'] {
-    padding-left: ${space(2)};
-  }
-`;
-
-const SubProjects = styled('div')`
-  color: ${p => p.theme.subText};
-  font-size: ${p => p.theme.fontSizeSmall};
-  margin-left: ${space(2)};
-  & > div {
-    line-height: 2;
-    margin-right: -${space(2)};
-    padding-right: ${space(2)};
-    margin-left: -${space(1)};
-    padding-left: ${space(1)};
-    border-top-left-radius: ${p => p.theme.borderRadius};
-    border-bottom-left-radius: ${p => p.theme.borderRadius};
-    &:nth-child(odd) {
-      background: ${p => p.theme.backgroundSecondary};
-    }
-  }
-`;
-
-const SubSpans = styled('div')`
-  color: ${p => p.theme.subText};
-  font-size: ${p => p.theme.fontSizeSmall};
-  text-align: right;
-  & > div {
-    line-height: 2;
-    margin-left: -${space(2)};
-    padding-left: ${space(2)};
-    margin-right: -${space(1)};
-    padding-right: ${space(1)};
-    border-top-right-radius: ${p => p.theme.borderRadius};
-    border-bottom-right-radius: ${p => p.theme.borderRadius};
-    &:nth-child(odd) {
-      background: ${p => p.theme.backgroundSecondary};
-    }
-  }
-`;
-
-const HiddenButton = styled('button')`
-  background: none;
-  border: none;
-  padding: 0;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-
-  /* Overwrite the platform icon's cursor style */
-  &:not([disabled]) img {
-    cursor: pointer;
-  }
-`;
-
-const StyledIconChevron = styled(IconChevron)`
-  height: 12px;
-  width: 12px;
-  margin-right: ${space(0.5)};
-  color: ${p => p.theme.subText};
-`;
-
-const SettingsButton = styled(LinkButton)`
-  margin-left: ${space(0.5)};
-  color: ${p => p.theme.subText};
-  visibility: hidden;
-
-  &:focus {
-    visibility: visible;
-  }
-  ${Cell}:hover & {
-    visibility: visible;
-  }
-`;
-
-const TrailingPercent = styled('strong')`
-  padding: 0 ${space(0.25)};
-`;

+ 445 - 0
static/app/views/settings/dynamicSampling/projectsTable.tsx

@@ -0,0 +1,445 @@
+import {Fragment, memo, useCallback, useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {hasEveryAccess} from 'sentry/components/acl/access';
+import {LinkButton} from 'sentry/components/button';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import {InputGroup} from 'sentry/components/inputGroup';
+import {PanelTable} from 'sentry/components/panels/panelTable';
+import {Tooltip} from 'sentry/components/tooltip';
+import {IconArrow, IconChevron, IconSettings} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Project} from 'sentry/types/project';
+import {formatAbbreviatedNumber} from 'sentry/utils/formatters';
+import oxfordizeArray from 'sentry/utils/oxfordizeArray';
+import useOrganization from 'sentry/utils/useOrganization';
+
+interface ProjectItem {
+  count: number;
+  initialSampleRate: string;
+  ownCount: number;
+  project: Project;
+  sampleRate: string;
+  subProjects: SubProject[];
+  error?: string;
+}
+
+interface Props extends Omit<React.ComponentProps<typeof StyledPanelTable>, 'headers'> {
+  items: ProjectItem[];
+  canEdit?: boolean;
+  inactiveItems?: ProjectItem[];
+  onChange?: (projectId: string, value: string) => void;
+}
+
+export function ProjectsTable({
+  items,
+  inactiveItems = [],
+  canEdit,
+  onChange,
+  ...props
+}: Props) {
+  const [tableSort, setTableSort] = useState<'asc' | 'desc'>('desc');
+
+  const handleTableSort = useCallback(() => {
+    setTableSort(value => (value === 'asc' ? 'desc' : 'asc'));
+  }, []);
+
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const hasActiveItems = items.length > 0;
+  const mainItems = hasActiveItems ? items : inactiveItems;
+
+  return (
+    <StyledPanelTable
+      {...props}
+      isEmpty={!items.length && !inactiveItems.length}
+      headers={[
+        t('Project'),
+        <SortableHeader type="button" key="spans" onClick={handleTableSort}>
+          {t('Spans')}
+          <IconArrow direction={tableSort === 'desc' ? 'down' : 'up'} size="xs" />
+        </SortableHeader>,
+        canEdit ? t('Target Rate') : t('Projected Rate'),
+      ]}
+    >
+      {mainItems
+        .toSorted((a, b) => {
+          if (a.count === b.count) {
+            return a.project.slug.localeCompare(b.project.slug);
+          }
+          if (tableSort === 'asc') {
+            return a.count - b.count;
+          }
+          return b.count - a.count;
+        })
+        .map(item => (
+          <TableRow
+            key={item.project.id}
+            canEdit={canEdit}
+            onChange={onChange}
+            {...item}
+          />
+        ))}
+      {hasActiveItems && inactiveItems.length > 0 && (
+        <SectionHeader
+          isExpanded={isExpanded}
+          setIsExpanded={setIsExpanded}
+          title={
+            inactiveItems.length > 1
+              ? t(`+%d Inactive Projects`, inactiveItems.length)
+              : t(`+1 Inactive Project`)
+          }
+        />
+      )}
+      {hasActiveItems &&
+        isExpanded &&
+        inactiveItems
+          .toSorted((a, b) => a.project.slug.localeCompare(b.project.slug))
+          .map(item => (
+            <TableRow
+              key={item.project.id}
+              canEdit={canEdit}
+              onChange={onChange}
+              {...item}
+            />
+          ))}
+    </StyledPanelTable>
+  );
+}
+
+interface SubProject {
+  count: number;
+  slug: string;
+}
+
+function SectionHeader({
+  isExpanded,
+  setIsExpanded,
+  title,
+}: {
+  isExpanded: boolean;
+  setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>;
+  title: React.ReactNode;
+}) {
+  return (
+    <Fragment>
+      <SectionHeaderCell
+        role="button"
+        tabIndex={0}
+        onClick={() => setIsExpanded(value => !value)}
+        aria-label={
+          isExpanded ? t('Collapse inactive projects') : t('Expand inactive projects')
+        }
+        onKeyDown={e => {
+          if (e.key === 'Enter' || e.key === ' ') {
+            setIsExpanded(value => !value);
+          }
+        }}
+      >
+        <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
+        {title}
+      </SectionHeaderCell>
+      {/* As the main element spans 3 grid colums we need to ensure that nth child css selectors of other elements
+        remain functional by adding hidden elements */}
+      <div style={{display: 'none'}} />
+      <div style={{display: 'none'}} />
+    </Fragment>
+  );
+}
+
+function getSubProjectContent(
+  ownSlug: string,
+  subProjects: SubProject[],
+  isExpanded: boolean
+) {
+  let subProjectContent: React.ReactNode = t('No distributed traces');
+  if (subProjects.length > 1) {
+    const truncatedSubProjects = subProjects.slice(0, MAX_PROJECTS_COLLAPSED);
+    const overflowCount = subProjects.length - MAX_PROJECTS_COLLAPSED;
+    const moreTranslation = t('+%d more', overflowCount);
+    const stringifiedSubProjects =
+      overflowCount > 0
+        ? `${truncatedSubProjects.map(p => p.slug).join(', ')}, ${moreTranslation}`
+        : oxfordizeArray(truncatedSubProjects.map(p => p.slug));
+
+    subProjectContent = isExpanded ? (
+      <Fragment>
+        <div>{ownSlug}</div>
+        {subProjects.map(subProject => (
+          <div key={subProject.slug}>{subProject.slug}</div>
+        ))}
+      </Fragment>
+    ) : (
+      t('Including spans in ') + stringifiedSubProjects
+    );
+  }
+
+  return subProjectContent;
+}
+
+function getSubSpansContent(
+  ownCount: number,
+  subProjects: SubProject[],
+  isExpanded: boolean
+) {
+  let subSpansContent: React.ReactNode = '';
+  if (subProjects.length > 1) {
+    const subProjectSum = subProjects.reduce(
+      (acc, subProject) => acc + subProject.count,
+      0
+    );
+
+    subSpansContent = isExpanded ? (
+      <Fragment>
+        <div>{formatAbbreviatedNumber(ownCount, 2)}</div>
+        {subProjects.map(subProject => (
+          <div key={subProject.slug}>{formatAbbreviatedNumber(subProject.count)}</div>
+        ))}
+      </Fragment>
+    ) : (
+      formatAbbreviatedNumber(subProjectSum)
+    );
+  }
+
+  return subSpansContent;
+}
+
+const MAX_PROJECTS_COLLAPSED = 3;
+const TableRow = memo(function TableRow({
+  project,
+  canEdit,
+  count,
+  ownCount,
+  sampleRate,
+  initialSampleRate,
+  subProjects,
+  error,
+  onChange,
+}: {
+  count: number;
+  initialSampleRate: string;
+  ownCount: number;
+  project: Project;
+  sampleRate: string;
+  subProjects: SubProject[];
+  canEdit?: boolean;
+  error?: string;
+  onChange?: (projectId: string, value: string) => void;
+}) {
+  const organization = useOrganization();
+  const [isExpanded, setIsExpanded] = useState(false);
+
+  const isExpandable = subProjects.length > 0;
+  const hasAccess = hasEveryAccess(['project:write'], {organization, project});
+
+  const subProjectContent = getSubProjectContent(project.slug, subProjects, isExpanded);
+  const subSpansContent = getSubSpansContent(ownCount, subProjects, isExpanded);
+
+  const handleChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      onChange?.(project.id, event.target.value);
+    },
+    [onChange, project.id]
+  );
+
+  return (
+    <Fragment key={project.slug}>
+      <Cell>
+        <FirstCellLine data-has-chevron={isExpandable}>
+          <HiddenButton
+            disabled={!isExpandable}
+            aria-label={isExpanded ? t('Collapse') : t('Expand')}
+            onClick={() => setIsExpanded(value => !value)}
+          >
+            {isExpandable && (
+              <StyledIconChevron direction={isExpanded ? 'down' : 'right'} />
+            )}
+            <ProjectBadge project={project} disableLink avatarSize={16} />
+          </HiddenButton>
+          {hasAccess && (
+            <SettingsButton
+              title={t('Open Project Settings')}
+              aria-label={t('Open Project Settings')}
+              size="xs"
+              priority="link"
+              icon={<IconSettings />}
+              to={`/organizations/${organization.slug}/settings/projects/${project.slug}/performance`}
+            />
+          )}
+        </FirstCellLine>
+        <SubProjects>{subProjectContent}</SubProjects>
+      </Cell>
+      <Cell>
+        <FirstCellLine data-align="right">{formatAbbreviatedNumber(count)}</FirstCellLine>
+        <SubSpans>{subSpansContent}</SubSpans>
+      </Cell>
+      <Cell>
+        <FirstCellLine>
+          <Tooltip
+            disabled={canEdit}
+            title={t('To edit project sample rates, switch to manual sampling mode.')}
+          >
+            <InputGroup
+              css={css`
+                width: 160px;
+              `}
+            >
+              <InputGroup.Input
+                type="number"
+                disabled={!canEdit}
+                onChange={handleChange}
+                min={0}
+                max={100}
+                size="sm"
+                value={sampleRate}
+              />
+              <InputGroup.TrailingItems>
+                <TrailingPercent>%</TrailingPercent>
+              </InputGroup.TrailingItems>
+            </InputGroup>
+          </Tooltip>
+        </FirstCellLine>
+        {error ? (
+          <ErrorMessage>{error}</ErrorMessage>
+        ) : sampleRate !== initialSampleRate ? (
+          <SmallPrint>{t('previous: %s%%', initialSampleRate)}</SmallPrint>
+        ) : null}
+      </Cell>
+    </Fragment>
+  );
+});
+
+const StyledPanelTable = styled(PanelTable)`
+  grid-template-columns: 1fr max-content max-content;
+`;
+
+const SmallPrint = styled('span')`
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  color: ${p => p.theme.subText};
+  line-height: 1.5;
+  text-align: right;
+`;
+
+const ErrorMessage = styled('span')`
+  color: ${p => p.theme.error};
+  font-size: ${p => p.theme.fontSizeExtraSmall};
+  line-height: 1.5;
+  text-align: right;
+`;
+
+const SortableHeader = styled('button')`
+  border: none;
+  background: none;
+  cursor: pointer;
+  display: flex;
+  text-transform: inherit;
+  align-items: center;
+  gap: ${space(0.5)};
+`;
+
+const Cell = styled('div')`
+  display: flex;
+  flex-direction: column;
+  gap: ${space(0.25)};
+`;
+
+const SectionHeaderCell = styled('div')`
+  display: flex;
+  grid-column: span 3;
+  padding: ${space(1.5)};
+  align-items: center;
+  background: ${p => p.theme.backgroundSecondary};
+  color: ${p => p.theme.subText};
+  cursor: pointer;
+`;
+
+const FirstCellLine = styled('div')`
+  display: flex;
+  align-items: center;
+  height: 32px;
+  & > * {
+    flex-shrink: 0;
+  }
+  &[data-align='right'] {
+    justify-content: flex-end;
+  }
+  &[data-has-chevron='false'] {
+    padding-left: ${space(2)};
+  }
+`;
+
+const SubProjects = styled('div')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+  margin-left: ${space(2)};
+  & > div {
+    line-height: 2;
+    margin-right: -${space(2)};
+    padding-right: ${space(2)};
+    margin-left: -${space(1)};
+    padding-left: ${space(1)};
+    border-top-left-radius: ${p => p.theme.borderRadius};
+    border-bottom-left-radius: ${p => p.theme.borderRadius};
+    &:nth-child(odd) {
+      background: ${p => p.theme.backgroundSecondary};
+    }
+  }
+`;
+
+const SubSpans = styled('div')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeSmall};
+  text-align: right;
+  & > div {
+    line-height: 2;
+    margin-left: -${space(2)};
+    padding-left: ${space(2)};
+    margin-right: -${space(1)};
+    padding-right: ${space(1)};
+    border-top-right-radius: ${p => p.theme.borderRadius};
+    border-bottom-right-radius: ${p => p.theme.borderRadius};
+    &:nth-child(odd) {
+      background: ${p => p.theme.backgroundSecondary};
+    }
+  }
+`;
+
+const HiddenButton = styled('button')`
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+
+  /* Overwrite the platform icon's cursor style */
+  &:not([disabled]) img {
+    cursor: pointer;
+  }
+`;
+
+const StyledIconChevron = styled(IconChevron)`
+  height: 12px;
+  width: 12px;
+  margin-right: ${space(0.5)};
+  color: ${p => p.theme.subText};
+`;
+
+const SettingsButton = styled(LinkButton)`
+  margin-left: ${space(0.5)};
+  color: ${p => p.theme.subText};
+  visibility: hidden;
+
+  &:focus {
+    visibility: visible;
+  }
+  ${Cell}:hover & {
+    visibility: visible;
+  }
+`;
+
+const TrailingPercent = styled('strong')`
+  padding: 0 ${space(0.25)};
+`;

+ 87 - 35
static/app/views/settings/dynamicSampling/utils/formContext.tsx

@@ -1,6 +1,9 @@
-import {createContext, useCallback, useContext, useState} from 'react';
+import {createContext, useCallback, useContext, useMemo, useState} from 'react';
 
-interface FormState<FormFields extends Record<string, any>> {
+interface FormState<
+  FormFields extends PlainValue,
+  FieldErrors extends Record<keyof FormFields, any>,
+> {
   /**
    * State for each field in the form.
    */
@@ -8,9 +11,9 @@ interface FormState<FormFields extends Record<string, any>> {
     [K in keyof FormFields]: {
       hasChanged: boolean;
       initialValue: FormFields[K];
-      onChange: (value: FormFields[K]) => void;
+      onChange: (value: React.SetStateAction<FormFields[K]>) => void;
       value: FormFields[K];
-      error?: string;
+      error?: FieldErrors[K];
     };
   };
   /**
@@ -32,8 +35,18 @@ interface FormState<FormFields extends Record<string, any>> {
   save: () => void;
 }
 
-export type FormValidators<FormFields extends Record<string, any>> = {
-  [K in keyof FormFields]?: (value: FormFields[K]) => string | undefined;
+type PlainValue = AtomicValue | PlainArray | PlainObject;
+interface PlainObject {
+  [key: string]: PlainValue;
+}
+interface PlainArray extends Array<PlainValue> {}
+type AtomicValue = string | number | boolean | null | undefined;
+
+export type FormValidators<
+  FormFields extends Record<string, PlainValue>,
+  FieldErrors extends Record<keyof FormFields, any>,
+> = {
+  [K in keyof FormFields]?: (value: FormFields[K]) => FieldErrors[K];
 };
 
 type InitialValues<FormFields extends Record<string, any>> = {
@@ -43,17 +56,24 @@ type InitialValues<FormFields extends Record<string, any>> = {
 /**
  * Creates a form state object with fields and validation for a given set of form fields.
  */
-export const useFormState = <FormFields extends Record<string, any>>(config: {
+export const useFormState = <
+  FormFields extends Record<string, PlainValue>,
+  FieldErrors extends Record<keyof FormFields, any>,
+>(config: {
   initialValues: InitialValues<FormFields>;
-  validators?: FormValidators<FormFields>;
-}): FormState<FormFields> => {
+  validators?: FormValidators<FormFields, FieldErrors>;
+}): FormState<FormFields, FieldErrors> => {
   const [initialValues, setInitialValues] = useState(config.initialValues);
+  const [validators] = useState(config.validators);
   const [values, setValues] = useState(initialValues);
-  const [errors, setErrors] = useState<{[K in keyof FormFields]?: string}>({});
+  const [errors, setErrors] = useState<{[K in keyof FormFields]?: FieldErrors[K]}>({});
 
   const setValue = useCallback(
-    <K extends keyof FormFields>(name: K, value: FormFields[K]) => {
-      setValues(old => ({...old, [name]: value}));
+    <K extends keyof FormFields>(name: K, value: React.SetStateAction<FormFields[K]>) => {
+      setValues(old => ({
+        ...old,
+        [name]: typeof value === 'function' ? value(old[name]) : value,
+      }));
     },
     []
   );
@@ -70,31 +90,55 @@ export const useFormState = <FormFields extends Record<string, any>>(config: {
    */
   const validateField = useCallback(
     <K extends keyof FormFields>(name: K, value: FormFields[K]) => {
-      const validator = config.validators?.[name];
+      const validator = validators?.[name];
       return validator?.(value);
     },
-    [config.validators]
+    [validators]
   );
 
-  const handleFieldChange = <K extends keyof FormFields>(
-    name: K,
-    value: FormFields[K]
-  ) => {
-    setValue(name, value);
-    setError(name, validateField(name, value));
-  };
+  const handleFieldChange = useCallback(
+    <K extends keyof FormFields>(name: K, value: React.SetStateAction<FormFields[K]>) => {
+      setValue(name, old => {
+        const newValue = typeof value === 'function' ? value(old) : value;
+        const error = validateField(name, newValue);
+        setError(name, error);
+        return newValue;
+      });
+    },
+    [setError, setValue, validateField]
+  );
 
-  return {
-    fields: Object.entries(values).reduce((acc, [name, value]) => {
-      acc[name as keyof FormFields] = {
-        value,
-        onChange: inputValue => handleFieldChange(name as keyof FormFields, inputValue),
-        error: errors[name as keyof FormFields],
-        hasChanged: value !== initialValues[name],
+  const changeHandlers = useMemo(() => {
+    const result: {
+      [K in keyof FormFields]: (value: React.SetStateAction<FormFields[K]>) => void;
+    } = {} as any;
+
+    for (const name in initialValues) {
+      result[name] = (value: React.SetStateAction<FormFields[typeof name]>) =>
+        handleFieldChange(name, value);
+    }
+
+    return result;
+  }, [handleFieldChange, initialValues]);
+
+  const fields = useMemo(() => {
+    const result: FormState<FormFields, FieldErrors>['fields'] = {} as any;
+
+    for (const name in initialValues) {
+      result[name] = {
+        value: values[name],
+        onChange: changeHandlers[name],
+        error: errors[name],
+        hasChanged: values[name] !== initialValues[name],
         initialValue: initialValues[name],
       };
-      return acc;
-    }, {} as any),
+    }
+
+    return result;
+  }, [changeHandlers, errors, initialValues, values]);
+
+  return {
+    fields,
     isValid: Object.values(errors).every(error => !error),
     hasChanged: Object.entries(values).some(
       ([name, value]) => value !== initialValues[name]
@@ -112,19 +156,27 @@ export const useFormState = <FormFields extends Record<string, any>>(config: {
 /**
  * Creates a form context and hooks for a form with a given set of fields to enable type-safe form handling.
  */
-export const createForm = <FormFields extends Record<string, any>>({
+export const createForm = <
+  FormFields extends Record<string, PlainValue>,
+  FieldErrors extends Record<keyof FormFields, any> = Record<
+    keyof FormFields,
+    string | undefined
+  >,
+>({
   validators,
 }: {
-  validators?: FormValidators<FormFields>;
+  validators?: FormValidators<FormFields, FieldErrors>;
 }) => {
-  const FormContext = createContext<FormState<FormFields> | undefined>(undefined);
+  const FormContext = createContext<FormState<FormFields, FieldErrors> | undefined>(
+    undefined
+  );
 
   function FormProvider({
     children,
     formState,
   }: {
     children: React.ReactNode;
-    formState: FormState<FormFields>;
+    formState: FormState<FormFields, FieldErrors>;
   }) {
     return <FormContext.Provider value={formState}>{children}</FormContext.Provider>;
   }
@@ -140,7 +192,7 @@ export const createForm = <FormFields extends Record<string, any>>({
 
   return {
     useFormState: (initialValues: InitialValues<FormFields>) =>
-      useFormState<FormFields>({initialValues, validators}),
+      useFormState<FormFields, FieldErrors>({initialValues, validators}),
     FormProvider,
     useFormField,
   };

+ 2 - 2
static/app/views/settings/dynamicSampling/utils/organizationSamplingForm.tsx

@@ -13,12 +13,12 @@ export const organizationSamplingForm = createForm<FormFields>({
       }
 
       const numericValue = Number(value);
-      if (isNaN(numericValue) ? t('Please enter a valid number.') : undefined) {
+      if (isNaN(numericValue)) {
         return t('Please enter a valid number.');
       }
 
       if (numericValue < 0 || numericValue > 100) {
-        return t('The sample rate must be between 0% and 100%');
+        return t('Must be between 0% and 100%');
       }
       return undefined;
     },

+ 35 - 0
static/app/views/settings/dynamicSampling/utils/projectSamplingForm.tsx

@@ -0,0 +1,35 @@
+import {t} from 'sentry/locale';
+import {createForm} from 'sentry/views/settings/dynamicSampling/utils/formContext';
+
+type FormFields = {
+  projectRates: {[id: string]: string};
+};
+
+type FormErrors = {
+  projectRates: Record<string, string>;
+};
+
+export const projectSamplingForm = createForm<FormFields, FormErrors>({
+  validators: {
+    projectRates: value => {
+      const errors: Record<string, string> = {};
+
+      Object.entries(value).forEach(([projectId, rate]) => {
+        if (rate === '') {
+          errors[projectId] = t('This field is required');
+        }
+
+        const numericRate = Number(rate);
+        if (isNaN(numericRate)) {
+          errors[projectId] = t('Please enter a valid number');
+        }
+
+        if (numericRate < 0 || numericRate > 100) {
+          errors[projectId] = t('Must be between 0% and 100%');
+        }
+      });
+
+      return errors;
+    },
+  },
+});