feat(automation): one step creation, recipes, and a panel that says what it does

Making an automation took two visits to two lists. You wrote the automation
in one, then remembered that an automation alone never runs, went to the
trigger list, made a trigger, and pointed it back at what you had just made.
A first time user who stopped after the first step got a thing that looked
finished and did nothing.

Lifecycles are now picked on the automation form, as chips. Saving reconciles
the triggers behind it, diffed against a snapshot taken when the form opened
so a save never removes a trigger the user could not see. Every lifecycle is
in one place, including the two that fire continuously, which say so before
you pick them rather than after your log fills up.

That leaves the trigger list as what it actually is: a place to rename a
generated trigger, or to point several differently named ones at the same
automation. It says so, and it names the triggers whose automation is gone.

New opens one list of starting points: an empty automation, then a handful of
recipes for the software people actually pair Ontime with. A recipe is
nothing but a pre-filled form, so choosing one opens the ordinary automation
form with its values in place. The user reads what it will do, points it at
their own gear and saves. Nothing is written until they do, and there is no
second creation path to keep working: one request, the same as any other
automation. Every recipe targets loopback, so one saved without thinking
cannot put traffic on a venue network.

The list now answers what an automation does without opening it: when it
runs, whether it filters, and what it sends. An automation with no trigger or
no output is the common half-finished state and is called out rather than
shown as a blank cell. Outputs are cards with their type, a summary and a
Test button in the header, which is also where the test says whether it
worked: the panel used to send and tell the user nothing either way.

Deleting confirms first and names the triggers that go with it, instead of
dumping the server's refusal into a stray row under the table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
This commit is contained in:
Claude
2026-09-06 15:28:10 +00:00
parent 149cc0da92
commit a905bf912f
13 changed files with 1106 additions and 409 deletions
@@ -26,61 +26,84 @@
gap: 1rem; gap: 1rem;
} }
.titleSection, .titleSection {
.filterSection,
.oscSection,
.httpSection,
.actionSection {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-gap: 0.5rem; grid-gap: 0.5rem;
button {
align-self: flex-end;
}
} }
.titleSection, .titleSection,
.ruleSection, .ruleSection,
.filterSection, .card {
.oscSection,
.httpSection,
.actionSection {
label,
div {
// we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element
font-size: calc(1rem - 3px);
}
label { label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray; color: $label-gray;
} }
} }
.titleSection { /** shared shell for a single filter or output */
grid-template-columns: 1fr; .card {
} border: 1px solid $white-10;
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
}
}
.outputCard {
border-left: 0.25rem solid $gray-1200; border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem; border-radius: $component-border-radius-md;
background-color: $black-10;
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
/** pushes the actions to the end of the header, and absorbs any overflow */
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.cardBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
/** for fields that read badly when narrow: OSC address and args, URLs, message text */
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: $aux-text-size;
color: $green-400;
}
.testError {
padding: 0 0.75rem 0.5rem;
}
.tagOsc {
background-color: $blue-1000;
color: $blue-300;
}
.tagHttp {
background-color: $green-1000;
color: $green-300;
}
.tagOntime {
background-color: $gray-1000;
color: $gray-200;
} }
@@ -1,21 +1,28 @@
import { import {
Automation, Automation,
AutomationDTO, AutomationDTO,
HTTPOutput, AutomationFilter,
OSCOutput, TimerLifeCycle,
OntimeAction, Trigger,
isHTTPOutput, isHTTPOutput,
isOSCOutput, isOSCOutput,
isOntimeAction, isOntimeAction,
} from 'ontime-types'; } from 'ontime-types';
import { useEffect, useMemo } from 'react'; import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoTrash } from 'react-icons/io5'; import { IoAdd, IoCheckmark, IoTrash } from 'react-icons/io5';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; import {
addAutomation,
addTrigger,
deleteTrigger,
editAutomation,
testOutput,
} 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 IconButton from '../../../../common/components/buttons/IconButton'; import IconButton from '../../../../common/components/buttons/IconButton';
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
import Info from '../../../../common/components/info/Info'; import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
@@ -27,7 +34,7 @@ import useAutomationSettings from '../../../../common/hooks-query/useAutomationS
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { startsWithHttp } from '../../../../common/utils/regex'; import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils'; import { cycles, isAutomation, makeFieldList, operators } from './automationUtils';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
import TemplateInput from './template-input/TemplateInput'; import TemplateInput from './template-input/TemplateInput';
@@ -36,17 +43,72 @@ import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation'; const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
const formId = 'automation-form'; const formId = 'automation-form';
/** how long a successful test keeps its confirmation on screen */
const testFeedbackDuration = 2000;
type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
triggers: Trigger[];
/** lifecycles a new automation starts with selected, used by recipes */
defaultCycles?: TimerLifeCycle[];
onClose: () => void; onClose: () => void;
} }
export default function AutomationForm({ automation, onClose }: AutomationFormProps) { export default function AutomationForm({ automation, triggers, defaultCycles, onClose }: AutomationFormProps) {
const isEdit = isAutomation(automation); const isEdit = isAutomation(automation);
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
/**
* Triggers are a separate entity, so they live outside the form state.
*
* We snapshot the automation's triggers when the form opens and reconcile against that
* snapshot, never against the live prop: settings are polled, so a trigger created
* elsewhere while this form is open must not be deleted by a save that never saw it.
*/
const [initialTriggers] = useState<Trigger[]>(() =>
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
);
const initialCycles = useMemo(
() => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))),
[initialTriggers],
);
// a new automation can arrive pre-filled from a recipe, an existing one resolves its own triggers
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(
isEdit ? initialCycles : (defaultCycles ?? []),
);
/** 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);
const cyclesAreDirty =
selectedCycles.length !== initialCycles.length ||
selectedCycles.some((cycle) => !initialCycles.includes(cycle)) ||
initialCycles.some((cycle) => !selectedCycles.includes(cycle));
const toggleCycle = (cycle: TimerLifeCycle) => {
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
};
/**
* A lifecycle can carry several differently named triggers, which the chips collapse into one.
* Unchecking it removes all of them, so say which ones rather than deleting them quietly.
*/
const triggersToRemove = initialTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
/**
* Test results are keyed by the field array id rather than the index:
* removing an output shifts every index after it, which would leave feedback on the wrong row
*/
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const { const {
control, control,
handleSubmit, handleSubmit,
@@ -93,6 +155,26 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title'); setFocus('title');
}, [setFocus]); }, [setFocus]);
// the timers outlive a fast close, clearing them avoids setting state on an unmounted form
useEffect(() => {
const timers = feedbackTimers.current;
return () => Object.values(timers).forEach(clearTimeout);
}, []);
const reportTest = (key: string, state: TestState) => {
setTestResults((prev) => ({ ...prev, [key]: state }));
clearTimeout(feedbackTimers.current[key]);
if (state.status === 'ok') {
feedbackTimers.current[key] = setTimeout(() => {
setTestResults((prev) => {
const { [key]: _discarded, ...rest } = prev;
return rest;
});
}, testFeedbackDuration);
}
};
const handleAddNewFilter = () => { const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' }); appendFilter({ field: '', operator: 'equals', value: '' });
}; };
@@ -110,80 +192,101 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'ontime', action: 'aux1-start' }); appendOutput({ type: 'ontime', action: 'aux1-start' });
}; };
const handleTestOSCOutput = async (index: number) => { /**
* Sends a single output as configured, without saving the automation.
* OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
*/
const handleTest = async (index: number, key: string) => {
const values = getValues(`outputs.${index}`);
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
return;
}
if (isHTTPOutput(values) && !values.url) {
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
return;
}
reportTest(key, { status: 'sending' });
try { try {
const values = getValues(`outputs.${index}`) as OSCOutput; // NOTE: there is no meaningful validation to do on an Ontime action, we let the server deal with the data
if (!values.targetIP || !values.targetPort || !values.address) { await testOutput(values);
return; reportTest(key, { status: 'ok', message: 'Sent' });
} } catch (error) {
await testOutput({ reportTest(key, { status: 'error', message: maybeAxiosError(error) });
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
} }
}; };
const handleTestHTTPOutput = async (index: number) => { /**
try { * Reconciles the lifecycle selection against the global triggers.
const values = getValues(`outputs.${index}`) as HTTPOutput; * Runs after the automation itself is saved: a new automation has no id until then.
if (!values.url) { *
return; * Both sides are diffed against the mount-time snapshot, so this only ever removes
} * triggers the user could actually see when they made the change.
await testOutput({ */
type: 'http', const syncTriggers = async (automationId: string, title: string) => {
url: values.url, for (const trigger of triggersToRemove) {
}); await deleteTrigger(trigger.id);
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
} }
};
const handleTestOntimeAction = async (index: number) => { const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle));
try { for (const cycle of toAdd) {
const values = getValues(`outputs.${index}`) as OntimeAction; const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle;
// NOTE: there is no meaningful validation to do here, we let the server deal with the data await addTrigger({ title: `${title}${label}`, trigger: cycle, automationId });
await testOutput({
...values,
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
} }
}; };
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) { // saving happens in two requests, so a retry after a partial failure must edit rather than create again
await handleEdit(automation.id, { id: automation.id, ...values }); const existingId = isAutomation(automation) ? automation.id : createdId;
} else { let automationId: string;
await handleCreate(values);
try {
if (existingId) {
await editAutomation(existingId, { id: existingId, ...values });
automationId = existingId;
} else {
const created = await addAutomation(values);
setCreatedId(created.id);
automationId = created.id;
}
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
return;
} }
try {
await syncTriggers(automationId, values.title);
} catch (error) {
// the automation itself is saved, only its triggers failed. Keep the form open so the user can retry
refetch();
setError('root', { message: `Automation saved, but its triggers failed: ${maybeAxiosError(error)}` });
return;
}
refetch(); refetch();
onClose();
async function handleEdit(id: string, values: Automation) {
try {
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
async function handleCreate(values: AutomationDTO) {
try {
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
}; };
const canSubmit = !isSubmitting && isDirty && isValid; /** describes a filter in plain language so the user does not have to read the form back to themselves */
const describeFilter = (index: number): string | null => {
const field = watch(`filters.${index}.field`);
if (!field) {
return null;
}
const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field;
const operator = watch(`filters.${index}.operator`);
const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator;
const value = watch(`filters.${index}.value`);
return `${fieldLabel} ${operatorLabel} ${value ? `${value}` : 'nothing'}`;
};
// a recipe arrives complete, so a new automation is savable without the user changing anything
const canSubmit = !isSubmitting && (!isEdit || isDirty || cyclesAreDirty) && isValid;
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
return ( return (
<Modal <Modal
@@ -191,6 +294,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
onClose={onClose} onClose={onClose}
showBackdrop showBackdrop
showCloseButton showCloseButton
size='wide'
title={isEdit ? 'Edit automation' : 'Create automation'} title={isEdit ? 'Edit automation' : 'Create automation'}
bodyElements={ bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
@@ -207,87 +311,119 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</div> </div>
<div className={style.titleSection}>
<label id='runs-on-label'>Runs on</label>
<Panel.Description>
Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
single event from the event editor.
</Panel.Description>
<Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
{cycles.map(({ id, label, value }) => {
const cycle = value as TimerLifeCycle;
const isSelected = selectedCycles.includes(cycle);
return (
<Button
key={id}
size='small'
variant={isSelected ? 'primary' : 'subtle'}
aria-pressed={isSelected}
onClick={() => toggleCycle(cycle)}
>
{label}
</Button>
);
})}
</Panel.InlineElements>
{hasContinuousCycle && (
<Panel.Description tone='warning'>
Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you mean
to send on every tick.
</Panel.Description>
)}
{triggersToRemove.length > 0 && (
<Panel.Description tone='warning'>
{`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove
.map((trigger) => trigger.title)
.join(', ')}`}
</Panel.Description>
)}
</div>
</div> </div>
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Filters (optional)</h3> <h3>Filters (optional)</h3>
<Panel.Description>
Without filters the outputs are sent every time the automation is triggered.
</Panel.Description>
<div className={style.ruleSection}> <div className={style.ruleSection}>
<label> {fieldFilters.length > 1 && (
Trigger outputs if <label>
<RadioGroup Trigger outputs if
orientation='horizontal' <RadioGroup
value={watch('filterRule')} orientation='horizontal'
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })} value={watch('filterRule')}
items={[ onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
{ value: 'all', label: 'All filters pass' }, items={[
{ value: 'any', label: 'Any filter passes' }, { value: 'all', label: 'All filters pass' },
]} { value: 'any', label: 'Any filter passes' },
/> ]}
</label> />
</label>
)}
{fieldFilters.map((field, index) => { {fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`; const description = describeFilter(index);
return ( return (
<div key={key} className={style.filterSection}> <div key={field.id} className={style.card}>
<label> <div className={style.cardHeader}>
Runtime data source <Tag>Filter</Tag>
<Select<string | null> <span className={style.cardSummary}>{description}</span>
// need to normalize '' to null for the Select to show the placeholder <IconButton
value={watch(`filters.${index}.field`) || null} aria-label='Delete filter'
onValueChange={(value) => { variant='ghosted-destructive'
if (value === null) return; onClick={() => removeFilter(index)}
setValue(`filters.${index}.field`, value, { shouldDirty: true }); >
}} <IoTrash />
options={fieldList.map(({ value, label }) => ({ </IconButton>
value, </div>
label, <div className={style.cardBody}>
disabled: value === null, <label>
}))} Runtime data source
aria-label='Event field' <Select<string | null>
/> // need to normalize '' to null for the Select to show the placeholder
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error> value={watch(`filters.${index}.field`) || null}
</label> onValueChange={(value) => {
<label> if (value === null) return;
Matching condition setValue(`filters.${index}.field`, value, { shouldDirty: true });
<Select }}
value={watch(`filters.${index}.operator`)} options={fieldList.map(({ value, label }) => ({
onValueChange={(value: string | null) => { value,
if (value === null) return; label,
setValue( disabled: value === null,
`filters.${index}.operator`, }))}
value as aria-label='Event field'
| 'equals' />
| 'not_equals' <Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
| 'greater_than' </label>
| 'less_than' <label>
| 'contains' Matching condition
| 'not_contains', <Select
{ shouldDirty: true }, value={watch(`filters.${index}.operator`)}
); onValueChange={(value: string | null) => {
}} if (value === null) return;
options={[ setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
{ value: 'equals', label: 'equals' }, shouldDirty: true,
{ value: 'not_equals', label: 'not equals' }, });
{ value: 'contains', label: 'contains' }, }}
]} options={operators}
aria-label='Operator' aria-label='Operator'
/> />
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label> </label>
<label> <label>
Value to match Value to match
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' /> <Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
</label> </label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeFilter(index)}
>
<IoTrash />
</IconButton>
</div>
</div> </div>
</div> </div>
); );
@@ -303,12 +439,17 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<div className={style.innerColumn}> <div className={style.innerColumn}>
<h3>Outputs</h3> <h3>Outputs</h3>
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink> <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</Info> </Info>
{fieldOutputs.length === 0 && (
<Panel.EmptyState
title='This automation does nothing yet'
description='An automation without outputs will be triggered, but it has nothing to send.'
/>
)}
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) { if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as const rowErrors = errors.outputs?.[index] as
@@ -321,75 +462,61 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <OutputCard
<Tag>OSC</Tag> key={output.id}
<div className={style.oscSection}> label='OSC'
<label> kindClass={style.tagOsc}
Target IP summary={watch(`outputs.${index}.address`)}
<Input testState={testResults[output.id]}
{...register(`outputs.${index}.targetIP`, { onTest={() => handleTest(index, output.id)}
required: { value: true, message: 'Required field' }, onDelete={() => removeOutput(index)}
})} >
fluid <label>
placeholder='127.0.0.1' Target IP
/> <Input
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error> {...register(`outputs.${index}.targetIP`, {
</label> required: { value: true, message: 'Required field' },
<label> })}
Target Port fluid
<Input placeholder='127.0.0.1'
{...register(`outputs.${index}.targetPort`, { />
required: { value: true, message: 'Required field' }, <Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
setValueAs: (value) => (value === '' ? 0 : Number(value)), </label>
max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, <label>
min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, Target Port
})} <Input
fluid {...register(`outputs.${index}.targetPort`, {
type='number' required: { value: true, message: 'Required field' },
maxLength={5} setValueAs: (value) => (value === '' ? 0 : Number(value)),
placeholder='8000' max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
/> min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error> })}
</label> fluid
<label> type='number'
Address maxLength={5}
<TemplateInput placeholder='8000'
{...register(`outputs.${index}.address`)} />
value={output.address} <Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
fluid </label>
placeholder='/cue/start' <label className={style.spanFull}>
/> Address
<Panel.Error>{rowErrors?.address?.message}</Panel.Error> <TemplateInput
</label> {...register(`outputs.${index}.address`)}
<label> value={output.address}
Arguments fluid
<TemplateInput placeholder='/cue/start'
{...register(`outputs.${index}.args`)} />
value={output.args} <Panel.Error>{rowErrors?.address?.message}</Panel.Error>
fluid </label>
placeholder='1' <label className={style.spanFull}>
/> Arguments
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
</label> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
<div> </label>
<span>&nbsp;</span> </OutputCard>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
); );
} }
if (isHTTPOutput(output)) { if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as const rowErrors = errors.outputs?.[index] as
| { | {
@@ -397,42 +524,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
} }
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <OutputCard
<Tag>HTTP</Tag> key={output.id}
<div className={style.httpSection}> label='HTTP'
<label> kindClass={style.tagHttp}
Target URL testState={testResults[output.id]}
<TemplateInput onTest={() => handleTest(index, output.id)}
{...register(`outputs.${index}.url`, { onDelete={() => removeOutput(index)}
required: { value: true, message: 'Required field' }, >
pattern: { <label className={style.spanFull}>
value: startsWithHttp, Target URL
message: 'HTTP messages should target http:// or https://', <TemplateInput
}, {...register(`outputs.${index}.url`, {
})} required: { value: true, message: 'Required field' },
value={output.url} pattern: {
fluid value: startsWithHttp,
placeholder='http://127.0.0.1/start/1' message: 'HTTP messages should target http:// or https://',
/> },
<Panel.Error>{rowErrors?.url?.message}</Panel.Error> })}
</label> value={output.url}
<div> fluid
<span>&nbsp;</span> placeholder='http://127.0.0.1/start/1'
<Panel.InlineElements relation='inner'> />
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}> <Panel.Error>{rowErrors?.url?.message}</Panel.Error>
Test </label>
</Button> </OutputCard>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
); );
} }
@@ -447,8 +563,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
} }
| undefined; | undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <OutputCard
<Tag>Ontime action</Tag> key={output.id}
label='Ontime action'
kindClass={style.tagOntime}
testState={testResults[output.id]}
onTest={() => handleTest(index, output.id)}
onDelete={() => removeOutput(index)}
>
<OntimeActionForm <OntimeActionForm
value={output.action} value={output.action}
index={index} index={index}
@@ -456,38 +578,40 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
rowErrors={rowErrors} rowErrors={rowErrors}
setValue={setValue} setValue={setValue}
watch={watch} watch={watch}
> />
<span>&nbsp;</span> </OutputCard>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</OntimeActionForm>
</div>
); );
} }
return null; return null;
})} })}
<Panel.InlineElements relation='inner'> <div>
<Button onClick={handleAddNewOSCOutput}> <DropdownMenu
OSC <IoAdd /> render={<Button />}
</Button> items={[
<Button onClick={handleAddNewHTTPOutput}> {
HTTP <IoAdd /> type: 'item',
</Button> label: 'OSC',
<Button onClick={handleAddnewOntimeAction}> description: 'Send an OSC message to a device on the network',
Ontime action <IoAdd /> onClick: handleAddNewOSCOutput,
</Button> },
</Panel.InlineElements> {
type: 'item',
label: 'HTTP',
description: 'Call a URL, for webhooks and REST APIs',
onClick: handleAddNewHTTPOutput,
},
{
type: 'item',
label: 'Ontime action',
description: 'Change something inside Ontime, like a message or an aux timer',
onClick: handleAddnewOntimeAction,
},
]}
>
Add output <IoAdd />
</DropdownMenu>
</div>
</div> </div>
</form> </form>
} }
@@ -503,3 +627,42 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
/> />
); );
} }
interface OutputCardProps {
label: string;
kindClass?: string;
summary?: string;
testState?: TestState;
onTest: () => void;
onDelete: () => void;
children: ReactNode;
}
/**
* Shared chrome for every output kind: the type tag and the actions live in the header,
* so they stop competing with the form fields for grid columns
*/
function OutputCard({ label, kindClass, summary, testState, onTest, onDelete, children }: OutputCardProps) {
return (
<div className={style.card}>
<div className={style.cardHeader}>
<Tag className={kindClass}>{label}</Tag>
<span className={style.cardSummary}>{summary}</span>
{testState?.status === 'ok' && (
<span className={style.testOk}>
<IoCheckmark />
{testState.message}
</span>
)}
<Button variant='ghosted-white' onClick={onTest} loading={testState?.status === 'sending'}>
Test
</Button>
<IconButton aria-label='Delete output' variant='ghosted-destructive' onClick={onDelete}>
<IoTrash />
</IconButton>
</div>
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
<div className={style.cardBody}>{children}</div>
</div>
);
}
@@ -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,20 @@
/** tags make a cell taller than the title beside it, which staggers on the default baseline */
.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,57 +1,98 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; import { Automation, AutomationDTO, NormalisedAutomation, TimerLifeCycle, 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 type { AutomationRecipe } from './automationRecipes';
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: [],
outputs: [], outputs: [],
}; };
/** what the automation form opens with: an existing automation, or a blank/pre-filled draft */
type FormState = {
automation: Automation | AutomationDTO;
/** only used when creating, an existing automation resolves its own lifecycles */
defaultCycles?: TimerLifeCycle[];
};
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 [formState, setFormState] = useState<FormState | 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 automationIds = Object.keys(automations);
setDeleteError(null);
await deleteAutomation(id); /** a recipe is only ever a pre-filled form, nothing is written until the user saves */
} catch (error) { const handleStartFrom = (recipe: AutomationRecipe | null) => {
setDeleteError(maybeAxiosError(error)); setIsPickingStart(false);
} finally { setFormState({ automation: recipe?.automation ?? emptyAutomation, defaultCycles: recipe?.triggers });
refetch();
}
}; };
const arrayAutomations = Object.keys(automations); const handleDeleted = async () => {
setDeleteTarget(null);
await refetch();
};
return ( return (
<Panel.Section> <Panel.Section>
<Panel.Card> <Panel.Card>
{automationFormData !== null && ( {formState !== 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(formState.automation) ? formState.automation.id : 'new'}
automation={formState.automation}
triggers={triggers}
defaultCycles={formState.defaultCycles}
onClose={() => setFormState(null)}
/>
)}
{isPickingStart && <NewAutomationDialog onClose={() => setIsPickingStart(false)} onSelect={handleStartFrom} />}
{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={() => setIsPickingStart(true)}>
New <IoAdd /> New <IoAdd />
</Button> </Button>
</Panel.SubHeader> </Panel.SubHeader>
@@ -60,74 +101,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>
{/* the master switch is at the top of the panel, out of sight once the list has rows */}
<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 && automationIds.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={() => setIsPickingStart(true)}>
Create automation <IoAdd /> New automation <IoAdd />
</Button> </Button>
} }
/> />
)} )}
{arrayAutomations.map((automationId) => { {automationIds.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) { const automation = automations[automationId];
return null; const lifecycles = lifecyclesByAutomation[automationId] ?? [];
} const outputs = summariseOutputs(automation.outputs);
return ( return (
<Fragment key={automationId}> <tr key={automationId}>
<tr> <td>{automation.title}</td>
<td>{automations[automationId].title}</td> <td>
<td> <div className={style.tags}>
<Tag>{automations[automationId].filterRule}</Tag> {lifecycles.length === 0 ? (
</td> <Tag variant='warning'>Never runs</Tag>
<td>{automations[automationId].filters.length}</td> ) : (
<td>{automations[automationId].outputs.length}</td> lifecycles.map((cycle) => <Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>)
<Panel.InlineElements align='end' relation='inner' as='td'> )}
</div>
</td>
<td>
{automation.filters.length === 0 ? (
<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={() => setFormState({ 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={() => setDeleteTarget(automation)}
> >
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
</Panel.InlineElements> </div>
</tr> </td>
</Fragment> </tr>
); );
})} })}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Section> </Panel.Section>
@@ -0,0 +1,96 @@
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 handleDelete = async () => {
setError(null);
setIsDeleting(true);
try {
await deleteAutomation(automation.id);
onDeleted();
} catch (error) {
setError(maybeAxiosError(error));
} 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>
An automation attached to a single event has to be removed from that event first, in the event editor.
</Info.Footer>
</Info>
)}
</Panel.Section>
}
footerElements={
<>
<Button onClick={onCancel} disabled={isDeleting}>
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
Delete
</Button>
</>
}
/>
);
}
@@ -0,0 +1,58 @@
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
color: $ui-white;
}
.listLabel {
margin-top: 0.5rem;
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem;
text-align: left;
color: inherit;
background-color: $black-10;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
cursor: pointer;
&:hover {
background-color: $white-3;
border-color: $white-20;
}
&:focus-visible {
outline: 1px solid $action-blue;
outline-offset: 1px;
}
}
.optionText {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.optionTitle {
font-weight: 600;
}
.optionDescription {
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.chevron {
flex-shrink: 0;
color: $secondary-text-gray;
}
@@ -0,0 +1,79 @@
import { IoChevronForward } from 'react-icons/io5';
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, needsTarget, type AutomationRecipe } from './automationRecipes';
import style from './NewAutomationDialog.module.scss';
interface NewAutomationDialogProps {
onClose: () => void;
/** called with the recipe to pre-fill the form with, or null to start from an empty one */
onSelect: (recipe: AutomationRecipe | null) => void;
}
/**
* The single entry point for making an automation: a list of starting points.
* Picking one opens the ordinary automation form pre-filled, so a recipe is a head start
* rather than a separate kind of object. Nothing is saved until the user saves the form.
*/
export default function NewAutomationDialog({ onClose, onSelect }: NewAutomationDialogProps) {
// OSC is not available in the cloud service, offering those recipes there would be a lie
const recipes = isOntimeCloud
? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc'))
: automationRecipes;
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title='New automation'
bodyElements={
<div className={style.list}>
<button type='button' className={style.option} onClick={() => onSelect(null)}>
<div className={style.optionText}>
<div className={style.optionTitle}>Empty automation</div>
<div className={style.optionDescription}>Start from scratch.</div>
</div>
<IoChevronForward className={style.chevron} />
</button>
<div className={style.listLabel}>
Or start from a recipe. Each one opens as a normal automation you can edit before saving.
</div>
{recipes.map((recipe) => (
<button
type='button'
key={recipe.id}
className={style.option}
onClick={() => onSelect(recipe)}
aria-label={`Start from ${recipe.automation.title}`}
>
<div className={style.optionText}>
<div className={style.optionTitle}>{recipe.automation.title}</div>
<div className={style.optionDescription}>{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>
))}
{needsTarget(recipe) && <Tag variant='warning'>Point it at your device</Tag>}
</Panel.InlineElements>
</div>
<IoChevronForward className={style.chevron} />
</button>
))}
</div>
}
/>
);
}
@@ -1,5 +1,5 @@
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types'; import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
import { PropsWithChildren, useState } from 'react'; import { useState } from 'react';
import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form'; import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input'; import Input from '../../../../common/components/input/input/Input';
@@ -30,9 +30,8 @@ export default function OntimeActionForm({
setValue, setValue,
rowErrors, rowErrors,
value, value,
children,
watch, watch,
}: PropsWithChildren<OntimeActionFormProps>) { }: OntimeActionFormProps) {
const [selectedAction, setSelectedAction] = useState<string>(value); const [selectedAction, setSelectedAction] = useState<string>(value);
const handleSetAction = (value: OntimeActionKey) => { const handleSetAction = (value: OntimeActionKey) => {
@@ -41,7 +40,7 @@ export default function OntimeActionForm({
}; };
return ( return (
<div className={style.actionSection}> <>
<label> <label>
Action Action
<Select <Select
@@ -95,7 +94,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-set' && ( {selectedAction === 'message-set' && (
<> <>
<label> <label className={style.spanFull}>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -127,7 +126,7 @@ export default function OntimeActionForm({
{selectedAction === 'message-secondary' && ( {selectedAction === 'message-secondary' && (
<> <>
<label> <label className={style.spanFull}>
Text (leave empty for no change) Text (leave empty for no change)
<TemplateInput <TemplateInput
{...register(`outputs.${index}.text`)} {...register(`outputs.${index}.text`)}
@@ -169,8 +168,6 @@ export default function OntimeActionForm({
</label> </label>
</> </>
)} )}
</>
<div className={style.test}>{children}</div>
</div>
); );
} }
@@ -5,9 +5,9 @@ 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,14 +109,18 @@ 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>
) )
} }
/> />
@@ -0,0 +1,65 @@
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
import { automationRecipes, needsTarget } from '../automationRecipes';
import { operators } from '../automationUtils';
/**
* Recipes are shipped as constants but saved through the same endpoint as a hand written
* automation. These assertions stand in for the server side validation, so a recipe cannot
* silently rot into something that 400s when the user presses save.
*/
describe('automationRecipes', () => {
it('has unique ids', () => {
const ids = automationRecipes.map(({ id }) => id);
expect(new Set(ids).size).toBe(ids.length);
});
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 a title and something to send', () => {
for (const recipe of automationRecipes) {
expect(recipe.automation.title).not.toBe('');
expect(recipe.automation.outputs.length).toBeGreaterThan(0);
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('flags the recipes that reach outside Ontime', () => {
for (const recipe of automationRecipes) {
const reachesOut = recipe.automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
expect(needsTarget(recipe)).toBe(reachesOut);
}
});
});
@@ -0,0 +1,119 @@
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
import { isOntimeAction, TimerLifeCycle as Cycle } from 'ontime-types';
/**
* A recipe is a pre-filled automation form, nothing more.
* Choosing one opens the normal form with its values in place: the user reviews it,
* points it at their own gear and saves. Nothing is written until they do.
*/
export type AutomationRecipe = {
/** stable, client only. Never persisted */
id: string;
/** one line, plain language: what this does for the user */
description: string;
/** typed so the compiler catches drift against the automation schema */
automation: AutomationDTO;
/** lifecycles the form starts with selected */
triggers: TimerLifeCycle[];
};
/**
* A recipe that only sends Ontime actions works the moment it is saved.
* Anything else points at software we cannot locate for the user.
*/
export function needsTarget(recipe: AutomationRecipe): boolean {
return !recipe.automation.outputs.every(isOntimeAction);
}
/**
* Every recipe targets loopback by default.
* A recipe saved without thinking must not put traffic on a venue network, so the user
* has to point it somewhere real before it can reach anything.
*
* Ordered so the ones that work out of the box come first.
*/
export const automationRecipes: AutomationRecipe[] = [
{
id: 'ontime-aux-timer',
description: 'Sets aux timer 1 to five minutes and starts it whenever an event starts.',
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',
description: 'Shows a message on the stage timer as soon as the running event enters its danger window.',
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',
description: 'Hides the stage message once the event finishes. Pairs with the danger warning above.',
automation: {
title: 'Hide the stage message on finish',
filterRule: 'all',
filters: [],
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
},
triggers: [Cycle.onFinish],
},
{
id: 'qlab-go',
description: "Sends OSC to QLab to start the cue whose number matches the Ontime event's cue.",
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',
description: 'Presses page 1, button 1 on a Stream Deck through the Companion HTTP API.',
automation: {
title: 'Companion button press',
filterRule: 'all',
filters: [],
// Companion HTTP API: /api/location/<page>/<row>/<column>/press
outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/0/press' }],
},
triggers: [Cycle.onStart],
},
{
id: 'vmix-overlay-warning',
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
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: 'webhook-event-title',
description: 'Posts the running event title to any URL. A good place to see template strings at work.',
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],
},
];