Skip to content

Commit db2de88

Browse files
craigmarkerclaudedonovanlopez
authored
Add kill action to trigger runs (#1054)
Wires kill lifecycle action into the trigger run list view using the existing ActionsPopover/ActionConfig system. Actions are always visible but disabled with a hover tooltip when not applicable — only RUNNING and PAUSED runs can be killed. Each action opens a confirmation dialog backed by UpdateTriggerRun via useStudioMutation. Exposes UpdateTriggerRun in the RPC handler registry. Add stopCircle icon for Kill. **What type of PR is this? (check all applicable)** - [ ] Refactor - [x] Feature - [ ] Bug Fix - [ ] Optimization - [ ] Documentation Update Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: donovan.lopez <donovan.lopez@uber.com>
1 parent 7622df4 commit db2de88

8 files changed

Lines changed: 268 additions & 2 deletions

File tree

javascript/app/icons/icons.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import SearchIcon from '@mui/icons-material/Search';
2121
import SettingsIcon from '@mui/icons-material/Settings';
2222
import ShowChartIcon from '@mui/icons-material/ShowChart';
2323
import SkipNextIcon from '@mui/icons-material/SkipNext';
24+
import StopCircleIcon from '@mui/icons-material/StopCircle';
2425

2526
import { createMuiIconAdapter } from './mui-icon-adapter';
2627

@@ -39,6 +40,7 @@ export const ICONS = {
3940
close: createMuiIconAdapter(CloseIcon),
4041
deleteAlt: createMuiIconAdapter(CancelIcon),
4142
diamondEmpty: createMuiIconAdapter(CropSquareIcon),
43+
stopCircle: createMuiIconAdapter(StopCircleIcon),
4244
menu: createMuiIconAdapter(MenuIcon),
4345
overflowMenu: createMuiIconAdapter(MoreVertIcon),
4446
playerNext: createMuiIconAdapter(SkipNextIcon),

javascript/packages/core/components/actions/__tests__/actions-popover.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,9 @@ describe('ActionsPopover', () => {
257257
await screen.findByText('Cannot delete');
258258
await user.hover(archiveOption);
259259
expect(await screen.findByText('Cannot archive')).toBeInTheDocument();
260-
expect(screen.queryByText('Cannot delete')).not.toBeInTheDocument();
260+
await waitFor(() => {
261+
expect(screen.queryByText('Cannot delete')).not.toBeInTheDocument();
262+
});
261263
});
262264

263265
it('does not show the tooltip from auto-highlight when the menu opens', async () => {

javascript/packages/core/config/entities/pipeline/pipeline.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export const PIPELINE_ENTITY_CONFIG: PhaseEntityConfig = {
1414
actions: [
1515
{
1616
display: { label: 'Run', icon: 'playerPlay' },
17-
component: CreatePipelineRunForm,
1817
hierarchy: ActionHierarchy.PRIMARY,
18+
component: CreatePipelineRunForm,
1919
},
2020
],
2121
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { useState } from 'react';
2+
import { render, screen, waitFor, within } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
import { buildWrapper } from '#core/test/wrappers/build-wrapper';
6+
import { getBaseProviderWrapper } from '#core/test/wrappers/get-base-provider-wrapper';
7+
import { getErrorProviderWrapper } from '#core/test/wrappers/get-error-provider-wrapper';
8+
import { getIconProviderWrapper } from '#core/test/wrappers/get-icon-provider-wrapper';
9+
import { getRouterWrapper } from '#core/test/wrappers/get-router-wrapper';
10+
import {
11+
createQueryMockRouter,
12+
getServiceProviderWrapper,
13+
} from '#core/test/wrappers/get-service-provider-wrapper';
14+
import { KillTriggerRunForm } from '../trigger-run-action-form';
15+
import { TriggerRunAction, TriggerRunState } from '../types';
16+
17+
import type { ActionComponentProps } from '#core/components/actions/types';
18+
import type { TriggerRun } from '../types';
19+
20+
// Provides real isOpen state so FormDialog can close on success.
21+
function FormWrapper({
22+
Form,
23+
}: {
24+
Form: (props: ActionComponentProps<TriggerRun>) => React.ReactElement | null;
25+
}) {
26+
const [isOpen, setIsOpen] = useState(true);
27+
const record: TriggerRun = {
28+
metadata: { name: 'my-trigger', namespace: 'test-ns' },
29+
spec: {
30+
pipeline: { name: 'test-pipeline', namespace: 'test-ns' },
31+
revision: { name: 'test-revision', namespace: 'test-ns' },
32+
actor: { name: 'test-user' },
33+
sourceTriggerName: '',
34+
autoFlip: false,
35+
notifications: [],
36+
kill: false,
37+
action: TriggerRunAction.NO_ACTION,
38+
},
39+
status: { state: TriggerRunState.RUNNING },
40+
};
41+
return <Form record={record} isOpen={isOpen} onClose={() => setIsOpen(false)} />;
42+
}
43+
44+
it.each([
45+
{
46+
Form: KillTriggerRunForm,
47+
dialogName: 'Kill Trigger Run',
48+
buttonLabel: 'Kill',
49+
action: TriggerRunAction.KILL,
50+
},
51+
])(
52+
'$dialogName: submits UpdateTriggerRun with correct action and closes dialog',
53+
async ({ Form, dialogName, buttonLabel, action }) => {
54+
const user = userEvent.setup();
55+
const mockRequest = createQueryMockRouter({
56+
UpdateTriggerRun: { triggerRun: { metadata: { name: 'my-trigger' } } },
57+
});
58+
59+
render(
60+
<FormWrapper Form={Form} />,
61+
buildWrapper([
62+
getBaseProviderWrapper(),
63+
getIconProviderWrapper(),
64+
getErrorProviderWrapper(),
65+
getRouterWrapper({ location: '/test-ns/triggers' }),
66+
getServiceProviderWrapper({ request: mockRequest }),
67+
])
68+
);
69+
70+
const dialog = await screen.findByRole('dialog', { name: dialogName });
71+
await user.click(within(dialog).getByRole('button', { name: buttonLabel }));
72+
73+
await waitFor(() => {
74+
expect(mockRequest).toHaveBeenCalledWith(
75+
'UpdateTriggerRun',
76+
expect.objectContaining({
77+
triggerRun: expect.objectContaining({
78+
spec: expect.objectContaining({ action }) as Record<string, unknown>,
79+
}) as Record<string, unknown>,
80+
})
81+
);
82+
});
83+
84+
await waitFor(() => {
85+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
86+
});
87+
}
88+
);
89+
90+
describe('KillTriggerRunForm', () => {
91+
it('keeps dialog open and displays error when submission fails', async () => {
92+
const user = userEvent.setup();
93+
const mockRequest = createQueryMockRouter({ UpdateTriggerRun: new Error('test') });
94+
95+
render(
96+
<FormWrapper Form={KillTriggerRunForm} />,
97+
buildWrapper([
98+
getBaseProviderWrapper(),
99+
getIconProviderWrapper(),
100+
getErrorProviderWrapper(),
101+
getRouterWrapper({ location: '/test-ns/triggers' }),
102+
getServiceProviderWrapper({ request: mockRequest }),
103+
])
104+
);
105+
106+
const dialog = await screen.findByRole('dialog');
107+
await user.click(within(dialog).getByRole('button', { name: 'Kill' }));
108+
109+
await within(dialog).findByText(/Test error/);
110+
expect(mockRequest).toHaveBeenCalledTimes(1);
111+
expect(screen.getByRole('dialog')).toBeInTheDocument();
112+
});
113+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useQueryClient } from '@tanstack/react-query';
2+
3+
import { FormDialog } from '#core/components/form/components/form-dialog/form-dialog';
4+
import { StringField } from '#core/components/form/fields/string/string-field';
5+
import { useStudioMutation } from '#core/hooks/use-studio-mutation';
6+
import { TriggerRunAction } from './types';
7+
8+
import type { ActionComponentProps } from '#core/components/actions/types';
9+
import type { TriggerRun } from './types';
10+
11+
const ACTION_TO_ENUM = {
12+
kill: TriggerRunAction.KILL,
13+
} as const;
14+
15+
const ACTION_CONFIG = {
16+
kill: { heading: 'Kill Trigger Run', submitLabel: 'Kill' },
17+
} as const;
18+
19+
type Action = keyof typeof ACTION_CONFIG;
20+
21+
function TriggerRunActionForm({
22+
record,
23+
isOpen,
24+
onClose,
25+
action,
26+
}: ActionComponentProps<TriggerRun> & { action: Action }) {
27+
const queryClient = useQueryClient();
28+
29+
const config = ACTION_CONFIG[action];
30+
31+
const mutation = useStudioMutation<{ triggerRun: TriggerRun }, { triggerRun: TriggerRun }>({
32+
mutationName: 'UpdateTriggerRun',
33+
});
34+
35+
const initialValues: TriggerRun = {
36+
...record,
37+
spec: { ...record.spec, action: ACTION_TO_ENUM[action] },
38+
};
39+
40+
const handleSubmit = async (values: TriggerRun) => {
41+
await mutation.mutateAsync({ triggerRun: values });
42+
43+
// We wait a few seconds before invalidating the queries so that the action can be processed
44+
setTimeout(() => {
45+
void queryClient.invalidateQueries({
46+
queryKey: [
47+
'GetTriggerRun',
48+
{ name: record.metadata.name, namespace: record.metadata.namespace },
49+
],
50+
});
51+
void queryClient.invalidateQueries({ queryKey: ['ListTriggerRun'] });
52+
}, 2000);
53+
};
54+
55+
return (
56+
<FormDialog<TriggerRun>
57+
isOpen={isOpen}
58+
onDismiss={onClose}
59+
heading={config.heading}
60+
onSubmit={handleSubmit}
61+
submitLabel={config.submitLabel}
62+
initialValues={initialValues}
63+
>
64+
<p>
65+
Kill run <strong>{record.metadata.name}</strong> in pipeline{' '}
66+
<strong>{record.spec.pipeline.name}</strong>? This action cannot be undone.
67+
</p>
68+
<StringField name="metadata.name" label="Trigger Run Name" readOnly />
69+
</FormDialog>
70+
);
71+
}
72+
73+
export const KillTriggerRunForm = (props: ActionComponentProps<TriggerRun>) => (
74+
<TriggerRunActionForm {...props} action="kill" />
75+
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,37 @@
1+
import { ActionHierarchy } from '#core/components/actions/types';
2+
import { interpolate } from '#core/interpolation/interpolate';
13
import { TRIGGER_DETAIL_CONFIG } from './detail';
24
import { TRIGGER_LIST_CONFIG } from './list';
5+
import { KillTriggerRunForm } from './trigger-run-action-form';
6+
import { TriggerRunState } from './types';
37

48
import type { PhaseEntityConfig } from '#core/types/common/studio-types';
9+
import type { TriggerRun } from './types';
10+
11+
const isKillable = (record: unknown) => {
12+
const state = (record as TriggerRun).status?.state;
13+
return state === TriggerRunState.RUNNING || state === TriggerRunState.PAUSED;
14+
};
515

616
export const TRIGGER_ENTITY_CONFIG: PhaseEntityConfig = {
717
id: 'triggers',
818
name: 'Triggers',
919
service: 'triggerRun',
1020
state: 'active',
1121
views: [TRIGGER_LIST_CONFIG, TRIGGER_DETAIL_CONFIG],
22+
actions: [
23+
{
24+
display: { label: 'Kill', icon: 'stopCircle' },
25+
component: KillTriggerRunForm,
26+
hierarchy: interpolate(({ data }) =>
27+
isKillable(data) ? ActionHierarchy.SECONDARY : ActionHierarchy.TERTIARY
28+
),
29+
disabled: [
30+
{
31+
condition: interpolate(({ data }) => !isKillable(data)),
32+
message: 'Only running or paused trigger runs can be killed',
33+
},
34+
],
35+
},
36+
],
1237
};

javascript/packages/core/config/entities/trigger/types.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
/**
2+
* Mirrors generated types from @michelangelo/rpc trigger_run_pb.
3+
* Update alongside proto/api/v2/trigger_run.proto.
4+
*/
5+
16
export type Trigger = {
27
metadata: {
38
name: string;
@@ -10,3 +15,44 @@ export type Trigger = {
1015
};
1116
};
1217
};
18+
19+
export type TriggerRun = {
20+
metadata: {
21+
name: string;
22+
namespace: string;
23+
};
24+
spec: {
25+
pipeline: { name: string; namespace: string };
26+
revision: { name: string; namespace: string };
27+
actor: { name: string };
28+
sourceTriggerName: string;
29+
autoFlip: boolean;
30+
notifications: unknown[];
31+
/** @deprecated Use action instead (proto field 11). */
32+
kill: boolean;
33+
/** proto field 11 — replaces deprecated kill boolean */
34+
action: TriggerRunAction;
35+
};
36+
status: {
37+
state: TriggerRunState;
38+
};
39+
};
40+
41+
/** Mirrors proto TriggerRunAction enum (trigger_run.proto). */
42+
export enum TriggerRunAction {
43+
NO_ACTION = 0,
44+
KILL = 1,
45+
PAUSE = 2,
46+
RESUME = 3,
47+
}
48+
49+
/** Mirrors proto TriggerRunState enum (trigger_run.proto). */
50+
export enum TriggerRunState {
51+
INVALID = 0,
52+
RUNNING = 1,
53+
KILLED = 2,
54+
FAILED = 3,
55+
SUCCEEDED = 4,
56+
PENDING_KILL = 5,
57+
PAUSED = 6,
58+
}

javascript/packages/rpc/handlers.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ async function createHandlers() {
3232
GetTriggerRun: services.TriggerRunService.getTriggerRun as ExtractUnaryRpc<
3333
typeof services.TriggerRunService.getTriggerRun
3434
>,
35+
UpdateTriggerRun: services.TriggerRunService.updateTriggerRun as ExtractUnaryRpc<
36+
typeof services.TriggerRunService.updateTriggerRun
37+
>,
3538
CreatePipelineRun: services.PipelineRunService.createPipelineRun as ExtractUnaryRpc<
3639
typeof services.PipelineRunService.createPipelineRun
3740
>,

0 commit comments

Comments
 (0)