From 53963a9ad7c60d25ea59201d321333b4acc004c6 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Sun, 11 Feb 2024 21:25:23 +0100 Subject: [PATCH] Integrations data (#770) * feat: OSC settings * feat: HTTP settings --- apps/client/src/common/api/ontimeApi.ts | 27 +- .../src/common/hooks-query/useHttpSettings.ts | 6 +- .../src/common/hooks-query/useOscSettings.ts | 20 +- apps/client/src/common/models/Http.ts | 9 +- apps/client/src/common/models/Info.ts | 18 +- apps/client/src/common/models/OscSettings.ts | 23 +- .../src/common/utils/__tests__/regex.test.ts | 14 +- apps/client/src/common/utils/keyEvent.ts | 4 + apps/client/src/common/utils/regex.ts | 6 + .../app-settings/panel/Panel.module.scss | 7 +- .../app-settings/panel/PanelUtils.tsx | 30 +- .../integrations-panel/HttpIntegrations.tsx | 193 +++++++++---- .../IntegrationsPanel.module.css | 5 + .../integrations-panel/IntegrationsPanel.tsx | 24 +- .../integrations-panel/OscIntegrations.tsx | 270 ++++++++++++++---- .../integrations-panel/integrationUtils.ts | 8 + .../ProjectSettingsPanel.tsx | 3 +- apps/client/src/features/editors/Editor.tsx | 9 - apps/client/src/features/menu/MenuBar.tsx | 15 - .../integration-modal/IntegrationModal.tsx | 51 ---- .../http/HttpIntegration.tsx | 152 ---------- .../http/HttpSubscriptionRow.tsx | 94 ------ .../integration-modal/integration.utils.ts | 30 -- .../integration-modal/osc/OscIntegration.tsx | 139 --------- .../integration-modal/osc/OscSettings.tsx | 179 ------------ .../osc/OscSubscriptionRow.tsx | 91 ------ .../modals/settings-modal/AppSettings.tsx | 4 +- apps/client/src/theme/OntimeAlert.ts | 1 + apps/client/src/theme/ontimeButton.ts | 6 + apps/server/src/app.ts | 28 +- .../src/classes/data-provider/DataProvider.ts | 11 +- .../data-provider/DataProvider.utils.ts | 21 +- .../__tests__/DataProvider.test.ts | 63 ++-- .../src/controllers/ontimeController.ts | 60 +--- .../controllers/ontimeController.validate.ts | 50 +--- apps/server/src/models/dataModel.ts | 18 +- apps/server/src/routes/ontimeRouter.ts | 7 +- .../integration-service/HttpIntegration.ts | 85 ++---- .../integration-service/IIntegration.ts | 21 +- .../integration-service/IntegrationService.ts | 5 +- .../integration-service/OscIntegration.ts | 137 ++++----- .../utils/__tests__/parserFunctions.test.ts | 210 ++++---------- apps/server/src/utils/backend.types.ts | 1 + apps/server/src/utils/parserFunctions.ts | 102 ++----- apps/server/test-db/db.json | 23 +- demo-db/db.json | 24 +- e2e/tests/fixtures/test-db.json | 24 +- .../src/definitions/core/HttpSettings.type.ts | 7 +- .../src/definitions/core/OscSettings.type.ts | 7 +- .../src/definitions/core/Subscription.type.ts | 3 - packages/types/src/index.ts | 11 +- packages/types/src/utils/guards.ts | 6 + 52 files changed, 742 insertions(+), 1620 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts delete mode 100644 apps/client/src/features/modals/integration-modal/IntegrationModal.tsx delete mode 100644 apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx delete mode 100644 apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx delete mode 100644 apps/client/src/features/modals/integration-modal/integration.utils.ts delete mode 100644 apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx delete mode 100644 apps/client/src/features/modals/integration-modal/osc/OscSettings.tsx delete mode 100644 apps/client/src/features/modals/integration-modal/osc/OscSubscriptionRow.tsx create mode 100644 apps/server/src/utils/backend.types.ts delete mode 100644 packages/types/src/definitions/core/Subscription.type.ts diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index c90cb3b15..389df3522 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -7,7 +7,6 @@ import { MessageResponse, OntimeRundown, OSCSettings, - OscSubscription, ProjectData, ProjectFileListResponse, Settings, @@ -107,6 +106,14 @@ export async function getOSC(): Promise { return res.data; } +/** + * @description HTTP request to mutate osc settings + * @return {Promise} + */ +export async function postOSC(data: OSCSettings): Promise> { + return axios.post(`${ontimeURL}/osc`, data); +} + /** * @description HTTP request to retrieve http settings * @return {Promise} @@ -120,26 +127,10 @@ export async function getHTTP(): Promise { * @description HTTP request to mutate http settings * @return {Promise} */ -export async function postHTTP(data: HttpSettings) { +export async function postHTTP(data: HttpSettings): Promise> { return axios.post(`${ontimeURL}/http`, data); } -/** - * @description HTTP request to mutate osc settings - * @return {Promise} - */ -export async function postOSC(data: OSCSettings) { - return axios.post(`${ontimeURL}/osc`, data); -} - -/** - * @description HTTP request to mutate osc subscriptions - * @return {Promise} - */ -export async function postOscSubscriptions(data: OscSubscription) { - return axios.post(`${ontimeURL}/osc-subscriptions`, data); -} - /** * @description HTTP request to download db in CSV format */ diff --git a/apps/client/src/common/hooks-query/useHttpSettings.ts b/apps/client/src/common/hooks-query/useHttpSettings.ts index 6435eb4fb..bdfdcea41 100644 --- a/apps/client/src/common/hooks-query/useHttpSettings.ts +++ b/apps/client/src/common/hooks-query/useHttpSettings.ts @@ -1,5 +1,4 @@ import { useMutation, useQuery } from '@tanstack/react-query'; -import { HttpSettings } from 'ontime-types'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { HTTP_SETTINGS } from '../api/apiConstants'; @@ -20,13 +19,16 @@ export function useHttpSettings() { }); // we need to jump through some hoops because of the type op port - return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch }; + return { data: data ?? httpPlaceholder, status, isFetching, isError, refetch }; } export function usePostHttpSettings() { const { isPending, mutateAsync } = useMutation({ mutationFn: postHTTP, onError: (error) => logAxiosError('Error saving HTTP settings', error), + onSuccess: (res) => { + ontimeQueryClient.setQueryData(HTTP_SETTINGS, res.data); + }, onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }), }); return { isPending, mutateAsync }; diff --git a/apps/client/src/common/hooks-query/useOscSettings.ts b/apps/client/src/common/hooks-query/useOscSettings.ts index 703a64edf..11c154ae1 100644 --- a/apps/client/src/common/hooks-query/useOscSettings.ts +++ b/apps/client/src/common/hooks-query/useOscSettings.ts @@ -3,17 +3,14 @@ import { useMutation, useQuery } from '@tanstack/react-query'; import { queryRefetchIntervalSlow } from '../../ontimeConfig'; import { OSC_SETTINGS } from '../api/apiConstants'; import { logAxiosError } from '../api/apiUtils'; -import { getOSC, postOSC, postOscSubscriptions } from '../api/ontimeApi'; +import { getOSC, postOSC } from '../api/ontimeApi'; import { oscPlaceholderSettings } from '../models/OscSettings'; import { ontimeQueryClient } from '../queryClient'; export default function useOscSettings() { const { data, status, isFetching, isError, refetch } = useQuery({ queryKey: OSC_SETTINGS, - queryFn: async () => { - const oscData = await getOSC(); - return { ...oscData, portIn: String(oscData.portIn), portOut: String(oscData.portOut) }; - }, + queryFn: getOSC, placeholderData: oscPlaceholderSettings, retry: 5, retryDelay: (attempt: number) => attempt * 2500, @@ -28,16 +25,9 @@ export function useOscSettingsMutation() { const { isPending, mutateAsync } = useMutation({ mutationFn: postOSC, onError: (error) => logAxiosError('Error saving OSC settings', error), - onSuccess: (res) => ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data), - onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), - }); - return { isPending, mutateAsync }; -} - -export function usePostOscSubscriptions() { - const { isPending, mutateAsync } = useMutation({ - mutationFn: postOscSubscriptions, - onError: (error) => logAxiosError('Error saving OSC settings', error), + onSuccess: (res) => { + ontimeQueryClient.setQueryData(OSC_SETTINGS, res.data); + }, onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: OSC_SETTINGS }), }); return { isPending, mutateAsync }; diff --git a/apps/client/src/common/models/Http.ts b/apps/client/src/common/models/Http.ts index f2393a1dd..0534c7f72 100644 --- a/apps/client/src/common/models/Http.ts +++ b/apps/client/src/common/models/Http.ts @@ -2,12 +2,5 @@ import { HttpSettings } from 'ontime-types'; export const httpPlaceholder: HttpSettings = { enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onUpdate: [], - onPause: [], - onStop: [], - onFinish: [], - }, + subscriptions: [], }; diff --git a/apps/client/src/common/models/Info.ts b/apps/client/src/common/models/Info.ts index f066f3290..481d05991 100644 --- a/apps/client/src/common/models/Info.ts +++ b/apps/client/src/common/models/Info.ts @@ -1,20 +1,6 @@ -import { GetInfo, OSCSettings } from 'ontime-types'; +import { GetInfo } from 'ontime-types'; -export const oscPlaceholderSettings: OSCSettings = { - portIn: 0, - portOut: 0, - targetIP: '', - enabledIn: false, - enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, -}; +import { oscPlaceholderSettings } from './OscSettings'; export const ontimePlaceholderInfo: GetInfo = { networkInterfaces: [], diff --git a/apps/client/src/common/models/OscSettings.ts b/apps/client/src/common/models/OscSettings.ts index 9c86368b0..365b9ae8c 100644 --- a/apps/client/src/common/models/OscSettings.ts +++ b/apps/client/src/common/models/OscSettings.ts @@ -1,23 +1,10 @@ import { OSCSettings } from 'ontime-types'; -// in the placeholder, we pass strings to satisfy input type -export interface PlaceholderSettings extends Omit { - portIn: string; - portOut: string; -} - -export const oscPlaceholderSettings: PlaceholderSettings = { - portIn: '', - portOut: '', - targetIP: '', +export const oscPlaceholderSettings: OSCSettings = { + portIn: 8888, + portOut: 9999, + targetIP: '127.0.0.1', enabledIn: false, enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, + subscriptions: [], }; diff --git a/apps/client/src/common/utils/__tests__/regex.test.ts b/apps/client/src/common/utils/__tests__/regex.test.ts index 84c65349d..ebfa24983 100644 --- a/apps/client/src/common/utils/__tests__/regex.test.ts +++ b/apps/client/src/common/utils/__tests__/regex.test.ts @@ -1,4 +1,4 @@ -import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex'; +import { isIPAddress, isOnlyNumbers, startsWithHttp, startsWithSlash } from '../regex'; describe('simple tests for regex', () => { test('isOnlyNumbers', () => { @@ -36,4 +36,16 @@ describe('simple tests for regex', () => { expect(startsWithHttp.test(t)).toBe(false); }); }); + + test('startsWithSlash', () => { + const right = ['//test']; + const wrong = ['testing', '123.0.1']; + + right.forEach((t) => { + expect(startsWithSlash.test(t)).toBe(true); + }); + wrong.forEach((t) => { + expect(startsWithSlash.test(t)).toBe(false); + }); + }); }); diff --git a/apps/client/src/common/utils/keyEvent.ts b/apps/client/src/common/utils/keyEvent.ts index 1a6cc361a..30bf3c6e1 100644 --- a/apps/client/src/common/utils/keyEvent.ts +++ b/apps/client/src/common/utils/keyEvent.ts @@ -3,3 +3,7 @@ import { KeyboardEvent } from 'react'; export function isKeyEnter(event: KeyboardEvent): boolean { return event.key === 'Enter'; } + +export function isKeyEscape(event: KeyboardEvent): boolean { + return event.key === 'Escape'; +} diff --git a/apps/client/src/common/utils/regex.ts b/apps/client/src/common/utils/regex.ts index 0d31fa1d6..9f9d0f2bc 100644 --- a/apps/client/src/common/utils/regex.ts +++ b/apps/client/src/common/utils/regex.ts @@ -1,3 +1,9 @@ +/** + * Simple regex patterns for common use cases + * mostly used in form validation + */ + export const isOnlyNumbers = /^\d+$/; export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; export const startsWithHttp = /^http:\/\//; +export const startsWithSlash = /^\//; diff --git a/apps/client/src/features/app-settings/panel/Panel.module.scss b/apps/client/src/features/app-settings/panel/Panel.module.scss index 67010e477..2a7abdc37 100644 --- a/apps/client/src/features/app-settings/panel/Panel.module.scss +++ b/apps/client/src/features/app-settings/panel/Panel.module.scss @@ -28,7 +28,7 @@ padding: 1rem; background-color: $white-1; border: 1px solid $gray-1100; - border-radius: 0.25rem; + border-radius: 3px; } .error { @@ -87,3 +87,8 @@ font-size: calc(1rem - 2px); color: $gray-400; } + +.fieldError { + font-size: calc(1rem - 2px); + color: $red-500; +} diff --git a/apps/client/src/features/app-settings/panel/PanelUtils.tsx b/apps/client/src/features/app-settings/panel/PanelUtils.tsx index 0dcb9d8f3..5b861eeea 100644 --- a/apps/client/src/features/app-settings/panel/PanelUtils.tsx +++ b/apps/client/src/features/app-settings/panel/PanelUtils.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { HTMLAttributes, ReactNode } from 'react'; import style from './Panel.module.scss'; @@ -10,8 +10,19 @@ export function SubHeader({ children }: { children: ReactNode }) { return

{children}

; } -export function Section({ children }: { children: ReactNode }) { - return
{children}
; +type AllowedTags = 'div' | 'form'; +type SectionProps = { + as?: C; + children: ReactNode; +} & JSX.IntrinsicElements[C]; + +export function Section({ as, children, ...props }: SectionProps) { + const Element = as ?? 'div'; + return ( + )}> + {children} + + ); } export function Paragraph({ children }: { children: ReactNode }) { @@ -34,11 +45,20 @@ export function ListItem({ children }: { children: ReactNode }) { return
  • {children}
  • ; } -export function Field({ title, description }: { title: string; description: string }) { +export function Field({ title, description, error }: { title: string; description: string; error?: string }) { return (
    {title} - {description &&
    {description}
    } + {error && {error}} + {!error && description && {description}}
    ); } + +export function Description({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + +export function Error({ children }: { children: ReactNode }) { + return
    {children}
    ; +} diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx index 86ab880d3..2e9b29e1e 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/HttpIntegrations.tsx @@ -1,29 +1,94 @@ +import { useFieldArray, useForm } from 'react-hook-form'; import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { HttpSettings } from 'ontime-types'; +import { generateId } from 'ontime-utils'; +import { maybeAxiosError } from '../../../../common/api/apiUtils'; +import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings'; +import { isKeyEscape } from '../../../../common/utils/keyEvent'; +import { startsWithHttp } from '../../../../common/utils/regex'; import * as Panel from '../PanelUtils'; -import { cycles } from './IntegrationsPanel'; +import { cycles } from './integrationUtils'; import style from './IntegrationsPanel.module.css'; -const demoIntegrations = [ - { id: 1, enabled: true, cycle: 'onLoad', message: '/ontime/scene/1' }, - { id: 2, enabled: true, cycle: 'onLoad', message: '/ontime/scene/2' }, - { id: 3, enabled: true, cycle: 'onLoad', message: '/ontime/scene/3' }, - { id: 4, enabled: true, cycle: 'onLoad', message: '/ontime/scene/4' }, -]; - export default function HttpIntegrations() { + const { data } = useHttpSettings(); + const { mutateAsync } = usePostHttpSettings(); + + const { + control, + handleSubmit, + reset, + register, + setError, + formState: { errors, isSubmitting, isDirty, isValid }, + } = useForm({ + mode: 'onBlur', + defaultValues: data, + values: data, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const { fields, prepend, remove } = useFieldArray({ + name: 'subscriptions', + control, + }); + + const onSubmit = async (values: HttpSettings) => { + try { + await mutateAsync(values); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + }; + + const preventEscape = (event: React.KeyboardEvent) => { + if (isKeyEscape(event)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const handleAddNewSubscription = () => { + prepend({ + id: generateId(), + cycle: 'onLoad', + message: '', + enabled: false, + }); + }; + + const handleDeleteSubscription = (index: number) => { + remove(index); + }; + + const canSubmit = !isSubmitting && isDirty && isValid; + return ( - + - HTTP integrations + + HTTP settings +
    + + +
    +
    + {errors?.root && {errors.root.message}} - +
    @@ -32,51 +97,75 @@ export default function HttpIntegrations() { HTTP Integration - - Integration Settings for OSC protocol - - - - Enabled - Cycle - Message - - - - - {demoIntegrations.map((integration) => ( - - - - - - - - - - - - } - aria-label='Delete entry' - /> - + {fields.length > 0 && ( + + + + Enabled + Cycle + Message + - ))} - - + + + {fields.map((integration, index) => { + // @ts-expect-error -- not sure why it is not finding the type, it is ok + const maybeError = errors.subscriptions?.[index]?.message?.message; + return ( + + + + + + + + + + {maybeError && {maybeError}} + + + } + aria-label='Delete entry' + onClick={() => handleDeleteSubscription(index)} + /> + + + ); + })} + + + )}
    diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css index eeac7f0f0..acae1956c 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css +++ b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.module.css @@ -5,3 +5,8 @@ .fitContents { width: max-content !important; /* override chakra */ } + +.flex { + display: flex; + gap: 1rem; +} diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx index 2a493c4f7..ed3be56f8 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/IntegrationsPanel.tsx @@ -1,21 +1,29 @@ +import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; + +import ExternalLink from '../../../../common/components/external-link/ExternalLink'; import * as Panel from '../PanelUtils'; import HttpIntegrations from './HttpIntegrations'; import OscIntegrations from './OscIntegrations'; -export const cycles = [ - { id: 1, label: 'On Load', value: 'onLoad' }, - { id: 2, label: 'On Start', value: 'onStart' }, - { id: 3, label: 'On Pause', value: 'onPause' }, - { id: 4, label: 'On Stop', value: 'onStop' }, - { id: 5, label: 'Every second', value: 'onUpdate' }, - { id: 6, label: 'On Finish', value: 'onFinish' }, -]; +const integrationDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations'; export default function IntegrationsPanel() { return ( <> Integration settings + + + + + Integrations allow Ontime to receive commands or send its data to other systems in your workflow.
    {' '} +
    + Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets.
    + WebSockets are used for Ontime and cannot be configured independently.
    + See the docs +
    +
    +
    diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx index 29ada7acc..a4f2e924e 100644 --- a/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx +++ b/apps/client/src/features/app-settings/panel/integrations-panel/OscIntegrations.tsx @@ -1,114 +1,258 @@ +import { useFieldArray, useForm } from 'react-hook-form'; import { Button, IconButton, Input, Select, Switch } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; +import { OSCSettings } from 'ontime-types'; +import { generateId } from 'ontime-utils'; +import { maybeAxiosError } from '../../../../common/api/apiUtils'; +import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; +import { isKeyEscape } from '../../../../common/utils/keyEvent'; +import { isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex'; import * as Panel from '../PanelUtils'; -import { cycles } from './IntegrationsPanel'; +import { cycles } from './integrationUtils'; import style from './IntegrationsPanel.module.css'; -const demoIntegrations = [ - { id: 1, enabled: true, cycle: 'onLoad', message: '/ontime/scene/1' }, - { id: 2, enabled: true, cycle: 'onLoad', message: '/ontime/scene/2' }, - { id: 3, enabled: true, cycle: 'onLoad', message: '/ontime/scene/3' }, - { id: 4, enabled: true, cycle: 'onLoad', message: '/ontime/scene/4' }, -]; - export default function OscIntegrations() { + const { data } = useOscSettings(); + const { mutateAsync } = useOscSettingsMutation(); + + const { + control, + handleSubmit, + reset, + register, + setError, + formState: { errors, isSubmitting, isDirty, isValid }, + } = useForm({ + mode: 'onBlur', + defaultValues: data, + values: data, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const { fields, prepend, remove } = useFieldArray({ + name: 'subscriptions', + control, + }); + + const onSubmit = async (values: OSCSettings) => { + if (values.portIn === values.portOut) { + setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' }); + return; + } + + const parsedValues = { ...values, portIn: Number(values.portIn), portOut: Number(values.portOut) }; + try { + await mutateAsync(parsedValues); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + }; + + const preventEscape = (event: React.KeyboardEvent) => { + if (isKeyEscape(event)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + + const handleAddNewSubscription = () => { + prepend({ + id: generateId(), + cycle: 'onLoad', + message: '', + enabled: false, + }); + }; + + const handleDeleteSubscription = (index: number) => { + remove(index); + }; + + const canSubmit = !isSubmitting && isDirty && isValid; + return ( - - - Open Sound Control integrations + + + + Open Sound Control settings +
    + + +
    +
    + {errors?.root && {errors.root.message}} - + - + - + - + + + + + - - - -
    OSC Integration - - - - - Enabled - Cycle - Message - - - - - {demoIntegrations.map((integration) => ( - - - - - - - - - - - - } - aria-label='Delete entry' - /> - + {fields.length > 0 && ( + + + + Enabled + Cycle + Message + - ))} - - + + + {fields.map((field, index) => { + // @ts-expect-error -- not sure why it is not finding the type, it is ok + const maybeError = errors.subscriptions?.[index]?.message?.message; + return ( + + + + + + + + + + {maybeError && {maybeError}} + + + } + aria-label='Delete entry' + onClick={() => handleDeleteSubscription(index)} + /> + + + ); + })} + + + )}
    ); diff --git a/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts b/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts new file mode 100644 index 000000000..fdd65fe4b --- /dev/null +++ b/apps/client/src/features/app-settings/panel/integrations-panel/integrationUtils.ts @@ -0,0 +1,8 @@ +export const cycles = [ + { id: 1, label: 'On Load', value: 'onLoad' }, + { id: 2, label: 'On Start', value: 'onStart' }, + { id: 3, label: 'On Pause', value: 'onPause' }, + { id: 4, label: 'On Stop', value: 'onStop' }, + { id: 5, label: 'Every second', value: 'onUpdate' }, + { id: 6, label: 'On Finish', value: 'onFinish' }, +]; diff --git a/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx b/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx index c6f06a251..56703a084 100644 --- a/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/project-settings-panel/ProjectSettingsPanel.tsx @@ -1,5 +1,4 @@ -import { IconButton } from '@chakra-ui/react'; -import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react'; +import { Alert, AlertDescription, AlertIcon, IconButton } from '@chakra-ui/react'; import { IoPencil } from '@react-icons/all-files/io5/IoPencil'; import { IoTrash } from '@react-icons/all-files/io5/IoTrash'; diff --git a/apps/client/src/features/editors/Editor.tsx b/apps/client/src/features/editors/Editor.tsx index 6aa36ba68..cf37be836 100644 --- a/apps/client/src/features/editors/Editor.tsx +++ b/apps/client/src/features/editors/Editor.tsx @@ -14,7 +14,6 @@ import styles from './Editor.module.scss'; const Rundown = lazy(() => import('../rundown/RundownExport')); const TimerControl = lazy(() => import('../control/playback/TimerControlExport')); const MessageControl = lazy(() => import('../control/message/MessageControlExport')); -const IntegrationModal = lazy(() => import('../modals/integration-modal/IntegrationModal')); const SettingsModal = lazy(() => import('../modals/settings-modal/SettingsModal')); export default function Editor() { @@ -27,11 +26,6 @@ export default function Editor() { const { isOpen: isOldSettingsOpen, onOpen: onSettingsOpen, onClose: onSettingsClose } = useDisclosure(); const { isOpen: isUploadModalOpen, onOpen: onUploadModalOpen, onClose: onUploadModalClose } = useDisclosure(); - const { - isOpen: isIntegrationModalOpen, - onOpen: onIntegrationModalOpen, - onClose: onIntegrationModalClose, - } = useDisclosure(); const { isOpen: isSheetsOpen, onOpen: onSheetsOpen, onClose: onSheetsClose } = useDisclosure(); // Set window title @@ -45,7 +39,6 @@ export default function Editor() { <> - @@ -57,8 +50,6 @@ export default function Editor() { onSettingsClose={onSettingsClose} isUploadOpen={isUploadModalOpen} onUploadOpen={onUploadModalOpen} - isIntegrationOpen={isIntegrationModalOpen} - onIntegrationOpen={onIntegrationModalOpen} openSettings={handleSettings} isSettingsOpen={isSettingsOpen} isSheetsOpen={isSheetsOpen} diff --git a/apps/client/src/features/menu/MenuBar.tsx b/apps/client/src/features/menu/MenuBar.tsx index f7ba6839a..29282251e 100644 --- a/apps/client/src/features/menu/MenuBar.tsx +++ b/apps/client/src/features/menu/MenuBar.tsx @@ -3,8 +3,6 @@ import { IconButton, MenuButton, Tooltip } from '@chakra-ui/react'; import { IoAdd } from '@react-icons/all-files/io5/IoAdd'; import { IoCloud } from '@react-icons/all-files/io5/IoCloud'; import { IoCloudOutline } from '@react-icons/all-files/io5/IoCloudOutline'; -import { IoExtensionPuzzle } from '@react-icons/all-files/io5/IoExtensionPuzzle'; -import { IoExtensionPuzzleOutline } from '@react-icons/all-files/io5/IoExtensionPuzzleOutline'; import { IoOptions } from '@react-icons/all-files/io5/IoOptions'; import { IoPlay } from '@react-icons/all-files/io5/IoPlay'; import { IoPushOutline } from '@react-icons/all-files/io5/IoPushOutline'; @@ -27,8 +25,6 @@ interface MenuBarProps { onSettingsClose: () => void; isUploadOpen: boolean; onUploadOpen: () => void; - isIntegrationOpen: boolean; - onIntegrationOpen: () => void; isSheetsOpen: boolean; onSheetsOpen: () => void; openSettings: (newTab?: string) => void; @@ -54,8 +50,6 @@ const MenuBar = (props: MenuBarProps) => { onSettingsClose, isUploadOpen, onUploadOpen, - isIntegrationOpen, - onIntegrationOpen, openSettings, isSettingsOpen, isSheetsOpen, @@ -156,15 +150,6 @@ const MenuBar = (props: MenuBarProps) => { tooltip='Sheets' aria-label='Sheets' /> - : } - className={isIntegrationOpen ? style.open : ''} - clickHandler={onIntegrationOpen} - tooltip='Integrations' - aria-label='Integrations' - /> void; -} - -const oscDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations'; - -export default function IntegrationModal(props: IntegrationModalProps) { - const { isOpen, onClose } = props; - - return ( - - -
    - Manage settings related to protocol integrations - - Read the docs - -
    - - - OSC - OSC Integration - HTTP Integration - - - - - - - - - - - - - -
    -
    - ); -} diff --git a/apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx b/apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx deleted file mode 100644 index 617005e84..000000000 --- a/apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { Switch } from '@chakra-ui/react'; -import type { HttpSettings } from 'ontime-types'; -import { TimerLifeCycle } from 'ontime-types'; - -import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings'; -import { useEmitLog } from '../../../../common/stores/logger'; -import ModalLoader from '../../modal-loader/ModalLoader'; -import OntimeModalFooter from '../../OntimeModalFooter'; -import { OntimeCycle, sectionText } from '../integration.utils'; - -import HttpSubscriptionRow from './HttpSubscriptionRow'; - -import styles from '../../Modal.module.scss'; - -export default function HttpIntegration() { - const { data, isFetching } = useHttpSettings(); - const { mutateAsync } = usePostHttpSettings(); - const { emitError } = useEmitLog(); - const { - control, - handleSubmit, - register, - reset, - formState: { isSubmitting, isDirty, isValid }, - } = useForm({ - mode: 'onBlur', - defaultValues: data, - values: data, - resetOptions: { - keepDirtyValues: true, - }, - }); - - const [showSection, setShowSection] = useState(TimerLifeCycle.onLoad); - - useEffect(() => { - if (data) { - reset(data); - } - }, [data, reset]); - - const resetForm = () => { - reset(data); - }; - - const onSubmit = async (values: HttpSettings) => { - try { - const newSettings: HttpSettings = { - enabledOut: Boolean(values.enabledOut), - subscriptions: { - onLoad: values.subscriptions.onLoad ?? [], - onStart: values.subscriptions.onStart ?? [], - onPause: values.subscriptions.onPause ?? [], - onStop: values.subscriptions.onStop ?? [], - onUpdate: values.subscriptions.onUpdate ?? [], - onFinish: values.subscriptions.onFinish ?? [], - }, - }; - - await mutateAsync(newSettings); - } catch (error) { - emitError(`Error setting HTML: ${error}`); - } - }; - - if (isFetching) { - return ; - } - - const placeholder = 'http://x.x.x.x:xxxx/api/path'; - return ( -
    -
    -
    - HTTP Output - Ontime data feedback -
    - -
    - - - - - - - - - - ); -} diff --git a/apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx b/apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx deleted file mode 100644 index 4826c76db..000000000 --- a/apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { Control, useFieldArray, UseFormRegister } from 'react-hook-form'; -import { Button, IconButton, Input, Switch } from '@chakra-ui/react'; -import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp'; -import { IoRemove } from '@react-icons/all-files/io5/IoRemove'; -import { HttpSettings, TimerLifeCycle } from 'ontime-types'; - -import { useEmitLog } from '../../../../common/stores/logger'; -import { startsWithHttp } from '../../../../common/utils/regex'; - -import styles from '../../Modal.module.scss'; - -interface SubscriptionRowProps { - cycle: TimerLifeCycle; - title: string; - subtitle: string; - visible: boolean; - setShowSection: (cycle: TimerLifeCycle) => void; - register: UseFormRegister; - control: Control; - placeholder: string; -} - -export default function SubscriptionRow(props: SubscriptionRowProps) { - const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props; - const { emitError } = useEmitLog(); - const { fields, append, remove } = useFieldArray({ - name: `subscriptions.${cycle}`, - control, - }); - - const hasTooManyOptions = fields.length >= 3; - const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`; - - const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`; - - const handleAddNew = () => { - if (hasTooManyOptions) { - emitError(`Maximum amount of ${cycle} subscriptions reached (3)`); - return; - } - append({ - message: '', - enabled: false, - }); - }; - - return ( - <> -
    setShowSection(cycle)}> -
    - {sectionTitle} - {visible && {subtitle}} -
    - -
    - {visible && ( - <> - {fields.map((subscription, index) => ( -
    - } - onClick={() => remove(index)} - aria-label='delete' - size='xs' - colorScheme='red' - /> - - -
    - ))} - - - )} - - ); -} diff --git a/apps/client/src/features/modals/integration-modal/integration.utils.ts b/apps/client/src/features/modals/integration-modal/integration.utils.ts deleted file mode 100644 index 8d35c42f7..000000000 --- a/apps/client/src/features/modals/integration-modal/integration.utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { TimerLifeCycle } from 'ontime-types'; - -export type OntimeCycle = keyof typeof TimerLifeCycle; - -export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = { - onLoad: { - title: 'On Load', - subtitle: 'Triggers when a timer is loaded', - }, - onStart: { - title: 'On Start', - subtitle: 'Triggers when a timer starts', - }, - onPause: { - title: 'On Pause', - subtitle: 'Triggers when a running timer is paused', - }, - onStop: { - title: 'On Stop', - subtitle: 'Triggers when a running timer is stopped', - }, - onUpdate: { - title: 'On Every Second', - subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)', - }, - onFinish: { - title: 'On Finish', - subtitle: 'Triggers when a running reaches 0', - }, -}; diff --git a/apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx b/apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx deleted file mode 100644 index 0cbbf5c8c..000000000 --- a/apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import type { OscSubscription } from 'ontime-types'; -import { TimerLifeCycle } from 'ontime-types'; - -import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings'; -import { useEmitLog } from '../../../../common/stores/logger'; -import ModalLoader from '../../modal-loader/ModalLoader'; -import OntimeModalFooter from '../../OntimeModalFooter'; -import { type OntimeCycle, sectionText } from '../integration.utils'; - -import OscSubscriptionRow from './OscSubscriptionRow'; - -import styles from '../../Modal.module.scss'; - -export default function OscIntegration() { - const { data, isFetching } = useOscSettings(); - const { mutateAsync } = usePostOscSubscriptions(); - const { emitError } = useEmitLog(); - const { - control, - handleSubmit, - register, - reset, - formState: { isSubmitting, isDirty, isValid }, - } = useForm({ - defaultValues: data.subscriptions, - values: data.subscriptions, - resetOptions: { - keepDirtyValues: true, - }, - }); - - const [showSection, setShowSection] = useState(TimerLifeCycle.onLoad); - - useEffect(() => { - if (data) { - reset(data.subscriptions); - } - }, [data, reset]); - - const resetForm = () => { - reset(data.subscriptions); - }; - - const onSubmit = async (values: OscSubscription) => { - try { - const subscriptions = { - onLoad: values.onLoad ?? [], - onStart: values.onStart ?? [], - onPause: values.onPause ?? [], - onStop: values.onStop ?? [], - onUpdate: values.onUpdate ?? [], - onFinish: values.onFinish ?? [], - }; - - await mutateAsync(subscriptions); - } catch (error) { - emitError(`Error setting OSC: ${error}`); - } - }; - - if (isFetching) { - return ; - } - - const placeholder = 'OSC message'; - return ( -
    - - - - - - - - - ); -} diff --git a/apps/client/src/features/modals/integration-modal/osc/OscSettings.tsx b/apps/client/src/features/modals/integration-modal/osc/OscSettings.tsx deleted file mode 100644 index 220b77b31..000000000 --- a/apps/client/src/features/modals/integration-modal/osc/OscSettings.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { FormControl, Input, Switch } from '@chakra-ui/react'; - -import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings'; -import { PlaceholderSettings } from '../../../../common/models/OscSettings'; -import { useEmitLog } from '../../../../common/stores/logger'; -import { isIPAddress, isOnlyNumbers } from '../../../../common/utils/regex'; -import ModalLoader from '../../modal-loader/ModalLoader'; -import OntimeModalFooter from '../../OntimeModalFooter'; - -import styles from '../../Modal.module.scss'; - -export default function OscSettings() { - const { data, isFetching } = useOscSettings(); - const { mutateAsync } = useOscSettingsMutation(); - const { emitError } = useEmitLog(); - const { - handleSubmit, - register, - reset, - setError, - formState: { errors, isSubmitting, isDirty, isValid }, - } = useForm({ - defaultValues: data, - values: data, - resetOptions: { - keepDirtyValues: true, - }, - }); - - useEffect(() => { - if (data) { - reset(data); - } - }, [data, reset]); - const onSubmit = async (values: PlaceholderSettings) => { - const numericPortIn = Number(values.portIn); - const numericPortOut = Number(values.portOut); - - if (numericPortIn === numericPortOut) { - setError('portIn', { message: 'OSC IN and OUT Ports cant be the same' }); - return; - } - - const parsedValues = { - ...values, - portIn: numericPortIn, - portOut: numericPortOut, - }; - - try { - await mutateAsync(parsedValues); - } catch (error) { - emitError(`Error setting OSC: ${error}`); - } - }; - - const resetForm = () => { - reset(data); - }; - - if (isFetching) { - return ; - } - - return ( -
    -
    -
    - OSC Input - Control Ontime with OSC -
    - -
    - - - - - -
    -
    -
    - - OSC Output - - Ontime data feedback -
    - -
    - - - - - - - - - - - - - ); -} diff --git a/apps/client/src/features/modals/integration-modal/osc/OscSubscriptionRow.tsx b/apps/client/src/features/modals/integration-modal/osc/OscSubscriptionRow.tsx deleted file mode 100644 index 88b946c14..000000000 --- a/apps/client/src/features/modals/integration-modal/osc/OscSubscriptionRow.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Control, useFieldArray, UseFormRegister } from 'react-hook-form'; -import { Button, IconButton, Input, Switch } from '@chakra-ui/react'; -import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp'; -import { IoRemove } from '@react-icons/all-files/io5/IoRemove'; -import type { OscSubscription, TimerLifeCycle } from 'ontime-types'; - -import { useEmitLog } from '../../../../common/stores/logger'; - -import styles from '../../Modal.module.scss'; - -interface OscSubscriptionRowProps { - cycle: TimerLifeCycle; - title: string; - subtitle: string; - visible: boolean; - setShowSection: (cycle: TimerLifeCycle) => void; - register: UseFormRegister; - control: Control; - placeholder: string; -} - -export default function OscSubscriptionRow(props: OscSubscriptionRowProps) { - const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props; - const { emitError } = useEmitLog(); - const { fields, append, remove } = useFieldArray({ - name: cycle, - control, - }); - - const hasTooManyOptions = fields.length >= 3; - const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`; - - const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`; - - const handleAddNew = () => { - if (hasTooManyOptions) { - emitError('Maximum amount of onLoad subscriptions reached (3)'); - return; - } - append({ - message: '', - enabled: false, - }); - }; - - return ( - <> -
    setShowSection(cycle)}> -
    - {sectionTitle} - {visible && {subtitle}} -
    - -
    - {visible && ( - <> - {fields.map((subscription, index) => ( -
    - } - onClick={() => remove(index)} - aria-label='delete' - size='xs' - colorScheme='red' - /> - - -
    - ))} - - - )} - - ); -} diff --git a/apps/client/src/features/modals/settings-modal/AppSettings.tsx b/apps/client/src/features/modals/settings-modal/AppSettings.tsx index 6c774bf34..51d653096 100644 --- a/apps/client/src/features/modals/settings-modal/AppSettings.tsx +++ b/apps/client/src/features/modals/settings-modal/AppSettings.tsx @@ -72,8 +72,8 @@ export default function AppSettingsModal() { variant='ontime-filled-on-light' {...register('serverPort', { required: { value: true, message: 'Required field' }, - max: { value: 65535, message: 'Port in incorrect range (1024 - 65535)' }, - min: { value: 1024, message: 'Port in incorrect range (1024 - 65535)' }, + max: { value: 65535, message: 'Port must be within range 1024 - 65535' }, + min: { value: 1024, message: 'Port must be within range 1024 - 65535' }, pattern: { value: isOnlyNumbers, message: 'Value should be numeric', diff --git a/apps/client/src/theme/OntimeAlert.ts b/apps/client/src/theme/OntimeAlert.ts index f6f7970c7..72acaf42c 100644 --- a/apps/client/src/theme/OntimeAlert.ts +++ b/apps/client/src/theme/OntimeAlert.ts @@ -18,6 +18,7 @@ export const ontimeAlertOnDark = { borderRadius: '3px', }, icon: { + alignSelf: 'start', color: '#578AF4', // $blue-500 }, }; diff --git a/apps/client/src/theme/ontimeButton.ts b/apps/client/src/theme/ontimeButton.ts index 3ed4dc33e..59be791e7 100644 --- a/apps/client/src/theme/ontimeButton.ts +++ b/apps/client/src/theme/ontimeButton.ts @@ -58,6 +58,12 @@ export const ontimeButtonGhostedWhite = { export const ontimeButtonGhosted = { ...ontimeButtonSubtle, backgroundColor: 'transparent', + _hover: { + background: '#404040', // $gray-1000 + _disabled: { + backgroundColor: 'transparent', + }, + }, }; export const ontimeButtonSubtleOnLight = { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index cc5128623..e29d81934 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -233,26 +233,26 @@ export const startOSCServer = async (overrideConfig?: { port: number }) => { export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => { checkStart(OntimeStartOrder.InitIO); + // if a config is not provided, we use the persisted one const { osc, http } = config ?? DataProvider.getData(); - if (!osc) { - return 'OSC Invalid configuration'; - } else { - const { success, message } = oscIntegration.init(osc); - logger.info(LogOrigin.Tx, message); - - if (success) { + if (osc) { + logger.info(LogOrigin.Tx, 'Initialising OSC Integration...'); + try { + oscIntegration.init(osc); integrationService.register(oscIntegration); + } catch (error) { + logger.error(LogOrigin.Tx, 'OSC Integration initialisation failed'); } } - if (!http) { - return 'HTTP Invalid configuration'; - } else { - const { success, message } = httpIntegration.init(http); - logger.info(LogOrigin.Tx, message); - if (success) { + if (http) { + logger.info(LogOrigin.Tx, 'Initialising HTTP Integration...'); + try { + httpIntegration.init(http); integrationService.register(httpIntegration); + } catch (error) { + logger.error(LogOrigin.Tx, `HTTP Integration initialisation failed: ${error}`); } } }; @@ -268,7 +268,7 @@ export const shutdown = async (exitCode = 0) => { // clear the restore file if it was a normal exit // 0 means it was a SIGNAL // 1 means crash -> keep the file - // 99 means it was the UI + // 99 means there was a shutdown request from the UI if (exitCode === 0 || exitCode === 99) { await restoreService.clear(); } diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index b15073a0c..866daeb5c 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -59,11 +59,11 @@ export class DataProvider { await this.persist(); } - static getOsc() { + static getOsc(): OSCSettings { return data.osc; } - static getHttp() { + static getHttp(): HttpSettings { return data.http; } @@ -94,14 +94,16 @@ export class DataProvider { await this.persist(); } - static async setOsc(newData: OSCSettings) { + static async setOsc(newData: OSCSettings): Promise { data.osc = { ...newData }; await this.persist(); + return data.osc; } - static async setHttp(newData: HttpSettings) { + static async setHttp(newData: HttpSettings): Promise { data.http = { ...newData }; await this.persist(); + return data.http; } static getRundown() { @@ -118,6 +120,7 @@ export class DataProvider { data.settings = mergedData.settings; data.viewSettings = mergedData.viewSettings; data.osc = mergedData.osc; + data.http = mergedData.http; data.aliases = mergedData.aliases; data.userFields = mergedData.userFields; data.rundown = mergedData.rundown; diff --git a/apps/server/src/classes/data-provider/DataProvider.utils.ts b/apps/server/src/classes/data-provider/DataProvider.utils.ts index eed142912..f49e39221 100644 --- a/apps/server/src/classes/data-provider/DataProvider.utils.ts +++ b/apps/server/src/classes/data-provider/DataProvider.utils.ts @@ -6,7 +6,8 @@ import { DatabaseModel } from 'ontime-types'; * @param {object} newData */ export function safeMerge(existing: DatabaseModel, newData: Partial) { - const { rundown, project, settings, viewSettings, osc, aliases, userFields } = newData || {}; + const { rundown, project, settings, viewSettings, aliases, userFields, osc, http } = newData || {}; + return { ...existing, rundown: rundown ?? existing.rundown, @@ -18,21 +19,7 @@ export function safeMerge(existing: DatabaseModel, newData: Partial value !== null))), }, - osc: { - ...existing.osc, - ...osc, - subscriptions: { - ...existing.osc?.subscriptions, - ...(newData?.osc?.subscriptions || {}), - ...(existing.osc?.subscriptions && newData?.osc?.subscriptions - ? Object.keys(existing.osc.subscriptions).reduce((acc, key) => { - if (!(key in newData.osc.subscriptions)) { - acc[key] = existing.osc.subscriptions[key]; - } - return acc; - }, {}) - : {}), - }, - }, + osc: { ...existing.osc, ...osc }, + http: { ...existing.http, ...http }, }; } diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts index 07b1ce0e8..2e125ea7d 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.test.ts @@ -36,14 +36,11 @@ describe('safeMerge', () => { targetIP: '127.0.0.1', enabledIn: false, enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, + subscriptions: [], + }, + http: { + enabledOut: false, + subscriptions: [], }, } as DatabaseModel; @@ -101,39 +98,32 @@ describe('safeMerge', () => { const newData = { osc: { portIn: 7777, - subscriptions: { - onStart: [ - { - id: 'unique', - message: 'new message', - enabled: true, - }, - ], - }, + subscriptions: [ + { + id: 'unique', + cycle: 'onStart', + message: 'new message', + enabled: true, + }, + ], }, }; //@ts-expect-error -- testing partial merge const mergedData = safeMerge(existing, newData); - expect(mergedData.osc).toEqual({ + expect(mergedData.osc).toMatchObject({ portIn: 7777, portOut: 9999, targetIP: '127.0.0.1', enabledIn: false, enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [ - { - id: 'unique', - message: 'new message', - enabled: true, - }, - ], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, + subscriptions: [ + { + id: 'unique', + cycle: 'onStart', + message: 'new message', + enabled: true, + }, + ], }); }); @@ -179,14 +169,7 @@ describe('safeMerge', () => { targetIP: '127.0.0.1', enabledIn: false, enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, + subscriptions: [], }, } as DatabaseModel; diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index cd1a28491..6c30faee6 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -7,6 +7,7 @@ import type { ProjectData, ErrorResponse, ProjectFileListResponse, + OSCSettings, } from 'ontime-types'; import { ExcelImportOptions, deepmerge } from 'ontime-utils'; @@ -33,7 +34,6 @@ import { oscIntegration } from '../services/integration-service/OscIntegration.j import { httpIntegration } from '../services/integration-service/HttpIntegration.js'; import { logger } from '../classes/Logger.js'; import { notifyChanges, setRundown } from '../services/rundown-service/RundownService.js'; -import { integrationService } from '../services/integration-service/IntegrationService.js'; import { getProjectFiles } from '../utils/getFileListFromFolder.js'; import { configService } from '../services/ConfigService.js'; import { deleteFile } from '../utils/parserUtils.js'; @@ -41,6 +41,7 @@ import { validateProjectFiles } from './ontimeController.validate.js'; import { dbModel } from '../models/dataModel.js'; import { sheet } from '../utils/sheetsAuth.js'; import { removeFileExtension } from '../utils/removeFileExtension.js'; +import type { OntimeError } from '../utils/backend.types.js'; import { ensureJsonExtension } from '../utils/ensureJsonExtension.js'; import { generateUniqueFileName } from '../utils/generateUniqueFilename.js'; @@ -318,47 +319,18 @@ export const getOSC = async (_req: Request, res: Response) => { // Create controller for POST request to '/ontime/osc' // Returns ACK message -export const postOSC = async (req: Request, res: Response) => { +export const postOSC = async (req: Request, res: Response) => { if (failEmptyObjects(req.body, res)) { return; } try { const oscSettings = req.body; - await DataProvider.setOsc(oscSettings); - integrationService.unregister(oscIntegration); - - // TODO: this update could be more granular, checking that relevant data was changed - const { success, message } = oscIntegration.init(oscSettings); - logger.info(LogOrigin.Tx, message); - - if (success) { - integrationService.register(oscIntegration); - } - - res.send(oscSettings).status(200); - } catch (error) { - res.status(400).send({ message: String(error) }); - } -}; - -export const postOscSubscriptions = async (req: Request, res: Response) => { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const subscriptions = req.body; - const oscSettings = DataProvider.getOsc(); - oscSettings.subscriptions = subscriptions; - await DataProvider.setOsc(oscSettings); - - // TODO: this update could be more granular, checking that relevant data was changed - const { message } = oscIntegration.init(oscSettings); - logger.info(LogOrigin.Tx, message); - - res.send(oscSettings).status(200); + oscIntegration.init(oscSettings); + // we persist the data after init to avoid persisting invalid data + const result = await DataProvider.setOsc(oscSettings); + res.send(result).status(200); } catch (error) { res.status(400).send({ message: String(error) }); } @@ -371,26 +343,18 @@ export const getHTTP = async (_req: Request, res: Response) => { }; // Create controller for POST request to '/ontime/http' -export const postHTTP = async (req: Request, res: Response) => { +export const postHTTP = async (req: Request, res: Response) => { if (failEmptyObjects(req.body, res)) { return; } try { const httpSettings = req.body; - await DataProvider.setHttp(httpSettings); - integrationService.unregister(httpIntegration); - - // TODO: this update could be more granular, checking that relevant data was changed - const { success, message } = httpIntegration.init(httpSettings); - logger.info(LogOrigin.Tx, message); - - if (success) { - integrationService.register(httpIntegration); - } - - res.send(httpSettings).status(200); + httpIntegration.init(httpSettings); + // we persist the data after init to avoid persisting invalid data + const result = await DataProvider.setHttp(httpSettings); + res.send(result).status(200); } catch (error) { res.status(400).send({ message: String(error) }); } diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index 862392935..f385e6b82 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -2,13 +2,8 @@ import { body, check, validationResult } from 'express-validator'; import { join } from 'path'; import { existsSync } from 'fs'; import { Request, Response, NextFunction } from 'express'; - -import { - validateHttpSubscriptionObject, - validateOscSubscriptionObject, - validateOscSubscriptionCycle, -} from '../utils/parserFunctions.js'; import { uploadsFolderPath } from '../setup.js'; +import { sanitiseHttpSubscriptions, sanitiseOscSubscriptions } from '../utils/parserFunctions.js'; /** * @description Validates object for POST /ontime/views @@ -87,14 +82,16 @@ export const validateSettings = [ * @description Validates object for POST /ontime/osc */ export const validateOSC = [ - body('portIn').exists().isInt({ min: 1024, max: 65535 }), - body('portOut').exists().isInt({ min: 1024, max: 65535 }), + body('portIn').exists().isPort(), + body('portOut').exists().isPort(), body('targetIP').exists().isIP(), body('enabledIn').exists().isBoolean(), body('enabledOut').exists().isBoolean(), body('subscriptions') - .isObject() - .custom((value) => validateOscSubscriptionObject(value)), + .exists() + .isArray() + .custom((value) => sanitiseOscSubscriptions(value)), + (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); @@ -108,38 +105,9 @@ export const validateOSC = [ export const validateHTTP = [ body('enabledOut').exists().isBoolean(), body('subscriptions') - .isObject() - .custom((value) => validateHttpSubscriptionObject(value)), - - (req: Request, res: Response, next: NextFunction) => { - const errors = validationResult(req); - if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); - next(); - }, -]; - -/** - * @description Validates object for POST /ontime/osc-subscriptions - */ -export const validateOscSubscription = [ - body('onLoad') + .exists() .isArray() - .custom((value) => validateOscSubscriptionCycle(value)), - body('onStart') - .isArray() - .custom((value) => validateOscSubscriptionCycle(value)), - body('onPause') - .isArray() - .custom((value) => validateOscSubscriptionCycle(value)), - body('onStop') - .isArray() - .custom((value) => validateOscSubscriptionCycle(value)), - body('onUpdate') - .isArray() - .custom((value) => validateOscSubscriptionCycle(value)), - body('onFinish') - .isArray() - .custom((value) => validateOscSubscriptionCycle(value)), + .custom((value) => sanitiseHttpSubscriptions(value)), (req: Request, res: Response, next: NextFunction) => { const errors = validationResult(req); diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 217c0e30a..6ade1cb2a 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -46,24 +46,10 @@ export const dbModel: DatabaseModel = { targetIP: '127.0.0.1', enabledIn: false, enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, + subscriptions: [], }, http: { enabledOut: false, - subscriptions: { - onLoad: [], - onStart: [], - onPause: [], - onStop: [], - onUpdate: [], - onFinish: [], - }, + subscriptions: [], }, }; diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 5eaf9c07b..a6957a827 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -14,7 +14,6 @@ import { poll, postAliases, postOSC, - postOscSubscriptions, postSettings, postUserFields, postViewSettings, @@ -43,7 +42,6 @@ import { validateUserFields, viewValidator, validateHTTP, - validateOscSubscription, validateProjectDuplicate, validateLoadProjectFile, validateProjectRename, @@ -104,9 +102,6 @@ router.get('/osc', getOSC); // create route between controller and '/ontime/osc' endpoint router.post('/osc', validateOSC, postOSC); -// create route between controller and '/ontime/osc-subscriptions' endpoint -router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions); - // create route between controller and '/ontime/http' endpoint router.get('/http', getHTTP); @@ -135,7 +130,7 @@ router.delete('/project/:filename', sanitizeProjectFilename, deleteProjectFile); router.post('/sheet/clientsecret', uploadFile, uploadClientSecret); router.get('/sheet/clientsecret', uploadFile, getClientSecret); -// Google Sheet integration - Step 1 +// Google Sheet integration - Step 2 router.get('/sheet/authentication/url', getAuthenticationUrl); router.get('/sheet/authentication', getAuthentication); diff --git a/apps/server/src/services/integration-service/HttpIntegration.ts b/apps/server/src/services/integration-service/HttpIntegration.ts index f325eddb7..4af1437f0 100644 --- a/apps/server/src/services/integration-service/HttpIntegration.ts +++ b/apps/server/src/services/integration-service/HttpIntegration.ts @@ -1,23 +1,22 @@ import got from 'got'; -import { HttpSettings, HttpSubscription, HttpSubscriptionOptions, LogOrigin } from 'ontime-types'; +import { HttpSettings, HttpSubscription, LogOrigin } from 'ontime-types'; import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; import { parseTemplateNested } from './integrationUtils.js'; -import { dbModel } from '../../models/dataModel.js'; import { logger } from '../../classes/Logger.js'; -import { validateHttpSubscriptionObject } from '../../utils/parserFunctions.js'; - -type Action = TimerLifeCycleKey | string; /** * @description Class contains logic towards outgoing HTTP communications * @class */ -export class HttpIntegration implements IIntegration { - subscriptions: HttpSubscription; +export class HttpIntegration implements IIntegration { + subscriptions: HttpSubscription[]; + enabled: boolean; + constructor() { - this.subscriptions = dbModel.http.subscriptions; + this.subscriptions = []; + this.enabled = false; } /** @@ -25,64 +24,40 @@ export class HttpIntegration implements IIntegration { */ init(config: HttpSettings) { const { subscriptions, enabledOut } = config; - - if (!enabledOut) { - return { - success: false, - message: 'HTTP output disabled', - }; - } - this.initSubscriptions(subscriptions); - return { - success: true, - message: 'HTTP integration client ready', - }; + this.enabled = enabledOut; } - initSubscriptions(subscriptionOptions: HttpSubscription) { - if (validateHttpSubscriptionObject(subscriptionOptions)) { - this.subscriptions = { ...subscriptionOptions }; - } + initSubscriptions(subscriptions: HttpSubscription[]) { + this.subscriptions = subscriptions; } - dispatch(action: Action, state?: object) { - if (!action) { - return { - success: false, - message: 'HTTP called with no action', - }; + dispatch(action: TimerLifeCycleKey, state?: object) { + // noop + if (!this.enabled || !action) { + return; } - // check subscriptions for action - const eventSubscriptions = this.subscriptions?.[action] || []; - - eventSubscriptions.forEach((sub) => { - const { enabled, message } = sub; - if (enabled && message) { - const parsedMessage = parseTemplateNested(message, state || {}); - try { - const parsedUrl = new URL(parsedMessage); - this.emit(parsedUrl); - } catch (err) { - logger.error(LogOrigin.Tx, `HTTP Integration: ${err}`); - return { - success: false, - message: `${err}`, - }; - } + for (let i = 0; i < this.subscriptions.length; i++) { + const { cycle, message, enabled } = this.subscriptions[i]; + if (cycle !== action || !enabled || !message) { + continue; } - }); + + const parsedMessage = parseTemplateNested(message, state || {}); + try { + const parsedUrl = new URL(parsedMessage); + this.emit(parsedUrl); + } catch (error) { + logger.error(LogOrigin.Tx, `HTTP Integration: ${error}`); + } + } } async emit(path: URL) { - try { - await got.get(path, { - retry: { limit: 0 }, - }); - } catch (err) { - logger.error(LogOrigin.Tx, `HTTP integration: ${err}`); - } + await got.get(path, { + retry: { limit: 0 }, + }); } shutdown() {} diff --git a/apps/server/src/services/integration-service/IIntegration.ts b/apps/server/src/services/integration-service/IIntegration.ts index 4e4d57b15..fb57c3e2b 100644 --- a/apps/server/src/services/integration-service/IIntegration.ts +++ b/apps/server/src/services/integration-service/IIntegration.ts @@ -1,24 +1,11 @@ -import { TimerLifeCycle, Subscription } from 'ontime-types'; +import { TimerLifeCycle } from 'ontime-types'; export type TimerLifeCycleKey = keyof typeof TimerLifeCycle; export default interface IIntegration { - subscriptions: Subscription; - init: (config: unknown) => OperationReturn; - dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn; + subscriptions: T[]; + init: (config: unknown) => void; + dispatch: (action: TimerLifeCycleKey, state?: object) => void; emit: (...args: unknown[]) => unknown; shutdown: () => void; } - -// either went well, or explain what failed -type OperationReturn = ReturnOnSuccess | ReturnOnError; - -type ReturnOnSuccess = { - success: true; - message?: string; -}; - -type ReturnOnError = { - success: false; - message: string; -}; diff --git a/apps/server/src/services/integration-service/IntegrationService.ts b/apps/server/src/services/integration-service/IntegrationService.ts index 944409eab..d59ba76e9 100644 --- a/apps/server/src/services/integration-service/IntegrationService.ts +++ b/apps/server/src/services/integration-service/IntegrationService.ts @@ -1,5 +1,8 @@ +import { LogOrigin } from 'ontime-types'; + import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; import { eventStore } from '../../stores/EventStore.js'; +import { logger } from '../../classes/Logger.js'; class IntegrationService { private integrations: IIntegration[]; @@ -24,7 +27,7 @@ class IntegrationService { } shutdown() { - console.log('Shutdown integrations'); + logger.info(LogOrigin.Tx, `Shutdown Integrations`); this.integrations.forEach((integration) => { integration.shutdown(); }); diff --git a/apps/server/src/services/integration-service/OscIntegration.ts b/apps/server/src/services/integration-service/OscIntegration.ts index f1652e704..ca886b869 100644 --- a/apps/server/src/services/integration-service/OscIntegration.ts +++ b/apps/server/src/services/integration-service/OscIntegration.ts @@ -1,25 +1,28 @@ import { ArgumentType, Client, Message } from 'node-osc'; -import { OSCSettings, OscSubscription, OscSubscriptionOptions } from 'ontime-types'; +import { LogOrigin, MaybeNumber, MaybeString, OSCSettings, OscSubscription } from 'ontime-types'; import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; import { parseTemplateNested } from './integrationUtils.js'; import { isObject } from '../../utils/varUtils.js'; -import { dbModel } from '../../models/dataModel.js'; -import { validateOscSubscriptionObject } from '../../utils/parserFunctions.js'; - -type Action = TimerLifeCycleKey | string; +import { logger } from '../../classes/Logger.js'; /** * @description Class contains logic towards outgoing OSC communications * @class */ -export class OscIntegration implements IIntegration { +export class OscIntegration implements IIntegration { protected oscClient: null | Client; - subscriptions: OscSubscription; + subscriptions: OscSubscription[]; + targetIP: MaybeString; + portOut: MaybeNumber; + enabledOut: boolean; constructor() { this.oscClient = null; - this.subscriptions = dbModel.osc.subscriptions; + this.subscriptions = []; + this.targetIP = null; + this.portOut = null; + this.enabledOut = false; } /** @@ -27,75 +30,58 @@ export class OscIntegration implements IIntegration { */ init(config: OSCSettings) { const { targetIP, portOut, subscriptions, enabledOut } = config; - - if (!enabledOut) { - this.oscClient?.close(); - return { - success: false, - message: 'OSC output disabled', - }; - } - this.initSubscriptions(subscriptions); - // runtime validation - const validateType = typeof targetIP !== 'string' || typeof portOut !== 'number'; - const validateNull = !targetIP || !portOut; - - if (validateType || validateNull) { - return { - success: false, - message: 'Config options incorrect', - }; + if (!enabledOut && this.enabledOut) { + this.targetIP = targetIP; + this.portOut = portOut; + this.enabledOut = enabledOut; + this.shutdown(); + return; } + + if (this.oscClient && targetIP === this.targetIP && portOut === this.portOut) { + // nothing changed that would mean we need a new client + return; + } + + this.targetIP = targetIP; + this.portOut = portOut; + this.enabledOut = enabledOut; + try { - // this allows re-calling the init function during runtime - this.oscClient?.close(); + logger.info(LogOrigin.Tx, 'Initialising OSC integration...'); this.oscClient = new Client(targetIP, portOut); - return { - success: true, - message: `OSC integration client connected to ${targetIP}:${portOut}`, - }; } catch (error) { this.oscClient = null; - return { - success: false, - message: `Failed initialising OSC Client: ${error}`, - }; + throw new Error(`Failed initialising OSC client: ${error}`); } + return `OSC integration client connected to ${targetIP}:${portOut}`; } - initSubscriptions(subscriptionOptions: OscSubscription) { - if (validateOscSubscriptionObject(subscriptionOptions)) { - this.subscriptions = { ...subscriptionOptions }; - } + initSubscriptions(subscriptions: OscSubscription[]) { + this.subscriptions = subscriptions; } - dispatch(action: Action, state?: object) { - if (!this.oscClient) { - return { - success: false, - message: 'Client not initialised', - }; + dispatch(action: TimerLifeCycleKey, state?: object) { + // noop + if (!this.oscClient || !action) { + return; } - if (!action) { - return { - success: false, - message: 'OSC called with no action', - }; - } - - // check subscriptions for action - const eventSubscriptions = this.subscriptions?.[action] || []; - - eventSubscriptions.forEach((sub) => { - const { enabled, message } = sub; - if (enabled && message) { - const parsedMessage = parseTemplateNested(message, state || {}); - this.emit(parsedMessage); + for (let i = 0; i < this.subscriptions.length; i++) { + const { cycle, message, enabled } = this.subscriptions[i]; + if (cycle !== action || !enabled || !message) { + continue; } - }); + + const parsedMessage = parseTemplateNested(message, state || {}); + try { + this.emit(parsedMessage); + } catch (error) { + logger.error(LogOrigin.Tx, `OSC Integration: ${error}`); + } + } } emit(path: string, payload?: ArgumentType) { @@ -105,33 +91,18 @@ export class OscIntegration implements IIntegration { const message = new Message(path); if (payload) { - try { - if (isObject(payload)) { - message.append(JSON.stringify(payload)); - } else { - message.append(payload); - } - } catch (error) { - console.log('OSC ERROR', error, payload); + if (isObject(payload)) { + message.append(JSON.stringify(payload)); + } else { + message.append(payload); } } - this.oscClient.send(message, (error) => { - if (error) { - return { - success: false, - message: `Error sending message: ${JSON.stringify(error)}`, - }; - } - return { - success: true, - message: 'OSC Message sent', - }; - }); + this.oscClient.send(message); } shutdown() { - console.log('Shutting down OSC integration'); + logger.info(LogOrigin.Tx, 'Shutting down OSC integration'); if (this.oscClient) { this.oscClient?.close(); this.oscClient = null; diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts index ba6f456fc..6ceaf0a44 100644 --- a/apps/server/src/utils/__tests__/parserFunctions.test.ts +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -1,162 +1,72 @@ import { HttpSubscription, OscSubscription } from 'ontime-types'; -import { - validateOscSubscriptionObject, - validateOscSubscriptionCycle, - validateHttpSubscriptionCycle, - validateHttpSubscriptionObject, -} from '../parserFunctions.js'; +import { sanitiseOscSubscriptions, sanitiseHttpSubscriptions } from '../parserFunctions.js'; -describe('validateOscSubscriptionCycle()', () => { - it('should return false when given an OscSubscription with an invalid property value', () => { - const invalidEntry = [{ message: 'test', enabled: 'not a boolean' }]; +describe('sanitiseOscSubscriptions()', () => { + it('returns an empty array if not an array', () => { + expect(sanitiseOscSubscriptions(undefined)).toEqual([]); + // @ts-expect-error -- data is external, we check bad types + expect(sanitiseOscSubscriptions({})).toEqual([]); + expect(sanitiseOscSubscriptions(null)).toEqual([]); + }); - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateOscSubscriptionCycle(invalidEntry); - expect(result).toBe(false); + it('returns an array of valid entries', () => { + const oscSubscriptions: OscSubscription[] = [ + { id: '1', cycle: 'onLoad', message: 'test', enabled: true }, + { id: '2', cycle: 'onStart', message: 'test', enabled: false }, + { id: '3', cycle: 'onPause', message: 'test', enabled: true }, + { id: '4', cycle: 'onStop', message: 'test', enabled: false }, + { id: '5', cycle: 'onUpdate', message: 'test', enabled: true }, + { id: '6', cycle: 'onFinish', message: 'test', enabled: false }, + ]; + const sanitationResult = sanitiseOscSubscriptions(oscSubscriptions); + expect(sanitationResult).toStrictEqual(oscSubscriptions); + }); + + it('filters invalid entries', () => { + const oscSubscriptions = [ + { cycle: 'onLoad', message: 'test', enabled: true }, + { id: '2', cycle: 'unknown', message: 'test', enabled: false }, + { id: '3', message: 'test', enabled: true }, + { id: '4', cycle: 'onStop', enabled: false }, + { id: '5', cycle: 'onUpdate', message: 'test' }, + { id: '6', cycle: 'onFinish', message: 'test', enabled: 'true' }, + ]; + const sanitationResult = sanitiseOscSubscriptions(oscSubscriptions as OscSubscription[]); + expect(sanitationResult.length).toBe(0); }); }); -describe('validateOscSubscriptionObject()', () => { - it('should return true when given a valid OscSubscription', () => { - const validSubscription: OscSubscription = { - onLoad: [{ message: 'test', enabled: true }], - onStart: [{ message: 'test', enabled: false }], - onPause: [{ message: 'test', enabled: true }], - onStop: [{ message: 'test', enabled: false }], - onUpdate: [{ message: 'test', enabled: true }], - onFinish: [{ message: 'test', enabled: false }], - }; - - const result = validateOscSubscriptionObject(validSubscription); - expect(result).toBe(true); +describe('sanitiseHttpSubscriptions()', () => { + it('returns an empty array if not an array', () => { + expect(sanitiseHttpSubscriptions(undefined)).toEqual([]); + // @ts-expect-error -- data is external, we check bad types + expect(sanitiseHttpSubscriptions({})).toEqual([]); + expect(sanitiseHttpSubscriptions(null)).toEqual([]); }); - it('should return false when given undefined', () => { - const result = validateOscSubscriptionObject(undefined); - expect(result).toBe(false); + it('returns an array of valid entries', () => { + const oscSubscriptions: OscSubscription[] = [ + { id: '1', cycle: 'onLoad', message: 'http://test', enabled: true }, + { id: '2', cycle: 'onStart', message: 'http://test', enabled: false }, + { id: '3', cycle: 'onPause', message: 'http://test', enabled: true }, + { id: '4', cycle: 'onStop', message: 'http://test', enabled: false }, + { id: '5', cycle: 'onUpdate', message: 'http://test', enabled: true }, + { id: '6', cycle: 'onFinish', message: 'http://test', enabled: false }, + ]; + const sanitationResult = sanitiseHttpSubscriptions(oscSubscriptions); + expect(sanitationResult).toStrictEqual(oscSubscriptions); }); - it('should return false when given null', () => { - const result = validateOscSubscriptionObject(null); - expect(result).toBe(false); - }); - - it('should return false when given an empty object', () => { - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateOscSubscriptionObject({}); - expect(result).toBe(false); - }); - - it('should return false when given an empty array', () => { - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateOscSubscriptionObject([]); - expect(result).toBe(false); - }); - - it('should return false when given an object that is not an OscSubscription', () => { - const invalidObject = { foo: 'bar' }; - - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateOscSubscriptionObject(invalidObject); - expect(result).toBe(false); - }); - - it('should return false when given an OscSubscription with a missing property', () => { - const invalidSubscription = { - onLoad: [{ message: 'test', enabled: true }], - onStart: [{ message: 'test', enabled: false }], - onPause: [{ message: 'test', enabled: true }], - // Missing onStop - onUpdate: [{ message: 'test', enabled: true }], - onFinish: [{ message: 'test', enabled: false }], - }; - - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateOscSubscriptionObject(invalidSubscription); - expect(result).toBe(false); - }); -}); - -describe('validateHttpSubscriptionCycle()', () => { - it('should return false when given an HttpSubscription with an invalid property value', () => { - const invalidBoolean = [{ message: 'http://', enabled: 'not a boolean' }]; - const invalidHttp = [{ message: 'test', enabled: true }]; - const noFtp = [{ message: 'ftp://test', enabled: true }]; - const noEmpty = [{ message: '', enabled: true }]; - - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - expect(validateHttpSubscriptionCycle(invalidBoolean)).toBe(false); - - expect(validateHttpSubscriptionCycle(invalidHttp)).toBe(false); - expect(validateHttpSubscriptionCycle(noFtp)).toBe(false); - expect(validateHttpSubscriptionCycle(noEmpty)).toBe(false); - }); - it('should return true when given an HttpSubscription matches definition', () => { - const validHttp = [{ message: 'http://', enabled: true }]; - const invalidHttps = [{ message: 'https://', enabled: true }]; - - expect(validateHttpSubscriptionCycle(validHttp)).toBe(true); - expect(validateHttpSubscriptionCycle(invalidHttps)).toBe(false); - }); -}); - -describe('validateHttpSubscriptionObject()', () => { - it('should return true when given a valid HttpSubscription', () => { - const validSubscription: HttpSubscription = { - onLoad: [{ message: 'http://', enabled: true }], - onStart: [{ message: 'http://', enabled: false }], - onPause: [{ message: 'http://', enabled: true }], - onStop: [{ message: 'http://', enabled: false }], - onUpdate: [{ message: 'http://', enabled: true }], - onFinish: [{ message: 'http://', enabled: false }], - }; - - const result = validateHttpSubscriptionObject(validSubscription); - expect(result).toBe(true); - }); - - it('should return false when given undefined', () => { - const result = validateHttpSubscriptionObject(undefined); - expect(result).toBe(false); - }); - - it('should return false when given null', () => { - const result = validateHttpSubscriptionObject(null); - expect(result).toBe(false); - }); - - it('should return false when given an empty object', () => { - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateOscSubscriptionObject({}); - expect(result).toBe(false); - }); - - it('should return false when given an empty array', () => { - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateHttpSubscriptionObject([]); - expect(result).toBe(false); - }); - - it('should return false when given an object that is not an HttpSubscription', () => { - const invalidObject = { foo: 'bar' }; - - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateHttpSubscriptionObject(invalidObject); - expect(result).toBe(false); - }); - - it('should return false when given an HttpSubscription with a missing property', () => { - const invalidSubscription = { - onLoad: [{ message: 'http://', enabled: true }], - onStart: [{ message: 'http://', enabled: false }], - onPause: [{ message: 'http://', enabled: true }], - // Missing onStop - onUpdate: [{ message: 'http://', enabled: true }], - onFinish: [{ message: 'http://', enabled: false }], - }; - - // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught - const result = validateHttpSubscriptionObject(invalidSubscription); - expect(result).toBe(false); + it('filters invalid entries', () => { + const oscSubscriptions = [ + { cycle: 'onLoad', message: 'http://test', enabled: true }, + { id: '2', cycle: 'unknown', message: 'http://test', enabled: false }, + { id: '3', message: 'http://test', enabled: true }, + { id: '4', cycle: 'onStop', enabled: false }, + { id: '5', cycle: 'onUpdate', message: 'http://test' }, + { id: '6', cycle: 'onFinish', message: 'ftp://test', enabled: 'true' }, + ]; + const sanitationResult = sanitiseHttpSubscriptions(oscSubscriptions as HttpSubscription[]); + expect(sanitationResult.length).toBe(0); }); }); diff --git a/apps/server/src/utils/backend.types.ts b/apps/server/src/utils/backend.types.ts new file mode 100644 index 000000000..acc1a20c3 --- /dev/null +++ b/apps/server/src/utils/backend.types.ts @@ -0,0 +1 @@ +export type OntimeError = { message: string }; diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 4170ad466..23f496ae9 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -6,17 +6,15 @@ import { OSCSettings, ProjectData, Settings, - TimerLifeCycle, UserFields, ViewSettings, OscSubscription, - HttpSubscription, - OscSubscriptionOptions, - HttpSubscriptionOptions, DatabaseModel, isOntimeEvent, isOntimeDelay, isOntimeBlock, + isOntimeCycle, + HttpSubscription, } from 'ontime-types'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; @@ -156,40 +154,18 @@ export const parseViewSettings = (data): ViewSettings => { }; /** - * Parses and validates OSC subscription cycle options - * @param data + * Sanitises an OSC Subscriptions array */ -export const validateOscSubscriptionCycle = (data: OscSubscriptionOptions[]): boolean => { - for (const subscriptionOption of data) { - if (typeof subscriptionOption.message !== 'string' || typeof subscriptionOption.enabled !== 'boolean') { - return false; - } - } - return true; -}; - -/** - * Parses and validates OSC subscription object - * @param data - */ -export const validateOscSubscriptionObject = (data: OscSubscription): boolean => { - if (!data) { - return false; +export function sanitiseOscSubscriptions(subscriptions?: OscSubscription[]): OscSubscription[] { + if (!Array.isArray(subscriptions)) { + return []; } - const timerKeys = Object.keys(TimerLifeCycle); - for (const key of timerKeys) { - // must contains all keys and be an array - if (!(key in data) || !Array.isArray(data[key])) { - return false; - } - const isValid = validateOscSubscriptionCycle(data[key]); - if (!isValid) { - return false; - } - } - return true; -}; + return subscriptions.filter( + ({ id, cycle, message, enabled }) => + typeof id === 'string' && isOntimeCycle(cycle) && typeof message === 'string' && typeof enabled === 'boolean', + ); +} /** * Parse osc portion of an entry @@ -198,58 +174,35 @@ export const parseOsc = (data: { osc?: Partial }): OSCSettings => { if ('osc' in data) { console.log('Found OSC definition, importing...'); - // TODO: this can be improved by only merging known keys const loadedConfig = data.osc || {}; - const validatedSubscriptions = validateOscSubscriptionObject(loadedConfig.subscriptions) - ? loadedConfig.subscriptions - : dbModel.osc.subscriptions; - return { portIn: loadedConfig.portIn ?? dbModel.osc.portIn, portOut: loadedConfig.portOut ?? dbModel.osc.portOut, targetIP: loadedConfig.targetIP ?? dbModel.osc.targetIP, enabledIn: loadedConfig.enabledIn ?? dbModel.osc.enabledIn, enabledOut: loadedConfig.enabledOut ?? dbModel.osc.enabledOut, - subscriptions: validatedSubscriptions, + subscriptions: sanitiseOscSubscriptions(loadedConfig.subscriptions), }; } }; /** - * Parses and validates HTTP subscription cycle options - * @param data + * Sanitises an HTTP Subscriptions array */ -export const validateHttpSubscriptionCycle = (data: HttpSubscriptionOptions[]): boolean => { - for (const subscriptionOption of data) { - const isHttp = subscriptionOption.message?.startsWith('http://'); - if (typeof subscriptionOption.message !== 'string' || !isHttp || typeof subscriptionOption.enabled !== 'boolean') { - return false; - } +export function sanitiseHttpSubscriptions(subscriptions?: HttpSubscription[]): HttpSubscription[] { + if (!Array.isArray(subscriptions)) { + return []; } - return true; -}; -/** - * Parses and validates HTTP subscription object - * @param data - */ -export const validateHttpSubscriptionObject = (data: HttpSubscription): boolean => { - if (!data) { - return false; - } - const timerKeys = Object.keys(TimerLifeCycle); - // must contains all keys and be an array - for (const key of timerKeys) { - if (!(key in data) || !Array.isArray(data[key])) { - return false; - } - const isValid = validateHttpSubscriptionCycle(data[key]); - if (!isValid) { - return false; - } - } - return true; -}; + return subscriptions.filter( + ({ id, cycle, message, enabled }) => + typeof id === 'string' && + isOntimeCycle(cycle) && + typeof message === 'string' && + message.startsWith('http://') && + typeof enabled === 'boolean', + ); +} /** * Parse Http portion of an entry @@ -263,13 +216,10 @@ export const parseHttp = (data: { http?: Partial }): HttpSettings // TODO: this can be improved by only merging known keys const loadedConfig = data?.http || {}; - const validatedSubscriptions = validateHttpSubscriptionObject(loadedConfig.subscriptions) - ? loadedConfig.subscriptions - : dbModel.http.subscriptions; return { enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut, - subscriptions: validatedSubscriptions, + subscriptions: sanitiseHttpSubscriptions(loadedConfig.subscriptions), }; } }; diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 6bbc1d0ea..7daca5e61 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -454,30 +454,17 @@ "targetIP": "127.0.0.1", "enabledIn": true, "enabledOut": true, - "subscriptions": { - "onLoad": [], - "onStart": [], - "onPause": [], - "onStop": [], - "onUpdate": [ + "subscriptions": [ { "id": "10eea", "enabled": true, + "cycle": "onUpdate", "message": "/ontime/update/{{timer.current}}" } - ], - "onFinish": [] - } - }, + ] + }, "http": { "enabledOut": true, - "subscriptions": { - "onLoad": [], - "onStart": [], - "onPause": [], - "onStop": [], - "onUpdate": [], - "onFinish": [] - } + "subscriptions": [] } } \ No newline at end of file diff --git a/demo-db/db.json b/demo-db/db.json index 6bbc1d0ea..01ff47a36 100644 --- a/demo-db/db.json +++ b/demo-db/db.json @@ -454,30 +454,10 @@ "targetIP": "127.0.0.1", "enabledIn": true, "enabledOut": true, - "subscriptions": { - "onLoad": [], - "onStart": [], - "onPause": [], - "onStop": [], - "onUpdate": [ - { - "id": "10eea", - "enabled": true, - "message": "/ontime/update/{{timer.current}}" - } - ], - "onFinish": [] - } + "subscriptions": [] }, "http": { "enabledOut": true, - "subscriptions": { - "onLoad": [], - "onStart": [], - "onPause": [], - "onStop": [], - "onUpdate": [], - "onFinish": [] - } + "subscriptions": [] } } \ No newline at end of file diff --git a/e2e/tests/fixtures/test-db.json b/e2e/tests/fixtures/test-db.json index 6bbc1d0ea..01ff47a36 100644 --- a/e2e/tests/fixtures/test-db.json +++ b/e2e/tests/fixtures/test-db.json @@ -454,30 +454,10 @@ "targetIP": "127.0.0.1", "enabledIn": true, "enabledOut": true, - "subscriptions": { - "onLoad": [], - "onStart": [], - "onPause": [], - "onStop": [], - "onUpdate": [ - { - "id": "10eea", - "enabled": true, - "message": "/ontime/update/{{timer.current}}" - } - ], - "onFinish": [] - } + "subscriptions": [] }, "http": { "enabledOut": true, - "subscriptions": { - "onLoad": [], - "onStart": [], - "onPause": [], - "onStop": [], - "onUpdate": [], - "onFinish": [] - } + "subscriptions": [] } } \ No newline at end of file diff --git a/packages/types/src/definitions/core/HttpSettings.type.ts b/packages/types/src/definitions/core/HttpSettings.type.ts index 39c0898ad..3dad7b039 100644 --- a/packages/types/src/definitions/core/HttpSettings.type.ts +++ b/packages/types/src/definitions/core/HttpSettings.type.ts @@ -1,9 +1,8 @@ -import { Subscription } from './Subscription.type.js'; +import { TimerLifeCycleKey } from './TimerLifecycle.type.js'; -export type HttpSubscriptionOptions = { message: string; enabled: boolean }; -export type HttpSubscription = Subscription; +export type HttpSubscription = { id: string; cycle: TimerLifeCycleKey; message: string; enabled: boolean }; export interface HttpSettings { enabledOut: boolean; - subscriptions: HttpSubscription; + subscriptions: HttpSubscription[]; } diff --git a/packages/types/src/definitions/core/OscSettings.type.ts b/packages/types/src/definitions/core/OscSettings.type.ts index 49e9bd076..271f1c640 100644 --- a/packages/types/src/definitions/core/OscSettings.type.ts +++ b/packages/types/src/definitions/core/OscSettings.type.ts @@ -1,7 +1,6 @@ -import { Subscription } from './Subscription.type.js'; +import { TimerLifeCycleKey } from './TimerLifecycle.type.js'; -export type OscSubscriptionOptions = { message: string; enabled: boolean }; -export type OscSubscription = Subscription; +export type OscSubscription = { id: string; cycle: TimerLifeCycleKey; message: string; enabled: boolean }; export interface OSCSettings { portIn: number; @@ -9,5 +8,5 @@ export interface OSCSettings { targetIP: string; enabledIn: boolean; enabledOut: boolean; - subscriptions: OscSubscription; + subscriptions: OscSubscription[]; } diff --git a/packages/types/src/definitions/core/Subscription.type.ts b/packages/types/src/definitions/core/Subscription.type.ts deleted file mode 100644 index c43f8b30f..000000000 --- a/packages/types/src/definitions/core/Subscription.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TimerLifeCycleKey } from './TimerLifecycle.type.js'; - -export type Subscription = { [key in TimerLifeCycleKey]: T[] }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 62aafe7d1..60731fddf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -31,13 +31,8 @@ export type { Alias } from './definitions/core/Alias.type.js'; export type { UserFields } from './definitions/core/UserFields.type.js'; // ---> Integration, Subscription -export type { Subscription } from './definitions/core/Subscription.type.js'; - -// ---> OSC -export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js'; - -// ---> HTTP -export type { HttpSettings, HttpSubscription, HttpSubscriptionOptions } from './definitions/core/HttpSettings.type.js'; +export type { OSCSettings, OscSubscription } from './definitions/core/OscSettings.type.js'; +export type { HttpSettings, HttpSubscription } from './definitions/core/HttpSettings.type.js'; // SERVER RESPONSES export type { @@ -67,5 +62,5 @@ export { type SimpleTimerState, SimplePlayback, SimpleDirection } from './defini // CLIENT // TYPE UTILITIES -export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isKeyOfType } from './utils/guards.js'; +export { isOntimeBlock, isOntimeDelay, isOntimeEvent, isOntimeCycle, isKeyOfType } from './utils/guards.js'; export type { DeepPartial, MaybeNumber, MaybeString } from './utils/utils.type.js'; diff --git a/packages/types/src/utils/guards.ts b/packages/types/src/utils/guards.ts index 4d302ac70..284448315 100644 --- a/packages/types/src/utils/guards.ts +++ b/packages/types/src/utils/guards.ts @@ -1,5 +1,6 @@ import { OntimeRundownEntry } from '../definitions/core/Rundown.type.js'; import { OntimeBlock, OntimeDelay, OntimeEvent, SupportedEvent } from '../definitions/core/OntimeEvent.type.js'; +import { TimerLifeCycle, TimerLifeCycleKey } from '../definitions/core/TimerLifecycle.type.js'; type MaybeEvent = OntimeRundownEntry | Partial | null | undefined; @@ -20,3 +21,8 @@ type AnyKeys = keyof T; export function isKeyOfType(key: PropertyKey, obj: T): key is AnyKeys { return key in obj; } + +export function isOntimeCycle(maybeCycle: unknown): maybeCycle is TimerLifeCycleKey { + if (typeof maybeCycle !== 'string') return false; + return Object.values(TimerLifeCycle).includes(maybeCycle as TimerLifeCycle); +}