mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-13 18:19:40 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f04e8efc | |||
| 5c6824d1fd | |||
| 7a4a25fc55 | |||
| 6e9fc36abc | |||
| 67b874fe04 | |||
| 0f27334f97 | |||
| 90217108d6 |
@@ -4,6 +4,7 @@ import type {
|
||||
AutomationDTO,
|
||||
AutomationOutput,
|
||||
AutomationSettings,
|
||||
AutomationTriggerDTO,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
@@ -57,16 +58,20 @@ export function deleteTrigger(id: string): Promise<void> {
|
||||
/**
|
||||
* HTTP request to create a new automation
|
||||
*/
|
||||
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
|
||||
const res = await axios.post(`${automationsPath}/automation`, automation);
|
||||
export async function addAutomation(automation: AutomationDTO, triggers: AutomationTriggerDTO[]): Promise<Automation> {
|
||||
const res = await axios.post(`${automationsPath}/automation`, { ...automation, triggers });
|
||||
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);
|
||||
export async function editAutomation(
|
||||
id: string,
|
||||
automation: Automation,
|
||||
triggers?: AutomationTriggerDTO[],
|
||||
): Promise<Automation> {
|
||||
const res = await axios.put(`${automationsPath}/automation/${id}`, { ...automation, triggers });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
export const lifecycleLabels: Record<TimerLifeCycle, string> = {
|
||||
[TimerLifeCycle.onLoad]: 'On Load',
|
||||
[TimerLifeCycle.onStart]: 'On Start',
|
||||
[TimerLifeCycle.onPause]: 'On Pause',
|
||||
[TimerLifeCycle.onStop]: 'On Stop',
|
||||
[TimerLifeCycle.onClock]: 'Every second',
|
||||
[TimerLifeCycle.onUpdate]: 'On Timer Update',
|
||||
[TimerLifeCycle.onFinish]: 'On Finish',
|
||||
[TimerLifeCycle.onWarning]: 'On Warning',
|
||||
[TimerLifeCycle.onDanger]: 'On Danger',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a lifecycle to its user facing label, falling back to the raw value
|
||||
*/
|
||||
export function getLifecycleLabel(cycle: TimerLifeCycle | string): string {
|
||||
return lifecycleLabels[cycle as TimerLifeCycle] ?? cycle;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AutomationOutput } from 'ontime-types';
|
||||
|
||||
import { summariseOutputs } from '../automationOutputs';
|
||||
|
||||
describe('summariseOutputs', () => {
|
||||
it('returns an empty list when there are no outputs', () => {
|
||||
expect(summariseOutputs([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts repeated output kinds', () => {
|
||||
const outputs: AutomationOutput[] = [
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/stop', args: '' },
|
||||
{ type: 'http', url: 'http://127.0.0.1/start' },
|
||||
];
|
||||
|
||||
expect(summariseOutputs(outputs)).toEqual([
|
||||
{ type: 'osc', label: 'OSC', count: 2 },
|
||||
{ type: 'http', label: 'HTTP', count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('presents kinds in a stable order regardless of insertion order', () => {
|
||||
const outputs: AutomationOutput[] = [
|
||||
{ type: 'ontime', action: 'aux1-start' },
|
||||
{ type: 'http', url: 'http://127.0.0.1/start' },
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/go', args: '' },
|
||||
];
|
||||
|
||||
expect(summariseOutputs(outputs).map(({ type }) => type)).toEqual(['osc', 'http', 'ontime']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AutomationOutput } from 'ontime-types';
|
||||
|
||||
const outputLabels: Record<AutomationOutput['type'], string> = {
|
||||
osc: 'OSC',
|
||||
http: 'HTTP',
|
||||
ontime: 'Ontime',
|
||||
};
|
||||
|
||||
export type OutputSummary = {
|
||||
type: AutomationOutput['type'];
|
||||
label: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Summarises an automation's outputs by kind so that a list row can say what the
|
||||
* automation does without the user having to open the form.
|
||||
* Shared between the automation settings panel and the rundown event editor.
|
||||
*/
|
||||
export function summariseOutputs(outputs: AutomationOutput[]): OutputSummary[] {
|
||||
const counts = new Map<AutomationOutput['type'], number>();
|
||||
|
||||
for (const output of outputs) {
|
||||
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// keep a stable presentation order regardless of the order the user added outputs
|
||||
const order: AutomationOutput['type'][] = ['osc', 'http', 'ontime'];
|
||||
return order
|
||||
.filter((type) => counts.has(type))
|
||||
.map((type) => ({ type, label: outputLabels[type], count: counts.get(type) as number }));
|
||||
}
|
||||
@@ -82,10 +82,12 @@ $card-padding: 2rem;
|
||||
color: $error-red;
|
||||
}
|
||||
|
||||
// tables scroll with the panel rather than owning a nested scroll area,
|
||||
// which keeps the sticky table head anchored to the panel viewport
|
||||
// Tables retain the panel's vertical scroll so sticky headers remain anchored
|
||||
// to the panel viewport. They may scroll horizontally when their columns need
|
||||
// more room than a narrow settings panel can provide.
|
||||
.pad {
|
||||
padding: 0 var(--panel-card-padding, #{$card-padding});
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
|
||||
+69
-48
@@ -4,10 +4,8 @@
|
||||
gap: 2rem;
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $ui-white;
|
||||
|
||||
// the shared modal body owns scrolling for this regular form modal
|
||||
min-height: 100%;
|
||||
padding-block: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
@@ -26,61 +24,84 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.titleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
.titleSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-gap: 0.5rem;
|
||||
|
||||
button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.titleSection,
|
||||
.ruleSection,
|
||||
.filterSection,
|
||||
.oscSection,
|
||||
.httpSection,
|
||||
.actionSection {
|
||||
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);
|
||||
}
|
||||
.card {
|
||||
label {
|
||||
display: block;
|
||||
font-size: calc(1rem - 3px);
|
||||
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;
|
||||
}
|
||||
|
||||
.actionSection {
|
||||
grid-template-columns: auto 1fr 1fr auto;
|
||||
|
||||
.test {
|
||||
grid-column: -1;
|
||||
}
|
||||
}
|
||||
|
||||
.outputCard {
|
||||
/** shared shell for a single filter or output */
|
||||
.card {
|
||||
border: 1px solid $white-10;
|
||||
border-left: 0.25rem solid $gray-1200;
|
||||
padding-left: 0.5rem;
|
||||
border-radius: $component-border-radius-md;
|
||||
background-color: $black-10;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid $white-10;
|
||||
}
|
||||
|
||||
/** pushes the actions to the end of the header, and absorbs any overflow */
|
||||
.cardSummary {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: $aux-text-size;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/** for fields that read badly when narrow: OSC address and args, URLs, message text */
|
||||
.spanFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.testOk {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $green-400;
|
||||
}
|
||||
|
||||
.testError {
|
||||
padding: 0 0.75rem 0.5rem;
|
||||
}
|
||||
|
||||
.tagOsc {
|
||||
background-color: $blue-1000;
|
||||
color: $blue-300;
|
||||
}
|
||||
|
||||
.tagHttp {
|
||||
background-color: $green-1000;
|
||||
color: $green-300;
|
||||
}
|
||||
|
||||
.tagOntime {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
+338
-354
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
HTTPOutput,
|
||||
OSCOutput,
|
||||
OntimeAction,
|
||||
AutomationFilter,
|
||||
TimerLifeCycle,
|
||||
Trigger,
|
||||
isHTTPOutput,
|
||||
isOSCOutput,
|
||||
isOntimeAction,
|
||||
} from 'ontime-types';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
|
||||
@@ -16,6 +16,7 @@ import { addAutomation, editAutomation, testOutput } from '../../../../common/ap
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
@@ -25,29 +26,59 @@ import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils';
|
||||
import HttpOutputForm from './HttpOutputForm';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
import OscOutputForm from './OscOutputForm';
|
||||
import OutputCard, { type TestState } from './OutputCard';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||
const formId = 'automation-form';
|
||||
|
||||
/** how long a successful test keeps its confirmation on screen */
|
||||
const testFeedbackDuration = 2000;
|
||||
|
||||
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
|
||||
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
triggers?: Trigger[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
|
||||
export default function AutomationForm({ automation, triggers = [], onClose }: AutomationFormProps) {
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
|
||||
const [initialCycles] = useState<TimerLifeCycle[]>(() =>
|
||||
isAutomation(automation)
|
||||
? Array.from(
|
||||
new Set(
|
||||
triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger),
|
||||
),
|
||||
)
|
||||
: [],
|
||||
);
|
||||
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles);
|
||||
const cyclesAreDirty =
|
||||
selectedCycles.length !== initialCycles.length || selectedCycles.some((cycle) => !initialCycles.includes(cycle));
|
||||
|
||||
const toggleCycle = (cycle: TimerLifeCycle) => {
|
||||
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
|
||||
};
|
||||
|
||||
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
|
||||
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
const {
|
||||
clearErrors,
|
||||
control,
|
||||
handleSubmit,
|
||||
getValues,
|
||||
@@ -60,10 +91,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
} = useForm<AutomationDTO>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
title: automation?.title ?? '',
|
||||
filterRule: automation?.filterRule ?? 'all',
|
||||
filters: automation?.filters ?? [],
|
||||
outputs: automation?.outputs ?? [],
|
||||
title: automation.title,
|
||||
filterRule: automation.filterRule,
|
||||
filters: automation.filters,
|
||||
outputs: automation.outputs,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
@@ -88,11 +119,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
control,
|
||||
});
|
||||
|
||||
// give initial focus to the title field
|
||||
useEffect(() => {
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = feedbackTimers.current;
|
||||
return () => Object.values(timers).forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
const reportTest = (key: string, state: TestState) => {
|
||||
setTestResults((prev) => ({ ...prev, [key]: state }));
|
||||
clearTimeout(feedbackTimers.current[key]);
|
||||
|
||||
if (state.status === 'ok') {
|
||||
feedbackTimers.current[key] = setTimeout(() => {
|
||||
setTestResults((prev) => {
|
||||
const { [key]: _discarded, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, testFeedbackDuration);
|
||||
}
|
||||
};
|
||||
|
||||
const getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
|
||||
|
||||
const handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
@@ -106,84 +157,86 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
appendOutput({ type: 'http', url: '' });
|
||||
};
|
||||
|
||||
const handleAddnewOntimeAction = () => {
|
||||
const handleAddNewOntimeAction = () => {
|
||||
appendOutput({ type: 'ontime', action: 'aux1-start' });
|
||||
};
|
||||
|
||||
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 */
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Sends a single output as configured, without saving the automation.
|
||||
* OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
|
||||
*/
|
||||
const handleTest = async (index: number, key: string) => {
|
||||
const values = getValues(`outputs.${index}`);
|
||||
|
||||
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 */
|
||||
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
|
||||
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
|
||||
return;
|
||||
}
|
||||
if (isHTTPOutput(values) && !values.url) {
|
||||
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOntimeAction = async (index: number) => {
|
||||
reportTest(key, { status: 'sending' });
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OntimeAction;
|
||||
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
|
||||
await testOutput({
|
||||
...values,
|
||||
type: 'ontime',
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here */
|
||||
await testOutput(values);
|
||||
reportTest(key, { status: 'ok', message: 'Request sent' });
|
||||
} catch (error) {
|
||||
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
} else {
|
||||
await handleCreate(values);
|
||||
// a stale failure from the previous attempt would otherwise sit under a successful retry
|
||||
clearErrors('root');
|
||||
|
||||
try {
|
||||
if (!isAutomation(automation)) {
|
||||
await addAutomation(
|
||||
values,
|
||||
selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })),
|
||||
);
|
||||
refetch();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await editAutomation(
|
||||
automation.id,
|
||||
{ id: automation.id, ...values },
|
||||
selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })),
|
||||
);
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
const describeFilter = (index: number): string | null => {
|
||||
const field = watch(`filters.${index}.field`);
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldLabel = fieldList.find((option) => option.value === field)?.label ?? field;
|
||||
const operator = watch(`filters.${index}.operator`);
|
||||
const operatorLabel = operators.find((option) => option.value === operator)?.label ?? operator;
|
||||
const value = watch(`filters.${index}.value`);
|
||||
|
||||
return `${fieldLabel} ${operatorLabel} ${value ? `“${value}”` : 'nothing'}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A failed save reports itself as a root error, which react-hook-form counts against
|
||||
* isValid. Left alone that disables the very retry the message is asking the user to make,
|
||||
* so a root error on its own does not block submitting again.
|
||||
*/
|
||||
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
|
||||
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0);
|
||||
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -193,301 +246,232 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
showCloseButton
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<div className={style.ruleSection}>
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
{fieldFilters.map((field, index) => {
|
||||
const key = `filters.${index}.field.${field.id}`;
|
||||
return (
|
||||
<div key={key} className={style.filterSection}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(
|
||||
`filters.${index}.operator`,
|
||||
value as
|
||||
| 'equals'
|
||||
| 'not_equals'
|
||||
| 'greater_than'
|
||||
| 'less_than'
|
||||
| 'contains'
|
||||
| 'not_contains',
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'not equals' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
]}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<div>
|
||||
<div className={style.titleSection}>
|
||||
<label id='runs-on-label'>Runs on</label>
|
||||
<Panel.Description>
|
||||
Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
|
||||
single event from the event editor.
|
||||
</Panel.Description>
|
||||
<Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
|
||||
{cycles.map(({ label, value }) => {
|
||||
const isSelected = selectedCycles.includes(value);
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
size='small'
|
||||
variant={isSelected ? 'primary' : 'subtle'}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCycle(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Panel.InlineElements>
|
||||
{hasContinuousCycle && (
|
||||
<Panel.Description tone='warning'>
|
||||
Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
|
||||
mean to send on every tick.
|
||||
</Panel.Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<Panel.Description>
|
||||
Without filters the outputs are sent every time the automation is triggered.
|
||||
</Panel.Description>
|
||||
<div className={style.ruleSection}>
|
||||
{fieldFilters.length > 1 && (
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{fieldFilters.map((field, index) => {
|
||||
const description = describeFilter(index);
|
||||
return (
|
||||
<div key={field.id} className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag>Filter</Tag>
|
||||
<span className={style.cardSummary}>{description}</span>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
aria-label='Delete filter'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.cardBody}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={operators}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Automation outputs can be used to send data from Ontime to external software <br />
|
||||
or to change properties of Ontime itself. <br /> <br />
|
||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{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;
|
||||
{fieldOutputs.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='This automation does nothing yet'
|
||||
description='An automation without outputs will be triggered, but it has nothing to send.'
|
||||
/>
|
||||
)}
|
||||
|
||||
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' },
|
||||
})}
|
||||
fluid
|
||||
placeholder='127.0.0.1'
|
||||
/>
|
||||
<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' },
|
||||
})}
|
||||
fluid
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Arguments
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.args`)}
|
||||
value={output.args}
|
||||
fluid
|
||||
placeholder='1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOSCOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</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
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: {
|
||||
value: startsWithHttp,
|
||||
message: 'HTTP messages should target http:// or https://',
|
||||
},
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
<div>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestHTTPOutput(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{fieldOutputs.map((output, index) => {
|
||||
const rowErrors = getOutputErrors(index);
|
||||
const cardProps = {
|
||||
testState: testResults[output.id],
|
||||
onTest: () => handleTest(index, output.id),
|
||||
onDelete: () => removeOutput(index),
|
||||
};
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
if (isOSCOutput(output)) {
|
||||
return (
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
unavailableReason={isOntimeCloud ? 'Unavailable in Ontime Cloud' : undefined}
|
||||
{...cardProps}
|
||||
>
|
||||
<span> </span>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='ghosted-white' onClick={() => handleTestOntimeAction(index)}>
|
||||
Test
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeOutput(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</OntimeActionForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={handleAddNewOSCOutput}>
|
||||
OSC <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddNewHTTPOutput}>
|
||||
HTTP <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={handleAddnewOntimeAction}>
|
||||
Ontime action <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
if (isHTTPOutput(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
|
||||
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
<div>
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
items={[
|
||||
...(isOntimeCloud
|
||||
? []
|
||||
: [
|
||||
{
|
||||
type: 'item' as const,
|
||||
label: 'OSC',
|
||||
description: 'Send an OSC message to a device on the network',
|
||||
onClick: handleAddNewOSCOutput,
|
||||
},
|
||||
]),
|
||||
{
|
||||
type: 'item',
|
||||
label: 'HTTP',
|
||||
description: 'Call a URL, for webhooks and REST APIs',
|
||||
onClick: handleAddNewHTTPOutput,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Ontime action',
|
||||
description: 'Change something inside Ontime, like a message or an aux timer',
|
||||
onClick: handleAddNewOntimeAction,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Add output <IoAdd />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
@@ -30,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList
|
||||
triggers={data.triggers}
|
||||
<AutomationsList
|
||||
automations={data.automations}
|
||||
triggers={data.triggers}
|
||||
enabledAutomations={automationState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
||||
<span>- Automations allow Ontime to send its data on lifecycle triggers.</span>
|
||||
<span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
|
||||
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
|
||||
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
.table {
|
||||
min-width: 42rem;
|
||||
|
||||
td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: $muted-gray;
|
||||
}
|
||||
+142
-58
@@ -1,18 +1,25 @@
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import AutomationForm from './AutomationForm';
|
||||
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
|
||||
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||
import NewAutomationDialog from './NewAutomationDialog';
|
||||
|
||||
const automationPlaceholder: AutomationDTO = {
|
||||
import style from './AutomationsList.module.scss';
|
||||
|
||||
const emptyAutomation: AutomationDTO = {
|
||||
title: '',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
@@ -21,37 +28,93 @@ const automationPlaceholder: AutomationDTO = {
|
||||
|
||||
interface AutomationsListProps {
|
||||
automations: NormalisedAutomation;
|
||||
triggers: Trigger[];
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||
export default function AutomationsList({
|
||||
automations,
|
||||
triggers,
|
||||
enabledAutomations,
|
||||
isLoading,
|
||||
}: AutomationsListProps) {
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
|
||||
const [isPickingStart, setIsPickingStart] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
setDeleteError(null);
|
||||
await deleteAutomation(id);
|
||||
} catch (error) {
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
}
|
||||
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
|
||||
const automationList = Object.values(automations);
|
||||
|
||||
/** a recipe creates the automation itself, so it lands in the list rather than in a form */
|
||||
const handleCreated = async () => {
|
||||
setIsPickingStart(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
const handleStartEmpty = () => {
|
||||
setIsPickingStart(false);
|
||||
setDeleteTarget(null);
|
||||
setEditing(emptyAutomation);
|
||||
};
|
||||
|
||||
const handleDeleted = async () => {
|
||||
setDeleteTarget(null);
|
||||
setEditing(null);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const startPicking = () => {
|
||||
setEditing(null);
|
||||
setDeleteTarget(null);
|
||||
setIsPickingStart(true);
|
||||
};
|
||||
|
||||
const startEditing = (automation: Automation) => {
|
||||
setIsPickingStart(false);
|
||||
setDeleteTarget(null);
|
||||
setEditing(automation);
|
||||
};
|
||||
|
||||
const startDeleting = (automation: Automation) => {
|
||||
setIsPickingStart(false);
|
||||
setEditing(null);
|
||||
setDeleteTarget(automation);
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
{editing !== null && (
|
||||
<AutomationForm
|
||||
// the form snapshots the automation's lifecycles on mount, so it must never be
|
||||
// reused across two different automations
|
||||
key={isAutomation(editing) ? editing.id : 'new'}
|
||||
automation={editing}
|
||||
triggers={triggers}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{isPickingStart && (
|
||||
<NewAutomationDialog
|
||||
onClose={() => setIsPickingStart(false)}
|
||||
onStartEmpty={handleStartEmpty}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget !== null && (
|
||||
<DeleteAutomationDialog
|
||||
automation={deleteTarget}
|
||||
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
<Button onClick={startPicking}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
@@ -60,74 +123,95 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
||||
enabled.
|
||||
<Info type='warning'>
|
||||
<Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
|
||||
<Info.Footer>
|
||||
<Button size='small' onClick={() => setLocation('automation__settings')}>
|
||||
Go to automation settings
|
||||
</Button>
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
|
||||
<Panel.Table>
|
||||
<Panel.Table className={style.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 style={{ width: '35%' }}>Title</th>
|
||||
<th style={{ width: '25%' }}>Runs on</th>
|
||||
<th style={{ width: '15%' }}>Filter rule</th>
|
||||
<th style={{ width: '15%' }}>Sends</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!isLoading && arrayAutomations.length === 0 && (
|
||||
{!isLoading && automationList.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No automations yet'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create automation <IoAdd />
|
||||
<Button variant='primary' onClick={startPicking}>
|
||||
New automation <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{arrayAutomations.map((automationId) => {
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
{automationList.map((automation) => {
|
||||
const lifecycles = lifecyclesByAutomation[automation.id] ?? [];
|
||||
const outputs = summariseOutputs(automation.outputs);
|
||||
|
||||
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'>
|
||||
<tr key={automation.id}>
|
||||
<td>{automation.title}</td>
|
||||
<td>
|
||||
{lifecycles.length === 0 ? (
|
||||
<span className={style.muted}>—</span>
|
||||
) : (
|
||||
<div className={style.tags}>
|
||||
{lifecycles.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{automation.filters.length === 0 ? (
|
||||
<span className={style.muted}>—</span>
|
||||
) : (
|
||||
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className={style.tags}>
|
||||
{outputs.length === 0 ? (
|
||||
<Tag variant='warning'>No outputs</Tag>
|
||||
) : (
|
||||
outputs.map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={cx([style.tags, style.actions])}>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
onClick={() => startEditing(automation)}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
onClick={() => startDeleting(automation)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</tr>
|
||||
</Fragment>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import axios from 'axios';
|
||||
import type { Automation, Trigger } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { deleteAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
interface DeleteAutomationDialogProps {
|
||||
automation: Automation;
|
||||
/** global triggers pointing at this automation, they are deleted along with it */
|
||||
attachedTriggers: Trigger[];
|
||||
onCancel: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting takes the automation's global triggers with it, so say so before it happens rather
|
||||
* than leaving the user to discover it in the triggers list.
|
||||
*
|
||||
* An automation attached to an event is still refused by the server: that reference lives in
|
||||
* the rundown and removing it is an edit to the show, not to this panel.
|
||||
*/
|
||||
export default function DeleteAutomationDialog({
|
||||
automation,
|
||||
attachedTriggers,
|
||||
onCancel,
|
||||
onDeleted,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isAttachedToEvent, setIsAttachedToEvent] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
setIsAttachedToEvent(false);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAutomation(automation.id);
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
setIsAttachedToEvent(axios.isAxiosError(error) && error.response?.status === 409);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={onCancel}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title='Delete automation'
|
||||
bodyElements={
|
||||
<Panel.Section>
|
||||
<Panel.Paragraph>
|
||||
Delete <strong>{automation.title}</strong>? This cannot be undone.
|
||||
</Panel.Paragraph>
|
||||
|
||||
{attachedTriggers.length > 0 && (
|
||||
<Info type='warning'>
|
||||
<Info.Title>
|
||||
{attachedTriggers.length === 1
|
||||
? 'Its trigger is deleted with it'
|
||||
: `Its ${attachedTriggers.length} triggers are deleted with it`}
|
||||
</Info.Title>
|
||||
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
|
||||
</Info>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Info type='error'>
|
||||
<Info.Title>Could not delete this automation</Info.Title>
|
||||
<Info.Body>{error}</Info.Body>
|
||||
<Info.Footer>
|
||||
{isAttachedToEvent
|
||||
? 'An automation attached to a single event has to be removed from that event first, in the event editor.'
|
||||
: 'Try again. If the problem persists, check the network log for details.'}
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
</Panel.Section>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { AutomationDTO, HTTPOutput } from 'ontime-types';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import type { OutputErrors } from './automationUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
interface HttpOutputFormProps {
|
||||
index: number;
|
||||
output: HTTPOutput;
|
||||
register: UseFormRegister<AutomationDTO>;
|
||||
rowErrors?: OutputErrors;
|
||||
}
|
||||
|
||||
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
|
||||
return (
|
||||
<label className={style.spanFull}>
|
||||
Target URL
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.url`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
|
||||
})}
|
||||
value={output.url}
|
||||
fluid
|
||||
placeholder='http://127.0.0.1/start/1'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
.listViewport {
|
||||
height: auto;
|
||||
max-height: min(52vh, 30rem);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
position: absolute;
|
||||
left: 0.625rem;
|
||||
color: $gray-400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.searchClear {
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.groupTitle {
|
||||
margin: 0;
|
||||
padding-inline: 0.125rem;
|
||||
font-size: $aux-text-size;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.recipe {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $component-border-radius-md;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: $white-3;
|
||||
border-color: $white-10;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 1px solid $action-blue;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
.recipeText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recipeTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipeDescription {
|
||||
font-size: $aux-text-size;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.recipeTags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: $gray-400;
|
||||
}
|
||||
|
||||
.setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
color: $ui-white;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
|
||||
.setupDescription {
|
||||
margin: 0;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: 5rem 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background-color: $black-10;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
dt {
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
dd {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.75rem;
|
||||
|
||||
@media (width < 40rem) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: $aux-text-size;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.apart {
|
||||
margin-right: auto;
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
import type { Automation } from 'ontime-types';
|
||||
import { useState, type KeyboardEvent } from 'react';
|
||||
import { IoAdd, IoArrowBack, IoChevronForward, IoClose, IoSearch } from 'react-icons/io5';
|
||||
|
||||
import { addAutomation } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import { cx } from '../../../../common/utils/styleUtils';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import {
|
||||
defaultValues,
|
||||
getAvailableRecipes,
|
||||
recipeCategoryLabels,
|
||||
recipeCategoryOrder,
|
||||
type AutomationRecipe,
|
||||
type RecipeValues,
|
||||
validateRecipeValues,
|
||||
} from './automationRecipes';
|
||||
import { makeTriggerTitle } from './automationUtils';
|
||||
|
||||
import style from './NewAutomationDialog.module.scss';
|
||||
|
||||
const availableRecipes = getAvailableRecipes(Boolean(isOntimeCloud));
|
||||
|
||||
interface NewAutomationDialogProps {
|
||||
onClose: () => void;
|
||||
onStartEmpty: () => void;
|
||||
onCreated: (automation: Automation) => void;
|
||||
}
|
||||
|
||||
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
|
||||
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
|
||||
|
||||
return selected === null ? (
|
||||
<RecipePicker onClose={onClose} onStartEmpty={onStartEmpty} onSelect={setSelected} />
|
||||
) : (
|
||||
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
|
||||
);
|
||||
}
|
||||
|
||||
function matches(recipe: AutomationRecipe, query: string): boolean {
|
||||
const haystack = [recipe.title, recipe.description, recipeCategoryLabels[recipe.category], ...(recipe.keywords ?? [])]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.every((term) => haystack.includes(term));
|
||||
}
|
||||
|
||||
interface RecipePickerProps {
|
||||
onClose: () => void;
|
||||
onStartEmpty: () => void;
|
||||
onSelect: (recipe: AutomationRecipe) => void;
|
||||
}
|
||||
|
||||
function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const trimmed = query.trim();
|
||||
const results = trimmed ? availableRecipes.filter((recipe) => matches(recipe, trimmed)) : availableRecipes;
|
||||
|
||||
const handleSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// the dialog is the only thing listening for escape, and losing it while clearing a
|
||||
// search would be a bigger surprise than the search staying put
|
||||
if (event.key === 'Escape' && trimmed.length > 0) {
|
||||
event.stopPropagation();
|
||||
setQuery('');
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && results.length > 0) {
|
||||
onSelect(results[0]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='default'
|
||||
title='New automation'
|
||||
bodyElements={
|
||||
<div className={style.picker}>
|
||||
<div className={style.search}>
|
||||
<IoSearch className={style.searchIcon} />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={handleSearchKey}
|
||||
placeholder='Search recipes, eg. QLab, OSC, message'
|
||||
className={style.searchInput}
|
||||
aria-label='Search recipes'
|
||||
fluid
|
||||
autoFocus
|
||||
/>
|
||||
{trimmed.length > 0 && (
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
size='small'
|
||||
aria-label='Clear search'
|
||||
className={style.searchClear}
|
||||
onClick={() => setQuery('')}
|
||||
>
|
||||
<IoClose />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='No recipe matches that'
|
||||
description='Try the name of the software, or start from an empty automation.'
|
||||
/>
|
||||
)}
|
||||
|
||||
<ScrollArea viewportClassName={style.listViewport} contentClassName={style.list}>
|
||||
{recipeCategoryOrder.map((category) => {
|
||||
const inCategory = results.filter((recipe) => recipe.category === category);
|
||||
if (inCategory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={category} className={style.group}>
|
||||
<h4 className={style.groupTitle}>{recipeCategoryLabels[category]}</h4>
|
||||
{inCategory.map((recipe) => (
|
||||
<button type='button' key={recipe.id} className={style.recipe} onClick={() => onSelect(recipe)}>
|
||||
<div className={style.recipeText}>
|
||||
<div className={style.recipeTitle}>{recipe.title}</div>
|
||||
<div className={style.recipeDescription}>{recipe.description}</div>
|
||||
</div>
|
||||
<div className={style.recipeTags}>
|
||||
{recipe.triggers.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
<IoChevronForward className={style.chevron} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button variant='ghosted-white' className={style.apart} onClick={onStartEmpty}>
|
||||
Start from an empty automation
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecipeSetupProps {
|
||||
recipe: AutomationRecipe;
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
onCreated: (automation: Automation) => void;
|
||||
}
|
||||
|
||||
function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
|
||||
const [values, setValues] = useState<RecipeValues>(() => defaultValues(recipe));
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const automation = recipe.build(values);
|
||||
const validationErrors = validateRecipeValues(recipe, values);
|
||||
const isComplete = Object.keys(validationErrors).length === 0;
|
||||
|
||||
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError(null);
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const created = await addAutomation(
|
||||
automation,
|
||||
recipe.triggers.map((cycle) => ({
|
||||
title: makeTriggerTitle(automation.title, cycle),
|
||||
trigger: cycle,
|
||||
})),
|
||||
);
|
||||
onCreated(created);
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={recipe.title}
|
||||
bodyElements={
|
||||
<div className={style.setup}>
|
||||
<p className={style.setupDescription}>{recipe.description}</p>
|
||||
|
||||
<dl className={style.summary}>
|
||||
<dt>Runs on</dt>
|
||||
<dd>
|
||||
{recipe.triggers.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
<dt>Sends</dt>
|
||||
<dd>
|
||||
{summariseOutputs(automation.outputs).map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{recipe.params.length > 0 && (
|
||||
<div className={style.fields}>
|
||||
{recipe.params.map((param) => (
|
||||
<label key={param.name} className={cx([style.field, param.wide && style.wide])}>
|
||||
{param.label}
|
||||
{param.type === 'choice' ? (
|
||||
<Select
|
||||
value={values[param.name]}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(param.name, value);
|
||||
}}
|
||||
options={param.options ?? []}
|
||||
aria-label={param.label}
|
||||
fluid
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={param.type === 'number' ? 'number' : 'text'}
|
||||
value={values[param.name]}
|
||||
onChange={(event) => setValue(param.name, event.target.value)}
|
||||
aria-invalid={Boolean(validationErrors[param.name])}
|
||||
fluid
|
||||
/>
|
||||
)}
|
||||
{param.hint && <span className={style.hint}>{param.hint}</span>}
|
||||
<Panel.Error>{validationErrors[param.name]}</Panel.Error>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Button variant='ghosted-white' className={style.apart} onClick={onBack} disabled={isCreating}>
|
||||
<IoArrowBack /> All recipes
|
||||
</Button>
|
||||
<Button onClick={onClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='primary' onClick={handleCreate} loading={isCreating} disabled={!isComplete}>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
|
||||
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import type { OutputErrors } from './automationUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
|
||||
interface OntimeActionFormProps {
|
||||
index: number;
|
||||
register: UseFormRegister<AutomationDTO>;
|
||||
rowErrors?: {
|
||||
action?: { message?: string };
|
||||
time?: { message?: string };
|
||||
text?: { message?: string };
|
||||
visible?: { message?: string };
|
||||
secondarySource?: { message?: string };
|
||||
};
|
||||
rowErrors?: OutputErrors;
|
||||
value: OntimeAction['action'];
|
||||
watch: UseFormWatch<AutomationDTO>;
|
||||
setValue: UseFormSetValue<AutomationDTO>;
|
||||
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
|
||||
setValue,
|
||||
rowErrors,
|
||||
value,
|
||||
children,
|
||||
watch,
|
||||
}: PropsWithChildren<OntimeActionFormProps>) {
|
||||
}: OntimeActionFormProps) {
|
||||
const [selectedAction, setSelectedAction] = useState<string>(value);
|
||||
|
||||
const handleSetAction = (value: OntimeActionKey) => {
|
||||
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.actionSection}>
|
||||
<>
|
||||
<label>
|
||||
Action
|
||||
<Select
|
||||
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
|
||||
|
||||
{selectedAction === 'message-set' && (
|
||||
<>
|
||||
<label>
|
||||
<label className={style.spanFull}>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
@@ -127,7 +121,7 @@ export default function OntimeActionForm({
|
||||
|
||||
{selectedAction === 'message-secondary' && (
|
||||
<>
|
||||
<label>
|
||||
<label className={style.spanFull}>
|
||||
Text (leave empty for no change)
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.text`)}
|
||||
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={style.test}>{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AutomationDTO, OSCOutput } from 'ontime-types';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import type { OutputErrors } from './automationUtils';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
interface OscOutputFormProps {
|
||||
index: number;
|
||||
output: OSCOutput;
|
||||
register: UseFormRegister<AutomationDTO>;
|
||||
rowErrors?: OutputErrors;
|
||||
}
|
||||
|
||||
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
|
||||
return (
|
||||
<>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='127.0.0.1'
|
||||
/>
|
||||
<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' },
|
||||
})}
|
||||
fluid
|
||||
type='number'
|
||||
maxLength={5}
|
||||
placeholder='8000'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||
</label>
|
||||
<label className={style.spanFull}>
|
||||
Address
|
||||
<TemplateInput
|
||||
{...register(`outputs.${index}.address`)}
|
||||
value={output.address}
|
||||
fluid
|
||||
placeholder='/cue/start'
|
||||
/>
|
||||
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||
</label>
|
||||
<label className={style.spanFull}>
|
||||
Arguments
|
||||
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { IoCheckmark, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AutomationForm.module.scss';
|
||||
|
||||
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
|
||||
|
||||
interface OutputCardProps {
|
||||
label: string;
|
||||
kindClass?: string;
|
||||
summary?: string;
|
||||
testState?: TestState;
|
||||
unavailableReason?: string;
|
||||
onTest: () => void;
|
||||
onDelete: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function OutputCard({
|
||||
label,
|
||||
kindClass,
|
||||
summary,
|
||||
testState,
|
||||
unavailableReason,
|
||||
onTest,
|
||||
onDelete,
|
||||
children,
|
||||
}: OutputCardProps) {
|
||||
return (
|
||||
<div className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag className={kindClass}>{label}</Tag>
|
||||
<span className={style.cardSummary}>{summary}</span>
|
||||
{testState?.status === 'ok' && (
|
||||
<span className={style.testOk}>
|
||||
<IoCheckmark />
|
||||
{testState.message}
|
||||
</span>
|
||||
)}
|
||||
{unavailableReason ? (
|
||||
<Tag variant='warning'>{unavailableReason}</Tag>
|
||||
) : (
|
||||
<Button variant='ghosted-white' onClick={onTest} loading={testState?.status === 'sending'}>
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
<IconButton aria-label='Delete output' variant='ghosted-destructive' onClick={onDelete}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
|
||||
<div className={style.cardBody}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.table {
|
||||
min-width: 36rem;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import { deleteTrigger } from '../../../../common/api/automation';
|
||||
@@ -8,10 +8,13 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
import { checkDuplicates } from './automationUtils';
|
||||
import TriggerForm from './TriggerForm';
|
||||
import TriggersListItem from './TriggersListItem';
|
||||
|
||||
import style from './TriggersList.module.scss';
|
||||
|
||||
type FormState = {
|
||||
isOpen: boolean;
|
||||
trigger?: Trigger;
|
||||
@@ -20,13 +23,13 @@ type FormState = {
|
||||
interface TriggersListProps {
|
||||
triggers: Trigger[];
|
||||
automations: NormalisedAutomation;
|
||||
enabledAutomations?: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
|
||||
export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) {
|
||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
||||
const { refetch } = useAutomationSettings();
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const openNewForm = () => setFormState({ isOpen: true });
|
||||
@@ -50,6 +53,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
};
|
||||
|
||||
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
|
||||
const orphans = useMemo(
|
||||
() => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length,
|
||||
[triggers, automations],
|
||||
);
|
||||
|
||||
// there is no point letting user creating a trigger if there are no automations
|
||||
const canAdd = Object.keys(automations).length > 0;
|
||||
@@ -66,25 +73,30 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage triggers
|
||||
Global triggers
|
||||
<Button disabled={!canAdd} onClick={openNewForm}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
</Info>
|
||||
)}
|
||||
<Info>
|
||||
<Info.Body>Actions in this section affect the entire project runtime, not just a single event.</Info.Body>
|
||||
</Info>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
You have created multiple links between the same trigger and automation which can cause performance
|
||||
issues.
|
||||
You have created multiple links between the same trigger and automation. Duplicate combinations will only
|
||||
fire once per lifecycle event.
|
||||
</Panel.Error>
|
||||
)}
|
||||
<Panel.Table>
|
||||
{orphans > 0 && (
|
||||
<Panel.Error>
|
||||
{orphans === 1
|
||||
? '1 trigger points at an automation that no longer exists and will never run.'
|
||||
: `${orphans} triggers point at automations that no longer exist and will never run.`}
|
||||
</Panel.Error>
|
||||
)}
|
||||
<Panel.Table className={style.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>Title</th>
|
||||
@@ -99,31 +111,32 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
title='No triggers yet'
|
||||
description={
|
||||
canAdd
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||
: 'Create an automation first, then add a trigger to decide when it should run.'
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.'
|
||||
: 'Create an automation first, then pick the lifecycles it should run on.'
|
||||
}
|
||||
action={
|
||||
canAdd && (
|
||||
canAdd ? (
|
||||
<Button variant='primary' onClick={openNewForm}>
|
||||
Create trigger <IoAdd />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant='primary' onClick={() => setLocation('automation__automations')}>
|
||||
Go to automations
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{triggers.map((trigger, index) => {
|
||||
return (
|
||||
<Fragment key={trigger.id}>
|
||||
<TriggersListItem
|
||||
automations={automations}
|
||||
trigger={trigger}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleEdit={() => openEditForm(trigger)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{triggers.map((trigger, index) => (
|
||||
<TriggersListItem
|
||||
key={trigger.id}
|
||||
automations={automations}
|
||||
trigger={trigger}
|
||||
duplicate={duplicates?.includes(index)}
|
||||
handleEdit={() => openEditForm(trigger)}
|
||||
handleDelete={() => handleDelete(trigger.id)}
|
||||
/>
|
||||
))}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
|
||||
@@ -16,6 +16,7 @@ interface TriggersListItemProps {
|
||||
|
||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
||||
const automation = automations[trigger.automationId];
|
||||
|
||||
return (
|
||||
<tr data-warn={duplicate}>
|
||||
@@ -31,7 +32,8 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
||||
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||
</td>
|
||||
<td>
|
||||
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
||||
{/* a trigger can outlive the automation it points at, say after a partial project import */}
|
||||
{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
|
||||
|
||||
import {
|
||||
automationRecipes,
|
||||
defaultValues,
|
||||
getAvailableRecipes,
|
||||
recipeCategoryOrder,
|
||||
validateRecipeValues,
|
||||
} from '../automationRecipes';
|
||||
import { operators } from '../automationUtils';
|
||||
|
||||
/**
|
||||
* Recipes are shipped as constants but created through the same endpoint as a hand written
|
||||
* automation. These assertions stand in for the server side validation, so a recipe cannot
|
||||
* silently rot into something that 400s when the user presses create.
|
||||
*/
|
||||
describe('automationRecipes', () => {
|
||||
const built = automationRecipes.map((recipe) => ({ recipe, automation: recipe.build(defaultValues(recipe)) }));
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = automationRecipes.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('only uses categories the picker knows how to render', () => {
|
||||
for (const { recipe } of built) {
|
||||
expect(recipeCategoryOrder).toContain(recipe.category);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds every recipe to at least one valid lifecycle', () => {
|
||||
for (const { recipe } of built) {
|
||||
expect(recipe.triggers.length).toBeGreaterThan(0);
|
||||
for (const cycle of recipe.triggers) {
|
||||
expect(timerLifecycleValues).toContain(cycle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('builds a titled automation with something to send, from its own defaults', () => {
|
||||
for (const { automation } of built) {
|
||||
expect(automation.title).not.toBe('');
|
||||
expect(automation.outputs.length).toBeGreaterThan(0);
|
||||
|
||||
for (const output of automation.outputs) {
|
||||
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every choice parameter options, and a default that is one of them', () => {
|
||||
const choices = automationRecipes.flatMap(({ params }) => params.filter(({ type }) => type === 'choice'));
|
||||
expect(choices.filter(({ options }) => !options?.length)).toEqual([]);
|
||||
expect(choices.filter(({ options, defaultValue }) => !options?.some((o) => o.value === defaultValue))).toEqual([]);
|
||||
});
|
||||
|
||||
it('reads every parameter it declares', () => {
|
||||
// a param the builder ignores is a field the user fills in for nothing, and a typo in
|
||||
// either half would put the literal 'undefined' inside a URL
|
||||
for (const { recipe } of built) {
|
||||
for (const param of recipe.params) {
|
||||
// a choice can only take one of its own options, so probe with the last one
|
||||
if (param.type === 'choice') {
|
||||
const last = param.options?.at(-1)?.value ?? '';
|
||||
expect(JSON.stringify(recipe.build({ ...defaultValues(recipe), [param.name]: last }))).toContain(last);
|
||||
continue;
|
||||
}
|
||||
const marker =
|
||||
param.type === 'number' ? '4242' : param.validation?.kind === 'url' ? 'http://ontime-probe' : 'ontime-probe';
|
||||
const probed = { ...defaultValues(recipe), [param.name]: marker };
|
||||
expect(JSON.stringify(recipe.build(probed))).toContain(marker);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('only uses filter operators the server accepts', () => {
|
||||
const allowed = operators.map(({ value }) => value);
|
||||
for (const { automation } of built) {
|
||||
for (const filter of automation.filters) {
|
||||
expect(allowed).toContain(filter.operator);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults every external target to this machine', () => {
|
||||
const outputs = built.flatMap(({ automation }) => automation.outputs);
|
||||
const osc = outputs.filter(isOSCOutput);
|
||||
const http = outputs.filter(isHTTPOutput);
|
||||
|
||||
// filtering rather than asserting in a branch, so a failure names the offending recipe
|
||||
expect(osc.filter(({ targetIP }) => targetIP !== '127.0.0.1')).toEqual([]);
|
||||
expect(osc.filter(({ targetPort }) => !Number.isFinite(targetPort))).toEqual([]);
|
||||
expect(http.filter(({ url }) => !url.startsWith('http://127.0.0.1'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('hides local-network recipes in Ontime Cloud', () => {
|
||||
expect(getAvailableRecipes(true).map(({ id }) => id)).toEqual([
|
||||
'ontime-aux-timer',
|
||||
'ontime-aux-stop',
|
||||
'ontime-warn-stage',
|
||||
'ontime-clear-message',
|
||||
'ontime-secondary-message',
|
||||
'webhook-event-title',
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates recipe addresses and numeric bounds', () => {
|
||||
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
|
||||
const companion = automationRecipes.find(({ id }) => id === 'companion-press');
|
||||
const vmix = automationRecipes.find(({ id }) => id === 'vmix-overlay-warning');
|
||||
const webhook = automationRecipes.find(({ id }) => id === 'webhook-event-title');
|
||||
|
||||
expect(qlab && validateRecipeValues(qlab, { ip: 'not a host', port: '70000' })).toEqual({
|
||||
ip: 'Enter an IP address or hostname',
|
||||
port: 'Enter a whole number from 1024 to 65535',
|
||||
});
|
||||
expect(
|
||||
companion && validateRecipeValues(companion, { host: 'localhost:8888', page: '1.5', row: '-1', column: '0' }),
|
||||
).toEqual({
|
||||
host: 'Enter a URL starting with http:// or https://',
|
||||
page: 'Enter a whole number of 1 or more',
|
||||
row: 'Enter a whole number of 0 or more',
|
||||
});
|
||||
expect(vmix && validateRecipeValues(vmix, { host: 'http://127.0.0.1:8088', overlay: '5' })).toEqual({
|
||||
overlay: 'Enter a whole number from 1 to 4',
|
||||
});
|
||||
expect(webhook && validateRecipeValues(webhook, { url: 'ftp://example.com' })).toEqual({
|
||||
url: 'Enter a URL starting with http:// or https://',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts every recipe default', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(validateRecipeValues(recipe, defaultValues(recipe))).toEqual({});
|
||||
}
|
||||
});
|
||||
|
||||
/** the outputs a recipe builds from the given answers, as plain JSON to assert against */
|
||||
function buildWith(id: string, values: Record<string, string>) {
|
||||
const recipe = automationRecipes.find((candidate) => candidate.id === id);
|
||||
return JSON.stringify(recipe?.build(values).outputs);
|
||||
}
|
||||
|
||||
it('tolerates a URL that already carries a query', () => {
|
||||
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now?source=ontime' })).toContain(
|
||||
'/now?source=ontime&title=',
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the event title for URL-safe substitution', () => {
|
||||
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now' })).toContain(
|
||||
'title={{url:eventNow.title}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('adds webhook query parameters before a URL fragment', () => {
|
||||
expect(buildWith('webhook-event-title', { url: 'http://127.0.0.1:3000/now#details' })).toContain(
|
||||
'/now?title={{url:eventNow.title}}#details',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid aux timer durations', () => {
|
||||
const auxTimer = automationRecipes.find(({ id }) => id === 'ontime-aux-timer');
|
||||
|
||||
expect(auxTimer && validateRecipeValues(auxTimer, { aux: '1', duration: 'abc' })).toEqual({
|
||||
duration: 'Enter a valid duration',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported OSC targets', () => {
|
||||
const qlab = automationRecipes.find(({ id }) => id === 'qlab-go');
|
||||
|
||||
expect(qlab && validateRecipeValues(qlab, { ip: '::1', port: '53000' })).toEqual({
|
||||
ip: 'Enter an IP address or hostname',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps recipe previews available while an address is invalid', () => {
|
||||
expect(() => buildWith('companion-press', { host: 'http:', page: '1', row: '0', column: '0' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('tolerates an address pasted with a trailing slash', () => {
|
||||
expect(
|
||||
buildWith('companion-press', { host: 'http://127.0.0.1:8888/', page: '1', row: '0', column: '0' }),
|
||||
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
|
||||
});
|
||||
|
||||
it('drops a pasted query before adding a Companion path', () => {
|
||||
expect(
|
||||
buildWith('companion-press', { host: 'http://127.0.0.1:8888?token=value', page: '1', row: '0', column: '0' }),
|
||||
).toContain('http://127.0.0.1:8888/api/location/1/0/0/press');
|
||||
});
|
||||
});
|
||||
+41
-1
@@ -1,6 +1,6 @@
|
||||
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { checkDuplicates } from '../automationUtils';
|
||||
import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
|
||||
|
||||
describe('checkDuplicates', () => {
|
||||
it('should return undefined if there are no duplicates', () => {
|
||||
@@ -22,3 +22,43 @@ describe('checkDuplicates', () => {
|
||||
expect(checkDuplicates(triggers)).toStrictEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupTriggersByAutomation', () => {
|
||||
it('returns an empty object when there are no triggers', () => {
|
||||
expect(groupTriggersByAutomation([])).toEqual({});
|
||||
});
|
||||
|
||||
it('collects the lifecycles each automation is bound to', () => {
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onFinish, automationId: 'a' },
|
||||
{ id: '3', title: 'Third', trigger: TimerLifeCycle.onLoad, automationId: 'b' },
|
||||
];
|
||||
|
||||
expect(groupTriggersByAutomation(triggers)).toEqual({
|
||||
a: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
|
||||
b: [TimerLifeCycle.onLoad],
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses duplicates, the runtime only fires an automation once per lifecycle', () => {
|
||||
const triggers: Trigger[] = [
|
||||
{ id: '1', title: 'First', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||
{ id: '2', title: 'Second', trigger: TimerLifeCycle.onStart, automationId: 'a' },
|
||||
];
|
||||
|
||||
expect(groupTriggersByAutomation(triggers)).toEqual({ a: [TimerLifeCycle.onStart] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('operators', () => {
|
||||
it('does not offer not_contains, which the server validation rejects', () => {
|
||||
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycles', () => {
|
||||
it('uses the shared user facing labels', () => {
|
||||
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import type { AutomationDTO, AutomationOutput, TimerLifeCycle } from 'ontime-types';
|
||||
import { TimerLifeCycle as Cycle } from 'ontime-types';
|
||||
import { parseUserTime } from 'ontime-utils';
|
||||
|
||||
export type RecipeCategory = 'ontime' | 'playback' | 'video' | 'messaging';
|
||||
|
||||
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
|
||||
ontime: 'Ontime automations',
|
||||
playback: 'Playback and cue systems',
|
||||
video: 'Video and streaming',
|
||||
messaging: 'Webhooks and messaging',
|
||||
};
|
||||
|
||||
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'playback', 'video', 'messaging'];
|
||||
|
||||
export type RecipeParam = {
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
type?: 'text' | 'number' | 'choice';
|
||||
options?: { value: string; label: string }[];
|
||||
wide?: boolean;
|
||||
defaultValue: string;
|
||||
validation?:
|
||||
| { kind: 'duration' }
|
||||
| { kind: 'host' }
|
||||
| { kind: 'url' }
|
||||
| { kind: 'integer'; min: number; max?: number };
|
||||
};
|
||||
|
||||
export type RecipeValues = Record<string, string>;
|
||||
|
||||
export type AutomationRecipe = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: RecipeCategory;
|
||||
localOnly?: boolean;
|
||||
keywords?: string[];
|
||||
params: RecipeParam[];
|
||||
triggers: TimerLifeCycle[];
|
||||
build: (values: RecipeValues) => AutomationDTO;
|
||||
};
|
||||
|
||||
const auxTimers = [
|
||||
{ value: '1', label: 'Aux timer 1' },
|
||||
{ value: '2', label: 'Aux timer 2' },
|
||||
{ value: '3', label: 'Aux timer 3' },
|
||||
];
|
||||
|
||||
type AuxNumber = '1' | '2' | '3';
|
||||
|
||||
function toAux(value: string): AuxNumber {
|
||||
return value === '2' || value === '3' ? value : '1';
|
||||
}
|
||||
|
||||
const auxSet = { 1: 'aux1-set', 2: 'aux2-set', 3: 'aux3-set' } as const;
|
||||
const auxStart = { 1: 'aux1-start', 2: 'aux2-start', 3: 'aux3-start' } as const;
|
||||
const auxStop = { 1: 'aux1-stop', 2: 'aux2-stop', 3: 'aux3-stop' } as const;
|
||||
const auxSource = { 1: 'aux1', 2: 'aux2', 3: 'aux3' } as const;
|
||||
|
||||
function origin(value: string): string {
|
||||
try {
|
||||
return new URL(value.trim()).origin;
|
||||
} catch {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function withQuery(url: string, query: string): string {
|
||||
const trimmed = url.trim();
|
||||
const fragmentIndex = trimmed.indexOf('#');
|
||||
const base = fragmentIndex === -1 ? trimmed : trimmed.slice(0, fragmentIndex);
|
||||
const fragment = fragmentIndex === -1 ? '' : trimmed.slice(fragmentIndex);
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${query}${fragment}`;
|
||||
}
|
||||
|
||||
function buildUnfilteredAutomation(title: string, outputs: AutomationOutput[]): AutomationDTO {
|
||||
return { title, filterRule: 'all', filters: [], outputs };
|
||||
}
|
||||
|
||||
export const automationRecipes: AutomationRecipe[] = [
|
||||
{
|
||||
id: 'ontime-aux-timer',
|
||||
title: 'Run an aux timer with the event',
|
||||
description: 'Sets an aux timer and starts it whenever an event starts.',
|
||||
category: 'ontime',
|
||||
keywords: ['countdown', 'stage timer', 'speaker'],
|
||||
params: [
|
||||
{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' },
|
||||
{
|
||||
name: 'duration',
|
||||
label: 'Duration',
|
||||
hint: 'hh:mm:ss',
|
||||
defaultValue: '00:05:00',
|
||||
validation: { kind: 'duration' },
|
||||
},
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ aux, duration }) =>
|
||||
buildUnfilteredAutomation(`Run Aux Timer ${toAux(aux)} with the event`, [
|
||||
{ type: 'ontime', action: auxSet[toAux(aux)], time: duration.trim() },
|
||||
{ type: 'ontime', action: auxStart[toAux(aux)] },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'ontime-aux-stop',
|
||||
title: 'Stop the aux timer when the event ends',
|
||||
description: 'Stops an aux timer on finish, so it does not keep running into the next event.',
|
||||
category: 'ontime',
|
||||
keywords: ['countdown', 'stage timer', 'reset'],
|
||||
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
|
||||
triggers: [Cycle.onFinish],
|
||||
build: ({ aux }) =>
|
||||
buildUnfilteredAutomation(`Stop Aux Timer ${toAux(aux)} on finish`, [
|
||||
{ type: 'ontime', action: auxStop[toAux(aux)] },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'ontime-warn-stage',
|
||||
title: 'Warn the stage when time runs low',
|
||||
description: 'Shows a message on the stage timer as the running event enters its danger window.',
|
||||
category: 'ontime',
|
||||
keywords: ['message', 'danger', 'wrap up', 'presenter'],
|
||||
params: [{ name: 'message', label: 'Message', wide: true, defaultValue: 'Please wrap up' }],
|
||||
triggers: [Cycle.onDanger],
|
||||
build: ({ message }) =>
|
||||
buildUnfilteredAutomation('Warn the stage at danger', [
|
||||
{ type: 'ontime', action: 'message-set', text: message, visible: true },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'ontime-clear-message',
|
||||
title: 'Hide the stage message on finish',
|
||||
description: 'Clears the stage message once the event finishes. Pairs with the warning above.',
|
||||
category: 'ontime',
|
||||
keywords: ['message', 'clear', 'presenter'],
|
||||
params: [],
|
||||
triggers: [Cycle.onFinish],
|
||||
build: () =>
|
||||
buildUnfilteredAutomation('Hide the stage message on finish', [
|
||||
{ type: 'ontime', action: 'message-set', text: '', visible: false },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'ontime-secondary-message',
|
||||
title: 'Show an aux timer beside the stage message',
|
||||
description: 'Points the secondary field on the stage timer at an aux timer when an event loads.',
|
||||
category: 'ontime',
|
||||
keywords: ['message', 'secondary', 'stage', 'countdown'],
|
||||
params: [{ name: 'aux', label: 'Which timer', type: 'choice', options: auxTimers, defaultValue: '1' }],
|
||||
triggers: [Cycle.onLoad],
|
||||
build: ({ aux }) =>
|
||||
buildUnfilteredAutomation(`Show Aux Timer ${toAux(aux)} as the secondary message`, [
|
||||
{ type: 'ontime', action: 'message-secondary', secondarySource: auxSource[toAux(aux)] },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'qlab-go',
|
||||
title: 'QLab — fire the matching cue',
|
||||
description: "Starts the QLab cue whose number matches the Ontime event's cue.",
|
||||
category: 'playback',
|
||||
localOnly: true,
|
||||
keywords: ['osc', 'sound', 'audio', 'mac', 'figure 53'],
|
||||
params: [
|
||||
{
|
||||
name: 'ip',
|
||||
label: 'QLab computer',
|
||||
hint: 'IP address of the machine running QLab',
|
||||
wide: true,
|
||||
defaultValue: '127.0.0.1',
|
||||
validation: { kind: 'host' },
|
||||
},
|
||||
{
|
||||
name: 'port',
|
||||
label: 'OSC port',
|
||||
type: 'number',
|
||||
hint: "QLab's default is 53000",
|
||||
defaultValue: '53000',
|
||||
validation: { kind: 'integer', min: 1024, max: 65535 },
|
||||
},
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ ip, port }) =>
|
||||
buildUnfilteredAutomation('QLab GO on event start', [
|
||||
{
|
||||
type: 'osc',
|
||||
targetIP: ip.trim(),
|
||||
targetPort: Number(port),
|
||||
address: '/cue/{{eventNow.cue}}/start',
|
||||
args: '',
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'companion-press',
|
||||
title: 'Companion — press a button',
|
||||
description: 'Presses a Stream Deck button through the Companion HTTP API when an event starts.',
|
||||
category: 'playback',
|
||||
localOnly: true,
|
||||
keywords: ['stream deck', 'bitfocus', 'obs', 'http', 'elgato'],
|
||||
params: [
|
||||
{
|
||||
name: 'host',
|
||||
label: 'Companion address',
|
||||
hint: 'Where the Companion HTTP API is listening',
|
||||
wide: true,
|
||||
defaultValue: 'http://127.0.0.1:8888',
|
||||
validation: { kind: 'url' },
|
||||
},
|
||||
{ name: 'page', label: 'Page', type: 'number', defaultValue: '1', validation: { kind: 'integer', min: 1 } },
|
||||
{ name: 'row', label: 'Row', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
|
||||
{ name: 'column', label: 'Column', type: 'number', defaultValue: '0', validation: { kind: 'integer', min: 0 } },
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ host, page, row, column }) =>
|
||||
buildUnfilteredAutomation('Companion button press', [
|
||||
{ type: 'http', url: `${origin(host)}/api/location/${page}/${row}/${column}/press` },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'vmix-overlay-warning',
|
||||
title: 'vMix — show an overlay on warning',
|
||||
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
|
||||
category: 'video',
|
||||
localOnly: true,
|
||||
keywords: ['streaming', 'http', 'lower third', 'graphics'],
|
||||
params: [
|
||||
{
|
||||
name: 'host',
|
||||
label: 'vMix address',
|
||||
hint: 'The vMix web controller',
|
||||
wide: true,
|
||||
defaultValue: 'http://127.0.0.1:8088',
|
||||
validation: { kind: 'url' },
|
||||
},
|
||||
{
|
||||
name: 'overlay',
|
||||
label: 'Overlay number',
|
||||
type: 'number',
|
||||
defaultValue: '1',
|
||||
validation: { kind: 'integer', min: 1, max: 4 },
|
||||
},
|
||||
],
|
||||
triggers: [Cycle.onWarning],
|
||||
build: ({ host, overlay }) =>
|
||||
buildUnfilteredAutomation('vMix overlay on warning', [
|
||||
{ type: 'http', url: `${origin(host)}/api/?Function=OverlayInput${overlay}In` },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'webhook-event-title',
|
||||
title: 'Webhook — send the running event title',
|
||||
description: 'Calls any URL with the running event title, as a template string you can edit afterwards.',
|
||||
category: 'messaging',
|
||||
keywords: ['http', 'rest', 'api', 'integration', 'slack'],
|
||||
params: [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
hint: 'The event title is added as a title parameter',
|
||||
wide: true,
|
||||
defaultValue: 'http://127.0.0.1:3000/now',
|
||||
validation: { kind: 'url' },
|
||||
},
|
||||
],
|
||||
triggers: [Cycle.onStart],
|
||||
build: ({ url }) =>
|
||||
buildUnfilteredAutomation('Webhook with the current event', [
|
||||
{ type: 'http', url: withQuery(url, 'title={{url:eventNow.title}}') },
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
export function defaultValues(recipe: AutomationRecipe): RecipeValues {
|
||||
return Object.fromEntries(recipe.params.map(({ name, defaultValue }) => [name, defaultValue]));
|
||||
}
|
||||
|
||||
export function getAvailableRecipes(isCloud: boolean): AutomationRecipe[] {
|
||||
return isCloud ? automationRecipes.filter((recipe) => !recipe.localOnly) : automationRecipes;
|
||||
}
|
||||
|
||||
export function validateRecipeValues(recipe: AutomationRecipe, values: RecipeValues): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
for (const param of recipe.params) {
|
||||
const value = values[param.name]?.trim() ?? '';
|
||||
if (!value) {
|
||||
errors[param.name] = 'Required field';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (param.validation?.kind === 'url' && !isHttpUrl(value)) {
|
||||
errors[param.name] = 'Enter a URL starting with http:// or https://';
|
||||
} else if (param.validation?.kind === 'duration' && !isDuration(value)) {
|
||||
errors[param.name] = 'Enter a valid duration';
|
||||
} else if (param.validation?.kind === 'host' && !isHost(value)) {
|
||||
errors[param.name] = 'Enter an IP address or hostname';
|
||||
} else if (param.validation?.kind === 'integer') {
|
||||
const number = Number(value);
|
||||
const { min, max } = param.validation;
|
||||
if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
|
||||
errors[param.name] =
|
||||
max === undefined ? `Enter a whole number of ${min} or more` : `Enter a whole number from ${min} to ${max}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function isDuration(value: string): boolean {
|
||||
return /^\d+(?::\d{1,2}){0,2}$/.test(value) && (parseUserTime(value) > 0 || /^0+(?::0+){0,2}$/.test(value));
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:') && Boolean(url.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isHost(value: string): boolean {
|
||||
if (value === 'localhost') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ipv4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$/;
|
||||
const hostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i;
|
||||
if (ipv4.test(value) || hostname.test(value)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,21 +1,50 @@
|
||||
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { getLifecycleLabel, lifecycleLabels } from '../../../../common/constants/timerLifecycle';
|
||||
|
||||
/**
|
||||
* Names a trigger created from an automation's lifecycle picker.
|
||||
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
|
||||
*/
|
||||
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
|
||||
return `${automationTitle} — ${getLifecycleLabel(cycle)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outputs are a union, so react-hook-form cannot resolve a field's error by name.
|
||||
* Every output card knows which fields it registered, this just makes them reachable.
|
||||
*/
|
||||
export type OutputErrors = Partial<Record<string, { message?: string }>>;
|
||||
|
||||
type CycleLabel = {
|
||||
id: number;
|
||||
label: string;
|
||||
value: keyof typeof TimerLifeCycle;
|
||||
value: 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' },
|
||||
{ label: lifecycleLabels.onLoad, value: TimerLifeCycle.onLoad },
|
||||
{ label: lifecycleLabels.onStart, value: TimerLifeCycle.onStart },
|
||||
{ label: lifecycleLabels.onPause, value: TimerLifeCycle.onPause },
|
||||
{ label: lifecycleLabels.onStop, value: TimerLifeCycle.onStop },
|
||||
{ label: lifecycleLabels.onClock, value: TimerLifeCycle.onClock },
|
||||
{ label: lifecycleLabels.onUpdate, value: TimerLifeCycle.onUpdate },
|
||||
{ label: lifecycleLabels.onFinish, value: TimerLifeCycle.onFinish },
|
||||
{ label: lifecycleLabels.onWarning, value: TimerLifeCycle.onWarning },
|
||||
{ label: lifecycleLabels.onDanger, value: TimerLifeCycle.onDanger },
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter operators offered in the automation form
|
||||
* NOTE: not_contains is supported by the type and by the runtime, but the server
|
||||
* validation list omits it, so an automation using it cannot be saved.
|
||||
* It stays out of the UI until the server accepts it.
|
||||
*/
|
||||
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'greater_than', label: 'is greater than' },
|
||||
{ value: 'less_than', label: 'is less than' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -66,20 +95,38 @@ export function makeFieldList(customFields: CustomFields): SelectableField[] {
|
||||
* 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 = [];
|
||||
const seen = new Set<string>();
|
||||
const duplicates: number[] = [];
|
||||
|
||||
for (let i = 0; i < triggers.length; i++) {
|
||||
const trigger = triggers[i];
|
||||
if (!Object.hasOwn(triggersMap, trigger.trigger)) {
|
||||
triggersMap[trigger.trigger] = [];
|
||||
}
|
||||
for (const [index, trigger] of triggers.entries()) {
|
||||
const key = `${trigger.trigger}:${trigger.automationId}`;
|
||||
|
||||
if (triggersMap[trigger.trigger].includes(trigger.automationId)) {
|
||||
duplicates.push(i);
|
||||
if (seen.has(key)) {
|
||||
duplicates.push(index);
|
||||
} else {
|
||||
triggersMap[trigger.trigger].push(trigger.automationId);
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates.length > 0 ? duplicates : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups the lifecycles each automation is bound to
|
||||
* Used to show when an automation runs, and to highlight the ones that never will
|
||||
*/
|
||||
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
|
||||
const grouped: Record<string, TimerLifeCycle[]> = {};
|
||||
|
||||
for (const trigger of triggers) {
|
||||
if (!Object.hasOwn(grouped, trigger.automationId)) {
|
||||
grouped[trigger.automationId] = [];
|
||||
}
|
||||
// the runtime fires an automation once per lifecycle, duplicates would be noise here
|
||||
if (!grouped[trigger.automationId].includes(trigger.trigger)) {
|
||||
grouped[trigger.automationId].push(trigger.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
@@ -84,11 +84,25 @@ const staticOptions = [
|
||||
{
|
||||
id: 'automation__automations',
|
||||
label: 'Manage automations',
|
||||
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
|
||||
keywords: [
|
||||
'osc',
|
||||
'http',
|
||||
'webhook',
|
||||
'integration',
|
||||
'api',
|
||||
'output',
|
||||
'action',
|
||||
'recipe',
|
||||
'example',
|
||||
'preset',
|
||||
'qlab',
|
||||
'vmix',
|
||||
'companion',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation__triggers',
|
||||
label: 'Manage triggers',
|
||||
label: 'Global triggers',
|
||||
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
||||
},
|
||||
],
|
||||
|
||||
+8
-2
@@ -15,7 +15,7 @@
|
||||
|
||||
.triggerHeader {
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
grid-template-columns: 8rem 1fr auto 2rem;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
@@ -25,7 +25,7 @@
|
||||
.trigger {
|
||||
padding: 0.5rem 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: 8rem 1fr 2rem;
|
||||
grid-template-columns: 8rem 1fr auto 2rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
@@ -41,6 +41,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.outputTags {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.duplicateMessage {
|
||||
padding-left: 0.75rem;
|
||||
font-size: $aux-text-size;
|
||||
|
||||
@@ -6,8 +6,11 @@ import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||
import { useEntryActionsContext } from '../../../../common/context/EntryActionsContext';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import { eventTriggerOptions } from './eventTrigger.constants';
|
||||
|
||||
import style from './EventEditorTriggers.module.scss';
|
||||
@@ -27,7 +30,7 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
label: title,
|
||||
}));
|
||||
const hasAutomationOptions = allAutomationOptions.length > 0;
|
||||
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: cycle }));
|
||||
const triggerOptions = eventTriggerOptions.map((cycle) => ({ value: cycle, label: getLifecycleLabel(cycle) }));
|
||||
|
||||
const duplicateIds = new Set<string>();
|
||||
const seen = new Map<string, string>();
|
||||
@@ -76,8 +79,10 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
<div className={style.triggerHeader}>
|
||||
<span>Lifecycle</span>
|
||||
<span>Automation</span>
|
||||
<span>Sends</span>
|
||||
</div>
|
||||
{triggers.map((trigger) => {
|
||||
const automation = automationSettings.automations[trigger.automationId];
|
||||
const isDuplicate = duplicateIds.has(trigger.id);
|
||||
const lifecycleOptions = isDuplicate
|
||||
? triggerOptions.map((opt) => (opt.value === trigger.trigger ? { ...opt, label: `${opt.label} *` } : opt))
|
||||
@@ -103,6 +108,15 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
<div className={style.outputTags}>
|
||||
{automation ? (
|
||||
summariseOutputs(automation.outputs).map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))
|
||||
) : (
|
||||
<Tag variant='warning'>Missing automation</Tag>
|
||||
)}
|
||||
</div>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
|
||||
@@ -135,6 +135,27 @@ describe('addAutomation()', () => {
|
||||
const automations = getAutomations();
|
||||
expect(automations[automation.id]).toMatchObject(testData);
|
||||
});
|
||||
|
||||
it('creates an automation and its global triggers together', async () => {
|
||||
const automation = await addAutomation({ title: 'triggered', filterRule: 'all', filters: [], outputs: [] }, [
|
||||
{ title: 'triggered — On Start', trigger: TimerLifeCycle.onStart },
|
||||
{ title: 'triggered — On Finish', trigger: TimerLifeCycle.onFinish },
|
||||
]);
|
||||
|
||||
expect(getAutomations()[automation.id]).toEqual(automation);
|
||||
expect(getAutomationTriggers()).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'triggered — On Start',
|
||||
trigger: TimerLifeCycle.onStart,
|
||||
automationId: automation.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
title: 'triggered — On Finish',
|
||||
trigger: TimerLifeCycle.onFinish,
|
||||
automationId: automation.id,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAutomation()', () => {
|
||||
@@ -182,6 +203,19 @@ describe('editAutomation()', () => {
|
||||
outputs: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('replaces lifecycle triggers with the automation update', async () => {
|
||||
await addTrigger({ title: 'On Start', trigger: TimerLifeCycle.onStart, automationId: firstAutomation.id });
|
||||
await addTrigger({ title: 'On Finish', trigger: TimerLifeCycle.onFinish, automationId: firstAutomation.id });
|
||||
|
||||
await editAutomation(firstAutomation.id, { title: 'edited-title', filterRule: 'all', filters: [], outputs: [] }, [
|
||||
{ title: 'edited-title — On Danger', trigger: TimerLifeCycle.onDanger },
|
||||
]);
|
||||
|
||||
expect(getAutomationTriggers()).toEqual([
|
||||
expect.objectContaining({ automationId: firstAutomation.id, trigger: TimerLifeCycle.onDanger }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAutomation()', () => {
|
||||
@@ -225,4 +259,44 @@ describe('deleteAutomation()', () => {
|
||||
const removed = getAutomations();
|
||||
expect(Object.keys(removed).length).toEqual(0);
|
||||
});
|
||||
|
||||
it('takes the automation global triggers with it, and leaves the others alone', async () => {
|
||||
const doomed = Object.keys(getAutomations())[0];
|
||||
const survivor = await addAutomation({ title: 'survivor', filterRule: 'all', filters: [], outputs: [] });
|
||||
|
||||
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId: doomed });
|
||||
await addTrigger({ title: 'on finish', trigger: TimerLifeCycle.onFinish, automationId: doomed });
|
||||
await addTrigger({ title: 'keep me', trigger: TimerLifeCycle.onStart, automationId: survivor.id });
|
||||
|
||||
await deleteAutomation({}, doomed);
|
||||
|
||||
// a trigger pointing at nothing never fires, so it must not outlive its automation
|
||||
expect(getAutomationTriggers()).toEqual([expect.objectContaining({ title: 'keep me' })]);
|
||||
expect(Object.keys(getAutomations())).toEqual([survivor.id]);
|
||||
});
|
||||
|
||||
it('refuses an automation attached to an event, and keeps its triggers', async () => {
|
||||
const automationId = Object.keys(getAutomations())[0];
|
||||
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId });
|
||||
|
||||
const projectRundowns: ProjectRundowns = {
|
||||
'rundown-1': {
|
||||
id: 'rundown-1',
|
||||
title: 'Rundown 1',
|
||||
order: ['1'],
|
||||
flatOrder: ['1'],
|
||||
entries: {
|
||||
'1': makeOntimeEvent({
|
||||
id: '1',
|
||||
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
|
||||
}),
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
|
||||
expect(getAutomationTriggers()).toHaveLength(1);
|
||||
expect(Object.keys(getAutomations())).toEqual([automationId]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,14 @@ describe('parseTemplateNested()', () => {
|
||||
const result = parseTemplateNested(testString, mockState);
|
||||
expect(result).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
it('URL-encodes an explicitly marked variable', () => {
|
||||
const result = parseTemplateNested('http://example.com/?title={{url:event.title}}', {
|
||||
event: { title: 'Keynote & Roadmap #1' },
|
||||
});
|
||||
|
||||
expect(result).toBe('http://example.com/?title=Keynote%20%26%20Roadmap%20%231');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseNestedTemplate() -> resolveAliasData()', () => {
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { parseOutput } from '../automation.validation.js';
|
||||
import { parseAutomationTriggers, parseOutput } from '../automation.validation.js';
|
||||
|
||||
describe('parseAutomationTriggers', () => {
|
||||
it('accepts trigger descriptors without an automation ID', () => {
|
||||
expect(parseAutomationTriggers([{ title: 'Start', trigger: 'onStart' }])).toEqual([
|
||||
{ title: 'Start', trigger: 'onStart' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects incomplete or unknown trigger descriptors', () => {
|
||||
expect(() => parseAutomationTriggers([{ trigger: 'onStart' }])).toThrow();
|
||||
expect(() => parseAutomationTriggers([{ title: 'Start', trigger: 'unknown' }])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOutput', () => {
|
||||
describe('handles OSC outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
const payload = {
|
||||
type: 'osc',
|
||||
targetIP: 'localhost',
|
||||
targetIP: ' qlab ',
|
||||
targetPort: 1234,
|
||||
address: '/test',
|
||||
args: 'test',
|
||||
};
|
||||
const result = parseOutput(payload);
|
||||
expect(result).toStrictEqual(payload);
|
||||
expect(result).toStrictEqual({ ...payload, targetIP: 'qlab' });
|
||||
});
|
||||
|
||||
it('throws on a invalid payload', () => {
|
||||
@@ -24,6 +37,33 @@ describe('parseOutput', () => {
|
||||
};
|
||||
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
||||
});
|
||||
|
||||
it('rejects invalid targets and ports', () => {
|
||||
expect(() =>
|
||||
parseOutput({ type: 'osc', targetIP: 'not a host', targetPort: 53000, address: '/test', args: '' }),
|
||||
).toThrow('Invalid OSC target');
|
||||
expect(() =>
|
||||
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 70000, address: '/test', args: '' }),
|
||||
).toThrow('Invalid OSC port');
|
||||
expect(() =>
|
||||
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 0, address: '/test', args: '' }),
|
||||
).toThrow('Invalid OSC port');
|
||||
expect(() =>
|
||||
parseOutput({ type: 'osc', targetIP: '::1', targetPort: 53000, address: '/test', args: '' }),
|
||||
).toThrow('Invalid OSC target');
|
||||
});
|
||||
|
||||
it('allows runtime templates in a target hostname', () => {
|
||||
expect(
|
||||
parseOutput({
|
||||
type: 'osc',
|
||||
targetIP: '{{eventNow.custom.oscTarget}}',
|
||||
targetPort: 53000,
|
||||
address: '/test',
|
||||
args: '',
|
||||
}),
|
||||
).toMatchObject({ targetIP: '{{eventNow.custom.oscTarget}}' });
|
||||
});
|
||||
});
|
||||
describe('handles HTTP outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
@@ -41,6 +81,18 @@ describe('parseOutput', () => {
|
||||
};
|
||||
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
|
||||
});
|
||||
|
||||
it('rejects malformed and unsupported URLs', () => {
|
||||
expect(() => parseOutput({ type: 'http', url: 'localhost:3000/hook' })).toThrow('Invalid HTTP URL');
|
||||
expect(() => parseOutput({ type: 'http', url: 'ftp://example.com/hook' })).toThrow('Invalid HTTP URL');
|
||||
});
|
||||
|
||||
it('allows runtime templates in HTTP URLs', () => {
|
||||
expect(parseOutput({ type: 'http', url: 'http://{{eventNow.customFields.webhookHost}}/hook' })).toEqual({
|
||||
type: 'http',
|
||||
url: 'http://{{eventNow.customFields.webhookHost}}/hook',
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('handles Ontime outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const { send } = vi.hoisted(() => ({ send: vi.fn() }));
|
||||
|
||||
vi.mock('node:dgram', () => ({
|
||||
createSocket: vi.fn(() => ({ send })),
|
||||
}));
|
||||
|
||||
import { emitOSC } from '../clients/osc.client.js';
|
||||
|
||||
describe('emitOSC()', () => {
|
||||
it('resolves runtime templates in the target host', () => {
|
||||
emitOSC(
|
||||
{
|
||||
type: 'osc',
|
||||
targetIP: '{{eventNow.custom.oscTarget}}',
|
||||
targetPort: 53000,
|
||||
address: '/cue/start',
|
||||
args: '',
|
||||
},
|
||||
{
|
||||
eventNow: { custom: { oscTarget: 'qlab' } },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(send).toHaveBeenCalledWith(expect.anything(), 0, expect.any(Number), 53000, 'qlab', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -75,12 +75,18 @@ export async function deleteTrigger(req: Request, res: Response<void | ErrorResp
|
||||
|
||||
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
try {
|
||||
const newAutomation = await automationDao.addAutomation({
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
});
|
||||
const newAutomation = await automationDao.addAutomation(
|
||||
{
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
},
|
||||
req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({
|
||||
title: title.trim(),
|
||||
trigger,
|
||||
})),
|
||||
);
|
||||
res.status(201).send(newAutomation);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -90,12 +96,19 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
|
||||
|
||||
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
try {
|
||||
const newAutomation = await automationDao.editAutomation(req.params.id, {
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
});
|
||||
const newAutomation = await automationDao.editAutomation(
|
||||
req.params.id,
|
||||
{
|
||||
title: req.body.title,
|
||||
filterRule: req.body.filterRule,
|
||||
filters: req.body.filters,
|
||||
outputs: req.body.outputs,
|
||||
},
|
||||
req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({
|
||||
title: title.trim(),
|
||||
trigger,
|
||||
})),
|
||||
);
|
||||
res.status(200).send(newAutomation);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
@@ -110,7 +123,7 @@ export async function deleteAutomation(req: Request, res: Response<void | ErrorR
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
res.status(message.startsWith('Unable to delete automation used in rundown:') ? 409 : 400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationSettings,
|
||||
AutomationTriggerDTO,
|
||||
NormalisedAutomation,
|
||||
ProjectRundowns,
|
||||
Trigger,
|
||||
@@ -113,28 +114,71 @@ export async function deleteAll() {
|
||||
/**
|
||||
* Adds a validated automation to the store
|
||||
*/
|
||||
export async function addAutomation(newAutomation: AutomationDTO): Promise<Automation> {
|
||||
const automations = getAutomations();
|
||||
export async function addAutomation(
|
||||
newAutomation: AutomationDTO,
|
||||
newTriggers: AutomationTriggerDTO[] = [],
|
||||
): Promise<Automation> {
|
||||
const automations = { ...getAutomations() };
|
||||
const id = getUniqueAutomationId(automations);
|
||||
automations[id] = { ...newAutomation, id };
|
||||
await saveChanges({ automations });
|
||||
|
||||
const triggers = [...getAutomationTriggers()];
|
||||
for (const newTrigger of newTriggers) {
|
||||
triggers.push({ ...newTrigger, id: getUniqueTriggerId(triggers), automationId: id });
|
||||
}
|
||||
|
||||
await saveChanges({ automations, triggers });
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing automation with a new entry
|
||||
*/
|
||||
export async function editAutomation(id: string, newAutomation: AutomationDTO): Promise<Automation> {
|
||||
export async function editAutomation(
|
||||
id: string,
|
||||
newAutomation: AutomationDTO,
|
||||
requestedTriggers?: AutomationTriggerDTO[],
|
||||
): Promise<Automation> {
|
||||
const automations = getAutomations();
|
||||
if (!Object.hasOwn(automations, id)) {
|
||||
throw new Error(`Automation with id ${id} not found`);
|
||||
}
|
||||
|
||||
automations[id] = { ...newAutomation, id };
|
||||
await saveChanges({ automations });
|
||||
|
||||
if (requestedTriggers === undefined) {
|
||||
await saveChanges({ automations });
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
const triggers = replaceAutomationTriggers(getAutomationTriggers(), id, requestedTriggers);
|
||||
await saveChanges({ automations, triggers });
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
function replaceAutomationTriggers(
|
||||
triggers: Trigger[],
|
||||
automationId: string,
|
||||
requestedTriggers: AutomationTriggerDTO[],
|
||||
): Trigger[] {
|
||||
const requestedCycles = new Set(requestedTriggers.map((trigger) => trigger.trigger));
|
||||
const keptTriggers = triggers.filter(
|
||||
(trigger) => trigger.automationId !== automationId || requestedCycles.has(trigger.trigger),
|
||||
);
|
||||
const existingCycles = new Set(
|
||||
keptTriggers.filter((trigger) => trigger.automationId === automationId).map((trigger) => trigger.trigger),
|
||||
);
|
||||
|
||||
for (const trigger of requestedTriggers) {
|
||||
if (!existingCycles.has(trigger.trigger)) {
|
||||
keptTriggers.push({ ...trigger, id: getUniqueTriggerId(keptTriggers), automationId });
|
||||
existingCycles.add(trigger.trigger);
|
||||
}
|
||||
}
|
||||
|
||||
return keptTriggers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a automation given its ID
|
||||
*/
|
||||
@@ -145,24 +189,18 @@ export async function deleteAutomation(projectRundowns: ProjectRundowns, automat
|
||||
return;
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in triggers
|
||||
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === automationId);
|
||||
if (triggers.length) {
|
||||
const firstTrigger = triggers[0];
|
||||
const triggerTitle = firstTrigger?.title ?? 'Unknown trigger';
|
||||
throw new Error(
|
||||
`Unable to delete automation used in trigger ${triggerTitle}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// prevent deleting a automation that is in use in events
|
||||
// prevent deleting an automation that is in use in events, the user has to unlink it there
|
||||
const isInUse = isAutomationUsed(projectRundowns, automationId);
|
||||
if (isInUse) {
|
||||
throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`);
|
||||
}
|
||||
|
||||
// a global trigger without its automation is dead data, so it goes with it.
|
||||
// Both are written in a single patch, there is no state where one outlived the other
|
||||
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId !== automationId);
|
||||
|
||||
delete automations[automationId];
|
||||
await saveChanges({ automations });
|
||||
await saveChanges({ automations, triggers });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,7 @@ const placeholderRegex = /{{(.*?)}}/g;
|
||||
|
||||
/**
|
||||
* Parses a templated string to values in a nested object
|
||||
* Prefix a variable with `url:` when its value must be safe inside a URL component.
|
||||
*/
|
||||
export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string {
|
||||
let parsedTemplate = template;
|
||||
@@ -79,7 +80,9 @@ export function parseTemplateNested(template: string, state: object, humanReadab
|
||||
|
||||
for (const match of matches) {
|
||||
const variableName = match[1];
|
||||
const variableParts = variableName.split('.');
|
||||
const shouldEncodeForUrl = variableName.startsWith('url:');
|
||||
const propertyName = shouldEncodeForUrl ? variableName.slice(4) : variableName;
|
||||
const variableParts = propertyName.split('.');
|
||||
let value: string | undefined = undefined;
|
||||
|
||||
if (variableParts[0] === 'human') {
|
||||
@@ -93,10 +96,10 @@ export function parseTemplateNested(template: string, state: object, humanReadab
|
||||
}
|
||||
} else {
|
||||
// we cast to string since this will be used in a string context
|
||||
value = getPropertyFromPath(variableName, state) as string;
|
||||
value = getPropertyFromPath(propertyName, state) as string;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
parsedTemplate = parsedTemplate.replace(match[0], value);
|
||||
parsedTemplate = parsedTemplate.replace(match[0], shouldEncodeForUrl ? encodeURIComponent(value) : value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { body, oneOf, param } from 'express-validator';
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
import { body, param } from 'express-validator';
|
||||
import {
|
||||
Automation,
|
||||
AutomationFilter,
|
||||
AutomationOutput,
|
||||
AutomationTriggerDTO,
|
||||
HTTPOutput,
|
||||
OSCOutput,
|
||||
OntimeAction,
|
||||
SecondarySource,
|
||||
isTimerLifeCycle,
|
||||
timerLifecycleValues,
|
||||
} from 'ontime-types';
|
||||
|
||||
@@ -44,11 +48,16 @@ export const validateTriggerPatch = [
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
|
||||
export const validateAutomation = [
|
||||
body().custom(parseAutomation),
|
||||
body('triggers').optional().custom(parseAutomationTriggers),
|
||||
requestValidationFunction,
|
||||
];
|
||||
|
||||
export const validateAutomationPatch = [
|
||||
param('id').isString().notEmpty(),
|
||||
body().custom(parseAutomation),
|
||||
body('triggers').optional().custom(parseAutomationTriggers),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
@@ -75,6 +84,20 @@ export function parseAutomation(maybeAutomation: unknown): Automation {
|
||||
return maybeAutomation as Automation;
|
||||
}
|
||||
|
||||
export function parseAutomationTriggers(maybeTriggers: unknown): AutomationTriggerDTO[] {
|
||||
assert.isArray(maybeTriggers);
|
||||
return maybeTriggers.map((maybeTrigger) => {
|
||||
assert.isObject(maybeTrigger);
|
||||
assert.hasKeys(maybeTrigger, ['title', 'trigger']);
|
||||
assert.isString(maybeTrigger.title);
|
||||
assert.isString(maybeTrigger.trigger);
|
||||
if (!maybeTrigger.title.trim() || !isTimerLifeCycle(maybeTrigger.trigger)) {
|
||||
throw new Error('Invalid automation trigger');
|
||||
}
|
||||
return { title: maybeTrigger.title.trim(), trigger: maybeTrigger.trigger };
|
||||
});
|
||||
}
|
||||
|
||||
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
|
||||
filters.forEach((condition) => {
|
||||
assert.isObject(condition);
|
||||
@@ -103,33 +126,7 @@ function validateOutput(output: Array<unknown>): output is AutomationOutput[] {
|
||||
}
|
||||
|
||||
export const validateTestPayload = [
|
||||
body('type').isIn(['osc', 'http', 'ontime']),
|
||||
|
||||
// validation for OSC message
|
||||
oneOf([
|
||||
body('targetIP').if(body('type').equals('osc')).isIP(),
|
||||
body('targetIP').if(body('type').equals('osc')).isFQDN(),
|
||||
body('targetIP').if(body('type').equals('osc')).equals('localhost'),
|
||||
]),
|
||||
body('targetPort').if(body('type').equals('osc')).isPort(),
|
||||
body('address').if(body('type').equals('osc')).isString().trim(),
|
||||
body('args').if(body('type').equals('osc')).isString().trim(),
|
||||
|
||||
// validation for HTTP message
|
||||
body('url').if(body('type').equals('http')).isURL({ require_tld: false }).trim(),
|
||||
|
||||
// validation for Ontime actions
|
||||
body('action').if(body('type').equals('ontime')).isString().trim(),
|
||||
body('text').if(body('type').equals('ontime')).optional().isString().trim(),
|
||||
body('time').if(body('type').equals('ontime')).optional().isString().trim(),
|
||||
body('visible').if(body('type').equals('ontime')).optional().isBoolean(),
|
||||
// secondary source can be a enum case or null to clear it
|
||||
body('secondarySource')
|
||||
.if(body('type').equals('ontime'))
|
||||
.optional({ nullable: true })
|
||||
.if((value) => value !== null)
|
||||
.isString()
|
||||
.trim(),
|
||||
body().custom(parseOutput),
|
||||
|
||||
requestValidationFunction,
|
||||
];
|
||||
@@ -163,9 +160,25 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
|
||||
assert.isString(maybeOSCOutput.address);
|
||||
assert.isString(maybeOSCOutput.args);
|
||||
|
||||
const targetIP = maybeOSCOutput.targetIP.trim();
|
||||
const target = replaceAutomationTemplates(targetIP, 'template.local');
|
||||
const isHostname = /^(?=.{1,253}$)[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*$/i.test(
|
||||
target,
|
||||
);
|
||||
if (isIP(target) !== 4 && !isHostname) {
|
||||
throw new Error('Invalid OSC target');
|
||||
}
|
||||
if (
|
||||
!Number.isInteger(maybeOSCOutput.targetPort) ||
|
||||
maybeOSCOutput.targetPort < 1 ||
|
||||
maybeOSCOutput.targetPort > 65535
|
||||
) {
|
||||
throw new Error('Invalid OSC port');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'osc',
|
||||
targetIP: maybeOSCOutput.targetIP,
|
||||
targetIP,
|
||||
targetPort: maybeOSCOutput.targetPort,
|
||||
address: maybeOSCOutput.address,
|
||||
args: maybeOSCOutput.args,
|
||||
@@ -176,12 +189,25 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
|
||||
assert.hasKeys(maybeHTTPOutput, ['url']);
|
||||
assert.isString(maybeHTTPOutput.url);
|
||||
|
||||
try {
|
||||
const url = new URL(replaceAutomationTemplates(maybeHTTPOutput.url, 'template'));
|
||||
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
|
||||
throw new Error('Invalid HTTP URL');
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Invalid HTTP URL');
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'http',
|
||||
url: maybeHTTPOutput.url,
|
||||
};
|
||||
}
|
||||
|
||||
function replaceAutomationTemplates(value: string, replacement: string): string {
|
||||
return value.replace(/{{.*?}}/g, replacement);
|
||||
}
|
||||
|
||||
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
|
||||
assert.hasKeys(maybeOntimeAction, ['action']);
|
||||
assert.isString(maybeOntimeAction.action);
|
||||
|
||||
@@ -14,7 +14,8 @@ const udpClient = dgram.createSocket('udp4');
|
||||
*/
|
||||
export function emitOSC(output: OSCOutput, store: DeepReadonly<RuntimeStore>) {
|
||||
const message = preparePayload(output, store);
|
||||
emit(output.targetIP, output.targetPort, message);
|
||||
const targetIP = parseTemplateNested(output.targetIP, store);
|
||||
emit(targetIP, output.targetPort, message);
|
||||
}
|
||||
|
||||
/** Parses the state and prepares payload to be emitted */
|
||||
|
||||
@@ -33,6 +33,9 @@ export type Trigger = {
|
||||
|
||||
export type TriggerDTO = Omit<Trigger, 'id'>;
|
||||
|
||||
/** A global trigger whose automation ID is assigned during automation creation. */
|
||||
export type AutomationTriggerDTO = Omit<TriggerDTO, 'automationId'>;
|
||||
|
||||
export type AutomationFilter = {
|
||||
field: string; // this should be a key of a OntimeEvent + custom fields
|
||||
operator: 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains';
|
||||
|
||||
@@ -34,6 +34,7 @@ export type {
|
||||
AutomationDTO,
|
||||
AutomationFilter,
|
||||
AutomationSettings,
|
||||
AutomationTriggerDTO,
|
||||
AutomationOutput,
|
||||
FilterRule,
|
||||
HTTPOutput,
|
||||
|
||||
Reference in New Issue
Block a user