diff --git a/apps/client/src/common/api/automation.ts b/apps/client/src/common/api/automation.ts index d2384da8f..fb41105ad 100644 --- a/apps/client/src/common/api/automation.ts +++ b/apps/client/src/common/api/automation.ts @@ -80,6 +80,6 @@ export function deleteBlueprint(id: string): Promise { * HTTP request to test automation output * The return is irrelevant as we care for the resolution of the promise */ -export async function testOutput(output: AutomationOutput): Promise { - return axios.post(automationsPath, output); +export function testOutput(output: AutomationOutput): Promise { + return axios.post(`${automationsPath}/test`, output); } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx index f6cd2d769..e24323ce4 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/BlueprintForm.tsx @@ -6,13 +6,14 @@ import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; import { AutomationBlueprint, AutomationBlueprintDTO, + CustomFields, HTTPOutput, isHTTPOutput, isOSCOutput, - OSCOutput, + OntimeEvent, } from 'ontime-types'; -import { addBlueprint, editBlueprint } from '../../../../common/api/automation'; +import { addBlueprint, editBlueprint, testOutput } from '../../../../common/api/automation'; import { maybeAxiosError } from '../../../../common/api/utils'; import Tag from '../../../../common/components/tag/Tag'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; @@ -40,6 +41,7 @@ export default function BlueprintForm(props: BlueprintFormProps) { const { control, handleSubmit, + getValues, register, setError, setFocus, @@ -364,7 +366,6 @@ export default function BlueprintForm(props: BlueprintFormProps) { url?: { message?: string }; } | undefined; - const canTest = output.url; return (
HTTP @@ -387,7 +388,7 @@ export default function BlueprintForm(props: BlueprintFormProps) { {rowErrors?.url?.message} - ); } + +/** + * We use this guard to find out if the form is receiving an existing blueprint or creating a DTO + * We do this by checking whether an ID has been generated + */ +function isBlueprint(blueprint: AutomationBlueprintDTO | AutomationBlueprint): blueprint is AutomationBlueprint { + return Object.hasOwn(blueprint, 'id'); +} + +export const staticSelectProperties = [ + { value: 'id', label: 'ID' }, + { value: 'title', label: 'Title' }, + { value: 'cue', label: 'Cue' }, + { value: 'countToEnd', label: 'Count to end' }, + { value: 'isPublic', label: 'Is public' }, + { value: 'skip', label: 'Skip' }, + { value: 'note', label: 'Note' }, + { value: 'colour', label: 'Colour' }, + { value: 'endAction', label: 'End action' }, + { value: 'timerType', label: 'Timer type' }, + { value: 'timeWarning', label: 'Time warning' }, + { value: 'timeDanger', label: 'Time danger' }, +]; + +type SelectableField = { + value: keyof OntimeEvent | string; // string for custom fields + label: string; +}; + +function makeFieldList(customFields: CustomFields): SelectableField[] { + return [ + ...staticSelectProperties, + ...Object.entries(customFields).map(([key, { label }]) => ({ value: key, label: `Custom: ${label}` })), + ]; +} diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index b24b64e9b..c8cb84845 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -1,5 +1,14 @@ -import type { AutomationFilter, AutomationOutput, FilterRule, RuntimeStore, TimerLifeCycle } from 'ontime-types'; +import { + isHTTPOutput, + isOSCOutput, + type AutomationFilter, + type AutomationOutput, + type FilterRule, + type RuntimeStore, + type TimerLifeCycle, +} from 'ontime-types'; +import { getState, type RuntimeState } from '../../stores/runtimeState.js'; import { emitOSC } from './clients/osc.client.js'; import { emitHTTP } from './clients/http.client.js'; import { getAutomations, getBlueprints } from './automation.dao.js'; @@ -7,7 +16,7 @@ import { getAutomations, getBlueprints } from './automation.dao.js'; /** * Exposes a method for triggering actions based on a TimerLifeCycle event */ -export function triggerAction(event: TimerLifeCycle, state: Partial) { +export function triggerAutomations(event: TimerLifeCycle, state: RuntimeState) { const automations = getAutomations(); const triggerAutomations = automations.filter((automation) => automation.trigger === event); if (triggerAutomations.length === 0) { @@ -31,11 +40,8 @@ export function triggerAction(event: TimerLifeCycle, state: Partial) { - const success = send([payload], state); - if (!success) { - throw new Error('Failed to send output'); - } +export function testOutput(payload: AutomationOutput) { + send([payload]); } /** @@ -60,6 +66,7 @@ export function testConditions( const { field, operator, value } = filter; const fieldValue = state[field]; + // TODO: if value is empty string, the user could be meaning to check if the value does not exist switch (operator) { case 'equals': return fieldValue === value; @@ -83,17 +90,14 @@ export function testConditions( * Handles preparing and sending of the data * Returns a boolean indicating whether a message was sent */ -function send(output: AutomationOutput[], _state: Partial): boolean { +function send(output: AutomationOutput[], state?: RuntimeState) { + const stateSnapshot = state ?? getState(); output.forEach((payload) => { - if (payload.type === 'osc') { + if (isOSCOutput(payload)) { emitOSC(); - return true; } - if (payload.type === 'http') { - emitHTTP(); - return true; + if (isHTTPOutput(payload)) { + emitHTTP(payload, stateSnapshot); } - return false; }); - return true; } diff --git a/apps/server/src/api-data/automation/automation.validation.ts b/apps/server/src/api-data/automation/automation.validation.ts index 56fa5fd21..0a8f5d989 100644 --- a/apps/server/src/api-data/automation/automation.validation.ts +++ b/apps/server/src/api-data/automation/automation.validation.ts @@ -1,4 +1,11 @@ -import { AutomationBlueprint, AutomationFilter, AutomationOutput, timerLifecycleValues } from 'ontime-types'; +import { + AutomationBlueprint, + AutomationFilter, + AutomationOutput, + HTTPOutput, + OSCOutput, + timerLifecycleValues, +} from 'ontime-types'; import { Request, Response, NextFunction } from 'express'; import { body, param, validationResult } from 'express-validator'; @@ -130,26 +137,9 @@ function validateOutput(output: Array): output is AutomationOutput[] { assert.isString(type); if (type === 'osc') { - assert.hasKeys(payload, ['targetIP', 'targetPort', 'address', 'args']); - const { targetIP, targetPort, address, args } = payload; - assert.isString(targetIP); - assert.isNumber(targetPort); - assert.isString(address); - if (typeof args !== 'string' && typeof args !== 'number') { - throw new Error('Invalid automation'); - } + validateOSCOutput(payload); } else if (type === 'http') { - assert.hasKeys(payload, ['targetIP', 'address']); - const { targetIP, address } = payload; - assert.isString(targetIP); - assert.isString(address); - } else if (type === 'companion') { - assert.hasKeys(payload, ['targetIP', 'address', 'page', 'bank']); - const { targetIP, address, page, bank } = payload; - assert.isString(targetIP); - assert.isString(address); - assert.isNumber(page); - assert.isNumber(bank); + validateHttpOutput(payload); } else { throw new Error('Invalid automation'); } @@ -157,6 +147,25 @@ function validateOutput(output: Array): output is AutomationOutput[] { return true; } +function validateOSCOutput(payload: object): payload is OSCOutput { + assert.hasKeys(payload, ['targetIP', 'targetPort', 'address', 'args']); + const { targetIP, targetPort, address, args } = payload; + assert.isString(targetIP); + assert.isNumber(targetPort); + assert.isString(address); + if (typeof args !== 'string' && typeof args !== 'number') { + throw new Error('Invalid automation'); + } + return true; +} + +function validateHttpOutput(payload: object): payload is HTTPOutput { + assert.hasKeys(payload, ['url']); + const { url } = payload; + assert.isString(url); + return true; +} + export const validateTestPayload = [ body('type').exists().isIn(['osc', 'http']), @@ -164,10 +173,10 @@ export const validateTestPayload = [ body('targetIP').if(body('type').equals('osc')).isIP(), body('targetPort').if(body('type').equals('osc')).isPort(), body('address').if(body('type').equals('osc')).isString().trim(), - body('message').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')).isString().trim(), + body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); diff --git a/apps/server/src/api-data/automation/clients/http.client.ts b/apps/server/src/api-data/automation/clients/http.client.ts index 6028f8bf3..4dc752556 100644 --- a/apps/server/src/api-data/automation/clients/http.client.ts +++ b/apps/server/src/api-data/automation/clients/http.client.ts @@ -1,18 +1,44 @@ +import { HTTPOutput, LogOrigin } from 'ontime-types'; + +import { logger } from '../../../classes/Logger.js'; +import type { RuntimeState } from '../../../stores/runtimeState.js'; + +import { parseTemplateNested } from '../automation.utils.js'; + /** * Expose possibility to send a message using HTTP protocol */ -export function emitHTTP() { - console.log('HTTP emit not implemented'); - const payload = preparePayload(); - emit(payload); +export function emitHTTP(output: HTTPOutput, state: RuntimeState) { + const url = preparePayload(output, state); + emit(url); } /** Parses the state and prepares payload to be emitted */ -function preparePayload() { - return; +function preparePayload(output: HTTPOutput, state: RuntimeState): string { + const parsedUrl = parseTemplateNested(output.url, state); + return parsedUrl; } /** Emits message over transport */ -function emit(_payload) { - return; +async function emit(url: string) { + logger.info(LogOrigin.Rx, `Sending HTTP: ${url}`); + try { + const response = await fetch(url); + if (!response.ok) { + if (response.status >= 500 && response.status < 600) { + logger.warning(LogOrigin.Tx, `HTTP Integration: Server refused message ${response.status}`); + } else if (response.status >= 400) { + logger.warning(LogOrigin.Tx, `HTTP Integration: Failed sending message ${response.status}`); + } else { + logger.warning(LogOrigin.Tx, `HTTP Integration: Failed sending message ${response.status}`); + } + } + } catch (error) { + if (!(error instanceof Error)) { + logger.warning(LogOrigin.Tx, `HTTP Integration: Failed sending message ${error}`); + return; + } + + logger.warning(LogOrigin.Tx, `HTTP Integration: ${error.name} ${error.message}`); + } } diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts index 7bc2b620e..20c55e0ba 100644 --- a/apps/server/src/stores/EventStore.ts +++ b/apps/server/src/stores/EventStore.ts @@ -41,7 +41,7 @@ export const eventStore = { } }, poll() { - return store; + return store as RuntimeStore; }, broadcast() { socket.sendAsJson({ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4a076a031..0c1237114 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -105,5 +105,7 @@ export { isPlayableEvent, isOntimeCycle, isKeyOfType, + isOSCOutput, + isHTTPOutput, } from './utils/guards.js'; export type { MaybeNumber, MaybeString } from './utils/utils.type.js'; diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index 1741c26c5..272a06414 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,3 +1,4 @@ +import type { AutomationOutput, HTTPOutput, OSCOutput } from '../definitions/core/Automation.type.js'; import type { OntimeBlock, OntimeDelay, OntimeEvent, PlayableEvent } from '../definitions/core/OntimeEvent.type.js'; import { SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; import type { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; @@ -32,3 +33,11 @@ export function isOntimeCycle(maybeCycle: unknown): maybeCycle is TimerLifeCycle if (typeof maybeCycle !== 'string') return false; return Object.values(TimerLifeCycle).includes(maybeCycle as TimerLifeCycle); } + +export function isOSCOutput(output: AutomationOutput): output is OSCOutput { + return output.type === 'osc'; +} + +export function isHTTPOutput(output: AutomationOutput): output is HTTPOutput { + return output.type === 'http'; +}