diff --git a/apps/client/src/common/api/automation.ts b/apps/client/src/common/api/automation.ts index 5f37fa27a..15e3a3101 100644 --- a/apps/client/src/common/api/automation.ts +++ b/apps/client/src/common/api/automation.ts @@ -1,9 +1,12 @@ import axios from 'axios'; import type { Automation, + AutomationComposition, + AutomationCompositionDTO, AutomationDTO, AutomationOutput, AutomationSettings, + AutomationUsage, Trigger, TriggerDTO, } from 'ontime-types'; @@ -21,6 +24,11 @@ export async function getAutomationSettings(options?: RequestOptions): Promise { + const res = await axios.get(`${automationsPath}/usage`, { signal: options?.signal }); + return res.data; +} + /** * HTTP request to edit the automations settings */ @@ -62,6 +70,14 @@ export async function addAutomation(automation: AutomationDTO): Promise { + const res = await axios.post(`${automationsPath}/composition`, composition); + return res.data; +} + /** * HTTP request to update a automation */ diff --git a/apps/client/src/common/hooks-query/useAutomationUsage.ts b/apps/client/src/common/hooks-query/useAutomationUsage.ts new file mode 100644 index 000000000..eec6871f5 --- /dev/null +++ b/apps/client/src/common/hooks-query/useAutomationUsage.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; + +import { getAutomationUsage } from '../api/automation'; +import { AUTOMATION } from '../api/constants'; + +export default function useAutomationUsage() { + return useQuery({ + queryKey: [...AUTOMATION, 'usage'], + queryFn: ({ signal }) => getAutomationUsage({ signal }), + }); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss index c5ae23626..80c738566 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss @@ -19,6 +19,19 @@ gap: 0.5rem; } +.lifecycleGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 0.5rem; +} + +.lifecycleOption { + display: flex; + align-items: center; + gap: 0.5rem; + color: $ui-white; +} + .ruleSection { display: flex; flex-direction: column; 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 14c87581b..1261340b7 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 @@ -1,12 +1,13 @@ -import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types'; +import { Automation, AutomationDTO, TimerLifeCycle, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useFieldArray, useForm } from 'react-hook-form'; import { IoAdd, IoTrash } from 'react-icons/io5'; -import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation'; +import { createAutomationComposition, editAutomation, testOutput } 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 Checkbox from '../../../../common/components/checkbox/Checkbox'; import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu'; import Info from '../../../../common/components/info/Info'; import Input from '../../../../common/components/input/input/Input'; @@ -15,10 +16,11 @@ import Modal from '../../../../common/components/modal/Modal'; import RadioGroup from '../../../../common/components/radio-group/RadioGroup'; import Select from '../../../../common/components/select/Select'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; +import useAutomationUsage from '../../../../common/hooks-query/useAutomationUsage'; import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import { isOntimeCloud } from '../../../../externals'; import * as Panel from '../../panel-utils/PanelUtils'; -import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils'; +import { cycles, isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils'; import HttpOutputForm from './HttpOutputForm'; import OntimeActionForm from './OntimeActionForm'; import OscOutputForm from './OscOutputForm'; @@ -32,16 +34,19 @@ const testFeedbackDuration = 2000; interface AutomationFormProps { automation: Automation | AutomationDTO; + initialLifecycles?: TimerLifeCycle[]; onClose: () => void; } -export default function AutomationForm({ automation, onClose }: AutomationFormProps) { +export default function AutomationForm({ automation, initialLifecycles = [], onClose }: AutomationFormProps) { const isEdit = isAutomation(automation); const { data } = useCustomFields(); const { refetch } = useAutomationSettings(); + const { refetch: refetchUsage } = useAutomationUsage(); const fieldList = useMemo(() => makeFieldList(data), [data]); const [testResults, setTestResults] = useState>({}); const [submitError, setSubmitError] = useState(); + const [lifecycles, setLifecycles] = useState(initialLifecycles); const feedbackTimers = useRef>>({}); const { @@ -157,6 +162,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr await handleCreate(values); } refetch(); + refetchUsage(); async function handleEdit(id: string, values: Automation) { try { @@ -169,7 +175,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr async function handleCreate(values: AutomationDTO) { try { - await addAutomation(values); + await createAutomationComposition({ automation: values, lifecycles }); onClose(); } catch (error) { setSubmitError(maybeAxiosError(error)); @@ -177,7 +183,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } }; - const canSubmit = !isSubmitting && isDirty && isValid; + const canSubmit = !isSubmitting && isValid && (isDirty || initialLifecycles.length > 0); + const toggleLifecycle = (lifecycle: TimerLifeCycle, checked: boolean) => { + setLifecycles((current) => + checked ? [...current, lifecycle] : current.filter((selectedLifecycle) => selectedLifecycle !== lifecycle), + ); + }; const addOutputMenu = ( } @@ -220,6 +231,26 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr bodyElements={
+ {!isEdit && ( + <> +

Global trigger (optional)

+ + Choose when this automation should run globally. Leave all unchecked to save a reusable definition + only. + +
+ {cycles.map(({ value, label }) => ( + + ))} +
+ + )}

Automation options

+ ) : ( + + {automationRecipes.map((candidate) => ( + + + + + ))} + + ) + } + footerElements={ + recipe ? ( + <> + + + + ) : ( + + ) + } + /> + ); +} 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..06346b6fe --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/__tests__/automationRecipes.test.ts @@ -0,0 +1,18 @@ +import { TimerLifeCycle, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types'; + +import { automationRecipes, defaultRecipeValues } from '../automationRecipes'; + +describe('automation recipes', () => { + it('builds valid OSC, HTTP, and Ontime definitions with explicit global lifecycles', () => { + const built = automationRecipes.map((recipe) => ({ + recipe, + automation: recipe.build(defaultRecipeValues(recipe)), + })); + + expect(built.some(({ automation }) => automation.outputs.some(isOSCOutput))).toBe(true); + expect(built.some(({ automation }) => automation.outputs.some(isHTTPOutput))).toBe(true); + expect(built.some(({ automation }) => automation.outputs.some(isOntimeAction))).toBe(true); + expect(built.every(({ recipe }) => recipe.lifecycles.length > 0)).toBe(true); + expect(built.flatMap(({ recipe }) => recipe.lifecycles)).toContain(TimerLifeCycle.onStart); + }); +}); 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..e649a060c --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/automationRecipes.ts @@ -0,0 +1,64 @@ +import type { AutomationDTO, TimerLifeCycle } from 'ontime-types'; +import { TimerLifeCycle as Cycle } from 'ontime-types'; + +export type RecipeValues = Record; + +export type AutomationRecipe = { + id: string; + title: string; + description: string; + lifecycles: TimerLifeCycle[]; + params: { name: string; label: string; defaultValue: string; type?: 'number' }[]; + build: (values: RecipeValues) => AutomationDTO; +}; + +function definition(title: string, outputs: AutomationDTO['outputs']): AutomationDTO { + return { title, filterRule: 'all', filters: [], outputs }; +} + +export const automationRecipes: AutomationRecipe[] = [ + { + id: 'qlab-go', + title: 'QLab — fire the matching cue', + description: 'Sends an OSC GO message for the event cue when an event starts.', + lifecycles: [Cycle.onStart], + params: [ + { name: 'host', label: 'QLab host', defaultValue: '127.0.0.1' }, + { name: 'port', label: 'OSC port', defaultValue: '53000', type: 'number' }, + ], + build: ({ host, port }) => + definition('QLab GO on event start', [ + { + type: 'osc', + targetIP: host.trim(), + targetPort: Number(port), + address: '/cue/{{eventNow.cue}}/start', + args: '', + }, + ]), + }, + { + id: 'webhook-event-title', + title: 'Webhook — send the event title', + description: 'Calls a webhook with the running event title when an event starts.', + lifecycles: [Cycle.onStart], + params: [{ name: 'url', label: 'Webhook URL', defaultValue: 'http://127.0.0.1:3000/ontime' }], + build: ({ url }) => + definition('Send event title to webhook', [ + { type: 'http', url: `${url.trim()}${url.includes('?') ? '&' : '?'}title={{url:eventNow.title}}` }, + ]), + }, + { + id: 'ontime-stage-warning', + title: 'Ontime — show a stage warning', + description: 'Shows a message in Ontime when an event reaches its danger window.', + lifecycles: [Cycle.onDanger], + params: [{ name: 'message', label: 'Message', defaultValue: 'Please wrap up' }], + build: ({ message }) => + definition('Warn the stage at danger', [{ type: 'ontime', action: 'message-set', text: message, visible: true }]), + }, +]; + +export function defaultRecipeValues(recipe: AutomationRecipe): RecipeValues { + return Object.fromEntries(recipe.params.map((param) => [param.name, param.defaultValue])); +} diff --git a/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts b/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts index 1714d59cb..46d2ce689 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.controller.test.ts @@ -1,11 +1,12 @@ import type { Request, Response } from 'express'; -import type { Automation, ErrorResponse } from 'ontime-types'; +import type { Automation, AutomationComposition, ErrorResponse } from 'ontime-types'; -import { editAutomation, postAutomation } from '../automation.controller.js'; +import { editAutomation, postAutomation, postAutomationComposition } from '../automation.controller.js'; import * as automationDao from '../automation.dao.js'; vi.mock('../automation.dao.js', () => ({ addAutomation: vi.fn(), + createAutomationComposition: vi.fn(), editAutomation: vi.fn(), })); @@ -28,6 +29,15 @@ describe('automation controllers', () => { vi.clearAllMocks(); vi.mocked(automationDao.addAutomation).mockImplementation(async (automation) => ({ id: 'new-id', ...automation })); vi.mocked(automationDao.editAutomation).mockImplementation(async (id, automation) => ({ id, ...automation })); + vi.mocked(automationDao.createAutomationComposition).mockImplementation(async ({ automation, lifecycles }) => ({ + automation: { id: 'new-id', ...automation }, + triggers: lifecycles.map((trigger, index) => ({ + id: String(index), + title: `Global ${trigger}`, + trigger, + automationId: 'new-id', + })), + })); }); it('persists normalized outputs when creating an automation', async () => { @@ -81,4 +91,19 @@ describe('automation controllers', () => { expect.objectContaining({ message: 'Automation definitions cannot include triggers' }), ); }); + + it('creates a definition and global lifecycle bindings through the composition command', async () => { + const response = makeResponse() as unknown as Response; + const request = { + body: { automation: requestBody, lifecycles: ['onStart', 'onFinish'] }, + } as Request; + + await postAutomationComposition(request, response); + + expect(automationDao.createAutomationComposition).toHaveBeenCalledWith({ + automation: expect.objectContaining({ title: 'OSC definition' }), + lifecycles: ['onStart', 'onFinish'], + }); + expect(response.status).toHaveBeenCalledWith(201); + }); }); diff --git a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts index 8a6452b1b..0d5f83c00 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts @@ -3,6 +3,7 @@ import { Automation, AutomationDTO, ProjectRundowns, TimerLifeCycle, TriggerDTO import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; import { addAutomation, + createAutomationComposition, addTrigger, deleteAll, deleteAllTriggers, @@ -137,6 +138,37 @@ describe('addAutomation()', () => { }); }); +describe('createAutomationComposition()', () => { + beforeEach(async () => { + await deleteAll(); + }); + + it('writes a definition and all requested global bindings together', async () => { + const composition = await createAutomationComposition({ + automation: { title: 'New definition', filterRule: 'all', filters: [], outputs: [makeHTTPAction()] }, + lifecycles: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish], + }); + + expect(getAutomations()[composition.automation.id]).toEqual(composition.automation); + expect(getAutomationTriggers()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onStart }), + expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onFinish }), + ]), + ); + }); + + it('writes only a reusable definition when no lifecycle is selected', async () => { + const composition = await createAutomationComposition({ + automation: { title: 'Reusable definition', filterRule: 'all', filters: [], outputs: [] }, + lifecycles: [], + }); + + expect(getAutomations()[composition.automation.id]).toEqual(composition.automation); + expect(getAutomationTriggers()).toEqual([]); + }); +}); + describe('editAutomation()', () => { // saving the ID of the added automation let firstAutomation: Automation; diff --git a/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts index d3ffd28c6..67455754c 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.utils.test.ts @@ -1,7 +1,13 @@ import { ProjectRundowns, TimerLifeCycle } from 'ontime-types'; import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js'; -import { isAutomationUsed, isHostname, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js'; +import { + getAutomationUsage, + isAutomationUsed, + isHostname, + parseTemplateNested, + stringToOSCArgs, +} from '../automation.utils.js'; describe('isHostname()', () => { it.each(['localhost', 'qlab', 'osc.example.com', 'osc-target.example'])('accepts %s', (hostname) => { @@ -367,3 +373,52 @@ describe('isAutomationUsed()', () => { expect(result).toBeUndefined(); }); }); + +describe('getAutomationUsage()', () => { + it('counts global and event bindings across multiple rundowns without counting missing references', () => { + const usage = getAutomationUsage( + { + used: { id: 'used', title: 'Used', filterRule: 'all', filters: [], outputs: [] }, + unused: { id: 'unused', title: 'Unused', filterRule: 'all', filters: [], outputs: [] }, + }, + [ + { id: 'global-1', title: 'Global', trigger: TimerLifeCycle.onStart, automationId: 'used' }, + { id: 'global-missing', title: 'Missing', trigger: TimerLifeCycle.onStart, automationId: 'missing' }, + ], + { + one: { + id: 'one', + title: 'One', + order: ['event-1'], + flatOrder: ['event-1'], + entries: { + 'event-1': makeOntimeEvent({ + id: 'event-1', + triggers: [ + { id: 'event-trigger-1', title: 'Event', trigger: TimerLifeCycle.onFinish, automationId: 'used' }, + ], + }), + }, + revision: 1, + }, + two: { + id: 'two', + title: 'Two', + order: ['event-2'], + flatOrder: ['event-2'], + entries: { + 'event-2': makeOntimeEvent({ + id: 'event-2', + triggers: [ + { id: 'event-trigger-2', title: 'Event', trigger: TimerLifeCycle.onFinish, automationId: 'used' }, + ], + }), + }, + revision: 1, + }, + }, + ); + + expect(usage).toEqual({ used: { global: 1, event: 2 }, unused: { global: 0, event: 0 } }); + }); +}); diff --git a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts index 5c96895d2..55119548e 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.validation.test.ts @@ -1,4 +1,4 @@ -import { parseAutomation, parseOutput } from '../automation.validation.js'; +import { parseAutomation, parseAutomationComposition, parseOutput } from '../automation.validation.js'; describe('parseAutomation', () => { it('rejects trigger bindings from definition payloads', () => { @@ -8,6 +8,29 @@ describe('parseAutomation', () => { }); }); +describe('parseAutomationComposition', () => { + it('accepts a reusable definition without global lifecycles', () => { + expect( + parseAutomationComposition({ + automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] }, + lifecycles: [], + }), + ).toEqual({ + automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] }, + lifecycles: [], + }); + }); + + it('rejects duplicate global lifecycles', () => { + expect(() => + parseAutomationComposition({ + automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] }, + lifecycles: ['onStart', 'onStart'], + }), + ).toThrow('Duplicate automation lifecycle: onStart'); + }); +}); + describe('parseOutput', () => { describe('handles OSC outputs', () => { it('parses a valid payload', () => { diff --git a/apps/server/src/api-data/automation/automation.controller.ts b/apps/server/src/api-data/automation/automation.controller.ts index b9cfcf34e..748f728ab 100644 --- a/apps/server/src/api-data/automation/automation.controller.ts +++ b/apps/server/src/api-data/automation/automation.controller.ts @@ -1,17 +1,32 @@ import type { Request, Response } from 'express'; -import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types'; +import { + Automation, + AutomationComposition, + AutomationSettings, + AutomationUsage, + ErrorResponse, + Trigger, +} from 'ontime-types'; import { getErrorMessage } from 'ontime-utils'; import { oscServer } from '../../adapters/OscAdapter.js'; import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; import * as automationDao from './automation.dao.js'; import * as automationService from './automation.service.js'; -import { parseAutomation, parseOutput } from './automation.validation.js'; +import { getAutomationUsage } from './automation.utils.js'; +import { parseAutomation, parseAutomationComposition, parseOutput } from './automation.validation.js'; export function getAutomationSettings(_req: Request, res: Response) { res.status(200).json(automationDao.getAutomationSettings()); } +export function getAutomationUsageCounts(_req: Request, res: Response) { + const settings = automationDao.getAutomationSettings(); + res + .status(200) + .json(getAutomationUsage(settings.automations, settings.triggers, getDataProvider().getProjectRundowns())); +} + export async function postAutomationSettings(req: Request, res: Response) { try { // body payload is a patch object that must contain root properties @@ -84,6 +99,17 @@ export async function postAutomation(req: Request, res: Response) { + try { + const composition = parseAutomationComposition(req.body); + const created = await automationDao.createAutomationComposition(composition); + res.status(201).send(created); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +} + export async function editAutomation(req: Request, res: Response) { try { const automation = parseAutomation(req.body); diff --git a/apps/server/src/api-data/automation/automation.dao.ts b/apps/server/src/api-data/automation/automation.dao.ts index 464df87e4..698a9307c 100644 --- a/apps/server/src/api-data/automation/automation.dao.ts +++ b/apps/server/src/api-data/automation/automation.dao.ts @@ -1,5 +1,7 @@ import type { Automation, + AutomationComposition, + AutomationCompositionDTO, AutomationDTO, AutomationSettings, NormalisedAutomation, @@ -121,6 +123,37 @@ export async function addAutomation(newAutomation: AutomationDTO): Promise { + const settings = getAutomationSettings(); + const automations = { ...settings.automations }; + const triggers = [...settings.triggers]; + const automationId = getUniqueAutomationId(automations); + const automation: Automation = { ...newAutomation, id: automationId }; + const newTriggers: Trigger[] = []; + for (const trigger of lifecycles) { + const binding = { + id: getUniqueTriggerId([...triggers, ...newTriggers]), + title: `Global ${trigger}`, + trigger, + automationId, + }; + newTriggers.push(binding); + } + + automations[automationId] = automation; + triggers.push(...newTriggers); + await getDataProvider().setAutomation({ ...settings, automations, triggers }); + + return { automation, triggers: newTriggers }; +} + /** * Updates an existing automation with a new entry */ diff --git a/apps/server/src/api-data/automation/automation.router.ts b/apps/server/src/api-data/automation/automation.router.ts index 5d91fc683..d64e012b4 100644 --- a/apps/server/src/api-data/automation/automation.router.ts +++ b/apps/server/src/api-data/automation/automation.router.ts @@ -6,6 +6,8 @@ import { deleteTrigger, editAutomation, getAutomationSettings, + getAutomationUsageCounts, + postAutomationComposition, postAutomation, postAutomationSettings, postTrigger, @@ -14,6 +16,7 @@ import { } from './automation.controller.js'; import { validateAutomation, + validateAutomationComposition, validateAutomationPatch, validateAutomationSettings, validateTestPayload, @@ -24,6 +27,7 @@ import { export const router: Router = express.Router(); router.get('/', getAutomationSettings); +router.get('/usage', getAutomationUsageCounts); router.post('/', validateAutomationSettings, postAutomationSettings); router.post('/trigger', validateTrigger, postTrigger); @@ -31,6 +35,7 @@ router.put('/trigger/:id', validateTriggerPatch, putTrigger); router.delete('/trigger/:id', paramsWithId, deleteTrigger); router.post('/automation', validateAutomation, postAutomation); +router.post('/composition', validateAutomationComposition, postAutomationComposition); router.put('/automation/:id', validateAutomationPatch, editAutomation); router.delete('/automation/:id', paramsWithId, deleteAutomation); diff --git a/apps/server/src/api-data/automation/automation.utils.ts b/apps/server/src/api-data/automation/automation.utils.ts index 6025f0e91..7bd6bdee5 100644 --- a/apps/server/src/api-data/automation/automation.utils.ts +++ b/apps/server/src/api-data/automation/automation.utils.ts @@ -1,11 +1,14 @@ import { AutomationFilter, + AutomationUsage, EntryId, FilterRule, MaybeNumber, OntimeAction, + NormalisedAutomation, ProjectRundowns, RundownEntries, + Trigger, isOntimeEvent, ontimeActionKeyValues, } from 'ontime-types'; @@ -283,3 +286,32 @@ export function isAutomationUsed( } } } + +/** Counts global and event bindings in one pass over global triggers and project rundowns. */ +export function getAutomationUsage( + automations: NormalisedAutomation, + globalTriggers: Trigger[], + projectRundowns: ProjectRundowns, +): AutomationUsage { + const usage: AutomationUsage = Object.fromEntries( + Object.keys(automations).map((automationId) => [automationId, { global: 0, event: 0 }]), + ); + + for (const trigger of globalTriggers) { + const count = usage[trigger.automationId]; + if (count) count.global += 1; + } + + for (const rundown of Object.values(projectRundowns)) { + for (const entryId of rundown.flatOrder) { + const entry = rundown.entries[entryId]; + if (!isOntimeEvent(entry)) continue; + for (const trigger of entry.triggers ?? []) { + const count = usage[trigger.automationId]; + if (count) count.event += 1; + } + } + } + + return usage; +} diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts index ab0f8f001..234c519d5 100644 --- a/apps/server/src/api-data/automation/automation.validation.ts +++ b/apps/server/src/api-data/automation/automation.validation.ts @@ -3,6 +3,7 @@ import { isIP } from 'node:net'; import { body, param } from 'express-validator'; import { AutomationDTO, + AutomationCompositionDTO, AutomationFilter, AutomationOutput, HTTPOutput, @@ -48,6 +49,8 @@ export const validateTriggerPatch = [ export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction]; +export const validateAutomationComposition = [body().custom(parseAutomationComposition), requestValidationFunction]; + export const validateAutomationPatch = [ param('id').isString().notEmpty(), body().custom(parseAutomation), @@ -81,6 +84,31 @@ export function parseAutomation(maybeAutomation: unknown): AutomationDTO { return { title, filterRule, filters, outputs: parsedOutputs }; } +/** Parses a create-only command for a reusable definition and its global bindings. */ +export function parseAutomationComposition(maybeComposition: unknown): AutomationCompositionDTO { + assert.isObject(maybeComposition); + assert.hasKeys(maybeComposition, ['automation', 'lifecycles']); + + const { automation, lifecycles } = maybeComposition; + const parsedAutomation = parseAutomation(automation); + assert.isArray(lifecycles); + + const parsedLifecycles = lifecycles.map((lifecycle) => { + assert.isString(lifecycle); + if (!timerLifecycleValues.includes(lifecycle)) { + throw new Error(`Invalid automation lifecycle: ${lifecycle}`); + } + return lifecycle as AutomationCompositionDTO['lifecycles'][number]; + }); + + const duplicate = parsedLifecycles.find((lifecycle, index) => parsedLifecycles.indexOf(lifecycle) !== index); + if (duplicate) { + throw new Error(`Duplicate automation lifecycle: ${duplicate}`); + } + + return { automation: parsedAutomation, lifecycles: parsedLifecycles }; +} + function validateFilters(filters: Array): asserts filters is AutomationFilter[] { filters.forEach((condition) => { assert.isObject(condition); diff --git a/packages/types/src/definitions/core/Automation.type.ts b/packages/types/src/definitions/core/Automation.type.ts index c67768b1b..b2011d71e 100644 --- a/packages/types/src/definitions/core/Automation.type.ts +++ b/packages/types/src/definitions/core/Automation.type.ts @@ -22,6 +22,19 @@ export type Automation = { export type AutomationDTO = Omit; +/** Creates one reusable definition and optional global lifecycle bindings in a single operation. */ +export type AutomationCompositionDTO = { + automation: AutomationDTO; + lifecycles: TimerLifeCycle[]; +}; + +export type AutomationComposition = { + automation: Automation; + triggers: Trigger[]; +}; + +export type AutomationUsage = Record; + export type NormalisedAutomation = Record; export type Trigger = { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 66ea56090..9f324af70 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -31,9 +31,12 @@ export { ontimeActionKeyValues } from './definitions/core/Automation.type.js'; export type { OntimeActionKey, Automation, + AutomationComposition, + AutomationCompositionDTO, AutomationDTO, AutomationFilter, AutomationSettings, + AutomationUsage, AutomationOutput, FilterRule, HTTPOutput, diff --git a/tasks/automation-composer-todo.md b/tasks/automation-composer-todo.md new file mode 100644 index 000000000..4168d46c6 --- /dev/null +++ b/tasks/automation-composer-todo.md @@ -0,0 +1,114 @@ +# Automation UI Foundation and Global Composer Tasks + +## PR 1 — automation UI foundation + +### Completed implementation + +- [x] Start from clean `master` and retain the mixed branch as reference only. +- [x] Keep automation create/edit payloads definition-only. +- [x] Preserve global and event trigger references when definitions are edited. +- [x] Refuse deletion of definitions referenced by global triggers or rundown events. +- [x] Reject trigger bindings submitted to definition CRUD. +- [x] Persist normalized output values returned by server validation. +- [x] Support the full filter-operator contract, including `not_contains`. +- [x] Extract focused OSC, HTTP, Ontime, and output-card form components. +- [x] Add honest per-output test success/error feedback and failed-save retry behavior. +- [x] Add shared lifecycle labels and output summaries. +- [x] Clarify global trigger scope, duplicate behavior, and missing references. +- [x] Improve event trigger lifecycle labels and output visibility. +- [x] Keep recipes, composer code, and lifecycle-on-definition state out of the PR. +- [x] Cover shared lifecycle labels, the full filter-operator contract, controller rejection of trigger fields, and + definition output replacement with focused regression tests. + +### Required before merge + +- [x] Accept IPv6 OSC targets as well as IPv4 and hostnames. + - [x] Add a parser regression test using an IPv6 target such as `::1`. + - [x] Keep runtime-template hostname support and normalized persistence intact. +- [x] Remove stale output-form styles that no rendered component references. + - `oscSection` + - `httpSection` + - `actionSection` + - `outputCard` + - nested `test` +- [x] Run the browser smoke check: + - create and edit a definition; + - add, remove, and test OSC, HTTP, and Ontime outputs; + - retry a save after a server error; + - create/edit a global trigger; + - attach the same definition to an event; + - verify missing and duplicate states; + - verify narrow-panel horizontal scrolling and sticky headers. +- [x] Show the concise server validation message after a failed save instead of the full raw 422 response and submitted + definition; keep the form open and retryable. +- [x] Re-run final verification and inspect the final diff for temporary code, stale comments, generated files, and + recipe/composer leakage. + +### Verification already observed + +- [x] Server test pipeline: 51 files passed; 778 tests passed; 6 todo. +- [x] Client test pipeline: 34 files passed; 276 tests passed. +- [x] Client and server type checks passed. +- [x] Client and server lint passed. +- [x] Format check passed. +- [x] Client and server builds passed with only existing Vite warnings. +- [x] `git diff --check master...HEAD` passed. + +### PR 1 checkpoint + +- [x] IPv6 compatibility and stale-style cleanup are committed to their owning commits. +- [x] Added PR 1 regression coverage is committed separately from production behavior. +- [x] Browser smoke check is recorded. +- [x] PR 1 is self-sufficient and ready for human review. + +## PR 2 — global automation composer and recipes + +### Task 1: Define the composition contract + +- [ ] Add a create-only command containing a definition plus zero or more global lifecycles. +- [ ] Return the created definition and trigger bindings. +- [ ] Keep persisted `Automation` and `Trigger` entities and ordinary CRUD unchanged. +- [ ] Document that zero lifecycles saves a reusable definition only. + +### Task 2: Add atomic global composition creation + +- [ ] Validate the definition and every lifecycle before writing. +- [ ] Normalize or reject duplicate lifecycle inputs consistently. +- [ ] Assign IDs and persist the definition and bindings in one settings write. +- [ ] Test success, invalid input, duplicate inputs, and no-partial-write failure behavior. + +### Task 3: Build the manual composer + +- [ ] Present separate Global trigger, Conditions, and Actions sections. +- [ ] Reuse the PR 1 output editor components where their contracts fit. +- [ ] Support multiple global lifecycles and definition-only save. +- [ ] Test failed-save retry and refresh of both definitions and triggers. + +### Task 4: Move recipes onto the composer + +- [ ] Restore the recipe catalog and parameter setup without restoring trigger ownership to definitions. +- [ ] Make every recipe declare global lifecycle defaults explicitly. +- [ ] Submit manual and recipe flows through the same composition command. +- [ ] Test representative OSC, HTTP, and Ontime recipes. + +### Task 5: Evaluate complete usage counts + +- [ ] Implement a pure aggregation over global triggers and all project rundowns. +- [ ] Test zero usage, both scopes, multiple rundowns, duplicates, and missing references. +- [ ] Confirm the read path performs no repeated per-definition scans or persisted counting. +- [ ] Ship the UI count only if both global and event usage are complete and cheap; otherwise record the decision to + defer it. + +### Task 6: Integrate and verify + +- [ ] Keep event-scoped recipes and event-composer navigation absent. +- [ ] Verify disabled automations, errors, modal keyboard behavior, and narrow layouts. +- [ ] Run full tests, type checks, lint, formatting, and builds. +- [ ] Review the final diff for stale compatibility code and scope leakage. + +### PR 2 checkpoint + +- [ ] Manual and recipe flows create global compositions atomically. +- [ ] Reusable definitions remain independently editable and attachable in both scopes. +- [ ] Usage information is complete or intentionally omitted. +- [ ] PR 2 is ready for human review. diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 000000000..2ba1e63fc --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,108 @@ +# Automation UI Foundation and Global Composer Plan + +## Decision + +Keep the work split into two pull requests: + +1. **PR 1 — automation UI foundation:** reusable definition editing, clearer global/event trigger surfaces, output + editing, and validation hardening. +2. **PR 2 — global automation composer and recipes:** one create flow that produces a reusable definition and zero or + more global trigger bindings. + +The implementation confirms that this boundary is workable. PR 1 is useful without recipes and does not move lifecycle +ownership into automation definitions. PR 2 can therefore build on a stable definition/trigger model instead of +repairing the old mixed branch. + +## Architecture decisions + +- An automation definition owns its title, filters, and outputs. +- A trigger owns lifecycle, scope, and the reference to a definition. +- The composer is a create-time orchestration over those two existing resources; it is not a new persisted entity. +- The first composer creates global bindings only. Event-scoped recipes remain out of scope. +- Users may continue editing definitions and triggers independently after creation. +- Recipes use the same composition command as the manual composer. +- Usage is derived, not persisted. Show it only if complete global and event counts can be obtained with one bounded + aggregation; otherwise omit it. + +## Current PR 1 assessment + +Branch: `automation-ui` + +Commits: + +- `b38e5de9` — template-aware output validation and normalized persistence. +- `1732c7cc` — definition/output editing, clearer global/event trigger presentation, and regression coverage for the + definition/trigger boundary and shared presentation helpers. + +The two commits can remain in one PR. Although the total diff is slightly above the normal review target, much of it is +the extraction of the existing output form into focused components, and the final behavior is one coherent foundation. +Do not create a third PR solely for the validation commit. + +### Review result + +The code-review findings are resolved: + +- IPv6 support is restored in OSC target validation with a `::1` regression test while IPv4, hostname, and runtime + template handling remain covered. +- The obsolete `oscSection`, `httpSection`, `actionSection`, `outputCard`, and nested `test` styles left behind by the + output-card extraction have been removed. + +The browser smoke check passed for definition creation/editing, all output types, failed-save retry, global trigger +editing, event attachment, duplicate and missing-reference states, and narrow-panel scrolling with sticky headers. It +revealed one small UX issue which is now fixed: server-side validation failures show the concise validation message (for +example, `Invalid OSC target`) instead of the complete raw 422 response and submitted definition. + +No recipe, composer, lifecycle-on-definition, or event-scoped recipe work should be added while closing these items. + +## PR 2 sequence + +1. Define a create-only global composition request and response without changing definition CRUD. +2. Implement atomic server creation of one definition plus distinct global lifecycle bindings. +3. Build a manual `When -> If -> Then` composer using the PR 1 output editors. +4. Move recipes onto the same composition command and label their global scope explicitly. +5. Spike complete usage aggregation across global triggers and all project rundowns; ship the count only if the bounded + scan is cheap and does not widen persisted state. +6. Verify refresh, error recovery, disabled-automation behavior, responsive layout, and representative recipes. + +## Pull request boundaries + +### PR 1 includes + +- Definition-only create/edit behavior and rejection of trigger fields at that boundary. +- Reference-safe deletion behavior and tests for global/event usages. +- Output cards, output summaries, test feedback, shared lifecycle labels, and trigger clarity. +- Template-aware OSC/HTTP validation. +- The minimum shared scrolling changes required by these screens. + +### PR 1 excludes + +- Recipe catalog or recipe picker. +- Composer API or UI. +- Lifecycle fields on automation definitions. +- Trigger reconciliation in definition CRUD. +- Usage counts. +- Event-scoped recipes. + +### PR 2 includes + +- A dedicated composition contract and atomic global creation path. +- Manual and recipe-driven composition using the same path. +- Definition-only save as an explicit composer option. +- Complete usage counts only if the aggregation spike meets the cost constraint. + +### PR 2 excludes + +- Event-scoped recipes or an event composer. +- Persisted usage counters. +- Trigger schema/title migration. +- Editing shared definitions through trigger rows. +- A general workflow engine. + +## Verification gates + +PR 1 must pass focused and full client/server automation tests, client/server type checks and lint, formatting, builds, +diff hygiene, and the browser smoke check. PR 2 repeats those gates and adds tests for atomicity, duplicate lifecycle +inputs, manual creation, recipe creation, failed-save retry, and any usage aggregation that ships. + +Detailed progress is tracked in `tasks/automation-composer-todo.md`; the unrelated teleprompter plan in +`tasks/todo.md` remains untouched.