mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-12 17:49:37 +00:00
feat(automation): streamline automation management
This commit is contained in:
+85
-48
@@ -1,13 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* The wide modal body does not scroll, so the form owns it.
|
||||||
|
* Without this the form is simply clipped: four outputs is enough to put Save out of reach.
|
||||||
|
*/
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formScroll {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.outerColumn {
|
.outerColumn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
font-size: calc(1rem - 1px);
|
font-size: calc(1rem - 1px);
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
|
|
||||||
// the shared modal body owns scrolling for this regular form modal
|
|
||||||
min-height: 100%;
|
|
||||||
padding-block: 0.5rem;
|
padding-block: 0.5rem;
|
||||||
|
// leaves the overlay scrollbar somewhere to sit without covering a field
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -26,61 +40,84 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleSection,
|
.titleSection {
|
||||||
.filterSection,
|
|
||||||
.oscSection,
|
|
||||||
.httpSection,
|
|
||||||
.actionSection {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
|
|
||||||
button {
|
|
||||||
align-self: flex-end;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleSection,
|
.titleSection,
|
||||||
.ruleSection,
|
.ruleSection,
|
||||||
.filterSection,
|
.card {
|
||||||
.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);
|
|
||||||
}
|
|
||||||
label {
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: calc(1rem - 3px);
|
||||||
color: $label-gray;
|
color: $label-gray;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.titleSection {
|
/** shared shell for a single filter or output */
|
||||||
grid-template-columns: 1fr;
|
.card {
|
||||||
}
|
border: 1px solid $white-10;
|
||||||
|
|
||||||
.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 {
|
|
||||||
border-left: 0.25rem solid $gray-1200;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+297
-255
@@ -1,53 +1,108 @@
|
|||||||
import {
|
import {
|
||||||
Automation,
|
Automation,
|
||||||
AutomationDTO,
|
AutomationDTO,
|
||||||
HTTPOutput,
|
AutomationFilter,
|
||||||
OSCOutput,
|
TimerLifeCycle,
|
||||||
OntimeAction,
|
Trigger,
|
||||||
isHTTPOutput,
|
isHTTPOutput,
|
||||||
isOSCOutput,
|
isOSCOutput,
|
||||||
isOntimeAction,
|
isOntimeAction,
|
||||||
} from 'ontime-types';
|
} from 'ontime-types';
|
||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
import { IoAdd, 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 { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||||
import Modal from '../../../../common/components/modal/Modal';
|
import Modal from '../../../../common/components/modal/Modal';
|
||||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||||
|
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||||
import { startsWithHttp } from '../../../../common/utils/regex';
|
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
import { isAutomation, makeFieldList } from './automationUtils';
|
import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils';
|
||||||
|
import HttpOutputForm from './HttpOutputForm';
|
||||||
import OntimeActionForm from './OntimeActionForm';
|
import OntimeActionForm from './OntimeActionForm';
|
||||||
import TemplateInput from './template-input/TemplateInput';
|
import OscOutputForm from './OscOutputForm';
|
||||||
|
import OutputCard, { type TestState } from './OutputCard';
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
const integrationsDocsUrl = 'https://docs.getontime.no/api/automation/#using-variables-in-automation';
|
||||||
const formId = 'automation-form';
|
const formId = 'automation-form';
|
||||||
|
|
||||||
|
/** how long a successful test keeps its confirmation on screen */
|
||||||
|
const testFeedbackDuration = 2000;
|
||||||
|
|
||||||
|
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
|
||||||
|
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
|
||||||
|
|
||||||
interface AutomationFormProps {
|
interface AutomationFormProps {
|
||||||
automation: Automation | AutomationDTO;
|
automation: Automation | AutomationDTO;
|
||||||
|
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
|
||||||
|
triggers: Trigger[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
|
export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) {
|
||||||
const isEdit = isAutomation(automation);
|
const isEdit = isAutomation(automation);
|
||||||
const { data } = useCustomFields();
|
const { data } = useCustomFields();
|
||||||
const { refetch } = useAutomationSettings();
|
const { refetch } = useAutomationSettings();
|
||||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The triggers the server holds for this automation, as far as this form knows.
|
||||||
|
*
|
||||||
|
* Seeded from a snapshot taken when the form opens, never from 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. It then advances as each request succeeds, so a save that fails
|
||||||
|
* half way leaves only the outstanding work for the retry.
|
||||||
|
*/
|
||||||
|
const [syncedTriggers, setSyncedTriggers] = useState<Trigger[]>(() =>
|
||||||
|
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
|
||||||
|
);
|
||||||
|
const syncedCycles = useMemo(
|
||||||
|
() => Array.from(new Set(syncedTriggers.map((trigger) => trigger.trigger))),
|
||||||
|
[syncedTriggers],
|
||||||
|
);
|
||||||
|
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
|
||||||
|
// both are deduped, so equal lengths and one being a subset makes them the same selection
|
||||||
|
const cyclesAreDirty =
|
||||||
|
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.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 = syncedTriggers.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 {
|
const {
|
||||||
|
clearErrors,
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
getValues,
|
getValues,
|
||||||
@@ -60,10 +115,10 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
} = useForm<AutomationDTO>({
|
} = useForm<AutomationDTO>({
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
title: automation?.title ?? '',
|
title: automation.title,
|
||||||
filterRule: automation?.filterRule ?? 'all',
|
filterRule: automation.filterRule,
|
||||||
filters: automation?.filters ?? [],
|
filters: automation.filters,
|
||||||
outputs: automation?.outputs ?? [],
|
outputs: automation.outputs,
|
||||||
},
|
},
|
||||||
resetOptions: {
|
resetOptions: {
|
||||||
keepDirtyValues: true,
|
keepDirtyValues: true,
|
||||||
@@ -93,6 +148,28 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
setFocus('title');
|
setFocus('title');
|
||||||
}, [setFocus]);
|
}, [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 getOutputErrors = (index: number) => errors.outputs?.[index] as OutputErrors | undefined;
|
||||||
|
|
||||||
const handleAddNewFilter = () => {
|
const handleAddNewFilter = () => {
|
||||||
appendFilter({ field: '', operator: 'equals', value: '' });
|
appendFilter({ field: '', operator: 'equals', value: '' });
|
||||||
};
|
};
|
||||||
@@ -106,84 +183,115 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
appendOutput({ type: 'http', url: '' });
|
appendOutput({ type: 'http', url: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddnewOntimeAction = () => {
|
const handleAddNewOntimeAction = () => {
|
||||||
appendOutput({ type: 'ontime', action: 'aux1-start' });
|
appendOutput({ type: 'ontime', action: 'aux1-start' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTestOSCOutput = async (index: number) => {
|
/**
|
||||||
try {
|
* Sends a single output as configured, without saving the automation.
|
||||||
const values = getValues(`outputs.${index}`) as OSCOutput;
|
* OSC is fire and forget over UDP, so the most we can honestly claim is that we sent it.
|
||||||
if (!values.targetIP || !values.targetPort || !values.address) {
|
*/
|
||||||
|
const handleTest = async (index: number, key: string) => {
|
||||||
|
const values = getValues(`outputs.${index}`);
|
||||||
|
|
||||||
|
if (isOSCOutput(values) && (!values.targetIP || !values.targetPort || !values.address)) {
|
||||||
|
reportTest(key, { status: 'error', message: 'Fill in the target and address before testing' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await testOutput({
|
if (isHTTPOutput(values) && !values.url) {
|
||||||
type: 'osc',
|
reportTest(key, { status: 'error', message: 'Add a target URL before testing' });
|
||||||
targetIP: values.targetIP,
|
return;
|
||||||
targetPort: values.targetPort,
|
}
|
||||||
address: values.address,
|
|
||||||
args: values.args,
|
reportTest(key, { status: 'sending' });
|
||||||
});
|
try {
|
||||||
} catch (_error) {
|
// NOTE: there is no meaningful validation to do on an Ontime action, we let the server deal with the data
|
||||||
/** we dont handle errors here, users should use the network tab */
|
await testOutput(values);
|
||||||
|
reportTest(key, { status: 'ok', message: 'Sent' });
|
||||||
|
} catch (error) {
|
||||||
|
reportTest(key, { status: 'error', message: maybeAxiosError(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTestHTTPOutput = async (index: number) => {
|
/**
|
||||||
try {
|
* Reconciles an existing automation's lifecycle selection against the global triggers.
|
||||||
const values = getValues(`outputs.${index}`) as HTTPOutput;
|
* New automations and their triggers are created together in one request.
|
||||||
if (!values.url) {
|
*
|
||||||
return;
|
* Every request advances the synced snapshot as it succeeds, so pressing save again after
|
||||||
|
* a failure half way through retries only what is left. Without that a retry would re-add
|
||||||
|
* a trigger it already created, and re-delete one it already deleted, which the server
|
||||||
|
* rejects outright.
|
||||||
|
*/
|
||||||
|
const syncTriggers = async (automationId: string, title: string) => {
|
||||||
|
for (const trigger of triggersToRemove) {
|
||||||
|
await deleteTrigger(trigger.id);
|
||||||
|
setSyncedTriggers((prev) => prev.filter((synced) => synced.id !== trigger.id));
|
||||||
}
|
}
|
||||||
await testOutput({
|
|
||||||
type: 'http',
|
|
||||||
url: values.url,
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
/** we dont handle errors here, users should use the network tab */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestOntimeAction = async (index: number) => {
|
const toAdd = selectedCycles.filter((cycle) => !syncedCycles.includes(cycle));
|
||||||
try {
|
for (const cycle of toAdd) {
|
||||||
const values = getValues(`outputs.${index}`) as OntimeAction;
|
const created = await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId });
|
||||||
// NOTE: there is no meaningful validation to do here, we let the server deal with the data
|
setSyncedTriggers((prev) => [...prev, created]);
|
||||||
await testOutput({
|
|
||||||
...values,
|
|
||||||
type: 'ontime',
|
|
||||||
});
|
|
||||||
} catch (_error) {
|
|
||||||
/** we dont handle errors here */
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: AutomationDTO) => {
|
const onSubmit = async (values: AutomationDTO) => {
|
||||||
if (isAutomation(automation)) {
|
// a stale failure from the previous attempt would otherwise sit under a successful retry
|
||||||
await handleEdit(automation.id, { id: automation.id, ...values });
|
clearErrors('root');
|
||||||
} else {
|
|
||||||
await handleCreate(values);
|
try {
|
||||||
}
|
if (!isAutomation(automation)) {
|
||||||
|
await addAutomation(
|
||||||
|
values,
|
||||||
|
selectedCycles.map((cycle) => ({ title: makeTriggerTitle(values.title, cycle), trigger: cycle })),
|
||||||
|
);
|
||||||
refetch();
|
refetch();
|
||||||
|
|
||||||
async function handleEdit(id: string, values: Automation) {
|
|
||||||
try {
|
|
||||||
await editAutomation(id, values);
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
return;
|
||||||
setError('root', { message: maybeAxiosError(error) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate(values: AutomationDTO) {
|
await editAutomation(automation.id, { id: automation.id, ...values });
|
||||||
try {
|
|
||||||
await addAutomation(values);
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('root', { message: maybeAxiosError(error) });
|
setError('root', { message: maybeAxiosError(error) });
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await syncTriggers(automation.id, 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();
|
||||||
|
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'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed save reports itself as a root error, which react-hook-form counts against
|
||||||
|
* isValid. Left alone that disables the very retry the message is asking the user to make,
|
||||||
|
* so a root error on its own does not block submitting again.
|
||||||
|
*/
|
||||||
|
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
|
||||||
|
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0);
|
||||||
|
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -191,9 +299,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
showBackdrop
|
showBackdrop
|
||||||
showCloseButton
|
showCloseButton
|
||||||
|
size='wide'
|
||||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||||
bodyElements={
|
bodyElements={
|
||||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||||
|
<ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
|
||||||
<div className={style.innerColumn}>
|
<div className={style.innerColumn}>
|
||||||
<h3>Automation options</h3>
|
<h3>Automation options</h3>
|
||||||
<div className={style.titleSection}>
|
<div className={style.titleSection}>
|
||||||
@@ -207,11 +317,52 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
</label>
|
</label>
|
||||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={style.titleSection}>
|
||||||
|
<label id='runs-on-label'>Runs on</label>
|
||||||
|
<Panel.Description>
|
||||||
|
Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
|
||||||
|
single event from the event editor.
|
||||||
|
</Panel.Description>
|
||||||
|
<Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
|
||||||
|
{cycles.map(({ label, value }) => {
|
||||||
|
const isSelected = selectedCycles.includes(value);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
size='small'
|
||||||
|
variant={isSelected ? 'primary' : 'subtle'}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
onClick={() => toggleCycle(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Panel.InlineElements>
|
||||||
|
{hasContinuousCycle && (
|
||||||
|
<Panel.Description tone='warning'>
|
||||||
|
Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
|
||||||
|
mean to send on every tick.
|
||||||
|
</Panel.Description>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
|
||||||
<div className={style.innerColumn}>
|
<div className={style.innerColumn}>
|
||||||
<h3>Filters (optional)</h3>
|
<h3>Filters (optional)</h3>
|
||||||
|
<Panel.Description>
|
||||||
|
Without filters the outputs are sent every time the automation is triggered.
|
||||||
|
</Panel.Description>
|
||||||
<div className={style.ruleSection}>
|
<div className={style.ruleSection}>
|
||||||
|
{fieldFilters.length > 1 && (
|
||||||
<label>
|
<label>
|
||||||
Trigger outputs if
|
Trigger outputs if
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@@ -224,10 +375,23 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
{fieldFilters.map((field, index) => {
|
{fieldFilters.map((field, index) => {
|
||||||
const key = `filters.${index}.field.${field.id}`;
|
const description = describeFilter(index);
|
||||||
return (
|
return (
|
||||||
<div key={key} className={style.filterSection}>
|
<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>
|
<label>
|
||||||
Runtime data source
|
Runtime data source
|
||||||
<Select<string | null>
|
<Select<string | null>
|
||||||
@@ -252,23 +416,11 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
value={watch(`filters.${index}.operator`)}
|
value={watch(`filters.${index}.operator`)}
|
||||||
onValueChange={(value: string | null) => {
|
onValueChange={(value: string | null) => {
|
||||||
if (value === null) return;
|
if (value === null) return;
|
||||||
setValue(
|
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
|
||||||
`filters.${index}.operator`,
|
shouldDirty: true,
|
||||||
value as
|
});
|
||||||
| 'equals'
|
|
||||||
| 'not_equals'
|
|
||||||
| 'greater_than'
|
|
||||||
| 'less_than'
|
|
||||||
| 'contains'
|
|
||||||
| 'not_contains',
|
|
||||||
{ shouldDirty: true },
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
options={[
|
options={operators}
|
||||||
{ value: 'equals', label: 'equals' },
|
|
||||||
{ value: 'not_equals', label: 'not equals' },
|
|
||||||
{ value: 'contains', label: 'contains' },
|
|
||||||
]}
|
|
||||||
aria-label='Operator'
|
aria-label='Operator'
|
||||||
/>
|
/>
|
||||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||||
@@ -277,17 +429,6 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
Value to match
|
Value to match
|
||||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||||
</label>
|
</label>
|
||||||
<div>
|
|
||||||
<span> </span>
|
|
||||||
<div>
|
|
||||||
<IconButton
|
|
||||||
aria-label='Delete'
|
|
||||||
variant='ghosted-destructive'
|
|
||||||
onClick={() => removeFilter(index)}
|
|
||||||
>
|
|
||||||
<IoTrash />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -303,152 +444,50 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
<div className={style.innerColumn}>
|
<div className={style.innerColumn}>
|
||||||
<h3>Outputs</h3>
|
<h3>Outputs</h3>
|
||||||
<Info>
|
<Info>
|
||||||
Automation outputs can be used to send data from Ontime to external software <br />
|
Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
|
||||||
or to change properties of Ontime itself. <br /> <br />
|
|
||||||
Use Ontime runtime data in these fields with template strings. Type {'{{'} to see autocomplete, or{' '}
|
|
||||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
|
|
||||||
{fieldOutputs.map((output, index) => {
|
{fieldOutputs.length === 0 && (
|
||||||
if (isOSCOutput(output)) {
|
<Panel.EmptyState
|
||||||
const rowErrors = errors.outputs?.[index] as
|
title='This automation does nothing yet'
|
||||||
| {
|
description='An automation without outputs will be triggered, but it has nothing to send.'
|
||||||
targetIP?: { message?: string };
|
/>
|
||||||
targetPort?: { message?: string };
|
)}
|
||||||
address?: { message?: string };
|
|
||||||
args?: { message?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
|
{fieldOutputs.map((output, index) => {
|
||||||
|
const rowErrors = getOutputErrors(index);
|
||||||
|
const cardProps = {
|
||||||
|
testState: testResults[output.id],
|
||||||
|
onTest: () => handleTest(index, output.id),
|
||||||
|
onDelete: () => removeOutput(index),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOSCOutput(output)) {
|
||||||
return (
|
return (
|
||||||
<div key={output.id} className={style.outputCard}>
|
<OutputCard
|
||||||
<Tag>OSC</Tag>
|
key={output.id}
|
||||||
<div className={style.oscSection}>
|
label='OSC'
|
||||||
<label>
|
kindClass={style.tagOsc}
|
||||||
Target IP
|
summary={watch(`outputs.${index}.address`)}
|
||||||
<Input
|
{...cardProps}
|
||||||
{...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 />
|
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||||
</IconButton>
|
</OutputCard>
|
||||||
</Panel.InlineElements>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHTTPOutput(output)) {
|
if (isHTTPOutput(output)) {
|
||||||
const rowErrors = errors.outputs?.[index] as
|
|
||||||
| {
|
|
||||||
url?: { message?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
return (
|
return (
|
||||||
<div key={output.id} className={style.outputCard}>
|
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
|
||||||
<Tag>HTTP</Tag>
|
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||||
<div className={style.httpSection}>
|
</OutputCard>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOntimeAction(output)) {
|
if (isOntimeAction(output)) {
|
||||||
const rowErrors = errors.outputs?.[index] as
|
|
||||||
| {
|
|
||||||
action?: { message?: string };
|
|
||||||
time?: { message?: string };
|
|
||||||
text?: { message?: string };
|
|
||||||
visible?: { message?: string };
|
|
||||||
secondarySource?: { message?: string };
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
return (
|
return (
|
||||||
<div key={output.id} className={style.outputCard}>
|
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
|
||||||
<Tag>Ontime action</Tag>
|
|
||||||
<OntimeActionForm
|
<OntimeActionForm
|
||||||
value={output.action}
|
value={output.action}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -456,39 +495,42 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
|||||||
rowErrors={rowErrors}
|
rowErrors={rowErrors}
|
||||||
setValue={setValue}
|
setValue={setValue}
|
||||||
watch={watch}
|
watch={watch}
|
||||||
>
|
/>
|
||||||
<span> </span>
|
</OutputCard>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
<Panel.InlineElements relation='inner'>
|
<div>
|
||||||
<Button onClick={handleAddNewOSCOutput}>
|
<DropdownMenu
|
||||||
OSC <IoAdd />
|
render={<Button />}
|
||||||
</Button>
|
items={[
|
||||||
<Button onClick={handleAddNewHTTPOutput}>
|
{
|
||||||
HTTP <IoAdd />
|
type: 'item',
|
||||||
</Button>
|
label: 'OSC',
|
||||||
<Button onClick={handleAddnewOntimeAction}>
|
description: 'Send an OSC message to a device on the network',
|
||||||
Ontime action <IoAdd />
|
onClick: handleAddNewOSCOutput,
|
||||||
</Button>
|
},
|
||||||
</Panel.InlineElements>
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'HTTP',
|
||||||
|
description: 'Call a URL, for webhooks and REST APIs',
|
||||||
|
onClick: handleAddNewHTTPOutput,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'item',
|
||||||
|
label: 'Ontime action',
|
||||||
|
description: 'Change something inside Ontime, like a message or an aux timer',
|
||||||
|
onClick: handleAddNewOntimeAction,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Add output <IoAdd />
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
footerElements={
|
footerElements={
|
||||||
|
|||||||
@@ -30,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={automationsRef}>
|
<div ref={automationsRef}>
|
||||||
<AutomationsList automations={data.automations} enabledAutomations={automationState} isLoading={isLoading} />
|
<AutomationsList
|
||||||
</div>
|
|
||||||
<div ref={triggersRef}>
|
|
||||||
<TriggersList
|
|
||||||
triggers={data.triggers}
|
|
||||||
automations={data.automations}
|
automations={data.automations}
|
||||||
|
triggers={data.triggers}
|
||||||
enabledAutomations={automationState}
|
enabledAutomations={automationState}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={triggersRef}>
|
||||||
|
<TriggersList triggers={data.triggers} automations={data.automations} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -94,7 +94,8 @@ export default function AutomationSettingsForm({
|
|||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Info>
|
<Info>
|
||||||
<span>Control Ontime and share its data with external systems in your workflow.</span>
|
<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>
|
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
|
||||||
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
|
||||||
</Info>
|
</Info>
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
/** tags make a cell taller than the title beside it, which staggers on the default baseline */
|
||||||
|
.table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: $muted-gray;
|
||||||
|
}
|
||||||
+126
-56
@@ -1,18 +1,25 @@
|
|||||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||||
import { Fragment, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||||
|
|
||||||
import { deleteAutomation } from '../../../../common/api/automation';
|
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
import Info from '../../../../common/components/info/Info';
|
import Info from '../../../../common/components/info/Info';
|
||||||
import Tag from '../../../../common/components/tag/Tag';
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
|
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||||
|
import { cx } from '../../../../common/utils/styleUtils';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||||
import AutomationForm from './AutomationForm';
|
import AutomationForm from './AutomationForm';
|
||||||
|
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
|
||||||
|
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||||
|
import NewAutomationDialog from './NewAutomationDialog';
|
||||||
|
|
||||||
const automationPlaceholder: AutomationDTO = {
|
import style from './AutomationsList.module.scss';
|
||||||
|
|
||||||
|
const emptyAutomation: AutomationDTO = {
|
||||||
title: '',
|
title: '',
|
||||||
filterRule: 'all',
|
filterRule: 'all',
|
||||||
filters: [],
|
filters: [],
|
||||||
@@ -21,37 +28,73 @@ const automationPlaceholder: AutomationDTO = {
|
|||||||
|
|
||||||
interface AutomationsListProps {
|
interface AutomationsListProps {
|
||||||
automations: NormalisedAutomation;
|
automations: NormalisedAutomation;
|
||||||
|
triggers: Trigger[];
|
||||||
enabledAutomations?: boolean;
|
enabledAutomations?: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
export default function AutomationsList({
|
||||||
|
automations,
|
||||||
|
triggers,
|
||||||
|
enabledAutomations,
|
||||||
|
isLoading,
|
||||||
|
}: AutomationsListProps) {
|
||||||
const { refetch } = useAutomationSettings();
|
const { refetch } = useAutomationSettings();
|
||||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
const { setLocation } = useAppSettingsNavigation();
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
|
||||||
|
const [isPickingStart, setIsPickingStart] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
|
||||||
try {
|
const automationList = Object.values(automations);
|
||||||
setDeleteError(null);
|
|
||||||
await deleteAutomation(id);
|
/** a recipe creates the automation itself, so it lands in the list rather than in a form */
|
||||||
} catch (error) {
|
const handleCreated = async () => {
|
||||||
setDeleteError(maybeAxiosError(error));
|
setIsPickingStart(false);
|
||||||
} finally {
|
await refetch();
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const arrayAutomations = Object.keys(automations);
|
const handleStartEmpty = () => {
|
||||||
|
setIsPickingStart(false);
|
||||||
|
setEditing(emptyAutomation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleted = async () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await refetch();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
<Panel.Card>
|
<Panel.Card>
|
||||||
{automationFormData !== null && (
|
{editing !== 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(editing) ? editing.id : 'new'}
|
||||||
|
automation={editing}
|
||||||
|
triggers={triggers}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPickingStart && (
|
||||||
|
<NewAutomationDialog
|
||||||
|
onClose={() => setIsPickingStart(false)}
|
||||||
|
onStartEmpty={handleStartEmpty}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{deleteTarget !== null && (
|
||||||
|
<DeleteAutomationDialog
|
||||||
|
automation={deleteTarget}
|
||||||
|
attachedTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onDeleted={handleDeleted}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Manage automations
|
Manage automations
|
||||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
<Button onClick={() => setIsPickingStart(true)}>
|
||||||
New <IoAdd />
|
New <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
@@ -60,74 +103,101 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
|||||||
|
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
{enabledAutomations === false && (
|
{enabledAutomations === false && (
|
||||||
<Info>
|
<Info type='warning'>
|
||||||
Automations are disabled. You can still manage automation definitions here, but they will not run until
|
<Info.Body>Automations are off, so nothing in this list will run.</Info.Body>
|
||||||
enabled.
|
<Info.Footer>
|
||||||
|
{/* the master switch is at the top of the panel, out of sight once the list has rows */}
|
||||||
|
<Button size='small' onClick={() => setLocation('automation__settings')}>
|
||||||
|
Go to automation settings
|
||||||
|
</Button>
|
||||||
|
</Info.Footer>
|
||||||
</Info>
|
</Info>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Panel.Table>
|
<Panel.Table className={style.table}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '45%' }}>Title</th>
|
<th style={{ width: '35%' }}>Title</th>
|
||||||
<th style={{ width: '15%' }}>Trigger rule</th>
|
<th style={{ width: '25%' }}>Runs on</th>
|
||||||
<th style={{ width: '15%' }}>Filters</th>
|
<th style={{ width: '15%' }}>Filter rule</th>
|
||||||
<th style={{ width: '15%' }}>Outputs</th>
|
<th style={{ width: '15%' }}>Sends</th>
|
||||||
<th />
|
<th />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{!isLoading && arrayAutomations.length === 0 && (
|
{!isLoading && automationList.length === 0 && (
|
||||||
<Panel.TableEmpty
|
<Panel.TableEmpty
|
||||||
title='No automations yet'
|
title='No automations yet'
|
||||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires.'
|
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
|
||||||
action={
|
action={
|
||||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
<Button variant='primary' onClick={() => setIsPickingStart(true)}>
|
||||||
Create automation <IoAdd />
|
New automation <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{arrayAutomations.map((automationId) => {
|
{automationList.map((automation) => {
|
||||||
if (!Object.hasOwn(automations, automationId)) {
|
const lifecycles = lifecyclesByAutomation[automation.id] ?? [];
|
||||||
return null;
|
const outputs = summariseOutputs(automation.outputs);
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={automationId}>
|
<tr key={automation.id}>
|
||||||
<tr>
|
<td>{automation.title}</td>
|
||||||
<td>{automations[automationId].title}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<Tag>{automations[automationId].filterRule}</Tag>
|
{/*
|
||||||
|
* Only global triggers are visible here: an automation can also be attached to
|
||||||
|
* single events, which live in the rundown. An empty cell is therefore not the
|
||||||
|
* same as never running, so it says nothing rather than claiming that.
|
||||||
|
*/}
|
||||||
|
{lifecycles.length === 0 ? (
|
||||||
|
<span className={style.muted}>—</span>
|
||||||
|
) : (
|
||||||
|
<div className={style.tags}>
|
||||||
|
{lifecycles.map((cycle) => (
|
||||||
|
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{automations[automationId].filters.length}</td>
|
<td>
|
||||||
<td>{automations[automationId].outputs.length}</td>
|
{automation.filters.length === 0 ? (
|
||||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
<span className={style.muted}>—</span>
|
||||||
|
) : (
|
||||||
|
<Tag>{automation.filterRule === 'all' ? 'All filters' : 'Any filter'}</Tag>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={style.tags}>
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<Tag variant='warning'>No outputs</Tag>
|
||||||
|
) : (
|
||||||
|
outputs.map(({ type, label, count }) => (
|
||||||
|
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className={cx([style.tags, style.actions])}>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='ghosted-white'
|
variant='ghosted-white'
|
||||||
aria-label='Edit entry'
|
aria-label='Edit entry'
|
||||||
onClick={() => setAutomationFormData(automations[automationId])}
|
onClick={() => setEditing(automation)}
|
||||||
>
|
>
|
||||||
<IoPencil />
|
<IoPencil />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant='ghosted-destructive'
|
variant='ghosted-destructive'
|
||||||
aria-label='Delete entry'
|
aria-label='Delete entry'
|
||||||
onClick={() => handleDelete(automationId)}
|
onClick={() => setDeleteTarget(automation)}
|
||||||
>
|
>
|
||||||
<IoTrash />
|
<IoTrash />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Panel.InlineElements>
|
</div>
|
||||||
</tr>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{deleteError && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5}>
|
|
||||||
<Panel.Error>{deleteError}</Panel.Error>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Panel.Table>
|
</Panel.Table>
|
||||||
</Panel.Section>
|
</Panel.Section>
|
||||||
|
|||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Automation, Trigger } from 'ontime-types';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { deleteAutomation } from '../../../../common/api/automation';
|
||||||
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import Dialog from '../../../../common/components/dialog/Dialog';
|
||||||
|
import Info from '../../../../common/components/info/Info';
|
||||||
|
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
interface DeleteAutomationDialogProps {
|
||||||
|
automation: Automation;
|
||||||
|
/** global triggers pointing at this automation, they are deleted along with it */
|
||||||
|
attachedTriggers: Trigger[];
|
||||||
|
onCancel: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting takes the automation's global triggers with it, so say so before it happens rather
|
||||||
|
* than leaving the user to discover it in the triggers list.
|
||||||
|
*
|
||||||
|
* An automation attached to an event is still refused by the server: that reference lives in
|
||||||
|
* the rundown and removing it is an edit to the show, not to this panel.
|
||||||
|
*/
|
||||||
|
export default function DeleteAutomationDialog({
|
||||||
|
automation,
|
||||||
|
attachedTriggers,
|
||||||
|
onCancel,
|
||||||
|
onDeleted,
|
||||||
|
}: DeleteAutomationDialogProps) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
const 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>
|
||||||
|
|
||||||
|
{attachedTriggers.length > 0 && (
|
||||||
|
<Info type='warning'>
|
||||||
|
<Info.Title>
|
||||||
|
{attachedTriggers.length === 1
|
||||||
|
? 'Its trigger is deleted with it'
|
||||||
|
: `Its ${attachedTriggers.length} triggers are deleted with it`}
|
||||||
|
</Info.Title>
|
||||||
|
<Info.Body>{attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')}</Info.Body>
|
||||||
|
</Info>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Info type='error'>
|
||||||
|
<Info.Title>Could not delete this automation</Info.Title>
|
||||||
|
<Info.Body>{error}</Info.Body>
|
||||||
|
<Info.Footer>
|
||||||
|
An automation attached to a single event has 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>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { AutomationDTO, HTTPOutput } from 'ontime-types';
|
||||||
|
import type { UseFormRegister } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { startsWithHttp } from '../../../../common/utils/regex';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import type { OutputErrors } from './automationUtils';
|
||||||
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
|
|
||||||
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
|
interface HttpOutputFormProps {
|
||||||
|
index: number;
|
||||||
|
output: HTTPOutput;
|
||||||
|
register: UseFormRegister<AutomationDTO>;
|
||||||
|
rowErrors?: OutputErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HttpOutputForm({ index, output, register, rowErrors }: HttpOutputFormProps) {
|
||||||
|
return (
|
||||||
|
<label className={style.spanFull}>
|
||||||
|
Target URL
|
||||||
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.url`, {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
pattern: { value: startsWithHttp, message: 'HTTP messages should target http:// or https://' },
|
||||||
|
})}
|
||||||
|
value={output.url}
|
||||||
|
fluid
|
||||||
|
placeholder='http://127.0.0.1/start/1'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.url?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { AutomationDTO, OntimeAction, OntimeActionKey, SecondarySource } from 'ontime-types';
|
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 { UseFormRegister, UseFormSetValue, UseFormWatch } from 'react-hook-form';
|
||||||
|
|
||||||
import Input from '../../../../common/components/input/input/Input';
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
import Select from '../../../../common/components/select/Select';
|
import Select from '../../../../common/components/select/Select';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import type { OutputErrors } from './automationUtils';
|
||||||
import TemplateInput from './template-input/TemplateInput';
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
|
|
||||||
import style from './AutomationForm.module.scss';
|
import style from './AutomationForm.module.scss';
|
||||||
@@ -12,13 +13,7 @@ import style from './AutomationForm.module.scss';
|
|||||||
interface OntimeActionFormProps {
|
interface OntimeActionFormProps {
|
||||||
index: number;
|
index: number;
|
||||||
register: UseFormRegister<AutomationDTO>;
|
register: UseFormRegister<AutomationDTO>;
|
||||||
rowErrors?: {
|
rowErrors?: OutputErrors;
|
||||||
action?: { message?: string };
|
|
||||||
time?: { message?: string };
|
|
||||||
text?: { message?: string };
|
|
||||||
visible?: { message?: string };
|
|
||||||
secondarySource?: { message?: string };
|
|
||||||
};
|
|
||||||
value: OntimeAction['action'];
|
value: OntimeAction['action'];
|
||||||
watch: UseFormWatch<AutomationDTO>;
|
watch: UseFormWatch<AutomationDTO>;
|
||||||
setValue: UseFormSetValue<AutomationDTO>;
|
setValue: UseFormSetValue<AutomationDTO>;
|
||||||
@@ -30,9 +25,8 @@ export default function OntimeActionForm({
|
|||||||
setValue,
|
setValue,
|
||||||
rowErrors,
|
rowErrors,
|
||||||
value,
|
value,
|
||||||
children,
|
|
||||||
watch,
|
watch,
|
||||||
}: PropsWithChildren<OntimeActionFormProps>) {
|
}: OntimeActionFormProps) {
|
||||||
const [selectedAction, setSelectedAction] = useState<string>(value);
|
const [selectedAction, setSelectedAction] = useState<string>(value);
|
||||||
|
|
||||||
const handleSetAction = (value: OntimeActionKey) => {
|
const handleSetAction = (value: OntimeActionKey) => {
|
||||||
@@ -41,7 +35,7 @@ export default function OntimeActionForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.actionSection}>
|
<>
|
||||||
<label>
|
<label>
|
||||||
Action
|
Action
|
||||||
<Select
|
<Select
|
||||||
@@ -95,7 +89,7 @@ export default function OntimeActionForm({
|
|||||||
|
|
||||||
{selectedAction === 'message-set' && (
|
{selectedAction === 'message-set' && (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label className={style.spanFull}>
|
||||||
Text (leave empty for no change)
|
Text (leave empty for no change)
|
||||||
<TemplateInput
|
<TemplateInput
|
||||||
{...register(`outputs.${index}.text`)}
|
{...register(`outputs.${index}.text`)}
|
||||||
@@ -127,7 +121,7 @@ export default function OntimeActionForm({
|
|||||||
|
|
||||||
{selectedAction === 'message-secondary' && (
|
{selectedAction === 'message-secondary' && (
|
||||||
<>
|
<>
|
||||||
<label>
|
<label className={style.spanFull}>
|
||||||
Text (leave empty for no change)
|
Text (leave empty for no change)
|
||||||
<TemplateInput
|
<TemplateInput
|
||||||
{...register(`outputs.${index}.text`)}
|
{...register(`outputs.${index}.text`)}
|
||||||
@@ -169,8 +163,6 @@ export default function OntimeActionForm({
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
<div className={style.test}>{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { AutomationDTO, OSCOutput } from 'ontime-types';
|
||||||
|
import type { UseFormRegister } from 'react-hook-form';
|
||||||
|
|
||||||
|
import Input from '../../../../common/components/input/input/Input';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import type { OutputErrors } from './automationUtils';
|
||||||
|
import TemplateInput from './template-input/TemplateInput';
|
||||||
|
|
||||||
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
|
interface OscOutputFormProps {
|
||||||
|
index: number;
|
||||||
|
output: OSCOutput;
|
||||||
|
register: UseFormRegister<AutomationDTO>;
|
||||||
|
rowErrors?: OutputErrors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OscOutputForm({ index, output, register, rowErrors }: OscOutputFormProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Target IP
|
||||||
|
<Input
|
||||||
|
{...register(`outputs.${index}.targetIP`, { required: { value: true, message: 'Required field' } })}
|
||||||
|
fluid
|
||||||
|
placeholder='127.0.0.1'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.targetIP?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Target Port
|
||||||
|
<Input
|
||||||
|
{...register(`outputs.${index}.targetPort`, {
|
||||||
|
required: { value: true, message: 'Required field' },
|
||||||
|
setValueAs: (value) => (value === '' ? 0 : Number(value)),
|
||||||
|
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
|
||||||
|
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
|
||||||
|
})}
|
||||||
|
fluid
|
||||||
|
type='number'
|
||||||
|
maxLength={5}
|
||||||
|
placeholder='8000'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.targetPort?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<label className={style.spanFull}>
|
||||||
|
Address
|
||||||
|
<TemplateInput
|
||||||
|
{...register(`outputs.${index}.address`)}
|
||||||
|
value={output.address}
|
||||||
|
fluid
|
||||||
|
placeholder='/cue/start'
|
||||||
|
/>
|
||||||
|
<Panel.Error>{rowErrors?.address?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
<label className={style.spanFull}>
|
||||||
|
Arguments
|
||||||
|
<TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
|
||||||
|
<Panel.Error>{rowErrors?.args?.message}</Panel.Error>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { IoCheckmark, IoTrash } from 'react-icons/io5';
|
||||||
|
|
||||||
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
|
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||||
|
import Tag from '../../../../common/components/tag/Tag';
|
||||||
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
|
import style from './AutomationForm.module.scss';
|
||||||
|
|
||||||
|
export type TestState = { status: 'sending' | 'ok' | 'error'; message?: string };
|
||||||
|
|
||||||
|
interface OutputCardProps {
|
||||||
|
label: string;
|
||||||
|
kindClass?: string;
|
||||||
|
summary?: string;
|
||||||
|
testState?: TestState;
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export default 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
import { NormalisedAutomation, Trigger } from 'ontime-types';
|
||||||
import { Fragment, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { IoAdd } from 'react-icons/io5';
|
import { IoAdd } from 'react-icons/io5';
|
||||||
|
|
||||||
import { deleteTrigger } from '../../../../common/api/automation';
|
import { deleteTrigger } from '../../../../common/api/automation';
|
||||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||||
import Button from '../../../../common/components/buttons/Button';
|
import Button from '../../../../common/components/buttons/Button';
|
||||||
import Info from '../../../../common/components/info/Info';
|
|
||||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||||
import * as Panel from '../../panel-utils/PanelUtils';
|
import * as Panel from '../../panel-utils/PanelUtils';
|
||||||
|
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
|
||||||
import { checkDuplicates } from './automationUtils';
|
import { checkDuplicates } from './automationUtils';
|
||||||
import TriggerForm from './TriggerForm';
|
import TriggerForm from './TriggerForm';
|
||||||
import TriggersListItem from './TriggersListItem';
|
import TriggersListItem from './TriggersListItem';
|
||||||
@@ -20,13 +20,13 @@ type FormState = {
|
|||||||
interface TriggersListProps {
|
interface TriggersListProps {
|
||||||
triggers: Trigger[];
|
triggers: Trigger[];
|
||||||
automations: NormalisedAutomation;
|
automations: NormalisedAutomation;
|
||||||
enabledAutomations?: boolean;
|
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) {
|
export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) {
|
||||||
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
const [formState, setFormState] = useState<FormState>({ isOpen: false, trigger: undefined });
|
||||||
const { refetch } = useAutomationSettings();
|
const { refetch } = useAutomationSettings();
|
||||||
|
const { setLocation } = useAppSettingsNavigation();
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const openNewForm = () => setFormState({ isOpen: true });
|
const openNewForm = () => setFormState({ isOpen: true });
|
||||||
@@ -50,6 +50,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
|||||||
};
|
};
|
||||||
|
|
||||||
const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]);
|
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
|
// there is no point letting user creating a trigger if there are no automations
|
||||||
const canAdd = Object.keys(automations).length > 0;
|
const canAdd = Object.keys(automations).length > 0;
|
||||||
@@ -66,22 +70,28 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Panel.SubHeader>
|
<Panel.SubHeader>
|
||||||
Manage triggers
|
Global triggers
|
||||||
<Button disabled={!canAdd} onClick={openNewForm}>
|
<Button disabled={!canAdd} onClick={openNewForm}>
|
||||||
New <IoAdd />
|
New <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
</Panel.SubHeader>
|
</Panel.SubHeader>
|
||||||
<Panel.Divider />
|
<Panel.Divider />
|
||||||
<Panel.Section>
|
<Panel.Section>
|
||||||
{enabledAutomations === false && (
|
<Panel.Description>
|
||||||
<Info>
|
Triggers are managed from the automation itself. This list is for naming them, or for pointing several
|
||||||
Automations are disabled. You can still manage triggers here, but they will not run until enabled.
|
differently named triggers at the same automation.
|
||||||
</Info>
|
</Panel.Description>
|
||||||
)}
|
|
||||||
{duplicates && (
|
{duplicates && (
|
||||||
<Panel.Error>
|
<Panel.Error>
|
||||||
You have created multiple links between the same trigger and automation which can cause performance
|
You have created multiple links between the same trigger and automation. Duplicate combinations will only
|
||||||
issues.
|
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.Error>
|
||||||
)}
|
)}
|
||||||
<Panel.Table>
|
<Panel.Table>
|
||||||
@@ -99,31 +109,32 @@ export default function TriggersList({ triggers, automations, enabledAutomations
|
|||||||
title='No triggers yet'
|
title='No triggers yet'
|
||||||
description={
|
description={
|
||||||
canAdd
|
canAdd
|
||||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
? '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 add a trigger to decide when it should run.'
|
: 'Create an automation first, then pick the lifecycles it should run on.'
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
canAdd && (
|
canAdd ? (
|
||||||
<Button variant='primary' onClick={openNewForm}>
|
<Button variant='primary' onClick={openNewForm}>
|
||||||
Create trigger <IoAdd />
|
Create trigger <IoAdd />
|
||||||
</Button>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant='primary' onClick={() => setLocation('automation__automations')}>
|
||||||
|
Go to automations
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{triggers.map((trigger, index) => {
|
{triggers.map((trigger, index) => (
|
||||||
return (
|
|
||||||
<Fragment key={trigger.id}>
|
|
||||||
<TriggersListItem
|
<TriggersListItem
|
||||||
|
key={trigger.id}
|
||||||
automations={automations}
|
automations={automations}
|
||||||
trigger={trigger}
|
trigger={trigger}
|
||||||
duplicate={duplicates?.includes(index)}
|
duplicate={duplicates?.includes(index)}
|
||||||
handleEdit={() => openEditForm(trigger)}
|
handleEdit={() => openEditForm(trigger)}
|
||||||
handleDelete={() => handleDelete(trigger.id)}
|
handleDelete={() => handleDelete(trigger.id)}
|
||||||
/>
|
/>
|
||||||
</Fragment>
|
))}
|
||||||
);
|
|
||||||
})}
|
|
||||||
{deleteError && (
|
{deleteError && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>
|
<td colSpan={5}>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface TriggersListItemProps {
|
|||||||
|
|
||||||
export default function TriggersListItem(props: TriggersListItemProps) {
|
export default function TriggersListItem(props: TriggersListItemProps) {
|
||||||
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
const { automations, trigger, duplicate, handleEdit, handleDelete } = props;
|
||||||
|
const automation = automations[trigger.automationId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr data-warn={duplicate}>
|
<tr data-warn={duplicate}>
|
||||||
@@ -31,7 +32,8 @@ export default function TriggersListItem(props: TriggersListItemProps) {
|
|||||||
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
<Tag>{cycles.find((cycle) => cycle.value === trigger.trigger)?.label}</Tag>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<Tag>{automations?.[trigger.automationId]?.title}</Tag>
|
{/* a trigger can outlive the automation it points at, say after a partial project import */}
|
||||||
|
{automation ? <Tag>{automation.title}</Tag> : <Tag variant='warning'>Missing automation</Tag>}
|
||||||
</td>
|
</td>
|
||||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||||
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
<IconButton variant='ghosted-white' aria-label='Edit entry' onClick={handleEdit}>
|
||||||
|
|||||||
@@ -84,11 +84,25 @@ const staticOptions = [
|
|||||||
{
|
{
|
||||||
id: 'automation__automations',
|
id: 'automation__automations',
|
||||||
label: 'Manage automations',
|
label: 'Manage automations',
|
||||||
keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'],
|
keywords: [
|
||||||
|
'osc',
|
||||||
|
'http',
|
||||||
|
'webhook',
|
||||||
|
'integration',
|
||||||
|
'api',
|
||||||
|
'output',
|
||||||
|
'action',
|
||||||
|
'recipe',
|
||||||
|
'example',
|
||||||
|
'preset',
|
||||||
|
'qlab',
|
||||||
|
'vmix',
|
||||||
|
'companion',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'automation__triggers',
|
id: 'automation__triggers',
|
||||||
label: 'Manage triggers',
|
label: 'Global triggers',
|
||||||
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user