diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx index cabba40b5..7f9021ed6 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -66,13 +66,11 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa * Triggers are a separate entity, so they live outside the form state. * We resolve the current selection once and reconcile it against the server on save. */ - const initialCycles = useMemo(() => { - if (!isAutomation(automation)) { - return []; - } - return triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we intentionally snapshot the selection when the form opens - }, []); + const [initialCycles] = useState(() => + isAutomation(automation) + ? triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger) + : [], + ); const [selectedCycles, setSelectedCycles] = useState(initialCycles); /** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */ const [createdId, setCreatedId] = useState(null); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index be64b628a..2d90b2d87 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -1,6 +1,6 @@ -import { AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; +import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; import { Fragment, useMemo, useState } from 'react'; -import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; +import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5'; import { deleteAutomation } from '../../../../common/api/automation'; import { maybeAxiosError } from '../../../../common/api/utils'; @@ -14,6 +14,7 @@ import { summariseOutputs } from '../../../../common/utils/automationOutputs'; import * as Panel from '../../panel-utils/PanelUtils'; import AutomationForm from './AutomationForm'; import { groupTriggersByAutomation } from './automationUtils'; +import RecipeLibraryModal from './recipes/RecipeLibraryModal'; import style from './AutomationsList.module.scss'; @@ -34,8 +35,20 @@ interface AutomationsListProps { export default function AutomationsList({ automations, triggers, enabledAutomations, isLoading }: AutomationsListProps) { const { refetch } = useAutomationSettings(); const [automationFormData, setAutomationFormData] = useState(null); + const [showRecipes, setShowRecipes] = useState(false); const [deleteError, setDeleteError] = useState(null); + /** + * A recipe lands in the editor rather than only in the list. + * Seeing it as an editable automation is the point, and recipes with an + * external target are unusable until the user changes it anyway. + */ + const handleRecipeInstalled = async (created: Automation) => { + setShowRecipes(false); + await refetch(); + setAutomationFormData(created); + }; + const handleDelete = async (id: string) => { try { setDeleteError(null); @@ -61,11 +74,22 @@ export default function AutomationsList({ automations, triggers, enabledAutomati onClose={() => setAutomationFormData(null)} /> )} + {showRecipes && ( + setShowRecipes(false)} + onInstalled={(_recipe, created) => handleRecipeInstalled(created)} + /> + )} Manage automations - + + + + @@ -95,8 +119,11 @@ export default function AutomationsList({ automations, triggers, enabledAutomati description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.' action={ - + } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.module.scss new file mode 100644 index 000000000..9dfe54709 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.module.scss @@ -0,0 +1,47 @@ +.library { + display: flex; + flex-direction: column; + gap: 1.5rem; + color: $ui-white; + font-size: calc(1rem - 1px); + padding-block: 0.5rem; +} + +.category { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.recipeGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); + gap: 0.75rem; +} + +.recipe { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border: 1px solid $white-10; + border-radius: $component-border-radius-md; + background-color: $black-10; +} + +.recipeTitle { + font-weight: 600; +} + +.recipeDescription { + flex: 1; + font-size: $aux-text-size; + color: $secondary-text-gray; +} + +.recipeActions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.75rem; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.tsx b/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.tsx new file mode 100644 index 000000000..54e7ab4cc --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.tsx @@ -0,0 +1,116 @@ +import type { Automation } from 'ontime-types'; +import { useState } from 'react'; + +import { maybeAxiosError } from '../../../../../common/api/utils'; +import Button from '../../../../../common/components/buttons/Button'; +import Info from '../../../../../common/components/info/Info'; +import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink'; +import Modal from '../../../../../common/components/modal/Modal'; +import Tag from '../../../../../common/components/tag/Tag'; +import { getLifecycleLabel } from '../../../../../common/constants/timerLifecycle'; +import { summariseOutputs } from '../../../../../common/utils/automationOutputs'; +import { isOntimeCloud } from '../../../../../externals'; +import * as Panel from '../../../panel-utils/PanelUtils'; +import { automationRecipes, recipeCategoryLabels, recipeCategoryOrder, type AutomationRecipe } from './automationRecipes'; +import { installRecipe } from './recipeUtils'; + +import style from './RecipeLibraryModal.module.scss'; + +interface RecipeLibraryModalProps { + onClose: () => void; + /** called with the installed automation so the caller can open it for editing */ + onInstalled: (automation: AutomationRecipe, created: Automation) => void; +} + +export default function RecipeLibraryModal({ onClose, onInstalled }: RecipeLibraryModalProps) { + const [installing, setInstalling] = useState(null); + const [error, setError] = useState(null); + + // OSC is not available in the cloud service, offering those recipes there would be a lie + const available = isOntimeCloud + ? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc')) + : automationRecipes; + + const handleInstall = async (recipe: AutomationRecipe) => { + setError(null); + setInstalling(recipe.id); + try { + const created = await installRecipe(recipe); + onInstalled(recipe, created); + } catch (error) { + setError(maybeAxiosError(error)); + } finally { + setInstalling(null); + } + }; + + return ( + + + + Recipes are a starting point, not a black box. Each one is added as a normal automation that you can edit, + test or delete. Recipes that reach external software are set to this machine, so point them at the right + device before you rely on them. + + + + {recipeCategoryOrder.map((category) => { + const recipes = available.filter((recipe) => recipe.category === category); + if (recipes.length === 0) { + return null; + } + + return ( +
+ {recipeCategoryLabels[category]} +
+ {recipes.map((recipe) => ( +
+
{recipe.title}
+
{recipe.description}
+ + {recipe.triggers.map((cycle) => ( + {getLifecycleLabel(cycle)} + ))} + {summariseOutputs(recipe.automation.outputs).map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + ))} + {recipe.needsSetup && Needs a target} + +
+ {recipe.docsUrl && Docs} + +
+
+ ))} +
+
+ ); + })} + + } + footerElements={ + <> + {error && {error}} + + + } + /> + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/recipes/__tests__/automationRecipes.test.ts b/apps/client/src/features/app-settings/panel/automations-panel/recipes/__tests__/automationRecipes.test.ts new file mode 100644 index 000000000..eb0c86c06 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/recipes/__tests__/automationRecipes.test.ts @@ -0,0 +1,75 @@ +import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types'; + +import { operators } from '../../automationUtils'; +import { automationRecipes, recipeCategoryOrder } from '../automationRecipes'; + +/** + * Recipes are shipped as constants but installed through the same endpoints as a + * hand written automation. These assertions stand in for the server side validation, + * so a recipe cannot silently rot into something that 400s on install. + */ +describe('automationRecipes', () => { + it('ships recipes', () => { + expect(automationRecipes.length).toBeGreaterThan(0); + }); + + it('has unique ids', () => { + const ids = automationRecipes.map(({ id }) => id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('only uses categories the library knows how to render', () => { + for (const recipe of automationRecipes) { + expect(recipeCategoryOrder).toContain(recipe.category); + } + }); + + it('binds every recipe to at least one valid lifecycle', () => { + for (const recipe of automationRecipes) { + expect(recipe.triggers.length).toBeGreaterThan(0); + for (const cycle of recipe.triggers) { + expect(timerLifecycleValues).toContain(cycle); + } + } + }); + + it('gives every recipe something to send', () => { + for (const recipe of automationRecipes) { + expect(recipe.automation.outputs.length).toBeGreaterThan(0); + expect(recipe.automation.title).not.toBe(''); + + for (const output of recipe.automation.outputs) { + expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true); + } + } + }); + + it('only uses filter operators the server accepts', () => { + const allowed = operators.map(({ value }) => value); + for (const recipe of automationRecipes) { + for (const filter of recipe.automation.filters) { + expect(allowed).toContain(filter.operator); + } + } + }); + + it('defaults every external target to this machine', () => { + for (const recipe of automationRecipes) { + for (const output of recipe.automation.outputs) { + if (isOSCOutput(output)) { + expect(output.targetIP).toBe('127.0.0.1'); + } + if (isHTTPOutput(output)) { + expect(output.url.startsWith('http://127.0.0.1')).toBe(true); + } + } + } + }); + + it('marks recipes that reach outside Ontime as needing a target', () => { + for (const recipe of automationRecipes) { + const reachesOut = recipe.automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output)); + expect(recipe.needsSetup).toBe(reachesOut); + } + }); +}); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/recipes/automationRecipes.ts b/apps/client/src/features/app-settings/panel/automations-panel/recipes/automationRecipes.ts new file mode 100644 index 000000000..85af8fa78 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/recipes/automationRecipes.ts @@ -0,0 +1,157 @@ +import type { AutomationDTO, TimerLifeCycle } from 'ontime-types'; +import { TimerLifeCycle as Cycle } from 'ontime-types'; + +export type RecipeCategory = 'video' | 'audio' | 'playback' | 'messaging' | 'ontime'; + +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; + docsUrl?: string; + /** true when the recipe points at external software the user has to locate */ + needsSetup: boolean; + /** typed so the compiler catches drift against the automation schema */ + automation: AutomationDTO; + triggers: TimerLifeCycle[]; +}; + +export const recipeCategoryLabels: Record = { + ontime: 'Works out of the box', + playback: 'Playback and cue systems', + video: 'Video and streaming', + audio: 'Audio', + messaging: 'Webhooks and messaging', +}; + +/** presentation order for the library */ +export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'video', 'playback', 'audio', 'messaging']; + +/** + * Every recipe targets loopback by default. + * A recipe added by mistake must not put traffic on a venue network, so the user + * has to point it somewhere real before it can reach anything. + */ +export const automationRecipes: AutomationRecipe[] = [ + { + id: 'ontime-aux-timer', + title: 'Start Aux Timer 1 with the event', + description: 'Sets aux timer 1 to five minutes and starts it whenever an event starts.', + category: 'ontime', + needsSetup: false, + automation: { + title: 'Start Aux Timer 1 with the event', + filterRule: 'all', + filters: [], + outputs: [ + { type: 'ontime', action: 'aux1-set', time: '00:05:00' }, + { type: 'ontime', action: 'aux1-start' }, + ], + }, + triggers: [Cycle.onStart], + }, + { + id: 'ontime-warn-stage', + title: 'Warn the stage when the timer hits danger', + description: 'Shows a message on the stage timer as soon as the running event enters its danger window.', + category: 'ontime', + needsSetup: false, + automation: { + title: 'Warn the stage at danger', + filterRule: 'all', + filters: [], + outputs: [{ type: 'ontime', action: 'message-set', text: 'Please wrap up', visible: true }], + }, + triggers: [Cycle.onDanger], + }, + { + id: 'ontime-clear-message', + title: 'Hide the stage message on finish', + description: 'Hides the stage message once the event finishes. Pairs with the danger warning above.', + category: 'ontime', + needsSetup: false, + automation: { + title: 'Hide the stage message on finish', + filterRule: 'all', + filters: [], + // an empty text means "leave the text alone", so this only changes visibility + outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }], + }, + triggers: [Cycle.onFinish], + }, + { + id: 'obs-record', + title: 'OBS — start recording when the show starts', + description: 'Calls the OBS websocket HTTP bridge when the first event is loaded.', + category: 'video', + needsSetup: true, + automation: { + title: 'OBS start recording', + filterRule: 'all', + filters: [], + outputs: [{ type: 'http', url: 'http://127.0.0.1:4455/api/StartRecord' }], + }, + triggers: [Cycle.onLoad], + }, + { + id: 'vmix-overlay-warning', + title: 'vMix — show an overlay on timer warning', + description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.', + category: 'video', + needsSetup: true, + automation: { + title: 'vMix overlay on warning', + filterRule: 'all', + filters: [], + outputs: [{ type: 'http', url: 'http://127.0.0.1:8088/api/?Function=OverlayInput1In' }], + }, + triggers: [Cycle.onWarning], + }, + { + id: 'qlab-go', + title: 'QLab — fire the matching cue on event start', + description: "Sends OSC to QLab to start the cue whose number matches the Ontime event's cue.", + category: 'playback', + needsSetup: true, + automation: { + title: 'QLab GO on event start', + filterRule: 'all', + filters: [], + outputs: [ + { type: 'osc', targetIP: '127.0.0.1', targetPort: 53000, address: '/cue/{{eventNow.cue}}/start', args: '' }, + ], + }, + triggers: [Cycle.onStart], + }, + { + id: 'companion-press', + title: 'Companion — press a button on event start', + description: 'Presses page 1 button 1 on a Stream Deck through the Companion HTTP API.', + category: 'playback', + needsSetup: true, + automation: { + title: 'Companion button press', + filterRule: 'all', + filters: [], + outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/0/press' }], + }, + triggers: [Cycle.onStart], + }, + { + id: 'webhook-event-title', + title: 'Webhook — send the current event title', + description: 'Posts the running event title to any URL. A good place to see template strings at work.', + category: 'messaging', + docsUrl: 'https://docs.getontime.no/api/automation/#using-variables-in-automation', + needsSetup: true, + automation: { + title: 'Webhook with the current event', + filterRule: 'all', + filters: [], + outputs: [{ type: 'http', url: 'http://127.0.0.1:3000/now?title={{eventNow.title}}' }], + }, + triggers: [Cycle.onStart], + }, +]; diff --git a/apps/client/src/features/app-settings/panel/automations-panel/recipes/recipeUtils.ts b/apps/client/src/features/app-settings/panel/automations-panel/recipes/recipeUtils.ts new file mode 100644 index 000000000..a0b86313d --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/recipes/recipeUtils.ts @@ -0,0 +1,47 @@ +import type { Automation } from 'ontime-types'; + +import { addAutomation, addTrigger, deleteAutomation, deleteTrigger } from '../../../../../common/api/automation'; +import { cycles } from '../automationUtils'; +import type { AutomationRecipe } from './automationRecipes'; + +/** + * Installs a recipe as an ordinary automation, using the same endpoints as the form. + * There is nothing special about the result: the user owns it and can edit or delete it. + * + * The server generates the ids, so the automation has to exist before its triggers can + * point at it. If a trigger fails half way we undo the whole thing, triggers first: + * the server refuses to delete an automation that is still referenced. + */ +export async function installRecipe(recipe: AutomationRecipe): Promise { + const created = await addAutomation(recipe.automation); + const createdTriggerIds: string[] = []; + + try { + for (const cycle of recipe.triggers) { + const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle; + const trigger = await addTrigger({ + title: `${recipe.automation.title} — ${label}`, + trigger: cycle, + automationId: created.id, + }); + createdTriggerIds.push(trigger.id); + } + } catch (error) { + await rollback(created.id, createdTriggerIds); + throw error; + } + + return created; +} + +async function rollback(automationId: string, triggerIds: string[]) { + try { + for (const id of triggerIds) { + await deleteTrigger(id); + } + await deleteAutomation(automationId); + } catch (_error) { + // the install already failed and we are reporting that. A failed cleanup leaves an + // editable automation behind, which is recoverable, so it should not mask the original error + } +}