From b19024292e8b75eaaa0fc2592de985cf05f805a3 Mon Sep 17 00:00:00 2001 From: Andrew Liu <159852527+aliu39@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:02:18 -0700 Subject: [PATCH 01/28] feat(seer): Add Automations and Runs tabs to the Automations page Split /seer/automations/ into a shared page shell with Automations and Runs tabs. The existing run list moves to the Runs tab at /seer/automations/runs/, and the Automations tab at /seer/automations/ is blank for now. --- static/app/oxlint-suppressions.json | 10 +- static/app/router/routes.tsx | 10 + .../seerWorkflows/configuration/index.tsx | 3 + static/app/views/seerWorkflows/index.tsx | 674 +----------------- .../seerWorkflows/{ => runs}/index.spec.tsx | 22 +- static/app/views/seerWorkflows/runs/index.tsx | 651 +++++++++++++++++ static/app/views/seerWorkflows/utils.ts | 4 + 7 files changed, 706 insertions(+), 668 deletions(-) create mode 100644 static/app/views/seerWorkflows/configuration/index.tsx rename static/app/views/seerWorkflows/{ => runs}/index.spec.tsx (97%) create mode 100644 static/app/views/seerWorkflows/runs/index.tsx diff --git a/static/app/oxlint-suppressions.json b/static/app/oxlint-suppressions.json index 68d42a5c644d..f5c06059a217 100644 --- a/static/app/oxlint-suppressions.json +++ b/static/app/oxlint-suppressions.json @@ -3147,11 +3147,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 @@ -3502,5 +3497,10 @@ "@sentry/scraps/prefer-primitives": { "count": 1 } + }, + "views/seerWorkflows/runs/index.spec.tsx": { + "@sentry/require-fake-timer-cleanup": { + "count": 2 + } } } diff --git a/static/app/router/routes.tsx b/static/app/router/routes.tsx index 2136daf67106..50bb70d3eb27 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/configuration')), + }, + { + path: 'runs/', + component: make(() => import('sentry/views/seerWorkflows/runs')), + }, + ], }, ]; const seerRoutes: SentryRouteObject = { diff --git a/static/app/views/seerWorkflows/configuration/index.tsx b/static/app/views/seerWorkflows/configuration/index.tsx new file mode 100644 index 000000000000..53c8aa783fe6 --- /dev/null +++ b/static/app/views/seerWorkflows/configuration/index.tsx @@ -0,0 +1,3 @@ +export default function Automations() { + return null; +} diff --git a/static/app/views/seerWorkflows/index.tsx b/static/app/views/seerWorkflows/index.tsx index 87320a531475..f7a3d2bdc417 100644 --- a/static/app/views/seerWorkflows/index.tsx +++ b/static/app/views/seerWorkflows/index.tsx @@ -1,667 +1,47 @@ -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 {Text} from '@sentry/scraps/text'; -import {Tooltip} from '@sentry/scraps/tooltip'; +import {Stack} from '@sentry/scraps/layout'; +import {TabList, Tabs} from '@sentry/scraps/tabs'; -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 = location.pathname.endsWith('/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')} - - - } - > - {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)} + + + +