From 5c6824d1fdc4c858db50c78881733ff185803ffa Mon Sep 17 00:00:00 2001 From: Carlos Valente Date: Sat, 12 Sep 2026 16:49:15 +0200 Subject: [PATCH] feat(automation): streamline automation management Surface lifecycle and output summaries, guided creation, and clear deletion safeguards in automation settings. --- .../automations-panel/AutomationPanel.tsx | 10 +- .../AutomationSettingsForm.tsx | 3 +- .../AutomationsList.module.scss | 19 ++ .../automations-panel/AutomationsList.tsx | 200 +++++++++++++----- .../DeleteAutomationDialog.tsx | 102 +++++++++ .../panel/automations-panel/TriggersList.tsx | 67 +++--- .../automations-panel/TriggersListItem.tsx | 4 +- .../app-settings/useAppSettingsMenu.tsx | 18 +- 8 files changed, 328 insertions(+), 95 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss create mode 100644 apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx 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..47cfe46a5 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,16 +30,16 @@ export default function AutomationPanel({ location }: PanelBaseProps) { />
- -
-
-
+
+ +
); } diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx index 184b04926..3ae8ed702 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationSettingsForm.tsx @@ -94,7 +94,8 @@ export default function AutomationSettingsForm({ 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..785c4726f --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss @@ -0,0 +1,19 @@ +.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; +} 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..ad09b2395 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,18 +1,25 @@ -import { AutomationDTO, NormalisedAutomation } from 'ontime-types'; -import { Fragment, useState } from 'react'; +import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types'; +import { useMemo, useState } from 'react'; import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5'; -import { deleteAutomation } from '../../../../common/api/automation'; -import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import IconButton from '../../../../common/components/buttons/IconButton'; import Info from '../../../../common/components/info/Info'; import Tag from '../../../../common/components/tag/Tag'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; +import { summariseOutputs } from '../../../../common/utils/automationOutputs'; +import { cx } from '../../../../common/utils/styleUtils'; import * as Panel from '../../panel-utils/PanelUtils'; +import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import AutomationForm from './AutomationForm'; +import { groupTriggersByAutomation, isAutomation } from './automationUtils'; +import DeleteAutomationDialog from './DeleteAutomationDialog'; +import NewAutomationDialog from './NewAutomationDialog'; -const automationPlaceholder: AutomationDTO = { +import style from './AutomationsList.module.scss'; + +const emptyAutomation: AutomationDTO = { title: '', filterRule: 'all', filters: [], @@ -21,37 +28,93 @@ const automationPlaceholder: AutomationDTO = { interface AutomationsListProps { automations: NormalisedAutomation; + triggers: Trigger[]; enabledAutomations?: boolean; isLoading: boolean; } -export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) { +export default function AutomationsList({ + automations, + triggers, + enabledAutomations, + isLoading, +}: AutomationsListProps) { const { refetch } = useAutomationSettings(); - const [automationFormData, setAutomationFormData] = useState(null); - const [deleteError, setDeleteError] = useState(null); + const { setLocation } = useAppSettingsNavigation(); + const [editing, setEditing] = useState(null); + const [isPickingStart, setIsPickingStart] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); - const handleDelete = async (id: string) => { - try { - setDeleteError(null); - await deleteAutomation(id); - } catch (error) { - setDeleteError(maybeAxiosError(error)); - } finally { - refetch(); - } + const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]); + const automationList = Object.values(automations); + + /** a recipe creates the automation itself, so it lands in the list rather than in a form */ + const handleCreated = async () => { + setIsPickingStart(false); + await refetch(); }; - const arrayAutomations = Object.keys(automations); + const handleStartEmpty = () => { + setIsPickingStart(false); + setDeleteTarget(null); + setEditing(emptyAutomation); + }; + + const handleDeleted = async () => { + setDeleteTarget(null); + setEditing(null); + await refetch(); + }; + + const startPicking = () => { + setEditing(null); + setDeleteTarget(null); + setIsPickingStart(true); + }; + + const startEditing = (automation: Automation) => { + setIsPickingStart(false); + setDeleteTarget(null); + setEditing(automation); + }; + + const startDeleting = (automation: Automation) => { + setIsPickingStart(false); + setEditing(null); + setDeleteTarget(automation); + }; return ( - {automationFormData !== null && ( - setAutomationFormData(null)} /> + {editing !== null && ( + setEditing(null)} + /> + )} + {isPickingStart && ( + setIsPickingStart(false)} + onStartEmpty={handleStartEmpty} + onCreated={handleCreated} + /> + )} + {deleteTarget !== null && ( + trigger.automationId === deleteTarget.id)} + onCancel={() => setDeleteTarget(null)} + onDeleted={handleDeleted} + /> )} Manage automations - @@ -60,74 +123,95 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa {enabledAutomations === false && ( - - Automations are disabled. You can still manage automation definitions here, but they will not run until - enabled. + + Automations are off, so nothing in this list will run. + + + )} - + - Title - Trigger rule - Filters - Outputs + Title + Runs on + Filter rule + Sends - {!isLoading && arrayAutomations.length === 0 && ( + {!isLoading && automationList.length === 0 && ( setAutomationFormData(automationPlaceholder)}> - Create automation + } /> )} - {arrayAutomations.map((automationId) => { - if (!Object.hasOwn(automations, automationId)) { - return null; - } + {automationList.map((automation) => { + const lifecycles = lifecyclesByAutomation[automation.id] ?? []; + const outputs = summariseOutputs(automation.outputs); + return ( - - - {automations[automationId].title} - - {automations[automationId].filterRule} - - {automations[automationId].filters.length} - {automations[automationId].outputs.length} - + + {automation.title} + + {lifecycles.length === 0 ? ( + + ) : ( +
+ {lifecycles.map((cycle) => ( + {getLifecycleLabel(cycle)} + ))} +
+ )} + + + {automation.filters.length === 0 ? ( + + ) : ( + {automation.filterRule === 'all' ? 'All filters' : 'Any filter'} + )} + + +
+ {outputs.length === 0 ? ( + No outputs + ) : ( + outputs.map(({ type, label, count }) => ( + {count > 1 ? `${label} ×${count}` : label} + )) + )} +
+ + +
setAutomationFormData(automations[automationId])} + onClick={() => startEditing(automation)} > handleDelete(automationId)} + onClick={() => startDeleting(automation)} > - - - +
+ + ); })} - {deleteError && ( - - - {deleteError} - - - )}
diff --git a/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx new file mode 100644 index 000000000..a26da170a --- /dev/null +++ b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx @@ -0,0 +1,102 @@ +import axios from 'axios'; +import type { Automation, Trigger } from 'ontime-types'; +import { useState } from 'react'; + +import { deleteAutomation } from '../../../../common/api/automation'; +import { maybeAxiosError } from '../../../../common/api/utils'; +import Button from '../../../../common/components/buttons/Button'; +import Dialog from '../../../../common/components/dialog/Dialog'; +import Info from '../../../../common/components/info/Info'; +import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; +import * as Panel from '../../panel-utils/PanelUtils'; + +interface DeleteAutomationDialogProps { + automation: Automation; + /** global triggers pointing at this automation, they are deleted along with it */ + attachedTriggers: Trigger[]; + onCancel: () => void; + onDeleted: () => void; +} + +/** + * Deleting takes the automation's global triggers with it, so say so before it happens rather + * than leaving the user to discover it in the triggers list. + * + * An automation attached to an event is still refused by the server: that reference lives in + * the rundown and removing it is an edit to the show, not to this panel. + */ +export default function DeleteAutomationDialog({ + automation, + attachedTriggers, + onCancel, + onDeleted, +}: DeleteAutomationDialogProps) { + const [error, setError] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [isAttachedToEvent, setIsAttachedToEvent] = useState(false); + + const handleDelete = async () => { + setError(null); + setIsAttachedToEvent(false); + setIsDeleting(true); + try { + await deleteAutomation(automation.id); + onDeleted(); + } catch (error) { + setError(maybeAxiosError(error)); + setIsAttachedToEvent(axios.isAxiosError(error) && error.response?.status === 409); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + Delete {automation.title}? This cannot be undone. + + + {attachedTriggers.length > 0 && ( + + + {attachedTriggers.length === 1 + ? 'Its trigger is deleted with it' + : `Its ${attachedTriggers.length} triggers are deleted with it`} + + {attachedTriggers.map((trigger) => getLifecycleLabel(trigger.trigger)).join(', ')} + + )} + + {error && ( + + Could not delete this automation + {error} + + {isAttachedToEvent + ? 'An automation attached to a single event has to be removed from that event first, in the event editor.' + : 'Try again. If the problem persists, check the network log for details.'} + + + )} +
+ } + footerElements={ + <> + + + + } + /> + ); +} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx index f8c76531b..5e43c9139 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersList.tsx @@ -1,13 +1,13 @@ import { NormalisedAutomation, Trigger } from 'ontime-types'; -import { Fragment, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { IoAdd } from 'react-icons/io5'; import { deleteTrigger } from '../../../../common/api/automation'; import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; -import Info from '../../../../common/components/info/Info'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import * as Panel from '../../panel-utils/PanelUtils'; +import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import { checkDuplicates } from './automationUtils'; import TriggerForm from './TriggerForm'; import TriggersListItem from './TriggersListItem'; @@ -20,13 +20,13 @@ type FormState = { interface TriggersListProps { triggers: Trigger[]; automations: NormalisedAutomation; - enabledAutomations?: boolean; isLoading: boolean; } -export default function TriggersList({ triggers, automations, enabledAutomations, isLoading }: TriggersListProps) { +export default function TriggersList({ triggers, automations, isLoading }: TriggersListProps) { const [formState, setFormState] = useState({ isOpen: false, trigger: undefined }); const { refetch } = useAutomationSettings(); + const { setLocation } = useAppSettingsNavigation(); const [deleteError, setDeleteError] = useState(null); const openNewForm = () => setFormState({ isOpen: true }); @@ -50,6 +50,10 @@ export default function TriggersList({ triggers, automations, enabledAutomations }; const duplicates = useMemo(() => checkDuplicates(triggers), [triggers]); + const orphans = useMemo( + () => triggers.filter((trigger) => !Object.hasOwn(automations, trigger.automationId)).length, + [triggers, automations], + ); // there is no point letting user creating a trigger if there are no automations const canAdd = Object.keys(automations).length > 0; @@ -66,22 +70,28 @@ export default function TriggersList({ triggers, automations, enabledAutomations /> )} - Manage triggers + Global triggers - {enabledAutomations === false && ( - - Automations are disabled. You can still manage triggers here, but they will not run until enabled. - - )} + + Triggers are managed from the automation itself. This list is for naming them, or for pointing several + differently named triggers at the same automation. + {duplicates && ( - You have created multiple links between the same trigger and automation which can cause performance - issues. + You have created multiple links between the same trigger and automation. Duplicate combinations will only + fire once per lifecycle event. + + )} + {orphans > 0 && ( + + {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.`} )} @@ -99,31 +109,32 @@ export default function TriggersList({ triggers, automations, enabledAutomations title='No triggers yet' description={ canAdd - ? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.' - : 'Create an automation first, then add a trigger to decide when it should run.' + ? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.' + : 'Create an automation first, then pick the lifecycles it should run on.' } action={ - canAdd && ( + canAdd ? ( + ) : ( + ) } /> )} - {triggers.map((trigger, index) => { - return ( - - openEditForm(trigger)} - handleDelete={() => handleDelete(trigger.id)} - /> - - ); - })} + {triggers.map((trigger, index) => ( + openEditForm(trigger)} + handleDelete={() => handleDelete(trigger.id)} + /> + ))} {deleteError && ( diff --git a/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx b/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx index aecda7a0c..e359f0370 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/TriggersListItem.tsx @@ -16,6 +16,7 @@ interface TriggersListItemProps { export default function TriggersListItem(props: TriggersListItemProps) { const { automations, trigger, duplicate, handleEdit, handleDelete } = props; + const automation = automations[trigger.automationId]; return ( @@ -31,7 +32,8 @@ export default function TriggersListItem(props: TriggersListItemProps) { {cycles.find((cycle) => cycle.value === trigger.trigger)?.label} - {automations?.[trigger.automationId]?.title} + {/* a trigger can outlive the automation it points at, say after a partial project import */} + {automation ? {automation.title} : Missing automation} diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index 8c7b57f0e..1b09e682a 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -84,11 +84,25 @@ const staticOptions = [ { id: 'automation__automations', label: 'Manage automations', - keywords: ['osc', 'http', 'webhook', 'integration', 'api', 'output', 'action'], + keywords: [ + 'osc', + 'http', + 'webhook', + 'integration', + 'api', + 'output', + 'action', + 'recipe', + 'example', + 'preset', + 'qlab', + 'vmix', + 'companion', + ], }, { id: 'automation__triggers', - label: 'Manage triggers', + label: 'Global triggers', keywords: ['lifecycle', 'on load', 'on start', 'on finish', 'on update'], }, ],