Browse Source

feat(performance): Add queues module tables components (#69354)

Adds Queues module table components with tests
edwardgou-sentry 10 months ago
parent
commit
bdb732f1c8

+ 87 - 0
static/app/views/performance/queues/destinationSummary/transactionsTable.spec.tsx

@@ -0,0 +1,87 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useOrganization from 'sentry/utils/useOrganization';
+import {TransactionsTable} from 'sentry/views/performance/queues/destinationSummary/transactionsTable';
+
+jest.mock('sentry/utils/useOrganization');
+
+describe('transactionsTable', () => {
+  const organization = OrganizationFixture();
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  let eventsMock;
+
+  beforeEach(() => {
+    eventsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      body: {
+        data: [
+          {
+            transaction: 'celery.backend_cleanup',
+            'avg_if(span.self_time,span.op,queue.task.celery)': 3,
+            'sum(span.self_time)': 6,
+            'count_op(queue.submit.celery)': 0,
+            'count_op(queue.task.celery)': 2,
+            'avg_if(span.self_time,span.op,queue.submit.celery)': 0,
+            'count()': 2,
+            'avg(span.self_time)': 3,
+          },
+        ],
+        meta: {
+          fields: {
+            'avg_if(span.self_time,span.op,queue.task.celery)': 'duration',
+            'count_op(queue.submit.celery)': 'integer',
+            'avg_if(span.self_time,span.op,queue.submit.celery)': 'duration',
+            'count_op(queue.task.celery)': 'integer',
+            'sum(span.self_time)': 'duration',
+            'count()': 'integer',
+            'avg(span.self_time)': 'duration',
+          },
+        },
+      },
+    });
+  });
+  it('renders', async () => {
+    render(<TransactionsTable />);
+    expect(screen.getByRole('table', {name: 'Transactions'})).toBeInTheDocument();
+
+    expect(screen.getByRole('columnheader', {name: 'Transactions'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Type'})).toBeInTheDocument();
+    expect(
+      screen.getByRole('columnheader', {name: 'Avg Time in Queue'})
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('columnheader', {name: 'Avg Processing Time'})
+    ).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Error Rate'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Published'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Processed'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Time Spent'})).toBeInTheDocument();
+
+    expect(eventsMock).toHaveBeenCalledWith(
+      '/organizations/org-slug/events/',
+      expect.objectContaining({
+        query: expect.objectContaining({
+          field: [
+            'transaction',
+            'count()',
+            'count_op(queue.submit.celery)',
+            'count_op(queue.task.celery)',
+            'sum(span.self_time)',
+            'avg(span.self_time)',
+            'avg_if(span.self_time,span.op,queue.submit.celery)',
+            'avg_if(span.self_time,span.op,queue.task.celery)',
+          ],
+          dataset: 'spansMetrics',
+        }),
+      })
+    );
+    await screen.findByText('celery.backend_cleanup');
+    expect(screen.getByRole('cell', {name: '3.00ms'})).toBeInTheDocument();
+    expect(screen.getByRole('cell', {name: '2'})).toBeInTheDocument();
+    expect(screen.getByRole('cell', {name: '6.00ms'})).toBeInTheDocument();
+  });
+});

+ 214 - 0
static/app/views/performance/queues/destinationSummary/transactionsTable.tsx

@@ -0,0 +1,214 @@
+import {Fragment} from 'react';
+import {browserHistory, Link} from 'react-router';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+import qs from 'qs';
+
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  type GridColumnHeader,
+} from 'sentry/components/gridEditable';
+import type {CursorHandler} from 'sentry/components/pagination';
+import Pagination from 'sentry/components/pagination';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types';
+import type {EventsMetaType} from 'sentry/utils/discover/eventView';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {DEFAULT_QUERY_FILTER} from 'sentry/views/performance/queues/settings';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
+import type {MetricsResponse} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type Row = Pick<
+  MetricsResponse,
+  | 'avg_if(span.self_time,span.op,queue.task.celery)'
+  | 'count_op(queue.submit.celery)'
+  | 'count_op(queue.task.celery)'
+  | 'sum(span.self_time)'
+  | 'transaction'
+>;
+
+type Column = GridColumnHeader<string>;
+
+const COLUMN_ORDER: Column[] = [
+  {
+    key: 'transaction',
+    name: t('Transactions'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: '', // TODO
+    name: t('Type'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: '', // TODO
+    name: t('Avg Time in Queue'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'avg_if(span.self_time,span.op,queue.task.celery)',
+    name: t('Avg Processing Time'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: '', // TODO
+    name: t('Error Rate'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'count_op(queue.submit.celery)',
+    name: t('Published'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'count_op(queue.task.celery)',
+    name: t('Processed'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'sum(span.self_time)',
+    name: t('Time Spent'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+];
+
+interface Props {
+  domain?: string;
+  error?: Error | null;
+  meta?: EventsMetaType;
+  pageLinks?: string;
+}
+
+export function TransactionsTable({error, pageLinks}: Props) {
+  const organization = useOrganization();
+  const location = useLocation();
+  const destination = decodeScalar(location.query.destination);
+  const cursor = decodeScalar(location.query?.[QueryParameterNames.DOMAINS_CURSOR]);
+
+  const mutableSearch = new MutableSearch(DEFAULT_QUERY_FILTER);
+  // TODO: This should filter by destination, not transaction.
+  // We are using transaction for now as a proxy to demo some functionality until destination becomes a filterable tag.
+  if (destination) {
+    mutableSearch.addFilterValue('transaction', destination);
+  }
+  const {data, isLoading, meta} = useSpanMetrics({
+    search: mutableSearch,
+    fields: [
+      'transaction',
+      'count()',
+      'count_op(queue.submit.celery)',
+      'count_op(queue.task.celery)',
+      'sum(span.self_time)',
+      'avg(span.self_time)',
+      'avg_if(span.self_time,span.op,queue.submit.celery)',
+      'avg_if(span.self_time,span.op,queue.task.celery)',
+    ],
+    sorts: [],
+    limit: 10,
+    cursor,
+    referrer: 'api.starfish.http-module-landing-domains-list',
+  });
+
+  const handleCursor: CursorHandler = (newCursor, pathname, query) => {
+    browserHistory.push({
+      pathname,
+      query: {...query, [QueryParameterNames.TRANSACTIONS_CURSOR]: newCursor},
+    });
+  };
+
+  return (
+    <Fragment>
+      <GridEditable
+        aria-label={t('Transactions')}
+        isLoading={isLoading}
+        error={error}
+        data={data}
+        columnOrder={COLUMN_ORDER}
+        columnSortBy={[]}
+        grid={{
+          renderHeadCell: col =>
+            renderHeadCell({
+              column: col,
+              location,
+            }),
+          renderBodyCell: (column, row) =>
+            renderBodyCell(column, row, meta, location, organization),
+        }}
+        location={location}
+      />
+
+      <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
+    </Fragment>
+  );
+}
+
+function renderBodyCell(
+  column: Column,
+  row: Row,
+  meta: EventsMetaType | undefined,
+  location: Location,
+  organization: Organization
+) {
+  const key = column.key;
+  if (row[key] === undefined) {
+    return (
+      <AlignRight>
+        <NoValue>{' \u2014 '}</NoValue>
+      </AlignRight>
+    );
+  }
+
+  if (key === 'transaction') {
+    return <TransactionCell transaction={row[key]} />;
+  }
+
+  if (!meta?.fields) {
+    return row[column.key];
+  }
+
+  const renderer = getFieldRenderer(column.key, meta.fields, false);
+  return renderer(row, {
+    location,
+    organization,
+    unit: meta.units?.[column.key],
+  });
+}
+
+function TransactionCell({transaction}: {transaction: string}) {
+  const organization = useOrganization();
+  const {query} = useLocation();
+  const queryString = {
+    ...query,
+    transaction,
+  };
+  return (
+    <NoOverflow>
+      <Link
+        to={normalizeUrl(
+          `/organizations/${organization.slug}/performance/queues/destination/?${qs.stringify(queryString)}`
+        )}
+      >
+        {transaction}
+      </Link>
+    </NoOverflow>
+  );
+}
+
+const NoOverflow = styled('span')`
+  overflow: hidden;
+`;
+
+const AlignRight = styled('span')`
+  text-align: right;
+`;
+
+const NoValue = styled('span')`
+  color: ${p => p.theme.gray300};
+`;

+ 26 - 0
static/app/views/performance/queues/messageSpanSamplesTable.spec.tsx

@@ -0,0 +1,26 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useOrganization from 'sentry/utils/useOrganization';
+import {MessageSpanSamplesTable} from 'sentry/views/performance/queues/messageSpanSamplesTable';
+
+jest.mock('sentry/utils/useOrganization');
+
+describe('messageSpanSamplesTable', () => {
+  const organization = OrganizationFixture();
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  beforeEach(() => {});
+  it('renders', () => {
+    render(<MessageSpanSamplesTable data={[]} isLoading={false} />);
+    expect(screen.getByRole('table', {name: 'Span Samples'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Span ID'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Message ID'})).toBeInTheDocument();
+    expect(
+      screen.getByRole('columnheader', {name: 'Processing Latency'})
+    ).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Message Size'})).toBeInTheDocument();
+    expect(screen.getByRole('columnheader', {name: 'Status'})).toBeInTheDocument();
+  });
+});

+ 144 - 0
static/app/views/performance/queues/messageSpanSamplesTable.tsx

@@ -0,0 +1,144 @@
+import type {ComponentProps} from 'react';
+import type {Location} from 'history';
+
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  type GridColumnHeader,
+} from 'sentry/components/gridEditable';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import type {EventsMetaType} from 'sentry/utils/discover/eventView';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import {SpanIdCell} from 'sentry/views/starfish/components/tableCells/spanIdCell';
+import type {IndexedResponse} from 'sentry/views/starfish/types';
+import {SpanIndexedField} from 'sentry/views/starfish/types';
+
+type DataRowKeys =
+  | SpanIndexedField.PROJECT
+  | SpanIndexedField.TRANSACTION_ID
+  | SpanIndexedField.TRACE
+  | SpanIndexedField.TIMESTAMP
+  | SpanIndexedField.ID
+  | SpanIndexedField.SPAN_DESCRIPTION
+  | SpanIndexedField.RESPONSE_CODE;
+
+type ColumnKeys =
+  | SpanIndexedField.ID
+  | SpanIndexedField.MESSAGE_ID
+  | SpanIndexedField.MESSAGE_SIZE
+  | SpanIndexedField.MESSAGE_STATUS
+  | SpanIndexedField.SPAN_SELF_TIME;
+
+type DataRow = Pick<IndexedResponse, DataRowKeys>;
+
+type Column = GridColumnHeader<ColumnKeys>;
+
+const COLUMN_ORDER: Column[] = [
+  {
+    key: SpanIndexedField.ID,
+    name: t('Span ID'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanIndexedField.MESSAGE_ID,
+    name: t('Message ID'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanIndexedField.SPAN_SELF_TIME,
+    name: t('Processing Latency'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanIndexedField.MESSAGE_SIZE,
+    name: t('Message Size'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanIndexedField.MESSAGE_STATUS,
+    name: t('Status'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+];
+
+interface Props {
+  data: DataRow[];
+  isLoading: boolean;
+  error?: Error | null;
+  highlightedSpanId?: string;
+  meta?: EventsMetaType;
+  onSampleMouseOut?: ComponentProps<typeof GridEditable>['onRowMouseOut'];
+  onSampleMouseOver?: ComponentProps<typeof GridEditable>['onRowMouseOver'];
+}
+
+export function MessageSpanSamplesTable({
+  data,
+  isLoading,
+  error,
+  meta,
+  onSampleMouseOver,
+  onSampleMouseOut,
+  highlightedSpanId,
+}: Props) {
+  const location = useLocation();
+  const organization = useOrganization();
+
+  return (
+    <GridEditable
+      aria-label={t('Span Samples')}
+      isLoading={isLoading}
+      error={error}
+      data={data}
+      columnOrder={COLUMN_ORDER}
+      columnSortBy={[]}
+      grid={{
+        renderHeadCell: col =>
+          renderHeadCell({
+            column: col,
+            location,
+          }),
+        renderBodyCell: (column, row) =>
+          renderBodyCell(column, row, meta, location, organization),
+      }}
+      highlightedRowKey={data.findIndex(row => row.span_id === highlightedSpanId)}
+      onRowMouseOver={onSampleMouseOver}
+      onRowMouseOut={onSampleMouseOut}
+      location={location}
+    />
+  );
+}
+
+function renderBodyCell(
+  column: Column,
+  row: DataRow,
+  meta: EventsMetaType | undefined,
+  location: Location,
+  organization: Organization
+) {
+  if (column.key === SpanIndexedField.ID) {
+    return (
+      <SpanIdCell
+        projectSlug={row.project}
+        traceId={row.trace}
+        timestamp={row.timestamp}
+        transactionId={row[SpanIndexedField.TRANSACTION_ID]}
+        spanId={row[SpanIndexedField.ID]}
+      />
+    );
+  }
+
+  if (!meta?.fields) {
+    return row[column.key];
+  }
+
+  const renderer = getFieldRenderer(column.key, meta.fields, false);
+
+  return renderer(row, {
+    location,
+    organization,
+    unit: meta.units?.[column.key],
+  });
+}

+ 79 - 0
static/app/views/performance/queues/queuesTable.spec.tsx

@@ -0,0 +1,79 @@
+import {OrganizationFixture} from 'sentry-fixture/organization';
+
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import useOrganization from 'sentry/utils/useOrganization';
+import {QueuesTable} from 'sentry/views/performance/queues/queuesTable';
+
+jest.mock('sentry/utils/useOrganization');
+
+describe('queuesTable', () => {
+  const organization = OrganizationFixture();
+  jest.mocked(useOrganization).mockReturnValue(organization);
+
+  let eventsMock;
+
+  beforeEach(() => {
+    eventsMock = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      body: {
+        data: [
+          {
+            transaction: 'celery.backend_cleanup',
+            'avg_if(span.self_time,span.op,queue.task.celery)': 3,
+            'sum(span.self_time)': 6,
+            'count_op(queue.submit.celery)': 0,
+            'count_op(queue.task.celery)': 2,
+            'avg_if(span.self_time,span.op,queue.submit.celery)': 0,
+            'count()': 2,
+            'avg(span.self_time)': 3,
+          },
+        ],
+        meta: {
+          fields: {
+            'avg_if(span.self_time,span.op,queue.task.celery)': 'duration',
+            'count_op(queue.submit.celery)': 'integer',
+            'avg_if(span.self_time,span.op,queue.submit.celery)': 'duration',
+            'count_op(queue.task.celery)': 'integer',
+            'sum(span.self_time)': 'duration',
+            'count()': 'integer',
+            'avg(span.self_time)': 'duration',
+          },
+        },
+      },
+    });
+  });
+  it('renders', async () => {
+    render(<QueuesTable />);
+    screen.getByText('Destination');
+    screen.getByText('Avg Time in Queue');
+    screen.getByText('Avg Processing Time');
+    screen.getByText('Error Rate');
+    screen.getByText('Published');
+    screen.getByText('Processed');
+    screen.getByText('Time Spent');
+    expect(eventsMock).toHaveBeenCalledWith(
+      '/organizations/org-slug/events/',
+      expect.objectContaining({
+        query: expect.objectContaining({
+          field: [
+            'transaction',
+            'count()',
+            'count_op(queue.submit.celery)',
+            'count_op(queue.task.celery)',
+            'sum(span.self_time)',
+            'avg(span.self_time)',
+            'avg_if(span.self_time,span.op,queue.submit.celery)',
+            'avg_if(span.self_time,span.op,queue.task.celery)',
+          ],
+          dataset: 'spansMetrics',
+        }),
+      })
+    );
+    await screen.findByText('celery.backend_cleanup');
+    screen.getByText('3.00ms');
+    screen.getByText(2);
+    screen.getByText('6.00ms');
+  });
+});

+ 190 - 0
static/app/views/performance/queues/queuesTable.tsx

@@ -0,0 +1,190 @@
+import {Fragment} from 'react';
+import {browserHistory, Link} from 'react-router';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+import qs from 'qs';
+
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  type GridColumnHeader,
+} from 'sentry/components/gridEditable';
+import type {CursorHandler} from 'sentry/components/pagination';
+import Pagination from 'sentry/components/pagination';
+import {t} from 'sentry/locale';
+import type {Organization} from 'sentry/types';
+import type {EventsMetaType} from 'sentry/utils/discover/eventView';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import {formatAbbreviatedNumber, formatPercentage} from 'sentry/utils/formatters';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {useQueueByTransactionQuery} from 'sentry/views/performance/queues/queries/useQueuesByTransactionQuery';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import type {MetricsResponse} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type Row = Pick<
+  MetricsResponse,
+  | 'avg_if(span.self_time,span.op,queue.task.celery)'
+  | 'count_op(queue.submit.celery)'
+  | 'count_op(queue.task.celery)'
+  | 'sum(span.self_time)'
+  | 'transaction'
+>;
+
+type Column = GridColumnHeader<string>;
+
+const COLUMN_ORDER: Column[] = [
+  // TODO: Needs to be updated to display an actual destination, not transaction
+  {
+    key: 'transaction',
+    name: t('Destination'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: '', // TODO
+    name: t('Avg Time in Queue'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'avg_if(span.self_time,span.op,queue.task.celery)',
+    name: t('Avg Processing Time'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'failure_rate()',
+    name: t('Error Rate'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'count_op(queue.submit.celery)',
+    name: t('Published'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'count_op(queue.task.celery)',
+    name: t('Processed'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'sum(span.self_time)',
+    name: t('Time Spent'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+];
+
+interface Props {
+  domain?: string;
+  error?: Error | null;
+  meta?: EventsMetaType;
+  pageLinks?: string;
+}
+
+export function QueuesTable({error, pageLinks}: Props) {
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const {data, isLoading, meta} = useQueueByTransactionQuery({});
+
+  const handleCursor: CursorHandler = (newCursor, pathname, query) => {
+    browserHistory.push({
+      pathname,
+      query: {...query, [QueryParameterNames.TRANSACTIONS_CURSOR]: newCursor},
+    });
+  };
+
+  return (
+    <Fragment>
+      <GridEditable
+        aria-label={t('Queues')}
+        isLoading={isLoading}
+        error={error}
+        data={data}
+        columnOrder={COLUMN_ORDER}
+        columnSortBy={[]}
+        grid={{
+          renderHeadCell: col =>
+            renderHeadCell({
+              column: col,
+              location,
+            }),
+          renderBodyCell: (column, row) =>
+            renderBodyCell(column, row, meta, location, organization),
+        }}
+        location={location}
+      />
+
+      <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
+    </Fragment>
+  );
+}
+
+function renderBodyCell(
+  column: Column,
+  row: Row,
+  meta: EventsMetaType | undefined,
+  location: Location,
+  organization: Organization
+) {
+  const key = column.key;
+  if (row[key] === undefined) {
+    return (
+      <AlignRight>
+        <NoValue>{' \u2014 '}</NoValue>
+      </AlignRight>
+    );
+  }
+
+  if (key === 'transaction') {
+    return <DestinationCell destination={row[key]} />;
+  }
+
+  if (key.startsWith('count')) {
+    return <AlignRight>{formatAbbreviatedNumber(row[key])}</AlignRight>;
+  }
+
+  if (key === 'failure_rate()') {
+    return <AlignRight>{formatPercentage(row[key])}</AlignRight>;
+  }
+
+  if (!meta?.fields) {
+    return row[column.key];
+  }
+
+  const renderer = getFieldRenderer(column.key, meta.fields, false);
+  return renderer(row, {
+    location,
+    organization,
+    unit: meta.units?.[column.key],
+  });
+}
+
+function DestinationCell({destination}: {destination: string}) {
+  const organization = useOrganization();
+  const queryString = {
+    destination,
+  };
+  return (
+    <NoOverflow>
+      <Link
+        to={normalizeUrl(
+          `/organizations/${organization.slug}/performance/queues/destination/?${qs.stringify(queryString)}`
+        )}
+      >
+        {destination}
+      </Link>
+    </NoOverflow>
+  );
+}
+
+const NoOverflow = styled('span')`
+  overflow: hidden;
+`;
+
+const AlignRight = styled('span')`
+  text-align: right;
+`;
+
+const NoValue = styled('span')`
+  color: ${p => p.theme.gray300};
+`;