Compare commits

...

25 Commits

Author SHA1 Message Date
Carlos Valente 5d5b48aca6 bump version to 3.11.0-beta.1 2025-01-26 20:22:01 +01:00
Carlos Valente 0f57689750 feat: generate authenticated url (#1471)
* refactor: extract info component

* feat: generate authenticated url

* refactor: add companion select

* refactor: ensure behaviour across environments
2025-01-26 16:51:28 +01:00
Alex Christoffer Rasmussen d34280c03d More batch (#1468)
* batch updates in client

* rearange updating logic

* utilety type

* guard undefinde in affectsLoaded

* fix: pause state not saved to restore point
2025-01-26 13:14:24 +01:00
Carlos Valente bb1e9072fd refactor: apply param structure 2025-01-25 18:54:25 +01:00
Carlos Valente 4503d1e411 refactor: param sections are collapsible 2025-01-25 18:54:25 +01:00
Carlos Valente b4caec0644 refactor: use type imports 2025-01-24 17:06:50 +01:00
Carlos Valente 960f0322ad refactor: improve Esc handling in forms 2025-01-24 16:48:42 +01:00
Carlos Valente a12694b186 refactor: use inline elements 2025-01-24 16:48:42 +01:00
Carlos Valente a1d34c8f6c refactor: remove > and < comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 7f8dbbfa87 refactor: simplify string comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 39e012452b refactor: improve form ux 2025-01-24 13:53:17 +01:00
Carlos Valente c333117347 refactor: start with small runtimedata subset 2025-01-24 13:53:17 +01:00
Carlos Valente eb0e5e6420 refactor: handle boolean comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente 8ba65fa2bd refactor: handle non numeric comparisons 2025-01-24 13:53:17 +01:00
Carlos Valente efe5ac16f2 chore: rename feature elements
rename automations > triggers
rename blueprints > automations

chore: add link to documentation
2025-01-24 13:53:17 +01:00
arc-alex f4f266dbd4 refactor: migrate to OSC min 2025-01-24 13:53:17 +01:00
Carlos Valente bc9345199b feat: filtering logic 2025-01-24 13:53:17 +01:00
Carlos Valente 2cc434b0e9 refactor: remove legacy service 2025-01-24 13:53:17 +01:00
Carlos Valente 1f71d4578c refactor: migrate triggers to new service 2025-01-24 13:53:17 +01:00
Carlos Valente 6c4d80158c feat: send osc 2025-01-24 13:53:17 +01:00
Carlos Valente ab4dd300ca feat: send http 2025-01-24 13:53:17 +01:00
Carlos Valente 8b84963e17 feat: automation UI
merge with automation service
2025-01-24 13:53:17 +01:00
Carlos Valente 05e61e7bf8 feat: automation service 2025-01-24 13:53:17 +01:00
Carlos Valente a8fe4c8e68 refactor: allow both http and https 2025-01-24 13:53:17 +01:00
Alex Christoffer Rasmussen d175c788c5 Batch client updates (#1448) 2025-01-24 13:52:58 +01:00
173 changed files with 4765 additions and 2651 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getontime/cli",
"version": "3.10.5",
"version": "3.11.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "3.10.5",
"version": "3.11.0-beta.1",
"private": true,
"type": "module",
"dependencies": {
+85
View File
@@ -0,0 +1,85 @@
import axios from 'axios';
import type {
Automation,
AutomationDTO,
AutomationOutput,
AutomationSettings,
Trigger,
TriggerDTO,
} from 'ontime-types';
import { apiEntryUrl } from './constants';
const automationsPath = `${apiEntryUrl}/automations`;
/**
* HTTP request to get the automations settings
*/
export async function getAutomationSettings(): Promise<AutomationSettings> {
const res = await axios.get(automationsPath);
return res.data;
}
/**
* HTTP request to edit the automations settings
*/
export async function editAutomationSettings(
automationSettings: Partial<AutomationSettings>,
): Promise<AutomationSettings> {
const res = await axios.post(automationsPath, automationSettings);
return res.data;
}
/**
* HTTP request to create a new automation trigger
*/
export async function addTrigger(trigger: TriggerDTO): Promise<Trigger> {
const res = await axios.post(`${automationsPath}/trigger`, trigger);
return res.data;
}
/**
* HTTP request to update an automation trigger
*/
export async function editTrigger(id: string, trigger: Trigger): Promise<Trigger> {
const res = await axios.put(`${automationsPath}/trigger/${id}`, trigger);
return res.data;
}
/**
* HTTP request to delete an automation trigger
*/
export function deleteTrigger(id: string): Promise<void> {
return axios.delete(`${automationsPath}/trigger/${id}`);
}
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
return res.data;
}
/**
* HTTP request to update a automation
*/
export async function editAutomation(id: string, automation: Automation): Promise<Automation> {
const res = await axios.put(`${automationsPath}/automation/${id}`, automation);
return res.data;
}
/**
* HTTP request to delete a automation
*/
export function deleteAutomation(id: string): Promise<void> {
return axios.delete(`${automationsPath}/automation/${id}`);
}
/**
* HTTP request to test automation output
* The return is irrelevant as we care for the resolution of the promise
*/
export function testOutput(output: AutomationOutput): Promise<void> {
return axios.post(`${automationsPath}/test`, output);
}
+1 -2
View File
@@ -4,9 +4,8 @@ import { serverURL } from '../../externals';
export const APP_INFO = ['appinfo'];
export const APP_SETTINGS = ['appSettings'];
export const APP_VERSION = ['appVersion'];
export const AUTOMATION = ['automation'];
export const CUSTOM_FIELDS = ['customFields'];
export const HTTP_SETTINGS = ['httpSettings'];
export const OSC_SETTINGS = ['oscSettings'];
export const PROJECT_DATA = ['project'];
export const PROJECT_LIST = ['projectList'];
export const RUNDOWN = ['rundown'];
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { HttpSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const httpPath = `${apiEntryUrl}/http`;
/**
* HTTP request to retrieve http settings
*/
export async function getHTTP(): Promise<HttpSettings> {
const res = await axios.get(httpPath);
return res.data;
}
/**
* HTTP request to mutate http settings
*/
export async function postHTTP(data: HttpSettings): Promise<AxiosResponse<HttpSettings>> {
return axios.post(httpPath, data);
}
-21
View File
@@ -1,21 +0,0 @@
import axios, { AxiosResponse } from 'axios';
import { OSCSettings } from 'ontime-types';
import { apiEntryUrl } from './constants';
const oscPath = `${apiEntryUrl}/osc`;
/**
* HTTP request to retrieve osc settings
*/
export async function getOSC(): Promise<OSCSettings> {
const res = await axios.get(oscPath);
return res.data;
}
/**
* HTTP request to mutate osc settings
*/
export async function postOSC(data: OSCSettings): Promise<AxiosResponse<OSCSettings>> {
return axios.post(oscPath, data);
}
+13
View File
@@ -12,3 +12,16 @@ export async function getInfo(): Promise<GetInfo> {
const res = await axios.get(`${sessionPath}/info`);
return res.data;
}
/**
* HTTP request to get a pre-authenticated URL
*/
export async function generateUrl(
baseUrl: string,
path: string,
lock: boolean,
authenticate: boolean,
): Promise<string> {
const res = await axios.post(`${sessionPath}/url`, { baseUrl, path, lock, authenticate });
return res.data.url;
}
@@ -0,0 +1,16 @@
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
@@ -0,0 +1,13 @@
import { PropsWithChildren } from 'react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import style from './Info.module.scss';
export default function Info({ children }: PropsWithChildren) {
return (
<div className={style.infoLabel}>
<IoAlertCircle />
{children}
</div>
);
}
@@ -1,6 +1,6 @@
.drawerFooter {
display: flex;
justify-content: end;
justify-content: end;
gap: $section-spacing;
button {
@@ -8,56 +8,11 @@
}
}
.infoLabel {
display: flex;
align-items: center;
gap: $element-spacing;
padding: 1rem;
margin-bottom: 1rem;
background-color: $gray-1100;
border-radius: 2px;
font-size: $inner-section-text-size;
svg {
font-size: 1.5rem;
color: $info-blue;
}
}
.label {
font-size: $inner-section-text-size;
color: $label-gray;
.sectionList {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.section {
color: $ui-white;
font-size: 1rem;
&:not(:first-child) {
margin-top: 2rem;
}
}
.fieldSet {
display: flex;
padding: $section-spacing 0;
flex-direction: column;
gap: $element-spacing;
margin-left: 0.5rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
min-height: 100%;
gap: 2rem;
overflow-y: scroll;
padding-right: 0.5rem;
}
@@ -11,12 +11,12 @@ import {
DrawerOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { IoAlertCircle } from '@react-icons/all-files/io5/IoAlertCircle';
import useViewSettings from '../../../common/hooks-query/useViewSettings';
import Info from '../info/Info';
import ParamInput from './ParamInput';
import { isSection, ViewOption } from './types';
import { ViewOption } from './types';
import ViewParamsSection from './ViewParamsSection';
import style from './ViewParamsEditor.module.scss';
@@ -40,15 +40,15 @@ const getURLSearchParamsFromObj = (paramsObj: ViewParamsObj, paramFields: ViewOp
// Convert paramFields to an object that contains default values
const defaultValues: Record<string, string> = {};
paramFields.forEach((option) => {
if (!isSection(option)) {
paramFields.forEach((section) => {
section.options.forEach((option) => {
defaultValues[option.id] = String(option.defaultValue);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
// extract persisted values
if ('type' in option && option.type === 'persist') {
newSearchParams.set(option.id, option.value);
}
});
});
// compare which values are different from the default values
@@ -132,36 +132,16 @@ export default function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
</DrawerHeader>
<DrawerBody>
{viewSettings.overrideStyles && (
<div className={style.infoLabel}>
<IoAlertCircle />
This view style is being modified by a custom CSS file. <br />
</div>
)}
<form id='edit-params-form' onSubmit={onParamsFormSubmit}>
{viewOptions.map((option) => {
if (isSection(option)) {
return (
<div key={option.section} className={style.section}>
{option.section}
</div>
);
}
if (option.type === 'persist') {
return null;
}
return (
<div key={option.title} className={style.fieldSet}>
<label className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput key={option.title} paramField={option} />
</label>
</div>
);
})}
{viewSettings.overrideStyles && <Info>This view style is being modified by a custom CSS file.</Info>}
<form id='edit-params-form' onSubmit={onParamsFormSubmit} className={style.sectionList}>
{viewOptions.map((section) => (
<ViewParamsSection
key={section.title}
title={section.title}
collapsible={section.collapsible}
options={section.options}
/>
))}
</form>
</DrawerBody>
@@ -0,0 +1,50 @@
.section {
color: $ui-white;
font-size: 1rem;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
height: 2rem;
&.collapsible {
cursor: pointer;
&:hover {
border-bottom: 1px solid $white-10;
}
}
}
.label {
margin-top: $section-spacing;
font-size: $inner-section-text-size;
color: $label-gray;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.title {
font-size: $inner-section-text-size;
display: block;
width: 100%;
}
.description {
font-size: $inner-section-text-size;
display: block;
color: $modal-note-color;
}
.closed {
transition: rotate $transition-time-feedback;
rotate: 0deg;
}
.open {
transition: rotate $transition-time-feedback;
rotate: 180deg;
}
@@ -0,0 +1,54 @@
import { useLocalStorage } from '@mantine/hooks';
import { IoChevronDown } from '@react-icons/all-files/io5/IoChevronDown';
import { cx } from '../../utils/styleUtils';
import ParamInput from './ParamInput';
import { type ParamField } from './types';
import style from './ViewParamsSection.module.scss';
interface ViewParamsSectionProps {
title: string;
collapsible?: boolean;
options: ParamField[];
}
export default function ViewParamsSection(props: ViewParamsSectionProps) {
const { title, collapsible, options } = props;
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
const handleCollapse = () => {
if (collapsible) {
setCollapsed((prev) => !prev);
}
};
return (
<section className={style.section}>
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
{title}
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
</div>
{!collapsed && (
<>
{options.map((option) => {
if (option.type === 'persist') {
return null;
}
return (
<label key={option.title} className={style.label}>
<span className={style.title}>{option.title}</span>
<span className={style.description}>{option.description}</span>
<ParamInput paramField={option} />
</label>
);
})}
</>
)}
</section>
);
}
@@ -38,3 +38,14 @@ export const showLeadingZeros: ParamField = {
type: 'boolean',
defaultValue: false,
};
export enum OptionTitle {
ClockOptions = 'Clock Options',
TimerOptions = 'Timer Options',
DataSources = 'Data sources',
ElementVisibility = 'Element visibility',
BehaviourOptions = 'View behaviour',
StyleOverride = 'View style override',
Animation = 'View animation',
Schedule = 'Schedule options',
}
@@ -1,6 +1,4 @@
type ParamSection = {
section: string;
};
import { OptionTitle } from './constants';
type BaseField = {
id: string;
@@ -28,11 +26,9 @@ type PersistedField = { type: 'persist'; defaultValue?: string; value: string };
export type ParamField = BaseField &
(OptionsField | MultiOptionsField | StringField | NumberField | BooleanField | ColourField | PersistedField);
export type ViewOption = ParamSection | ParamField;
/**
* Type assertion utility checks whether an entry is a section separator
*/
export function isSection(entry: ViewOption): entry is ParamSection {
return 'section' in entry;
}
export type ViewOption = {
title: OptionTitle;
options: ParamField[];
collapsible?: boolean;
};
@@ -0,0 +1,34 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { editAutomationSettings, getAutomationSettings } from '../api/automation';
import { AUTOMATION } from '../api/constants';
import { logAxiosError } from '../api/utils';
import { automationPlaceholderSettings } from '../models/AutomationSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useAutomationSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: AUTOMATION,
queryFn: getAutomationSettings,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? automationPlaceholderSettings, status, isFetching, isError, refetch };
}
export function useAutomationSettingsMutation() {
const { isPending, mutateAsync } = useMutation({
mutationFn: editAutomationSettings,
onError: (error) => logAxiosError('Error saving Automation settings', error),
onSuccess: (data) => {
ontimeQueryClient.setQueryData(AUTOMATION, data);
},
onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: AUTOMATION }),
});
return { isPending, mutateAsync };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { HTTP_SETTINGS } from '../api/constants';
import { getHTTP, postHTTP } from '../api/http';
import { logAxiosError } from '../api/utils';
import { httpPlaceholder } from '../models/Http';
import { ontimeQueryClient } from '../queryClient';
export function useHttpSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: HTTP_SETTINGS,
queryFn: getHTTP,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
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 };
}
@@ -1,34 +0,0 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
import { OSC_SETTINGS } from '../api/constants';
import { getOSC, postOSC } from '../api/osc';
import { logAxiosError } from '../api/utils';
import { oscPlaceholderSettings } from '../models/OscSettings';
import { ontimeQueryClient } from '../queryClient';
export default function useOscSettings() {
const { data, status, isFetching, isError, refetch } = useQuery({
queryKey: OSC_SETTINGS,
queryFn: getOSC,
placeholderData: (previousData, _previousQuery) => previousData,
retry: 5,
retryDelay: (attempt: number) => attempt * 2500,
refetchInterval: queryRefetchIntervalSlow,
networkMode: 'always',
});
return { data: data ?? oscPlaceholderSettings, status, isFetching, isError, refetch };
}
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 };
}
@@ -0,0 +1,9 @@
import { AutomationSettings } from 'ontime-types';
export const automationPlaceholderSettings: AutomationSettings = {
enabledAutomations: false,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [],
automations: {},
};
-6
View File
@@ -1,6 +0,0 @@
import { HttpSettings } from 'ontime-types';
export const httpPlaceholder: HttpSettings = {
enabledOut: false,
subscriptions: [],
};
-3
View File
@@ -1,11 +1,8 @@
import { GetInfo } from 'ontime-types';
import { oscPlaceholderSettings } from './OscSettings';
export const ontimePlaceholderInfo: GetInfo = {
networkInterfaces: [],
version: '2.0.0',
serverPort: 4001,
osc: oscPlaceholderSettings,
publicDir: '',
};
@@ -1,10 +0,0 @@
import { OSCSettings } from 'ontime-types';
export const oscPlaceholderSettings: OSCSettings = {
portIn: 8888,
portOut: 9999,
targetIP: '127.0.0.1',
enabledIn: false,
enabledOut: false,
subscriptions: [],
};
+12
View File
@@ -14,6 +14,18 @@ export const runtimeStore = createWithEqualityFn<RuntimeStore>(
export const useRuntimeStore = <T>(selector: (state: RuntimeStore) => T) =>
useStoreWithEqualityFn(runtimeStore, selector, deepCompare);
let batchStore: Partial<RuntimeStore> = {};
export function addToBatchUpdates<K extends keyof RuntimeStore>(key: K, value: RuntimeStore[K]) {
batchStore[key] = value;
}
export function flushBatchUpdates() {
const state = runtimeStore.getState();
runtimeStore.setState({ ...state, ...batchStore });
batchStore = {};
}
/**
* Allows patching a property of the runtime store
*/
@@ -0,0 +1,17 @@
import { linkToOtherHost } from '../linkUtils';
describe('linkToOTherHost', () => {
it('should handle electron links', () => {
const serverUrl = 'http://localhost:4001';
const baseUri = '';
const destination = linkToOtherHost('192.168.10.166', 'path', serverUrl, baseUri);
expect(destination).toBe('http://192.168.10.166:4001/path');
});
it('should handle ontime cloud links', () => {
const serverUrl = 'https://cloud.getontime.no/user-hash';
const baseUri = 'user-hash';
const destination = linkToOtherHost('cloud.getontime.no', 'path', serverUrl, baseUri);
expect(destination).toBe('https://cloud.getontime.no/user-hash/path');
});
});
@@ -26,8 +26,8 @@ describe('simple tests for regex', () => {
});
test('startsWithHttp', () => {
const right = ['http://test'];
const wrong = ['https://test', 'testing', '123.0.1'];
const right = ['https://test', 'http://test'];
const wrong = ['testing', '123.0.1'];
right.forEach((t) => {
expect(startsWithHttp.test(t)).toBe(true);
+17 -8
View File
@@ -18,23 +18,32 @@ export function openLink(url: string) {
/**
* Handles opening external links
* @param event
* @param location
* serverUrl and baseURI are used for testing
*/
export function handleLinks(event: MouseEvent, location: string) {
export function handleLinks(
event: MouseEvent,
location: string,
externalServerUrl: string = serverURL,
externalBaseURI: string = baseURI,
) {
// we handle the link manually
event.preventDefault();
const destination = new URL(serverURL);
destination.pathname = baseURI ? `${baseURI}/${location}` : location;
const destination = new URL(externalServerUrl);
destination.pathname = externalBaseURI ? `${externalBaseURI}/${location}` : location;
openLink(destination.toString());
}
export function linkToOtherHost(host: string, path?: string) {
const destination = new URL(serverURL);
export function linkToOtherHost(
host: string,
path?: string,
externalServerUrl: string = serverURL,
externalBaseURI: string = baseURI,
) {
const destination = new URL(externalServerUrl);
destination.hostname = host;
if (path) {
destination.pathname = baseURI ? `${baseURI}/${path}` : path;
destination.pathname = externalBaseURI ? `${externalBaseURI}/${path}` : path;
}
return destination.toString();
}
+1 -1
View File
@@ -5,7 +5,7 @@
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 startsWithHttp = /^https?:\/\//;
export const startsWithSlash = /^\//;
export const isAlphanumeric = /^[a-z0-9]+$/i;
export const isASCII = /^[ -~]+$/; //https://catonmat.net/my-favorite-regex
+16 -12
View File
@@ -14,7 +14,7 @@ import {
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
import { addToBatchUpdates, flushBatchUpdates, patchRuntime, patchRuntimeProperty } from '../stores/runtime';
export let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
@@ -140,57 +140,57 @@ export const connectSocket = () => {
break;
}
case 'ontime-clock': {
patchRuntimeProperty('clock', payload);
addToBatchUpdates('clock', payload);
updateDevTools({ clock: payload });
break;
}
case 'ontime-timer': {
patchRuntimeProperty('timer', payload);
addToBatchUpdates('timer', payload);
updateDevTools({ timer: payload });
break;
}
case 'ontime-onAir': {
patchRuntimeProperty('onAir', payload);
addToBatchUpdates('onAir', payload);
updateDevTools({ onAir: payload });
break;
}
case 'ontime-message': {
patchRuntimeProperty('message', payload);
addToBatchUpdates('message', payload);
updateDevTools({ message: payload });
break;
}
case 'ontime-runtime': {
patchRuntimeProperty('runtime', payload);
addToBatchUpdates('runtime', payload);
updateDevTools({ runtime: payload });
break;
}
case 'ontime-eventNow': {
patchRuntimeProperty('eventNow', payload);
addToBatchUpdates('eventNow', payload);
updateDevTools({ eventNow: payload });
break;
}
case 'ontime-currentBlock': {
patchRuntimeProperty('currentBlock', payload);
addToBatchUpdates('currentBlock', payload);
updateDevTools({ currentBlock: payload });
break;
}
case 'ontime-publicEventNow': {
patchRuntimeProperty('publicEventNow', payload);
addToBatchUpdates('publicEventNow', payload);
updateDevTools({ publicEventNow: payload });
break;
}
case 'ontime-eventNext': {
patchRuntimeProperty('eventNext', payload);
addToBatchUpdates('eventNext', payload);
updateDevTools({ eventNext: payload });
break;
}
case 'ontime-publicEventNext': {
patchRuntimeProperty('publicEventNext', payload);
addToBatchUpdates('publicEventNext', payload);
updateDevTools({ publicEventNext: payload });
break;
}
case 'ontime-auxtimer1': {
patchRuntimeProperty('auxtimer1', payload);
addToBatchUpdates('auxtimer1', payload);
updateDevTools({ auxtimer1: payload });
break;
}
@@ -207,6 +207,10 @@ export const connectSocket = () => {
}
break;
}
case 'ontime-flush': {
flushBatchUpdates()
break;
}
}
} catch (_) {
// ignore unhandled
+1 -1
View File
@@ -75,4 +75,4 @@ function resolveBaseURI(): string {
}
return base;
}
}
@@ -3,10 +3,9 @@ import { ErrorBoundary } from '@sentry/react';
import { useKeyDown } from '../../common/hooks/useKeyDown';
import AboutPanel from './panel/about-panel/AboutPanel';
import ClientControlPanel from './panel/client-control-panel/ClientControlPanel';
import AutomationPanel from './panel/automations-panel/AutomationPanel';
import FeatureSettingsPanel from './panel/feature-settings-panel/FeatureSettingsPanel';
import GeneralPanel from './panel/general-panel/GeneralPanel';
import IntegrationsPanel from './panel/integrations-panel/IntegrationsPanel';
import NetworkLogPanel from './panel/network-panel/NetworkLogPanel';
import ProjectPanel from './panel/project-panel/ProjectPanel';
import ShutdownPanel from './panel/shutdown-panel/ShutdownPanel';
@@ -30,10 +29,9 @@ export default function AppSettings() {
{panel === 'general' && <GeneralPanel location={location} />}
{panel === 'feature_settings' && <FeatureSettingsPanel location={location} />}
{panel === 'sources' && <SourcesPanel />}
{panel === 'integrations' && <IntegrationsPanel location={location} />}
{panel === 'client_control' && <ClientControlPanel />}
{panel === 'about' && <AboutPanel />}
{panel === 'automation' && <AutomationPanel location={location} />}
{panel === 'network' && <NetworkLogPanel location={location} />}
{panel === 'about' && <AboutPanel />}
{panel === 'shutdown' && <ShutdownPanel />}
</PanelContent>
</ErrorBoundary>
@@ -10,7 +10,6 @@
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
@@ -18,4 +17,5 @@
margin: 1rem;
overflow-y: auto;
flex-grow: 1;
padding-bottom: 300px;
}
@@ -26,13 +26,13 @@ $inner-padding: 1rem;
color: $gray-300;
}
.section, .indent {
.section,
.indent {
font-size: calc(1rem - 1px);
display: flex;
flex-direction: column;
gap: 1rem;
color: $ui-white;
}
.section {
@@ -111,6 +111,12 @@ $inner-padding: 1rem;
tr:nth-child(even) {
background-color: $white-1;
}
// allow highlighting table elements
tr[data-warn='true'],
td[data-warn='true'] {
background-color: $orange-1300;
}
}
.listGroup {
@@ -177,7 +183,7 @@ $inner-padding: 1rem;
color: $muted-gray;
td {
padding: 2rem;
padding-block: 1rem;
}
button {
@@ -185,23 +191,21 @@ $inner-padding: 1rem;
}
}
.inlineSiblings {
.inlineElements {
display: flex;
align-items: center;
gap: 1rem;
}
.empty {
background-color: $black-10;
text-align: center;
color: $muted-gray;
td {
padding: 2rem;
&.inner {
gap: 0.5rem;
}
button {
margin-top: 1rem;
&.component {
gap: 1rem;
}
&.start {
justify-content: flex-start;
}
&.end {
justify-content: flex-end;
}
}
@@ -1,4 +1,4 @@
import { HTMLAttributes, ReactNode } from 'react';
import { HTMLAttributes, PropsWithChildren, ReactNode } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
@@ -55,20 +55,19 @@ export function Card({ children, className, ...props }: { children: ReactNode }
}
export function Table({ className, children }: { className?: string; children: ReactNode }) {
const classes = cx([style.table, className]);
return (
<div className={style.pad}>
<table className={classes}>{children}</table>
<table className={cx([style.table, className])}>{children}</table>
</div>
);
}
export function TableEmpty({ handleClick }: { handleClick: () => void }) {
export function TableEmpty({ label, handleClick }: { label?: string; handleClick?: () => void }) {
return (
<tr className={style.empty}>
<td colSpan={99}>
<div>No data yet</div>
<Button onClick={handleClick} variant='ontime-subtle' rightIcon={<IoAdd />} size='sm'>
<div>{label ?? 'No data yet'}</div>
<Button onClick={handleClick} isDisabled={!handleClick} variant='ontime-filled' rightIcon={<IoAdd />} size='sm'>
New
</Button>
</td>
@@ -124,3 +123,22 @@ export function Loader({ isLoading }: { isLoading: boolean }) {
</div>
);
}
type AllowedInlineTags = 'div' | 'td';
type InlineProps<C extends AllowedInlineTags> = {
as?: C;
relation?: 'inner' | 'component' | 'section';
align?: 'start' | 'end';
className?: string;
};
export function InlineElements<C extends AllowedInlineTags = 'div'>({
children,
as,
relation = 'component',
align = 'start',
className,
}: PropsWithChildren<InlineProps<C>>) {
const Element = as ?? 'div';
return <Element className={cx([style.inlineElements, style[relation], style[align], className])}>{children}</Element>;
}
@@ -0,0 +1,74 @@
.outerColumn {
gap: 2rem;
margin-bottom: 2rem;
h3 {
font-size: 1rem;
}
}
.innerColumn {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.matchRadio {
display: flex;
gap: 1rem;
}
.ruleSection {
display: flex;
flex-direction: column;
gap: 1rem;
}
.titleSection,
.filterSection,
.oscSection,
.httpSection {
display: grid;
grid-gap: 0.5rem;
button {
align-self: flex-end;
}
}
.titleSection,
.ruleSection,
.filterSection,
.oscSection,
.httpSection {
label, div {
// we use the div as non-interactive placeholder for button cells
// it needs to match the size of the label element
font-size: calc(1rem - 3px);
}
label {
color: $label-gray;
}
}
.titleSection {
grid-template-columns: 1fr;
}
.filterSection {
grid-template-columns: 2fr 1fr 2fr auto;
}
.oscSection {
grid-template-columns: 9rem 5rem 3fr 4fr auto;
}
.httpSection {
grid-template-columns: 1fr auto;
}
.outputCard {
border-left: 0.25rem solid $gray-1200;
padding-left: 0.5rem;
}
@@ -0,0 +1,478 @@
import { useEffect, useMemo } from 'react';
import { Controller, useFieldArray, useForm } from 'react-hook-form';
import {
Alert,
AlertDescription,
AlertIcon,
Button,
IconButton,
Input,
Radio,
RadioGroup,
Select,
} from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { Automation, AutomationDTO, HTTPOutput, isHTTPOutput, isOSCOutput, OSCOutput } from 'ontime-types';
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { isAutomation, makeFieldList } from './automationUtils';
import style from './AutomationForm.module.scss';
const integrationsDocsUrl = 'https://docs.getontime.no/api/integrations/#using-variables-in-integrations';
interface AutomationFormProps {
automation: Automation | AutomationDTO;
onClose: () => void;
}
export default function AutomationForm(props: AutomationFormProps) {
const { automation, onClose } = props;
const isEdit = isAutomation(automation);
const { data } = useCustomFields();
const { refetch } = useAutomationSettings();
const fieldList = useMemo(() => makeFieldList(data), [data]);
const {
control,
handleSubmit,
getValues,
register,
setError,
setFocus,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationDTO>({
mode: 'onChange',
defaultValues: {
title: automation?.title ?? '',
filterRule: automation?.filterRule ?? 'all',
filters: automation?.filters ?? [],
outputs: automation?.outputs ?? [],
},
resetOptions: {
keepDirtyValues: true,
},
});
const {
fields: fieldFilters,
append: appendFilter,
remove: removeFilter,
} = useFieldArray({
name: 'filters',
control,
});
const {
fields: fieldOutputs,
append: appendOutput,
remove: removeOutput,
} = useFieldArray({
name: 'outputs',
control,
});
// give initial focus to the title field
useEffect(() => {
setFocus('title');
}, [setFocus]);
const handleAddNewFilter = () => {
appendFilter({ field: '', operator: 'equals', value: '' });
};
const handleAddNewOSCOutput = () => {
// @ts-expect-error -- we dont want to pass a port to the new object
appendOutput({ type: 'osc', targetIP: '', targetPort: undefined, address: '', args: '' });
};
const handleAddNewHTTPOutput = () => {
appendOutput({ type: 'http', url: '' });
};
const handleTestOSCOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as OSCOutput;
if (!values.targetIP || !values.targetPort || !values.address) {
return;
}
await testOutput({
type: 'osc',
targetIP: values.targetIP,
targetPort: values.targetPort,
address: values.address,
args: values.args,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const handleTestHTTPOutput = async (index: number) => {
try {
const values = getValues(`outputs.${index}`) as HTTPOutput;
if (!values.url) {
return;
}
await testOutput({
type: 'http',
url: values.url,
});
} catch (_error) {
/** we dont handle errors here, users should use the network tab */
}
};
const onSubmit = async (values: AutomationDTO) => {
if (isAutomation(automation)) {
await handleEdit(automation.id, { id: automation.id, ...values });
} else {
await handleCreate(values);
}
refetch();
async function handleEdit(id: string, values: Automation) {
try {
await editAutomation(id, values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
async function handleCreate(values: AutomationDTO) {
try {
await addAutomation(values);
onClose();
} catch (error) {
setError('root', { message: maybeAxiosError(error) });
}
}
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Indent
as='form'
name='automation-form'
onSubmit={handleSubmit(onSubmit)}
className={style.outerColumn}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.SubHeader>{isEdit ? 'Edit automation' : 'Create automation'}</Panel.SubHeader>
<div className={style.innerSection}>
<h3>Automation options</h3>
<div className={style.titleSection}>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
variant='ontime-filled'
size='sm'
placeholder='Load preset'
autoComplete='off'
/>
</label>
<Panel.Error>{errors.title?.message}</Panel.Error>
</div>
</div>
<div className={style.innerSection}>
<h3>Filters</h3>
<div className={style.ruleSection}>
<label>
Trigger outputs if
<Controller
name='filterRule'
control={control}
render={({ field }) => (
<RadioGroup {...field} size='sm' className={style.matchRadio} variant='ontime'>
<Radio value='all'>All filters pass</Radio>
<Radio value='any'>Any filter passes</Radio>
</RadioGroup>
)}
/>
</label>
{fieldFilters.map((field, index) => (
<div key={field.id} className={style.filterSection}>
<label>
Runtime data source
<Select
{...register(`filters.${index}.field`, { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime'
>
<option selected hidden disabled value=''>
Event field
</option>
{fieldList.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</Select>
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
</label>
<label>
Matching condition
<Select
{...register(`filters.${index}.operator`, { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime'
>
<option selected hidden disabled value=''>
Operator
</option>
<option value='equals'>equals</option>
<option value='not_equals'>not equals</option>
<option value='contains'>contains</option>
{/*
We dont currently offer a data source where these operators would make sense
<option value='greater_than'>greater than</option>
<option value='less_than'>less than</option>
*/}
</Select>
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
</label>
<label>
Value to match
<Input
{...register(`filters.${index}.value`)}
variant='ontime-filled'
size='sm'
placeholder='<no value>'
autoComplete='off'
/>
</label>
<div>
<span>&nbsp;</span>
<div>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
color='#FA5656' // $red-500
onClick={() => removeFilter(index)}
isDisabled={false}
isLoading={false}
/>
</div>
</div>
</div>
))}
<div>
<Button
variant='ontime-subtle'
size='sm'
type='submit'
rightIcon={<IoAdd />}
onClick={handleAddNewFilter}
isDisabled={false}
isLoading={false}
>
Add filter
</Button>
</div>
</div>
</div>
<div className={style.innerColumn}>
<h3>Outputs</h3>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Automation outputs can be used to send data from Ontime to external software.
<ExternalLink href={integrationsDocsUrl}>See the documentation for templates</ExternalLink>
</AlertDescription>
</Alert>
{fieldOutputs.map((output, index) => {
if (isOSCOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
targetIP?: { message?: string };
targetPort?: { message?: string };
address?: { message?: string };
args?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>OSC</Tag>
<div className={style.oscSection}>
<label>
Target IP
<Input
{...register(`outputs.${index}.targetIP`, {
required: { value: true, message: 'Required field' },
})}
variant='ontime-filled'
size='sm'
placeholder='127.0.0.1'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
</label>
<label>
Target Port
<Input
{...register(`outputs.${index}.targetPort`, {
required: { value: true, message: 'Required field' },
setValueAs: (value) => (value === '' ? 0 : Number(value)),
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
})}
variant='ontime-filled'
size='sm'
type='number'
maxLength={5}
placeholder='8000'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
</label>
<label>
Address
<Input
{...register(`outputs.${index}.address`)}
variant='ontime-filled'
size='sm'
placeholder='/cue/start'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
</label>
<label>
Parameters
<Input
{...register(`outputs.${index}.args`)}
variant='ontime-filled'
size='sm'
placeholder='1'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestOSCOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
isDisabled={false}
isLoading={false}
/>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
if (isHTTPOutput(output)) {
const rowErrors = errors.outputs?.[index] as
| {
url?: { message?: string };
}
| undefined;
return (
<div key={output.id} className={style.outputCard}>
<Tag>HTTP</Tag>
<div className={style.httpSection}>
<label>
Target URL
<Input
{...register(`outputs.${index}.url`, {
required: { value: true, message: 'Required field' },
pattern: {
value: startsWithHttp,
message: 'HTTP messages should target http:// or https://',
},
})}
variant='ontime-filled'
size='sm'
placeholder='http://127.0.0.1/start/1'
autoComplete='off'
/>
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
</label>
<div>
<span>&nbsp;</span>
<Panel.InlineElements relation='inner'>
<Button size='sm' variant='ontime-ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
Test
</Button>
<IconButton
aria-label='Delete'
icon={<IoTrash />}
variant='ontime-ghosted'
size='sm'
onClick={() => removeOutput(index)}
color='#FA5656' // $red-500
isDisabled={false}
isLoading={false}
/>
</Panel.InlineElements>
</div>
</div>
</div>
);
}
// there should be no other output types
return null;
})}
<Panel.InlineElements relation='inner'>
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewOSCOutput}
isDisabled={false}
isLoading={false}
>
OSC
</Button>
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
onClick={handleAddNewHTTPOutput}
isDisabled={false}
isLoading={false}
>
HTTP
</Button>
</Panel.InlineElements>
</div>
<Panel.InlineElements align='end'>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Button variant='ontime-subtle' size='sm' onClick={onClose}>
Cancel
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -0,0 +1,39 @@
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationSettingsForm from './AutomationSettingsForm';
import AutomationsList from './AutomationsList';
import TriggersList from './TriggersList';
export default function AutomationPanel({ location }: PanelBaseProps) {
const { data, status } = useAutomationSettings();
const settingsRef = useScrollIntoView<HTMLDivElement>('settings', location);
const triggersRef = useScrollIntoView<HTMLDivElement>('triggers', location);
const automationsRef = useScrollIntoView<HTMLDivElement>('automations', location);
const isLoading = status === 'pending';
return (
<>
<Panel.Header>Automation</Panel.Header>
<Panel.Section>
<Panel.Loader isLoading={isLoading} />
<div ref={settingsRef}>
<AutomationSettingsForm
enabledAutomations={data.enabledAutomations}
enabledOscIn={data.enabledOscIn}
oscPortIn={data.oscPortIn}
/>
</div>
<div ref={triggersRef}>
<TriggersList triggers={data.triggers} automations={data.automations} />
</div>
<div ref={automationsRef}>
<AutomationsList automations={data.automations} />
</div>
</Panel.Section>
</>
);
}
@@ -0,0 +1,161 @@
import { Controller, useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertIcon, Button, Input, Switch } from '@chakra-ui/react';
import { editAutomationSettings } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
interface AutomationSettingsProps {
enabledAutomations: boolean;
enabledOscIn: boolean;
oscPortIn: number;
}
export default function AutomationSettingsForm(props: AutomationSettingsProps) {
const { enabledAutomations, enabledOscIn, oscPortIn } = props;
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<AutomationSettingsProps>({
mode: 'onChange',
defaultValues: { enabledAutomations, enabledOscIn, oscPortIn },
values: { enabledAutomations, enabledOscIn, oscPortIn },
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (formData: AutomationSettingsProps) => {
try {
await editAutomationSettings(formData);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
}
};
const onReset = () => {
reset({ enabledAutomations, enabledOscIn, oscPortIn });
};
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Card>
<Panel.SubHeader>
Automation settings
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='automation-settings-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors?.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Alert status='info' variant='ontime-on-dark-info'>
<AlertIcon />
<AlertDescription>
Control Ontime and share its data with external systems in your workflow. <br />
- Automations allow Ontime to send its data on lifecycle triggers. <br />- OSC Input tells Ontime to listen
to messages on the specific port. <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
</AlertDescription>
</Alert>
</Panel.Section>
<Panel.Section
as='form'
id='automation-settings-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
>
<Panel.Loader isLoading={false} />
<Panel.Title>Automation</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Enable automations'
description='Allow Ontime to send messages on lifecycle triggers'
error={errors.enabledAutomations?.message}
/>
<Controller
control={control}
name='enabledAutomations'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Title>OSC Input</Panel.Title>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='OSC input'
description='Allow control of Ontime through OSC'
error={errors.enabledOscIn?.message}
/>
<Controller
control={control}
name='enabledOscIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.oscPortIn?.message}
/>
<Input
id='oscPortIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
variant='ontime-filled'
type='number'
autoComplete='off'
{...register('oscPortIn', {
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.Section>
</Panel.Card>
);
}
@@ -0,0 +1,130 @@
import { Fragment, useState } from 'react';
import { Button, IconButton } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
import { deleteAutomation } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import Tag from '../../../../common/components/tag/Tag';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
const automationPlaceholder: AutomationDTO = {
title: '',
filterRule: 'all',
filters: [],
outputs: [],
};
interface AutomationsListProps {
automations: NormalisedAutomation;
}
export default function AutomationsList(props: AutomationsListProps) {
const { automations } = props;
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
setDeleteError(null);
await deleteAutomation(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
}
};
const arrayAutomations = Object.keys(automations);
return (
<Panel.Card>
<Panel.SubHeader>
Manage automations
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
isDisabled={Boolean(automationFormData)}
onClick={() => setAutomationFormData(automationPlaceholder)}
>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
{automationFormData !== null && (
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '45%' }}>Title</th>
<th style={{ width: '15%' }}>Trigger rule</th>
<th style={{ width: '15%' }}>Filters</th>
<th style={{ width: '15%' }}>Outputs</th>
<th />
</tr>
</thead>
<tbody>
{arrayAutomations.length === 0 && (
<Panel.TableEmpty
handleClick={!automationFormData ? () => setAutomationFormData(automationPlaceholder) : undefined}
/>
)}
{arrayAutomations.map((automationId) => {
if (!Object.hasOwn(automations, automationId)) {
return null;
}
return (
<Fragment key={automationId}>
<tr>
<td>{automations[automationId].title}</td>
<td>
<Tag>{automations[automationId].filterRule}</Tag>
</td>
<td>{automations[automationId].filters.length}</td>
<td>{automations[automationId].outputs.length}</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setAutomationFormData(automations[automationId])}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={() => handleDelete(automationId)}
/>
</Panel.InlineElements>
</tr>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
</Panel.Card>
);
}
@@ -0,0 +1,139 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input, Select } from '@chakra-ui/react';
import { NormalisedAutomation, TimerLifeCycle, TriggerDTO } from 'ontime-types';
import { addTrigger, editTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
interface TriggerFormProps {
automations: NormalisedAutomation;
initialId?: string;
initialTitle?: string;
initialAutomationId?: string;
initialTrigger?: TimerLifeCycle;
onCancel: () => void;
postSubmit: () => void;
}
export default function TriggerForm(props: TriggerFormProps) {
const { automations, initialId, initialTitle, initialAutomationId, initialTrigger, onCancel, postSubmit } = props;
const {
handleSubmit,
register,
setFocus,
setError,
formState: { errors, isSubmitting, isValid, isDirty },
} = useForm<TriggerDTO>({
defaultValues: {
title: initialTitle,
trigger: initialTrigger,
automationId: initialAutomationId,
},
resetOptions: {
keepDirtyValues: true,
},
});
// give initial focus to the title field
useEffect(() => {
setFocus('title');
// eslint-disable-next-line react-hooks/exhaustive-deps -- focus on mount
}, []);
const onSubmit = async (values: TriggerDTO) => {
// if we were passed an ID we are editing a Trigger
if (initialId) {
try {
await editTrigger(initialId, { id: initialId, ...values });
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save changes to trigger ${maybeAxiosError(error)}` });
}
return;
}
// otherwise we are creating a new automation
try {
await addTrigger(values);
postSubmit();
} catch (error) {
setError('root', { message: `Failed to save trigger ${maybeAxiosError(error)}` });
}
};
const automationSelect = Object.keys(automations).map((automation) => {
return {
value: automation,
label: automations[automation].title,
};
});
const canSubmit = isDirty && isValid;
return (
<Panel.Indent
as='form'
name='trigger-form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<Panel.SubHeader>{initialId ? 'Edit trigger' : 'Create trigger'}</Panel.SubHeader>
<label>
Title
<Input
{...register('title', { required: { value: true, message: 'Required field' } })}
size='sm'
variant='ontime-filled'
autoComplete='off'
defaultValue={initialTitle}
/>
<Panel.Error>{errors.title?.message}</Panel.Error>
</label>
<label>
Lifecycle trigger
<Select
size='sm'
variant='ontime'
defaultValue={initialTrigger}
{...register('trigger', { required: { value: true, message: 'Required field' } })}
>
{cycles.map((cycle) => (
<option key={cycle.id} value={cycle.value}>
{cycle.label}
</option>
))}
</Select>
<Panel.Error>{errors.trigger?.message}</Panel.Error>
</label>
<label>
Automation title
<Select
size='sm'
variant='ontime'
defaultValue={initialAutomationId}
{...register('automationId', { required: { value: true, message: 'Required field' } })}
>
{automationSelect.map((automation) => (
<option key={automation.value} value={automation.value}>
{automation.label}
</option>
))}
</Select>
<Panel.Error>{errors.automationId?.message}</Panel.Error>
</label>
<Panel.InlineElements align='end'>
<Button size='sm' variant='ontime-subtle' isDisabled={isSubmitting} onClick={onCancel}>
Cancel
</Button>
<Button type='submit' size='sm' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</Panel.InlineElements>
</Panel.Indent>
);
}
@@ -0,0 +1,117 @@
import { Fragment, useMemo, useState } from 'react';
import { Button } from '@chakra-ui/react';
import { IoAdd } from '@react-icons/all-files/io5/IoAdd';
import { NormalisedAutomation, Trigger } from 'ontime-types';
import { deleteTrigger } from '../../../../common/api/automation';
import { maybeAxiosError } from '../../../../common/api/utils';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import * as Panel from '../../panel-utils/PanelUtils';
import { checkDuplicates } from './automationUtils';
import AutomationForm from './TriggerForm';
import TriggersListItem from './TriggersListItem';
interface TriggersListProps {
triggers: Trigger[];
automations: NormalisedAutomation;
}
export default function TriggersList(props: TriggersListProps) {
const { triggers, automations } = props;
const [showForm, setShowForm] = useState(false);
const { refetch } = useAutomationSettings();
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async (id: string) => {
try {
await deleteTrigger(id);
} catch (error) {
setDeleteError(maybeAxiosError(error));
} finally {
refetch();
}
};
const postSubmit = () => {
setShowForm(false);
refetch();
};
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
// there is no point letting user creating a trigger if there are no automations
const canAdd = Object.keys(automations).length > 0;
return (
<Panel.Card>
<Panel.SubHeader>
Manage triggers
<Button
variant='ontime-subtle'
rightIcon={<IoAdd />}
size='sm'
type='submit'
form='trigger-form'
isDisabled={!canAdd}
isLoading={false}
onClick={() => setShowForm(true)}
>
New
</Button>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
{duplicates && (
<Panel.Error>
You have created multiple links between the same trigger and automation which can performance issues.
</Panel.Error>
)}
{showForm && (
<AutomationForm automations={automations} onCancel={() => setShowForm(false)} postSubmit={postSubmit} />
)}
<Panel.Table>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Lifecycle trigger</th>
<th style={{ width: '25%' }}>Automation</th>
<th />
</tr>
</thead>
<tbody>
{!showForm && triggers.length === 0 && (
<Panel.TableEmpty
label='Create an automation before to attach triggers to'
handleClick={canAdd ? () => setShowForm(true) : undefined}
/>
)}
{triggers.map((trigger, index) => {
return (
<Fragment key={trigger.id}>
<TriggersListItem
automations={automations}
id={trigger.id}
title={trigger.title}
trigger={trigger.trigger}
automationId={trigger.automationId}
duplicate={duplicates?.includes(index)}
handleDelete={() => handleDelete(trigger.id)}
postSubmit={postSubmit}
/>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</Panel.Table>
</Panel.Section>
</Panel.Card>
);
}
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { IconButton } from '@chakra-ui/react';
import { IoPencil } from '@react-icons/all-files/io5/IoPencil';
import { IoTrash } from '@react-icons/all-files/io5/IoTrash';
import { IoWarningOutline } from '@react-icons/all-files/io5/IoWarningOutline';
import { NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
import Tag from '../../../../common/components/tag/Tag';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './automationUtils';
import AutomationForm from './TriggerForm';
interface TriggersListItemProps {
automations: NormalisedAutomation;
id: string;
title: string;
trigger: TimerLifeCycle;
automationId: string;
duplicate?: boolean;
handleDelete: () => void;
postSubmit: () => void;
}
export default function TriggersListItem(props: TriggersListItemProps) {
const { automations, id, title, trigger, automationId, duplicate, handleDelete, postSubmit } = props;
const [isEditing, setIsEditing] = useState(false);
if (isEditing) {
return (
<tr>
<td colSpan={99}>
<AutomationForm
automations={automations}
initialId={id}
initialTitle={title}
initialTrigger={trigger}
initialAutomationId={automationId}
onCancel={() => setIsEditing(false)}
postSubmit={() => {
setIsEditing(false);
postSubmit();
}}
/>
</td>
</tr>
);
}
return (
<tr data-warn={duplicate}>
<Panel.InlineElements as='td' relation='inner'>
{duplicate && (
<IoWarningOutline
color='#FFBC56' // $orange-500
/>
)}
{title}
</Panel.InlineElements>
<td>
<Tag>{cycles.find((cycle) => cycle.value === trigger)?.label}</Tag>
</td>
<td>
<Tag>{automations?.[automationId]?.title}</Tag>
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#e2e2e2' // $gray-200
icon={<IoPencil />}
aria-label='Edit entry'
onClick={() => setIsEditing(true)}
/>
<IconButton
size='sm'
variant='ontime-ghosted'
color='#FA5656' // $red-500
icon={<IoTrash />}
aria-label='Delete entry'
onClick={handleDelete}
/>
</Panel.InlineElements>
</tr>
);
}
@@ -0,0 +1,24 @@
import { TimerLifeCycle, Trigger } from 'ontime-types';
import { checkDuplicates } from '../automationUtils';
describe('checkDuplicates', () => {
it('should return undefined if there are no duplicates', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: '3' },
];
expect(checkDuplicates(triggers)).toBeUndefined();
});
it('should return list of titles of duplicates', () => {
const triggers: Trigger[] = [
{ id: '1', title: 'First', trigger: TimerLifeCycle.onClock, automationId: '1' },
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onDanger, automationId: '2' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onClock, automationId: '1' },
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onPause, automationId: '1' },
];
expect(checkDuplicates(triggers)).toStrictEqual([2]);
});
});
@@ -0,0 +1,74 @@
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ 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: 'onClock' },
{ id: 6, label: 'On Timer Update', value: 'onUpdate' },
{ id: 7, label: 'On Finish', value: 'onFinish' },
{ id: 8, label: 'On Warning', value: 'onWarning' },
{ id: 9, label: 'On Danger', value: 'onDanger' },
];
/**
* We use this guard to find out if the form is receiving an existing automation or creating a DTO
* We do this by checking whether an ID has been generated
*/
export function isAutomation(automation: AutomationDTO | Automation): automation is Automation {
return Object.hasOwn(automation, 'id');
}
const staticSelectProperties = [
{ value: 'eventNow.id', label: 'ID' },
{ value: 'eventNow.title', label: 'Title' },
{ value: 'eventNow.cue', label: 'Cue' },
{ value: 'eventNow.countToEnd', label: 'Count to end' },
{ value: 'eventNow.isPublic', label: 'Is public' },
{ value: 'eventNow.note', label: 'Note' },
{ value: 'eventNow.colour', label: 'Colour' },
];
type SelectableField = {
value: string; // string encodes path in runtime state object
label: string;
};
export function makeFieldList(customFields: CustomFields): SelectableField[] {
return [
...staticSelectProperties,
...Object.entries(customFields).map(([key, { label }]) => ({
value: `eventNow.custom.${key}`,
label: `Custom: ${label}`,
})),
];
}
/**
* We warn the user if they have created multiple links between the same automation and a trigger
*/
export function checkDuplicates(triggers: Trigger[]) {
const triggersMap: Record<string, string[]> = {};
const duplicates = [];
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
triggersMap[trigger.trigger] = [];
}
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
duplicates.push(i);
} else {
triggersMap[trigger.trigger].push(trigger.automationId);
}
}
return duplicates.length > 0 ? duplicates : undefined;
}
@@ -2,23 +2,11 @@
width: 100%;
}
.actionButtons {
display: flex;
gap: $element-spacing;
.halfWidth {
width: 50%;
}
.halfWidth {
.halfWidthNoWrap {
width: 50%;
white-space: nowrap;
}
.pathList {
width: 100%;
}
.badgeList {
width: 100%;
* {
margin-right: $element-spacing;
}
}
@@ -52,7 +52,7 @@ export default function ClientList() {
<Panel.Table>
<thead>
<tr>
<td className={style.fullWidth}>Client Name (Connection ID)</td>
<td className={style.halfWidth}>Client Name (Connection ID)</td>
<td className={style.fullWidth}>Path</td>
<td />
</tr>
@@ -63,7 +63,7 @@ export default function ClientList() {
const isCurrent = id === key;
return (
<tr key={key}>
<td className={style.badgeList}>
<Panel.InlineElements relation='inner' as='td'>
<Badge variant='outline' size='xs'>
{key}
</Badge>
@@ -73,9 +73,9 @@ export default function ClientList() {
</Badge>
)}
{name}
</td>
<td className={style.pathList}>{path}</td>
<td className={style.actionButtons}>
</Panel.InlineElements>
<td>{path}</td>
<Panel.InlineElements relation='inner'>
<Button
size='xs'
className={`${identify ? style.blink : ''}`}
@@ -106,7 +106,7 @@ export default function ClientList() {
>
Redirect
</Button>
</td>
</Panel.InlineElements>
</tr>
);
})}
@@ -119,8 +119,8 @@ export default function ClientList() {
<Panel.Table>
<thead>
<tr>
<td className={style.halfWidth}>Client Name (Connection ID)</td>
<td className={style.halfWidth}>Client type</td>
<td className={style.halfWidthNoWrap}>Client Name (Connection ID)</td>
<td className={style.halfWidthNoWrap}>Client type</td>
</tr>
</thead>
<tbody>
@@ -129,12 +129,12 @@ export default function ClientList() {
return (
<tr key={key}>
<td className={style.badgeList}>
<Panel.InlineElements relation='inner' as='td'>
<Badge variant='outline' size='sx'>
{key}
</Badge>
{name}
</td>
</Panel.InlineElements>
<td>{type}</td>
</tr>
);
@@ -6,16 +6,6 @@
width: 100%;
}
.actions {
display: flex;
gap: 0.5px;
}
.actionButtons {
display: flex;
gap: 1em;
}
.fieldForm {
padding: 1rem;
background-color: $gray-1350;
@@ -24,12 +14,6 @@
gap: 1rem;
}
.buttonRow {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.fit {
width: fit-content;
}
@@ -38,10 +22,6 @@
min-width: 12em;
}
.flex {
display: flex;
}
.twoCols {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -11,6 +11,7 @@ import { maybeAxiosError } from '../../../../common/api/utils';
import TooltipActionBtn from '../../../../common/components/buttons/TooltipActionBtn';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { handleLinks } from '../../../../common/utils/linkUtils';
import { validateUrlPresetPath } from '../../../../common/utils/urlPresets';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -88,18 +89,23 @@ export default function UrlPresetsForm() {
const canSubmit = !isSubmitting && isDirty && isValid;
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} data-testid='url-preset-form'>
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
data-testid='url-preset-form'
>
<Panel.Card>
<Panel.SubHeader>
URL presets
<div className={style.actionButtons}>
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button variant='ontime-filled' size='sm' type='submit' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'>
@@ -192,7 +198,7 @@ export default function UrlPresetsForm() {
/>
<Panel.Error>{maybeUrlError}</Panel.Error>
</td>
<td className={style.flex}>
<Panel.InlineElements relation='inner' as='td'>
<TooltipActionBtn
size='sm'
isDisabled={!canTest}
@@ -213,7 +219,7 @@ export default function UrlPresetsForm() {
aria-label='Delete entry'
data-testid={`field__delete_${index}`}
/>
</td>
</Panel.InlineElements>
</tr>
);
})}
@@ -6,6 +6,7 @@ import { CustomField, CustomFieldLabel } from 'ontime-types';
import CopyTag from '../../../../../common/components/copy-tag/CopyTag';
import Swatch from '../../../../../common/components/input/colour-input/Swatch';
import * as Panel from '../../../panel-utils/PanelUtils';
import CustomFieldForm from './CustomFieldForm';
@@ -55,7 +56,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
{field}
</CopyTag>
</td>
<td className={style.actions}>
<Panel.InlineElements relation='inner' as='td'>
<IconButton
size='sm'
variant='ontime-ghosted'
@@ -72,7 +73,7 @@ export default function CustomFieldEntry(props: CustomFieldEntryProps) {
aria-label='Delete entry'
onClick={() => onDelete(field)}
/>
</td>
</Panel.InlineElements>
</tr>
);
}
@@ -7,6 +7,7 @@ import { customFieldLabelToKey, isAlphanumericWithSpace } from 'ontime-utils';
import { maybeAxiosError } from '../../../../../common/api/utils';
import SwatchSelect from '../../../../../common/components/input/colour-input/SwatchSelect';
import useCustomFields from '../../../../../common/hooks-query/useCustomFields';
import { preventEscape } from '../../../../../common/utils/keyEvent';
import * as Panel from '../../../panel-utils/PanelUtils';
import style from '../FeatureSettings.module.scss';
@@ -71,7 +72,11 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
const isEditMode = initialKey !== undefined;
return (
<form onSubmit={handleSubmit(setupSubmit)} className={style.fieldForm}>
<form
onSubmit={handleSubmit(setupSubmit)}
className={style.fieldForm}
onKeyDown={(event) => preventEscape(event, onCancel)}
>
<div className={style.twoCols}>
<div>
<Panel.Description>Label (only alphanumeric characters are allowed)</Panel.Description>
@@ -107,14 +112,14 @@ export default function CustomFieldForm(props: CustomFieldsFormProps) {
</div>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<div className={style.buttonRow}>
<Panel.InlineElements relation='inner' align='end'>
<Button size='sm' variant='ontime-ghosted' onClick={onCancel}>
Cancel
</Button>
<Button size='sm' type='submit' variant='ontime-filled' isDisabled={!canSubmit} isLoading={isSubmitting}>
Save
</Button>
</div>
</Panel.InlineElements>
</form>
);
}
@@ -1,4 +0,0 @@
.actionButtons {
display: flex;
gap: 1em;
}
@@ -6,13 +6,12 @@ import { Settings } from 'ontime-types';
import { postSettings } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import useSettings from '../../../../common/hooks-query/useSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import GeneralPinInput from './GeneralPinInput';
import style from './GeneralPanel.module.scss';
export type GeneralPanelFormValues = {
filename: string;
};
@@ -62,11 +61,16 @@ export default function GeneralPanelForm() {
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='app-settings'>
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='app-settings'
>
<Panel.Card>
<Panel.SubHeader>
General settings
<div className={style.actionButtons}>
<Panel.InlineElements>
<Button isDisabled={!isDirty || isSubmitting} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved
</Button>
@@ -80,7 +84,7 @@ export default function GeneralPanelForm() {
>
Save
</Button>
</div>
</Panel.InlineElements>
</Panel.SubHeader>
{submitError && <Panel.Error>{submitError}</Panel.Error>}
<Panel.Divider />
@@ -9,10 +9,9 @@ import ExternalLink from '../../../../common/components/external-link/ExternalLi
import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker';
import useInfo from '../../../../common/hooks-query/useInfo';
import useViewSettings from '../../../../common/hooks-query/useViewSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './GeneralPanel.module.scss';
const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/';
export default function ViewSettingsForm() {
@@ -67,18 +66,23 @@ export default function ViewSettingsForm() {
const isLoading = status === 'pending' || infoStatus === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} id='view-settings'>
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='view-settings'
>
<Panel.Card>
<Panel.SubHeader>
View settings
<div className={style.actionButtons}>
<Panel.InlineElements>
<Button isDisabled={!isDirty} variant='ontime-ghosted' size='sm' onClick={onReset}>
Revert to saved
</Button>
<Button type='submit' isLoading={isSubmitting} isDisabled={!isDirty} variant='ontime-filled' size='sm'>
Save
</Button>
</div>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Alert status='info' variant='ontime-on-dark-info'>
@@ -1,189 +0,0 @@
import { Controller, 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/utils';
import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings';
import { isKeyEscape } from '../../../../common/utils/keyEvent';
import { startsWithHttp } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function HttpIntegrations() {
const { data, status } = useHttpSettings();
const { mutateAsync } = usePostHttpSettings();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<HttpSettings>({
mode: 'onChange',
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: true,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>
HTTP settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='http-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section as='form' id='http-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
{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' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
</Panel.ListGroup>
<Panel.Divider />
<Panel.Title>
HTTP Integration
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
New
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.fullWidth}>Message</th>
<th />
</tr>
</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.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -1,16 +0,0 @@
.fullWidth {
width: 100%;
}
.halfWidth {
width: 50%;
}
.fitContents.fitContents {
width: max-content; /* override chakra */
}
.flex {
display: flex;
gap: 1rem;
}
@@ -1,42 +0,0 @@
import { Alert, AlertDescription, AlertIcon } from '@chakra-ui/react';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import HttpIntegrations from './HttpIntegrations';
import OscIntegrations from './OscIntegrations';
const integrationDocsUrl = 'https://docs.getontime.no/api/integrations/';
export default function IntegrationsPanel({ location }: PanelBaseProps) {
const oscRef = useScrollIntoView<HTMLDivElement>('osc', location);
const httpRef = useScrollIntoView<HTMLDivElement>('http', location);
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>
<Panel.Section>
<div ref={oscRef}>
<OscIntegrations />
</div>
<div ref={httpRef}>
<HttpIntegrations />
</div>
</Panel.Section>
</>
);
}
@@ -1,310 +0,0 @@
import { useEffect } from 'react';
import { Controller, 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/utils';
import useOscSettings, { useOscSettingsMutation } from '../../../../common/hooks-query/useOscSettings';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isASCII, isASCIIorEmpty, isIPAddress, isOnlyNumbers, startsWithSlash } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import { cycles } from './integrationUtils';
import style from './IntegrationsPanel.module.css';
export default function OscIntegrations() {
const { data, status } = useOscSettings();
const { mutateAsync } = useOscSettingsMutation();
const {
control,
handleSubmit,
reset,
register,
setError,
formState: { errors, isSubmitting, isDirty, isValid },
} = useForm<OSCSettings>({
mode: 'onChange',
defaultValues: data,
values: data,
resetOptions: {
keepDirtyValues: true,
},
});
const { fields, prepend, remove } = useFieldArray({
name: 'subscriptions',
control,
});
// update form if we get new data from server
useEffect(() => {
if (data) {
reset(data);
}
}, [data, reset]);
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 handleAddNewSubscription = () => {
prepend({
id: generateId(),
cycle: 'onLoad',
address: '',
payload: '',
enabled: true,
});
};
const handleDeleteSubscription = (index: number) => {
remove(index);
};
const canSubmit = !isSubmitting && isDirty && isValid;
const isLoading = status === 'pending';
return (
<Panel.Card>
<Panel.SubHeader>
OSC settings
<div className={style.flex}>
<Button variant='ontime-ghosted' size='sm' onClick={() => reset()} isDisabled={!canSubmit}>
Revert to saved
</Button>
<Button
variant='ontime-filled'
size='sm'
type='submit'
form='osc-form'
isDisabled={!canSubmit}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</Panel.SubHeader>
{isOntimeCloud && (
<Panel.Highlight>For security reasons OSC integrations are not available in the cloud service.</Panel.Highlight>
)}
<Panel.Divider />
<Panel.Section as='form' id='osc-form' onSubmit={handleSubmit(onSubmit)} onKeyDown={preventEscape}>
<Panel.Loader isLoading={isLoading} />
<Panel.Title>General OSC settings</Panel.Title>
{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' />
<Controller
control={control}
name='enabledIn'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Listen on port'
description='Port for incoming OSC. Default: 8888'
error={errors.portIn?.message}
/>
<Input
id='portIn'
placeholder='8888'
width='5rem'
maxLength={5}
size='sm'
textAlign='right'
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' />
<Controller
control={control}
name='enabledOut'
render={({ field: { onChange, value, ref } }) => (
<Switch variant='ontime' size='lg' isChecked={value} onChange={onChange} ref={ref} />
)}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='OSC target IP'
description='IP address Ontime will send OSC messages to'
error={errors.targetIP?.message}
/>
<Input
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'
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.ListGroup>
<Panel.Divider />
<Panel.Title>
OSC integrations
<Button variant='ontime-subtle' size='sm' rightIcon={<IoAdd />} onClick={handleAddNewSubscription}>
Add
</Button>
</Panel.Title>
{fields.length > 0 && (
<Panel.Table>
<thead>
<tr>
<th>Enabled</th>
<th>Cycle</th>
<th className={style.halfWidth}>Address</th>
<th className={style.halfWidth}>Arguments</th>
<th />
</tr>
</thead>
<tbody>
{fields.map((field, index) => {
const maybeAddressError = errors.subscriptions?.[index]?.address?.message;
const maybePayloadError = errors.subscriptions?.[index]?.payload?.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.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='/from-ontime/'
{...register(`subscriptions.${index}.address`, {
required: { value: true, message: 'Required field' },
validate: {
oscStartsWithSlash: (value) =>
startsWithSlash.test(value) || 'OSC address should start with a forward slash',
oscStringIsAscii: (value) =>
isASCII.test(value) || 'OSC address only allow ASCII characters',
},
})}
/>
{maybeAddressError && <Panel.Error>{maybeAddressError}</Panel.Error>}
</td>
<td className={style.halfWidth}>
<Input
key={field.id}
size='sm'
variant='ontime-filled'
autoComplete='off'
placeholder='{{timer.current}}'
{...register(`subscriptions.${index}.payload`, {
validate: {
oscStringIsAscii: (value) =>
isASCIIorEmpty.test(value) || 'OSC arguments only allow ASCII characters',
},
})}
/>
{maybePayloadError && <Panel.Error>{maybePayloadError}</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.Section>
</Panel.Card>
);
}
@@ -1,19 +0,0 @@
import { TimerLifeCycle } from 'ontime-types';
type CycleLabel = {
id: number;
label: string;
value: keyof typeof TimerLifeCycle;
};
export const cycles: CycleLabel[] = [
{ 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: 'onClock' },
{ id: 5, label: 'On Timer Update', value: 'onUpdate' },
{ id: 6, label: 'On Finish', value: 'onFinish' },
{ id: 7, label: 'On Warning', value: 'onWarning' },
{ id: 8, label: 'On Danger', value: 'onDanger' },
];
@@ -0,0 +1,5 @@
.qrCode {
padding: 0.5rem;
background: $ui-white;
border-radius: 3px;
}
@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import QRCode from 'react-qr-code';
import { Button, Select, Switch } from '@chakra-ui/react';
import { generateUrl } from '../../../../common/api/session';
import { maybeAxiosError } from '../../../../common/api/utils';
import ExternalLink from '../../../../common/components/external-link/ExternalLink';
import Info from '../../../../common/components/info/Info';
import useInfo from '../../../../common/hooks-query/useInfo';
import useUrlPresets from '../../../../common/hooks-query/useUrlPresets';
import copyToClipboard from '../../../../common/utils/copyToClipboard';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { linkToOtherHost } from '../../../../common/utils/linkUtils';
import { serverURL } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './GenerateLinkForm.module.scss';
interface GenerateLinkFormOptions {
baseUrl: string;
path: string;
lock: boolean;
authenticate: boolean;
}
type GenerateLinkState = 'pending' | 'loading' | 'success' | 'error';
export default function GenerateLinkForm() {
const { data: infoData } = useInfo();
const { data: urlPresetData } = useUrlPresets();
const [formState, setFormState] = useState<GenerateLinkState>('pending');
const [url, setUrl] = useState(serverURL);
const {
handleSubmit,
register,
setError,
formState: { errors },
} = useForm<GenerateLinkFormOptions>({
mode: 'onChange',
resetOptions: {
keepDirtyValues: true,
},
});
const onSubmit = async (options: GenerateLinkFormOptions) => {
try {
setFormState('loading');
const baseUrl = linkToOtherHost(options.baseUrl);
const url = await generateUrl(baseUrl, options.path, options.lock, options.authenticate);
await copyToClipboard(url);
setUrl(url);
setFormState('success');
setTimeout(() => {
setFormState('pending');
}, 4000);
} catch (error) {
const message = maybeAxiosError(error);
setError('root', { message });
setFormState('error');
}
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event)}>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Info>
<Panel.Paragraph>
You can generate a link to share with your team or to use in automation (such as companion).
</Panel.Paragraph>
</Info>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Host IP' description='Which IP address will be used' />
<Select variant='ontime' size='sm' {...register('baseUrl')}>
{infoData.networkInterfaces.map((nif) => {
return (
<option key={nif.name} value={nif.address}>
{`${nif.name} - ${nif.address}`}
</option>
);
})}
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='URL Preset'
description='Which preset will the link point to (will default to /timer if none is given)'
/>
<Select variant='ontime' size='sm' {...register('path')}>
<option key='timer' value='timer'>
Timer
</option>
<option key='companion' value=''>
Companion
</option>
{urlPresetData.map((preset) => {
return (
<option key={preset.alias} value={preset.alias}>
{`Preset: ${preset.alias}`}
</option>
);
})}
</Select>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Lock navigation'
description='Prevent showing navigation (will only work for non production URLs)'
/>
<Switch variant='ontime' size='lg' {...register('lock')} />
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field title='Authenticate' description='Whether the URL should be pre-authenticated' />
<Switch variant='ontime' size='lg' {...register('authenticate')} />
</Panel.ListItem>
</Panel.ListGroup>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field title='Generate link' description='Fill form and generate link and QR code' />
<Button
variant='ontime-filled'
size='sm'
isLoading={formState === 'loading'}
type='submit'
style={{ alignSelf: 'end' }}
>
{formState === 'success' ? 'Link copied to clipboard!' : 'Update share link'}
</Button>
<div style={{ marginTop: '1rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<QRCode size={172} value={url} className={style.qrCode} />
<ExternalLink href={url}>{url}</ExternalLink>
</div>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
);
}
@@ -1,10 +1,3 @@
.interfaces {
display: flex;
flex-wrap: wrap;
gap: $section-spacing;
row-gap: $element-inner-spacing;
}
.goIcon {
@include rotate-fourty-five;
margin-left: 0.25rem;
@@ -4,6 +4,7 @@ import CopyTag from '../../../../common/components/copy-tag/CopyTag';
import useInfo from '../../../../common/hooks-query/useInfo';
import { linkToOtherHost, openLink } from '../../../../common/utils/linkUtils';
import { isLocalhost } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './NetworkInterfaces.module.scss';
@@ -13,7 +14,7 @@ export default function InfoNif() {
const handleClick = (address: string) => openLink(address);
return (
<div className={style.interfaces}>
<Panel.InlineElements>
{data.networkInterfaces.map((nif) => {
// interfaces outside localhost wont have access
if (nif.name === 'localhost' && !isLocalhost) return null;
@@ -30,6 +31,6 @@ export default function InfoNif() {
</CopyTag>
);
})}
</div>
</Panel.InlineElements>
);
}
@@ -8,21 +8,34 @@ import type { PanelBaseProps } from '../../panel-list/PanelList';
import * as Panel from '../../panel-utils/PanelUtils';
import ClientControlPanel from '../client-control-panel/ClientControlPanel';
import GenerateLinkForm from './GenerateLinkForm';
import InfoNif from './NetworkInterfaces';
import LogExport from './NetworkLogExport';
export default function NetworkLogPanel({ location }: PanelBaseProps) {
const linkRef = useScrollIntoView<HTMLDivElement>('link', location);
const clientsRef = useScrollIntoView<HTMLDivElement>('clients', location);
const logRef = useScrollIntoView<HTMLDivElement>('log', location);
return (
<>
<Panel.Header>Network</Panel.Header>
<Panel.Section>
{isDockerImage && <OntimeCloudStats />}
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
</Panel.Section>
<InfoNif />
{isDockerImage && (
<Panel.Section>
<OntimeCloudStats />
</Panel.Section>
)}
<div ref={linkRef}>
<Panel.Section>
<Panel.Card>
<Panel.SubHeader>Share Ontime Link</Panel.SubHeader>
<Panel.Divider />
<Panel.Paragraph>Ontime is streaming on the following network interfaces</Panel.Paragraph>
<InfoNif />
<GenerateLinkForm />
</Panel.Card>
</Panel.Section>
</div>
<div ref={logRef}>
<LogExport />
</div>
@@ -11,8 +11,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import ProjectCreateForm from './ProjectCreateForm';
import ProjectList from './ProjectList';
import style from './ProjectPanel.module.scss';
export default function ManageProjects() {
const [searchParams, setSearchParams] = useSearchParams();
const [error, setError] = useState('');
@@ -70,7 +68,7 @@ export default function ManageProjects() {
<Panel.Card>
<Panel.SubHeader>
Manage projects
<div className={style.headerButtons}>
<Panel.InlineElements>
<Button
variant='ontime-subtle'
onClick={handleSelectFile}
@@ -89,7 +87,7 @@ export default function ManageProjects() {
>
New
</Button>
</div>
</Panel.InlineElements>
</Panel.SubHeader>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Divider />
@@ -6,6 +6,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { PROJECT_LIST } from '../../../../common/api/constants';
import { createProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -66,17 +67,21 @@ export default function ProjectCreateForm(props: ProjectCreateFromProps) {
};
return (
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Section
as='form'
onSubmit={handleSubmit(handleSubmitCreate)}
onKeyDown={(event) => preventEscape(event, onClose)}
>
<Panel.Title>
Create new project
<div className={style.createActionButtons}>
<Panel.InlineElements>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
<Button isDisabled={!isValid} type='submit' isLoading={isSubmitting} variant='ontime-filled' size='sm'>
Create
Create project
</Button>
</div>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={style.innerColumn}>
@@ -9,6 +9,7 @@ import { projectLogoPath } from '../../../../common/api/constants';
import { postProjectData, uploadProjectLogo } from '../../../../common/api/project';
import { maybeAxiosError } from '../../../../common/api/utils';
import useProjectData from '../../../../common/hooks-query/useProjectData';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { validateLogo } from '../../../../common/utils/uploadUtils';
import { documentationUrl, websiteUrl } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
@@ -96,11 +97,11 @@ export default function ProjectData() {
const isLoading = status === 'pending';
return (
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)}>
<Panel.Section as='form' onSubmit={handleSubmit(onSubmit)} onKeyDown={(event) => preventEscape(event, onReset)}>
<Panel.Card>
<Panel.SubHeader>
Project data
<div className={style.headerButtons}>
<Panel.InlineElements>
<Button variant='ontime-ghosted' size='sm' onClick={onReset} isDisabled={isSubmitting || !isDirty}>
Revert to saved
</Button>
@@ -113,7 +114,7 @@ export default function ProjectData() {
>
Save
</Button>
</div>
</Panel.InlineElements>
</Panel.SubHeader>
<Panel.Divider />
<Panel.Section>
@@ -2,6 +2,9 @@ import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { Button, Input } from '@chakra-ui/react';
import { preventEscape } from '../../../../common/utils/keyEvent';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
export type ProjectFormValues = {
@@ -34,7 +37,11 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
}, [setFocus]);
return (
<form onSubmit={handleSubmit(onSubmit)} className={style.form}>
<form
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onCancel)}
className={style.form}
>
<Input
className={style.formInput}
id='filename'
@@ -45,7 +52,7 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
autoComplete='off'
{...register('filename', { required: true })}
/>
<div className={style.actionButtons}>
<Panel.InlineElements relation='inner'>
<Button onClick={onCancel} size='sm' variant='ontime-ghosted' disabled={isSubmitting}>
Cancel
</Button>
@@ -58,7 +65,7 @@ export default function ProjectForm({ action, filename, onSubmit, onCancel }: Pr
>
{action}
</Button>
</div>
</Panel.InlineElements>
</form>
);
}
@@ -130,7 +130,7 @@ export default function ProjectListItem({
<>
<td className={style.containCell}>{filename}</td>
<td>{current ? 'Currently loaded' : new Date(updatedAt).toLocaleString()}</td>
<td className={style.actionButton}>
<td>
<ActionMenu
current={current}
filename={filename}
@@ -23,8 +23,7 @@ type ProjectMergeFormValues = {
rundown: boolean;
viewSettings: boolean;
urlPresets: boolean;
osc: boolean;
http: boolean;
automation: boolean;
};
export default function ProjectMergeForm(props: ProjectMergeFromProps) {
@@ -42,8 +41,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
rundown: false,
viewSettings: false,
urlPresets: false,
osc: false,
http: false,
automation: false,
},
resetOptions: {
keepDirtyValues: true,
@@ -77,7 +75,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
<Panel.Section as='form' onSubmit={handleSubmit(handleSubmitCreate)}>
<Panel.Title>
Merge {`"${fileName}"`}
<div className={style.createActionButtons}>
<Panel.InlineElements>
<Button onClick={onClose} variant='ontime-ghosted' size='sm' isDisabled={isSubmitting}>
Cancel
</Button>
@@ -90,7 +88,7 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
>
Merge
</Button>
</div>
</Panel.InlineElements>
</Panel.Title>
{error && <Panel.Error>{error}</Panel.Error>}
<Panel.Section className={cx([style.innerColumn, style.inlineLabels])}>
@@ -115,12 +113,8 @@ export default function ProjectMergeForm(props: ProjectMergeFromProps) {
URL Presets
</label>
<label>
<Switch variant='ontime' {...register('osc')} />
OSC Integration
</label>
<label>
<Switch variant='ontime' {...register('http')} />
HTTP Integration
<Switch variant='ontime' {...register('automation')} />
Automation Settings
</label>
</Panel.Section>
</Panel.Section>
@@ -6,39 +6,18 @@
color: $blue-500;
}
.actionButton {
flex: 1;
text-align: right;
}
.form {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 1rem;
}
.formInput {
flex: 2;
}
.headerButtons,
.actionButtons,
.createActionButtons {
display: flex;
align-items: center;
gap: 1rem;
}
.actionButtons {
margin-left: 1rem;
}
.createActionButtons {
margin-left: 1rem;
justify-content: flex-end;
}
.saveButton {
text-transform: capitalize;
}
@@ -62,12 +41,6 @@
}
}
.flex {
display: flex;
gap: 1rem;
height: 100px;
}
.uploadLogoCard {
display: flex;
gap: 1rem;
@@ -12,8 +12,6 @@ import * as Panel from '../../panel-utils/PanelUtils';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface GSheetSetupProps {
onCancel: () => void;
}
@@ -176,7 +174,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
</Panel.ListGroup>
{!canAuthenticate ? (
<Panel.ListGroup>
<div className={style.buttonRow}>
<Panel.InlineElements>
<Button
variant='ontime-subtle'
size='sm'
@@ -187,11 +185,11 @@ export default function GSheetSetup(props: GSheetSetupProps) {
>
Connect
</Button>
</div>
</Panel.InlineElements>
</Panel.ListGroup>
) : (
<Panel.ListGroup>
<div className={style.buttonRow}>
<Panel.InlineElements>
{isAuthenticating && <Spinner />}
<CopyTag copyValue={authKey ?? ''} label='Google Auth Key' disabled={!canAuthenticate} size='sm'>
{authKey ? authKey : 'Upload files to generate Auth Key'}
@@ -205,7 +203,7 @@ export default function GSheetSetup(props: GSheetSetupProps) {
>
Authenticate
</Button>
</div>
</Panel.InlineElements>
</Panel.ListGroup>
)}
</Panel.Section>
@@ -8,8 +8,6 @@ import PreviewSpreadsheet from './preview/PreviewRundown';
import useGoogleSheet from './useGoogleSheet';
import { useSheetStore } from './useSheetStore';
import style from './SourcesPanel.module.scss';
interface ImportReviewProps {
rundown: OntimeRundown;
customFields: CustomFields;
@@ -40,14 +38,14 @@ export default function ImportReview(props: ImportReviewProps) {
<Panel.Section>
<Panel.Title>
Review Rundown
<div className={style.buttonRow}>
<Panel.InlineElements>
<Button onClick={handleCancel} variant='ontime-ghosted' size='sm' isDisabled={loading}>
Cancel
</Button>
<Button onClick={applyImport} variant='ontime-filled' size='sm' isLoading={loading}>
Apply
</Button>
</div>
</Panel.InlineElements>
</Panel.Title>
<PreviewSpreadsheet rundown={rundown} customFields={customFields} />
</Panel.Section>
@@ -29,17 +29,6 @@
}
}
.buttonRow {
display: flex;
gap: 1rem;
align-items: center;
justify-content: end;
}
.inputContainer {
flex: 1;
}
.singleActionCell {
width: 50px;
text-align: center;
@@ -84,7 +84,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
<Panel.Section as='form' id='import-map'>
<Panel.Title>
Import options
<div className={style.buttonRow}>
<Panel.InlineElements>
{!isSpreadsheet && (
<Tooltip label='Revoke the google authentication'>
<Button variant='ontime-subtle' size='sm' onClick={handleRevoke} isDisabled={isLoading}>
@@ -115,7 +115,7 @@ export default function ImportMapForm(props: ImportMapFormProps) {
>
Import preview
</Button>
</div>
</Panel.InlineElements>
</Panel.Title>
<Panel.Table>
<thead>
@@ -224,11 +224,11 @@ export default function ImportMapForm(props: ImportMapFormProps) {
})}
<tr>
<td />
<td className={style.buttonRow} colSpan={99}>
<Panel.InlineElements as='td' align='end'>
<Button size='sm' variant='ontime-subtle' rightIcon={<IoAdd />} onClick={addCustomImport}>
Add custom field
</Button>
</td>
</Panel.InlineElements>
<td />
</tr>
</tbody>
@@ -47,11 +47,12 @@ const staticOptions = [
split: true,
},
{
id: 'integrations',
label: 'Integrations',
id: 'automation',
label: 'Automation',
secondary: [
{ id: 'integrations__osc', label: 'OSC settings' },
{ id: 'integrations__http', label: 'HTTP settings' },
{ id: 'automation__settings', label: 'Automation settings' },
{ id: 'automation__triggers', label: 'Manage triggers' },
{ id: 'automation__automations', label: 'Manage automations' },
],
},
{
@@ -59,6 +60,10 @@ const staticOptions = [
label: 'Network',
split: true,
secondary: [
{
id: 'network__link',
label: 'Share link',
},
{
id: 'network__log',
label: 'Event log',
@@ -42,8 +42,6 @@ $info-hover: $section-white;
}
.buttonBar {
display: flex;
flex-wrap: wrap;
gap: 1em;
padding-bottom: 0.5rem;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { Button } from '@chakra-ui/react';
import { LogOrigin } from 'ontime-types';
import { clearLogs, useLogData } from '../../common/stores/logger';
import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss';
@@ -49,7 +50,7 @@ export default function Log() {
return (
<>
<div className={style.buttonBar}>
<Panel.InlineElements className={style.buttonBar}>
<Button
variant={showUser ? 'ontime-filled' : 'ontime-outlined'}
size='xs'
@@ -107,7 +108,7 @@ export default function Log() {
<Button variant='ontime-subtle' size='xs' onClick={clearLogs}>
Clear
</Button>
</div>
</Panel.InlineElements>
<ul className={style.log}>
{filteredData.map((logEntry) => (
<li key={logEntry.id} className={`${style.logEntry} ${style[logEntry.level]} `}>
@@ -1,6 +1,10 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../common/components/view-params-editor/constants';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const getOperatorOptions = (customFields: CustomFields, timeFormat: string): ViewOption[] => {
@@ -14,47 +18,55 @@ export const getOperatorOptions = (customFields: CustomFields, timeFormat: strin
}, {});
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main data field',
description: 'Field to be shown in the first line of text',
type: 'option',
values: fieldOptions,
defaultValue: 'title',
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
],
},
{
id: 'secondary',
title: 'Secondary data field',
description: 'Field to be shown in the second line of text',
type: 'option',
values: fieldOptions,
defaultValue: '',
},
{
id: 'subscribe',
title: 'Highlight Field',
description: 'Choose a custom field to highlight',
type: 'multi-option',
values: customFieldSelect,
},
{ section: 'Element visibility' },
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hidepast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'shouldEdit',
title: 'Edit custom field',
description: 'Allows editing an events selected custom field by long pressing.',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
@@ -1,44 +1,58 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getBackstageOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
title: OptionTitle.Schedule,
collapsible: true,
options: [
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
@@ -1,73 +1,77 @@
import { getTimeOption } from '../../../common/components/view-params-editor/constants';
import { getTimeOption, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'View style override' },
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
id: 'key',
title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour. Default: #FFFFFF',
type: 'colour',
defaultValue: 'FFFFFF',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
title: OptionTitle.ClockOptions,
collapsible: true,
options: [
{
id: 'key',
title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour. Default: #FFFFFF',
type: 'colour',
defaultValue: 'FFFFFF',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
],
},
];
@@ -1,7 +1,7 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
import { getTimeOption, hideTimerSeconds, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ParamField, ViewOption } from '../../../common/components/view-params-editor/types';
const makePersistedField = (id: string, value: string): ViewOption => {
const makePersistedField = (id: string, value: string): ParamField => {
return {
id,
title: 'Used to keep the selection on submit',
@@ -13,17 +13,20 @@ const makePersistedField = (id: string, value: string): ViewOption => {
type Persisted = { id: string; value: string };
export const getCountdownOptions = (timeFormat: string, persisted?: Persisted): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'View behaviour' },
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{ title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds] },
{
id: 'showProjected',
title: 'Show projected time',
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
type: 'boolean',
defaultValue: false,
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showProjected',
title: 'Show projected time',
description: 'Show projected times for the event, as well as apply the runtime offset to the timer.',
type: 'boolean',
defaultValue: false,
},
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
],
},
...(persisted ? [makePersistedField(persisted.id, persisted.value)] : []),
];
@@ -1,6 +1,6 @@
import { CustomFields } from 'ontime-types';
import { makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import { makeOptionsFromCustomFields, OptionTitle } from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] => {
@@ -16,102 +16,119 @@ export const getLowerThirdOptions = (customFields: CustomFields): ViewOption[] =
});
return [
{ section: 'Data sources' },
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'top-src',
title: 'Top Text',
description: '',
type: 'option',
values: topSourceOptions,
defaultValue: 'title',
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
},
],
},
{
id: 'bottom-src',
title: 'Bottom Text',
description: 'Select the data source for the bottom element',
type: 'option',
values: bottomSourceOptions,
defaultValue: 'none',
title: OptionTitle.Animation,
collapsible: true,
options: [
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
],
},
{ section: 'View animation' },
{
id: 'transition',
title: 'Transition',
description: 'Transition in time in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{
id: 'delay',
title: 'Delay',
description: 'Delay between transition in and out in seconds (default 3)',
type: 'number',
placeholder: '3 (default)',
},
{ section: 'View style override' },
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line. Default: #FF0000',
type: 'colour',
defaultValue: 'FF0000',
title: OptionTitle.StyleOverride,
collapsible: true,
options: [
{
id: 'top-size',
title: 'Top Text Size',
description: 'Font size of the top text',
type: 'string',
placeholder: '65px',
},
{
id: 'bottom-size',
title: 'Bottom Text Size',
description: 'Font size of the bottom text',
type: 'string',
placeholder: '64px',
},
{
id: 'width',
title: 'Minimum Width',
description: 'Minimum Width of the element',
type: 'number',
prefix: '%',
placeholder: '45 (default)',
},
{
id: 'key',
title: 'Key Colour',
description: 'Colour of the background. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'top-colour',
title: 'Top Text Colour',
description: 'Top text colour. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'bottom-colour',
title: 'Bottom Text Colour',
description: 'Bottom text colour. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'top-bg',
title: 'Top Background Colour',
description: 'Top text background colour. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'bottom-bg',
title: 'Bottom Background Colour',
description: 'Bottom text background colour. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'line-colour',
title: 'Line Colour',
description: 'Colour of the line. Default: #FF0000',
type: 'colour',
defaultValue: 'FF0000',
},
],
},
];
};
@@ -1,89 +1,101 @@
import { hideTimerSeconds, showLeadingZeros } from '../../../common/components/view-params-editor/constants';
import {
hideTimerSeconds,
OptionTitle,
showLeadingZeros,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const MINIMAL_TIMER_OPTIONS: ViewOption[] = [
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{ section: 'Element visibility' },
{ title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds, showLeadingZeros] },
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideovertime',
title: 'Hide Overtime',
description: 'Whether to suppress overtime styles (red borders and red text)',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
],
},
{
id: 'hideendmessage',
title: 'Hide End Message',
description: 'Whether to hide end message and continue showing the clock if timer is in overtime',
type: 'boolean',
defaultValue: false,
},
{ section: 'View style override' },
{
id: 'key',
title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour. Default: #FFFFFF',
type: 'colour',
defaultValue: 'FFFFFF',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
title: OptionTitle.StyleOverride,
collapsible: true,
options: [
{
id: 'key',
title: 'Key Colour',
description: 'Background or key colour for entire view. Default: #000000',
type: 'colour',
defaultValue: '000000',
},
{
id: 'text',
title: 'Text Colour',
description: 'Text colour. Default: #FFFFFF',
type: 'colour',
defaultValue: 'FFFFFF',
},
{
id: 'textbg',
title: 'Text Background',
description: 'Background colour for timer text. Default: #FFF0 (transparent)',
type: 'colour',
defaultValue: 'FFF0',
},
{
id: 'font',
title: 'Font',
description: 'Font family, will use the fonts available in the system',
type: 'string',
placeholder: 'Arial Black (default)',
},
{
id: 'size',
title: 'Text Size',
description: 'Scales the current style (0.5 = 50% 1 = 100% 2 = 200%)',
type: 'number',
placeholder: '1 (default)',
},
{
id: 'alignx',
title: 'Align Horizontal',
description: 'Moves the horizontally in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsetx',
title: 'Offset Horizontal',
description: 'Offsets the timer horizontal position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
{
id: 'aligny',
title: 'Align Vertical',
description: 'Moves the vertically in page to start = left | center | end = right',
type: 'option',
values: { start: 'Start', center: 'Center', end: 'End' },
defaultValue: 'center',
},
{
id: 'offsety',
title: 'Offset Vertical',
description: 'Offsets the timer vertical position by a given amount in pixels',
type: 'number',
placeholder: '0 (default)',
},
],
},
];
@@ -1,44 +1,57 @@
import { CustomFields } from 'ontime-types';
import { getTimeOption, makeOptionsFromCustomFields } from '../../../common/components/view-params-editor/constants';
import {
getTimeOption,
makeOptionsFromCustomFields,
OptionTitle,
} from '../../../common/components/view-params-editor/constants';
import { ViewOption } from '../../../common/components/view-params-editor/types';
export const getPublicOptions = (timeFormat: string, customFields: CustomFields): ViewOption[] => {
const secondaryOptions = makeOptionsFromCustomFields(customFields, { note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Data sources' },
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
{ section: 'Schedule options' },
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'secondary-src',
title: 'Event secondary text',
description: 'Select the data source for auxiliary text shown in now and next cards',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
title: OptionTitle.Schedule,
collapsible: true,
options: [
{
id: 'eventsPerPage',
title: 'Events per page',
description: 'Sets the number of events on the page, can cause overflow',
type: 'number',
placeholder: '8 (default)',
},
{
id: 'hidePast',
title: 'Hide past events',
description: 'Scheduler will only show upcoming events',
type: 'boolean',
defaultValue: false,
},
{
id: 'stopCycle',
title: 'Stop cycling through event pages',
description: 'Schedule will not auto-cycle through events',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
@@ -1,17 +1,20 @@
import { getTimeOption, hideTimerSeconds } from '../../../common/components/view-params-editor/constants';
import { getTimeOption, hideTimerSeconds, OptionTitle } from '../../../common/components/view-params-editor/constants';
import type { ViewOption } from '../../../common/components/view-params-editor/types';
export const getStudioClockOptions = (timeFormat: string): ViewOption[] => [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
{ section: 'Element visibility' },
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{ title: OptionTitle.TimerOptions, collapsible: true, options: [hideTimerSeconds] },
{
id: 'hideRight',
title: 'Hide right section',
description: 'Hides the right section with On Air indicator and the schedule',
type: 'boolean',
defaultValue: false,
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideRight',
title: 'Hide right section',
description: 'Hides the right section with On Air indicator and the schedule',
type: 'boolean',
defaultValue: false,
},
],
},
];
+21
View File
@@ -1,3 +1,24 @@
export const ontimeRadio = {
control: {
borderColor: '#262626', // $gray-1200
backgroundColor: '#262626', // $gray-1200
_checked: {
borderColor: '#262626', // $gray-1200
color: '#f6f6f6', // $ui-white
backgroundColor: '#f6f6f6', // $ui-white
},
},
label: {
color: '#9d9d9d', // $gray-500, same as placeholder value
_checked: {
color: '#f6f6f6', // $gray-200
},
_hover: {
color: '#e2e2e2', // $gray-200
},
},
};
export const ontimeBlockRadio = {
control: {
borderColor: '#262626', // $gray-1200
+2 -1
View File
@@ -14,7 +14,7 @@ import { ontimeDrawer } from './ontimeDrawer';
import { ontimeEditable } from './ontimeEditable';
import { ontimeMenuOnDark } from './ontimeMenu';
import { ontimeModal } from './ontimeModal';
import { ontimeBlockRadio } from './ontimeRadio';
import { ontimeBlockRadio, ontimeRadio } from './ontimeRadio';
import { ontimeSelect } from './ontimeSelect';
import { ontimeSwitch } from './ontimeSwitch';
import { ontimeTab } from './ontimeTab';
@@ -109,6 +109,7 @@ const theme = extendTheme({
},
Radio: {
variants: {
ontime: { ...ontimeRadio },
'ontime-block': { ...ontimeBlockRadio },
},
},
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
import { isStringBoolean } from '../../features/viewers/common/viewUtils';
@@ -9,56 +10,66 @@ import { isStringBoolean } from '../../features/viewers/common/viewUtils';
* we save the user preferences in the local storage
*/
export const cuesheetOptions: ViewOption[] = [
{ section: 'Table options' },
{
id: 'showActionMenu',
title: 'Show action menu',
description: 'Whether to show the action menu for every row in the table',
type: 'boolean',
defaultValue: false,
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'showActionMenu',
title: 'Show action menu',
description: 'Whether to show the action menu for every row in the table',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideTableSeconds',
title: 'Hide seconds in table',
description: 'Whether to hide seconds in the time fields displayed in the table',
type: 'boolean',
defaultValue: false,
},
{
id: 'followSelected',
title: 'Follow selected event',
description: 'Whether the view should automatically scroll to the selected event',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideIndexColumn',
title: 'Hide index column',
description: 'Whether the hide the event indexes in the table',
type: 'boolean',
defaultValue: false,
},
],
},
{
id: 'hideTableSeconds',
title: 'Hide seconds in table',
description: 'Whether to hide seconds in the time fields displayed in the table',
type: 'boolean',
defaultValue: false,
},
{
id: 'followSelected',
title: 'Follow selected event',
description: 'Whether the view should automatically scroll to the selected event',
type: 'boolean',
defaultValue: false,
},
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideIndexColumn',
title: 'Hide index column',
description: 'Whether the hide the event indexes in the table',
type: 'boolean',
defaultValue: false,
},
{ section: 'Delay flow' },
{
id: 'showDelayedTimes',
title: 'Show delayed times',
description: 'Whether the time fields should include delays',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideDelays',
title: 'Hide delays',
description: 'Whether to hide the rows containing scheduled delays',
type: 'boolean',
defaultValue: false,
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showDelayedTimes',
title: 'Show delayed times',
description: 'Whether the time fields should include delays',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideDelays',
title: 'Hide delays',
description: 'Whether to hide the rows containing scheduled delays',
type: 'boolean',
defaultValue: false,
},
],
},
];
@@ -1,19 +1,25 @@
import { OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const projectInfoOptions: ViewOption[] = [
{ section: 'Data visibility' },
{
id: 'showBackstage',
title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showPublic',
title: 'Show Public Data',
description: 'Weather to show fields related to the public views',
type: 'boolean',
defaultValue: false,
title: OptionTitle.BehaviourOptions,
collapsible: true,
options: [
{
id: 'showBackstage',
title: 'Show backstage Data',
description: 'Weather to show fields related to the backstage views',
type: 'boolean',
defaultValue: false,
},
{
id: 'showPublic',
title: 'Show Public Data',
description: 'Weather to show fields related to the public views',
type: 'boolean',
defaultValue: false,
},
],
},
];
@@ -1,22 +1,28 @@
import { getTimeOption } from '../../common/components/view-params-editor/constants';
import { getTimeOption, OptionTitle } from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
export const getTimelineOptions = (timeFormat: string): ViewOption[] => {
return [
getTimeOption(timeFormat),
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideBackstage',
title: 'Hide Private Events',
description: 'Whether to hide non-public events',
type: 'boolean',
defaultValue: false,
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hidePast',
title: 'Hide Past Events',
description: 'Whether to hide events that have passed',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideBackstage',
title: 'Hide Private Events',
description: 'Whether to hide non-public events',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
+76 -60
View File
@@ -7,6 +7,7 @@ import {
getTimeOption,
hideTimerSeconds,
makeOptionsFromCustomFields,
OptionTitle,
showLeadingZeros,
} from '../../common/components/view-params-editor/constants';
import { ViewOption } from '../../common/components/view-params-editor/types';
@@ -25,71 +26,86 @@ export const getTimerOptions = (timeFormat: string, customFields: CustomFields):
const secondaryOptions = makeOptionsFromCustomFields(customFields, { title: 'Title', note: 'Note' });
return [
{ section: 'Clock Options' },
getTimeOption(timeFormat),
{ section: 'Timer Options' },
hideTimerSeconds,
showLeadingZeros,
{ title: OptionTitle.ClockOptions, collapsible: true, options: [getTimeOption(timeFormat)] },
{
id: 'timerType',
title: 'Timer type',
description: 'Override the timer type',
type: 'option',
values: timerDisplayOptions,
defaultValue: 'no-overrides',
},
{ section: 'Data sources' },
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
title: OptionTitle.TimerOptions,
collapsible: true,
options: [
hideTimerSeconds,
showLeadingZeros,
{
id: 'timerType',
title: 'Timer type',
description: 'Override the timer type',
type: 'option',
values: timerDisplayOptions,
defaultValue: 'no-overrides',
},
],
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
title: OptionTitle.DataSources,
collapsible: true,
options: [
{
id: 'main',
title: 'Main text',
description: 'Select the data source for the main text',
type: 'option',
values: mainOptions,
defaultValue: 'Title',
},
{
id: 'secondary-src',
title: 'Secondary text',
description: 'Select the data source for the secondary text',
type: 'option',
values: secondaryOptions,
defaultValue: '',
},
],
},
{ section: 'Element visibility' },
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Timer Message',
description: 'Prevents displaying fullscreen messages in the timer',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide Auxiliary timer / External message',
description: 'Prevents the screen from displaying the secondary timer field',
type: 'boolean',
defaultValue: false,
title: OptionTitle.ElementVisibility,
collapsible: true,
options: [
{
id: 'hideClock',
title: 'Hide Time Now',
description: 'Hides the Time Now field',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideCards',
title: 'Hide Cards',
description: 'Hides the Now and Next cards',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideProgress',
title: 'Hide progress bar',
description: 'Hides the progress bar',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideMessage',
title: 'Hide Timer Message',
description: 'Prevents displaying fullscreen messages in the timer',
type: 'boolean',
defaultValue: false,
},
{
id: 'hideExternal',
title: 'Hide Auxiliary timer / External message',
description: 'Prevents the screen from displaying the secondary timer field',
type: 'boolean',
defaultValue: false,
},
],
},
];
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-electron",
"version": "3.10.5",
"version": "3.11.0-beta.1",
"author": "Carlos Valente",
"description": "Time keeping for live events",
"repository": "https://github.com/cpvalente/ontime",
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "ontime-server",
"type": "module",
"main": "src/index.ts",
"version": "3.10.5",
"version": "3.11.0-beta.1",
"exports": "./src/index.js",
"dependencies": {
"@googleapis/sheets": "^5.0.5",
@@ -18,8 +18,8 @@
"got": "^14.4.5",
"lowdb": "^7.0.1",
"multer": "^1.4.5-lts.1",
"node-osc": "^9.1.4",
"ontime-utils": "workspace:*",
"osc-min": "^2.1.1",
"sanitize-filename": "^1.6.3",
"steno": "^4.0.2",
"ws": "^8.18.0",
+39 -17
View File
@@ -1,33 +1,51 @@
import { LogOrigin } from 'ontime-types';
import { Server } from 'node-osc';
import { fromBuffer } from 'osc-min';
import * as dgram from 'node:dgram';
import { IAdapter } from './IAdapter.js';
import type { IAdapter } from './IAdapter.js';
import { logger } from '../classes/Logger.js';
import { integrationPayloadFromPath } from './utils/parse.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
import { isOntimeCloud } from '../externals.js';
export class OscServer implements IAdapter {
private readonly osc: Server;
class OscServer implements IAdapter {
private udpSocket: dgram.Socket | null = null;
constructor(portIn: number) {
this.osc = new Server(portIn, '0.0.0.0');
this.osc.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
this.osc.on('message', (msg) => {
init(port: number) {
if (isOntimeCloud) {
logger.warning(LogOrigin.Rx, 'OSC: Skip starting server in cloud environment');
}
this.udpSocket?.close();
logger.info(LogOrigin.Rx, `OSC: Starting server on port ${port}`);
this.udpSocket = dgram.createSocket('udp4');
this.udpSocket.on('error', (error) => logger.error(LogOrigin.Rx, `OSC IN: ${error}`));
this.udpSocket.on('message', (buf: ArrayBuffer) => {
// message should look like /ontime/{command}/{params?} {args} where
// ontime: fixed message for app
// command: command to be called
// params: used to create a nested object to patch with
// args: extra data, only used on some API entries
// split message
const [, address, command, ...params] = msg[0].split('/');
const args = msg[1];
/**
* TODO: remove this type casting when mergend in deleration file
* https://github.com/DefinitelyTyped/DefinitelyTyped/pull/71659
*/
const msg = fromBuffer(buf);
if (msg.oscType === 'bundle') {
//TODO: manage bundles
logger.error(LogOrigin.Rx, `OSC IN: We don't take bundles`);
return;
}
// get first part before (ontime)
if (address !== 'ontime') {
const { address, args: oscArgs } = msg;
// split message
const [, ontimeKey, command, ...params] = address.split('/');
const args = oscArgs[0]?.value ?? undefined; //TODO: manage multiple args or mayeb we have no usecase
// get first part (ontime)
if (ontimeKey !== 'ontime') {
logger.error(LogOrigin.Rx, `OSC IN: OSC messages to ontime must start with /ontime/, received: ${msg}`);
return;
}
@@ -50,9 +68,13 @@ export class OscServer implements IAdapter {
logger.error(LogOrigin.Rx, `OSC IN: ${error}`);
}
});
this.udpSocket.bind(port);
}
shutdown() {
this.osc?.close();
logger.info(LogOrigin.Rx, `OSC: Closing server`);
this.udpSocket?.close();
this.udpSocket = null;
}
}
export const oscServer = new OscServer();
+1 -1
View File
@@ -20,7 +20,7 @@ import { WebSocket, WebSocketServer } from 'ws';
import type { Server } from 'http';
import getRandomName from '../utils/getRandomName.js';
import { IAdapter } from './IAdapter.js';
import type { IAdapter } from './IAdapter.js';
import { eventStore } from '../stores/EventStore.js';
import { logger } from '../classes/Logger.js';
import { dispatchFromAdapter } from '../api-integration/integration.controller.js';
@@ -0,0 +1,232 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation } from 'ontime-types';
import {
addTrigger,
addAutomation,
deleteAll,
deleteAllTriggers,
deleteTrigger,
deleteAutomation,
editTrigger,
editAutomation,
getAutomationTriggers,
getAutomations,
} from '../automation.dao.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
// Create a small mock store
let automations = {
enabledAutomations: true,
enabledOscIn: true,
oscPortIn: 8888,
triggers: [],
automations: {},
};
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
getAutomation: vi.fn().mockImplementation(() => automations),
setAutomation: vi.fn().mockImplementation((newData) => (automations = newData)),
};
}),
};
});
});
afterAll(() => {
vi.clearAllMocks();
});
describe('addTrigger()', () => {
beforeEach(() => {
deleteAllTriggers();
});
it('should accept a valid automation', () => {
const testData: TriggerDTO = {
title: 'test',
trigger: TimerLifeCycle.onLoad,
automationId: 'test-automation-id',
};
const trigger = addTrigger(testData);
expect(trigger).toMatchObject(testData);
});
});
describe('editTrigger()', () => {
beforeEach(() => {
deleteAllTriggers();
addTrigger({
title: 'test-osc',
trigger: TimerLifeCycle.onLoad,
automationId: 'test-osc-automation',
});
addTrigger({
title: 'test-http',
trigger: TimerLifeCycle.onFinish,
automationId: 'test-http-automation',
});
});
it('should edit the contents of an automation', () => {
const triggers = getAutomationTriggers();
const fistTrigger = triggers[0];
expect(fistTrigger).toMatchObject({ id: expect.any(String), title: 'test-osc' });
const editedOSC = editTrigger(fistTrigger.id, {
title: 'edited-title',
trigger: TimerLifeCycle.onDanger,
automationId: 'test-osc-automation',
});
expect(editedOSC).toMatchObject({
id: expect.any(String),
title: 'edited-title',
trigger: TimerLifeCycle.onDanger,
automationId: 'test-osc-automation',
});
});
});
describe('deleteTrigger()', () => {
beforeEach(() => {
deleteAllTriggers();
addTrigger({
title: 'test-osc',
trigger: TimerLifeCycle.onLoad,
automationId: 'test-osc-automation',
});
addTrigger({
title: 'test-http',
trigger: TimerLifeCycle.onFinish,
automationId: 'test-http-automation',
});
});
it('should remove an automation from the list', () => {
const triggers = getAutomationTriggers();
expect(triggers.length).toEqual(2);
const fistTrigger = triggers[0];
expect(fistTrigger).toMatchObject({ id: expect.any(String), title: 'test-osc' });
deleteTrigger(fistTrigger.id);
const removed = getAutomationTriggers();
expect(removed.length).toEqual(1);
expect(removed[0].title).not.toEqual('test-osc');
});
});
describe('addAutomation()', () => {
beforeEach(() => {
deleteAll();
});
it('should accept a valid automation', () => {
const testData: AutomationDTO = {
title: 'test',
filterRule: 'all',
filters: [],
outputs: [makeOSCAction(), makeHTTPAction()],
};
const automation = addAutomation(testData);
const automations = getAutomations();
expect(automations[automation.id]).toMatchObject(testData);
});
});
describe('editAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(() => {
deleteAll();
firstAutomation = addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [],
});
addAutomation({
title: 'test-http',
filterRule: 'all',
filters: [],
outputs: [],
});
});
it('should edit the contents of an automation', () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(2);
expect(automations[firstAutomation.id]).toMatchObject({
id: firstAutomation.id,
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
});
const editedOSC = editAutomation(firstAutomation.id, {
title: 'edited-title',
filterRule: 'any',
filters: [],
outputs: [],
});
expect(editedOSC).toMatchObject({
id: firstAutomation.id,
title: 'edited-title',
filterRule: 'any',
filters: expect.any(Array),
outputs: expect.any(Array),
});
});
});
describe('deleteAutomation()', () => {
// saving the ID of the added automation
let firstAutomation: Automation;
beforeEach(() => {
deleteAll();
firstAutomation = addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [],
});
});
it('should remove m automation from the list', () => {
const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1);
deleteAutomation(Object.keys(automations)[0]);
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('should not remove an automation which is in use', () => {
const automations = getAutomations();
addTrigger({
title: 'test-automation',
trigger: TimerLifeCycle.onLoad,
automationId: firstAutomation.id,
});
const automationKeys = Object.keys(automations);
const automationId = automationKeys[0];
expect(automationId).toEqual(firstAutomation.id);
expect(automationKeys.length).toEqual(1);
expect(automations[automationId]).toMatchObject({
id: automationId,
title: 'test-osc',
filterRule: 'all',
filters: expect.any(Array),
outputs: expect.any(Array),
});
expect(() => deleteAutomation(automationId)).toThrowError();
});
});
@@ -0,0 +1,316 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { makeRuntimeStateData } from '../../../stores/__mocks__/runtimeState.mocks.js';
import { makeOntimeEvent } from '../../../services/rundown-service/__mocks__/rundown.mocks.js';
import { deleteAllTriggers, addTrigger, addAutomation } from '../automation.dao.js';
import { testConditions, triggerAutomations } from '../automation.service.js';
import * as oscClient from '../clients/osc.client.js';
import * as httpClient from '../clients/http.client.js';
import { makeOSCAction, makeHTTPAction } from './testUtils.js';
beforeAll(() => {
vi.mock('../../../classes/data-provider/DataProvider.js', () => {
// Create a small mock store
let automations = {
enabledAutomations: true,
enabledOscIn: true,
oscPortIn: 8888,
triggers: [],
automations: {},
};
return {
getDataProvider: vi.fn().mockImplementation(() => {
return {
getAutomation: vi.fn().mockImplementation(() => automations),
setAutomation: vi.fn().mockImplementation((newData) => (automations = newData)),
};
}),
};
});
});
afterAll(() => {
vi.clearAllMocks();
});
describe('triggerAction()', () => {
let oscSpy = vi.spyOn(oscClient, 'emitOSC');
let httpSpy = vi.spyOn(httpClient, 'emitHTTP');
beforeEach(() => {
oscSpy = vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
httpSpy = vi.spyOn(httpClient, 'emitHTTP').mockImplementation(() => {});
deleteAllTriggers();
const oscAutomation = addAutomation({
title: 'test-osc',
filterRule: 'all',
filters: [],
outputs: [makeOSCAction()],
});
const httpAutomation = addAutomation({
title: 'test-http',
filterRule: 'any',
filters: [],
outputs: [makeHTTPAction()],
});
addTrigger({
title: 'test-osc',
trigger: TimerLifeCycle.onLoad,
automationId: oscAutomation.id,
});
addTrigger({
title: 'test-http',
trigger: TimerLifeCycle.onFinish,
automationId: httpAutomation.id,
});
});
it('should trigger automations for a given action', () => {
const state = makeRuntimeStateData();
triggerAutomations(TimerLifeCycle.onLoad, state);
expect(oscSpy).toHaveBeenCalledTimes(1);
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onStart, state);
expect(oscClient.emitOSC).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onFinish, state);
expect(oscSpy).not.toBeCalled();
expect(httpSpy).toHaveBeenCalledTimes(1);
oscSpy.mockReset();
httpSpy.mockReset();
triggerAutomations(TimerLifeCycle.onStop, state);
expect(oscSpy).not.toBeCalled();
expect(httpSpy).not.toBeCalled();
});
});
describe('testConditions()', () => {
it('should return true when no filters are provided', () => {
const result = testConditions([], 'all', {});
expect(result).toBe(true);
});
describe('equals operator', () => {
it('should compare two equal values', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'equals', value: '10' }], 'all', mockStore);
expect(result).toBe(true);
});
it('string comparisons should be case insensitive', () => {
const mockStore = makeRuntimeStateData({ eventNow: { title: 'Title' } as PlayableEvent });
const result = testConditions(
[{ field: 'eventNow.title', operator: 'equals', value: 'title' }],
'all',
mockStore,
);
expect(result).toBe(true);
});
it('should check if a value does not exist', () => {
const mockStore = makeRuntimeStateData({ eventNow: null });
const result = testConditions([{ field: 'eventNow.title', operator: 'equals', value: '' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle trueness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: true } as PlayableEvent });
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(false);
});
it('should handle falseness boolean comparisons', () => {
const mockStore = makeRuntimeStateData({ eventNow: { countToEnd: false } as PlayableEvent });
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'false' }], 'all', mockStore),
).toBe(true);
expect(
testConditions([{ field: 'eventNow.countToEnd', operator: 'equals', value: 'true' }], 'all', mockStore),
).toBe(false);
});
});
describe('not_equals operator', () => {
it('should check if two values are different', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'not_equals', value: '11' }], 'all', mockStore);
expect(result).toBe(true);
});
});
describe('greater_than operator', () => {
it('should check if the given value is smaller', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: '9' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'greater_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
});
});
describe('less_than operator', () => {
it('should check if the given value is larger', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: '11' }], 'all', mockStore);
expect(result).toBe(true);
});
it('should handle values which are not numbers', () => {
const mockStore = makeRuntimeStateData({ clock: 10 });
const result = testConditions([{ field: 'clock', operator: 'less_than', value: 'testing' }], 'all', mockStore);
expect(result).toBe(false);
});
});
describe('contains operator', () => {
it('should check if value contains given string', () => {
const result = testConditions(
[{ field: 'eventNow.title', operator: 'contains', value: 'lighting' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result).toBe(true);
const result2 = testConditions(
[{ field: 'eventNow.title', operator: 'contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(false);
});
});
describe('not_contains operator', () => {
it('should check if value does not contain given string', () => {
const result = testConditions(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'lighting' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result).toBe(false);
const result2 = testConditions(
[{ field: 'eventNow.title', operator: 'not_contains', value: 'sound' }],
'all',
makeRuntimeStateData({ eventNow: makeOntimeEvent({ title: 'test-lighting' }) as PlayableEvent }),
);
expect(result2).toBe(true);
});
});
describe('for all filter rule', () => {
it('should return true when all filters are true', () => {
const mockStore = makeRuntimeStateData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
custom: { av: 'av-value' },
}) as PlayableEvent,
});
const result = testConditions(
[
{ field: 'clock', operator: 'equals', value: '10' },
{ field: 'eventNow.title', operator: 'equals', value: 'test' },
{ field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' },
],
'all',
mockStore,
);
expect(result).toBe(true);
});
it('should return false if any filters are false', () => {
const mockStore = makeRuntimeStateData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
custom: { av: 'not-av-value' },
}) as PlayableEvent,
});
const result = testConditions(
[
{ field: 'clock', operator: 'equals', value: '11' },
{ field: 'eventNow.title', operator: 'equals', value: 'test' },
{ field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' },
],
'all',
mockStore,
);
expect(result).toBe(false);
});
});
describe('for any filter rule', () => {
it('should return true when all filters are true', () => {
const mockStore = makeRuntimeStateData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'test',
custom: { av: 'av-value' },
}) as PlayableEvent,
});
const result = testConditions(
[
{ field: 'clock', operator: 'equals', value: '10' },
{ field: 'eventNow.title', operator: 'equals', value: 'test' },
{ field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' },
],
'any',
mockStore,
);
expect(result).toBe(true);
});
it('should return true if any filters are true', () => {
const mockStore = makeRuntimeStateData({
clock: 10,
eventNow: makeOntimeEvent({
title: 'not-test',
custom: { av: 'av-value' },
}) as PlayableEvent,
});
const result = testConditions(
[
{ field: 'clock', operator: 'equals', value: '11' },
{ field: 'eventNow.title', operator: 'equals', value: 'not-test' },
{ field: 'eventNow.custom.av', operator: 'equals', value: 'av-value' },
],
'any',
mockStore,
);
expect(result).toBe(true);
});
it('should return false if all filters are false', () => {
const mockStore = makeRuntimeStateData({
clock: 10,
eventNow: makeOntimeEvent({ title: 'test' }) as PlayableEvent,
});
const result = testConditions(
[
{ field: 'clock', operator: 'equals', value: '11' },
{ field: 'eventNow.title', operator: 'equals', value: 'not-test' },
],
'any',
mockStore,
);
expect(result).toBe(false);
});
});
});
@@ -1,5 +1,4 @@
import { stringToOSCArgs } from '../../utils/oscArgParser.js';
import { parseTemplateNested } from './integrationUtils.js';
import { parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => {
it('parses string with a single-level variable name', () => {
@@ -114,31 +113,31 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
const payloads = [
{
test: '"string with space and {{not.so.easy}}"',
expect: [{ type: 'string', value: 'string with space and data with space' }],
expect: { type: 'string', value: 'string with space and data with space' },
},
{
test: '',
expect: [],
expect: undefined,
},
{
test: ' ',
expect: [],
expect: undefined,
},
{
test: '""',
expect: [{ type: 'string', value: '' }],
expect: { type: 'string', value: '' },
},
{
test: '"string with space and {{not.so.empty}}"',
expect: [{ type: 'string', value: 'string with space and ' }],
expect: { type: 'string', value: 'string with space and ' },
},
{
test: '"string with space and {{not.so.number}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
expect: { type: 'string', value: 'string with space and 1234' },
},
{
test: '"string with space and {{not.so.stringNumber}}"',
expect: [{ type: 'string', value: 'string with space and 1234' }],
expect: { type: 'string', value: 'string with space and 1234' },
},
{
test: '"{{not.so.easy}}" 1',
@@ -156,7 +155,7 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
},
{
test: '',
expect: [],
expect: undefined,
},
];
@@ -167,3 +166,82 @@ describe('parseNestedTemplate() -> stringToOSCArgs()', () => {
});
});
});
describe('test stringToOSCArgs()', () => {
it('all types', () => {
const test = 'test 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'true' },
{ type: 'false' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = undefined;
const expected = undefined;
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('empty is nothing', () => {
const test = '';
const expected = undefined;
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('1 space is nothing', () => {
const test = ' ';
const expected = undefined;
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep other types in strings', () => {
const test = 'test "1111" "0.1111" "TRUE" "FALSE"';
const expected = [
{ type: 'string', value: 'test' },
{ type: 'string', value: '1111' },
{ type: 'string', value: '0.1111' },
{ type: 'string', value: 'TRUE' },
{ type: 'string', value: 'FALSE' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces in quoted strings', () => {
const test = '"test space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'true' },
{ type: 'false' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('keep spaces escaped quotes', () => {
const test = '"test \\" space" 1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'string', value: 'test " space' },
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'true' },
{ type: 'false' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
it('2 spaces', () => {
const test = '1111 0.1111 TRUE FALSE';
const expected = [
{ type: 'integer', value: 1111 },
{ type: 'float', value: 0.1111 },
{ type: 'true' },
{ type: 'false' },
];
expect(stringToOSCArgs(test)).toStrictEqual(expected);
});
});
@@ -0,0 +1,20 @@
import { OSCOutput, HTTPOutput } from 'ontime-types';
export function makeOSCAction(action?: Partial<OSCOutput>): OSCOutput {
return {
type: 'osc',
targetIP: 'localhost',
targetPort: 3000,
address: 'test',
args: 'message',
...action,
};
}
export function makeHTTPAction(action?: Partial<HTTPOutput>): HTTPOutput {
return {
type: 'http',
url: 'localhost',
...action,
};
}
@@ -0,0 +1,124 @@
import { getErrorMessage } from 'ontime-utils';
import { Automation, AutomationOutput, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
import type { Request, Response } from 'express';
import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js';
import { oscServer } from '../../adapters/OscAdapter.js';
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
res.json(automationDao.getAutomationSettings());
}
export function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
try {
// body payload is a patch object that must contain root properties
const automationSettings = automationDao.editAutomationSettings({
enabledAutomations: req.body.enabledAutomations,
enabledOscIn: req.body.enabledOscIn,
oscPortIn: req.body.oscPortIn,
triggers: req.body.triggers ?? undefined,
automations: req.body.automations ?? undefined,
});
if (automationSettings.enabledOscIn) {
oscServer.init(automationSettings.oscPortIn);
} else {
oscServer.shutdown();
}
res.status(200).send(automationSettings);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function postTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
try {
const automation = automationDao.addTrigger({
title: req.body.title,
trigger: req.body.trigger,
automationId: req.body.automationId,
});
res.status(201).send(automation);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function putTrigger(req: Request, res: Response<Trigger | ErrorResponse>) {
try {
// body payload is a patch object
const automation = automationDao.editTrigger(req.params.id, {
title: req.body.title ?? undefined,
trigger: req.body.trigger ?? undefined,
automationId: req.body.automationId ?? undefined,
});
res.status(200).send(automation);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function deleteTrigger(req: Request, res: Response<void | ErrorResponse>) {
try {
automationDao.deleteTrigger(req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = automationDao.addAutomation({
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
outputs: req.body.outputs,
});
res.status(201).send(newAutomation);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = automationDao.editAutomation(req.params.id, {
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
outputs: req.body.outputs,
});
res.status(200).send(newAutomation);
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try {
automationDao.deleteAutomation(req.params.id);
res.status(204).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
export function testOutput(req: Request, res: Response<void | ErrorResponse>) {
try {
const payload = req.body as AutomationOutput;
automationService.testOutput(payload);
res.status(200).send();
} catch (error) {
const message = getErrorMessage(error);
res.status(400).send({ message });
}
}
@@ -0,0 +1,181 @@
import type { Automation, AutomationDTO, AutomationSettings, NormalisedAutomation, Trigger, TriggerDTO } from 'ontime-types';
import { deleteAtIndex, generateId } from 'ontime-utils';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
/**
* Gets a copy of the stored automation settings
*/
export function getAutomationSettings(): AutomationSettings {
return structuredClone(getDataProvider().getAutomation());
}
/**
* Gets the enabled status of the automations
*/
export function getAutomationsEnabled(): boolean {
return getAutomationSettings().enabledAutomations;
}
/**
* Gets a copy of the stored automation triggers
*/
export function getAutomationTriggers(): Trigger[] {
return getAutomationSettings().triggers;
}
/**
* Gets a copy of the stored automations
*/
export function getAutomations(): NormalisedAutomation {
return getAutomationSettings().automations;
}
/**
* Patches the automation settings object
*/
export function editAutomationSettings(settings: Partial<AutomationSettings>): AutomationSettings {
saveChanges(settings);
return getAutomationSettings();
}
/**
* Adds a validated automation to the store
*/
export function addTrigger(newTrigger: TriggerDTO): Trigger {
const triggers = getAutomationTriggers();
const id = getUniqueTriggerId(triggers);
const trigger = { ...newTrigger, id };
triggers.push(trigger);
saveChanges({ triggers });
return trigger;
}
/**
* Patches an existing automation trigger
*/
export function editTrigger(id: string, newTrigger: TriggerDTO): Trigger {
const triggers = getAutomationTriggers();
const index = triggers.findIndex((trigger) => trigger.id === id);
if (index === -1) {
throw new Error(`Automation with id ${id} not found`);
}
triggers[index] = { ...triggers[index], ...newTrigger };
saveChanges({ triggers });
return triggers[index];
}
/**
* Deletes an automation trigger given its ID
*/
export function deleteTrigger(id: string): void {
let triggers = getAutomationTriggers();
const index = triggers.findIndex((trigger) => trigger.id === id);
if (index === -1) {
throw new Error(`Automation with id ${id} not found`);
}
triggers = deleteAtIndex(index, triggers);
saveChanges({ triggers });
}
/**
* Deletes all project automation triggers
*/
export function deleteAllTriggers(): void {
saveChanges({ triggers: [] });
}
/**
* Deletes all project automation triggers and automations
* We do this together to avoid issues with missing references
*/
export function deleteAll(): void {
saveChanges({ triggers: [], automations: {} });
}
/**
* Adds a validated automation to the store
*/
export function addAutomation(newAutomation: AutomationDTO): Automation {
const automations = getAutomations();
const id = getUniqueAutomationId(automations);
automations[id] = { ...newAutomation, id };
saveChanges({ automations });
return automations[id];
}
/**
* Updates an existing automation with a new entry
*/
export function editAutomation(id: string, newAutomation: AutomationDTO): Automation {
const automations = getAutomations();
if (!Object.hasOwn(automations, id)) {
throw new Error(`Automation with id ${id} not found`);
}
automations[id] = { ...newAutomation, id };
saveChanges({ automations });
return automations[id];
}
/**
* Deletes a automation given its ID
*/
export function deleteAutomation(id: string): void {
const automations = getAutomations();
// ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) {
return;
}
// prevent deleting a automation that is in use
const triggers = getAutomationTriggers();
for (let i = 0; i < triggers.length; i++) {
const trigger = triggers[i];
if (trigger.automationId === id) {
throw new Error(`Unable to delete automation used in trigger ${trigger.title}`);
}
}
delete automations[id];
saveChanges({ automations });
}
/**
* Internal utility to patch the automation settings
*/
async function saveChanges(patch: Partial<AutomationSettings>) {
const automation = getDataProvider().getAutomation();
// remove undefined keys from object, we probably want a better solution
Object.keys(patch).forEach((key) => (patch[key] === undefined ? delete patch[key] : {}));
await getDataProvider().setAutomation({ ...automation, ...patch });
}
/**
* Returns an ID guaranteed to be unique in an array
*/
function getUniqueTriggerId(triggers: Trigger[]): string {
let id = '';
do {
id = generateId();
} while (isInArray(id));
function isInArray(id: string): boolean {
return triggers.some((trigger) => trigger.id === id);
}
return id;
}
/**
* Returns an ID guaranteed to be unique in an objects keys
*/
function getUniqueAutomationId(automations: NormalisedAutomation): string {
let id = '';
do {
id = generateId();
} while (Object.hasOwn(automations, id));
return id;
}

Some files were not shown because too many files have changed in this diff Show More