diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 761b6f96f18c..08ff4ad1db01 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -707,6 +707,8 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get /tests/sentry/middleware/test_viewer_context.py @getsentry/machine-learning-ai /src/sentry/investigations/ @getsentry/machine-learning-ai @getsentry/agent-interfaces-sentry-backend /tests/sentry/investigations/ @getsentry/machine-learning-ai @getsentry/agent-interfaces-sentry-backend +/src/sentry/seer/workflows/ @getsentry/machine-learning-ai @getsentry/agent-interfaces-sentry-backend +/tests/sentry/seer/workflows/ @getsentry/machine-learning-ai @getsentry/agent-interfaces-sentry-backend ## End of ML & AI @@ -916,6 +918,7 @@ tests/sentry/api/endpoints/test_organization_attribute_mappings.py @get # Agent Interfaces /static/app/views/investigations/ @getsentry/agent-interfaces-sentry-frontend +/static/app/views/seerWorkflows/ @getsentry/agent-interfaces-sentry-frontend /static/app/components/seer/ @getsentry/agent-interfaces-sentry-frontend /static/app/components/prevent/ @getsentry/agent-interfaces-sentry-frontend /static/app/views/prevent/ @getsentry/agent-interfaces-sentry-frontend diff --git a/static/app/oxlint-suppressions.json b/static/app/oxlint-suppressions.json index 23bafecd5a35..1768a3a0fe27 100644 --- a/static/app/oxlint-suppressions.json +++ b/static/app/oxlint-suppressions.json @@ -3129,11 +3129,6 @@ "count": 1 } }, - "views/seerWorkflows/index.spec.tsx": { - "@sentry/require-fake-timer-cleanup": { - "count": 2 - } - }, "views/seerWorkflows/overview/index.spec.tsx": { "@sentry/prefer-fake-timers": { "count": 2 diff --git a/static/app/router/routes.spec.tsx b/static/app/router/routes.spec.tsx index 897cb22c253b..55cb251fdc14 100644 --- a/static/app/router/routes.spec.tsx +++ b/static/app/router/routes.spec.tsx @@ -252,6 +252,8 @@ describe('buildRoutes()', () => { it.each([ ['/seer/automations/', 'automations/', true], ['/organizations/test-org/seer/automations/', 'automations/', false], + ['/seer/automations/runs/', 'runs/', true], + ['/organizations/test-org/seer/automations/runs/', 'runs/', false], ['/seer/investigations/', 'investigations/', true], ['/seer/investigations/investigation-1/', 'investigations/:investigationId/', true], ['/organizations/test-org/seer/investigations/', 'investigations/', false], diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index a4da5222a79b..5bf37e40edee 100644 --- a/static/app/router/routes.tsx +++ b/static/app/router/routes.tsx @@ -2746,6 +2746,16 @@ function buildRoutes(): RouteObject[] { { path: 'automations/', component: make(() => import('sentry/views/seerWorkflows')), + children: [ + { + index: true, + component: make(() => import('sentry/views/seerWorkflows/automations')), + }, + { + path: 'runs/', + component: make(() => import('sentry/views/seerWorkflows/runs')), + }, + ], }, ]; const seerRoutes: SentryRouteObject = { diff --git a/static/app/views/seerWorkflows/automations/index.spec.tsx b/static/app/views/seerWorkflows/automations/index.spec.tsx new file mode 100644 index 000000000000..891b8d7d6a00 --- /dev/null +++ b/static/app/views/seerWorkflows/automations/index.spec.tsx @@ -0,0 +1,166 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {render, screen, within} from 'sentry-test/reactTestingLibrary'; + +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import AutomationsList, {getNextUpdate} from 'sentry/views/seerWorkflows/automations'; +import type {SeerWorkflowConfigsResponse} from 'sentry/views/seerWorkflows/types'; + +type Config = SeerWorkflowConfigsResponse['configs'][number]; + +function makeConfig(overrides: Partial): Config { + return { + id: '1', + strategy: 'custom_strategy', + enabled: true, + schedule: 'weekly', + version: 1, + projects: [], + environments: [], + filters: {}, + outputPolicy: {}, + seededDefault: null, + dateAdded: '2026-10-01T00:00:00Z', + dateUpdated: '2026-10-01T00:00:00Z', + ...overrides, + }; +} + +function getCard(title: string) { + return within(screen.getByRole('heading', {name: title}).closest('li')!); +} + +describe('AutomationsList', () => { + const organization = OrganizationFixture({features: ['seer-automations']}); + const configsUrl = `/organizations/${organization.slug}/seer/workflows/configs/`; + + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/projects/`, + body: [], + }); + ProjectsStore.loadInitialData([ + ProjectFixture({id: '1', slug: 'frontend'}), + ProjectFixture({id: '2', slug: 'backend'}), + ]); + }); + + function mockConfigs(configs: Config[]) { + return MockApiClient.addMockResponse({url: configsUrl, body: {configs}}); + } + + it('only renders placeholders without seer-automations', async () => { + const request = mockConfigs([makeConfig({id: '1'})]); + + render(, {organization: OrganizationFixture()}); + + expect(await screen.findAllByRole('listitem')).toHaveLength(2); + expect(request).not.toHaveBeenCalled(); + }); + + it('renders a card per config after the placeholders', async () => { + mockConfigs([ + makeConfig({id: '1', strategy: 'agentic_triage'}), + makeConfig({id: '2', strategy: 'custom_strategy'}), + ]); + + render(, {organization}); + + expect( + await screen.findByRole('heading', {name: 'Agentic triage'}) + ).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(4); + expect( + getCard('Agentic triage').getByText( + 'Automatically triage and fix the most fixable Issues' + ) + ).toBeInTheDocument(); + expect(getCard('custom_strategy').getByText('No description')).toBeInTheDocument(); + }); + + it('shows which projects each config covers', async () => { + mockConfigs([ + makeConfig({id: '1', strategy: 'all_strategy', projects: [-1]}), + makeConfig({id: '2', strategy: 'none_strategy', projects: []}), + makeConfig({id: '3', strategy: 'some_strategy', projects: [2, 99, 1]}), + ]); + + render(, {organization}); + + await screen.findByRole('heading', {name: 'some_strategy'}); + expect(getCard('all_strategy').getByText('All projects')).toBeInTheDocument(); + expect(getCard('none_strategy').getByText('No projects')).toBeInTheDocument(); + expect(getCard('some_strategy').getByText('backend, frontend')).toBeInTheDocument(); + }); + + it('shows a next run tag only for daily configs', async () => { + mockConfigs([ + makeConfig({id: '1', strategy: 'daily_strategy', schedule: 'daily'}), + makeConfig({id: '2', strategy: 'weekly_strategy', schedule: 'weekly'}), + ]); + + render(, {organization}); + + await screen.findByRole('heading', {name: 'daily_strategy'}); + expect(getCard('daily_strategy').getByText(/Next run/)).toBeInTheDocument(); + expect(getCard('weekly_strategy').queryByText(/Next run/)).not.toBeInTheDocument(); + }); + + it('shows a paused tag in place of the schedule for disabled configs', async () => { + mockConfigs([ + makeConfig({ + id: '1', + strategy: 'paused_strategy', + schedule: 'daily', + enabled: false, + }), + ]); + + render(, {organization}); + + await screen.findByRole('heading', {name: 'paused_strategy'}); + expect(getCard('paused_strategy').getByText('Paused')).toBeInTheDocument(); + expect(getCard('paused_strategy').queryByText(/Next run/)).not.toBeInTheDocument(); + }); +}); + +describe('getNextUpdate', () => { + afterEach(() => { + try { + jest.runOnlyPendingTimers(); + } finally { + jest.useRealTimers(); + } + }); + + it('returns undefined for schedules other than daily', () => { + jest.useFakeTimers({now: new Date('2026-10-23T09:00:00Z')}); + + expect(getNextUpdate('weekly')).toBeUndefined(); + expect(getNextUpdate('')).toBeUndefined(); + }); + + it.each([ + ['before 10:00', '2026-10-23T09:59:59Z', '2026-10-23T10:00:00.000Z'], + ['at 10:00', '2026-10-23T10:00:00Z', '2026-10-23T22:00:00.000Z'], + ['between 10:00 and 22:00', '2026-10-23T15:30:00Z', '2026-10-23T22:00:00.000Z'], + ['at 22:00', '2026-10-23T22:00:00Z', '2026-10-24T10:00:00.000Z'], + ['after 22:00', '2026-10-23T23:15:00Z', '2026-10-24T10:00:00.000Z'], + [ + 'after 22:00 at the end of a month', + '2026-10-31T23:00:00Z', + '2026-11-01T10:00:00.000Z', + ], + [ + 'after 22:00 at the end of a year', + '2026-12-31T23:00:00Z', + '2027-01-01T10:00:00.000Z', + ], + ])('picks the next daily run %s UTC', (_, now, expected) => { + jest.useFakeTimers({now: new Date(now)}); + + expect(getNextUpdate('daily')?.toISOString()).toBe(expected); + }); +}); diff --git a/static/app/views/seerWorkflows/automations/index.tsx b/static/app/views/seerWorkflows/automations/index.tsx new file mode 100644 index 000000000000..58020d9de729 --- /dev/null +++ b/static/app/views/seerWorkflows/automations/index.tsx @@ -0,0 +1,293 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; +import {IconAllProjects} from '@sentry/icons/allProjects'; +import {IconClock} from '@sentry/icons/clock'; +import {IconFire} from '@sentry/icons/fire'; +import {IconSentry} from '@sentry/icons/sentry'; +import {skipToken, useQuery} from '@tanstack/react-query'; + +import {Tag} from '@sentry/scraps/badge'; +import {InfoText} from '@sentry/scraps/info'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Heading, Text} from '@sentry/scraps/text'; + +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {ProjectList} from 'sentry/components/projectList'; +import {TimeSince} from 'sentry/components/timeSince'; +import {t, tct, tn} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {formatAbbreviatedNumber} from 'sentry/utils/formatters'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useProjects} from 'sentry/utils/useProjects'; +import {STRATEGY_META} from 'sentry/views/seerWorkflows/strategies'; +import type { + SeerWorkflowConfigResponse, + SeerWorkflowConfigsResponse, + WorkflowStrategy, +} from 'sentry/views/seerWorkflows/types'; +import {useAutomationsEnabled} from 'sentry/views/seerWorkflows/utils'; + +/** + * The narrowest a card may get before the grid drops to fewer columns per row. + */ +const MIN_CARD_WIDTH = '280px'; + +/** + * "daily" configs are picked up by the seer-night-shift cron, which fires at 10:00 + * and 22:00 UTC (see TASKWORKER_SCHEDULES in src/sentry/conf/server.py). + */ +const DAILY_SCHEDULE_HOURS = [10, 22]; + +/** + * How many project avatars/slugs a card shows before collapsing the rest. + */ +const MAX_VISIBLE_PROJECTS = 3; + +// TODO: Missing fields from backend - to add to configs API +type AdditionalConfigProps = { + /** + * Estimated cost of a single run, in US dollars + */ + costPerRun?: number; + description?: string; + title?: string; + /** + * Estimated tokens used per month + */ + tokensPerMonth?: number; + /** + * What triggers the automation, shown in place of its schedule + */ + trigger?: string; +}; + +// TODO: can just be Response type once API updated +type AutomationConfig = SeerWorkflowConfigResponse & AdditionalConfigProps; + +function makePlaceholderConfig( + id: string, + overrides: Partial +): AutomationConfig { + return { + id, + dateAdded: '', + dateUpdated: '', + enabled: true, + environments: [], + filters: {}, + outputPolicy: {}, + projects: [ALL_ACCESS_PROJECTS], + schedule: '', + seededDefault: null, + strategy: id, + version: 0, + ...overrides, + }; +} + +export default function AutomationsList() { + const organization = useOrganization(); + const automationsEnabled = useAutomationsEnabled(); + + const {data} = useQuery( + apiOptions.as()( + '/organizations/$organizationIdOrSlug/seer/workflows/configs/', + { + path: automationsEnabled ? {organizationIdOrSlug: organization.slug} : skipToken, + staleTime: 0, + } + ) + ); + + const configs: AutomationConfig[] = [ + // Placeholders for automations the configs endpoint doesn't return yet + makePlaceholderConfig('placeholder-agentic-triage', { + title: 'Agentic Triage', + description: 'Automatically triage and fix the most fixable issues.', + schedule: 'daily', + tokensPerMonth: 85_400_000, + costPerRun: 2.5, + }), + makePlaceholderConfig('placeholder-breached-metrics', { + title: 'Breached Metrics', + description: + 'Monitors backend metrics and investigates why certain key metrics were breached.', + projects: [6178942, 11276, 4511418012073984], + trigger: 'Triggered by metric exceptions', + tokensPerMonth: 341_000, + costPerRun: 0.052, + }), + ...(data?.configs ?? []), + ]; + + return ( + + {configs.map(config => ( + + ))} + + ); +} + +function AutomationCard({config}: {config: AutomationConfig}) { + const {projects} = useProjects(); + const strategyMeta = Object.hasOwn(STRATEGY_META, config.strategy) + ? STRATEGY_META[config.strategy as WorkflowStrategy] + : undefined; + + const {enabled, trigger, tokensPerMonth, costPerRun} = config; + const title = config.title ?? strategyMeta?.label ?? config.strategy; + const description = + config.description ?? strategyMeta?.description ?? t('No description'); + const projectSlugs = config.projects.includes(ALL_ACCESS_PROJECTS) + ? ['all'] + : config.projects + .map(id => projects.find(p => p.id === String(id))?.slug) + .filter((slug): slug is string => slug !== undefined); + const nextUpdate = getNextUpdate(config.schedule); + + const usage = [ + tokensPerMonth === undefined + ? null + : tct('[tokens] tokens/month', { + tokens: formatAbbreviatedNumber(tokensPerMonth, undefined, true), + }), + costPerRun === undefined + ? null + : tct('[cost]/run', { + cost: costPerRun.toLocaleString(undefined, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 3, + }), + }), + ].filter(part => part !== null); + + return ( + + {/* TODO(aliu): Link to the automation's page once it exists */} + + + + + {title} + + + + {enabled ? ( + trigger ? ( + + }> + {trigger} + + + ) : ( + nextUpdate && ( + + }> + {tct('Next run [time]', { + time: , + })} + + + ) + ) + ) : ( + + {t('Paused')} + + )} + {description && {description}} + {usage.length > 0 && ( + + {usage.map((part, index) => ( + + {index > 0 && ' | '} + {part} + + ))} + + )} + + {projectSlugs[0] === 'all' ? ( + + + + {t('All projects')} + + + ) : projectSlugs.length === 0 ? ( + + {t('No projects')} + + ) : ( + + + + + {projectSlugs.slice(0, MAX_VISIBLE_PROJECTS).join(', ')} + {projectSlugs.length > MAX_VISIBLE_PROJECTS && ( + + {' '} + + {tn( + '+%s more', + '+%s more', + projectSlugs.length - MAX_VISIBLE_PROJECTS + )} + + + )} + + + + )} + + + ); +} + +const CardStack = styled(Stack)` + &:hover { + background: ${p => p.theme.tokens.interactive.transparent.neutral.background.hover}; + } +`; + +/** + * When an automation with this schedule next runs. "daily" is the only supported + * schedule for now. + */ +export function getNextUpdate(schedule: string): Date | undefined { + if (schedule !== 'daily') { + return undefined; + } + const now = new Date(); + for (const hour of DAILY_SCHEDULE_HOURS) { + const next = new Date(now); + next.setUTCHours(hour, 0, 0, 0); + if (next > now) { + return next; + } + } + const tomorrow = new Date(now); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + tomorrow.setUTCHours(10, 0, 0, 0); + return tomorrow; +} diff --git a/static/app/views/seerWorkflows/index.spec.tsx b/static/app/views/seerWorkflows/index.spec.tsx index 926af92e6f2c..2667f8a4b2f2 100644 --- a/static/app/views/seerWorkflows/index.spec.tsx +++ b/static/app/views/seerWorkflows/index.spec.tsx @@ -1,768 +1,46 @@ import {OrganizationFixture} from 'sentry-fixture/organization'; -import {PullRequestFixture} from 'sentry-fixture/pullRequest'; -import {act, render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; +import {render, screen, within} from 'sentry-test/reactTestingLibrary'; -import SeerWorkflows from 'sentry/views/seerWorkflows'; +import AutomationsLayout from 'sentry/views/seerWorkflows'; -describe('SeerWorkflows', () => { - const organization = OrganizationFixture(); +describe('AutomationsLayout', () => { + const organization = OrganizationFixture({features: ['seer-automations']}); - beforeEach(() => { - MockApiClient.clearMockResponses(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('renders the Automations title with an alpha badge', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [], - }); - render(, {organization}); - expect(await screen.findByRole('heading', {name: 'Automations'})).toBeInTheDocument(); - expect(screen.getByLabelText('alpha')).toBeInTheDocument(); - }); - - it('hides the monitor scan trigger when its flag is disabled', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [], - }); - render(, {organization}); - expect(await screen.findByText('No workflow runs yet.')).toBeInTheDocument(); - expect(screen.queryByRole('button', {name: 'Run…'})).not.toBeInTheDocument(); - }); - - it('clears filters when starting a scan, expands it, and polls for completion', async () => { - jest.useFakeTimers(); - const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime}); - const scanOrganization = OrganizationFixture({ - features: ['seer-workflows-monitor-cleanup'], - }); - const url = `/organizations/${scanOrganization.slug}/seer/workflows/runs/`; - const previousRun = { - id: '1', - seerRunId: '45e94493-c356-4d2b-bb26-ae4e2e508a74', - strategy: 'duplicate_monitors', - source: null, - dateAdded: '2026-09-09T00:00:00Z', - extras: {status: 'complete'}, - results: [], - }; - MockApiClient.addMockResponse({url, body: [previousRun]}); - const startScan = MockApiClient.addMockResponse({ - url, - method: 'POST', - statusCode: 202, - body: { - runId: '2', - }, - }); - const {router} = render(, { - organization: scanOrganization, - initialRouterConfig: { - location: { - pathname: `/organizations/${scanOrganization.slug}/seer/automations/`, - query: {status: 'succeeded', strategy: 'agentic_triage', source: 'scheduled'}, - }, - }, - }); - expect(await screen.findByText('No runs match your filters.')).toBeInTheDocument(); - const runningRun = { - ...previousRun, - id: '2', - source: 'manual', - seerRunId: '09a15703-bf37-4208-bd90-c57013c9694b', - extras: {status: 'running'}, - }; - MockApiClient.addMockResponse({url, body: [runningRun, previousRun]}); - await user.click(screen.getByRole('button', {name: 'Run…'})); - expect(startScan).not.toHaveBeenCalled(); - await user.click(screen.getByRole('menuitemradio', {name: 'Monitor scan'})); - - expect(await screen.findByRole('status', {name: 'Running'})).toBeInTheDocument(); - expect(screen.getAllByText('Scanning monitors…')).not.toHaveLength(0); - expect(screen.getByLabelText('Manual')).toBeInTheDocument(); - expect(screen.getByText('--')).toBeInTheDocument(); - expect(startScan).toHaveBeenCalledTimes(1); - expect(startScan).toHaveBeenCalledWith( - url, - expect.objectContaining({data: {strategy: 'duplicate_monitors'}}) - ); - expect(router.location.query).toEqual({}); - expect(screen.getByRole('button', {name: 'Collapse run'})).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Expand run'})).toBeInTheDocument(); - - const completedRun = { - ...runningRun, - extras: {status: 'complete'}, - results: [ - { - id: '1', - kind: 'duplicate_monitors', - extras: { - outputKind: 'monitor_cleanup', - schemaVersion: 1, - projectId: '1', - projectSlug: 'checkout', - scan: {status: 'complete', monitorsScanned: 2}, - summary: 'No duplicates found.', - findings: [], - }, - }, - ], - }; - MockApiClient.addMockResponse({url, body: [completedRun, previousRun]}); - await act(async () => { - await jest.advanceTimersByTimeAsync(5000); - }); - expect(screen.queryByRole('status', {name: 'Running'})).not.toBeInTheDocument(); - expect(screen.getAllByRole('img', {name: 'Succeeded'})).toHaveLength(2); - expect(screen.getAllByText('No findings')).not.toHaveLength(0); - }); - - it('polls running Agentic triage workflows and keeps them visible when a background poll fails', async () => { - jest.useFakeTimers(); - const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime}); - const url = `/organizations/${organization.slug}/seer/workflows/runs/`; - const runningRun = { - id: '1', - strategy: 'agentic_triage', - dateAdded: '2026-09-09T00:00:00Z', - dateCompleted: null, - source: 'cron', - extras: {status: 'running', options: {source: 'manual'}}, - errorType: null, - errorMessage: null, - results: [], - issues: [], - seerRuns: [], - }; - const unsupportedRuns = [ - {id: '2', strategy: 'future_strategy', extras: {status: 'running'}}, - {id: '3', strategy: 'future_strategy'}, - {id: '4', strategy: 'constructor'}, - ]; - MockApiClient.addMockResponse({ - url, - body: [...unsupportedRuns, runningRun], - }); - render(, { + function renderLayout(pathname: string) { + render(, { organization, - initialRouterConfig: { - location: { - pathname: `/organizations/${organization.slug}/seer/automations/`, - query: {source: 'cron'}, - }, - }, - }); - expect(await screen.findByRole('status', {name: 'Running'})).toBeInTheDocument(); - - expect(screen.getByText('Triaging issues…')).toBeInTheDocument(); - expect(screen.getByLabelText('Automated')).toBeInTheDocument(); - expect(screen.queryByLabelText('Manual')).not.toBeInTheDocument(); - expect(screen.queryByText('No issues processed')).not.toBeInTheDocument(); - await user.click(screen.getByRole('button', {name: 'Expand run'})); - expect(screen.getByText('No issues processed yet.')).toBeInTheDocument(); - expect(screen.getByText('No triage batches recorded yet.')).toBeInTheDocument(); - expect( - screen.queryByText('No issues processed in this run.') - ).not.toBeInTheDocument(); - - const failedPoll = MockApiClient.addMockResponse({ - url, - statusCode: 503, - body: {detail: 'Service unavailable'}, - }); - await act(async () => { - await jest.advanceTimersByTimeAsync(5000); - }); - expect(failedPoll).toHaveBeenCalled(); - expect(screen.getByRole('status', {name: 'Running'})).toBeInTheDocument(); - expect(screen.queryByRole('button', {name: /retry/i})).not.toBeInTheDocument(); - - const completedPoll = MockApiClient.addMockResponse({ - url, - body: [ - ...unsupportedRuns, - { - ...runningRun, - dateCompleted: '2026-09-09T00:01:00Z', - extras: {...runningRun.extras, status: 'complete'}, - }, - ], - }); - await act(async () => { - await jest.advanceTimersByTimeAsync(5000); - }); - expect(screen.getByRole('img', {name: 'Succeeded'})).toBeInTheDocument(); - expect(screen.getByText('No issues processed in this run.')).toBeInTheDocument(); - expect(screen.queryByText('Triaging issues…')).not.toBeInTheDocument(); - await act(async () => { - await jest.advanceTimersByTimeAsync(10000); - }); - expect(completedPoll).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', {name: /Strategy/})); - expect(screen.getAllByRole('option')).toHaveLength(1); - expect(screen.getByRole('option', {name: 'Agentic triage'})).toBeInTheDocument(); - }); - - it('renders structured duplicate monitor findings in workflow history', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '2', - seerRunId: '09a15703-bf37-4208-bd90-c57013c9694b', - strategy: 'duplicate_monitors', - dateAdded: '2026-09-09T00:00:00Z', - extras: { - status: 'complete', - coverage: {total: 1, complete: 1, partial: 0, failed: 0}, - }, - issues: [], - results: [ - { - id: '1', - kind: 'duplicate_monitors', - seerRunId: '09a15703-bf37-4208-bd90-c57013c9694b', - extras: { - outputKind: 'monitor_cleanup', - schemaVersion: 1, - projectId: '1', - projectSlug: 'checkout', - scan: {status: 'complete', monitorsScanned: 2}, - summary: 'One matching pair', - findings: [ - { - kind: 'exact_duplicate', - monitors: [ - {id: '10', name: 'Checkout errors', enabled: true}, - {id: '11', name: 'Checkout errors copy', enabled: true}, - ], - suggestedKeepId: '10', - alerts: [], - comparison: [], - reason: 'Matching thresholds', - }, - { - kind: 'new_finding_kind', - monitors: [{id: '12', name: 'Payment errors'}], - }, - ], - }, - }, - ], - }, - ], - }); - render(, {organization}); - expect(await screen.findByText('Duplicate monitors')).toBeInTheDocument(); - expect( - screen.getByText('1 exact duplicate group · 1 other finding') - ).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', {name: 'Expand run'})); - expect(screen.getByRole('link', {name: 'Checkout errors'})).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', {name: 'Debug'})); - expect( - screen.getByRole('link', { - name: 'View prompt and agent run 09a15703-bf37-4208-bd90-c57013c9694b', - }) - ).toHaveAttribute( - 'href', - expect.stringContaining('explorerRunId=09a15703-bf37-4208-bd90-c57013c9694b') - ); - }); - - it('shows a safe failure label without exposing diagnostic errors', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: 'Unexpected Seer error', - errorType: 'unknown', - extras: {}, - issues: [], - }, - ], - }); - - render(, {organization}); - - expect(await screen.findByText('Run failed')).toBeInTheDocument(); - expect(screen.getByLabelText('Failed')).toBeInTheDocument(); - expect(screen.queryByText('Unexpected Seer error')).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', {name: 'Expand run'})); - expect(screen.queryByText(/Unexpected Seer error/)).not.toBeInTheDocument(); - }); - - it.each([ - ['no_quota', 'No Seer quota available', 'Skipped'], - ['invalid_shard_plan', 'Could not prepare triage', 'Failed'], - ])('shows friendly messaging for %s', async (errorType, resultText, status) => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: 'Backend diagnostic details', - errorType, - extras: {}, - issues: [], - }, - ], - }); - - render(, {organization}); - - expect(await screen.findByText(resultText)).toBeInTheDocument(); - expect(screen.getByLabelText(status)).toBeInTheDocument(); - }); - - it('expands a row to show the issue title, action, and a conversation link', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - extras: {foo: 'bar'}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: 'ValueError: something broke', - groupShortId: 'SEER-ABC', - action: 'autofix_triggered', - seerRunId: 'seer-1', - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - }, - ], - }); - - render(, {organization}); - - expect(await screen.findByText('1 issue')).toBeInTheDocument(); - expect(screen.getByLabelText('Succeeded')).toBeInTheDocument(); - await userEvent.click(screen.getByText('Agentic triage')); - - expect(screen.getByText('Autofix queued')).toBeInTheDocument(); - - expect( - screen.getByRole('link', {name: 'SEER-ABC ValueError: something broke'}) - ).toHaveAttribute('href', `/organizations/${organization.slug}/issues/100/`); - expect(screen.queryByRole('link', {name: '100'})).not.toBeInTheDocument(); - - expect(screen.getByRole('link', {name: 'Autofix queued'})).toHaveAttribute( - 'href', - expect.stringContaining('explorerRunId=seer-1') - ); - - expect(screen.queryByText('seer-1')).not.toBeInTheDocument(); - await userEvent.click(screen.getByText('Agentic triage')); - expect(screen.queryByText('Autofix queued')).not.toBeInTheDocument(); - }); - - it('shows the reason for skipping an issue without a conversation link', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - extras: {}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: 'ValueError: something broke', - action: 'skip', - skipReason: 'insufficient_info', - reason: 'More information is needed to investigate.', - seerRunId: null, - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - }, - ], + initialRouterConfig: {location: {pathname}}, }); + } - render(, {organization}); - - await userEvent.click(await screen.findByRole('button', {name: 'Expand run'})); - - expect(screen.getByText('Skipped: insufficient info')).toBeInTheDocument(); - expect( - screen.getByText('More information is needed to investigate.') - ).toBeInTheDocument(); - expect( - screen.queryByRole('link', {name: 'Skipped: insufficient info'}) - ).not.toBeInTheDocument(); - }); + it('renders the Automations title with an alpha badge', () => { + renderLayout('/organizations/org-slug/seer/automations/'); - it('shows a pull request chip for each PR linked to an issue', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - extras: {}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: 'ValueError: something broke', - action: 'autofix_triggered', - seerRunId: 'seer-1', - pullRequests: [ - { - ...PullRequestFixture({ - id: '42', - externalUrl: 'https://github.com/getsentry/sentry/pull/42', - }), - status: 'merged', - }, - {...PullRequestFixture({id: '43', externalUrl: ''}), status: 'merged'}, - { - ...PullRequestFixture({ - id: '44', - externalUrl: 'https://github.com/getsentry/sentry/pull/44', - }), - status: null, - }, - ], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - }, - ], - }); - - render(, {organization}); - - await userEvent.click(await screen.findByRole('button', {name: 'Expand run'})); - - const prChip = screen.getByRole('button', {name: 'Merged #42'}); - expect(prChip).toHaveAttribute('href', 'https://github.com/getsentry/sentry/pull/42'); - expect(prChip).toHaveAttribute('target', '_blank'); - expect(screen.getByText('Merged #43')).toBeInTheDocument(); - expect(screen.queryByRole('button', {name: 'Merged #43'})).not.toBeInTheDocument(); - expect(screen.getByRole('button', {name: '#44'})).toBeInTheDocument(); - expect(screen.queryByText('Autofix queued')).not.toBeInTheDocument(); + expect(screen.getByRole('heading', {name: 'Automations'})).toBeInTheDocument(); + expect(screen.getByLabelText('alpha')).toBeInTheDocument(); }); - it('shows one labeled pill per triage batch in the expanded panel', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - extras: {}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: 'ValueError: something broke', - action: 'autofix_triggered', - seerRunId: 'seer-1', - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - seerRuns: [{seerRunId: '42'}, {seerRunId: '43'}, {seerRunId: null}], - }, - ], - }); - - render(, {organization}); + it('links the Automations and Runs tabs', () => { + renderLayout('/organizations/org-slug/seer/automations/'); - expect(await screen.findByText('1 issue')).toBeInTheDocument(); expect( - screen.queryByRole('link', {name: 'Open run in Seer Explorer'}) - ).not.toBeInTheDocument(); - - await userEvent.click(screen.getByRole('button', {name: 'Expand run'})); - - expect(screen.getByText('Triage batches (2)')).toBeInTheDocument(); - expect(screen.getByRole('button', {name: 'Batch 1'})).toHaveAttribute( - 'href', - expect.stringContaining('explorerRunId=42') - ); - expect(screen.getByRole('button', {name: 'Batch 2'})).toHaveAttribute( - 'href', - expect.stringContaining('explorerRunId=43') - ); - }); - - it('shows no triage batches recorded when a run has no shards', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - extras: {}, - issues: [], - seerRuns: [], - }, - ], - }); - - render(, {organization}); - - expect(await screen.findByText('No issues processed')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', {name: 'Expand run'})); - + within(screen.getByRole('tab', {name: 'Automations'})).getByRole('link') + ).toHaveAttribute('href', '/organizations/org-slug/seer/automations/'); expect( - screen.getByText('No triage batches recorded for this run.') - ).toBeInTheDocument(); + within(screen.getByRole('tab', {name: 'Runs'})).getByRole('link') + ).toHaveAttribute('href', '/organizations/org-slug/seer/automations/runs/'); }); - it('sorts by date desc by default and toggles asc on Date header click', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: 'older', - dateAdded: '2026-04-10T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - source: 'cron', - extras: {}, - issues: [ - { - id: '1', - groupId: '100', - groupTitle: null, - action: 'a', - seerRunId: 's1', - pullRequests: [], - dateAdded: '2026-04-10T00:00:01Z', - }, - ], - }, - { - id: 'newer', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - source: 'cron', - extras: {}, - issues: [ - { - id: '2', - groupId: '101', - groupTitle: null, - action: 'a', - seerRunId: 's2', - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - { - id: '3', - groupId: '102', - groupTitle: null, - action: 'a', - seerRunId: 's3', - pullRequests: [], - dateAdded: '2026-04-20T00:00:02Z', - }, - ], - }, - ], - }); - - const {router} = render(, {organization}); - - const resultsDesc = (await screen.findAllByText(/issues?$/)).map( - el => el.textContent - ); - expect(resultsDesc).toEqual(['2 issues', '1 issue']); - - await userEvent.click( - within(screen.getByRole('columnheader', {name: /Date/})).getByRole('button') + it.each([ + ['/organizations/org-slug/seer/automations/', 'Automations'], + ['/organizations/org-slug/seer/automations/runs/', 'Runs'], + ])('selects the matching tab on %s', (pathname, tabName) => { + renderLayout(pathname); + + expect(screen.getByRole('tab', {name: tabName})).toHaveAttribute( + 'aria-selected', + 'true' ); - - expect(router.location.query.sort).toBe('asc'); - const resultsAsc = (await screen.findAllByText(/issues?$/)).map(el => el.textContent); - expect(resultsAsc).toEqual(['1 issue', '2 issues']); - }); - - it('shows error state when fetch fails', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - statusCode: 404, - body: {detail: 'not found'}, - }); - - render(, {organization}); - - expect(await screen.findByRole('button', {name: /retry/i})).toBeInTheDocument(); - }); - - it('filters rows by status via URL query param', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - source: 'cron', - extras: {}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: null, - action: 'autofix_triggered', - seerRunId: 's1', - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - }, - { - id: '2', - dateAdded: '2026-04-21T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: 'Unexpected Seer error', - errorType: 'unknown', - source: 'cron', - extras: {}, - issues: [], - }, - ], - }); - - render(, { - organization, - initialRouterConfig: { - location: { - pathname: '/organizations/org-slug/seer/automations/', - query: {status: 'failed'}, - }, - }, - }); - - expect(await screen.findByText('Run failed')).toBeInTheDocument(); - expect(screen.queryByText('1 issue')).not.toBeInTheDocument(); - }); - - it('Clear all resets all filter query params', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: '1', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - source: 'cron', - extras: {}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: null, - action: 'autofix_triggered', - seerRunId: 's1', - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - }, - ], - }); - - const {router} = render(, { - organization, - initialRouterConfig: { - location: { - pathname: '/organizations/org-slug/seer/automations/', - query: {status: 'failed', strategy: 'agentic_triage', period: '7d'}, - }, - }, - }); - - await userEvent.click(await screen.findByRole('button', {name: 'Clear all'})); - - expect(router.location.query.status).toBeUndefined(); - expect(router.location.query.strategy).toBeUndefined(); - expect(router.location.query.period).toBeUndefined(); - expect(await screen.findByText('1 issue')).toBeInTheDocument(); - }); - - it('expandLatest auto-expands the latest run visible under active filters', async () => { - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/seer/workflows/runs/`, - body: [ - { - id: 'newer-failed', - dateAdded: '2026-04-21T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: 'Unexpected Seer error', - errorType: 'unknown', - extras: {}, - issues: [], - }, - { - id: 'older-succeeded', - dateAdded: '2026-04-20T00:00:00Z', - strategy: 'agentic_triage', - errorMessage: null, - extras: {}, - issues: [ - { - id: '10', - groupId: '100', - groupTitle: null, - action: 'autofix_triggered', - seerRunId: 'seer-1', - pullRequests: [], - dateAdded: '2026-04-20T00:00:01Z', - }, - ], - }, - ], - }); - - render(, { - organization, - initialRouterConfig: { - location: { - pathname: '/organizations/org-slug/seer/automations/', - query: {expandLatest: 'agentic_triage', status: 'succeeded'}, - }, - }, - }); - - expect(await screen.findByText('Autofix queued')).toBeInTheDocument(); }); }); diff --git a/static/app/views/seerWorkflows/index.tsx b/static/app/views/seerWorkflows/index.tsx index 87320a531475..d77bb2243fee 100644 --- a/static/app/views/seerWorkflows/index.tsx +++ b/static/app/views/seerWorkflows/index.tsx @@ -1,667 +1,64 @@ -import {Fragment, useEffect, useMemo, useRef, useState} from 'react'; -import {IconBot} from '@sentry/icons/bot'; -import {IconCheckmark} from '@sentry/icons/checkmark'; -import {IconChevron} from '@sentry/icons/chevron'; -import {IconClose} from '@sentry/icons/close'; -import {IconFilter} from '@sentry/icons/filter'; -import {IconUser} from '@sentry/icons/user'; -import {IconWarning} from '@sentry/icons/warning'; -import * as Sentry from '@sentry/react'; -import {useMutation, useQuery} from '@tanstack/react-query'; +import {Outlet} from 'react-router'; import {FeatureBadge} from '@sentry/scraps/badge'; -import {Button} from '@sentry/scraps/button'; -import {Spinner} from '@sentry/scraps/chat'; -import {CompactSelect} from '@sentry/scraps/compactSelect'; -import {Disclosure} from '@sentry/scraps/disclosure'; -import {DropdownMenu} from '@sentry/scraps/dropdownMenu'; -import {Container, Flex, Stack} from '@sentry/scraps/layout'; -import {Link} from '@sentry/scraps/link'; -import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; -import type {TableColumnConfig} from '@sentry/scraps/table'; +import {Stack} from '@sentry/scraps/layout'; +import {TabList, Tabs} from '@sentry/scraps/tabs'; import {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {DateTime} from 'sentry/components/dateTime'; -import {LoadingError} from 'sentry/components/loadingError'; -import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {SentryDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {SimpleTable} from 'sentry/components/tables/simpleTable'; -import {TimeSince} from 'sentry/components/timeSince'; import {t} from 'sentry/locale'; -import {apiFetch} from 'sentry/utils/api/apiFetch'; -import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {fetchMutation} from 'sentry/utils/queryClient'; -import {decodeList, decodeScalar} from 'sentry/utils/queryString'; -import {RequestError} from 'sentry/utils/requestError/requestError'; -import {useIsSentryEmployee} from 'sentry/utils/useIsSentryEmployee'; import {useLocation} from 'sentry/utils/useLocation'; -import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; import {TopBar} from 'sentry/views/navigation/topBar'; -import {getRelativeExplorerUrl} from 'sentry/views/seerExplorer/utils'; -import {AgenticTriageDebug} from 'sentry/views/seerWorkflows/agenticTriage'; import { - getWorkflowRunActions, - getWorkflowStatus, - getWorkflowSummary, - WorkflowResults, - STRATEGY_META, -} from 'sentry/views/seerWorkflows/strategies'; -import type { - SeerWorkflowRun, - WorkflowDisplayStatus, - WorkflowRunCreateRequest, - WorkflowRunSource, - WorkflowStrategy, -} from 'sentry/views/seerWorkflows/types'; + getAutomationRunsPath, + getAutomationsPath, +} from 'sentry/views/seerWorkflows/utils'; -const RUNS_COLUMNS: TableColumnConfig[] = [ - {key: 'select', width: 'min-content'}, - {key: 'date', width: 'max-content'}, - {key: 'strategy', width: '1fr'}, - {key: 'result', width: '2fr'}, - {key: 'actions', width: 'min-content'}, -]; - -function SeerWorkflows() { +export default function AutomationsLayout() { const organization = useOrganization(); const location = useLocation(); - const navigate = useNavigate(); - const runActions = getWorkflowRunActions(organization.features); - const [expanded, setExpanded] = useState(new Set()); - const {mutate: startWorkflowRun, isPending: isStartingWorkflowRun} = useMutation({ - mutationFn: (data: WorkflowRunCreateRequest) => - fetchMutation<{runId: string}>({ - url: getApiUrl('/organizations/$organizationIdOrSlug/seer/workflows/runs/', { - path: {organizationIdOrSlug: organization.slug}, - }), - method: 'POST', - data, - }), - onSuccess: async result => { - clearAllFilters(); - setExpanded(previous => new Set(previous).add(result.runId)); - await refetch(); - }, - onError: error => { - addErrorMessage( - error instanceof RequestError && typeof error.responseJSON?.detail === 'string' - ? error.responseJSON.detail - : t('Could not start the workflow. Try again.') - ); - }, - }); - - const reportedStrategies = useRef(new Set()); - const {data, isPending, isError, refetch} = useQuery({ - ...apiOptions.as()( - '/organizations/$organizationIdOrSlug/seer/workflows/runs/', - { - path: {organizationIdOrSlug: organization.slug}, - staleTime: 0, - } - ), - queryFn: async context => { - const response = await apiFetch(context); - for (const {strategy} of response.json) { - if (isSupportedStrategy(strategy) || reportedStrategies.current.has(strategy)) { - continue; - } - reportedStrategies.current.add(strategy); - Sentry.captureMessage('Unsupported Seer workflow strategy', { - level: 'warning', - extra: {strategy}, - }); - } - return response; - }, - refetchInterval: query => - query.state.data?.json.some( - run => isSupportedStrategy(run.strategy) && run.extras.status === 'running' - ) - ? 5000 - : false, - }); - - const runs = useMemo( - () => data?.filter(run => isSupportedStrategy(run.strategy)) ?? [], - [data] - ); - - const strategyFilter = decodeList(location.query.strategy) as WorkflowStrategy[]; - const statusFilter = decodeList(location.query.status) as WorkflowDisplayStatus[]; - const sourceFilter = decodeList(location.query.source); - const period = decodeScalar(location.query.period); - - const [now] = useState(() => Date.now()); - const periodCutoffMs = useMemo(() => { - const days = PERIOD_TO_DAYS[period ?? '']; - return days === undefined ? null : now - days * 24 * 60 * 60 * 1000; - }, [period, now]); - - const sourceOptions = useMemo(() => { - const sources = new Set(); - for (const run of runs) { - if (run.source) { - sources.add(run.source); - } - } - return Array.from(sources) - .map(value => { - const Icon = SOURCE_ICONS[value]; - return { - value, - label: getSourceLabel(value), - leadingItems: Icon ? : undefined, - }; - }) - .sort((a, b) => a.label.localeCompare(b.label)); - }, [runs]); - - const strategyOptions = useMemo( - () => - Array.from(new Set(runs.map(run => run.strategy))).map(strategy => ({ - value: strategy, - label: STRATEGY_META[strategy].label, - })), - [runs] - ); - - const filteredRuns = useMemo(() => { - return runs.filter(run => { - if (strategyFilter.length && !strategyFilter.includes(run.strategy)) { - return false; - } - if (statusFilter.length && !statusFilter.includes(getWorkflowStatus(run))) { - return false; - } - if (sourceFilter.length && (!run.source || !sourceFilter.includes(run.source))) { - return false; - } - if (periodCutoffMs !== null && Date.parse(run.dateAdded) < periodCutoffMs) { - return false; - } - return true; - }); - }, [runs, strategyFilter, statusFilter, sourceFilter, periodCutoffMs]); - - const sortDirection = decodeScalar(location.query.sort) === 'asc' ? 'asc' : 'desc'; - - const sortedRuns = useMemo(() => { - const cmp = (a: SeerWorkflowRun, b: SeerWorkflowRun) => - Date.parse(a.dateAdded) - Date.parse(b.dateAdded); - const next = filteredRuns.toSorted(cmp); - return sortDirection === 'desc' ? next.reverse() : next; - }, [filteredRuns, sortDirection]); - - const hasActiveFilters = - strategyFilter.length > 0 || - statusFilter.length > 0 || - sourceFilter.length > 0 || - period !== undefined; - - const updateQuery = (patch: Record) => { - const nextQuery: Record = {}; - for (const [k, v] of Object.entries(location.query)) { - if (typeof v === 'string' || Array.isArray(v)) { - nextQuery[k] = v; - } - } - for (const [key, value] of Object.entries(patch)) { - if (value === undefined || (Array.isArray(value) && value.length === 0)) { - delete nextQuery[key]; - } else { - nextQuery[key] = value; - } - } - navigate({pathname: location.pathname, query: nextQuery}, {replace: true}); - }; - - const clearAllFilters = () => { - updateQuery({ - strategy: undefined, - status: undefined, - source: undefined, - period: undefined, - }); - }; - - const toggleSortDirection = () => { - updateQuery({sort: sortDirection === 'desc' ? 'asc' : undefined}); - }; - - const toggleExpanded = (runId: string) => { - setExpanded(prev => { - const next = new Set(prev); - if (next.has(runId)) { - next.delete(runId); - } else { - next.add(runId); - } - return next; - }); - }; - - const expandLatest = decodeScalar(location.query.expandLatest) as - | WorkflowStrategy - | undefined; - const autoExpandedForRef = useRef(null); - useEffect(() => { - if ( - !expandLatest || - autoExpandedForRef.current === expandLatest || - filteredRuns.length === 0 - ) { - return; - } - // Only auto-expand a run that's actually visible under the current filters, - // otherwise we'd set expansion state on a run hidden by status/period/etc. - const candidates = filteredRuns.filter(run => run.strategy === expandLatest); - if (candidates.length === 0) { - return; - } - const latest = candidates.reduce((acc, run) => - Date.parse(run.dateAdded) > Date.parse(acc.dateAdded) ? run : acc - ); - // oxlint-disable-next-line react/set-state-in-effect - setExpanded(prev => { - const next = new Set(prev); - next.add(latest.id); - return next; - }); - autoExpandedForRef.current = expandLatest; - }, [expandLatest, filteredRuns]); + const activeTab: 'automations' | 'runs' = location.pathname.includes( + '/seer/automations/runs/' + ) + ? 'runs' + : 'automations'; return ( - - }, - }} - /> - - - {t('Historical runs of Sentry workflows for this organization.')} - - {runActions.length > 0 && ( - ( - - {t('Run…')} - - )} - isDisabled={isStartingWorkflowRun} - items={runActions.map(({strategy, label}) => ({ - key: strategy, - label, - onAction: () => startWorkflowRun({strategy}), - }))} - /> - )} - - - - {isError && data === undefined ? ( - - ) : isPending ? ( - - ) : ( - - }, + }} + /> + + + - - - - - - - updateQuery({ - strategy: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - status: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - source: selected.map(o => String(o.value)), - }) - } - trigger={triggerProps => ( - - )} - /> - - updateQuery({ - period: - selected.value === '' ? undefined : String(selected.value), - }) - } - trigger={triggerProps => ( - - )} - /> - - {hasActiveFilters ? ( - - ) : null} - - - - - - {t('Date')} - - {t('Strategy')} - {t('Result')} - - - } + + {t('Automations')} + + + - {sortedRuns.length === 0 ? ( - - {runs.length === 0 - ? t('No workflow runs yet.') - : t('No runs match your filters.')} - - ) : ( - sortedRuns.map(run => { - const status = getWorkflowStatus(run); - const isExpanded = expanded.has(run.id); - return ( - - toggleExpanded(run.id)} - style={{cursor: 'pointer'}} - > - - - - - - - - - - - - - - - - - {STRATEGY_META[run.strategy].label} - - - - - {getWorkflowSummary(run)} - - - - + ) : null} + + + + + + {t('Date')} + + {t('Strategy')} + {t('Result')} + + + } + > + {sortedRuns.length === 0 ? ( + + {runs.length === 0 + ? t('No workflow runs yet.') + : t('No runs match your filters.')} + + ) : ( + sortedRuns.map(run => { + const status = getWorkflowStatus(run); + const isExpanded = expanded.has(run.id); + return ( + + toggleExpanded(run.id)} + style={{cursor: 'pointer'}} + > + + + + + + + + + + + + + + + + + {STRATEGY_META[run.strategy].label} + + + + + {getWorkflowSummary(run)} + + + +