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);