Skip to content

Commit 9e85b69

Browse files
authored
feat(seer): Show outputs and triggers in the automation runs table (#130517)
The automation runs table now shows what each run produced and what started it. - **Output column:** lists the investigations each run created, as links (e.g. `INV-2307 | Latency spike`). - **Trigger column:** replaces the Strategy column. It shows the strategy, plus a link to the issue when a monitor started the run. The link uses the issue's short ID (e.g. `BACKEND-4388`) when available, and falls back to `issue:{id}`. Other triggers, such as scheduled runs, show their type as plain text (e.g. `Cron`). - **Single automation's runs:** the strategy is hidden there, since every run on that page has the same one. - **Empty cells:** Status, Output, and Trigger show a muted dash when there's nothing to display, such as older runs without a status or trigger. This also adds frontend types for run triggers and execution outputs. Depends on the backend returning `trigger` (#130512) and `groupShortId` (#130537). Until those ship, the table works but shows no issue links or short IDs.
1 parent 3eb77be commit 9e85b69

4 files changed

Lines changed: 310 additions & 8 deletions

File tree

‎static/app/views/seerWorkflows/automations/detail/runs.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ export default function AutomationRuns() {
1111
return <NotFound />;
1212
}
1313

14-
return <RunsTable configId={automationId} />;
14+
return <RunsTable configId={automationId} showStrategy={false} />;
1515
}

‎static/app/views/seerWorkflows/automations/runs/runsTable.spec.tsx‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,26 @@ function makeRun(overrides: Partial<SeerAutomationRun>): SeerAutomationRun {
1616
dateCompleted: '2026-04-20T00:01:00Z',
1717
executions: [],
1818
status: 'complete',
19+
trigger: null,
1920
...overrides,
2021
} as SeerAutomationRun;
2122
}
2223

24+
function makeExecution(
25+
overrides: Partial<SeerAutomationRun['executions'][number]>
26+
): SeerAutomationRun['executions'][number] {
27+
return {
28+
id: '1',
29+
seerRunId: null,
30+
dateAdded: '2026-04-20T00:00:00Z',
31+
dateUpdated: '2026-04-20T00:01:00Z',
32+
dateCompleted: '2026-04-20T00:01:00Z',
33+
outputs: [],
34+
status: 'complete',
35+
...overrides,
36+
} as SeerAutomationRun['executions'][number];
37+
}
38+
2339
describe('RunsTable', () => {
2440
const organization = OrganizationFixture({features: ['seer-automations']});
2541
const runsUrl = `/organizations/${organization.slug}/seer/workflows/runs/`;
@@ -52,6 +68,175 @@ describe('RunsTable', () => {
5268
expect(screen.getByText('Agentic triage')).toBeInTheDocument();
5369
});
5470

71+
it('lists investigations from every execution in the output column', async () => {
72+
MockApiClient.addMockResponse({
73+
url: runsUrl,
74+
body: {
75+
runs: [
76+
makeRun({
77+
executions: [
78+
makeExecution({
79+
id: '1',
80+
outputs: [{type: 'investigation', id: '2307', title: 'Latency spike'}],
81+
}),
82+
makeExecution({
83+
id: '2',
84+
outputs: [{type: 'investigation', id: '2308', title: 'Error burst'}],
85+
}),
86+
],
87+
}),
88+
],
89+
},
90+
});
91+
92+
render(<RunsTable />, {organization});
93+
94+
expect(
95+
await screen.findByRole('link', {name: 'INV-2307 | Latency spike'})
96+
).toHaveAttribute(
97+
'href',
98+
`/organizations/${organization.slug}/seer/investigations/2307/`
99+
);
100+
expect(screen.getByRole('link', {name: 'INV-2308 | Error burst'})).toHaveAttribute(
101+
'href',
102+
`/organizations/${organization.slug}/seer/investigations/2308/`
103+
);
104+
});
105+
106+
it('shows a dash when a run has no outputs', async () => {
107+
MockApiClient.addMockResponse({
108+
url: runsUrl,
109+
body: {runs: [makeRun({executions: [makeExecution({outputs: []})]})]},
110+
});
111+
112+
render(<RunsTable />, {organization});
113+
114+
expect(await screen.findByText('—')).toBeInTheDocument();
115+
expect(screen.queryByRole('link', {name: /INV-/})).not.toBeInTheDocument();
116+
});
117+
118+
it('links to the triggering issue under the strategy', async () => {
119+
MockApiClient.addMockResponse({
120+
url: runsUrl,
121+
body: {
122+
runs: [
123+
makeRun({
124+
strategy: 'breached_metric_investigation',
125+
trigger: {
126+
detectorId: '5',
127+
environment: null,
128+
eventId: 'abc',
129+
groupId: '4388',
130+
observedAt: '2026-04-20T00:00:00Z',
131+
occurrenceId: 'def',
132+
projectId: '2',
133+
type: 'monitor',
134+
},
135+
}),
136+
],
137+
},
138+
});
139+
140+
render(<RunsTable />, {organization});
141+
142+
expect(await screen.findByText('Breached metrics')).toBeInTheDocument();
143+
expect(screen.getByRole('link', {name: 'issue:4388'})).toHaveAttribute(
144+
'href',
145+
`/organizations/${organization.slug}/issues/4388/`
146+
);
147+
});
148+
149+
it('shows only the strategy when a run has no trigger', async () => {
150+
MockApiClient.addMockResponse({
151+
url: runsUrl,
152+
body: {runs: [makeRun({strategy: 'breached_metric_investigation', trigger: null})]},
153+
});
154+
155+
render(<RunsTable />, {organization});
156+
157+
expect(await screen.findByText('Breached metrics')).toBeInTheDocument();
158+
expect(screen.queryByRole('link', {name: /issue:/})).not.toBeInTheDocument();
159+
});
160+
161+
it('shows the formatted trigger type for triggers without an issue', async () => {
162+
MockApiClient.addMockResponse({
163+
url: runsUrl,
164+
body: {
165+
runs: [
166+
makeRun({id: '1', trigger: {type: 'cron'}}),
167+
makeRun({
168+
id: '2',
169+
// A type the frontend doesn't know about yet
170+
trigger: {
171+
type: 'custom_trigger-type',
172+
} as unknown as SeerAutomationRun['trigger'],
173+
}),
174+
],
175+
},
176+
});
177+
178+
render(<RunsTable />, {organization});
179+
180+
expect(await screen.findByText('Cron')).toBeInTheDocument();
181+
expect(screen.getByText('Custom Trigger Type')).toBeInTheDocument();
182+
expect(screen.queryByRole('link', {name: /issue:/})).not.toBeInTheDocument();
183+
});
184+
185+
it('hides the strategy in the trigger column when showStrategy is false', async () => {
186+
MockApiClient.addMockResponse({
187+
url: runsUrl,
188+
body: {
189+
runs: [
190+
makeRun({
191+
id: '9',
192+
strategy: 'breached_metric_investigation',
193+
trigger: {
194+
detectorId: '5',
195+
environment: null,
196+
eventId: 'abc',
197+
groupId: '4388',
198+
observedAt: '2026-04-20T00:00:00Z',
199+
occurrenceId: 'def',
200+
projectId: '2',
201+
type: 'monitor',
202+
},
203+
}),
204+
],
205+
},
206+
});
207+
208+
render(<RunsTable showStrategy={false} />, {organization});
209+
210+
expect(await screen.findByRole('link', {name: 'issue:4388'})).toBeInTheDocument();
211+
expect(screen.queryByText('Breached metrics')).not.toBeInTheDocument();
212+
});
213+
214+
it('shows a dash in the status cell for runs without a status', async () => {
215+
MockApiClient.addMockResponse({
216+
url: runsUrl,
217+
body: {runs: [makeRun({id: '21', status: null})]},
218+
});
219+
220+
render(<RunsTable />, {organization});
221+
222+
expect(await screen.findByText('21')).toBeInTheDocument();
223+
const [statusCell] = screen.getAllByRole('cell');
224+
expect(statusCell).toHaveTextContent('—');
225+
});
226+
227+
it('shows a dash in the trigger cell when it has nothing to show', async () => {
228+
MockApiClient.addMockResponse({
229+
url: runsUrl,
230+
body: {runs: [makeRun({id: '22', trigger: null})]},
231+
});
232+
233+
render(<RunsTable showStrategy={false} />, {organization});
234+
235+
expect(await screen.findByText('22')).toBeInTheDocument();
236+
const triggerCell = screen.getAllByRole('cell').at(-1);
237+
expect(triggerCell).toHaveTextContent('—');
238+
});
239+
55240
it('shows an empty state when there are no runs', async () => {
56241
MockApiClient.addMockResponse({url: runsUrl, body: {runs: []}});
57242

‎static/app/views/seerWorkflows/automations/runs/runsTable.tsx‎

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import {Fragment} from 'react';
2+
import {IconIssues} from '@sentry/icons/issues';
3+
import upperFirst from 'lodash/upperFirst';
24
import {useQueryState} from 'nuqs';
35

6+
import {Flex, Stack} from '@sentry/scraps/layout';
7+
import {Link} from '@sentry/scraps/link';
48
import {Pagination} from '@sentry/scraps/pagination';
59
import type {TableColumnConfig} from '@sentry/scraps/table';
610
import {Text} from '@sentry/scraps/text';
@@ -12,6 +16,8 @@ import {LoadingIndicator} from 'sentry/components/loadingIndicator';
1216
import {SimpleTable} from 'sentry/components/tables/simpleTable';
1317
import {TimeSince} from 'sentry/components/timeSince';
1418
import {t} from 'sentry/locale';
19+
import {useOrganization} from 'sentry/utils/useOrganization';
20+
import {getInvestigationPath} from 'sentry/views/investigations/utils';
1521
import {useAutomationRuns} from 'sentry/views/seerWorkflows/automations/hooks/useAutomationRuns';
1622
import {getStrategyLabel} from 'sentry/views/seerWorkflows/strategies';
1723
import type {SeerAutomationRun} from 'sentry/views/seerWorkflows/types';
@@ -22,13 +28,22 @@ const COLUMNS: TableColumnConfig[] = [
2228
{key: 'status', width: 'min-content'},
2329
{key: 'id', width: 'max-content'},
2430
{key: 'date', width: 'max-content'},
25-
{key: 'strategy', width: '1fr'},
31+
{key: 'output', width: '1fr'},
32+
{key: 'trigger', width: '1fr'},
2633
];
2734

2835
/**
29-
* Pass `configId` to only show one automation's runs
36+
* Pass `configId` to only show one automation's runs. Pass `showStrategy={false}` to
37+
* leave the strategy out of the trigger column, e.g. when the page is already scoped
38+
* to one automation.
3039
*/
31-
export function RunsTable({configId}: {configId?: string}) {
40+
export function RunsTable({
41+
configId,
42+
showStrategy = true,
43+
}: {
44+
configId?: string;
45+
showStrategy?: boolean;
46+
}) {
3247
const [cursor] = useQueryState('cursor');
3348
const {runs, pageLinks, isPending, isLoadingError, refetch} = useAutomationRuns({
3449
configId,
@@ -48,7 +63,8 @@ export function RunsTable({configId}: {configId?: string}) {
4863
<SimpleTable.HeaderCell>{t('Status')}</SimpleTable.HeaderCell>
4964
<SimpleTable.HeaderCell>{t('ID')}</SimpleTable.HeaderCell>
5065
<SimpleTable.HeaderCell>{t('Date')}</SimpleTable.HeaderCell>
51-
<SimpleTable.HeaderCell>{t('Strategy')}</SimpleTable.HeaderCell>
66+
<SimpleTable.HeaderCell>{t('Output')}</SimpleTable.HeaderCell>
67+
<SimpleTable.HeaderCell>{t('Trigger')}</SimpleTable.HeaderCell>
5268
</SimpleTable.HeaderRow>
5369
}
5470
>
@@ -59,16 +75,18 @@ export function RunsTable({configId}: {configId?: string}) {
5975
) : runs.length === 0 ? (
6076
<SimpleTable.Empty>{t('No runs yet.')}</SimpleTable.Empty>
6177
) : (
62-
runs.map(run => <RunRow key={run.id} run={run} />)
78+
runs.map(run => <RunRow key={run.id} run={run} showStrategy={showStrategy} />)
6379
)}
6480
</SimpleTable>
6581
<Pagination pageLinks={pageLinks} />
6682
</Fragment>
6783
);
6884
}
6985

70-
function RunRow({run}: {run: SeerAutomationRun}) {
86+
function RunRow({run, showStrategy}: {run: SeerAutomationRun; showStrategy: boolean}) {
87+
const organization = useOrganization();
7188
const status = getRunDisplayStatus(run.status);
89+
const outputs = run.executions.flatMap(execution => execution.outputs);
7290
return (
7391
<SimpleTable.Row>
7492
<SimpleTable.RowCell>
@@ -86,8 +104,66 @@ function RunRow({run}: {run: SeerAutomationRun}) {
86104
</Tooltip>
87105
</SimpleTable.RowCell>
88106
<SimpleTable.RowCell>
89-
<Text>{getStrategyLabel(run.strategy)}</Text>
107+
{outputs.length > 0 ? (
108+
<Stack>
109+
{outputs.map((output, index) =>
110+
output.type === 'investigation' ? (
111+
<Link
112+
key={`${output.id}-${index}`}
113+
to={getInvestigationPath(organization.slug, output.id)}
114+
>
115+
{`INV-${output.id} | ${output.title}`}
116+
</Link>
117+
) : (
118+
<Text key={`${output.id}-${index}`}>{fallbackOutputText(output)}</Text>
119+
)
120+
)}
121+
</Stack>
122+
) : (
123+
<Text variant="muted">{'—'}</Text>
124+
)}
125+
</SimpleTable.RowCell>
126+
<SimpleTable.RowCell>
127+
{!showStrategy && !run.trigger ? (
128+
<Text variant="muted">{'—'}</Text>
129+
) : (
130+
<Stack>
131+
{showStrategy && <Text>{getStrategyLabel(run.strategy)}</Text>}
132+
{run.trigger?.type === 'monitor' ? (
133+
<Flex gap="xs" align="center">
134+
<IconIssues size="xs" />
135+
<Link
136+
to={`/organizations/${organization.slug}/issues/${run.trigger.groupId}/`}
137+
>
138+
{run.trigger.groupShortId ?? `issue:${run.trigger.groupId}`}
139+
</Link>
140+
</Flex>
141+
) : (
142+
run.trigger && (
143+
<Text variant="muted">{fallbackTriggerText(run.trigger.type)}</Text>
144+
)
145+
)}
146+
</Stack>
147+
)}
90148
</SimpleTable.RowCell>
91149
</SimpleTable.Row>
92150
);
93151
}
152+
153+
/**
154+
* Turns a trigger type like `custom_trigger-type` into `Custom Trigger Type`
155+
*/
156+
function fallbackTriggerText(type: string): string {
157+
return type
158+
.split(/[_-]/)
159+
.filter(Boolean)
160+
.map(word => upperFirst(word))
161+
.join(' ');
162+
}
163+
164+
/**
165+
* Plain text for output types without their own display, e.g. `report:12`
166+
*/
167+
function fallbackOutputText(output: {id: string; type: string}): string {
168+
return `${output.type}:${output.id}`;
169+
}

0 commit comments

Comments
 (0)