diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss new file mode 100644 index 000000000..3d0198f09 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.module.scss @@ -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; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx new file mode 100644 index 000000000..f0713fb68 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/NewAutomationDialog.tsx @@ -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(null); + + return selected === null ? ( + + ) : ( + 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) => { + // 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 ( + +
+ + 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 && ( + setQuery('')} + > + + + )} +
+ + {results.length === 0 && ( + + )} + + + {recipeCategoryOrder.map((category) => { + const inCategory = results.filter((recipe) => recipe.category === category); + if (inCategory.length === 0) { + return null; + } + + return ( +
+

{recipeCategoryLabels[category]}

+ {inCategory.map((recipe) => ( + + ))} +
+ ); + })} +
+ + } + footerElements={ + <> + + + + } + /> + ); +} + +interface RecipeSetupProps { + recipe: AutomationRecipe; + onClose: () => void; + onBack: () => void; + onCreated: (automation: Automation) => void; +} + +function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) { + const [values, setValues] = useState(() => defaultValues(recipe)); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(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 ( + +

{recipe.description}

+ +
+
Runs on
+
+ {recipe.triggers.map((cycle) => ( + {getLifecycleLabel(cycle)} + ))} +
+
Sends
+
+ {summariseOutputs(automation.outputs).map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + ))} +
+
+ + {recipe.params.length > 0 && ( +
+ {recipe.params.map((param) => ( + + ))} +
+ )} + + + {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.'} + + + } + footerElements={ + <> + {error && {error}} + + + + + } + /> + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts new file mode 100644 index 000000000..f7c2c780e --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts @@ -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) { + 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'); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts index eaa95f29b..908d1c31b 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationUtils.test.ts @@ -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'); + }); +}); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts new file mode 100644 index 000000000..be499beee --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts @@ -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 = { + 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; + +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////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 { + const errors: Record = {}; + + 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'); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts index b83935970..f47ffb98f 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationUtils.ts @@ -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>; 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 = {}; - const duplicates = []; + const seen = new Set(); + 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 { + const grouped: Record = {}; + + 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; +}