diff --git a/apps/client/src/common/constants/timerLifecycle.ts b/apps/client/src/common/constants/timerLifecycle.ts new file mode 100644 index 000000000..c8ec4b9cf --- /dev/null +++ b/apps/client/src/common/constants/timerLifecycle.ts @@ -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.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; +} diff --git a/apps/client/src/common/utils/__tests__/automationOutputs.test.ts b/apps/client/src/common/utils/__tests__/automationOutputs.test.ts new file mode 100644 index 000000000..7ba5fe1a9 --- /dev/null +++ b/apps/client/src/common/utils/__tests__/automationOutputs.test.ts @@ -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']); + }); +}); diff --git a/apps/client/src/common/utils/automationOutputs.ts b/apps/client/src/common/utils/automationOutputs.ts new file mode 100644 index 000000000..d20a49177 --- /dev/null +++ b/apps/client/src/common/utils/automationOutputs.ts @@ -0,0 +1,32 @@ +import type { AutomationOutput } from 'ontime-types'; + +const outputLabels: Record = { + 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(); + + 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 })); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss index 0567f45ea..d258c04b3 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.module.scss @@ -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; } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx index b5966877c..a0496c4e8 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationForm.tsx @@ -1,6 +1,7 @@ import { Automation, AutomationDTO, + AutomationFilter, HTTPOutput, OSCOutput, OntimeAction, @@ -8,14 +9,15 @@ import { 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 { 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'; @@ -26,8 +28,9 @@ import Tag from '../../../../common/components/tag/Tag'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useCustomFields from '../../../../common/hooks-query/useCustomFields'; import { startsWithHttp } from '../../../../common/utils/regex'; +import { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../../panel-utils/PanelUtils'; -import { isAutomation, makeFieldList } from './automationUtils'; +import { isAutomation, makeFieldList, operators } from './automationUtils'; import OntimeActionForm from './OntimeActionForm'; import TemplateInput from './template-input/TemplateInput'; @@ -36,6 +39,11 @@ 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 }; + interface AutomationFormProps { automation: Automation | AutomationDTO; onClose: () => void; @@ -47,6 +55,13 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr const { refetch } = useAutomationSettings(); const fieldList = useMemo(() => makeFieldList(data), [data]); + /** + * 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>({}); + const feedbackTimers = useRef>>({}); + const { control, handleSubmit, @@ -93,6 +108,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 +145,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,36 +161,39 @@ 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) }); } }; @@ -183,6 +224,21 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } }; + /** 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 && isValid; return ( @@ -191,6 +247,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr onClose={onClose} showBackdrop showCloseButton + size='wide' title={isEdit ? 'Edit automation' : 'Create automation'} bodyElements={
@@ -211,83 +268,77 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr

Filters (optional)

+ + Without filters the outputs are sent every time the automation is triggered. +
- + {fieldFilters.length > 1 && ( + + )} {fieldFilters.map((field, index) => { - const key = `filters.${index}.field.${field.id}`; + const description = describeFilter(index); return ( -
- - -
-   -
- removeFilter(index)} - > - - -
+
+
+ Filter + {description} + removeFilter(index)} + > + + +
+
+ +
); @@ -309,6 +360,13 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr read the docs + {fieldOutputs.length === 0 && ( + + )} + {fieldOutputs.map((output, index) => { if (isOSCOutput(output)) { const rowErrors = errors.outputs?.[index] as @@ -321,75 +379,66 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr | undefined; return ( -
- OSC -
- - - - -
-   - - - removeOutput(index)} - > - - - -
-
-
+ handleTestOSCOutput(index, output.id)} + onDelete={() => removeOutput(index)} + > + + + + + ); } + if (isHTTPOutput(output)) { const rowErrors = errors.outputs?.[index] as | { @@ -397,42 +446,31 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } | undefined; return ( -
- HTTP -
- -
-   - - - removeOutput(index)} - > - - - -
-
-
+ handleTestHTTPOutput(index, output.id)} + onDelete={() => removeOutput(index)} + > + + ); } @@ -447,8 +485,14 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr } | undefined; return ( -
- Ontime action + handleTestOntimeAction(index, output.id)} + onDelete={() => removeOutput(index)} + > -   - - - removeOutput(index)} - > - - - - -
+ /> + ); } return null; })} - - - - - +
+ } + 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 + +
} @@ -503,3 +549,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 ( +
+
+ {label} + {summary} + {testState?.status === 'ok' && ( + + + {testState.message} + + )} + + + + +
+ {testState?.status === 'error' && {testState.message}} +
{children}
+
+ ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx index 59a829b8d..ca52375f9 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationPanel.tsx @@ -30,7 +30,12 @@ export default function AutomationPanel({ location }: PanelBaseProps) { />
- +
Control Ontime and share its data with external systems in your workflow. - - Automations allow Ontime to send its data on lifecycle triggers. + - An automation is what to send: OSC and HTTP messages, or an action inside Ontime. + - A trigger is when to send it. Triggers for a single event live in the event editor. - OSC Input tells Ontime to listen to messages on the specific port. See the docs diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss new file mode 100644 index 000000000..3137d0529 --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss @@ -0,0 +1,3 @@ +.muted { + color: $muted-gray; +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index a80935057..ad7b6a8b9 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -1,5 +1,5 @@ -import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; -import { Fragment, useState } from 'react'; +import { AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; +import { Fragment, useMemo, useState } from 'react'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; import { deleteAutomation } from '../../../../common/api/automation'; @@ -8,9 +8,14 @@ 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 } from './automationUtils'; + +import style from './AutomationsList.module.scss'; const automationPlaceholder: AutomationDTO = { title: '', @@ -21,11 +26,12 @@ 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(null); const [deleteError, setDeleteError] = useState(null); @@ -41,6 +47,8 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa } }; + const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); + const arrayAutomations = Object.keys(automations); return ( @@ -69,10 +77,10 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa - Title - Trigger rule - Filters - Outputs + Title + Runs on + Filter rule + Sends @@ -82,9 +90,11 @@ 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={ - + + + } /> )} @@ -92,20 +102,42 @@ 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 ( - {automations[automationId].title} + {automation.title} + + {lifecycles.length === 0 ? ( + Never runs + ) : ( + lifecycles.map((cycle) => {getLifecycleLabel(cycle)}) + )} + - {automations[automationId].filterRule} + {automation.filters.length === 0 ? ( + + ) : ( + {automation.filterRule === 'all' ? 'All filters' : 'Any filter'} + )} - {automations[automationId].filters.length} - {automations[automationId].outputs.length} + + {outputs.length === 0 ? ( + No outputs + ) : ( + outputs.map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + )) + )} + setAutomationFormData(automations[automationId])} + onClick={() => setAutomationFormData(automation)} > diff --git a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx index 11f6413b4..e2cd556ec 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/OntimeActionForm.tsx @@ -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) { const [selectedAction, setSelectedAction] = useState(value); const handleSetAction = (value: OntimeActionKey) => { @@ -41,7 +40,7 @@ export default function OntimeActionForm({ }; return ( -
+ <>