feat(automation): streamline automation management

Surface lifecycle and output summaries, guided creation, and clear deletion safeguards in automation settings.
This commit is contained in:
Carlos Valente
2026-09-12 16:49:15 +02:00
parent 7a4a25fc55
commit 5c6824d1fd
8 changed files with 328 additions and 95 deletions
@@ -30,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
/> />
</div> </div>
<div ref={automationsRef}> <div ref={automationsRef}>
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} /> <AutomationsList
</div>
<div ref={triggersRef}>
<TriggersList
triggers={data.triggers}
automations={data.automations} automations={data.automations}
triggers={data.triggers}
enabledAutomations={automationState} enabledAutomations={automationState}
isLoading={isLoading} isLoading={isLoading}
/> />
</div> </div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
</div>
</> </>
); );
} }
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
<Panel.Section> <Panel.Section>
<Info> <Info>
<span>Control Ontime and share its data with external systems in your workflow.</span> <span>Control Ontime and share its data with external systems in your workflow.</span>
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span> <span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span> <span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</Info> </Info>
@@ -0,0 +1,19 @@
.table td {
vertical-align: middle;
}
.tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.actions {
justify-content: flex-end;
flex-wrap: nowrap;
}
.muted {
color: $muted-gray;
}
@@ -1,18 +1,25 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag'; import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog';
import NewAutomationDialog from './NewAutomationDialog';
const automationPlaceholder: AutomationDTO = { import style from './AutomationsList.module.scss';
const emptyAutomation: AutomationDTO = {
title: '', title: '',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
@@ -21,37 +28,93 @@ const automationPlaceholder: AutomationDTO = {
interface AutomationsListProps { interface AutomationsListProps {
automations: NormalisedAutomation; automations: NormalisedAutomation;
triggers: Trigger[];
enabledAutomations?: boolean; enabledAutomations?: boolean;
isLoading: boolean; isLoading: boolean;
} }
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) { export default function AutomationsList({
automations,
triggers,
enabledAutomations,
isLoading,
}: AutomationsListProps) {
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null); const { setLocation } = useAppSettingsNavigation();
const [deleteError, setDeleteError] = useState<string | null>(null); const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
const [isPickingStart, setIsPickingStart] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
const handleDelete = async (id: string) => { const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
try { const automationList = Object.values(automations);
setDeleteError(null);
await deleteAutomation(id); /** a recipe creates the automation itself, so it lands in the list rather than in a form */
} catch (error) { const handleCreated = async () => {
setDeleteError(maybeAxiosError(error)); setIsPickingStart(false);
} finally { await refetch();
refetch();
}
}; };
const arrayAutomations = Object.keys(automations); const handleStartEmpty = () => {
setIsPickingStart(false);
setDeleteTarget(null);
setEditing(emptyAutomation);
};
const handleDeleted = async () => {
setDeleteTarget(null);
setEditing(null);
await refetch();
};
const startPicking = () => {
setEditing(null);
setDeleteTarget(null);
setIsPickingStart(true);
};
const startEditing = (automation: Automation) => {
setIsPickingStart(false);
setDeleteTarget(null);
setEditing(automation);
};
const startDeleting = (automation: Automation) => {
setIsPickingStart(false);
setEditing(null);
setDeleteTarget(automation);
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{automationFormData !== null && ( {editing !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} /> <AutomationForm
// the form snapshots the automation's lifecycles on mount, so it must never be
// reused across two different automations
key={isAutomation(editing) ? editing.id : 'new'}
automation={editing}
triggers={triggers}
onClose={() => setEditing(null)}
/>
)}
{isPickingStart && (
<NewAutomationDialog
onClose={() => setIsPickingStart(false)}
onStartEmpty={handleStartEmpty}
onCreated={handleCreated}
/>
)}
{deleteTarget !== null && (
<DeleteAutomationDialog
automation={deleteTarget}
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
/>
)} )}
<Panel.SubHeader> <Panel.SubHeader>
Manage automations Manage automations
<Button onClick={() => setAutomationFormData(automationPlaceholder)}> <Button onClick={startPicking}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -60,74 +123,95 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section> <Panel.Section>
{enabledAutomations === false && ( {enabledAutomations === false && (
<Info> <Info type='warning'>
Automations are disabled. You can still manage automation definitions here, but they will not run until <Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
enabled. <Info.Footer>
<Button size='small' onClick={() => setLocation('automation__settings')}>
Go to automation settings
</Button>
</Info.Footer>
</Info> </Info>
)} )}
<Panel.Table> <Panel.Table className={style.table}>
<thead> <thead>
<tr> <tr>
<th style={{ width: '45%' }}>Title</th> <th style={{ width: '35%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th> <th style={{ width: '25%' }}>Runs on</th>
<th style={{ width: '15%' }}>Filters</th> <th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Outputs</th> <th style={{ width: '15%' }}>Sends</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{!isLoading && arrayAutomations.length === 0 && ( {!isLoading && automationList.length === 0 && (
<Panel.TableEmpty <Panel.TableEmpty
title='No automations yet' title='No automations yet'
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.' description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
action={ action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}> <Button variant='primary' onClick={startPicking}>
Create automation <IoAdd /> New automation <IoAdd />
</Button> </Button>
} }
/> />
)} )}
{arrayAutomations.map((automationId) => { {automationList.map((automation) => {
if (!Object.hasOwn(automations, automationId)) { const lifecycles = lifecyclesByAutomation[automation.id] ?? [];
return null; const outputs = summariseOutputs(automation.outputs);
}
return ( return (
<Fragment key={automationId}> <tr key={automation.id}>
<tr> <td>{automation.title}</td>
<td>{automations[automationId].title}</td>
<td> <td>
<Tag>{automations[automationId].filterRule}</Tag> {lifecycles.length === 0 ? (
<span className={style.muted}></span>
) : (
<div className={style.tags}>
{lifecycles.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
</div>
)}
</td> </td>
<td>{automations[automationId].filters.length}</td> <td>
<td>{automations[automationId].outputs.length}</td> {automation.filters.length === 0 ? (
<Panel.InlineElements align='end' relation='inner' as='td'> <span className={style.muted}></span>
) : (
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
)}
</td>
<td>
<div className={style.tags}>
{outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
outputs.map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</div>
</td>
<td>
<div className={cx([style.tags, style.actions])}>
<IconButton <IconButton
variant='ghosted-white' variant='ghosted-white'
aria-label='Edit entry' aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])} onClick={() => startEditing(automation)}
> >
<IoPencil /> <IoPencil />
</IconButton> </IconButton>
<IconButton <IconButton
variant='ghosted-destructive' variant='ghosted-destructive'
aria-label='Delete entry' aria-label='Delete entry'
onClick={() => handleDelete(automationId)} onClick={() => startDeleting(automation)}
> >
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</Panel.InlineElements> </div>
</tr>
</Fragment>
);
})}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td> </td>
</tr> </tr>
)} );
})}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Section> </Panel.Section>
@@ -0,0 +1,102 @@
import axios from 'axios';
import type { Automation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Dialog from '../../../../common/components/dialog/Dialog';
import Info from '../../../../common/components/info/Info';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import * as Panel from '../../panel-utils/PanelUtils';
interface DeleteAutomationDialogProps {
automation: Automation;
/** global triggers pointing at this automation, they are deleted along with it */
attachedTriggers: Trigger[];
onCancel: () => void;
onDeleted: () => void;
}
/**
* Deleting takes the automation's global triggers with it, so say so before it happens rather
* than leaving the user to discover it in the triggers list.
*
* An automation attached to an event is still refused by the server: that reference lives in
* the rundown and removing it is an edit to the show, not to this panel.
*/
export default function DeleteAutomationDialog({
automation,
attachedTriggers,
onCancel,
onDeleted,
}: DeleteAutomationDialogProps) {
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isAttachedToEvent, setIsAttachedToEvent] = useState(false);
const handleDelete = async () => {
setError(null);
setIsAttachedToEvent(false);
setIsDeleting(true);
try {
await deleteAutomation(automation.id);
onDeleted();
} catch (error) {
setError(maybeAxiosError(error));
setIsAttachedToEvent(axios.isAxiosError(error) && error.response?.status === 409);
} finally {
setIsDeleting(false);
}
};
return (
<Dialog
isOpen
onClose={onCancel}
showBackdrop
showCloseButton
title='Delete automation'
bodyElements={
<Panel.Section>
<Panel.Paragraph>
Delete <strong>{automation.title}</strong>? This cannot be undone.
</Panel.Paragraph>
{attachedTriggers.length > 0 && (
<Info type='warning'>
<Info.Title>
{attachedTriggers.length === 1
? 'Its trigger is deleted with it'
: `Its ${attachedTriggers.length} triggers are deleted with it`}
</Info.Title>
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
</Info>
)}
{error && (
<Info type='error'>
<Info.Title>Could not delete this automation</Info.Title>
<Info.Body>{error}</Info.Body>
<Info.Footer>
{isAttachedToEvent
? 'An automation attached to a single event has to be removed from that event first, in the event editor.'
: 'Try again. If the problem persists, check the network log for details.'}
</Info.Footer>
</Info>
)}
</Panel.Section>
}
footerElements={
<>
<Button onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
Delete
</Button>
</>
}
/>
);
}
@@ -1,13 +1,13 @@
import { NormalisedAutomation, Trigger } from 'ontime-types'; import { NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { IoAdd } from 'react-icons/io5'; import { IoAdd } from 'react-icons/io5';
import { deleteTrigger } from '../../../../common/api/automation'; import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils'; import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button'; import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import { checkDuplicates } from './automationUtils'; import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm'; import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
@@ -20,13 +20,13 @@ type FormState = {
interface TriggersListProps { interface TriggersListProps {
triggers: Trigger[]; triggers: Trigger[];
automations: NormalisedAutomation; automations: NormalisedAutomation;
enabledAutomations?: boolean;
isLoading: boolean; isLoading: boolean;
} }
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) { export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) {
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined }); const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const { setLocation } = useAppSettingsNavigation();
const [deleteError, setDeleteError] = useState<string | null>(null); const [deleteError, setDeleteError] = useState<string | null>(null);
const openNewForm = () => setFormState({ isOpen: true }); const openNewForm = () => setFormState({ isOpen: true });
@@ -50,6 +50,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
}; };
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]); const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
const orphans = useMemo(
() => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length,
[triggers, automations],
);
// there is no point letting user creating a trigger if there are no automations // there is no point letting user creating a trigger if there are no automations
const canAdd = Object.keys(automations).length > 0; const canAdd = Object.keys(automations).length > 0;
@@ -66,22 +70,28 @@ export default function TriggersList({ triggers, automations, enabledAutomations
/> />
)} )}
<Panel.SubHeader> <Panel.SubHeader>
Manage triggers Global triggers
<Button disabled={!canAdd} onClick={openNewForm}> <Button disabled={!canAdd} onClick={openNewForm}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
{enabledAutomations === false && ( <Panel.Description>
<Info> Triggers are managed from the automation itself. This list is for naming them, or for pointing several
Automations are disabled. You can still manage triggers here, but they will not run until enabled. differently named triggers at the same automation.
</Info> </Panel.Description>
)}
{duplicates && ( {duplicates && (
<Panel.Error> <Panel.Error>
You have created multiple links between the same trigger and automation which can cause performance You have created multiple links between the same trigger and automation. Duplicate combinations will only
issues. fire once per lifecycle event.
</Panel.Error>
)}
{orphans > 0 && (
<Panel.Error>
{orphans === 1
? '1 trigger points at an automation that no longer exists and will never run.'
: `${orphans} triggers point at automations that no longer exist and will never run.`}
</Panel.Error> </Panel.Error>
)} )}
<Panel.Table> <Panel.Table>
@@ -99,31 +109,32 @@ export default function TriggersList({ triggers, automations, enabledAutomations
title='No triggers yet' title='No triggers yet'
description={ description={
canAdd canAdd
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.' ? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.'
: 'Create an automation first, then add a trigger to decide when it should run.' : 'Create an automation first, then pick the lifecycles it should run on.'
} }
action={ action={
canAdd && ( canAdd ? (
<Button variant='primary' onClick={openNewForm}> <Button variant='primary' onClick={openNewForm}>
Create trigger <IoAdd /> Create trigger <IoAdd />
</Button> </Button>
) : (
<Button variant='primary' onClick={() => setLocation('automation__automations')}>
Go to automations
</Button>
) )
} }
/> />
)} )}
{triggers.map((trigger, index) => { {triggers.map((trigger, index) => (
return (
<Fragment key={trigger.id}>
<TriggersListItem <TriggersListItem
key={trigger.id}
automations={automations} automations={automations}
trigger={trigger} trigger={trigger}
duplicate={duplicates?.includes(index)} duplicate={duplicates?.includes(index)}
handleEdit={() => openEditForm(trigger)} handleEdit={() => openEditForm(trigger)}
handleDelete={() => handleDelete(trigger.id)} handleDelete={() => handleDelete(trigger.id)}
/> />
</Fragment> ))}
);
})}
{deleteError && ( {deleteError && (
<tr> <tr>
<td colSpan={5}> <td colSpan={5}>
@@ -16,6 +16,7 @@ interface TriggersListItemProps {
export default function TriggersListItem(props: TriggersListItemProps) { export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, trigger, duplicate, handleEdit, handleDelete } = props; const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
const automation = automations[trigger.automationId];
return ( return (
<tr data-warn={duplicate}> <tr data-warn={duplicate}>
@@ -31,7 +32,8 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td> </td>
<td> <td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag> {/* a trigger can outlive the automation it points at, say after a partial project import */}
{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}
</td> </td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}> <IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
@@ -84,11 +84,25 @@ const staticOptions = [
{ {
id: 'automation__automations', id: 'automation__automations',
label: 'Manage automations', label: 'Manage automations',
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'], keywords: [
'osc',
'http',
'webhook',
'integration',
'api',
'output',
'action',
'recipe',
'example',
'preset',
'qlab',
'vmix',
'companion',
],
}, },
{ {
id: 'automation__triggers', id: 'automation__triggers',
label: 'Manage triggers', label: 'Global triggers',
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'], keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
}, },
], ],