feat(automation): improve definition and trigger editing

This commit is contained in:
Carlos Valente
2026-09-13 09:51:47 +02:00
committed by Carlos Valente
parent 79c21daf73
commit b07544ab84
33 changed files with 865 additions and 370 deletions
@@ -0,0 +1,25 @@
import { maybeAxiosError } from '../utils';
describe('maybeAxiosError', () => {
it('shows the validation message without echoing the submitted value', () => {
const error = {
isAxiosError: true,
response: {
statusText: 'Unprocessable Entity',
data: {
errors: [
{
type: 'field',
value: { title: 'Automation definition', outputs: [{ targetIP: 'not a host' }] },
msg: 'Invalid OSC target',
path: '',
location: 'body',
},
],
},
},
};
expect(maybeAxiosError(error)).toBe('Unprocessable Entity: Invalid OSC target');
});
});
+9
View File
@@ -18,6 +18,15 @@ export function maybeAxiosError(error: unknown) {
if (typeof data === 'object') { if (typeof data === 'object') {
if ('message' in data) { if ('message' in data) {
data = JSON.stringify(data.message); data = JSON.stringify(data.message);
} else if ('errors' in data && Array.isArray(data.errors)) {
const firstError = data.errors.at(0);
data =
typeof firstError === 'object' &&
firstError !== null &&
'msg' in firstError &&
typeof firstError.msg === 'string'
? firstError.msg
: JSON.stringify(data);
} else { } else {
data = JSON.stringify(data); data = JSON.stringify(data);
} }
@@ -0,0 +1,13 @@
import { TimerLifeCycle } from 'ontime-types';
import { getLifecycleLabel } from '../timerLifecycle';
describe('getLifecycleLabel', () => {
it('returns the shared label for known lifecycle values', () => {
expect(getLifecycleLabel(TimerLifeCycle.onClock)).toBe('Every second');
});
it('keeps unknown lifecycle values visible', () => {
expect(getLifecycleLabel('future-lifecycle')).toBe('future-lifecycle');
});
});
@@ -0,0 +1,17 @@
import { TimerLifeCycle } from 'ontime-types';
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
[TimerLifeCycle.onLoad]: 'On Load',
[TimerLifeCycle.onStart]: 'On Start',
[TimerLifeCycle.onPause]: 'On Pause',
[TimerLifeCycle.onStop]: 'On Stop',
[TimerLifeCycle.onClock]: 'Every second',
[TimerLifeCycle.onUpdate]: 'On Timer Update',
[TimerLifeCycle.onFinish]: 'On Finish',
[TimerLifeCycle.onWarning]: 'On Warning',
[TimerLifeCycle.onDanger]: 'On Danger',
};
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
}
@@ -0,0 +1,32 @@
import type { AutomationOutput } from 'ontime-types';
import { summariseOutputs } from '../automationOutputs';
describe('summariseOutputs', () => {
it('returns an empty list when there are no outputs', () => {
expect(summariseOutputs([])).toEqual([]);
});
it('counts repeated output kinds', () => {
const outputs: AutomationOutput[] = [
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
{ type: 'http', url: 'http://127.0.0.1/start' },
];
expect(summariseOutputs(outputs)).toEqual([
{ type: 'osc', label: 'OSC', count: 2 },
{ type: 'http', label: 'HTTP', count: 1 },
]);
});
it('presents kinds in a stable order regardless of insertion order', () => {
const outputs: AutomationOutput[] = [
{ type: 'ontime', action: 'aux1-start' },
{ type: 'http', url: 'http://127.0.0.1/start' },
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
];
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
});
});
@@ -0,0 +1,26 @@
import type { AutomationOutput } from 'ontime-types';
const outputLabels: Record<AutomationOutput['type'], string> = {
osc: 'OSC',
http: 'HTTP',
ontime: 'Ontime',
};
export type OutputSummary = {
type: AutomationOutput['type'];
label: string;
count: number;
};
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
return order
.filter((type) => counts.has(type))
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
}
@@ -30,7 +30,8 @@ $content-max-width: 1280px;
max-width: $content-max-width; max-width: $content-max-width;
margin: 0 auto 1rem; margin: 0 auto 1rem;
padding-inline: 1rem; padding-inline: 1rem;
overflow-y: auto; // Own both scroll axes so sticky table headers stay anchored to the panel viewport.
overflow: auto;
flex-grow: 1; flex-grow: 1;
// room for the last section to scroll to the top of the viewport // room for the last section to scroll to the top of the viewport
padding-bottom: 40vh; padding-bottom: 40vh;
@@ -5,9 +5,8 @@
font-size: calc(1rem - 1px); font-size: calc(1rem - 1px);
color: $ui-white; color: $ui-white;
// the shared modal body owns scrolling for this regular form modal
min-height: 100%;
padding-block: 0.5rem; padding-block: 0.5rem;
padding-right: 0.5rem;
h3 { h3 {
font-size: 1rem; font-size: 1rem;
@@ -27,10 +26,7 @@
} }
.titleSection, .titleSection,
.filterSection, .filterSection {
.oscSection,
.httpSection,
.actionSection {
display: grid; display: grid;
grid-gap: 0.5rem; grid-gap: 0.5rem;
@@ -41,10 +37,7 @@
.titleSection, .titleSection,
.ruleSection, .ruleSection,
.filterSection, .filterSection {
.oscSection,
.httpSection,
.actionSection {
label, label,
div { div {
// we use the div as non-interactive placeholder for button cells // we use the div as non-interactive placeholder for button cells
@@ -61,26 +54,85 @@
} }
.filterSection { .filterSection {
grid-template-columns: 2fr 1fr 2fr auto; grid-template-columns: minmax(11rem, 1.25fr) minmax(10rem, 1fr) minmax(13rem, 1.75fr) auto;
align-items: end;
} }
.oscSection { @media (max-width: $min-tablet) {
grid-template-columns: 9rem 5rem 3fr 4fr auto; .filterSection {
} grid-template-columns: 1fr 1fr auto;
.httpSection { label:last-of-type {
grid-template-columns: 1fr auto; grid-column: 1 / -1;
} }
.actionSection {
grid-template-columns: auto 1fr 1fr auto;
.test {
grid-column: -1;
} }
} }
.outputCard { .card {
border: 1px solid $white-10;
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;
label {
display: block;
font-size: calc(1rem - 3px);
color: $label-gray;
}
}
.cardHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid $white-10;
}
.cardSummary {
flex: 1;
min-width: 0;
overflow: hidden;
color: $secondary-text-gray;
font-size: $aux-text-size;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardBody {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 0.5rem 0.75rem;
padding: 0.75rem;
}
.spanFull {
grid-column: 1 / -1;
}
.testOk {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: $green-400;
font-size: $aux-text-size;
}
.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,14 +1,5 @@
import { import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
Automation, import { useEffect, useMemo, useRef, useState } from 'react';
AutomationDTO,
HTTPOutput,
OSCOutput,
OntimeAction,
isHTTPOutput,
isOSCOutput,
isOntimeAction,
} from 'ontime-types';
import { useEffect, useMemo } 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, IoTrash } from 'react-icons/io5';
@@ -16,25 +7,28 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap
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';
import Modal from '../../../../common/components/modal/Modal'; import Modal from '../../../../common/components/modal/Modal';
import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { startsWithHttp } from '../../../../common/utils/regex'; import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils'; import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
import HttpOutputForm from './HttpOutputForm';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
import TemplateInput from './template-input/TemplateInput'; import OscOutputForm from './OscOutputForm';
import OutputCard, { type TestState } from './OutputCard';
import style from './AutomationForm.module.scss'; 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';
const testFeedbackDuration = 2000;
interface AutomationFormProps { interface AutomationFormProps {
automation: Automation | AutomationDTO; automation: Automation | AutomationDTO;
@@ -46,13 +40,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
const { data } = useCustomFields(); const { data } = useCustomFields();
const { refetch } = useAutomationSettings(); const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]); const fieldList = useMemo(() => makeFieldList(data), [data]);
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
const [submitError, setSubmitError] = useState<string>();
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
const { const {
control, control,
handleSubmit, handleSubmit,
getValues, getValues,
register, register,
setError,
setFocus, setFocus,
setValue, setValue,
watch, watch,
@@ -93,6 +89,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
setFocus('title'); setFocus('title');
}, [setFocus]); }, [setFocus]);
// Clear delayed output-test feedback when the modal unmounts.
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 getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
const handleAddNewFilter = () => { const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' }); appendFilter({ field: '', operator: 'equals', value: '' });
}; };
@@ -106,57 +124,33 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
appendOutput({ type: 'http', url: '' }); appendOutput({ type: 'http', url: '' });
}; };
const handleAddnewOntimeAction = () => { const handleAddNewOntimeAction = () => {
appendOutput({ type: 'ontime', action: 'aux1-start' }); appendOutput({ type: 'ontime', action: 'aux1-start' });
}; };
const handleTestOSCOutput = async (index: number) => { const handleTest = async (index: number, key: string) => {
try { const values = getValues(`outputs.${index}`);
const values = getValues(`outputs.${index}`) as OSCOutput;
if (!values.targetIP || !values.targetPort || !values.address) { if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
return; return;
} }
await testOutput({ if (isHTTPOutput(values) && !values.url) {
type: 'osc', reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
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 {
const values = getValues(`outputs.${index}`) as HTTPOutput;
if (!values.url) {
return; return;
} }
await testOutput({
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestOntimeAction = async (index: number) => { reportTest(key, { status: 'sending' });
try { try {
const values = getValues(`outputs.${index}`) as OntimeAction; await testOutput(values);
// NOTE: there is no meaningful validation to do here, we let the server deal with the data reportTest(key, { status: 'ok', message: 'Request sent' });
await testOutput({ } catch (error) {
...values, reportTest(key, { status: 'error', message: maybeAxiosError(error) });
type: 'ontime',
});
} catch (_error) {
/** we dont handle errors here */
} }
}; };
const onSubmit = async (values: AutomationDTO) => { const onSubmit = async (values: AutomationDTO) => {
setSubmitError(undefined);
if (isAutomation(automation)) { if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values }); await handleEdit(automation.id, { id: automation.id, ...values });
} else { } else {
@@ -169,7 +163,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await editAutomation(id, values); await editAutomation(id, values);
onClose(); onClose();
} catch (error) { } catch (error) {
setError('root', { message: maybeAxiosError(error) }); setSubmitError(maybeAxiosError(error));
} }
} }
@@ -178,12 +172,43 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
await addAutomation(values); await addAutomation(values);
onClose(); onClose();
} catch (error) { } catch (error) {
setError('root', { message: maybeAxiosError(error) }); setSubmitError(maybeAxiosError(error));
} }
} }
}; };
const canSubmit = !isSubmitting && isDirty && isValid; const canSubmit = !isSubmitting && isDirty && isValid;
const addOutputMenu = (
<DropdownMenu
render={<Button />}
items={[
...(isOntimeCloud
? []
: [
{
type: 'item' as const,
label: 'OSC',
description: 'Send an OSC message to a device on the network',
onClick: handleAddNewOSCOutput,
},
]),
{
type: 'item' as const,
label: 'HTTP',
description: 'Call a URL, for webhooks and REST APIs',
onClick: handleAddNewHTTPOutput,
},
{
type: 'item' as const,
label: 'Ontime action',
description: 'Change something inside Ontime, like a message or an aux timer',
onClick: handleAddNewOntimeAction,
},
]}
>
Add output <IoAdd />
</DropdownMenu>
);
return ( return (
<Modal <Modal
@@ -202,7 +227,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
placeholder='Load preset' placeholder='Automation title'
/> />
</label> </label>
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
@@ -223,6 +248,9 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ value: 'any', label: 'Any filter passes' }, { value: 'any', label: 'Any filter passes' },
]} ]}
/> />
<Panel.Description>
All filters pass requires every condition to match. Any filter passes requires at least one match.
</Panel.Description>
</label> </label>
{fieldFilters.map((field, index) => { {fieldFilters.map((field, index) => {
const key = `filters.${index}.field.${field.id}`; const key = `filters.${index}.field.${field.id}`;
@@ -264,11 +292,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
{ shouldDirty: true }, { shouldDirty: true },
); );
}} }}
options={[ options={operators}
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'not equals' },
{ value: 'contains', label: 'contains' },
]}
aria-label='Operator' aria-label='Operator'
/> />
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error> <Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
@@ -305,150 +329,52 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
<Info> <Info>
Automation outputs can be used to send data from Ontime to external software <br /> Automation outputs can be used to send data from Ontime to external software <br />
or to change properties of Ontime itself. <br /> <br /> or to change properties of Ontime itself. <br /> <br />
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '} <span>
Use Ontime runtime data in these fields with template strings. Type{' '}
<Panel.Highlight>{'{{'}</Panel.Highlight> to see autocomplete, or{' '}
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink> <ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
</span>
</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.'
action={addOutputMenu}
/>
)}
{fieldOutputs.map((output, index) => { {fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) { const cardProps = {
const rowErrors = errors.outputs?.[index] as testState: testResults[output.id],
| { onTest: () => handleTest(index, output.id),
targetIP?: { message?: string }; onDelete: () => removeOutput(index),
targetPort?: { message?: string }; };
address?: { message?: string }; const rowErrors = getOutputErrors(index);
args?: { message?: string };
}
| undefined;
if (isOSCOutput(output)) {
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 unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
{...register(`outputs.${index}.targetIP`, { {...cardProps}
required: { value: true, message: 'Required field' },
})}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Arguments
<TemplateInput
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
> >
<IoTrash /> <OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
</IconButton> </OutputCard>
</Panel.InlineElements>
</div>
</div>
</div>
); );
} }
if (isHTTPOutput(output)) { if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
<Tag>HTTP</Tag> <HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
<div className={style.httpSection}> </OutputCard>
<label>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
variant='ghosted-destructive'
onClick={() => removeOutput(index)}
>
<IoTrash />
</IconButton>
</Panel.InlineElements>
</div>
</div>
</div>
); );
} }
if (isOntimeAction(output)) { if (isOntimeAction(output)) {
const rowErrors = errors.outputs?.[index] as
| {
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
}
| undefined;
return ( return (
<div key={output.id} className={style.outputCard}> <OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
<Tag>Ontime action</Tag>
<OntimeActionForm <OntimeActionForm
value={output.action} value={output.action}
index={index} index={index}
@@ -456,44 +382,19 @@ 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'> {fieldOutputs.length > 0 && addOutputMenu}
<Button onClick={handleAddNewOSCOutput}>
OSC <IoAdd />
</Button>
<Button onClick={handleAddNewHTTPOutput}>
HTTP <IoAdd />
</Button>
<Button onClick={handleAddnewOntimeAction}>
Ontime action <IoAdd />
</Button>
</Panel.InlineElements>
</div> </div>
</form> </form>
} }
footerElements={ footerElements={
<> <>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>} {submitError && <Panel.Error>{submitError}</Panel.Error>}
<Button onClick={onClose}>Cancel</Button> <Button onClick={onClose}>Cancel</Button>
<Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}> <Button variant='primary' type='submit' form={formId} disabled={!canSubmit} loading={isSubmitting}>
Save Save
@@ -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,3 @@
.table {
min-width: 36rem;
}
@@ -9,9 +9,12 @@ 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 useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import style from './AutomationsList.module.scss';
const automationPlaceholder: AutomationDTO = { const automationPlaceholder: AutomationDTO = {
title: '', title: '',
filterRule: 'all', filterRule: 'all',
@@ -60,19 +63,18 @@ 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 Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
enabled.
</Info> </Info>
)} )}
<Panel.Table> <Panel.Table className={style.table}>
<thead> <thead>
<tr> <tr>
<th style={{ width: '45%' }}>Title</th> <th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th> <th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Filters</th> <th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th> <th style={{ width: '15%' }}>Sends</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@@ -80,7 +82,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
{!isLoading && arrayAutomations.length === 0 && ( {!isLoading && arrayAutomations.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='Create a reusable definition, then attach it to a global or an event trigger.'
action={ action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}> <Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
Create automation <IoAdd /> Create automation <IoAdd />
@@ -100,7 +102,15 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Tag>{automations[automationId].filterRule}</Tag> <Tag>{automations[automationId].filterRule}</Tag>
</td> </td>
<td>{automations[automationId].filters.length}</td> <td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td> <td>
{automations[automationId].outputs.length === 0 ? (
<Tag variant='warning'>No outputs</Tag>
) : (
summariseOutputs(automations[automationId].outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
)}
</td>
<Panel.InlineElements align='end' relation='inner' as='td'> <Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton <IconButton
variant='ghosted-white' variant='ghosted-white'
@@ -0,0 +1,34 @@
import type { AutomationDTO, HTTPOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface HttpOutputFormProps {
index: number;
output: HTTPOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
return (
<label className={style.spanFull}>
Target URL
<TemplateInput
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
})}
value={output.url}
fluid
placeholder='http://127.0.0.1/start/1'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
);
}
@@ -1,10 +1,11 @@
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';
import Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput'; import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss'; import style from './AutomationForm.module.scss';
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
interface OntimeActionFormProps { interface OntimeActionFormProps {
index: number; index: number;
register: UseFormRegister<AutomationDTO>; register: UseFormRegister<AutomationDTO>;
rowErrors?: { rowErrors?: OutputErrors;
action?: { message?: string };
time?: { message?: string };
text?: { message?: string };
visible?: { message?: string };
secondarySource?: { message?: string };
};
value: OntimeAction['action']; value: OntimeAction['action'];
watch: UseFormWatch<AutomationDTO>; watch: UseFormWatch<AutomationDTO>;
setValue: UseFormSetValue<AutomationDTO>; setValue: UseFormSetValue<AutomationDTO>;
@@ -30,9 +25,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 +35,7 @@ export default function OntimeActionForm({
}; };
return ( return (
<div className={style.actionSection}> <>
<label> <label>
Action Action
<Select <Select
@@ -95,7 +89,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`)}
@@ -105,7 +99,7 @@ export default function OntimeActionForm({
/> />
<Panel.Error>{rowErrors?.text?.message}</Panel.Error> <Panel.Error>{rowErrors?.text?.message}</Panel.Error>
</label> </label>
<label> <label className={style.spanFull}>
Visibility Visibility
<Select <Select
onValueChange={(value) => { onValueChange={(value) => {
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
</label> </label>
</> </>
)} )}
</>
<div className={style.test}>{children}</div>
</div>
); );
} }
@@ -0,0 +1,63 @@
import type { AutomationDTO, OSCOutput } from 'ontime-types';
import type { UseFormRegister } from 'react-hook-form';
import Input from '../../../../common/components/input/input/Input';
import * as Panel from '../../panel-utils/PanelUtils';
import type { OutputErrors } from './automationUtils';
import TemplateInput from './template-input/TemplateInput';
import style from './AutomationForm.module.scss';
interface OscOutputFormProps {
index: number;
output: OSCOutput;
register: UseFormRegister<AutomationDTO>;
rowErrors?: OutputErrors;
}
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
return (
<>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
fluid
placeholder='127.0.0.1'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
fluid
type='number'
maxLength={5}
placeholder='8000'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Address
<TemplateInput
{...register(`outputs.${index}.address`)}
value={output.address}
fluid
placeholder='/cue/start'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label className={style.spanFull}>
Arguments
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
</>
);
}
@@ -0,0 +1,60 @@
import type { ReactNode } from 'react';
import { IoCheckmark, IoTrash } from 'react-icons/io5';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './AutomationForm.module.scss';
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
interface OutputCardProps {
label: string;
kindClass?: string;
summary?: string;
testState?: TestState;
unavailableReason?: string;
onTest: () => void;
onDelete: () => void;
children: ReactNode;
}
export default function OutputCard({
label,
kindClass,
summary,
testState,
unavailableReason,
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>
)}
{unavailableReason ? (
<Tag variant='warning'>{unavailableReason}</Tag>
) : (
<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>
);
}
@@ -0,0 +1,25 @@
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.titleField {
display: block;
}
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
label {
min-width: 0;
}
}
@media (max-width: $min-tablet) {
.fields {
grid-template-columns: 1fr;
}
}
@@ -11,6 +11,8 @@ import Select from '../../../../common/components/select/Select';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils'; import { cycles } from './automationUtils';
import style from './TriggerForm.module.scss';
const formId = 'trigger-form'; const formId = 'trigger-form';
interface TriggerFormProps { interface TriggerFormProps {
@@ -83,18 +85,20 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
size='compact' size='compact'
title={trigger ? 'Edit trigger' : 'Create trigger'} title={trigger ? 'Edit trigger' : 'Create trigger'}
bodyElements={ bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)}> <form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
<label> <label className={style.titleField}>
Title <Panel.Description>Title</Panel.Description>
<Input <Input
{...register('title', { required: { value: true, message: 'Required field' } })} {...register('title', { required: { value: true, message: 'Required field' } })}
fluid fluid
defaultValue={trigger?.title} defaultValue={trigger?.title}
placeholder='Trigger title'
/> />
<Panel.Error>{errors.title?.message}</Panel.Error> <Panel.Error>{errors.title?.message}</Panel.Error>
</label> </label>
<div className={style.fields}>
<label> <label>
Lifecycle trigger <Panel.Description>When to run</Panel.Description>
<Select <Select
value={watch('trigger')} value={watch('trigger')}
onValueChange={(value) => { onValueChange={(value) => {
@@ -107,7 +111,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
<Panel.Error>{errors.trigger?.message}</Panel.Error> <Panel.Error>{errors.trigger?.message}</Panel.Error>
</label> </label>
<label> <label>
Automation title <Panel.Description>Automation</Panel.Description>
<Select <Select
value={watch('automationId')} value={watch('automationId')}
onValueChange={(value: string | null) => { onValueChange={(value: string | null) => {
@@ -119,6 +123,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
/> />
<Panel.Error>{errors.automationId?.message}</Panel.Error> <Panel.Error>{errors.automationId?.message}</Panel.Error>
</label> </label>
</div>
</form> </form>
} }
footerElements={ footerElements={
@@ -0,0 +1,3 @@
.table {
min-width: 36rem;
}
@@ -1,5 +1,5 @@
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';
@@ -12,6 +12,8 @@ import { checkDuplicates } from './automationUtils';
import TriggerForm from './TriggerForm'; import TriggerForm from './TriggerForm';
import TriggersListItem from './TriggersListItem'; import TriggersListItem from './TriggersListItem';
import style from './TriggersList.module.scss';
type FormState = { type FormState = {
isOpen: boolean; isOpen: boolean;
trigger?: Trigger; trigger?: Trigger;
@@ -50,6 +52,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,7 +72,7 @@ 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>
@@ -74,17 +80,25 @@ export default function TriggersList({ triggers, automations, enabledAutomations
<Panel.Divider /> <Panel.Divider />
<Panel.Section> <Panel.Section>
{enabledAutomations === false && ( {enabledAutomations === false && (
<Info> <Info type='warning'>
Automations are disabled. You can still manage triggers here, but they will not run until enabled. Automations are disabled. You can still manage them, but they won&apos;t run until enabled.
</Info> </Info>
)} )}
<Info>Actions in this section affect the entire project runtime, not just a single event.</Info>
{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> </Panel.Error>
)} )}
<Panel.Table> {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.Table className={style.table}>
<thead> <thead>
<tr> <tr>
<th style={{ width: '35%' }}>Title</th> <th style={{ width: '35%' }}>Title</th>
@@ -100,7 +114,7 @@ export default function TriggersList({ triggers, automations, enabledAutomations
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, like when an event starts or finishes.'
: 'Create an automation first, then add a trigger to decide when it should run.' : 'Create an automation definition first, then add a global trigger to decide when it should run.'
} }
action={ action={
canAdd && ( canAdd && (
@@ -111,19 +125,16 @@ export default function TriggersList({ triggers, automations, enabledAutomations
} }
/> />
)} )}
{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}>
@@ -30,9 +31,7 @@ export default function TriggersListItem(props: TriggersListItemProps) {
<td> <td>
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag> <Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
</td> </td>
<td> <td>{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}</td>
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
</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}>
<IoPencil /> <IoPencil />
@@ -1,6 +1,19 @@
import { TimerLifeCycle, Trigger } from 'ontime-types'; import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils'; import { checkDuplicates, operators } from '../automationUtils';
describe('automation form options', () => {
it('offers the complete filter operator contract', () => {
expect(operators.map(({ value }) => value)).toEqual([
'equals',
'not_equals',
'contains',
'not_contains',
'greater_than',
'less_than',
]);
});
});
describe('checkDuplicates', () => { describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => { it('should return undefined if there are no duplicates', () => {
@@ -1,21 +1,33 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types'; import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { lifecycleLabels } from '../../../../common/constants/timerLifecycle';
type CycleLabel = { type CycleLabel = {
id: number;
label: string; label: string;
value: keyof typeof TimerLifeCycle; value: TimerLifeCycle;
}; };
export const cycles: CycleLabel[] = [ export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' }, { label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
{ id: 2, label: 'On Start', value: 'onStart' }, { label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
{ id: 3, label: 'On Pause', value: 'onPause' }, { label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
{ id: 4, label: 'On Stop', value: 'onStop' }, { label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
{ id: 5, label: 'Every second', value: 'onClock' }, { label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' }, { label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
{ id: 7, label: 'On Finish', value: 'onFinish' }, { label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
{ id: 8, label: 'On Warning', value: 'onWarning' }, { label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
{ id: 9, label: 'On Danger', value: 'onDanger' }, { label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
];
export type OutputErrors = Partial<Record<string, { message?: string }>>;
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
{ value: 'equals', label: 'equals' },
{ value: 'not_equals', label: 'does not equal' },
{ value: 'contains', label: 'contains' },
{ value: 'not_contains', label: 'does not contain' },
{ value: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
]; ];
/** /**
@@ -66,19 +78,16 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
* We warn the user if they have created multiple links between the same automation and a trigger * We warn the user if they have created multiple links between the same automation and a trigger
*/ */
export function checkDuplicates(triggers: Trigger[]) { export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {}; const seen = new Set<string>();
const duplicates = []; const duplicates: number[] = [];
for (let i = 0; i < triggers.length; i++) { for (const [index, trigger] of triggers.entries()) {
const trigger = triggers[i]; const key = `${trigger.trigger}:${trigger.automationId}`;
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
if (triggersMap[trigger.trigger].includes(trigger.automationId)) { if (seen.has(key)) {
duplicates.push(i); duplicates.push(index);
} else { } else {
triggersMap[trigger.trigger].push(trigger.automationId); seen.add(key);
} }
} }
return duplicates.length > 0 ? duplicates : undefined; return duplicates.length > 0 ? duplicates : undefined;
@@ -15,7 +15,7 @@
.triggerHeader { .triggerHeader {
display: grid; display: grid;
grid-template-columns: 8rem 1fr 2rem; grid-template-columns: 8rem 1fr auto 2rem;
gap: 0.5rem; gap: 0.5rem;
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
font-size: $aux-text-size; font-size: $aux-text-size;
@@ -25,7 +25,7 @@
.trigger { .trigger {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
display: grid; display: grid;
grid-template-columns: 8rem 1fr 2rem; grid-template-columns: 8rem 1fr auto 2rem;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
min-height: 2.5rem; min-height: 2.5rem;
@@ -41,6 +41,12 @@
} }
} }
.outputTags {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.duplicateMessage { .duplicateMessage {
padding-left: 0.75rem; padding-left: 0.75rem;
font-size: $aux-text-size; font-size: $aux-text-size;
@@ -6,8 +6,11 @@ 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 Select from '../../../../common/components/select/Select'; import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext'; import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { eventTriggerOptions } from './eventTrigger.constants'; import { eventTriggerOptions } from './eventTrigger.constants';
import style from './EventEditorTriggers.module.scss'; import style from './EventEditorTriggers.module.scss';
@@ -27,7 +30,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
label: title, label: title,
})); }));
const hasAutomationOptions = allAutomationOptions.length > 0; const hasAutomationOptions = allAutomationOptions.length > 0;
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle })); const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: getLifecycleLabel(cycle) }));
const duplicateIds = new Set<string>(); const duplicateIds = new Set<string>();
const seen = new Map<string, string>(); const seen = new Map<string, string>();
@@ -76,8 +79,10 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
<div className={style.triggerHeader}> <div className={style.triggerHeader}>
<span>Lifecycle</span> <span>Lifecycle</span>
<span>Automation</span> <span>Automation</span>
<span>Sends</span>
</div> </div>
{triggers.map((trigger) => { {triggers.map((trigger) => {
const automation = automationSettings.automations[trigger.automationId];
const isDuplicate = duplicateIds.has(trigger.id); const isDuplicate = duplicateIds.has(trigger.id);
const lifecycleOptions = isDuplicate const lifecycleOptions = isDuplicate
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt)) ? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
@@ -103,6 +108,15 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
}} }}
options={automationOptions} options={automationOptions}
/> />
<div className={style.outputTags}>
{automation ? (
summariseOutputs(automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))
) : (
<Tag variant='warning'>Missing automation</Tag>
)}
</div>
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}> <IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
<IoTrash /> <IoTrash />
</IconButton> </IconButton>
@@ -51,4 +51,34 @@ describe('automation controllers', () => {
); );
}); });
it('persists definitions using the not_contains filter operator', async () => {
const request = {
body: {
...requestBody,
filters: [{ field: 'eventNow.title', operator: 'not_contains', value: 'break' }],
},
} as Request;
await postAutomation(request, makeResponse());
expect(automationDao.addAutomation).toHaveBeenCalledWith(
expect.objectContaining({ filters: [expect.objectContaining({ operator: 'not_contains' })] }),
);
});
it('rejects trigger bindings when editing a definition', async () => {
const request = {
body: { ...requestBody, triggers: [{ trigger: 'onStart', automationId: 'other-definition' }] },
params: { id: 'automation-id' },
} as unknown as Request;
const response = makeResponse();
await editAutomation(request, response);
expect(automationDao.editAutomation).not.toHaveBeenCalled();
expect(response.status).toHaveBeenCalledWith(400);
expect(response.send).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Automation definitions cannot include triggers' }),
);
});
}); });
@@ -146,7 +146,7 @@ describe('editAutomation()', () => {
title: 'test-osc', title: 'test-osc',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [], outputs: [makeOSCAction(), makeHTTPAction()],
}); });
await addAutomation({ await addAutomation({
title: 'test-http', title: 'test-http',
@@ -164,7 +164,7 @@ describe('editAutomation()', () => {
title: 'test-osc', title: 'test-osc',
filterRule: 'all', filterRule: 'all',
filters: expect.any(Array), filters: expect.any(Array),
outputs: expect.any(Array), outputs: [makeOSCAction(), makeHTTPAction()],
}); });
const editedOSC = await editAutomation(firstAutomation.id, { const editedOSC = await editAutomation(firstAutomation.id, {
@@ -179,9 +179,34 @@ describe('editAutomation()', () => {
title: 'edited-title', title: 'edited-title',
filterRule: 'any', filterRule: 'any',
filters: expect.any(Array), filters: expect.any(Array),
outputs: expect.any(Array), outputs: [],
}); });
}); });
it('replaces all outputs when an automation definition is edited', async () => {
const editedWithOneOutput = await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'any',
filters: [],
outputs: [makeHTTPAction()],
});
expect(editedWithOneOutput.outputs).toEqual([makeHTTPAction()]);
});
it('preserves global triggers when a definition is edited', async () => {
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id });
await editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'all',
filters: [],
outputs: [],
});
expect(getAutomationTriggers()).toEqual([
expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onStart }),
]);
});
}); });
describe('deleteAutomation()', () => { describe('deleteAutomation()', () => {
@@ -225,4 +250,35 @@ describe('deleteAutomation()', () => {
const removed = getAutomations(); const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0); expect(Object.keys(removed).length).toEqual(0);
}); });
it('refuses an automation attached to a global trigger', async () => {
const automationId = Object.keys(getAutomations())[0];
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId });
await expect(deleteAutomation({}, automationId)).rejects.toThrow(/used in trigger/);
expect(getAutomationTriggers()).toHaveLength(1);
expect(getAutomations()[automationId]).toBeDefined();
});
it('refuses an automation attached to an event', async () => {
const automationId = Object.keys(getAutomations())[0];
const projectRundowns: ProjectRundowns = {
'rundown-1': {
id: 'rundown-1',
title: 'Rundown 1',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
}),
},
revision: 1,
},
};
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
expect(getAutomations()[automationId]).toBeDefined();
});
}); });
@@ -1,7 +1,17 @@
import { ProjectRundowns, TimerLifeCycle } from 'ontime-types'; import { ProjectRundowns, TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js'; import { isAutomationUsed, isHostname, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('isHostname()', () => {
it.each(['localhost', 'qlab', 'osc.example.com', 'osc-target.example'])('accepts %s', (hostname) => {
expect(isHostname(hostname)).toBe(true);
});
it.each(['not a host', '-osc.example', 'osc-.example', 'osc..example', 'osc.example.'])('rejects %s', (hostname) => {
expect(isHostname(hostname)).toBe(false);
});
});
describe('parseTemplateNested()', () => { describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => { it('parses string with a single-level variable name', () => {
@@ -1,4 +1,12 @@
import { parseOutput } from '../automation.validation.js'; import { parseAutomation, parseOutput } from '../automation.validation.js';
describe('parseAutomation', () => {
it('rejects trigger bindings from definition payloads', () => {
expect(() =>
parseAutomation({ title: 'Definition', filterRule: 'all', filters: [], outputs: [], triggers: [] }),
).toThrow('Automation definitions cannot include triggers');
});
});
describe('parseOutput', () => { describe('parseOutput', () => {
describe('handles OSC outputs', () => { describe('handles OSC outputs', () => {
@@ -0,0 +1,57 @@
import { runtimeStorePlaceholder } from 'ontime-types';
const { send } = vi.hoisted(() => ({ send: vi.fn() }));
vi.mock('node:dgram', () => ({
createSocket: vi.fn(() => ({ send })),
}));
import { emitOSC } from '../clients/osc.client.js';
describe('emitOSC()', () => {
beforeEach(() => {
send.mockClear();
});
it('resolves templates in the target host before sending', () => {
emitOSC(
{
type: 'osc',
targetIP: '{{eventNow.custom.oscTarget}}',
targetPort: 53000,
address: '/cue/start',
args: '',
},
{
...runtimeStorePlaceholder,
eventNow: {
id: 'current-event',
type: 'event',
cue: '1',
title: 'Opening',
note: '',
timeStart: 0,
timeEnd: 0,
duration: 0,
timerType: 'count-down',
colour: '',
delay: 0,
isPublic: true,
skip: false,
endAction: 'none',
revision: 0,
custom: { oscTarget: '192.0.2.10' },
},
},
);
expect(send).toHaveBeenCalledWith(
expect.anything(),
0,
expect.any(Number),
53000,
'192.0.2.10',
expect.any(Function),
);
});
});
@@ -1,4 +1,5 @@
import { import {
AutomationFilter,
EntryId, EntryId,
FilterRule, FilterRule,
MaybeNumber, MaybeNumber,
@@ -11,10 +12,16 @@ import {
import { getPropertyFromPath, millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils'; import { getPropertyFromPath, millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils';
import type { OscArgInput, OscArgOrArrayInput } from 'osc-min'; import type { OscArgInput, OscArgOrArrayInput } from 'osc-min';
type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains'; type FilterOperator = AutomationFilter['operator'];
const hostnameRegex = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
export function isHostname(value: string): boolean {
return hostnameRegex.test(value);
}
export function isFilterOperator(value: string): value is FilterOperator { export function isFilterOperator(value: string): value is FilterOperator {
return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains'].includes(value); return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains', 'not_contains'].includes(value);
} }
export function isFilterRule(value: string): value is FilterRule { export function isFilterRule(value: string): value is FilterRule {
@@ -14,7 +14,7 @@ import {
import * as assert from '../../utils/assert.js'; import * as assert from '../../utils/assert.js';
import { requestValidationFunction } from '../validation-utils/validationFunction.js'; import { requestValidationFunction } from '../validation-utils/validationFunction.js';
import { isFilterOperator, isFilterRule, isOntimeActionAction } from './automation.utils.js'; import { isFilterOperator, isFilterRule, isHostname, isOntimeActionAction } from './automation.utils.js';
export const validateAutomationSettings = [ export const validateAutomationSettings = [
body('enabledAutomations').isBoolean(), body('enabledAutomations').isBoolean(),
@@ -62,6 +62,10 @@ export function parseAutomation(maybeAutomation: unknown): AutomationDTO {
assert.isObject(maybeAutomation); assert.isObject(maybeAutomation);
assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']); assert.hasKeys(maybeAutomation, ['title', 'filterRule', 'filters', 'outputs']);
if ('triggers' in maybeAutomation) {
throw new Error('Automation definitions cannot include triggers');
}
const { title, filterRule, filters, outputs } = maybeAutomation; const { title, filterRule, filters, outputs } = maybeAutomation;
assert.isString(title); assert.isString(title);
assert.isString(filterRule); assert.isString(filterRule);
@@ -128,11 +132,8 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
assert.isString(maybeOSCOutput.args); assert.isString(maybeOSCOutput.args);
const targetIP = maybeOSCOutput.targetIP.trim(); const targetIP = maybeOSCOutput.targetIP.trim();
const target = replaceAutomationTemplates(targetIP, 'template.local'); const target = replaceTemplatesForValidation(targetIP, 'template.local');
const isHostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i.test( if (isIP(target) !== 4 && !isHostname(target)) {
target,
);
if (isIP(target) !== 4 && !isHostname) {
throw new Error('Invalid OSC target'); throw new Error('Invalid OSC target');
} }
if ( if (
@@ -171,7 +172,8 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
}; };
} }
function replaceAutomationTemplates(value: string, replacement: string): string { /** Replaces runtime values so their surrounding host or URL syntax can be validated before execution. */
function replaceTemplatesForValidation(value: string, replacement: string): string {
return value.replace(/{{.*?}}/g, replacement); return value.replace(/{{.*?}}/g, replacement);
} }
@@ -179,7 +181,7 @@ function replaceHTTPTemplatesForValidation(value: string): string {
if (/^{{.*?}}$/.test(value)) { if (/^{{.*?}}$/.test(value)) {
return 'https://template.local'; return 'https://template.local';
} }
return replaceAutomationTemplates(value, 'template'); return replaceTemplatesForValidation(value, 'template');
} }
function parseOntimeAction(maybeOntimeAction: object): OntimeAction { function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
@@ -14,7 +14,8 @@ const udpClient = dgram.createSocket('udp4');
*/ */
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) { export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
const message = preparePayload(output, store); const message = preparePayload(output, store);
emit(output.targetIP, output.targetPort, message); const targetIP = parseTemplateNested(output.targetIP, store);
emit(targetIP, output.targetPort, message);
} }
/** Parses the state and prepares payload to be emitted */ /** Parses the state and prepares payload to be emitted */