feat(automation): add guided automation recipes

This commit is contained in:
Carlos Valente
2026-09-11 19:57:25 +02:00
parent ab61d3434b
commit 0a12a64240
6 changed files with 1118 additions and 23 deletions
@@ -0,0 +1,196 @@
/* ---------- step one: pick a recipe ---------- */
.picker {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: $ui-white;
}
/** outside the scrolling list, so it stays put however many recipes there are */
.search {
position: relative;
display: flex;
align-items: center;
padding-block: 0.5rem;
}
/**
* Caps the list rather than fixing its height, so the dialog still shrinks to two results
* when a search narrows it down.
*/
.listViewport {
height: auto;
max-height: min(52vh, 30rem);
}
.list {
display: flex;
flex-direction: column;
// room for the overlay scrollbar beside the chevrons
padding-right: 0.5rem;
}
.searchIcon {
position: absolute;
left: 0.625rem;
color: $gray-400;
pointer-events: none;
}
.searchInput {
padding-left: 2rem;
padding-right: 2rem;
}
.searchClear {
position: absolute;
right: 0.25rem;
}
.group {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-top: 0.75rem;
&:first-child {
padding-top: 0;
}
}
.groupTitle {
margin: 0;
padding-inline: 0.125rem;
font-size: $aux-text-size;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
color: $gray-400;
}
.recipe {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
text-align: left;
color: inherit;
background-color: transparent;
border: 1px solid transparent;
border-radius: $component-border-radius-md;
cursor: pointer;
&:hover {
background-color: $white-3;
border-color: $white-10;
}
&:focus-visible {
outline: 1px solid $action-blue;
outline-offset: -1px;
}
}
.recipeText {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.recipeTitle {
font-weight: 600;
}
.recipeDescription {
font-size: $aux-text-size;
color: $secondary-text-gray;
}
.recipeTags {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.chevron {
color: $gray-400;
}
/* ---------- step two: answer what the recipe cannot know ---------- */
.setup {
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
padding-block: 0.25rem;
}
.setupDescription {
margin: 0;
color: $secondary-text-gray;
}
/** the two facts a recipe decides for you, stated before the fields you can change */
.summary {
display: grid;
grid-template-columns: 5rem 1fr;
align-items: center;
gap: 0.5rem 0.75rem;
margin: 0;
padding: 0.75rem;
background-color: $black-10;
border: 1px solid $white-10;
border-radius: $component-border-radius-md;
dt {
font-size: $aux-text-size;
color: $label-gray;
}
dd {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
}
}
/* three columns, so a recipe's small numeric fields fill a row instead of leaving a hole */
.fields {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: start;
gap: 0.75rem;
@media (width < 40rem) {
grid-template-columns: repeat(2, 1fr);
}
}
/* an address or a sentence, which a third of a row cannot hold */
.wide {
grid-column: 1 / -1;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: $aux-text-size;
color: $label-gray;
}
.hint {
color: $secondary-text-gray;
}
/* pushes the leading action away from the confirming ones */
.apart {
margin-right: auto;
}
@@ -0,0 +1,299 @@
import type { Automation } from 'ontime-types';
import { useState, type KeyboardEvent } from 'react';
import { IoAdd, IoArrowBack, IoChevronForward, IoClose, IoSearch } from 'react-icons/io5';
import { addAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import IconButton from '../../../../common/components/buttons/IconButton';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
import Select from '../../../../common/components/select/Select';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import { cx } from '../../../../common/utils/styleUtils';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import {
defaultValues,
getAvailableRecipes,
needsTarget,
recipeCategoryLabels,
recipeCategoryOrder,
type AutomationRecipe,
type RecipeValues,
validateRecipeValues,
} from './automationRecipes';
import { makeTriggerTitle } from './automationUtils';
import style from './NewAutomationDialog.module.scss';
const availableRecipes = getAvailableRecipes(Boolean(isOntimeCloud));
interface NewAutomationDialogProps {
onClose: () => void;
/** hands over to the full automation form for someone who wants to start empty */
onStartEmpty: () => void;
onCreated: (automation: Automation) => void;
}
/**
* The single entry point for making an automation.
*
* Two steps in one dialog rather than two stacked ones: pick a recipe, then answer only
* what that recipe cannot know — where your gear is, how long the timer runs. Everything
* else the recipe already decided, which is the point of having recipes at all.
*/
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
return selected === null ? (
<RecipePicker onClose={onClose} onStartEmpty={onStartEmpty} onSelect={setSelected} />
) : (
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
);
}
/** matches on everything the user might type: the software, its protocol, the job it does */
function matches(recipe: AutomationRecipe, query: string): boolean {
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
.join(' ')
.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.every((term) => haystack.includes(term));
}
interface RecipePickerProps {
onClose: () => void;
onStartEmpty: () => void;
onSelect: (recipe: AutomationRecipe) => void;
}
function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
const [query, setQuery] = useState('');
const trimmed = query.trim();
const results = trimmed ? availableRecipes.filter((recipe) => matches(recipe, trimmed)) : availableRecipes;
const handleSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
// the dialog is the only thing listening for escape, and losing it while clearing a
// search would be a bigger surprise than the search staying put
if (event.key === 'Escape' && trimmed.length > 0) {
event.stopPropagation();
setQuery('');
return;
}
if (event.key === 'Enter' && results.length > 0) {
onSelect(results[0]);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='New automation'
bodyElements={
<div className={style.picker}>
<div className={style.search}>
<IoSearch className={style.searchIcon} />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={handleSearchKey}
placeholder='Search recipes, eg. QLab, OSC, message'
className={style.searchInput}
aria-label='Search recipes'
fluid
autoFocus
/>
{trimmed.length > 0 && (
<IconButton
variant='ghosted-white'
size='small'
aria-label='Clear search'
className={style.searchClear}
onClick={() => setQuery('')}
>
<IoClose />
</IconButton>
)}
</div>
{results.length === 0 && (
<Panel.EmptyState
title='No recipe matches that'
description='Try the name of the software, or start from an empty automation.'
/>
)}
<ScrollArea viewportClassName={style.listViewport} contentClassName={style.list}>
{recipeCategoryOrder.map((category) => {
const inCategory = results.filter((recipe) => recipe.category === category);
if (inCategory.length === 0) {
return null;
}
return (
<section key={category} className={style.group}>
<h4 className={style.groupTitle}>{recipeCategoryLabels[category]}</h4>
{inCategory.map((recipe) => (
<button type='button' key={recipe.id} className={style.recipe} onClick={() => onSelect(recipe)}>
<div className={style.recipeText}>
<div className={style.recipeTitle}>{recipe.title}</div>
<div className={style.recipeDescription}>{recipe.description}</div>
</div>
<div className={style.recipeTags}>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
<IoChevronForward className={style.chevron} />
</div>
</button>
))}
</section>
);
})}
</ScrollArea>
</div>
}
footerElements={
<>
<Button variant='ghosted-white' className={style.apart} onClick={onStartEmpty}>
Start from an empty automation
</Button>
<Button onClick={onClose}>Cancel</Button>
</>
}
/>
);
}
interface RecipeSetupProps {
recipe: AutomationRecipe;
onClose: () => void;
onBack: () => void;
onCreated: (automation: Automation) => void;
}
function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
const [values, setValues] = useState<RecipeValues>(() => defaultValues(recipe));
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const automation = recipe.build(values);
const validationErrors = validateRecipeValues(recipe, values);
const isComplete = Object.keys(validationErrors).length === 0;
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
const handleCreate = async () => {
setError(null);
setIsCreating(true);
try {
const created = await addAutomation(
automation,
recipe.triggers.map((cycle) => ({
title: makeTriggerTitle(automation.title, cycle),
trigger: cycle,
})),
);
onCreated(created);
} catch (error) {
setError(maybeAxiosError(error));
} finally {
setIsCreating(false);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title={recipe.title}
bodyElements={
<div className={style.setup}>
<p className={style.setupDescription}>{recipe.description}</p>
<dl className={style.summary}>
<dt>Runs on</dt>
<dd>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
</dd>
<dt>Sends</dt>
<dd>
{summariseOutputs(automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))}
</dd>
</dl>
{recipe.params.length > 0 && (
<div className={style.fields}>
{recipe.params.map((param) => (
<label key={param.name} className={cx([style.field, param.wide && style.wide])}>
{param.label}
{param.type === 'choice' ? (
<Select
value={values[param.name]}
onValueChange={(value: string | null) => {
if (value === null) return;
setValue(param.name, value);
}}
options={param.options ?? []}
aria-label={param.label}
fluid
/>
) : (
<Input
type={param.type === 'number' ? 'number' : 'text'}
value={values[param.name]}
onChange={(event) => setValue(param.name, event.target.value)}
aria-invalid={Boolean(validationErrors[param.name])}
fluid
/>
)}
{param.hint && <span className={style.hint}>{param.hint}</span>}
<Panel.Error>{validationErrors[param.name]}</Panel.Error>
</label>
))}
</div>
)}
<Panel.Description>
{needsTarget(recipe)
? 'Created as a normal automation. Nothing is sent until an event triggers it.'
: 'Created as a normal automation, which you can edit or delete like any other.'}
</Panel.Description>
</div>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button variant='ghosted-white' className={style.apart} onClick={onBack} disabled={isCreating}>
<IoArrowBack /> All recipes
</Button>
<Button onClick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button variant='primary' onClick={handleCreate} loading={isCreating} disabled={!isComplete}>
Create automation <IoAdd />
</Button>
</>
}
/>
);
}
@@ -0,0 +1,168 @@
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
import {
automationRecipes,
defaultValues,
getAvailableRecipes,
needsTarget,
recipeCategoryOrder,
validateRecipeValues,
} from '../automationRecipes';
import { operators } from '../automationUtils';
/**
* Recipes are shipped as constants but created 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 create.
*/
describe('automationRecipes', () => {
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
it('has unique ids', () => {
const ids = automationRecipes.map(({ id }) => id);
expect(new Set(ids).size).toBe(ids.length);
});
it('only uses categories the picker knows how to render', () => {
for (const { recipe } of built) {
expect(recipeCategoryOrder).toContain(recipe.category);
}
});
it('binds every recipe to at least one valid lifecycle', () => {
for (const { recipe } of built) {
expect(recipe.triggers.length).toBeGreaterThan(0);
for (const cycle of recipe.triggers) {
expect(timerLifecycleValues).toContain(cycle);
}
}
});
it('builds a titled automation with something to send, from its own defaults', () => {
for (const { automation } of built) {
expect(automation.title).not.toBe('');
expect(automation.outputs.length).toBeGreaterThan(0);
for (const output of automation.outputs) {
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
}
}
});
it('gives every choice parameter options, and a default that is one of them', () => {
const choices = automationRecipes.flatMap(({ params }) => params.filter(({ type }) => type === 'choice'));
expect(choices.filter(({ options }) => !options?.length)).toEqual([]);
expect(choices.filter(({ options, defaultValue }) => !options?.some((o) => o.value === defaultValue))).toEqual([]);
});
it('reads every parameter it declares', () => {
// a param the builder ignores is a field the user fills in for nothing, and a typo in
// either half would put the literal 'undefined' inside a URL
for (const { recipe } of built) {
for (const param of recipe.params) {
// a choice can only take one of its own options, so probe with the last one
if (param.type === 'choice') {
const last = param.options?.at(-1)?.value ?? '';
expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last);
continue;
}
const marker = param.type === 'number' ? '4242' : 'ontime-probe';
const probed = { ...defaultValues(recipe), [param.name]: marker };
expect(JSON.stringify(recipe.build(probed))).toContain(marker);
}
}
});
it('only uses filter operators the server accepts', () => {
const allowed = operators.map(({ value }) => value);
for (const { automation } of built) {
for (const filter of automation.filters) {
expect(allowed).toContain(filter.operator);
}
}
});
it('defaults every external target to this machine', () => {
const outputs = built.flatMap(({ automation }) => automation.outputs);
const osc = outputs.filter(isOSCOutput);
const http = outputs.filter(isHTTPOutput);
// filtering rather than asserting in a branch, so a failure names the offending recipe
expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]);
expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]);
expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]);
});
it('flags the recipes that reach outside Ontime', () => {
for (const { recipe, automation } of built) {
const reachesOut = automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
expect(needsTarget(recipe)).toBe(reachesOut);
}
});
it('hides local-network recipes in Ontime Cloud', () => {
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
'ontime-aux-timer',
'ontime-aux-stop',
'ontime-warn-stage',
'ontime-clear-message',
'ontime-secondary-message',
'webhook-event-title',
]);
});
it('validates recipe addresses and numeric bounds', () => {
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
const companion = automationRecipes.find(({ id }) => id === 'companion-press');
const vmix = automationRecipes.find(({ id }) => id === 'vmix-overlay-warning');
const webhook = automationRecipes.find(({ id }) => id === 'webhook-event-title');
expect(qlab && validateRecipeValues(qlab, { ip: 'not a host', port: '70000' })).toEqual({
ip: 'Enter an IP address or hostname',
port: 'Enter a whole number from 1 to 65535',
});
expect(
companion && validateRecipeValues(companion, { host: 'localhost:8888', page: '1.5', row: '-1', column: '0' }),
).toEqual({
host: 'Enter a URL starting with http:// or https://',
page: 'Enter a whole number of 1 or more',
row: 'Enter a whole number of 0 or more',
});
expect(vmix && validateRecipeValues(vmix, { host: 'http://127.0.0.1:8088', overlay: '5' })).toEqual({
overlay: 'Enter a whole number from 1 to 4',
});
expect(webhook && validateRecipeValues(webhook, { url: 'ftp://example.com' })).toEqual({
url: 'Enter a URL starting with http:// or https://',
});
});
it('accepts every recipe default', () => {
for (const recipe of automationRecipes) {
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
}
});
/** the outputs a recipe builds from the given answers, as plain JSON to assert against */
function buildWith(id: string, values: Record<string, string>) {
const recipe = automationRecipes.find((candidate) => candidate.id === id);
return JSON.stringify(recipe?.build(values).outputs);
}
it('tolerates a URL that already carries a query', () => {
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
'/now?source=ontime&title=',
);
});
it('marks the event title for URL-safe substitution', () => {
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now' })).toContain(
'title={{url:eventNow.title}}',
);
});
it('tolerates an address pasted with a trailing slash', () => {
expect(
buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
});
});
@@ -1,6 +1,6 @@
import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils';
import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => {
@@ -22,3 +22,43 @@ describe('checkDuplicates', () => {
expect(checkDuplicates(triggers)).toStrictEqual([2]);
});
});
describe('groupTriggersByAutomation', () => {
it('returns an empty object when there are no triggers', () => {
expect(groupTriggersByAutomation([])).toEqual({});
});
it('collects the lifecycles each automation is bound to', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' },
];
expect(groupTriggersByAutomation(triggers)).toEqual({
a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
b: [TimerLifeCycle.onLoad],
});
});
it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' },
];
expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] });
});
});
describe('operators', () => {
it('does not offer not_contains, which the server validation rejects', () => {
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
});
});
describe('cycles', () => {
it('uses the shared user facing labels', () => {
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
});
});
@@ -0,0 +1,345 @@
import type { AutomationDTO, AutomationOutput, TimerLifeCycle } from 'ontime-types';
import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
ontime: 'Ontime automations',
playback: 'Playback and cue systems',
video: 'Video and streaming',
messaging: 'Webhooks and messaging',
};
/** presentation order, empty categories are not rendered */
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
export type RecipeParam = {
name: string;
label: string;
/** one line under the field, for anything the label cannot say */
hint?: string;
type?: 'text' | 'number' | 'choice';
/** required by 'choice', which renders a select rather than a free field */
options?: { value: string; label: string }[];
/** takes a whole row: addresses and free text read badly in a narrow column */
wide?: boolean;
/** every default points at this machine, so a recipe cannot reach a venue network unasked */
defaultValue: string;
validation?: { kind: 'host' } | { kind: 'url' } | { kind: 'integer'; min: number; max?: number };
};
export type RecipeValues = Record<string, string>;
export type AutomationRecipe = {
/** stable, client only. Never persisted */
id: string;
title: string;
/** one line, plain language: what this does for the user */
description: string;
category: RecipeCategory;
/** requires access to software on the same network as the Ontime server */
localOnly?: boolean;
/** extra search terms: other names for the software, its protocol, the job it does */
keywords?: string[];
/** what the dialog asks for. Empty when the recipe needs nothing */
params: RecipeParam[];
triggers: TimerLifeCycle[];
/** typed, so the compiler catches a recipe drifting from the automation schema */
build: (values: RecipeValues) => AutomationDTO;
};
const auxTimers = [
{ value: '1', label: 'Aux timer 1' },
{ value: '2', label: 'Aux timer 2' },
{ value: '3', label: 'Aux timer 3' },
];
type AuxNumber = '1' | '2' | '3';
/**
* Action keys are a union the compiler checks against the automation schema, so the aux
* number is resolved through maps rather than string interpolation. Anything unexpected
* falls back to the first timer instead of building an action the server would reject.
*/
function toAux(value: string): AuxNumber {
return value === '2' || value === '3' ? value : '1';
}
const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
/** a user pasting an address is as likely to include the trailing slash as not */
function origin(value: string): string {
return value.trim().replace(/\/+$/, '');
}
/** the recipe cannot know whether the user's URL already carries a query */
function withQuery(url: string, query: string): string {
const trimmed = url.trim();
return trimmed.includes('?') ? `${trimmed}&${query}` : `${trimmed}?${query}`;
}
function buildUnfilteredAutomation(title: string, outputs: AutomationOutput[]): AutomationDTO {
return { title, filterRule: 'all', filters: [], outputs };
}
export const automationRecipes: AutomationRecipe[] = [
{
id: 'ontime-aux-timer',
title: 'Run an aux timer with the event',
description: 'Sets an aux timer and starts it whenever an event starts.',
category: 'ontime',
keywords: ['countdown', 'stage timer', 'speaker'],
params: [
{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' },
{ name: 'duration', label: 'Duration', hint: 'hh:mm:ss', defaultValue: '00:05:00' },
],
triggers: [Cycle.onStart],
build: ({ aux, duration }) =>
buildUnfilteredAutomation(`Run Aux Timer ${toAux(aux)} with the event`, [
{ type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
{ type: 'ontime', action: auxStart[toAux(aux)] },
]),
},
{
id: 'ontime-aux-stop',
title: 'Stop the aux timer when the event ends',
description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
category: 'ontime',
keywords: ['countdown', 'stage timer', 'reset'],
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
triggers: [Cycle.onFinish],
build: ({ aux }) =>
buildUnfilteredAutomation(`Stop Aux Timer ${toAux(aux)} on finish`, [
{ type: 'ontime', action: auxStop[toAux(aux)] },
]),
},
{
id: 'ontime-warn-stage',
title: 'Warn the stage when time runs low',
description: 'Shows a message on the stage timer as the running event enters its danger window.',
category: 'ontime',
keywords: ['message', 'danger', 'wrap up', 'presenter'],
params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }],
triggers: [Cycle.onDanger],
build: ({ message }) =>
buildUnfilteredAutomation('Warn the stage at danger', [
{ type: 'ontime', action: 'message-set', text: message, visible: true },
]),
},
{
id: 'ontime-clear-message',
title: 'Hide the stage message on finish',
description: 'Clears the stage message once the event finishes. Pairs with the warning above.',
category: 'ontime',
keywords: ['message', 'clear', 'presenter'],
params: [],
triggers: [Cycle.onFinish],
build: () =>
buildUnfilteredAutomation('Hide the stage message on finish', [
{ type: 'ontime', action: 'message-set', text: '', visible: false },
]),
},
{
id: 'ontime-secondary-message',
title: 'Show an aux timer beside the stage message',
description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
category: 'ontime',
keywords: ['message', 'secondary', 'stage', 'countdown'],
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
triggers: [Cycle.onLoad],
build: ({ aux }) =>
buildUnfilteredAutomation(`Show Aux Timer ${toAux(aux)} as the secondary message`, [
{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] },
]),
},
{
id: 'qlab-go',
title: 'QLab — fire the matching cue',
description: "Starts the QLab cue whose number matches the Ontime event's cue.",
category: 'playback',
localOnly: true,
keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'],
params: [
{
name: 'ip',
label: 'QLab computer',
hint: 'IP address of the machine running QLab',
wide: true,
defaultValue: '127.0.0.1',
validation: { kind: 'host' },
},
{
name: 'port',
label: 'OSC port',
type: 'number',
hint: "QLab's default is 53000",
defaultValue: '53000',
validation: { kind: 'integer', min: 1, max: 65535 },
},
],
triggers: [Cycle.onStart],
build: ({ ip, port }) =>
buildUnfilteredAutomation('QLab GO on event start', [
{
type: 'osc',
targetIP: ip.trim(),
targetPort: Number(port),
address: '/cue/{{eventNow.cue}}/start',
args: '',
},
]),
},
{
id: 'companion-press',
title: 'Companion — press a button',
description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.',
category: 'playback',
localOnly: true,
keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'],
params: [
{
name: 'host',
label: 'Companion address',
hint: 'Where the Companion HTTP API is listening',
wide: true,
defaultValue: 'http://127.0.0.1:8888',
validation: { kind: 'url' },
},
{ name: 'page', label: 'Page', type: 'number', defaultValue: '1', validation: { kind: 'integer', min: 1 } },
{ name: 'row', label: 'Row', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
{ name: 'column', label: 'Column', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
],
triggers: [Cycle.onStart],
build: ({ host, page, row, column }) =>
buildUnfilteredAutomation('Companion button press', [
// Companion HTTP API: /api/location/<page>/<row>/<column>/press
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
]),
},
{
id: 'vmix-overlay-warning',
title: 'vMix — show an overlay on warning',
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
category: 'video',
localOnly: true,
keywords: ['streaming', 'http', 'lower third', 'graphics'],
params: [
{
name: 'host',
label: 'vMix address',
hint: 'The vMix web controller',
wide: true,
defaultValue: 'http://127.0.0.1:8088',
validation: { kind: 'url' },
},
{
name: 'overlay',
label: 'Overlay number',
type: 'number',
defaultValue: '1',
validation: { kind: 'integer', min: 1, max: 4 },
},
],
triggers: [Cycle.onWarning],
build: ({ host, overlay }) =>
buildUnfilteredAutomation('vMix overlay on warning', [
{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` },
]),
},
{
id: 'webhook-event-title',
title: 'Webhook — post the running event',
description: 'Calls any URL with the running event title, as a template string you can edit afterwards.',
category: 'messaging',
keywords: ['http', 'rest', 'api', 'integration', 'slack'],
params: [
{
name: 'url',
label: 'URL',
hint: 'The event title is added as a title parameter',
wide: true,
defaultValue: 'http://127.0.0.1:3000/now',
validation: { kind: 'url' },
},
],
triggers: [Cycle.onStart],
build: ({ url }) =>
buildUnfilteredAutomation('Webhook with the current event', [
{ type: 'http', url: withQuery(url, 'title={{url:eventNow.title}}') },
]),
},
];
/** the values the dialog starts with, so a recipe can be created without touching a field */
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
}
export function getAvailableRecipes(isCloud: boolean): AutomationRecipe[] {
return isCloud ? automationRecipes.filter((recipe) => !recipe.localOnly) : automationRecipes;
}
export function validateRecipeValues(recipe: AutomationRecipe, values: RecipeValues): Record<string, string> {
const errors: Record<string, string> = {};
for (const param of recipe.params) {
const value = values[param.name]?.trim() ?? '';
if (!value) {
errors[param.name] = 'Required field';
continue;
}
if (param.validation?.kind === 'url' && !isHttpUrl(value)) {
errors[param.name] = 'Enter a URL starting with http:// or https://';
} else if (param.validation?.kind === 'host' && !isHost(value)) {
errors[param.name] = 'Enter an IP address or hostname';
} else if (param.validation?.kind === 'integer') {
const number = Number(value);
const { min, max } = param.validation;
if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
errors[param.name] =
max === undefined ? `Enter a whole number of ${min} or more` : `Enter a whole number from ${min} to ${max}`;
}
}
}
return errors;
}
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname);
} catch {
return false;
}
}
function isHost(value: string): boolean {
if (value === 'localhost') {
return true;
}
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
const fqdn = /^(?=.{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;
if (ipv4.test(value) || fqdn.test(value)) {
return true;
}
try {
return value.includes(':') && Boolean(new URL(`http://[${value}]`).hostname);
} catch {
return false;
}
}
/**
* A recipe that only sends Ontime actions works the moment it is created.
* Anything else points at software we cannot locate for the user.
*/
export function needsTarget(recipe: AutomationRecipe): boolean {
return !recipe.build(defaultValues(recipe)).outputs.every((output) => output.type === 'ontime');
}
@@ -1,21 +1,50 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
/**
* Names a trigger created from an automation's lifecycle picker.
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
*/
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
return `${automationTitle}${getLifecycleLabel(cycle)}`;
}
/**
* Outputs are a union, so react-hook-form cannot resolve a field's error by name.
* Every output card knows which fields it registered, this just makes them reachable.
*/
export type OutputErrors = Partial<Record<string, { message?: string }>>;
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
value: TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ id: 1, label: 'On Load', value: 'onLoad' },
{ id: 2, label: 'On Start', value: 'onStart' },
{ id: 3, label: 'On Pause', value: 'onPause' },
{ id: 4, label: 'On Stop', value: 'onStop' },
{ id: 5, label: 'Every second', value: 'onClock' },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
{ id: 7, label: 'On Finish', value: 'onFinish' },
{ id: 8, label: 'On Warning', value: 'onWarning' },
{ id: 9, label: 'On Danger', value: 'onDanger' },
{ label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
{ label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
{ label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
{ label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
{ label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
{ label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
{ label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
{ label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
{ label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
];
/**
* Filter operators offered in the automation form
* NOTE: not_contains is supported by the type and by the runtime, but the server
* validation list omits it, so an automation using it cannot be saved.
* It stays out of the UI until the server accepts it.
*/
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: 'greater_than', label: 'is greater than' },
{ value: 'less_than', label: 'is less than' },
];
/**
@@ -66,20 +95,38 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
* We warn the user if they have created multiple links between the same automation and a trigger
*/
export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {};
const duplicates = [];
const seen = new Set<string>();
const duplicates: number[] = [];
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
for (const [index, trigger] of triggers.entries()) {
const key = `${trigger.trigger}:${trigger.automationId}`;
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
duplicates.push(i);
if (seen.has(key)) {
duplicates.push(index);
} else {
triggersMap[trigger.trigger].push(trigger.automationId);
seen.add(key);
}
}
return duplicates.length > 0 ? duplicates : undefined;
}
/**
* Groups the lifecycles each automation is bound to
* Used to show when an automation runs, and to highlight the ones that never will
*/
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
const grouped: Record<string, TimerLifeCycle[]> = {};
for (const trigger of triggers) {
if (!Object.hasOwn(grouped, trigger.automationId)) {
grouped[trigger.automationId] = [];
}
// the runtime fires an automation once per lifecycle, duplicates would be noise here
if (!grouped[trigger.automationId].includes(trigger.trigger)) {
grouped[trigger.automationId].push(trigger.trigger);
}
}
return grouped;
}