mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 18:33:53 +00:00
feat(automation): add a recipe library
The panel started empty with no examples, so the first thing a new user saw was a form that assumes they already know what OSC, a lifecycle and a template string are. Eight recipes now cover the common integrations plus three that need no external software at all, so the library is useful on a bare laptop. A recipe is not a new kind of object. Installing one posts an ordinary automation and its triggers through the same endpoints the form uses, then opens it in the editor: seeing that it is editable is the point. Every external target defaults to this machine, so a mis-click cannot put traffic on a venue network, and a test asserts that along with the rest of the server's automation contract, since recipes are constants that would otherwise rot silently into something that 400s on install. OSC recipes are hidden in the cloud build, where OSC output is skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
@@ -66,13 +66,11 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
||||
* Triggers are a separate entity, so they live outside the form state.
|
||||
* We resolve the current selection once and reconcile it against the server on save.
|
||||
*/
|
||||
const initialCycles = useMemo(() => {
|
||||
if (!isAutomation(automation)) {
|
||||
return [];
|
||||
}
|
||||
return triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- we intentionally snapshot the selection when the form opens
|
||||
}, []);
|
||||
const [initialCycles] = useState<TimerLifeCycle[]>(() =>
|
||||
isAutomation(automation)
|
||||
? triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger)
|
||||
: [],
|
||||
);
|
||||
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles);
|
||||
/** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
@@ -14,6 +14,7 @@ import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AutomationForm from './AutomationForm';
|
||||
import { groupTriggersByAutomation } from './automationUtils';
|
||||
import RecipeLibraryModal from './recipes/RecipeLibraryModal';
|
||||
|
||||
import style from './AutomationsList.module.scss';
|
||||
|
||||
@@ -34,8 +35,20 @@ interface AutomationsListProps {
|
||||
export default function AutomationsList({ automations, triggers, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [showRecipes, setShowRecipes] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* A recipe lands in the editor rather than only in the list.
|
||||
* Seeing it as an editable automation is the point, and recipes with an
|
||||
* external target are unusable until the user changes it anyway.
|
||||
*/
|
||||
const handleRecipeInstalled = async (created: Automation) => {
|
||||
setShowRecipes(false);
|
||||
await refetch();
|
||||
setAutomationFormData(created);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
@@ -61,11 +74,22 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
|
||||
onClose={() => setAutomationFormData(null)}
|
||||
/>
|
||||
)}
|
||||
{showRecipes && (
|
||||
<RecipeLibraryModal
|
||||
onClose={() => setShowRecipes(false)}
|
||||
onInstalled={(_recipe, created) => handleRecipeInstalled(created)}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={() => setShowRecipes(true)}>
|
||||
Browse recipes <IoSparklesOutline />
|
||||
</Button>
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
@@ -95,8 +119,11 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||
action={
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create automation <IoAdd />
|
||||
<Button variant='primary' onClick={() => setShowRecipes(true)}>
|
||||
Browse recipes <IoSparklesOutline />
|
||||
</Button>
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create from scratch <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
.library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
color: $ui-white;
|
||||
font-size: calc(1rem - 1px);
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.recipeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
background-color: $black-10;
|
||||
}
|
||||
|
||||
.recipeTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipeDescription {
|
||||
flex: 1;
|
||||
font-size: $aux-text-size;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.recipeActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import type { Automation } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../../common/constants/timerLifecycle';
|
||||
import { summariseOutputs } from '../../../../../common/utils/automationOutputs';
|
||||
import { isOntimeCloud } from '../../../../../externals';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import { automationRecipes, recipeCategoryLabels, recipeCategoryOrder, type AutomationRecipe } from './automationRecipes';
|
||||
import { installRecipe } from './recipeUtils';
|
||||
|
||||
import style from './RecipeLibraryModal.module.scss';
|
||||
|
||||
interface RecipeLibraryModalProps {
|
||||
onClose: () => void;
|
||||
/** called with the installed automation so the caller can open it for editing */
|
||||
onInstalled: (automation: AutomationRecipe, created: Automation) => void;
|
||||
}
|
||||
|
||||
export default function RecipeLibraryModal({ onClose, onInstalled }: RecipeLibraryModalProps) {
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// OSC is not available in the cloud service, offering those recipes there would be a lie
|
||||
const available = isOntimeCloud
|
||||
? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc'))
|
||||
: automationRecipes;
|
||||
|
||||
const handleInstall = async (recipe: AutomationRecipe) => {
|
||||
setError(null);
|
||||
setInstalling(recipe.id);
|
||||
try {
|
||||
const created = await installRecipe(recipe);
|
||||
onInstalled(recipe, created);
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='wide'
|
||||
title='Automation recipes'
|
||||
bodyElements={
|
||||
<div className={style.library}>
|
||||
<Info>
|
||||
<Info.Body>
|
||||
Recipes are a starting point, not a black box. Each one is added as a normal automation that you can edit,
|
||||
test or delete. Recipes that reach external software are set to this machine, so point them at the right
|
||||
device before you rely on them.
|
||||
</Info.Body>
|
||||
</Info>
|
||||
|
||||
{recipeCategoryOrder.map((category) => {
|
||||
const recipes = available.filter((recipe) => recipe.category === category);
|
||||
if (recipes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={category} className={style.category}>
|
||||
<Panel.Title>{recipeCategoryLabels[category]}</Panel.Title>
|
||||
<div className={style.recipeGrid}>
|
||||
{recipes.map((recipe) => (
|
||||
<article key={recipe.id} className={style.recipe}>
|
||||
<div className={style.recipeTitle}>{recipe.title}</div>
|
||||
<div className={style.recipeDescription}>{recipe.description}</div>
|
||||
<Panel.InlineElements relation='inner' wrap='wrap'>
|
||||
{recipe.triggers.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
{summariseOutputs(recipe.automation.outputs).map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))}
|
||||
{recipe.needsSetup && <Tag variant='warning'>Needs a target</Tag>}
|
||||
</Panel.InlineElements>
|
||||
<div className={style.recipeActions}>
|
||||
{recipe.docsUrl && <ExternalLink href={recipe.docsUrl}>Docs</ExternalLink>}
|
||||
<Button
|
||||
variant='primary'
|
||||
size='small'
|
||||
loading={installing === recipe.id}
|
||||
disabled={installing !== null}
|
||||
onClick={() => handleInstall(recipe)}
|
||||
>
|
||||
{recipe.needsSetup ? 'Add and configure' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
|
||||
|
||||
import { operators } from '../../automationUtils';
|
||||
import { automationRecipes, recipeCategoryOrder } from '../automationRecipes';
|
||||
|
||||
/**
|
||||
* Recipes are shipped as constants but installed through the same endpoints as a
|
||||
* hand written automation. These assertions stand in for the server side validation,
|
||||
* so a recipe cannot silently rot into something that 400s on install.
|
||||
*/
|
||||
describe('automationRecipes', () => {
|
||||
it('ships recipes', () => {
|
||||
expect(automationRecipes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = automationRecipes.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('only uses categories the library knows how to render', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(recipeCategoryOrder).toContain(recipe.category);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds every recipe to at least one valid lifecycle', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(recipe.triggers.length).toBeGreaterThan(0);
|
||||
for (const cycle of recipe.triggers) {
|
||||
expect(timerLifecycleValues).toContain(cycle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every recipe something to send', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(recipe.automation.outputs.length).toBeGreaterThan(0);
|
||||
expect(recipe.automation.title).not.toBe('');
|
||||
|
||||
for (const output of recipe.automation.outputs) {
|
||||
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('only uses filter operators the server accepts', () => {
|
||||
const allowed = operators.map(({ value }) => value);
|
||||
for (const recipe of automationRecipes) {
|
||||
for (const filter of recipe.automation.filters) {
|
||||
expect(allowed).toContain(filter.operator);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults every external target to this machine', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
for (const output of recipe.automation.outputs) {
|
||||
if (isOSCOutput(output)) {
|
||||
expect(output.targetIP).toBe('127.0.0.1');
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
expect(output.url.startsWith('http://127.0.0.1')).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks recipes that reach outside Ontime as needing a target', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
const reachesOut = recipe.automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
|
||||
expect(recipe.needsSetup).toBe(reachesOut);
|
||||
}
|
||||
});
|
||||
});
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
|
||||
import { TimerLifeCycle as Cycle } from 'ontime-types';
|
||||
|
||||
export type RecipeCategory = 'video' | 'audio' | 'playback' | 'messaging' | 'ontime';
|
||||
|
||||
export type AutomationRecipe = {
|
||||
/** stable, client only. Never persisted */
|
||||
id: string;
|
||||
title: string;
|
||||
/** one line, plain language: what this does for the user */
|
||||
description: string;
|
||||
category: RecipeCategory;
|
||||
docsUrl?: string;
|
||||
/** true when the recipe points at external software the user has to locate */
|
||||
needsSetup: boolean;
|
||||
/** typed so the compiler catches drift against the automation schema */
|
||||
automation: AutomationDTO;
|
||||
triggers: TimerLifeCycle[];
|
||||
};
|
||||
|
||||
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
|
||||
ontime: 'Works out of the box',
|
||||
playback: 'Playback and cue systems',
|
||||
video: 'Video and streaming',
|
||||
audio: 'Audio',
|
||||
messaging: 'Webhooks and messaging',
|
||||
};
|
||||
|
||||
/** presentation order for the library */
|
||||
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'video', 'playback', 'audio', 'messaging'];
|
||||
|
||||
/**
|
||||
* Every recipe targets loopback by default.
|
||||
* A recipe added by mistake must not put traffic on a venue network, so the user
|
||||
* has to point it somewhere real before it can reach anything.
|
||||
*/
|
||||
export const automationRecipes: AutomationRecipe[] = [
|
||||
{
|
||||
id: 'ontime-aux-timer',
|
||||
title: 'Start Aux Timer 1 with the event',
|
||||
description: 'Sets aux timer 1 to five minutes and starts it whenever an event starts.',
|
||||
category: 'ontime',
|
||||
needsSetup: false,
|
||||
automation: {
|
||||
title: 'Start Aux Timer 1 with the event',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{ type: 'ontime', action: 'aux1-set', time: '00:05:00' },
|
||||
{ type: 'ontime', action: 'aux1-start' },
|
||||
],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
{
|
||||
id: 'ontime-warn-stage',
|
||||
title: 'Warn the stage when the timer hits danger',
|
||||
description: 'Shows a message on the stage timer as soon as the running event enters its danger window.',
|
||||
category: 'ontime',
|
||||
needsSetup: false,
|
||||
automation: {
|
||||
title: 'Warn the stage at danger',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: 'Please wrap up', visible: true }],
|
||||
},
|
||||
triggers: [Cycle.onDanger],
|
||||
},
|
||||
{
|
||||
id: 'ontime-clear-message',
|
||||
title: 'Hide the stage message on finish',
|
||||
description: 'Hides the stage message once the event finishes. Pairs with the danger warning above.',
|
||||
category: 'ontime',
|
||||
needsSetup: false,
|
||||
automation: {
|
||||
title: 'Hide the stage message on finish',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
// an empty text means "leave the text alone", so this only changes visibility
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
|
||||
},
|
||||
triggers: [Cycle.onFinish],
|
||||
},
|
||||
{
|
||||
id: 'obs-record',
|
||||
title: 'OBS — start recording when the show starts',
|
||||
description: 'Calls the OBS websocket HTTP bridge when the first event is loaded.',
|
||||
category: 'video',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'OBS start recording',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:4455/api/StartRecord' }],
|
||||
},
|
||||
triggers: [Cycle.onLoad],
|
||||
},
|
||||
{
|
||||
id: 'vmix-overlay-warning',
|
||||
title: 'vMix — show an overlay on timer warning',
|
||||
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
|
||||
category: 'video',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'vMix overlay on warning',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:8088/api/?Function=OverlayInput1In' }],
|
||||
},
|
||||
triggers: [Cycle.onWarning],
|
||||
},
|
||||
{
|
||||
id: 'qlab-go',
|
||||
title: 'QLab — fire the matching cue on event start',
|
||||
description: "Sends OSC to QLab to start the cue whose number matches the Ontime event's cue.",
|
||||
category: 'playback',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'QLab GO on event start',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 53000, address: '/cue/{{eventNow.cue}}/start', args: '' },
|
||||
],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
{
|
||||
id: 'companion-press',
|
||||
title: 'Companion — press a button on event start',
|
||||
description: 'Presses page 1 button 1 on a Stream Deck through the Companion HTTP API.',
|
||||
category: 'playback',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'Companion button press',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/0/press' }],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
{
|
||||
id: 'webhook-event-title',
|
||||
title: 'Webhook — send the current event title',
|
||||
description: 'Posts the running event title to any URL. A good place to see template strings at work.',
|
||||
category: 'messaging',
|
||||
docsUrl: 'https://docs.getontime.no/api/automation/#using-variables-in-automation',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'Webhook with the current event',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:3000/now?title={{eventNow.title}}' }],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Automation } from 'ontime-types';
|
||||
|
||||
import { addAutomation, addTrigger, deleteAutomation, deleteTrigger } from '../../../../../common/api/automation';
|
||||
import { cycles } from '../automationUtils';
|
||||
import type { AutomationRecipe } from './automationRecipes';
|
||||
|
||||
/**
|
||||
* Installs a recipe as an ordinary automation, using the same endpoints as the form.
|
||||
* There is nothing special about the result: the user owns it and can edit or delete it.
|
||||
*
|
||||
* The server generates the ids, so the automation has to exist before its triggers can
|
||||
* point at it. If a trigger fails half way we undo the whole thing, triggers first:
|
||||
* the server refuses to delete an automation that is still referenced.
|
||||
*/
|
||||
export async function installRecipe(recipe: AutomationRecipe): Promise<Automation> {
|
||||
const created = await addAutomation(recipe.automation);
|
||||
const createdTriggerIds: string[] = [];
|
||||
|
||||
try {
|
||||
for (const cycle of recipe.triggers) {
|
||||
const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle;
|
||||
const trigger = await addTrigger({
|
||||
title: `${recipe.automation.title} — ${label}`,
|
||||
trigger: cycle,
|
||||
automationId: created.id,
|
||||
});
|
||||
createdTriggerIds.push(trigger.id);
|
||||
}
|
||||
} catch (error) {
|
||||
await rollback(created.id, createdTriggerIds);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function rollback(automationId: string, triggerIds: string[]) {
|
||||
try {
|
||||
for (const id of triggerIds) {
|
||||
await deleteTrigger(id);
|
||||
}
|
||||
await deleteAutomation(automationId);
|
||||
} catch (_error) {
|
||||
// the install already failed and we are reporting that. A failed cleanup leaves an
|
||||
// editable automation behind, which is recoverable, so it should not mask the original error
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user