mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed39f86a81 | |||
| cdde054b14 | |||
| 32734de679 | |||
| fc8819cea6 | |||
| e7379c9add | |||
| 5ea3845f0d | |||
| f1bf8e8bee | |||
| 8ed708138d | |||
| 0f9e436576 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/cli",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-ui",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -176,13 +176,6 @@ export async function postCloneEntry(
|
||||
return axios.post(`${rundownPath}/${rundownId}/clone/${entryId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request events duration to fit inside the group target
|
||||
*/
|
||||
export async function requestFitGroupTarget(rundownId: RundownId, eventId: EntryId): Promise<AxiosResponse<Rundown>> {
|
||||
return axios.post(`${rundownPath}/${rundownId}/${eventId}/fit-group-duration`);
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request for grouping a list of entries into a group
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
/**
|
||||
* User facing labels for the timer lifecycle
|
||||
* Shared between the automation settings and the rundown event editor
|
||||
* so that a lifecycle is named the same everywhere it is shown
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
requestEventSwap,
|
||||
requestGroupEntries,
|
||||
requestUngroup,
|
||||
requestFitGroupTarget,
|
||||
} from '../api/rundown';
|
||||
import { logAxiosError } from '../api/utils';
|
||||
import { useEditorSettings } from '../stores/editorSettings';
|
||||
@@ -467,27 +466,7 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
return previousEnd;
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient, resolveCurrentRundownQueryKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates time of existing event so it satisfies the group target duration
|
||||
* @param eventId {EntryId} - id of the event
|
||||
*/
|
||||
const matchGroupDuration = useCallback(
|
||||
async (eventId: EntryId) => {
|
||||
const rundownId = getCurrentRundownData()?.id;
|
||||
if (!rundownId) {
|
||||
throw new Error('Rundown not initialised');
|
||||
}
|
||||
|
||||
try {
|
||||
await requestFitGroupTarget(rundownId, eventId);
|
||||
} catch (error) {
|
||||
logAxiosError('Error updating event', error);
|
||||
}
|
||||
},
|
||||
[getCurrentRundownData],
|
||||
[getCurrentRundownData, updateEntryMutation, queryClient],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -1030,7 +1009,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
}),
|
||||
[
|
||||
addEntry,
|
||||
@@ -1048,7 +1026,6 @@ function useEntryActionsForRundown(scopedRundownId: string | undefined) {
|
||||
swapEvents,
|
||||
updateEntry,
|
||||
updateTimer,
|
||||
matchGroupDuration,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,15 @@ const logger = createStore<LogStore>(() => ({
|
||||
|
||||
export const useLogData = () => useStore(logger);
|
||||
|
||||
/**
|
||||
* The server queue is capped at 100 entries, the client's was not.
|
||||
* A long show with chatty automations would otherwise grow this forever.
|
||||
*/
|
||||
const maxLogEntries = 500;
|
||||
|
||||
export const addLog = (log: Log) =>
|
||||
logger.setState((state) => ({
|
||||
logs: [log, ...state.logs],
|
||||
logs: [log, ...state.logs].slice(0, maxLogEntries),
|
||||
}));
|
||||
|
||||
export const clearLogs = () => logger.setState({ logs: [] });
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -200,7 +200,8 @@ $card-padding: 2rem;
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: $zindex-backdrop;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.updateIndicator {
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 99px;
|
||||
background-color: $red-400;
|
||||
}
|
||||
@@ -3,8 +3,6 @@ import useAppVersion from '../../../../common/hooks-query/useAppVersion';
|
||||
import { appVersion, isOntimeCloud, websiteUrl } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
|
||||
import style from './AppVersion.module.scss';
|
||||
|
||||
export default function AppVersion() {
|
||||
const { data, isError } = useAppVersion();
|
||||
|
||||
@@ -20,12 +18,7 @@ export default function AppVersion() {
|
||||
return (
|
||||
<Panel.ListItem>
|
||||
<Panel.Field
|
||||
title={
|
||||
<>
|
||||
<span className={style.updateIndicator} aria-hidden='true' />
|
||||
{`Ontime ${appVersion}`}
|
||||
</>
|
||||
}
|
||||
title={`Ontime ${appVersion}`}
|
||||
description={
|
||||
isOntimeCloud
|
||||
? `Version ${data.version} is available. Restart your stage to update.`
|
||||
@@ -33,7 +26,7 @@ export default function AppVersion() {
|
||||
}
|
||||
/>
|
||||
{!isOntimeCloud && (
|
||||
<ExternalLink href={websiteUrl}>Download the latest version from Ontime's page</ExternalLink>
|
||||
<ExternalLink href={websiteUrl}>Visit Ontime's page to download the latest version.</ExternalLink>
|
||||
)}
|
||||
</Panel.ListItem>
|
||||
);
|
||||
|
||||
+68
-45
@@ -26,61 +26,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;
|
||||
}
|
||||
|
||||
+448
-260
@@ -1,21 +1,31 @@
|
||||
import {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationFilter,
|
||||
HTTPOutput,
|
||||
OSCOutput,
|
||||
OntimeAction,
|
||||
TimerLifeCycle,
|
||||
Trigger,
|
||||
isHTTPOutput,
|
||||
isOSCOutput,
|
||||
isOntimeAction,
|
||||
} from 'ontime-types';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
import { IoAdd, IoCheckmark, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import {
|
||||
addAutomation,
|
||||
addTrigger,
|
||||
deleteTrigger,
|
||||
editAutomation,
|
||||
testOutput,
|
||||
} 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 { 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';
|
||||
@@ -27,7 +37,7 @@ import useAutomationSettings from '../../../../common/hooks-query/useAutomationS
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { isAutomation, makeFieldList } from './automationUtils';
|
||||
import { cycles, isAutomation, makeFieldList, operators } from './automationUtils';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
import TemplateInput from './template-input/TemplateInput';
|
||||
|
||||
@@ -36,17 +46,67 @@ 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;
|
||||
|
||||
type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
|
||||
|
||||
/** 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;
|
||||
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
|
||||
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]);
|
||||
|
||||
/**
|
||||
* Triggers are a separate entity, so they live outside the form state.
|
||||
*
|
||||
* We snapshot the automation's triggers when the form opens and reconcile against that
|
||||
* snapshot, never against the live prop: settings are polled, so a trigger created
|
||||
* elsewhere while this form is open must not be deleted by a save that never saw it.
|
||||
*/
|
||||
const [initialTriggers] = useState<Trigger[]>(() =>
|
||||
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
|
||||
);
|
||||
const initialCycles = useMemo(
|
||||
() => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))),
|
||||
[initialTriggers],
|
||||
);
|
||||
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles);
|
||||
/** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
const cyclesAreDirty =
|
||||
selectedCycles.length !== initialCycles.length ||
|
||||
selectedCycles.some((cycle) => !initialCycles.includes(cycle)) ||
|
||||
initialCycles.some((cycle) => !selectedCycles.includes(cycle));
|
||||
|
||||
const toggleCycle = (cycle: TimerLifeCycle) => {
|
||||
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
|
||||
};
|
||||
|
||||
/**
|
||||
* A lifecycle can carry several differently named triggers, which the chips collapse into one.
|
||||
* Unchecking it removes all of them, so say which ones rather than deleting them quietly.
|
||||
*/
|
||||
const triggersToRemove = initialTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
|
||||
|
||||
/**
|
||||
* Test results are keyed by the field array id rather than the index:
|
||||
* removing an output shifts every index after it, which would leave feedback on the wrong row
|
||||
*/
|
||||
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
|
||||
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -93,6 +153,26 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
setFocus('title');
|
||||
}, [setFocus]);
|
||||
|
||||
// the timers outlive a fast close, clearing them avoids setting state on an unmounted form
|
||||
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 handleAddNewFilter = () => {
|
||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||
};
|
||||
@@ -110,12 +190,15 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
appendOutput({ type: 'ontime', action: 'aux1-start' });
|
||||
};
|
||||
|
||||
const handleTestOSCOutput = async (index: number) => {
|
||||
const handleTestOSCOutput = async (index: number, key: string) => {
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
||||
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
|
||||
return;
|
||||
}
|
||||
|
||||
reportTest(key, { status: 'sending' });
|
||||
try {
|
||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
||||
return;
|
||||
}
|
||||
await testOutput({
|
||||
type: 'osc',
|
||||
targetIP: values.targetIP,
|
||||
@@ -123,67 +206,110 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
address: values.address,
|
||||
args: values.args,
|
||||
});
|
||||
} catch (_error) {
|
||||
/** we dont handle errors here, users should use the network tab */
|
||||
// OSC is fire and forget over UDP, the most we can honestly claim is that we sent it
|
||||
reportTest(key, { status: 'ok', message: 'Sent' });
|
||||
} catch (error) {
|
||||
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestHTTPOutput = async (index: number) => {
|
||||
const handleTestHTTPOutput = async (index: number, key: string) => {
|
||||
const values = getValues(`outputs.${index}`) as HTTPOutput;
|
||||
if (!values.url) {
|
||||
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
|
||||
return;
|
||||
}
|
||||
|
||||
reportTest(key, { status: 'sending' });
|
||||
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 */
|
||||
await testOutput({ type: 'http', url: values.url });
|
||||
reportTest(key, { status: 'ok', message: 'Sent' });
|
||||
} catch (error) {
|
||||
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOntimeAction = async (index: number) => {
|
||||
const handleTestOntimeAction = async (index: number, key: string) => {
|
||||
const values = getValues(`outputs.${index}`) as OntimeAction;
|
||||
|
||||
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, type: 'ontime' });
|
||||
reportTest(key, { status: 'ok', message: 'Done' });
|
||||
} catch (error) {
|
||||
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciles the lifecycle selection against the global triggers.
|
||||
* Runs after the automation itself is saved: a new automation has no id until then.
|
||||
*
|
||||
* Both sides are diffed against the mount-time snapshot, so this only ever removes
|
||||
* triggers the user could actually see when they made the change.
|
||||
*/
|
||||
const syncTriggers = async (automationId: string, title: string) => {
|
||||
for (const trigger of triggersToRemove) {
|
||||
await deleteTrigger(trigger.id);
|
||||
}
|
||||
|
||||
const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle));
|
||||
for (const cycle of toAdd) {
|
||||
const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle;
|
||||
await addTrigger({ title: `${title} — ${label}`, trigger: cycle, automationId });
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
if (isAutomation(automation)) {
|
||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
||||
} else {
|
||||
await handleCreate(values);
|
||||
// saving happens in two requests, so a retry after a partial failure must edit rather than create again
|
||||
const existingId = isAutomation(automation) ? automation.id : createdId;
|
||||
let automationId: string;
|
||||
|
||||
try {
|
||||
if (existingId) {
|
||||
await editAutomation(existingId, { id: existingId, ...values });
|
||||
automationId = existingId;
|
||||
} else {
|
||||
const created = await addAutomation(values);
|
||||
setCreatedId(created.id);
|
||||
automationId = created.id;
|
||||
}
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncTriggers(automationId, values.title);
|
||||
} catch (error) {
|
||||
// the automation itself is saved, only its triggers failed. Keep the form open so the user can retry
|
||||
refetch();
|
||||
setError('root', { message: `Automation saved, but its triggers failed: ${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;
|
||||
/** describes a filter in plain language so the user does not have to read the form back to themselves */
|
||||
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'}`;
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && isValid;
|
||||
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -191,6 +317,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='wide'
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
@@ -207,87 +334,119 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</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(({ id, label, value }) => {
|
||||
const cycle = value as TimerLifeCycle;
|
||||
const isSelected = selectedCycles.includes(cycle);
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
size='small'
|
||||
variant={isSelected ? 'primary' : 'subtle'}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCycle(cycle)}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{triggersToRemove.length > 0 && (
|
||||
<Panel.Description tone='warning'>
|
||||
{`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove
|
||||
.map((trigger) => trigger.title)
|
||||
.join(', ')}`}
|
||||
</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}>
|
||||
<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.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 key = `filters.${index}.field.${field.id}`;
|
||||
const description = describeFilter(index);
|
||||
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>
|
||||
<IconButton
|
||||
aria-label='Delete'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div key={field.id} className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag>Filter</Tag>
|
||||
<span className={style.cardSummary}>{description}</span>
|
||||
<IconButton
|
||||
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>
|
||||
);
|
||||
@@ -309,6 +468,13 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{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.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
if (isOSCOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
@@ -321,75 +487,61 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>OSC</Tag>
|
||||
<div className={style.oscSection}>
|
||||
<label>
|
||||
Target IP
|
||||
<Input
|
||||
{...register(`outputs.${index}.targetIP`, {
|
||||
required: { value: true, message: 'Required field' },
|
||||
})}
|
||||
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>
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
testState={testResults[output.id]}
|
||||
onTest={() => handleTestOSCOutput(index, output.id)}
|
||||
onDelete={() => removeOutput(index)}
|
||||
>
|
||||
<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>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHTTPOutput(output)) {
|
||||
const rowErrors = errors.outputs?.[index] as
|
||||
| {
|
||||
@@ -397,42 +549,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
}
|
||||
| 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>
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='HTTP'
|
||||
kindClass={style.tagHttp}
|
||||
testState={testResults[output.id]}
|
||||
onTest={() => handleTestHTTPOutput(index, output.id)}
|
||||
onDelete={() => removeOutput(index)}
|
||||
>
|
||||
<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>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -447,8 +588,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
}
|
||||
| undefined;
|
||||
return (
|
||||
<div key={output.id} className={style.outputCard}>
|
||||
<Tag>Ontime action</Tag>
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='Ontime action'
|
||||
kindClass={style.tagOntime}
|
||||
testState={testResults[output.id]}
|
||||
onTest={() => handleTestOntimeAction(index, output.id)}
|
||||
onDelete={() => removeOutput(index)}
|
||||
>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
@@ -456,38 +603,40 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
>
|
||||
<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>
|
||||
/>
|
||||
</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>
|
||||
<div>
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
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>
|
||||
</form>
|
||||
}
|
||||
@@ -503,3 +652,42 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface OutputCardProps {
|
||||
label: string;
|
||||
kindClass?: string;
|
||||
summary?: string;
|
||||
testState?: TestState;
|
||||
onTest: () => void;
|
||||
onDelete: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for every output kind: the type tag and the actions live in the header,
|
||||
* so they stop competing with the form fields for grid columns
|
||||
*/
|
||||
function OutputCard({ label, kindClass, summary, testState, 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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
||||
/>
|
||||
</div>
|
||||
<div ref={automationsRef}>
|
||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
||||
<AutomationsList
|
||||
automations={data.automations}
|
||||
triggers={data.triggers}
|
||||
enabledAutomations={automationState}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div ref={triggersRef}>
|
||||
<TriggersList
|
||||
|
||||
+12
-2
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
|
||||
import { isOnlyNumbers } from '../../../../common/utils/regex';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||
|
||||
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
|
||||
|
||||
@@ -32,6 +33,7 @@ export default function AutomationSettingsForm({
|
||||
oscInputState,
|
||||
isLoading,
|
||||
}: AutomationSettingsProps) {
|
||||
const { setLocation } = useAppSettingsNavigation();
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
@@ -94,9 +96,17 @@ 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.Footer>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||
<Button variant='ghosted' size='small' onClick={() => setLocation('network__log')}>
|
||||
Automations report to the event log
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.muted {
|
||||
color: $muted-gray;
|
||||
}
|
||||
+105
-40
@@ -1,16 +1,21 @@
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoSparklesOutline, 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 * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AutomationForm from './AutomationForm';
|
||||
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
|
||||
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||
import RecipeLibraryModal from './recipes/RecipeLibraryModal';
|
||||
|
||||
import style from './AutomationsList.module.scss';
|
||||
|
||||
const automationPlaceholder: AutomationDTO = {
|
||||
title: '',
|
||||
@@ -21,39 +26,79 @@ 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 [showRecipes, setShowRecipes] = 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();
|
||||
}
|
||||
/**
|
||||
* A recipe lands in the editor rather than only in the list.
|
||||
* Seeing it as an editable automation is the point, and recipes with an
|
||||
* external target are unusable until the user changes it anyway.
|
||||
*/
|
||||
const handleRecipeInstalled = async (created: Automation) => {
|
||||
setShowRecipes(false);
|
||||
await refetch();
|
||||
setAutomationFormData(created);
|
||||
};
|
||||
|
||||
const handleDeleted = async () => {
|
||||
setDeleteTarget(null);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
|
||||
|
||||
const arrayAutomations = Object.keys(automations);
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
<AutomationForm
|
||||
// the form snapshots the automation's lifecycles on mount, so it must never be
|
||||
// reused across two different automations
|
||||
key={isAutomation(automationFormData) ? automationFormData.id : 'new'}
|
||||
automation={automationFormData}
|
||||
triggers={triggers}
|
||||
onClose={() => setAutomationFormData(null)}
|
||||
/>
|
||||
)}
|
||||
{showRecipes && (
|
||||
<RecipeLibraryModal
|
||||
onClose={() => setShowRecipes(false)}
|
||||
onInstalled={(_recipe, created) => handleRecipeInstalled(created)}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget !== null && (
|
||||
<DeleteAutomationDialog
|
||||
automation={deleteTarget}
|
||||
blockingTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button onClick={() => setShowRecipes(true)}>
|
||||
Browse recipes <IoSparklesOutline />
|
||||
</Button>
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
@@ -69,10 +114,10 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '45%' }}>Title</th>
|
||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Outputs</th>
|
||||
<th 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>
|
||||
@@ -82,9 +127,14 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
title='No automations yet'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
<Panel.InlineElements relation='inner'>
|
||||
<Button variant='primary' onClick={() => setShowRecipes(true)}>
|
||||
Browse recipes <IoSparklesOutline />
|
||||
</Button>
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
Create from scratch <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -92,27 +142,49 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
if (!Object.hasOwn(automations, automationId)) {
|
||||
return null;
|
||||
}
|
||||
const automation = automations[automationId];
|
||||
const lifecycles = lifecyclesByAutomation[automationId] ?? [];
|
||||
const outputs = summariseOutputs(automation.outputs);
|
||||
|
||||
return (
|
||||
<Fragment key={automationId}>
|
||||
<tr>
|
||||
<td>{automations[automationId].title}</td>
|
||||
<td>{automation.title}</td>
|
||||
<Panel.InlineElements as='td' relation='inner' wrap='wrap'>
|
||||
{lifecycles.length === 0 ? (
|
||||
<Tag variant='warning'>Never runs</Tag>
|
||||
) : (
|
||||
lifecycles.map((cycle) => <Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>)
|
||||
)}
|
||||
</Panel.InlineElements>
|
||||
<td>
|
||||
<Tag>{automations[automationId].filterRule}</Tag>
|
||||
{automation.filters.length === 0 ? (
|
||||
<span className={style.muted}>—</span>
|
||||
) : (
|
||||
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
|
||||
)}
|
||||
</td>
|
||||
<td>{automations[automationId].filters.length}</td>
|
||||
<td>{automations[automationId].outputs.length}</td>
|
||||
<Panel.InlineElements as='td' relation='inner' wrap='wrap'>
|
||||
{outputs.length === 0 ? (
|
||||
<Tag variant='warning'>No outputs</Tag>
|
||||
) : (
|
||||
outputs.map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))
|
||||
)}
|
||||
</Panel.InlineElements>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
aria-label='Edit entry'
|
||||
onClick={() => setAutomationFormData(automations[automationId])}
|
||||
onClick={() => setAutomationFormData(automation)}
|
||||
>
|
||||
<IoPencil />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
aria-label='Delete entry'
|
||||
onClick={() => handleDelete(automationId)}
|
||||
onClick={() => setDeleteTarget(automation)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
@@ -121,13 +193,6 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
</Panel.Section>
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
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 block the delete server side */
|
||||
blockingTriggers: Trigger[];
|
||||
onCancel: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server refuses to delete an automation that is still referenced, and the panel used to
|
||||
* dump that refusal into a stray row under the table. This dialog confirms first and, on a
|
||||
* refusal, names what is blocking it: global triggers to remove from the Global Triggers list,
|
||||
* or an event reference to remove from the event editor. It does not delete those triggers for
|
||||
* the user — a single extra step there is safer than a delete-then-restore sequence here.
|
||||
*/
|
||||
export default function DeleteAutomationDialog({
|
||||
automation,
|
||||
blockingTriggers,
|
||||
onCancel,
|
||||
onDeleted,
|
||||
}: DeleteAutomationDialogProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null);
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAutomation(automation.id);
|
||||
onDeleted();
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
} 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>
|
||||
|
||||
{blockingTriggers.length > 0 && (
|
||||
<Info type='warning'>
|
||||
<Info.Title>
|
||||
{blockingTriggers.length === 1
|
||||
? 'One trigger points at this automation'
|
||||
: `${blockingTriggers.length} triggers point at this automation`}
|
||||
</Info.Title>
|
||||
<Info.Body>
|
||||
{blockingTriggers
|
||||
.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`)
|
||||
.join(', ')}
|
||||
</Info.Body>
|
||||
<Info.Footer>Remove them from Global Triggers first, then delete the automation.</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Info type='error'>
|
||||
<Info.Title>Could not delete this automation</Info.Title>
|
||||
<Info.Body>{error}</Info.Body>
|
||||
<Info.Footer>
|
||||
Automations attached to a single event have to be removed from that event first, in the event editor.
|
||||
</Info.Footer>
|
||||
</Info>
|
||||
)}
|
||||
</Panel.Section>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -30,9 +30,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 +40,7 @@ export default function OntimeActionForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.actionSection}>
|
||||
<>
|
||||
<label>
|
||||
Action
|
||||
<Select
|
||||
@@ -95,7 +94,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 +126,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 +168,6 @@ export default function OntimeActionForm({
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={style.test}>{children}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -27,6 +28,7 @@ interface TriggersListProps {
|
||||
export default function TriggersList({ triggers, automations, enabledAutomations, 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 +52,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,13 +72,17 @@ 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>
|
||||
<Panel.Description>
|
||||
Triggers are managed from the automation itself. This list is for naming them, or for pointing several
|
||||
differently named triggers at the same automation.
|
||||
</Panel.Description>
|
||||
{enabledAutomations === false && (
|
||||
<Info>
|
||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
||||
@@ -80,8 +90,15 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
@@ -99,14 +116,18 @@ 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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,12 @@ 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 */}
|
||||
{automations?.[trigger.automationId] ? (
|
||||
<Tag>{automations[trigger.automationId].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}>
|
||||
|
||||
+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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Automation, AutomationDTO, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { lifecycleLabels } from '../../../../common/constants/timerLifecycle';
|
||||
|
||||
type CycleLabel = {
|
||||
id: number;
|
||||
@@ -7,15 +9,29 @@ type CycleLabel = {
|
||||
};
|
||||
|
||||
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' },
|
||||
{ id: 1, label: lifecycleLabels.onLoad, value: 'onLoad' },
|
||||
{ id: 2, label: lifecycleLabels.onStart, value: 'onStart' },
|
||||
{ id: 3, label: lifecycleLabels.onPause, value: 'onPause' },
|
||||
{ id: 4, label: lifecycleLabels.onStop, value: 'onStop' },
|
||||
{ id: 5, label: lifecycleLabels.onClock, value: 'onClock' },
|
||||
{ id: 6, label: lifecycleLabels.onUpdate, value: 'onUpdate' },
|
||||
{ id: 7, label: lifecycleLabels.onFinish, value: 'onFinish' },
|
||||
{ id: 8, label: lifecycleLabels.onWarning, value: 'onWarning' },
|
||||
{ id: 9, label: lifecycleLabels.onDanger, value: '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' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -83,3 +99,23 @@ export function checkDuplicates(triggers: Trigger[]) {
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
.library {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
color: $ui-white;
|
||||
font-size: calc(1rem - 1px);
|
||||
padding-block: 0.5rem;
|
||||
}
|
||||
|
||||
.category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.recipeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.recipe {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid $white-10;
|
||||
border-radius: $component-border-radius-md;
|
||||
background-color: $black-10;
|
||||
}
|
||||
|
||||
.recipeTitle {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recipeDescription {
|
||||
flex: 1;
|
||||
font-size: $aux-text-size;
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
.recipeActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import type { Automation } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { maybeAxiosError } from '../../../../../common/api/utils';
|
||||
import Button from '../../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../../common/components/modal/Modal';
|
||||
import Tag from '../../../../../common/components/tag/Tag';
|
||||
import { getLifecycleLabel } from '../../../../../common/constants/timerLifecycle';
|
||||
import { summariseOutputs } from '../../../../../common/utils/automationOutputs';
|
||||
import { isOntimeCloud } from '../../../../../externals';
|
||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||
import {
|
||||
automationRecipes,
|
||||
recipeCategoryLabels,
|
||||
recipeCategoryOrder,
|
||||
type AutomationRecipe,
|
||||
} from './automationRecipes';
|
||||
import { installRecipe } from './recipeUtils';
|
||||
|
||||
import style from './RecipeLibraryModal.module.scss';
|
||||
|
||||
interface RecipeLibraryModalProps {
|
||||
onClose: () => void;
|
||||
/** called with the installed automation so the caller can open it for editing */
|
||||
onInstalled: (automation: AutomationRecipe, created: Automation) => void;
|
||||
}
|
||||
|
||||
export default function RecipeLibraryModal({ onClose, onInstalled }: RecipeLibraryModalProps) {
|
||||
const [installing, setInstalling] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// OSC is not available in the cloud service, offering those recipes there would be a lie
|
||||
const available = isOntimeCloud
|
||||
? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc'))
|
||||
: automationRecipes;
|
||||
|
||||
const handleInstall = async (recipe: AutomationRecipe) => {
|
||||
setError(null);
|
||||
setInstalling(recipe.id);
|
||||
try {
|
||||
const created = await installRecipe(recipe);
|
||||
onInstalled(recipe, created);
|
||||
} catch (error) {
|
||||
setError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='wide'
|
||||
title='Automation recipes'
|
||||
bodyElements={
|
||||
<div className={style.library}>
|
||||
<Info>
|
||||
<Info.Body>
|
||||
Recipes are a starting point, not a black box. Each one is added as a normal automation that you can edit,
|
||||
test or delete. Recipes that reach external software are set to this machine, so point them at the right
|
||||
device before you rely on them.
|
||||
</Info.Body>
|
||||
</Info>
|
||||
|
||||
{recipeCategoryOrder.map((category) => {
|
||||
const recipes = available.filter((recipe) => recipe.category === category);
|
||||
if (recipes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={category} className={style.category}>
|
||||
<Panel.Title>{recipeCategoryLabels[category]}</Panel.Title>
|
||||
<div className={style.recipeGrid}>
|
||||
{recipes.map((recipe) => (
|
||||
<article key={recipe.id} className={style.recipe}>
|
||||
<div className={style.recipeTitle}>{recipe.title}</div>
|
||||
<div className={style.recipeDescription}>{recipe.description}</div>
|
||||
<Panel.InlineElements relation='inner' wrap='wrap'>
|
||||
{recipe.triggers.map((cycle) => (
|
||||
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||
))}
|
||||
{summariseOutputs(recipe.automation.outputs).map(({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
))}
|
||||
{recipe.needsSetup && <Tag variant='warning'>Needs a target</Tag>}
|
||||
</Panel.InlineElements>
|
||||
<div className={style.recipeActions}>
|
||||
{recipe.docsUrl && <ExternalLink href={recipe.docsUrl}>Docs</ExternalLink>}
|
||||
<Button
|
||||
variant='primary'
|
||||
size='small'
|
||||
loading={installing === recipe.id}
|
||||
disabled={installing !== null}
|
||||
onClick={() => handleInstall(recipe)}
|
||||
>
|
||||
{recipe.needsSetup ? 'Add and configure' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { isHTTPOutput, isOSCOutput, isOntimeAction, timerLifecycleValues } from 'ontime-types';
|
||||
|
||||
import { operators } from '../../automationUtils';
|
||||
import { automationRecipes, recipeCategoryOrder } from '../automationRecipes';
|
||||
|
||||
/**
|
||||
* Recipes are shipped as constants but installed through the same endpoints as a
|
||||
* hand written automation. These assertions stand in for the server side validation,
|
||||
* so a recipe cannot silently rot into something that 400s on install.
|
||||
*/
|
||||
describe('automationRecipes', () => {
|
||||
it('ships recipes', () => {
|
||||
expect(automationRecipes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('has unique ids', () => {
|
||||
const ids = automationRecipes.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('only uses categories the library knows how to render', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(recipeCategoryOrder).toContain(recipe.category);
|
||||
}
|
||||
});
|
||||
|
||||
it('binds every recipe to at least one valid lifecycle', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(recipe.triggers.length).toBeGreaterThan(0);
|
||||
for (const cycle of recipe.triggers) {
|
||||
expect(timerLifecycleValues).toContain(cycle);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('gives every recipe something to send', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
expect(recipe.automation.outputs.length).toBeGreaterThan(0);
|
||||
expect(recipe.automation.title).not.toBe('');
|
||||
|
||||
for (const output of recipe.automation.outputs) {
|
||||
expect(isOSCOutput(output) || isHTTPOutput(output) || isOntimeAction(output)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('only uses filter operators the server accepts', () => {
|
||||
const allowed = operators.map(({ value }) => value);
|
||||
for (const recipe of automationRecipes) {
|
||||
for (const filter of recipe.automation.filters) {
|
||||
expect(allowed).toContain(filter.operator);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults every external target to this machine', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
for (const output of recipe.automation.outputs) {
|
||||
if (isOSCOutput(output)) {
|
||||
expect(output.targetIP).toBe('127.0.0.1');
|
||||
}
|
||||
if (isHTTPOutput(output)) {
|
||||
expect(output.url.startsWith('http://127.0.0.1')).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('marks recipes that reach outside Ontime as needing a target', () => {
|
||||
for (const recipe of automationRecipes) {
|
||||
const reachesOut = recipe.automation.outputs.some((output) => isOSCOutput(output) || isHTTPOutput(output));
|
||||
expect(recipe.needsSetup).toBe(reachesOut);
|
||||
}
|
||||
});
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
|
||||
import { TimerLifeCycle as Cycle } from 'ontime-types';
|
||||
|
||||
export type RecipeCategory = 'video' | 'audio' | 'playback' | 'messaging' | 'ontime';
|
||||
|
||||
export type AutomationRecipe = {
|
||||
/** stable, client only. Never persisted */
|
||||
id: string;
|
||||
title: string;
|
||||
/** one line, plain language: what this does for the user */
|
||||
description: string;
|
||||
category: RecipeCategory;
|
||||
docsUrl?: string;
|
||||
/** true when the recipe points at external software the user has to locate */
|
||||
needsSetup: boolean;
|
||||
/** typed so the compiler catches drift against the automation schema */
|
||||
automation: AutomationDTO;
|
||||
triggers: TimerLifeCycle[];
|
||||
};
|
||||
|
||||
export const recipeCategoryLabels: Record<RecipeCategory, string> = {
|
||||
ontime: 'Works out of the box',
|
||||
playback: 'Playback and cue systems',
|
||||
video: 'Video and streaming',
|
||||
audio: 'Audio',
|
||||
messaging: 'Webhooks and messaging',
|
||||
};
|
||||
|
||||
/** presentation order for the library */
|
||||
export const recipeCategoryOrder: RecipeCategory[] = ['ontime', 'video', 'playback', 'audio', 'messaging'];
|
||||
|
||||
/**
|
||||
* Every recipe targets loopback by default.
|
||||
* A recipe added by mistake must not put traffic on a venue network, so the user
|
||||
* has to point it somewhere real before it can reach anything.
|
||||
*/
|
||||
export const automationRecipes: AutomationRecipe[] = [
|
||||
{
|
||||
id: 'ontime-aux-timer',
|
||||
title: 'Start Aux Timer 1 with the event',
|
||||
description: 'Sets aux timer 1 to five minutes and starts it whenever an event starts.',
|
||||
category: 'ontime',
|
||||
needsSetup: false,
|
||||
automation: {
|
||||
title: 'Start Aux Timer 1 with the event',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{ type: 'ontime', action: 'aux1-set', time: '00:05:00' },
|
||||
{ type: 'ontime', action: 'aux1-start' },
|
||||
],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
{
|
||||
id: 'ontime-warn-stage',
|
||||
title: 'Warn the stage when the timer hits danger',
|
||||
description: 'Shows a message on the stage timer as soon as the running event enters its danger window.',
|
||||
category: 'ontime',
|
||||
needsSetup: false,
|
||||
automation: {
|
||||
title: 'Warn the stage at danger',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: 'Please wrap up', visible: true }],
|
||||
},
|
||||
triggers: [Cycle.onDanger],
|
||||
},
|
||||
{
|
||||
id: 'ontime-clear-message',
|
||||
title: 'Hide the stage message on finish',
|
||||
description: 'Hides the stage message once the event finishes. Pairs with the danger warning above.',
|
||||
category: 'ontime',
|
||||
needsSetup: false,
|
||||
automation: {
|
||||
title: 'Hide the stage message on finish',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
|
||||
},
|
||||
triggers: [Cycle.onFinish],
|
||||
},
|
||||
{
|
||||
id: 'obs-record',
|
||||
title: 'OBS — start recording when the show starts',
|
||||
description:
|
||||
'Presses a Companion button bound to OBS. obs-websocket speaks WebSocket rather than HTTP, so Ontime reaches OBS through Companion or a similar bridge.',
|
||||
category: 'video',
|
||||
docsUrl: 'https://docs.getontime.no/api/automation/',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'OBS start recording',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
// Companion HTTP API: /api/location/<page>/<row>/<column>/press
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/1/press' }],
|
||||
},
|
||||
triggers: [Cycle.onLoad],
|
||||
},
|
||||
{
|
||||
id: 'vmix-overlay-warning',
|
||||
title: 'vMix — show an overlay on timer warning',
|
||||
description: 'Triggers a vMix overlay through the web controller when the timer enters its warning window.',
|
||||
category: 'video',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'vMix overlay on warning',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:8088/api/?Function=OverlayInput1In' }],
|
||||
},
|
||||
triggers: [Cycle.onWarning],
|
||||
},
|
||||
{
|
||||
id: 'qlab-go',
|
||||
title: 'QLab — fire the matching cue on event start',
|
||||
description: "Sends OSC to QLab to start the cue whose number matches the Ontime event's cue.",
|
||||
category: 'playback',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'QLab GO on event start',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{ type: 'osc', targetIP: '127.0.0.1', targetPort: 53000, address: '/cue/{{eventNow.cue}}/start', args: '' },
|
||||
],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
{
|
||||
id: 'companion-press',
|
||||
title: 'Companion — press a button on event start',
|
||||
description: 'Presses page 1 button 1 on a Stream Deck through the Companion HTTP API.',
|
||||
category: 'playback',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'Companion button press',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/0/press' }],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
{
|
||||
id: 'webhook-event-title',
|
||||
title: 'Webhook — send the current event title',
|
||||
description: 'Posts the running event title to any URL. A good place to see template strings at work.',
|
||||
category: 'messaging',
|
||||
docsUrl: 'https://docs.getontime.no/api/automation/#using-variables-in-automation',
|
||||
needsSetup: true,
|
||||
automation: {
|
||||
title: 'Webhook with the current event',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'http', url: 'http://127.0.0.1:3000/now?title={{eventNow.title}}' }],
|
||||
},
|
||||
triggers: [Cycle.onStart],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Automation } from 'ontime-types';
|
||||
|
||||
import { addAutomation, addTrigger, deleteAutomation, deleteTrigger } from '../../../../../common/api/automation';
|
||||
import { cycles } from '../automationUtils';
|
||||
import type { AutomationRecipe } from './automationRecipes';
|
||||
|
||||
/**
|
||||
* Installs a recipe as an ordinary automation, using the same endpoints as the form.
|
||||
* There is nothing special about the result: the user owns it and can edit or delete it.
|
||||
*
|
||||
* The server generates the ids, so the automation has to exist before its triggers can
|
||||
* point at it. If a trigger fails half way we undo the whole thing, triggers first:
|
||||
* the server refuses to delete an automation that is still referenced.
|
||||
*/
|
||||
export async function installRecipe(recipe: AutomationRecipe): Promise<Automation> {
|
||||
const created = await addAutomation(recipe.automation);
|
||||
const createdTriggerIds: string[] = [];
|
||||
|
||||
try {
|
||||
for (const cycle of recipe.triggers) {
|
||||
const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle;
|
||||
const trigger = await addTrigger({
|
||||
title: `${recipe.automation.title} — ${label}`,
|
||||
trigger: cycle,
|
||||
automationId: created.id,
|
||||
});
|
||||
createdTriggerIds.push(trigger.id);
|
||||
}
|
||||
} catch (error) {
|
||||
await rollback(created.id, createdTriggerIds);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function rollback(automationId: string, triggerIds: string[]) {
|
||||
try {
|
||||
for (const id of triggerIds) {
|
||||
await deleteTrigger(id);
|
||||
}
|
||||
await deleteAutomation(automationId);
|
||||
} catch (_error) {
|
||||
// the install already failed and we are reporting that. A failed cleanup leaves an
|
||||
// editable automation behind, which is recoverable, so it should not mask the original error
|
||||
}
|
||||
}
|
||||
@@ -85,10 +85,10 @@ export default function ServerPortSettings() {
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{rootError && <Panel.Error>{rootError}</Panel.Error>}
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
{data.pendingRestart && (
|
||||
<Info type='warning'>A port change is pending and will happen on the next restart.</Info>
|
||||
)}
|
||||
|
||||
@@ -84,11 +84,28 @@ 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',
|
||||
'template',
|
||||
'example',
|
||||
'preset',
|
||||
'obs',
|
||||
'qlab',
|
||||
'vmix',
|
||||
'companion',
|
||||
'share',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'automation__triggers',
|
||||
label: 'Manage triggers',
|
||||
label: 'Global triggers',
|
||||
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9,118 +9,50 @@ import * as Panel from '../app-settings/panel-utils/PanelUtils';
|
||||
|
||||
import style from './Log.module.scss';
|
||||
|
||||
const origins = Object.values(LogOrigin);
|
||||
|
||||
type OriginFilters = Record<LogOrigin, boolean>;
|
||||
|
||||
const allEnabled = Object.fromEntries(origins.map((origin) => [origin, true])) as OriginFilters;
|
||||
|
||||
export default function Log() {
|
||||
const { logs: logData } = useLogData();
|
||||
const isExtracted = window.location.pathname.includes('/log');
|
||||
|
||||
const [showClient, setShowClient] = useState(true);
|
||||
const [showServer, setShowServer] = useState(true);
|
||||
const [showRx, setShowRx] = useState(true);
|
||||
const [showTx, setShowTx] = useState(true);
|
||||
const [showPlayback, setShowPlayback] = useState(true);
|
||||
const [showUser, setShowUser] = useState(true);
|
||||
const [filters, setFilters] = useState<OriginFilters>(allEnabled);
|
||||
|
||||
const matchers: LogOrigin[] = [];
|
||||
if (showUser) {
|
||||
matchers.push(LogOrigin.User);
|
||||
}
|
||||
if (showClient) {
|
||||
matchers.push(LogOrigin.Client);
|
||||
}
|
||||
if (showServer) {
|
||||
matchers.push(LogOrigin.Server);
|
||||
}
|
||||
if (showRx) {
|
||||
matchers.push(LogOrigin.Rx);
|
||||
}
|
||||
if (showTx) {
|
||||
matchers.push(LogOrigin.Tx);
|
||||
}
|
||||
if (showPlayback) {
|
||||
matchers.push(LogOrigin.Playback);
|
||||
}
|
||||
const filteredData = logData.filter((entry) => filters[entry.origin as LogOrigin]);
|
||||
|
||||
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
|
||||
const toggleOrigin = useCallback((origin: LogOrigin) => {
|
||||
setFilters((prev) => ({ ...prev, [origin]: !prev[origin] }));
|
||||
}, []);
|
||||
|
||||
const disableOthers = useCallback((toEnable: LogOrigin) => {
|
||||
setShowUser(toEnable === LogOrigin.User);
|
||||
setShowClient(toEnable === LogOrigin.Client);
|
||||
setShowServer(toEnable === LogOrigin.Server);
|
||||
setShowRx(toEnable === LogOrigin.Rx);
|
||||
setShowTx(toEnable === LogOrigin.Tx);
|
||||
setShowPlayback(toEnable === LogOrigin.Playback);
|
||||
/** middle click solos an origin */
|
||||
const soloOrigin = useCallback((toEnable: LogOrigin) => {
|
||||
setFilters(Object.fromEntries(origins.map((origin) => [origin, origin === toEnable])) as OriginFilters);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx([style.container, isExtracted && style.extracted])}>
|
||||
<Panel.InlineElements className={style.buttonBar}>
|
||||
<span className={style.filterLabel}>Filter by</span>
|
||||
<Button
|
||||
variant={showUser ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showUser}
|
||||
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
|
||||
onClick={() => setShowUser((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.User)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.User}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showClient ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showClient}
|
||||
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
|
||||
onClick={() => setShowClient((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Client)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Client}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showServer ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showServer}
|
||||
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
|
||||
onClick={() => setShowServer((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Server)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Server}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showPlayback ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showPlayback}
|
||||
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
|
||||
onClick={() => setShowPlayback((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Playback)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Playback}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showRx}
|
||||
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
|
||||
onClick={() => setShowRx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Rx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Rx}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showTx ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={showTx}
|
||||
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
|
||||
onClick={() => setShowTx((s) => !s)}
|
||||
onAuxClick={() => disableOthers(LogOrigin.Tx)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{LogOrigin.Tx}
|
||||
</Button>
|
||||
{origins.map((origin) => {
|
||||
const isEnabled = filters[origin];
|
||||
return (
|
||||
<Button
|
||||
key={origin}
|
||||
variant={isEnabled ? 'primary' : 'subtle'}
|
||||
size='small'
|
||||
aria-pressed={isEnabled}
|
||||
aria-label={`${isEnabled ? 'Hide' : 'Show'} ${origin} events`}
|
||||
onClick={() => toggleOrigin(origin)}
|
||||
onAuxClick={() => soloOrigin(origin)}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
{origin}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
|
||||
<IoClose /> Clear
|
||||
</Button>
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function GroupEditor({ group }: GroupEditorProps) {
|
||||
<div>
|
||||
<Editor.Label htmlFor='eventId'>Plan offset</Editor.Label>
|
||||
<TextLikeInput
|
||||
offset={planOffsetLabel === 'under' ? 'over' : planOffsetLabel}
|
||||
offset={planOffsetLabel}
|
||||
className={cx([style.textLikeInput, planOffset === null && style.inactive])}
|
||||
disabled
|
||||
>
|
||||
|
||||
+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,6 +79,7 @@ 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 isDuplicate = duplicateIds.has(trigger.id);
|
||||
@@ -103,6 +107,13 @@ export default function EventEditorTriggers({ triggers, eventId }: EventEditorTr
|
||||
}}
|
||||
options={automationOptions}
|
||||
/>
|
||||
<div className={style.outputTags}>
|
||||
{summariseOutputs(automationSettings.automations[trigger.automationId]?.outputs ?? []).map(
|
||||
({ type, label, count }) => (
|
||||
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<IconButton variant='ghosted-destructive' onClick={() => handleDelete(trigger.id)}>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Day, EndAction, EntryId, Maybe, OntimeGroup, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { Day, EndAction, EntryId, Playback, TimeStrategy, TimerType } from 'ontime-types';
|
||||
import { isPlaybackActive } from 'ontime-utils';
|
||||
import { MouseEvent, useEffect, useRef } from 'react';
|
||||
import {
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
IoTrash,
|
||||
IoUnlink,
|
||||
} from 'react-icons/io5';
|
||||
import { TbClockPin, TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
import { TbFlagFilled, TbListNumbers } from 'react-icons/tb';
|
||||
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useEntry } from '../../../common/hooks-query/useRundown';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
@@ -103,10 +102,7 @@ export default function RundownEvent({
|
||||
const clearSelectedEventId = useEventIdSwapping((state) => state.clearSelectedEventId);
|
||||
const openRenumberDialog = useRenumberCuesDialogStore((state) => state.onOpen);
|
||||
|
||||
const parentGroup = useEntry(parent) as Maybe<OntimeGroup>;
|
||||
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents, matchGroupDuration } =
|
||||
useEntryActionsContext();
|
||||
const { updateEntry, batchUpdateEvents, clone, deleteEntry, groupEntries, swapEvents } = useEntryActionsContext();
|
||||
|
||||
const isSelected = useEventSelection((state) => state.selectedEvents.has(eventId));
|
||||
const unselect = useEventSelection((state) => state.unselect);
|
||||
@@ -118,15 +114,6 @@ export default function RundownEvent({
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
|
||||
const [enableMatchDuration, groupTargetDurationDescription] = (() => {
|
||||
if (!parentGroup || parentGroup.targetDuration === null || parentGroup.duration === parentGroup.targetDuration)
|
||||
return [false, ''];
|
||||
const { targetDuration, duration } = parentGroup;
|
||||
return targetDuration > duration
|
||||
? [true, 'Increase event duration to fit the group target']
|
||||
: [true, 'Decrease event duration to fit the group target'];
|
||||
})();
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() =>
|
||||
selectedEvents.size > 1
|
||||
? [
|
||||
@@ -185,17 +172,6 @@ export default function RundownEvent({
|
||||
updateEntry({ id: eventId, flag: !flag });
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Group Target Duration',
|
||||
description: groupTargetDurationDescription,
|
||||
icon: TbClockPin,
|
||||
onClick: () => {
|
||||
if (!parent) return;
|
||||
matchGroupDuration(eventId);
|
||||
},
|
||||
disabled: !enableMatchDuration,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
|
||||
@@ -74,36 +74,42 @@
|
||||
.metaLabel {
|
||||
color: $muted-gray;
|
||||
font-size: calc(1rem - 3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.strike {
|
||||
text-decoration: wavy underline;
|
||||
margin-right: 0.25rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.duration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: $ui-white;
|
||||
|
||||
&.warning {
|
||||
.strike {
|
||||
// color: $playback-over;
|
||||
text-decoration: wavy underline;
|
||||
text-decoration-color: $playback-over;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-over;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lockIcon {
|
||||
color: $muted-gray;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.target {
|
||||
display: contents;
|
||||
.over {
|
||||
color: $playback-over;
|
||||
.strike {
|
||||
text-decoration-color: $playback-over;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-over;
|
||||
}
|
||||
}
|
||||
.under {
|
||||
color: $playback-under;
|
||||
.strike {
|
||||
text-decoration-color: $playback-under;
|
||||
}
|
||||
.offsetLabel {
|
||||
background-color: $playback-under;
|
||||
}
|
||||
}
|
||||
|
||||
.drag {
|
||||
|
||||
@@ -2,25 +2,24 @@ import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { EntryId, OntimeGroup } from 'ontime-types';
|
||||
import { MILLIS_PER_MINUTE } from 'ontime-utils';
|
||||
import { MouseEvent, useCallback, useRef } from 'react';
|
||||
import { MouseEvent, useRef } from 'react';
|
||||
import {
|
||||
IoChevronDown,
|
||||
IoChevronUp,
|
||||
IoDuplicateOutline,
|
||||
IoFolderOpenOutline,
|
||||
IoLockClosed,
|
||||
IoReorderTwo,
|
||||
IoTrash,
|
||||
IoLockClosed,
|
||||
} from 'react-icons/io5';
|
||||
import { TbClockPin } from 'react-icons/tb';
|
||||
|
||||
import IconButton from '../../../common/components/buttons/IconButton';
|
||||
import Tag from '../../../common/components/tag/Tag';
|
||||
import Tooltip from '../../../common/components/tooltip/Tooltip';
|
||||
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
|
||||
import { useContextMenu } from '../../../common/hooks/useContextMenu';
|
||||
import { useEntryCopy } from '../../../common/stores/entryCopyStore';
|
||||
import { deviceAlt, deviceMod } from '../../../common/utils/deviceUtils';
|
||||
import { getOffsetState } from '../../../common/utils/offset';
|
||||
import { cx, getAccessibleColour } from '../../../common/utils/styleUtils';
|
||||
import { formatDuration, formatTime } from '../../../common/utils/time';
|
||||
import TitleEditor from '../common/TitleEditor';
|
||||
@@ -41,31 +40,12 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
'use memo';
|
||||
|
||||
const handleRef = useRef<null | HTMLSpanElement>(null);
|
||||
const { clone, ungroup, deleteEntry, updateEntry } = useEntryActionsContext();
|
||||
const { clone, ungroup, deleteEntry } = useEntryActionsContext();
|
||||
|
||||
const selectSingleEntry = useEventSelection((state) => state.setSingleEntrySelection);
|
||||
const selectedEvents = useEventSelection((state) => state.selectedEvents);
|
||||
const entryCopyId = useEntryCopy((state) => state.entryCopyId);
|
||||
|
||||
const isDurationMatching = data.targetDuration !== null && data.targetDuration === data.duration;
|
||||
|
||||
const [planOffset, offset] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
const offset = data.duration - data.targetDuration;
|
||||
if (offset === 0) {
|
||||
return [null, 0];
|
||||
}
|
||||
const absOffset = Math.abs(offset);
|
||||
return [`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`, offset];
|
||||
})();
|
||||
|
||||
const matchDuration = useCallback(() => {
|
||||
updateEntry({ id: data.id, targetDuration: data.duration });
|
||||
}, [data.duration, data.id, updateEntry]);
|
||||
|
||||
const [onContextMenu] = useContextMenu<HTMLDivElement>(() => [
|
||||
{
|
||||
type: 'item',
|
||||
@@ -82,18 +62,6 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
disabled: data.entries.length === 0,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Match Content Duration',
|
||||
icon: TbClockPin,
|
||||
onClick: matchDuration,
|
||||
disabled: isDurationMatching,
|
||||
description:
|
||||
offset > 0
|
||||
? "Increase group target duration to match it's contents"
|
||||
: "Decrease group target duration to match it's contents",
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Delete Group',
|
||||
@@ -137,6 +105,22 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
const binderColours = data.colour && getAccessibleColour(data.colour);
|
||||
const isValidDrop = isDragging && over?.id && canDrop(over.data.current?.type, over.data.current?.parent);
|
||||
|
||||
const [planOffset, planOffsetLabel] = (() => {
|
||||
if (data.targetDuration === null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const offset = data.duration - data.targetDuration;
|
||||
if (offset === 0) {
|
||||
return [null, 'under'];
|
||||
}
|
||||
const absOffset = Math.abs(offset);
|
||||
return [
|
||||
`${offset < 0 ? '-' : '+'}${formatDuration(absOffset, absOffset > 2 * MILLIS_PER_MINUTE)}`,
|
||||
getOffsetState(offset),
|
||||
];
|
||||
})();
|
||||
|
||||
const dragStyle = {
|
||||
zIndex: isDragging ? 2 : 'inherit',
|
||||
transform: CSS.Translate.toString(transform),
|
||||
@@ -191,18 +175,20 @@ export default function RundownGroup({ data, hasCursor, collapsed, onCollapse }:
|
||||
<div className={style.metaLabel}>End</div>
|
||||
<div>{formatTime(data.timeEnd)}</div>
|
||||
</div>
|
||||
<Tooltip text={'Group has target duration'} disabled={data.targetDuration === null}>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>
|
||||
Duration
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
<div className={cx([style.duration, planOffset && style.warning])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
{planOffset && <Tag className={style.offsetLabel}>{planOffset}</Tag>}
|
||||
</div>
|
||||
<div className={style.metaEntry}>
|
||||
<div className={style.metaLabel}>Duration</div>
|
||||
<div className={style.duration}>
|
||||
{planOffset === null ? (
|
||||
formatDuration(data.duration)
|
||||
) : (
|
||||
<span className={cx([planOffsetLabel && style[planOffsetLabel]])}>
|
||||
<span className={style.strike}>{formatDuration(data.duration)}</span>
|
||||
<Tag className={style.offsetLabel}>{planOffset}</Tag>
|
||||
</span>
|
||||
)}
|
||||
{data.targetDuration !== null && <IoLockClosed className={style.lockIcon} />}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime-electron",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"author": "Carlos Valente",
|
||||
"description": "Time keeping for live events",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
|
||||
@@ -100,7 +100,7 @@ function makeFileMenu(askToQuit, serverUrl, redirectWindow, showDialog, download
|
||||
submenu: [
|
||||
{
|
||||
label: 'New project...',
|
||||
click: () => redirectWindow('/editor?settings=project__create'),
|
||||
click: () => redirectWindow('/editor?settings=project__manage&new=true'),
|
||||
},
|
||||
{
|
||||
label: 'Load...',
|
||||
@@ -202,18 +202,6 @@ function makeSettingsMenu(redirectWindow) {
|
||||
label: 'View settings',
|
||||
click: () => redirectWindow('/editor?settings=settings__view'),
|
||||
},
|
||||
{
|
||||
label: 'Custom views',
|
||||
click: () => redirectWindow('/editor?settings=settings__custom-views'),
|
||||
},
|
||||
{
|
||||
label: 'MCP Server',
|
||||
click: () => redirectWindow('/editor?settings=settings__mcp'),
|
||||
},
|
||||
{
|
||||
label: 'Server port',
|
||||
click: () => redirectWindow('/editor?settings=settings__port'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getontime/resolver",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/cpvalente/ontime",
|
||||
"types": "./dist/main.d.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "ontime-server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"exports": "./src/index.js",
|
||||
"dependencies": {
|
||||
"@googleapis/sheets": "^5.0.5",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
|
||||
import { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { addAutomation, addTrigger, deleteAllTriggers } from '../automation.dao.js';
|
||||
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||
import { resetAutomationLogState, testConditions, triggerAutomations } from '../automation.service.js';
|
||||
import * as httpClient from '../clients/http.client.js';
|
||||
import * as oscClient from '../clients/osc.client.js';
|
||||
import { makeHTTPAction, makeOSCAction } from './testUtils.js';
|
||||
@@ -644,3 +645,109 @@ describe('testConditions()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A successful fire used to be invisible. Making it visible is only useful if the log
|
||||
* stays readable: onClock fires every second and the logger queue holds 100 entries.
|
||||
*/
|
||||
describe('automation reporting', () => {
|
||||
let logSpy = vi.spyOn(logger, 'info');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
||||
logSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
|
||||
|
||||
await deleteAllTriggers();
|
||||
resetAutomationLogState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function bind(title: string, cycle: TimerLifeCycle) {
|
||||
const automation = await addAutomation({
|
||||
title,
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [makeOSCAction()],
|
||||
});
|
||||
await addTrigger({ title, trigger: cycle, automationId: automation.id });
|
||||
return automation;
|
||||
}
|
||||
|
||||
it('logs once per automation that fires, not once per output', async () => {
|
||||
await bind('reporting-finish', TimerLifeCycle.onFinish);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onFinish);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy.mock.calls[0][1]).toContain('reporting-finish');
|
||||
});
|
||||
|
||||
it('never logs per fire on a continuous lifecycle, and explains itself once', async () => {
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy.mock.calls[0][1]).toContain('suppressed');
|
||||
});
|
||||
|
||||
it('does not repeat the suppression notice on every load', async () => {
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
logSpy.mockClear();
|
||||
|
||||
// roll mode loads at every event boundary, and an operator steps through cues by hand.
|
||||
// Re-notifying on each one would be the very flooding the notice exists to prevent
|
||||
for (let i = 0; i < 5; i++) {
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
}
|
||||
|
||||
expect(logSpy.mock.calls.filter(([, message]) => String(message).includes('suppressed'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the suppression notice again after a stop', async () => {
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
logSpy.mockClear();
|
||||
|
||||
// a stop ends the run, the next one reports from scratch
|
||||
triggerAutomations(TimerLifeCycle.onStop);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
|
||||
expect(logSpy.mock.calls.some(([, message]) => String(message).includes('suppressed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('throttles repeated loads, which the reset used to defeat', async () => {
|
||||
vi.useFakeTimers();
|
||||
await bind('reporting-load', TimerLifeCycle.onLoad);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('collapses repeats of the same automation and cycle inside the throttle window', async () => {
|
||||
vi.useFakeTimers();
|
||||
await bind('reporting-danger', TimerLifeCycle.onDanger);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type Automation,
|
||||
type AutomationFilter,
|
||||
type AutomationOutput,
|
||||
type FilterRule,
|
||||
@@ -15,11 +16,36 @@ import { logger } from '../../classes/Logger.js';
|
||||
import { isOntimeCloud } from '../../setup/environment.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
import { getAutomationTriggers, getAutomations, getAutomationsEnabled } from './automation.dao.js';
|
||||
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
|
||||
import { isContained, isEquivalent, isGreaterThan, isLessThan, summariseOutputs } from './automation.utils.js';
|
||||
import { emitHTTP } from './clients/http.client.js';
|
||||
import { toOntimeAction } from './clients/ontime.client.js';
|
||||
import { emitOSC } from './clients/osc.client.js';
|
||||
|
||||
/**
|
||||
* Lifecycles that fire continuously while the timer runs.
|
||||
* The logger queue holds 100 entries, so logging every onClock fire would evict
|
||||
* everything else within two minutes and make the log useless.
|
||||
*/
|
||||
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
|
||||
|
||||
/** floor between two reports about the same automation, in milliseconds */
|
||||
const reportThrottleMs = 1000;
|
||||
|
||||
/** automations we have already warned about being bound to a continuous lifecycle */
|
||||
const suppressionNotices = new Set<string>();
|
||||
/** last time we logged a given automation + cycle pair */
|
||||
const lastLoggedAt = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Clears the reporting state.
|
||||
* Called when the runtime stops, so the next run reports from scratch rather than
|
||||
* inheriting throttles from the last one
|
||||
*/
|
||||
export function resetAutomationLogState() {
|
||||
suppressionNotices.clear();
|
||||
lastLoggedAt.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||
*/
|
||||
@@ -27,6 +53,21 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
if (!getAutomationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireForCycle(cycle);
|
||||
|
||||
// A stop ends a run, so the next one reports from scratch. This deliberately does not
|
||||
// happen on load: loading is not rare, roll mode loads at every event boundary, and
|
||||
// resetting there would re-emit the suppression notice once per cue, which is the
|
||||
// flooding the notice exists to prevent.
|
||||
// It sits out here because fireForCycle returns early when nothing is bound to onStop,
|
||||
// which is the common case
|
||||
if (cycle === TimerLifeCycle.onStop) {
|
||||
resetAutomationLogState();
|
||||
}
|
||||
}
|
||||
|
||||
function fireForCycle(cycle: TimerLifeCycle) {
|
||||
const store = eventStore.poll();
|
||||
|
||||
let triggers = getAutomationTriggers();
|
||||
@@ -63,10 +104,40 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
|
||||
if (shouldSend) {
|
||||
send(automation.outputs, store);
|
||||
reportFired(trigger.automationId, automation, cycle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a successful automation fire visible in the log, which it previously was not
|
||||
*/
|
||||
function reportFired(automationId: string, automation: Automation, cycle: TimerLifeCycle) {
|
||||
const now = Date.now();
|
||||
|
||||
if (continuousCycles.includes(cycle)) {
|
||||
// one notice per load is enough to explain why the log goes quiet from here
|
||||
if (!suppressionNotices.has(automationId)) {
|
||||
suppressionNotices.add(automationId);
|
||||
logger.info(
|
||||
LogOrigin.Automation,
|
||||
`${automation.title} is bound to ${cycle} and fires continuously, per-fire logging suppressed`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// a rapid reload can fire the same automation on the same cycle several times over
|
||||
const logKey = `${automationId}:${cycle}`;
|
||||
const lastLogged = lastLoggedAt.get(logKey);
|
||||
if (lastLogged !== undefined && now - lastLogged < reportThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastLoggedAt.set(logKey, now);
|
||||
|
||||
logger.info(LogOrigin.Automation, `${automation.title} fired on ${cycle} → ${summariseOutputs(automation.outputs)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes a method for bypassing the condition check and testing the sending of an output
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AutomationOutput,
|
||||
EntryId,
|
||||
FilterRule,
|
||||
MaybeNumber,
|
||||
@@ -25,6 +26,23 @@ export function isOntimeActionAction(value: string): value is OntimeAction['acti
|
||||
return ontimeActionKeyValues.includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes what an automation sends, for the log line of a successful fire
|
||||
* @example 'OSC ×2, HTTP'
|
||||
*/
|
||||
export function summariseOutputs(outputs: AutomationOutput[]): string {
|
||||
const labels: Record<AutomationOutput['type'], string> = { osc: 'OSC', http: 'HTTP', ontime: 'Ontime action' };
|
||||
const counts = new Map<AutomationOutput['type'], number>();
|
||||
|
||||
for (const output of outputs) {
|
||||
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return Array.from(counts.entries())
|
||||
.map(([type, count]) => (count > 1 ? `${labels[type]} ×${count}` : labels[type]))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function toOscValue(argString: string): OscArgInput {
|
||||
const argAsNum = Number(argString);
|
||||
// NOTE: number like: 1 2.0 33333
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TimerType,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { MILLIS_PER_HOUR, MILLIS_PER_MINUTE, createEvent } from 'ontime-utils';
|
||||
import { MILLIS_PER_HOUR, createEvent } from 'ontime-utils';
|
||||
import { assertType } from 'vitest';
|
||||
|
||||
import { makeOntimeEvent, makeOntimeGroup, makeOntimeMilestone, makeRundown } from '../__mocks__/rundown.mocks.js';
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
makeDeepClone,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from '../rundown.utils.js';
|
||||
|
||||
describe('test event validator', () => {
|
||||
@@ -611,107 +610,3 @@ describe('isLoadedPlayable()', () => {
|
||||
expect(isLoadedPlayable('keynote', rundown)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDurationMatchGroupTarget()', () => {
|
||||
it('returns unchanged duration when group already matches target', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('increases event duration when group is shorter than target', () => {
|
||||
// Group is 1h short of target, so event duration increases by 1h
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 2, // 2h
|
||||
groupDuration: MILLIS_PER_HOUR, // 1h
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30); // 1h30m
|
||||
});
|
||||
|
||||
it('decreases event duration when group is longer than target', () => {
|
||||
// Group is 30m over target, so event duration decreases by 30m
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR, // 1h
|
||||
groupDuration: MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30, // 1h30m
|
||||
eventDuration: MILLIS_PER_MINUTE * 30, // 30m
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero target duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(0);
|
||||
});
|
||||
|
||||
it('handles zero group duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: 0,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR + MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles zero event duration', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_MINUTE * 30,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR - MILLIS_PER_MINUTE * 30);
|
||||
});
|
||||
|
||||
it('handles all zero values', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 0,
|
||||
groupDuration: 0,
|
||||
eventDuration: 0,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when result would be negative', () => {
|
||||
// Group exceeds target by 1.5h, event shrinks by 1.5h (exceeds event duration)
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_MINUTE * 30,
|
||||
groupDuration: MILLIS_PER_HOUR * 2,
|
||||
eventDuration: MILLIS_PER_HOUR,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('handles large durations', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: MILLIS_PER_HOUR * 24, // 24h
|
||||
groupDuration: MILLIS_PER_HOUR * 12, // 12h
|
||||
eventDuration: MILLIS_PER_HOUR, // 1h
|
||||
});
|
||||
expect(result).toStrictEqual(MILLIS_PER_HOUR * 13); // 13h
|
||||
});
|
||||
|
||||
it('returns null when targetDuration is null', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: null,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
|
||||
it('returns null when duration would be over 24h', () => {
|
||||
const result = eventDurationMatchGroupTarget({
|
||||
targetDuration: 30 * MILLIS_PER_HOUR,
|
||||
groupDuration: MILLIS_PER_HOUR,
|
||||
eventDuration: MILLIS_PER_MINUTE * 30,
|
||||
});
|
||||
expect(result).toStrictEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
reorderEntry,
|
||||
swapEvents,
|
||||
ungroupEntries,
|
||||
entryFitGroupDuration,
|
||||
} from './rundown.service.js';
|
||||
import { normalisedToRundownArray } from './rundown.utils.js';
|
||||
import {
|
||||
@@ -338,23 +337,6 @@ router.post('/:rundownId/ungroup/:id', paramsWithId, async (req: Request, res: R
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
router.post(
|
||||
'/:rundownId/:id/fit-group-duration',
|
||||
paramsWithId,
|
||||
async (req: Request, res: Response<Rundown | ErrorResponse>) => {
|
||||
try {
|
||||
const rundown = await entryFitGroupDuration(req.params.rundownId, req.params.id);
|
||||
res.status(200).send(rundown);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes a list of entries by their ID
|
||||
*/
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
hasChanges,
|
||||
mergeRundownPreservingFields,
|
||||
isLoadedPlayable,
|
||||
eventDurationMatchGroupTarget,
|
||||
} from './rundown.utils.js';
|
||||
import { assertInsertAnchorExists, assertInsertAnchorInOrder, assertSingleInsertAnchor } from './rundown.validation.js';
|
||||
|
||||
@@ -448,69 +447,6 @@ export async function cloneEntry(rundownId: string, entryId: EntryId, options: I
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a events duration to fit inside the group target
|
||||
*/
|
||||
export async function entryFitGroupDuration(rundownId: string, entryId: EntryId): Promise<Rundown> {
|
||||
const { rundown, commit } = createTransaction({ rundownId, mutableRundown: true });
|
||||
|
||||
const entry = rundown.entries[entryId];
|
||||
|
||||
if (!entry) {
|
||||
throw new Error('Entry not found');
|
||||
}
|
||||
|
||||
if (!isOntimeEvent(entry)) {
|
||||
throw new Error('Entry must be an event');
|
||||
}
|
||||
|
||||
const { parent } = entry;
|
||||
if (!parent) {
|
||||
throw new Error('Entry must be in a group');
|
||||
}
|
||||
|
||||
const group = rundown.entries[parent];
|
||||
|
||||
if (!group) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
if (!isOntimeGroup(group)) {
|
||||
throw new Error('Group is not a group');
|
||||
}
|
||||
|
||||
const newDuration = eventDurationMatchGroupTarget({
|
||||
targetDuration: group.targetDuration,
|
||||
groupDuration: group.duration,
|
||||
eventDuration: entry.duration,
|
||||
});
|
||||
|
||||
if (newDuration === null) {
|
||||
throw new Error('Unable to fit a duration');
|
||||
}
|
||||
|
||||
const newEnd = entry.timeStart + newDuration;
|
||||
|
||||
rundownMutation.edit(rundown, {
|
||||
id: entryId,
|
||||
duration: newDuration,
|
||||
timeEnd: newEnd,
|
||||
timeStrategy: entry.timeStrategy,
|
||||
});
|
||||
const { rundown: rundownResult, rundownMetadata, revision } = await commit();
|
||||
|
||||
// schedule the side effects
|
||||
setImmediate(() => {
|
||||
// notify runtime that rundown has changed
|
||||
updateRuntimeOnChange(rundownMetadata);
|
||||
|
||||
// we need to notify the timer since we might be changing a running event
|
||||
notifyChanges(rundown.id, rundownMetadata, revision, { external: true, timer: true });
|
||||
});
|
||||
|
||||
return rundownResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups a list of entries into a new group
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
EntryCustomFields,
|
||||
EntryId,
|
||||
ImportedFields,
|
||||
Maybe,
|
||||
OntimeBaseEvent,
|
||||
OntimeDelay,
|
||||
OntimeEntry,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
generateId,
|
||||
getCueCandidate,
|
||||
makeString,
|
||||
maxDuration,
|
||||
validateEndAction,
|
||||
validateTimerType,
|
||||
validateTimes,
|
||||
@@ -603,27 +601,3 @@ export function getIntegerAndFraction(value: string): IncrementNumber {
|
||||
precision,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts an event's duration to fit inside the group target
|
||||
* @param targetDuration - The desired total duration for the group, or null
|
||||
* @param groupDuration - The current total duration of all events in the group
|
||||
* @param eventDuration - The current duration of the event being adjusted
|
||||
* @returns The adjusted event duration, or null if targetDuration is null or
|
||||
* the result would be negative
|
||||
*/
|
||||
export function eventDurationMatchGroupTarget({
|
||||
targetDuration,
|
||||
groupDuration,
|
||||
eventDuration,
|
||||
}: {
|
||||
targetDuration: Maybe<number>;
|
||||
groupDuration: number;
|
||||
eventDuration: number;
|
||||
}): Maybe<number> {
|
||||
if (targetDuration === null) return null;
|
||||
if (targetDuration === groupDuration) return null;
|
||||
const durationDiff = targetDuration - groupDuration;
|
||||
const newDuration = eventDuration + durationDiff;
|
||||
return newDuration < 0 || newDuration > maxDuration ? null : newDuration;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { isHTTPOutput, isOSCOutput, isOntimeEvent, timerLifecycleValues } from 'ontime-types';
|
||||
|
||||
import { parseAutomation } from '../../api-data/automation/automation.validation.js';
|
||||
import { demoDb } from '../demoProject.js';
|
||||
|
||||
/**
|
||||
* The demo ships with automations so the feature is visible on first run.
|
||||
* They are hand written literals: nothing in the type system checks that a trigger
|
||||
* resolves to an automation, or that the demo cannot reach out to the network.
|
||||
*/
|
||||
describe('demo project automations', () => {
|
||||
const { automations, triggers } = demoDb.automation;
|
||||
|
||||
it('is enabled, otherwise the automations are invisible', () => {
|
||||
expect(demoDb.automation.enabledAutomations).toBe(true);
|
||||
});
|
||||
|
||||
it('does not open a listening socket', () => {
|
||||
expect(demoDb.automation.enabledOscIn).toBe(false);
|
||||
});
|
||||
|
||||
it('keys every automation by its own id', () => {
|
||||
for (const [key, automation] of Object.entries(automations)) {
|
||||
expect(automation.id).toBe(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the same validation as a user created automation', () => {
|
||||
for (const automation of Object.values(automations)) {
|
||||
expect(() => parseAutomation(automation)).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves every global trigger to an automation', () => {
|
||||
for (const trigger of triggers) {
|
||||
expect(timerLifecycleValues).toContain(trigger.trigger);
|
||||
expect(Object.hasOwn(automations, trigger.automationId)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves every event level trigger to an automation', () => {
|
||||
for (const rundown of Object.values(demoDb.rundowns)) {
|
||||
for (const entry of Object.values(rundown.entries)) {
|
||||
if (!isOntimeEvent(entry)) {
|
||||
continue;
|
||||
}
|
||||
for (const trigger of entry.triggers) {
|
||||
expect(timerLifecycleValues).toContain(trigger.trigger);
|
||||
expect(Object.hasOwn(automations, trigger.automationId)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('never sends outside this machine, whatever the user presses', () => {
|
||||
// an automation only fires if something triggers it
|
||||
const boundIds = new Set<string>(triggers.map((trigger) => trigger.automationId));
|
||||
for (const rundown of Object.values(demoDb.rundowns)) {
|
||||
for (const entry of Object.values(rundown.entries)) {
|
||||
if (isOntimeEvent(entry)) {
|
||||
entry.triggers.forEach((trigger) => boundIds.add(trigger.automationId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of boundIds) {
|
||||
for (const output of automations[id].outputs) {
|
||||
expect(isOSCOutput(output)).toBe(false);
|
||||
expect(isHTTPOutput(output)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,11 +76,43 @@ export const demoDb: DatabaseModel = {
|
||||
label: 'PowerPoint Slide',
|
||||
},
|
||||
},
|
||||
/**
|
||||
* The demo ships with a working automation so the engine is visible the first time
|
||||
* someone presses Play, rather than hidden behind an empty settings panel.
|
||||
*
|
||||
* It fires an Ontime action: the demo must not put traffic on whatever network it
|
||||
* happens to be opened on. It is attached via an event-level trigger rather than a
|
||||
* global one, so per-event triggers are also discoverable by browsing the rundown
|
||||
* instead of reading docs. The OSC entry is there to be read and edited, and is
|
||||
* deliberately left without a trigger of its own.
|
||||
*
|
||||
* The ids are hand written and must match the map keys. Ids are only generated for
|
||||
* automations created through the DAO, so literals are safe here.
|
||||
*/
|
||||
automation: {
|
||||
enabledAutomations: false,
|
||||
enabledAutomations: true,
|
||||
// never open a listening socket without the user asking for it
|
||||
enabledOscIn: false,
|
||||
oscPortIn: 8888,
|
||||
triggers: [],
|
||||
automations: {},
|
||||
automations: {
|
||||
'demo-aux-timer': {
|
||||
id: 'demo-aux-timer',
|
||||
title: 'Demo: run Aux Timer 1 with the event',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [
|
||||
{ type: 'ontime', action: 'aux1-set', time: '00:05:00' },
|
||||
{ type: 'ontime', action: 'aux1-start' },
|
||||
],
|
||||
},
|
||||
'demo-osc-example': {
|
||||
id: 'demo-osc-example',
|
||||
title: 'Demo: OSC to a lighting console (example, not wired up)',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'osc', targetIP: '127.0.0.1', targetPort: 8000, address: '/ontime/go', args: '1' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SupportedEntry, TimeStrategy, EndAction, TimerType, Day, Rundown } from 'ontime-types';
|
||||
import { SupportedEntry, TimeStrategy, EndAction, TimerType, Day, Rundown, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
export const stageRundown: Rundown = {
|
||||
id: 'default',
|
||||
@@ -120,7 +120,16 @@ export const stageRundown: Rundown = {
|
||||
Audio_Notes: '1x Wireless Hand Held',
|
||||
PowerPoint_Name: 'HoldingSlide.pptx',
|
||||
},
|
||||
triggers: [],
|
||||
// demonstrates that an automation can be attached to a single event, which is
|
||||
// otherwise only discoverable by reading the docs. See demoProject.ts
|
||||
triggers: [
|
||||
{
|
||||
id: 'demo-event-trigger',
|
||||
title: 'Aux timer with the event',
|
||||
trigger: TimerLifeCycle.onStart,
|
||||
automationId: 'demo-aux-timer',
|
||||
},
|
||||
],
|
||||
},
|
||||
fa593e: {
|
||||
id: 'fa593e',
|
||||
|
||||
@@ -71,15 +71,11 @@ test('Move', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await page.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
// create events
|
||||
await page.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await page.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(page.getByTestId('rundown-event')).toHaveCount(3);
|
||||
|
||||
// copy move down
|
||||
await page.getByTestId('entry-1').getByTestId('rundown-event').getByText('1').click();
|
||||
@@ -90,16 +86,15 @@ test('Move', async ({ page }) => {
|
||||
.press('Alt+Control+ArrowDown');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('1');
|
||||
|
||||
// move entry three up twice, waiting for each reorder before targeting its new row
|
||||
// copy move up
|
||||
await page.getByTestId('entry-3').getByTestId('rundown-event').getByText('3').click();
|
||||
await page
|
||||
.getByTestId('entry-3')
|
||||
.getByTestId('rundown-event')
|
||||
.filter({ hasText: '3' })
|
||||
.press('Alt+ControlOrMeta+ArrowUp');
|
||||
await expect(page.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('3');
|
||||
await page
|
||||
.getByTestId('entry-2')
|
||||
.getByTestId('entry-3')
|
||||
.getByTestId('rundown-event')
|
||||
.filter({ hasText: '3' })
|
||||
.press('Alt+ControlOrMeta+ArrowUp');
|
||||
|
||||
@@ -13,27 +13,23 @@ test('time until absolute', async ({ context }) => {
|
||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||
|
||||
await editor.getByTestId('entry-1').getByTestId('rundown-event').click();
|
||||
const entry1Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
const ids = new Array<string>();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
await editor.getByTestId('entry-2').getByTestId('rundown-event').click();
|
||||
const entry2Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
await editor.getByTestId('entry-3').getByTestId('rundown-event').click();
|
||||
const entry3Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
await editor.getByTestId('entry-4').getByTestId('rundown-event').click();
|
||||
const entry4Id = await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue();
|
||||
ids.push(await editor.getByTestId('editor-container').getByLabel('Event ID (read only)').inputValue());
|
||||
|
||||
await countdown.goto(`/countdown?${entry1Id}&sub=${entry2Id}&sub=${entry3Id}&sub=${entry4Id}`);
|
||||
await countdown.goto(`/countdown?${ids.join('&sub=')}`);
|
||||
|
||||
// Create reusable locator references for different elements
|
||||
const entry2 = {
|
||||
@@ -61,10 +57,8 @@ test('time until absolute', async ({ context }) => {
|
||||
|
||||
await editor.getByRole('button', { name: 'Absolute' }).click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||
await expect(editor.getByTestId('offset')).not.toContainText('0:00'); // This might be a bad test requires that the test is not run at 0h
|
||||
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||
await editor.getByLabel('Pause event').click();
|
||||
|
||||
// 1. initial check
|
||||
await expect(entry2.editorEvent).toContainText('9m');
|
||||
@@ -135,29 +129,22 @@ test('time until absolute', async ({ context }) => {
|
||||
|
||||
test('time until relative', async ({ context }) => {
|
||||
const editor = await context.newPage();
|
||||
await editor.goto('/editor');
|
||||
editor.goto('/editor');
|
||||
|
||||
await editor.getByRole('button', { name: 'Edit' }).click();
|
||||
await editor.getByRole('button', { name: 'Rundown menu' }).click();
|
||||
await editor.getByRole('menuitem', { name: 'Clear all' }).click();
|
||||
await editor.getByRole('button', { name: 'Delete all' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(0);
|
||||
|
||||
await editor.getByRole('button', { name: 'Create Event' }).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(1);
|
||||
await editor.getByRole('button', { name: 'Event' }).nth(4).click();
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(2);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(3);
|
||||
await editor.getByRole('button', { name: 'Event', exact: true }).nth(1).click();
|
||||
await expect(editor.getByTestId('rundown-event')).toHaveCount(4);
|
||||
|
||||
await editor.getByRole('button', { name: 'Relative' }).click();
|
||||
await editor.getByTestId('entry-1').getByLabel('Start event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Pause event')).toBeVisible();
|
||||
await expect(editor.getByTestId('offset')).toContainText('0:00'); // This might be a bad test as it ruires the evaluation to happen within 1s
|
||||
await editor.getByTestId('entry-1').getByLabel('Pause event').click();
|
||||
await expect(editor.getByTestId('entry-1').getByLabel('Start event')).toBeVisible();
|
||||
await editor.getByLabel('Pause event').click();
|
||||
|
||||
await expect(editor.getByTestId('entry-2').getByTestId('rundown-event')).toContainText('9m');
|
||||
await expect(editor.getByTestId('entry-3').getByTestId('rundown-event')).toContainText('19m');
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const baseURL = 'http://localhost:4001';
|
||||
const automationsURL = `${baseURL}/data/automations`;
|
||||
|
||||
/**
|
||||
* Covers the automation delete flow: the server refuses to delete an automation that a
|
||||
* trigger still points at, and clears once the reference is removed.
|
||||
*/
|
||||
test.describe('automations', () => {
|
||||
let createdTriggers: string[] = [];
|
||||
let createdAutomations: string[] = [];
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
try {
|
||||
// triggers first, the server refuses to delete an automation that is still referenced
|
||||
for (const id of createdTriggers) {
|
||||
await request.delete(`${automationsURL}/trigger/${id}`);
|
||||
}
|
||||
for (const id of createdAutomations) {
|
||||
await request.delete(`${automationsURL}/automation/${id}`);
|
||||
}
|
||||
} catch {
|
||||
// cleanup is best effort, it must not turn a passing test red
|
||||
} finally {
|
||||
createdTriggers = [];
|
||||
createdAutomations = [];
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
|
||||
const automation = await (
|
||||
await request.post(`${automationsURL}/automation`, {
|
||||
data: {
|
||||
title: 'e2e referenced automation',
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [{ type: 'ontime', action: 'aux1-stop' }],
|
||||
},
|
||||
})
|
||||
).json();
|
||||
createdAutomations.push(automation.id);
|
||||
|
||||
const trigger = await (
|
||||
await request.post(`${automationsURL}/trigger`, {
|
||||
data: { title: 'e2e blocking trigger', trigger: 'onFinish', automationId: automation.id },
|
||||
})
|
||||
).json();
|
||||
createdTriggers.push(trigger.id);
|
||||
|
||||
const refused = await request.delete(`${automationsURL}/automation/${automation.id}`);
|
||||
expect(refused.status()).toBe(400);
|
||||
expect((await refused.json()).message).toContain('e2e blocking trigger');
|
||||
|
||||
// and it goes through once the reference is removed
|
||||
expect((await request.delete(`${automationsURL}/trigger/${trigger.id}`)).status()).toBe(204);
|
||||
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ontime",
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"description": "Time keeping for live events",
|
||||
"keywords": [
|
||||
"ontime",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.12.0",
|
||||
"version": "4.11.0",
|
||||
"name": "ontime-types",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -19,6 +19,7 @@ export type LogMessage = {
|
||||
};
|
||||
|
||||
export enum LogOrigin {
|
||||
Automation = 'AUTOMATION',
|
||||
Client = 'CLIENT',
|
||||
Playback = 'PLAYBACK',
|
||||
Rx = 'RX',
|
||||
|
||||
Reference in New Issue
Block a user