feat(automation): add composer

This commit is contained in:
Carlos Valente
2026-09-13 20:12:55 +02:00
parent b07544ab84
commit 79a535b1b3
21 changed files with 777 additions and 17 deletions
+16
View File
@@ -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<A
return res.data;
}
export async function getAutomationUsage(options?: RequestOptions): Promise<AutomationUsage> {
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<Automati
return res.data;
}
/** Creates a definition and optional global lifecycle bindings atomically. */
export async function createAutomationComposition(
composition: AutomationCompositionDTO,
): Promise<AutomationComposition> {
const res = await axios.post(`${automationsPath}/composition`, composition);
return res.data;
}
/**
* HTTP request to update a automation
*/
@@ -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 }),
});
}
@@ -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;
@@ -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<Record<string, TestState>>({});
const [submitError, setSubmitError] = useState<string>();
const [lifecycles, setLifecycles] = useState<TimerLifeCycle[]>(initialLifecycles);
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
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 = (
<DropdownMenu
render={<Button />}
@@ -220,6 +231,26 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
bodyElements={
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
<div className={style.innerColumn}>
{!isEdit && (
<>
<h3>Global trigger (optional)</h3>
<Panel.Description>
Choose when this automation should run globally. Leave all unchecked to save a reusable definition
only.
</Panel.Description>
<div className={style.lifecycleGrid}>
{cycles.map(({ value, label }) => (
<label key={value} className={style.lifecycleOption}>
<Checkbox
checked={lifecycles.includes(value)}
onCheckedChange={(checked) => toggleLifecycle(value, checked)}
/>
{label}
</label>
))}
</div>
</>
)}
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
@@ -1,4 +1,4 @@
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { AutomationDTO, NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import { Fragment, useState } from 'react';
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
@@ -9,9 +9,11 @@ import IconButton from '../../../../common/components/buttons/IconButton';
import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useAutomationUsage from '../../../../common/hooks-query/useAutomationUsage';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
import RecipeLibraryModal from './RecipeLibraryModal';
import style from './AutomationsList.module.scss';
@@ -30,7 +32,10 @@ interface AutomationsListProps {
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
const { refetch } = useAutomationSettings();
const { data: usage, refetch: refetchUsage } = useAutomationUsage();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [initialLifecycles, setInitialLifecycles] = useState<TimerLifeCycle[]>([]);
const [isRecipeLibraryOpen, setIsRecipeLibraryOpen] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
@@ -41,6 +46,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
refetchUsage();
}
};
@@ -50,13 +56,33 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<Panel.Section>
<Panel.Card>
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
<AutomationForm
automation={automationFormData}
initialLifecycles={initialLifecycles}
onClose={() => setAutomationFormData(null)}
/>
)}
{isRecipeLibraryOpen && (
<RecipeLibraryModal
onClose={() => setIsRecipeLibraryOpen(false)}
onSelect={(automation, lifecycles) => {
setInitialLifecycles(lifecycles);
setAutomationFormData(automation);
setIsRecipeLibraryOpen(false);
}}
/>
)}
<Panel.SubHeader>
Manage automations
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
<Button
onClick={() => {
setInitialLifecycles([]);
setAutomationFormData(automationPlaceholder);
}}
>
New <IoAdd />
</Button>
<Button onClick={() => setIsRecipeLibraryOpen(true)}>Recipes</Button>
</Panel.SubHeader>
<Panel.Divider />
@@ -75,6 +101,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
<th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Sends</th>
<th style={{ width: '10%' }}>Usage</th>
<th />
</tr>
</thead>
@@ -84,7 +111,13 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
title='No automations yet'
description='Create a reusable definition, then attach it to a global or an event trigger.'
action={
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
<Button
variant='primary'
onClick={() => {
setInitialLifecycles([]);
setAutomationFormData(automationPlaceholder);
}}
>
Create automation <IoAdd />
</Button>
}
@@ -111,6 +144,11 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
))
)}
</td>
<td>
{usage?.[automationId]
? `${usage[automationId].global} global, ${usage[automationId].event} event`
: '—'}
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -133,7 +171,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
})}
{deleteError && (
<tr>
<td colSpan={5}>
<td colSpan={6}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
@@ -0,0 +1,72 @@
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
import { useState } from 'react';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import * as Panel from '../../panel-utils/PanelUtils';
import { automationRecipes, defaultRecipeValues, type AutomationRecipe, type RecipeValues } from './automationRecipes';
interface RecipeLibraryModalProps {
onClose: () => void;
onSelect: (automation: AutomationDTO, lifecycles: TimerLifeCycle[]) => void;
}
export default function RecipeLibraryModal({ onClose, onSelect }: RecipeLibraryModalProps) {
const [recipe, setRecipe] = useState<AutomationRecipe>();
const [values, setValues] = useState<RecipeValues>({});
const selectRecipe = (nextRecipe: AutomationRecipe) => {
setRecipe(nextRecipe);
setValues(defaultRecipeValues(nextRecipe));
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
title={recipe ? recipe.title : 'Automation recipes'}
bodyElements={
recipe ? (
<div>
<Panel.Description>{recipe.description}</Panel.Description>
{recipe.params.map((param) => (
<label key={param.name}>
{param.label}
<Input
fluid
type={param.type ?? 'text'}
value={values[param.name] ?? ''}
onChange={(event) => setValues((current) => ({ ...current, [param.name]: event.target.value }))}
/>
</label>
))}
</div>
) : (
<Panel.ListGroup>
{automationRecipes.map((candidate) => (
<Panel.ListItem key={candidate.id}>
<Panel.Field title={candidate.title} description={candidate.description} />
<Button onClick={() => selectRecipe(candidate)}>Choose</Button>
</Panel.ListItem>
))}
</Panel.ListGroup>
)
}
footerElements={
recipe ? (
<>
<Button onClick={() => setRecipe(undefined)}>Back</Button>
<Button variant='primary' onClick={() => onSelect(recipe.build(values), recipe.lifecycles)}>
Continue
</Button>
</>
) : (
<Button onClick={onClose}>Cancel</Button>
)
}
/>
);
}
@@ -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);
});
});
@@ -0,0 +1,64 @@
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
import { TimerLifeCycle as Cycle } from 'ontime-types';
export type RecipeValues = Record<string, string>;
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]));
}
@@ -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<AutomationComposition | ErrorResponse>;
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);
});
});
@@ -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;
@@ -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 } });
});
});
@@ -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', () => {
@@ -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<AutomationSettings>) {
res.status(200).json(automationDao.getAutomationSettings());
}
export function getAutomationUsageCounts(_req: Request, res: Response<AutomationUsage>) {
const settings = automationDao.getAutomationSettings();
res
.status(200)
.json(getAutomationUsage(settings.automations, settings.triggers, getDataProvider().getProjectRundowns()));
}
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
try {
// body payload is a patch object that must contain root properties
@@ -84,6 +99,17 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
}
}
export async function postAutomationComposition(req: Request, res: Response<AutomationComposition | ErrorResponse>) {
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<Automation | ErrorResponse>) {
try {
const automation = parseAutomation(req.body);
@@ -1,5 +1,7 @@
import type {
Automation,
AutomationComposition,
AutomationCompositionDTO,
AutomationDTO,
AutomationSettings,
NormalisedAutomation,
@@ -121,6 +123,37 @@ export async function addAutomation(newAutomation: AutomationDTO): Promise<Autom
return automations[id];
}
/**
* Persists a definition and its global bindings with one settings write. An empty lifecycle list
* deliberately creates a reusable definition only.
*/
export async function createAutomationComposition({
automation: newAutomation,
lifecycles,
}: AutomationCompositionDTO): Promise<AutomationComposition> {
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
*/
@@ -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);
@@ -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;
}
@@ -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<unknown>): asserts filters is AutomationFilter[] {
filters.forEach((condition) => {
assert.isObject(condition);
@@ -22,6 +22,19 @@ export type Automation = {
export type AutomationDTO = Omit<Automation, 'id'>;
/** 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<AutomationId, { global: number; event: number }>;
export type NormalisedAutomation = Record<AutomationId, Automation>;
export type Trigger = {
+3
View File
@@ -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,
+114
View File
@@ -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.
+108
View File
@@ -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.