mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-12 17:49:37 +00:00
feat(automation): persist lifecycle triggers atomically
Create and edit automation lifecycle bindings in the same settings save, and validate template-aware OSC and HTTP outputs consistently.
This commit is contained in:
@@ -4,6 +4,7 @@ import type {
|
|||||||
AutomationDTO,
|
AutomationDTO,
|
||||||
AutomationOutput,
|
AutomationOutput,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
|
AutomationTriggerDTO,
|
||||||
Trigger,
|
Trigger,
|
||||||
TriggerDTO,
|
TriggerDTO,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
@@ -57,16 +58,20 @@ export function deleteTrigger(id: string): Promise<void> {
|
|||||||
/**
|
/**
|
||||||
* HTTP request to create a new automation
|
* HTTP request to create a new automation
|
||||||
*/
|
*/
|
||||||
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
|
export async function addAutomation(automation: AutomationDTO, triggers: AutomationTriggerDTO[]): Promise<Automation> {
|
||||||
const res = await axios.post(`${automationsPath}/automation`, automation);
|
const res = await axios.post(`${automationsPath}/automation`, { ...automation, triggers });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP request to update a automation
|
* HTTP request to update a automation
|
||||||
*/
|
*/
|
||||||
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
|
export async function editAutomation(
|
||||||
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
|
id: string,
|
||||||
|
automation: Automation,
|
||||||
|
triggers?: AutomationTriggerDTO[],
|
||||||
|
): Promise<Automation> {
|
||||||
|
const res = await axios.put(`${automationsPath}/automation/${id}`, { ...automation, triggers });
|
||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,27 @@ describe('addAutomation()', () => {
|
|||||||
const automations = getAutomations();
|
const automations = getAutomations();
|
||||||
expect(automations[automation.id]).toMatchObject(testData);
|
expect(automations[automation.id]).toMatchObject(testData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('creates an automation and its global triggers together', async () => {
|
||||||
|
const automation = await addAutomation({ title: 'triggered', filterRule: 'all', filters: [], outputs: [] }, [
|
||||||
|
{ title: 'triggered — On Start', trigger: TimerLifeCycle.onStart },
|
||||||
|
{ title: 'triggered — On Finish', trigger: TimerLifeCycle.onFinish },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getAutomations()[automation.id]).toEqual(automation);
|
||||||
|
expect(getAutomationTriggers()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'triggered — On Start',
|
||||||
|
trigger: TimerLifeCycle.onStart,
|
||||||
|
automationId: automation.id,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
title: 'triggered — On Finish',
|
||||||
|
trigger: TimerLifeCycle.onFinish,
|
||||||
|
automationId: automation.id,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('editAutomation()', () => {
|
describe('editAutomation()', () => {
|
||||||
@@ -182,6 +203,19 @@ describe('editAutomation()', () => {
|
|||||||
outputs: expect.any(Array),
|
outputs: expect.any(Array),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replaces lifecycle triggers with the automation update', async () => {
|
||||||
|
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id });
|
||||||
|
await addTrigger({ title: 'On Finish', trigger: TimerLifeCycle.onFinish, automationId: firstAutomation.id });
|
||||||
|
|
||||||
|
await editAutomation(firstAutomation.id, { title: 'edited-title', filterRule: 'all', filters: [], outputs: [] }, [
|
||||||
|
{ title: 'edited-title — On Danger', trigger: TimerLifeCycle.onDanger },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getAutomationTriggers()).toEqual([
|
||||||
|
expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onDanger }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteAutomation()', () => {
|
describe('deleteAutomation()', () => {
|
||||||
@@ -225,4 +259,44 @@ describe('deleteAutomation()', () => {
|
|||||||
const removed = getAutomations();
|
const removed = getAutomations();
|
||||||
expect(Object.keys(removed).length).toEqual(0);
|
expect(Object.keys(removed).length).toEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('takes the automation global triggers with it, and leaves the others alone', async () => {
|
||||||
|
const doomed = Object.keys(getAutomations())[0];
|
||||||
|
const survivor = await addAutomation({ title: 'survivor', filterRule: 'all', filters: [], outputs: [] });
|
||||||
|
|
||||||
|
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId: doomed });
|
||||||
|
await addTrigger({ title: 'on finish', trigger: TimerLifeCycle.onFinish, automationId: doomed });
|
||||||
|
await addTrigger({ title: 'keep me', trigger: TimerLifeCycle.onStart, automationId: survivor.id });
|
||||||
|
|
||||||
|
await deleteAutomation({}, doomed);
|
||||||
|
|
||||||
|
// a trigger pointing at nothing never fires, so it must not outlive its automation
|
||||||
|
expect(getAutomationTriggers()).toEqual([expect.objectContaining({ title: 'keep me' })]);
|
||||||
|
expect(Object.keys(getAutomations())).toEqual([survivor.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an automation attached to an event, and keeps its triggers', async () => {
|
||||||
|
const automationId = Object.keys(getAutomations())[0];
|
||||||
|
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId });
|
||||||
|
|
||||||
|
const projectRundowns: ProjectRundowns = {
|
||||||
|
'rundown-1': {
|
||||||
|
id: 'rundown-1',
|
||||||
|
title: 'Rundown 1',
|
||||||
|
order: ['1'],
|
||||||
|
flatOrder: ['1'],
|
||||||
|
entries: {
|
||||||
|
'1': makeOntimeEvent({
|
||||||
|
id: '1',
|
||||||
|
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
revision: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
|
||||||
|
expect(getAutomationTriggers()).toHaveLength(1);
|
||||||
|
expect(Object.keys(getAutomations())).toEqual([automationId]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ describe('parseTemplateNested()', () => {
|
|||||||
const result = parseTemplateNested(testString, mockState);
|
const result = parseTemplateNested(testString, mockState);
|
||||||
expect(result).toStrictEqual(expected);
|
expect(result).toStrictEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('URL-encodes an explicitly marked variable', () => {
|
||||||
|
const result = parseTemplateNested('http://example.com/?title={{url:event.title}}', {
|
||||||
|
event: { title: 'Keynote & Roadmap #1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('http://example.com/?title=Keynote%20%26%20Roadmap%20%231');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseNestedTemplate() -> resolveAliasData()', () => {
|
describe('parseNestedTemplate() -> resolveAliasData()', () => {
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import { parseOutput } from '../automation.validation.js';
|
import { parseAutomationTriggers, parseOutput } from '../automation.validation.js';
|
||||||
|
|
||||||
|
describe('parseAutomationTriggers', () => {
|
||||||
|
it('accepts trigger descriptors without an automation ID', () => {
|
||||||
|
expect(parseAutomationTriggers([{ title: 'Start', trigger: 'onStart' }])).toEqual([
|
||||||
|
{ title: 'Start', trigger: 'onStart' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects incomplete or unknown trigger descriptors', () => {
|
||||||
|
expect(() => parseAutomationTriggers([{ trigger: 'onStart' }])).toThrow();
|
||||||
|
expect(() => parseAutomationTriggers([{ title: 'Start', trigger: 'unknown' }])).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseOutput', () => {
|
describe('parseOutput', () => {
|
||||||
describe('handles OSC outputs', () => {
|
describe('handles OSC outputs', () => {
|
||||||
it('parses a valid payload', () => {
|
it('parses a valid payload', () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
type: 'osc',
|
type: 'osc',
|
||||||
targetIP: 'localhost',
|
targetIP: ' qlab ',
|
||||||
targetPort: 1234,
|
targetPort: 1234,
|
||||||
address: '/test',
|
address: '/test',
|
||||||
args: 'test',
|
args: 'test',
|
||||||
};
|
};
|
||||||
const result = parseOutput(payload);
|
const result = parseOutput(payload);
|
||||||
expect(result).toStrictEqual(payload);
|
expect(result).toStrictEqual({ ...payload, targetIP: 'qlab' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on a invalid payload', () => {
|
it('throws on a invalid payload', () => {
|
||||||
@@ -24,6 +37,33 @@ describe('parseOutput', () => {
|
|||||||
};
|
};
|
||||||
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid targets and ports', () => {
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: 'not a host', targetPort: 53000, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC target');
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 70000, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC port');
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 0, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC port');
|
||||||
|
expect(() =>
|
||||||
|
parseOutput({ type: 'osc', targetIP: '::1', targetPort: 53000, address: '/test', args: '' }),
|
||||||
|
).toThrow('Invalid OSC target');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows runtime templates in a target hostname', () => {
|
||||||
|
expect(
|
||||||
|
parseOutput({
|
||||||
|
type: 'osc',
|
||||||
|
targetIP: '{{eventNow.custom.oscTarget}}',
|
||||||
|
targetPort: 53000,
|
||||||
|
address: '/test',
|
||||||
|
args: '',
|
||||||
|
}),
|
||||||
|
).toMatchObject({ targetIP: '{{eventNow.custom.oscTarget}}' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
describe('handles HTTP outputs', () => {
|
describe('handles HTTP outputs', () => {
|
||||||
it('parses a valid payload', () => {
|
it('parses a valid payload', () => {
|
||||||
@@ -41,6 +81,18 @@ describe('parseOutput', () => {
|
|||||||
};
|
};
|
||||||
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects malformed and unsupported URLs', () => {
|
||||||
|
expect(() => parseOutput({ type: 'http', url: 'localhost:3000/hook' })).toThrow('Invalid HTTP URL');
|
||||||
|
expect(() => parseOutput({ type: 'http', url: 'ftp://example.com/hook' })).toThrow('Invalid HTTP URL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows runtime templates in HTTP URLs', () => {
|
||||||
|
expect(parseOutput({ type: 'http', url: 'http://{{eventNow.customFields.webhookHost}}/hook' })).toEqual({
|
||||||
|
type: 'http',
|
||||||
|
url: 'http://{{eventNow.customFields.webhookHost}}/hook',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
describe('handles Ontime outputs', () => {
|
describe('handles Ontime outputs', () => {
|
||||||
it('parses a valid payload', () => {
|
it('parses a valid payload', () => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const { send } = vi.hoisted(() => ({ send: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('node:dgram', () => ({
|
||||||
|
createSocket: vi.fn(() => ({ send })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { emitOSC } from '../clients/osc.client.js';
|
||||||
|
|
||||||
|
describe('emitOSC()', () => {
|
||||||
|
it('resolves runtime templates in the target host', () => {
|
||||||
|
emitOSC(
|
||||||
|
{
|
||||||
|
type: 'osc',
|
||||||
|
targetIP: '{{eventNow.custom.oscTarget}}',
|
||||||
|
targetPort: 53000,
|
||||||
|
address: '/cue/start',
|
||||||
|
args: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventNow: { custom: { oscTarget: 'qlab' } },
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledWith(expect.anything(), 0, expect.any(Number), 53000, 'qlab', expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,12 +75,18 @@ export async function deleteTrigger(req: Request, res: Response<void | ErrorResp
|
|||||||
|
|
||||||
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const newAutomation = await automationDao.addAutomation({
|
const newAutomation = await automationDao.addAutomation(
|
||||||
title: req.body.title,
|
{
|
||||||
filterRule: req.body.filterRule,
|
title: req.body.title,
|
||||||
filters: req.body.filters,
|
filterRule: req.body.filterRule,
|
||||||
outputs: req.body.outputs,
|
filters: req.body.filters,
|
||||||
});
|
outputs: req.body.outputs,
|
||||||
|
},
|
||||||
|
req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({
|
||||||
|
title: title.trim(),
|
||||||
|
trigger,
|
||||||
|
})),
|
||||||
|
);
|
||||||
res.status(201).send(newAutomation);
|
res.status(201).send(newAutomation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
@@ -90,12 +96,19 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
|
|||||||
|
|
||||||
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||||
try {
|
try {
|
||||||
const newAutomation = await automationDao.editAutomation(req.params.id, {
|
const newAutomation = await automationDao.editAutomation(
|
||||||
title: req.body.title,
|
req.params.id,
|
||||||
filterRule: req.body.filterRule,
|
{
|
||||||
filters: req.body.filters,
|
title: req.body.title,
|
||||||
outputs: req.body.outputs,
|
filterRule: req.body.filterRule,
|
||||||
});
|
filters: req.body.filters,
|
||||||
|
outputs: req.body.outputs,
|
||||||
|
},
|
||||||
|
req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({
|
||||||
|
title: title.trim(),
|
||||||
|
trigger,
|
||||||
|
})),
|
||||||
|
);
|
||||||
res.status(200).send(newAutomation);
|
res.status(200).send(newAutomation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
@@ -110,7 +123,7 @@ export async function deleteAutomation(req: Request, res: Response<void | ErrorR
|
|||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
res.status(400).send({ message });
|
res.status(message.startsWith('Unable to delete automation used in rundown:') ? 409 : 400).send({ message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
Automation,
|
Automation,
|
||||||
AutomationDTO,
|
AutomationDTO,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
|
AutomationTriggerDTO,
|
||||||
NormalisedAutomation,
|
NormalisedAutomation,
|
||||||
ProjectRundowns,
|
ProjectRundowns,
|
||||||
Trigger,
|
Trigger,
|
||||||
@@ -113,28 +114,71 @@ export async function deleteAll() {
|
|||||||
/**
|
/**
|
||||||
* Adds a validated automation to the store
|
* Adds a validated automation to the store
|
||||||
*/
|
*/
|
||||||
export async function addAutomation(newAutomation: AutomationDTO): Promise<Automation> {
|
export async function addAutomation(
|
||||||
const automations = getAutomations();
|
newAutomation: AutomationDTO,
|
||||||
|
newTriggers: AutomationTriggerDTO[] = [],
|
||||||
|
): Promise<Automation> {
|
||||||
|
const automations = { ...getAutomations() };
|
||||||
const id = getUniqueAutomationId(automations);
|
const id = getUniqueAutomationId(automations);
|
||||||
automations[id] = { ...newAutomation, id };
|
automations[id] = { ...newAutomation, id };
|
||||||
await saveChanges({ automations });
|
|
||||||
|
const triggers = [...getAutomationTriggers()];
|
||||||
|
for (const newTrigger of newTriggers) {
|
||||||
|
triggers.push({ ...newTrigger, id: getUniqueTriggerId(triggers), automationId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveChanges({ automations, triggers });
|
||||||
return automations[id];
|
return automations[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing automation with a new entry
|
* Updates an existing automation with a new entry
|
||||||
*/
|
*/
|
||||||
export async function editAutomation(id: string, newAutomation: AutomationDTO): Promise<Automation> {
|
export async function editAutomation(
|
||||||
|
id: string,
|
||||||
|
newAutomation: AutomationDTO,
|
||||||
|
requestedTriggers?: AutomationTriggerDTO[],
|
||||||
|
): Promise<Automation> {
|
||||||
const automations = getAutomations();
|
const automations = getAutomations();
|
||||||
if (!Object.hasOwn(automations, id)) {
|
if (!Object.hasOwn(automations, id)) {
|
||||||
throw new Error(`Automation with id ${id} not found`);
|
throw new Error(`Automation with id ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
automations[id] = { ...newAutomation, id };
|
automations[id] = { ...newAutomation, id };
|
||||||
await saveChanges({ automations });
|
|
||||||
|
if (requestedTriggers === undefined) {
|
||||||
|
await saveChanges({ automations });
|
||||||
|
return automations[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggers = replaceAutomationTriggers(getAutomationTriggers(), id, requestedTriggers);
|
||||||
|
await saveChanges({ automations, triggers });
|
||||||
return automations[id];
|
return automations[id];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceAutomationTriggers(
|
||||||
|
triggers: Trigger[],
|
||||||
|
automationId: string,
|
||||||
|
requestedTriggers: AutomationTriggerDTO[],
|
||||||
|
): Trigger[] {
|
||||||
|
const requestedCycles = new Set(requestedTriggers.map((trigger) => trigger.trigger));
|
||||||
|
const keptTriggers = triggers.filter(
|
||||||
|
(trigger) => trigger.automationId !== automationId || requestedCycles.has(trigger.trigger),
|
||||||
|
);
|
||||||
|
const existingCycles = new Set(
|
||||||
|
keptTriggers.filter((trigger) => trigger.automationId === automationId).map((trigger) => trigger.trigger),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const trigger of requestedTriggers) {
|
||||||
|
if (!existingCycles.has(trigger.trigger)) {
|
||||||
|
keptTriggers.push({ ...trigger, id: getUniqueTriggerId(keptTriggers), automationId });
|
||||||
|
existingCycles.add(trigger.trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keptTriggers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a automation given its ID
|
* Deletes a automation given its ID
|
||||||
*/
|
*/
|
||||||
@@ -145,24 +189,18 @@ export async function deleteAutomation(projectRundowns: ProjectRundowns, automat
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevent deleting a automation that is in use in triggers
|
// prevent deleting an automation that is in use in events, the user has to unlink it there
|
||||||
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === automationId);
|
|
||||||
if (triggers.length) {
|
|
||||||
const firstTrigger = triggers[0];
|
|
||||||
const triggerTitle = firstTrigger?.title ?? 'Unknown trigger';
|
|
||||||
throw new Error(
|
|
||||||
`Unable to delete automation used in trigger ${triggerTitle}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// prevent deleting a automation that is in use in events
|
|
||||||
const isInUse = isAutomationUsed(projectRundowns, automationId);
|
const isInUse = isAutomationUsed(projectRundowns, automationId);
|
||||||
if (isInUse) {
|
if (isInUse) {
|
||||||
throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`);
|
throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// a global trigger without its automation is dead data, so it goes with it.
|
||||||
|
// Both are written in a single patch, there is no state where one outlived the other
|
||||||
|
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId !== automationId);
|
||||||
|
|
||||||
delete automations[automationId];
|
delete automations[automationId];
|
||||||
await saveChanges({ automations });
|
await saveChanges({ automations, triggers });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const placeholderRegex = /{{(.*?)}}/g;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses a templated string to values in a nested object
|
* Parses a templated string to values in a nested object
|
||||||
|
* Prefix a variable with `url:` when its value must be safe inside a URL component.
|
||||||
*/
|
*/
|
||||||
export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string {
|
export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string {
|
||||||
let parsedTemplate = template;
|
let parsedTemplate = template;
|
||||||
@@ -79,7 +80,9 @@ export function parseTemplateNested(template: string, state: object, humanReadab
|
|||||||
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const variableName = match[1];
|
const variableName = match[1];
|
||||||
const variableParts = variableName.split('.');
|
const shouldEncodeForUrl = variableName.startsWith('url:');
|
||||||
|
const propertyName = shouldEncodeForUrl ? variableName.slice(4) : variableName;
|
||||||
|
const variableParts = propertyName.split('.');
|
||||||
let value: string | undefined = undefined;
|
let value: string | undefined = undefined;
|
||||||
|
|
||||||
if (variableParts[0] === 'human') {
|
if (variableParts[0] === 'human') {
|
||||||
@@ -93,10 +96,10 @@ export function parseTemplateNested(template: string, state: object, humanReadab
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// we cast to string since this will be used in a string context
|
// we cast to string since this will be used in a string context
|
||||||
value = getPropertyFromPath(variableName, state) as string;
|
value = getPropertyFromPath(propertyName, state) as string;
|
||||||
}
|
}
|
||||||
if (value !== undefined) {
|
if (value !== undefined) {
|
||||||
parsedTemplate = parsedTemplate.replace(match[0], value);
|
parsedTemplate = parsedTemplate.replace(match[0], shouldEncodeForUrl ? encodeURIComponent(value) : value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { body, oneOf, param } from 'express-validator';
|
import { isIP } from 'node:net';
|
||||||
|
|
||||||
|
import { body, param } from 'express-validator';
|
||||||
import {
|
import {
|
||||||
Automation,
|
Automation,
|
||||||
AutomationFilter,
|
AutomationFilter,
|
||||||
AutomationOutput,
|
AutomationOutput,
|
||||||
|
AutomationTriggerDTO,
|
||||||
HTTPOutput,
|
HTTPOutput,
|
||||||
OSCOutput,
|
OSCOutput,
|
||||||
OntimeAction,
|
OntimeAction,
|
||||||
SecondarySource,
|
SecondarySource,
|
||||||
|
isTimerLifeCycle,
|
||||||
timerLifecycleValues,
|
timerLifecycleValues,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
|
|
||||||
@@ -44,11 +48,16 @@ export const validateTriggerPatch = [
|
|||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
|
export const validateAutomation = [
|
||||||
|
body().custom(parseAutomation),
|
||||||
|
body('triggers').optional().custom(parseAutomationTriggers),
|
||||||
|
requestValidationFunction,
|
||||||
|
];
|
||||||
|
|
||||||
export const validateAutomationPatch = [
|
export const validateAutomationPatch = [
|
||||||
param('id').isString().notEmpty(),
|
param('id').isString().notEmpty(),
|
||||||
body().custom(parseAutomation),
|
body().custom(parseAutomation),
|
||||||
|
body('triggers').optional().custom(parseAutomationTriggers),
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
@@ -75,6 +84,20 @@ export function parseAutomation(maybeAutomation: unknown): Automation {
|
|||||||
return maybeAutomation as Automation;
|
return maybeAutomation as Automation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAutomationTriggers(maybeTriggers: unknown): AutomationTriggerDTO[] {
|
||||||
|
assert.isArray(maybeTriggers);
|
||||||
|
return maybeTriggers.map((maybeTrigger) => {
|
||||||
|
assert.isObject(maybeTrigger);
|
||||||
|
assert.hasKeys(maybeTrigger, ['title', 'trigger']);
|
||||||
|
assert.isString(maybeTrigger.title);
|
||||||
|
assert.isString(maybeTrigger.trigger);
|
||||||
|
if (!maybeTrigger.title.trim() || !isTimerLifeCycle(maybeTrigger.trigger)) {
|
||||||
|
throw new Error('Invalid automation trigger');
|
||||||
|
}
|
||||||
|
return { title: maybeTrigger.title.trim(), trigger: maybeTrigger.trigger };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
|
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
|
||||||
filters.forEach((condition) => {
|
filters.forEach((condition) => {
|
||||||
assert.isObject(condition);
|
assert.isObject(condition);
|
||||||
@@ -103,33 +126,7 @@ function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const validateTestPayload = [
|
export const validateTestPayload = [
|
||||||
body('type').isIn(['osc', 'http', 'ontime']),
|
body().custom(parseOutput),
|
||||||
|
|
||||||
// validation for OSC message
|
|
||||||
oneOf([
|
|
||||||
body('targetIP').if(body('type').equals('osc')).isIP(),
|
|
||||||
body('targetIP').if(body('type').equals('osc')).isFQDN(),
|
|
||||||
body('targetIP').if(body('type').equals('osc')).equals('localhost'),
|
|
||||||
]),
|
|
||||||
body('targetPort').if(body('type').equals('osc')).isPort(),
|
|
||||||
body('address').if(body('type').equals('osc')).isString().trim(),
|
|
||||||
body('args').if(body('type').equals('osc')).isString().trim(),
|
|
||||||
|
|
||||||
// validation for HTTP message
|
|
||||||
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
|
|
||||||
|
|
||||||
// validation for Ontime actions
|
|
||||||
body('action').if(body('type').equals('ontime')).isString().trim(),
|
|
||||||
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
|
|
||||||
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
|
|
||||||
body('visible').if(body('type').equals('ontime')).optional().isBoolean(),
|
|
||||||
// secondary source can be a enum case or null to clear it
|
|
||||||
body('secondarySource')
|
|
||||||
.if(body('type').equals('ontime'))
|
|
||||||
.optional({ nullable: true })
|
|
||||||
.if((value) => value !== null)
|
|
||||||
.isString()
|
|
||||||
.trim(),
|
|
||||||
|
|
||||||
requestValidationFunction,
|
requestValidationFunction,
|
||||||
];
|
];
|
||||||
@@ -163,9 +160,25 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
|
|||||||
assert.isString(maybeOSCOutput.address);
|
assert.isString(maybeOSCOutput.address);
|
||||||
assert.isString(maybeOSCOutput.args);
|
assert.isString(maybeOSCOutput.args);
|
||||||
|
|
||||||
|
const targetIP = maybeOSCOutput.targetIP.trim();
|
||||||
|
const target = replaceAutomationTemplates(targetIP, 'template.local');
|
||||||
|
const isHostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i.test(
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
if (isIP(target) !== 4 && !isHostname) {
|
||||||
|
throw new Error('Invalid OSC target');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(maybeOSCOutput.targetPort) ||
|
||||||
|
maybeOSCOutput.targetPort < 1 ||
|
||||||
|
maybeOSCOutput.targetPort > 65535
|
||||||
|
) {
|
||||||
|
throw new Error('Invalid OSC port');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'osc',
|
type: 'osc',
|
||||||
targetIP: maybeOSCOutput.targetIP,
|
targetIP,
|
||||||
targetPort: maybeOSCOutput.targetPort,
|
targetPort: maybeOSCOutput.targetPort,
|
||||||
address: maybeOSCOutput.address,
|
address: maybeOSCOutput.address,
|
||||||
args: maybeOSCOutput.args,
|
args: maybeOSCOutput.args,
|
||||||
@@ -176,12 +189,25 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
|
|||||||
assert.hasKeys(maybeHTTPOutput, ['url']);
|
assert.hasKeys(maybeHTTPOutput, ['url']);
|
||||||
assert.isString(maybeHTTPOutput.url);
|
assert.isString(maybeHTTPOutput.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(replaceAutomationTemplates(maybeHTTPOutput.url, 'template'));
|
||||||
|
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
|
||||||
|
throw new Error('Invalid HTTP URL');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid HTTP URL');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'http',
|
type: 'http',
|
||||||
url: maybeHTTPOutput.url,
|
url: maybeHTTPOutput.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceAutomationTemplates(value: string, replacement: string): string {
|
||||||
|
return value.replace(/{{.*?}}/g, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||||
assert.hasKeys(maybeOntimeAction, ['action']);
|
assert.hasKeys(maybeOntimeAction, ['action']);
|
||||||
assert.isString(maybeOntimeAction.action);
|
assert.isString(maybeOntimeAction.action);
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ const udpClient = dgram.createSocket('udp4');
|
|||||||
*/
|
*/
|
||||||
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
|
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
|
||||||
const message = preparePayload(output, store);
|
const message = preparePayload(output, store);
|
||||||
emit(output.targetIP, output.targetPort, message);
|
const targetIP = parseTemplateNested(output.targetIP, store);
|
||||||
|
emit(targetIP, output.targetPort, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parses the state and prepares payload to be emitted */
|
/** Parses the state and prepares payload to be emitted */
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export type Trigger = {
|
|||||||
|
|
||||||
export type TriggerDTO = Omit<Trigger, 'id'>;
|
export type TriggerDTO = Omit<Trigger, 'id'>;
|
||||||
|
|
||||||
|
/** A global trigger whose automation ID is assigned during automation creation. */
|
||||||
|
export type AutomationTriggerDTO = Omit<TriggerDTO, 'automationId'>;
|
||||||
|
|
||||||
export type AutomationFilter = {
|
export type AutomationFilter = {
|
||||||
field: string; // this should be a key of a OntimeEvent + custom fields
|
field: string; // this should be a key of a OntimeEvent + custom fields
|
||||||
operator: 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains';
|
operator: 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains';
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type {
|
|||||||
AutomationDTO,
|
AutomationDTO,
|
||||||
AutomationFilter,
|
AutomationFilter,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
|
AutomationTriggerDTO,
|
||||||
AutomationOutput,
|
AutomationOutput,
|
||||||
FilterRule,
|
FilterRule,
|
||||||
HTTPOutput,
|
HTTPOutput,
|
||||||
|
|||||||
Reference in New Issue
Block a user