mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-12 19:03:47 +00:00
@@ -7,7 +7,6 @@ import {
|
||||
MessageResponse,
|
||||
OntimeRundown,
|
||||
OSCSettings,
|
||||
OscSubscription,
|
||||
ProjectData,
|
||||
ProjectFileListResponse,
|
||||
Settings,
|
||||
@@ -107,6 +106,14 @@ export async function getOSC(): Promise<OSCSettings> {
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to mutate osc settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
|
||||
return axios.post(`${ontimeURL}/osc`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description HTTP request to retrieve http settings
|
||||
* @return {Promise}
|
||||
@@ -120,26 +127,10 @@ export async function getHTTP(): Promise<HttpSettings> {
|
||||
* @description HTTP request to mutate http settings
|
||||
* @return {Promise}
|
||||
*/
|
||||
export async function postHTTP(data: HttpSettings) {
|
||||
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -2,12 +2,5 @@ import { HttpSettings } from 'ontime-types';
|
||||
|
||||
export const httpPlaceholder: HttpSettings = {
|
||||
enabledOut: false,
|
||||
subscriptions: {
|
||||
onLoad: [],
|
||||
onStart: [],
|
||||
onUpdate: [],
|
||||
onPause: [],
|
||||
onStop: [],
|
||||
onFinish: [],
|
||||
},
|
||||
subscriptions: [],
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import { OSCSettings } from 'ontime-types';
|
||||
|
||||
// in the placeholder, we pass strings to satisfy input type
|
||||
export interface PlaceholderSettings extends Omit<OSCSettings, 'portIn' | 'portOut'> {
|
||||
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: [],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,3 +3,7 @@ import { KeyboardEvent } from 'react';
|
||||
export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Enter';
|
||||
}
|
||||
|
||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||
return event.key === 'Escape';
|
||||
}
|
||||
|
||||
@@ -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 = /^\//;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 <h3 className={style.subheader}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function Section({ children }: { children: ReactNode }) {
|
||||
return <div className={style.section}>{children}</div>;
|
||||
type AllowedTags = 'div' | 'form';
|
||||
type SectionProps<C extends AllowedTags> = {
|
||||
as?: C;
|
||||
children: ReactNode;
|
||||
} & JSX.IntrinsicElements[C];
|
||||
|
||||
export function Section<C extends AllowedTags = 'div'>({ as, children, ...props }: SectionProps<C>) {
|
||||
const Element = as ?? 'div';
|
||||
return (
|
||||
<Element className={style.section} {...(props as HTMLAttributes<HTMLElement>)}>
|
||||
{children}
|
||||
</Element>
|
||||
);
|
||||
}
|
||||
|
||||
export function Paragraph({ children }: { children: ReactNode }) {
|
||||
@@ -34,11 +45,20 @@ export function ListItem({ children }: { children: ReactNode }) {
|
||||
return <li className={style.listItem}>{children}</li>;
|
||||
}
|
||||
|
||||
export function Field({ title, description }: { title: string; description: string }) {
|
||||
export function Field({ title, description, error }: { title: string; description: string; error?: string }) {
|
||||
return (
|
||||
<div className={style.fieldTitle}>
|
||||
{title}
|
||||
{description && <div className={style.fieldDescription}>{description}</div>}
|
||||
{error && <Error>{error}</Error>}
|
||||
{!error && description && <Description>{description}</Description>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Description({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldDescription}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Error({ children }: { children: ReactNode }) {
|
||||
return <div className={style.fieldError}>{children}</div>;
|
||||
}
|
||||
|
||||
+141
-52
@@ -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<HttpSettings>({
|
||||
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 (
|
||||
<Panel.Section>
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>HTTP integrations</Panel.SubHeader>
|
||||
<Panel.SubHeader>
|
||||
HTTP settings
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='HTTP Output' description='Provide feedback from Ontime through HTTP' />
|
||||
<Switch variant='ontime' size='lg' />
|
||||
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
@@ -32,51 +97,75 @@ export default function HttpIntegrations() {
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
HTTP Integration
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={() => undefined}>
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Paragraph>Integration Settings for OSC protocol</Panel.Paragraph>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{demoIntegrations.map((integration) => (
|
||||
<tr key={integration.id}>
|
||||
<td>
|
||||
<Switch variant='ontime' />
|
||||
</td>
|
||||
<td className={style.autoWidth}>
|
||||
<Select size='sm' variant='ontime' className={style.fitContents}>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input size='sm' variant='ontime-filled' value={integration.message} />
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
/>
|
||||
</td>
|
||||
{fields.length > 0 && (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={integration.id}>
|
||||
<td>
|
||||
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
|
||||
</td>
|
||||
<td className={style.autoWidth}>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
className={style.fitContents}
|
||||
{...register(`subscriptions.${index}.cycle`)}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='http://third-party/vt1/{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.message`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should start with http://',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDeleteSubscription(index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
</Panel.Section>
|
||||
|
||||
+5
@@ -5,3 +5,8 @@
|
||||
.fitContents {
|
||||
width: max-content !important; /* override chakra */
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
+16
-8
@@ -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 (
|
||||
<>
|
||||
<Panel.Header>Integration settings</Panel.Header>
|
||||
<Panel.Section>
|
||||
<Alert status='info' variant='ontime-on-dark-info'>
|
||||
<AlertIcon />
|
||||
<AlertDescription>
|
||||
Integrations allow Ontime to receive commands or send its data to other systems in your workflow. <br />{' '}
|
||||
<br />
|
||||
Currently supported protocols are OSC (Open Sound Control), HTTP and Websockets. <br />
|
||||
WebSockets are used for Ontime and cannot be configured independently. <br />
|
||||
<ExternalLink href={integrationDocsUrl}>See the docs</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Panel.Section>
|
||||
<OscIntegrations />
|
||||
<HttpIntegrations />
|
||||
</>
|
||||
|
||||
+207
-63
@@ -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<OSCSettings>({
|
||||
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 (
|
||||
<Panel.Section>
|
||||
<Panel.Section>
|
||||
<Panel.SubHeader>Open Sound Control integrations</Panel.SubHeader>
|
||||
<Panel.Section onKeyDown={preventEscape}>
|
||||
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
|
||||
<Panel.SubHeader>
|
||||
Open Sound Control settings
|
||||
<div className={style.flex}>
|
||||
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</Panel.SubHeader>
|
||||
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC input' description='Allow control of Ontime through OSC' />
|
||||
<Switch variant='ontime' size='lg' />
|
||||
<Switch variant='ontime' size='lg' {...register('enabledIn')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='Listen on port' description='Port for incoming OSC. Default: 8888' />
|
||||
<Panel.Field
|
||||
title='Listen on port'
|
||||
description='Port for incoming OSC. Default: 8888'
|
||||
error={errors.portIn?.message}
|
||||
/>
|
||||
<Input
|
||||
id='portIn'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
width='5rem'
|
||||
maxLength={5}
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled'
|
||||
type='number'
|
||||
autoComplete='off'
|
||||
{...register('portIn', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
<Panel.ListGroup>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC output' description='Provide feedback from Ontime with OSC' />
|
||||
<Switch variant='ontime' size='lg' />
|
||||
<Switch variant='ontime' size='lg' {...register('enabledOut')} />
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC target IP' description='IP address Ontime will send OSC messages to' />
|
||||
<Panel.Field
|
||||
title='OSC target IP'
|
||||
description='IP address Ontime will send OSC messages to'
|
||||
error={errors.targetIP?.message}
|
||||
/>
|
||||
<Input
|
||||
id='portIn'
|
||||
id='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
width='9rem'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: isIPAddress,
|
||||
message: 'Invalid IP address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title='OSC target port'
|
||||
description='Port number Ontime will send OSC messages to'
|
||||
error={errors.portOut?.message}
|
||||
/>
|
||||
<Input
|
||||
id='portOut'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
{...register('portOut', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</Panel.ListItem>
|
||||
<Panel.ListItem>
|
||||
<Panel.Field title='OSC target port' description='Port number Ontime will send OSC messages to' />
|
||||
<Input id='portIn' placeholder='8888' width='75px' size='sm' textAlign='right' variant='ontime-filled' />
|
||||
</Panel.ListItem>
|
||||
</Panel.ListGroup>
|
||||
</Panel.Section>
|
||||
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
OSC Integration
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={() => undefined}>
|
||||
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
|
||||
New
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{demoIntegrations.map((integration) => (
|
||||
<tr key={integration.id}>
|
||||
<td>
|
||||
<Switch variant='ontime' />
|
||||
</td>
|
||||
<td className={style.autoWidth}>
|
||||
<Select size='sm' variant='ontime' className={style.fitContents}>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input size='sm' variant='ontime-filled' value={integration.message} />
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
/>
|
||||
</td>
|
||||
{fields.length > 0 && (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Enabled</th>
|
||||
<th>Cycle</th>
|
||||
<th className={style.fullWidth}>Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={field.id}>
|
||||
<td>
|
||||
<Switch variant='ontime' {...register(`subscriptions.${index}.enabled`)} />
|
||||
</td>
|
||||
<td className={style.autoWidth}>
|
||||
<Select
|
||||
size='sm'
|
||||
variant='ontime'
|
||||
className={style.fitContents}
|
||||
{...register(`subscriptions.${index}.cycle`)}
|
||||
>
|
||||
{cycles.map((cycle) => (
|
||||
<option key={cycle.id} value={cycle.value}>
|
||||
{cycle.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</td>
|
||||
<td className={style.fullWidth}>
|
||||
<Input
|
||||
key={field.id}
|
||||
size='sm'
|
||||
variant='ontime-filled'
|
||||
autoComplete='off'
|
||||
placeholder='/from-ontime/{{timer.current}}'
|
||||
{...register(`subscriptions.${index}.message`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithSlash,
|
||||
message: 'OSC messages should start with a forward slash',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{maybeError && <Panel.Error>{maybeError}</Panel.Error>}
|
||||
</td>
|
||||
<td>
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='ontime-ghosted'
|
||||
color='#FA5656' // $red-500
|
||||
icon={<IoTrash />}
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDeleteSubscription(index)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
)}
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
+1
-2
@@ -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';
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<>
|
||||
<ErrorBoundary>
|
||||
<UploadModal onClose={onUploadModalClose} isOpen={isUploadModalOpen} />
|
||||
<IntegrationModal onClose={onIntegrationModalClose} isOpen={isIntegrationModalOpen} />
|
||||
<SettingsModal isOpen={isOldSettingsOpen} onClose={onSettingsClose} />
|
||||
<SheetsModal onClose={onSheetsClose} isOpen={isSheetsOpen} />
|
||||
</ErrorBoundary>
|
||||
@@ -57,8 +50,6 @@ export default function Editor() {
|
||||
onSettingsClose={onSettingsClose}
|
||||
isUploadOpen={isUploadModalOpen}
|
||||
onUploadOpen={onUploadModalOpen}
|
||||
isIntegrationOpen={isIntegrationModalOpen}
|
||||
onIntegrationOpen={onIntegrationModalOpen}
|
||||
openSettings={handleSettings}
|
||||
isSettingsOpen={isSettingsOpen}
|
||||
isSheetsOpen={isSheetsOpen}
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
icon={isIntegrationOpen ? <IoExtensionPuzzle /> : <IoExtensionPuzzleOutline />}
|
||||
className={isIntegrationOpen ? style.open : ''}
|
||||
clickHandler={onIntegrationOpen}
|
||||
tooltip='Integrations'
|
||||
aria-label='Integrations'
|
||||
/>
|
||||
<TooltipActionBtn
|
||||
{...buttonStyle}
|
||||
isDisabled={appMode === AppMode.Run}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
|
||||
|
||||
import ModalWrapper from '../ModalWrapper';
|
||||
|
||||
import HttpIntegration from './http/HttpIntegration';
|
||||
import OscIntegration from './osc/OscIntegration';
|
||||
import OscSettings from './osc/OscSettings';
|
||||
|
||||
import styles from '../Modal.module.scss';
|
||||
|
||||
interface IntegrationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const oscDocsUrl = 'https://ontime.gitbook.io/v2/control-and-feedback/integrations';
|
||||
|
||||
export default function IntegrationModal(props: IntegrationModalProps) {
|
||||
const { isOpen, onClose } = props;
|
||||
|
||||
return (
|
||||
<ModalWrapper title='Integration Settings' isOpen={isOpen} onClose={onClose}>
|
||||
<ModalBody>
|
||||
<div className={styles.headerNotes}>
|
||||
Manage settings related to protocol integrations
|
||||
<a href={oscDocsUrl} target='_blank' rel='noreferrer'>
|
||||
Read the docs
|
||||
</a>
|
||||
</div>
|
||||
<Tabs variant='ontime' size='sm' isLazy>
|
||||
<TabList>
|
||||
<Tab>OSC</Tab>
|
||||
<Tab>OSC Integration</Tab>
|
||||
<Tab>HTTP Integration</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<OscSettings />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<OscIntegration />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<HttpIntegration />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
</ModalWrapper>
|
||||
);
|
||||
}
|
||||
@@ -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<HttpSettings>({
|
||||
mode: 'onBlur',
|
||||
defaultValues: data,
|
||||
values: data,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [showSection, setShowSection] = useState<OntimeCycle>(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 <ModalLoader />;
|
||||
}
|
||||
|
||||
const placeholder = 'http://x.x.x.x:xxxx/api/path';
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='http-subscriptions'>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>HTTP Output</span>
|
||||
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||
</div>
|
||||
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onLoad}
|
||||
title={sectionText.onLoad.title}
|
||||
subtitle={sectionText.onLoad.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onLoad}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStart}
|
||||
title={sectionText.onStart.title}
|
||||
subtitle={sectionText.onStart.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStart}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onPause}
|
||||
title={sectionText.onPause.title}
|
||||
subtitle={sectionText.onPause.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onPause}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStop}
|
||||
title={sectionText.onStop.title}
|
||||
subtitle={sectionText.onStop.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStop}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onUpdate}
|
||||
title={sectionText.onUpdate.title}
|
||||
subtitle={sectionText.onUpdate.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onUpdate}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<HttpSubscriptionRow
|
||||
cycle={TimerLifeCycle.onFinish}
|
||||
title={sectionText.onFinish.title}
|
||||
subtitle={sectionText.onFinish.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onFinish}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OntimeModalFooter
|
||||
formId='http-subscriptions'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<HttpSettings>;
|
||||
control: Control<HttpSettings>;
|
||||
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 (
|
||||
<>
|
||||
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
|
||||
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
<FiChevronUp />
|
||||
</div>
|
||||
{visible && (
|
||||
<>
|
||||
{fields.map((subscription, index) => (
|
||||
<div key={subscription.id} className={styles.entryRow}>
|
||||
<IconButton
|
||||
icon={<IoRemove />}
|
||||
onClick={() => remove(index)}
|
||||
aria-label='delete'
|
||||
size='xs'
|
||||
colorScheme='red'
|
||||
/>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
autoComplete='off'
|
||||
{...register(`subscriptions.${cycle}.${index}.message`, {
|
||||
pattern: { value: startsWithHttp, message: 'Request address must start with http://' },
|
||||
})}
|
||||
/>
|
||||
<Switch variant='ontime-on-light' {...register(`subscriptions.${cycle}.${index}.enabled`)} />
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleAddNew}
|
||||
className={styles.shiftRight}
|
||||
isDisabled={hasTooManyOptions}
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
padding='0 2em'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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<OscSubscription>({
|
||||
defaultValues: data.subscriptions,
|
||||
values: data.subscriptions,
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
},
|
||||
});
|
||||
|
||||
const [showSection, setShowSection] = useState<OntimeCycle>(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 <ModalLoader />;
|
||||
}
|
||||
|
||||
const placeholder = 'OSC message';
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='osc-subscriptions'>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onLoad}
|
||||
title={sectionText.onLoad.title}
|
||||
subtitle={sectionText.onLoad.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onLoad}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStart}
|
||||
title={sectionText.onStart.title}
|
||||
subtitle={sectionText.onStart.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStart}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onPause}
|
||||
title={sectionText.onPause.title}
|
||||
subtitle={sectionText.onPause.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onPause}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onStop}
|
||||
title={sectionText.onStop.title}
|
||||
subtitle={sectionText.onStop.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onStop}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onUpdate}
|
||||
title={sectionText.onUpdate.title}
|
||||
subtitle={sectionText.onUpdate.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onUpdate}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OscSubscriptionRow
|
||||
cycle={TimerLifeCycle.onFinish}
|
||||
title={sectionText.onFinish.title}
|
||||
subtitle={sectionText.onFinish.subtitle}
|
||||
visible={showSection === TimerLifeCycle.onFinish}
|
||||
setShowSection={setShowSection}
|
||||
register={register}
|
||||
control={control}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<OntimeModalFooter
|
||||
formId='osc-subscriptions'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<PlaceholderSettings>({
|
||||
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 <ModalLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.sectionContainer} id='oscSettings'>
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>OSC Input</span>
|
||||
<span className={styles.sectionSubtitle}>Control Ontime with OSC</span>
|
||||
</div>
|
||||
<Switch {...register('enabledIn')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<FormControl isInvalid={!!errors.portIn} className={styles.splitSection}>
|
||||
<label htmlFor='portIn'>
|
||||
<span className={styles.sectionTitle}>Listen on Port</span>
|
||||
{errors.portIn ? (
|
||||
<span className={styles.error}>{errors.portIn.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 8888</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portIn'
|
||||
placeholder='8888'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portIn', {
|
||||
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)' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<div style={{ height: '16px' }} />
|
||||
<div className={styles.splitSection}>
|
||||
<div>
|
||||
<span className={styles.sectionTitle} style={{ fontWeight: 600 }}>
|
||||
OSC Output
|
||||
</span>
|
||||
<span className={styles.sectionSubtitle}>Ontime data feedback</span>
|
||||
</div>
|
||||
<Switch {...register('enabledOut')} variant='ontime-on-light' />
|
||||
</div>
|
||||
|
||||
<FormControl isInvalid={!!errors.targetIP} className={styles.splitSection}>
|
||||
<label htmlFor='targetIP'>
|
||||
<span className={styles.sectionTitle}>OSC target IP</span>
|
||||
{errors.targetIP ? (
|
||||
<span className={styles.error}>{errors.targetIP.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 127.0.0.1</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='targetIP'
|
||||
placeholder='127.0.0.1'
|
||||
width='140px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('targetIP', {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: isIPAddress,
|
||||
message: 'Invalid IP address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl className={styles.splitSection}>
|
||||
<label htmlFor='portOut'>
|
||||
<span className={styles.sectionTitle}>OSC target Port</span>
|
||||
{errors.portOut ? (
|
||||
<span className={styles.error}>{errors.portOut.message}</span>
|
||||
) : (
|
||||
<span className={styles.sectionSubtitle}>Default 9999</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id='portOut'
|
||||
placeholder='9999'
|
||||
width='75px'
|
||||
size='sm'
|
||||
textAlign='right'
|
||||
maxLength={5}
|
||||
variant='ontime-filled-on-light'
|
||||
{...register('portOut', {
|
||||
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)' },
|
||||
pattern: {
|
||||
value: isOnlyNumbers,
|
||||
message: 'Value should be numeric',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<OntimeModalFooter
|
||||
formId='oscSettings'
|
||||
handleRevert={resetForm}
|
||||
isDirty={isDirty}
|
||||
isValid={isValid}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<OscSubscription>;
|
||||
control: Control<OscSubscription>;
|
||||
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 (
|
||||
<>
|
||||
<div className={headerStyle} onClick={() => setShowSection(cycle)}>
|
||||
<div>
|
||||
<span className={`${styles.sectionTitle} ${styles.main}`}>{sectionTitle}</span>
|
||||
{visible && <span className={styles.sectionSubtitle}>{subtitle}</span>}
|
||||
</div>
|
||||
<FiChevronUp />
|
||||
</div>
|
||||
{visible && (
|
||||
<>
|
||||
{fields.map((subscription, index) => (
|
||||
<div key={subscription.id} className={styles.entryRow}>
|
||||
<IconButton
|
||||
icon={<IoRemove />}
|
||||
onClick={() => remove(index)}
|
||||
aria-label='delete'
|
||||
size='xs'
|
||||
colorScheme='red'
|
||||
/>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
size='xs'
|
||||
variant='ontime-filled-on-light'
|
||||
autoComplete='off'
|
||||
{...register(`${cycle}.${index}.message`)}
|
||||
/>
|
||||
<Switch variant='ontime-on-light' {...register(`${cycle}.${index}.enabled`)} />
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleAddNew}
|
||||
className={styles.shiftRight}
|
||||
isDisabled={hasTooManyOptions}
|
||||
size='xs'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
padding='0 2em'
|
||||
>
|
||||
Add new
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ontimeAlertOnDark = {
|
||||
borderRadius: '3px',
|
||||
},
|
||||
icon: {
|
||||
alignSelf: 'start',
|
||||
color: '#578AF4', // $blue-500
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,6 +58,12 @@ export const ontimeButtonGhostedWhite = {
|
||||
export const ontimeButtonGhosted = {
|
||||
...ontimeButtonSubtle,
|
||||
backgroundColor: 'transparent',
|
||||
_hover: {
|
||||
background: '#404040', // $gray-1000
|
||||
_disabled: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ontimeButtonSubtleOnLight = {
|
||||
|
||||
+14
-14
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<OSCSettings> {
|
||||
data.osc = { ...newData };
|
||||
await this.persist();
|
||||
return data.osc;
|
||||
}
|
||||
|
||||
static async setHttp(newData: HttpSettings) {
|
||||
static async setHttp(newData: HttpSettings): Promise<HttpSettings> {
|
||||
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;
|
||||
|
||||
@@ -6,7 +6,8 @@ import { DatabaseModel } from 'ontime-types';
|
||||
* @param {object} newData
|
||||
*/
|
||||
export function safeMerge(existing: DatabaseModel, newData: Partial<DatabaseModel>) {
|
||||
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<DatabaseMode
|
||||
...existing.userFields,
|
||||
...(userFields && Object.fromEntries(Object.entries(userFields).filter(([_, value]) => 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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<OSCSettings | OntimeError>) => {
|
||||
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<HttpSettings>) => {
|
||||
};
|
||||
|
||||
// Create controller for POST request to '/ontime/http'
|
||||
export const postHTTP = async (req: Request, res: Response) => {
|
||||
export const postHTTP = async (req: Request, res: Response<HttpSettings | OntimeError>) => {
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<HttpSubscriptionOptions> {
|
||||
subscriptions: HttpSubscription;
|
||||
export class HttpIntegration implements IIntegration<HttpSubscription> {
|
||||
subscriptions: HttpSubscription[];
|
||||
enabled: boolean;
|
||||
|
||||
constructor() {
|
||||
this.subscriptions = dbModel.http.subscriptions;
|
||||
this.subscriptions = [];
|
||||
this.enabled = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,64 +24,40 @@ export class HttpIntegration implements IIntegration<HttpSubscriptionOptions> {
|
||||
*/
|
||||
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() {}
|
||||
|
||||
@@ -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<T> {
|
||||
subscriptions: Subscription<T>;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<unknown>[];
|
||||
@@ -24,7 +27,7 @@ class IntegrationService {
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
console.log('Shutdown integrations');
|
||||
logger.info(LogOrigin.Tx, `Shutdown Integrations`);
|
||||
this.integrations.forEach((integration) => {
|
||||
integration.shutdown();
|
||||
});
|
||||
|
||||
@@ -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<OscSubscriptionOptions> {
|
||||
export class OscIntegration implements IIntegration<OscSubscription> {
|
||||
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<OscSubscriptionOptions> {
|
||||
*/
|
||||
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<OscSubscriptionOptions> {
|
||||
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type OntimeError = { message: string };
|
||||
@@ -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> }): 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> }): 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),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
+2
-22
@@ -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": []
|
||||
}
|
||||
}
|
||||
Vendored
+2
-22
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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<HttpSubscriptionOptions>;
|
||||
export type HttpSubscription = { id: string; cycle: TimerLifeCycleKey; message: string; enabled: boolean };
|
||||
|
||||
export interface HttpSettings {
|
||||
enabledOut: boolean;
|
||||
subscriptions: HttpSubscription;
|
||||
subscriptions: HttpSubscription[];
|
||||
}
|
||||
|
||||
@@ -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<OscSubscriptionOptions>;
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { TimerLifeCycleKey } from './TimerLifecycle.type.js';
|
||||
|
||||
export type Subscription<T> = { [key in TimerLifeCycleKey]: T[] };
|
||||
@@ -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';
|
||||
|
||||
@@ -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<OntimeRundownEntry> | null | undefined;
|
||||
|
||||
@@ -20,3 +21,8 @@ type AnyKeys<T> = keyof T;
|
||||
export function isKeyOfType<T extends object>(key: PropertyKey, obj: T): key is AnyKeys<T> {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user