fix(automation): review fixes across the automations rework

Reviewing the branch turned up defects, several of them worse than the
problems the original commits set out to solve.

Destructive: deleting an automation removed its global triggers and only then
discovered a rundown event also referenced it. The delete failed, the user
cancelled, and their triggers were gone for good. The server reports trigger
references before event ones, so this was reachable whenever both existed.
Deleted triggers are now recreated when the delete is refused.

Silently wrong: a template read the project from disk while writes are
debounced by three seconds, so "make some automations, save them as a
template" could produce a template without them. Verified by hand: an
automation created milliseconds earlier now appears.

The demo shipped half a pair. The danger warning set a stage message visible
and nothing ever cleared it, so from the first event that hit its danger
window the message covered the countdown for the rest of the session. The
recipe library ships the clearing counterpart; the demo now does too, and the
whole cycle is verified against a running server.

Flood control undid itself. resetAutomationLogState ran on every onLoad, and
roll mode loads at every event boundary, so the "logging suppressed" notice
was re-emitted once per cue: exactly the flooding it exists to prevent. It
also wiped the throttle for onLoad and onStop immediately before writing to
it. Reset now happens on stop only, outside the early returns that made the
first attempt at this a no-op.

Trigger reconciliation diffed a mount-time selection against a live prop.
Settings are polled, so a trigger created in another tab while the form was
open would be deleted by a save that never saw it. It now diffs against the
snapshot, and says which triggers a save will remove rather than removing
several same-lifecycle triggers silently.

Also: a rundowns template no longer carries a phantom empty rundown from
makeNewProject; the partial-duplicate endpoint returns the name it actually
used, since collisions get renamed; the template flow invalidates the project
list rather than relying on a refetch on mount; the last-fired label drops to
a one minute cadence instead of holding a 1Hz timer per automation forever;
e2e cleanup moved to afterEach so a mid-test failure stops leaking state.

The e2e spec has now been run against a real server, green on both the demo
project and a blank one, leaving no residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
Claude
2026-08-08 16:43:29 +00:00
parent 32734de679
commit cdde054b14
18 changed files with 249 additions and 77 deletions
+1 -1
View File
@@ -132,7 +132,7 @@ export async function partialDuplicateProject(
filename: string, filename: string,
newFilename: string, newFilename: string,
sections: TemplateSection[], sections: TemplateSection[],
): Promise<MessageResponse> { ): Promise<{ filename: string }> {
const url = `${dbPath}/${filename}/partial-duplicate`; const url = `${dbPath}/${filename}/partial-duplicate`;
const decodedUrl = decodeURIComponent(url); const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, { const res = await axios.post(decodedUrl, {
+1 -1
View File
@@ -28,6 +28,7 @@ import {
} from '../api/constants'; } from '../api/constants';
import { invalidateAllCaches } from '../api/utils'; import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient'; import { ontimeQueryClient } from '../queryClient';
import { addAutomationFired } from '../stores/automationFired';
import { import {
getClientId, getClientId,
getClientName, getClientName,
@@ -37,7 +38,6 @@ import {
setClients, setClients,
} from '../stores/clientStore'; } from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore'; import { addDialog } from '../stores/dialogStore';
import { addAutomationFired } from '../stores/automationFired';
import { addLog } from '../stores/logger'; import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime'; import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
@@ -15,7 +15,13 @@ import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form'; import { useFieldArray, useForm } from 'react-hook-form';
import { IoAdd, IoCheckmark, IoTrash } from 'react-icons/io5'; import { IoAdd, IoCheckmark, IoTrash } from 'react-icons/io5';
import { addAutomation, addTrigger, deleteTrigger, 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';
@@ -30,7 +36,6 @@ 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 { startsWithHttp } from '../../../../common/utils/regex';
import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import { cycles, isAutomation, makeFieldList, operators } from './automationUtils'; import { cycles, isAutomation, makeFieldList, operators } from './automationUtils';
import OntimeActionForm from './OntimeActionForm'; import OntimeActionForm from './OntimeActionForm';
@@ -64,12 +69,17 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
/** /**
* Triggers are a separate entity, so they live outside the form state. * Triggers are a separate entity, so they live outside the form state.
* We resolve the current selection once and reconcile it against the server on save. *
* We snapshot the automation's triggers when the form opens and reconcile against that
* snapshot, never against the live prop: settings are polled, so a trigger created
* elsewhere while this form is open must not be deleted by a save that never saw it.
*/ */
const [initialCycles] = useState<TimerLifeCycle[]>(() => const [initialTriggers] = useState<Trigger[]>(() =>
isAutomation(automation) isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
? triggers.filter((trigger) => trigger.automationId === automation.id).map((trigger) => trigger.trigger) );
: [], const initialCycles = useMemo(
() => Array.from(new Set(initialTriggers.map((trigger) => trigger.trigger))),
[initialTriggers],
); );
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles); const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(initialCycles);
/** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */ /** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */
@@ -84,6 +94,12 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle])); setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
}; };
/**
* A lifecycle can carry several differently named triggers, which the chips collapse into one.
* Unchecking it removes all of them, so say which ones rather than deleting them quietly.
*/
const triggersToRemove = initialTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
/** /**
* Test results are keyed by the field array id rather than the index: * 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 * removing an output shifts every index after it, which would leave feedback on the wrong row
@@ -229,17 +245,16 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
/** /**
* Reconciles the lifecycle selection against the global triggers. * Reconciles the lifecycle selection against the global triggers.
* Runs after the automation itself is saved: a new automation has no id until then. * Runs after the automation itself is saved: a new automation has no id until then.
*
* Both sides are diffed against the mount-time snapshot, so this only ever removes
* triggers the user could actually see when they made the change.
*/ */
const syncTriggers = async (automationId: string, title: string) => { const syncTriggers = async (automationId: string, title: string) => {
const existing = triggers.filter((trigger) => trigger.automationId === automationId); for (const trigger of triggersToRemove) {
const toAdd = selectedCycles.filter((cycle) => !existing.some((trigger) => trigger.trigger === cycle));
const toRemove = existing.filter((trigger) => !selectedCycles.includes(trigger.trigger));
for (const trigger of toRemove) {
await deleteTrigger(trigger.id); await deleteTrigger(trigger.id);
} }
const toAdd = selectedCycles.filter((cycle) => !initialCycles.includes(cycle));
for (const cycle of toAdd) { for (const cycle of toAdd) {
const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle; const label = cycles.find(({ value }) => value === cycle)?.label ?? cycle;
await addTrigger({ title: `${title}${label}`, trigger: cycle, automationId }); await addTrigger({ title: `${title}${label}`, trigger: cycle, automationId });
@@ -349,6 +364,13 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
to send on every tick. to send on every tick.
</Panel.Description> </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>
@@ -513,12 +535,7 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
</label> </label>
<label className={style.spanFull}> <label className={style.spanFull}>
Arguments Arguments
<TemplateInput <TemplateInput {...register(`outputs.${index}.args`)} value={output.args} fluid placeholder='1' />
{...register(`outputs.${index}.args`)}
value={output.args}
fluid
placeholder='1'
/>
<Panel.Error>{rowErrors?.args?.message}</Panel.Error> <Panel.Error>{rowErrors?.args?.message}</Panel.Error>
</label> </label>
</OutputCard> </OutputCard>
@@ -670,7 +687,7 @@ function OutputCard({ label, kindClass, summary, testState, onTest, onDelete, ch
</IconButton> </IconButton>
</div> </div>
{testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>} {testState?.status === 'error' && <Panel.Error className={style.testError}>{testState.message}</Panel.Error>}
<div className={cx([style.cardBody])}>{children}</div> <div className={style.cardBody}>{children}</div>
</div> </div>
); );
} }
@@ -100,10 +100,12 @@ export default function AutomationSettingsForm({
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</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>
<Info.Footer> <Info.Footer>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink> <Panel.InlineElements relation='inner'>
<Button variant='ghosted' size='small' onClick={() => setLocation('network__log')}> <ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
Automations report to the event log <Button variant='ghosted' size='small' onClick={() => setLocation('network__log')}>
</Button> Automations report to the event log
</Button>
</Panel.InlineElements>
</Info.Footer> </Info.Footer>
</Info> </Info>
</Panel.Section> </Panel.Section>
@@ -2,6 +2,7 @@ import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime
import { Fragment, useEffect, useMemo, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoShareOutline, IoSparklesOutline, IoTrash } from 'react-icons/io5'; import { IoAdd, IoPencil, IoShareOutline, IoSparklesOutline, IoTrash } from 'react-icons/io5';
import { PROJECT_LIST } from '../../../../common/api/constants';
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 { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
@@ -10,13 +11,14 @@ import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle'; import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings'; import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList'; import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import { ontimeQueryClient } from '../../../../common/queryClient';
import { useAutomationFired } from '../../../../common/stores/automationFired'; import { useAutomationFired } from '../../../../common/stores/automationFired';
import { summariseOutputs } from '../../../../common/utils/automationOutputs'; import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils'; import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation'; import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectPartialCloneForm from '../project-panel/ProjectPartialCloneForm'; import ProjectPartialCloneForm from '../project-panel/ProjectPartialCloneForm';
import AutomationForm from './AutomationForm'; import AutomationForm from './AutomationForm';
import { groupTriggersByAutomation } from './automationUtils'; import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog'; import DeleteAutomationDialog from './DeleteAutomationDialog';
import RecipeLibraryModal from './recipes/RecipeLibraryModal'; import RecipeLibraryModal from './recipes/RecipeLibraryModal';
@@ -36,7 +38,12 @@ interface AutomationsListProps {
isLoading: boolean; isLoading: boolean;
} }
export default function AutomationsList({ automations, triggers, 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 [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [showRecipes, setShowRecipes] = useState(false); const [showRecipes, setShowRecipes] = useState(false);
@@ -73,6 +80,9 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
<Panel.Card> <Panel.Card>
{automationFormData !== null && ( {automationFormData !== null && (
<AutomationForm <AutomationForm
// the form snapshots the automation's lifecycles on mount, so it must never be
// reused across two different automations
key={isAutomation(automationFormData) ? automationFormData.id : 'new'}
automation={automationFormData} automation={automationFormData}
triggers={triggers} triggers={triggers}
onClose={() => setAutomationFormData(null)} onClose={() => setAutomationFormData(null)}
@@ -90,6 +100,7 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
blockingTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)} blockingTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)} onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted} onDeleted={handleDeleted}
onRefetch={refetch}
/> />
)} )}
{showTemplateForm && ( {showTemplateForm && (
@@ -97,7 +108,11 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
fileName={lastLoadedProject} fileName={lastLoadedProject}
preselected={['automation']} preselected={['automation']}
onClose={() => setShowTemplateForm(false)} onClose={() => setShowTemplateForm(false)}
onCreated={async () => setLocation('project__list')} onCreated={async () => {
// do not rely on the project panel refetching when it mounts
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
setLocation('project__list');
}}
/> />
)} )}
<Panel.SubHeader> <Panel.SubHeader>
@@ -244,13 +259,17 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
function LastFired({ at }: { at?: number }) { function LastFired({ at }: { at?: number }) {
const [, setTick] = useState(0); const [, setTick] = useState(0);
// once the label counts whole minutes it only changes once a minute, so drop to that
// cadence instead of holding a 1Hz timer per automation for the life of the panel
const isRecent = at !== undefined && Date.now() - at < millisPerMinute;
useEffect(() => { useEffect(() => {
if (at === undefined) { if (at === undefined) {
return; return;
} }
const interval = setInterval(() => setTick((value) => value + 1), 1000); const interval = setInterval(() => setTick((value) => value + 1), isRecent ? 1000 : millisPerMinute);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [at]); }, [at, isRecent]);
if (at === undefined) { if (at === undefined) {
return <span className={style.muted}></span>; return <span className={style.muted}></span>;
@@ -259,6 +278,8 @@ function LastFired({ at }: { at?: number }) {
return <span className={style.lastFired}>{formatElapsed(Date.now() - at)}</span>; return <span className={style.lastFired}>{formatElapsed(Date.now() - at)}</span>;
} }
const millisPerMinute = 60 * 1000;
function formatElapsed(elapsed: number): string { function formatElapsed(elapsed: number): string {
const seconds = Math.max(0, Math.floor(elapsed / 1000)); const seconds = Math.max(0, Math.floor(elapsed / 1000));
if (seconds < 5) { if (seconds < 5) {
@@ -1,7 +1,7 @@
import type { Automation, Trigger } from 'ontime-types'; import type { Automation, Trigger } from 'ontime-types';
import { useState } from 'react'; import { useState } from 'react';
import { deleteAutomation, deleteTrigger } from '../../../../common/api/automation'; import { addTrigger, deleteAutomation, 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 Dialog from '../../../../common/components/dialog/Dialog'; import Dialog from '../../../../common/components/dialog/Dialog';
@@ -15,6 +15,8 @@ interface DeleteAutomationDialogProps {
blockingTriggers: Trigger[]; blockingTriggers: Trigger[];
onCancel: () => void; onCancel: () => void;
onDeleted: () => void; onDeleted: () => void;
/** pulls fresh settings after a rollback, so the restored triggers carry their new ids */
onRefetch: () => Promise<unknown>;
} }
/** /**
@@ -29,22 +31,49 @@ export default function DeleteAutomationDialog({
blockingTriggers, blockingTriggers,
onCancel, onCancel,
onDeleted, onDeleted,
onRefetch,
}: DeleteAutomationDialogProps) { }: DeleteAutomationDialogProps) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
/** set only in the rare case where we could not undo our own trigger deletions */
const [removeFailed, setRemoveFailed] = useState(false);
/**
* Puts back triggers we deleted before the automation turned out to be undeletable.
* The new triggers get fresh ids, which nothing outside this panel holds on to
*/
const restoreTriggers = async (removed: Trigger[]) => {
for (const trigger of removed) {
try {
await addTrigger({ title: trigger.title, trigger: trigger.trigger, automationId: trigger.automationId });
} catch (_error) {
setRemoveFailed(true);
}
}
};
const handleDelete = async () => { const handleDelete = async () => {
setError(null); setError(null);
setIsDeleting(true); setIsDeleting(true);
// the server reports the trigger references first, so an event reference only surfaces
// once the triggers are gone. Track them so a refusal does not cost the user their triggers
const removed: Trigger[] = [];
try { try {
for (const trigger of blockingTriggers) { for (const trigger of blockingTriggers) {
await deleteTrigger(trigger.id); await deleteTrigger(trigger.id);
removed.push(trigger);
} }
await deleteAutomation(automation.id); await deleteAutomation(automation.id);
onDeleted(); onDeleted();
} catch (error) { } catch (error) {
setError(maybeAxiosError(error)); setError(maybeAxiosError(error));
if (removed.length > 0) {
await restoreTriggers(removed);
// the restored triggers have new ids, the dialog needs them before a second attempt
await onRefetch();
}
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
@@ -71,7 +100,9 @@ export default function DeleteAutomationDialog({
: `${blockingTriggers.length} triggers will be deleted with it`} : `${blockingTriggers.length} triggers will be deleted with it`}
</Info.Title> </Info.Title>
<Info.Body> <Info.Body>
{blockingTriggers.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`).join(', ')} {blockingTriggers
.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`)
.join(', ')}
</Info.Body> </Info.Body>
</Info> </Info>
)} )}
@@ -82,6 +113,9 @@ export default function DeleteAutomationDialog({
<Info.Body>{error}</Info.Body> <Info.Body>{error}</Info.Body>
<Info.Footer> <Info.Footer>
Automations attached to a single event have to be removed from that event first, in the event editor. Automations attached to a single event have to be removed from that event first, in the event editor.
{removeFailed
? ' Your triggers could not be put back, you will need to recreate them.'
: ' Nothing was deleted.'}
</Info.Footer> </Info.Footer>
</Info> </Info>
)} )}
@@ -11,7 +11,12 @@ import { getLifecycleLabel } from '../../../../../common/constants/timerLifecycl
import { summariseOutputs } from '../../../../../common/utils/automationOutputs'; import { summariseOutputs } from '../../../../../common/utils/automationOutputs';
import { isOntimeCloud } from '../../../../../externals'; import { isOntimeCloud } from '../../../../../externals';
import * as Panel from '../../../panel-utils/PanelUtils'; import * as Panel from '../../../panel-utils/PanelUtils';
import { automationRecipes, recipeCategoryLabels, recipeCategoryOrder, type AutomationRecipe } from './automationRecipes'; import {
automationRecipes,
recipeCategoryLabels,
recipeCategoryOrder,
type AutomationRecipe,
} from './automationRecipes';
import { installRecipe } from './recipeUtils'; import { installRecipe } from './recipeUtils';
import style from './RecipeLibraryModal.module.scss'; import style from './RecipeLibraryModal.module.scss';
@@ -76,7 +76,6 @@ export const automationRecipes: AutomationRecipe[] = [
title: 'Hide the stage message on finish', title: 'Hide the stage message on finish',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
// an empty text means "leave the text alone", so this only changes visibility
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }], outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
}, },
triggers: [Cycle.onFinish], triggers: [Cycle.onFinish],
@@ -84,14 +83,17 @@ export const automationRecipes: AutomationRecipe[] = [
{ {
id: 'obs-record', id: 'obs-record',
title: 'OBS — start recording when the show starts', title: 'OBS — start recording when the show starts',
description: 'Calls the OBS websocket HTTP bridge when the first event is loaded.', description:
'Presses a Companion button bound to OBS. obs-websocket speaks WebSocket rather than HTTP, so Ontime reaches OBS through Companion or a similar bridge.',
category: 'video', category: 'video',
docsUrl: 'https://docs.getontime.no/api/automation/',
needsSetup: true, needsSetup: true,
automation: { automation: {
title: 'OBS start recording', title: 'OBS start recording',
filterRule: 'all', filterRule: 'all',
filters: [], filters: [],
outputs: [{ type: 'http', url: 'http://127.0.0.1:4455/api/StartRecord' }], // Companion HTTP API: /api/location/<page>/<row>/<column>/press
outputs: [{ type: 'http', url: 'http://127.0.0.1:8888/api/location/1/0/1/press' }],
}, },
triggers: [Cycle.onLoad], triggers: [Cycle.onLoad],
}, },
@@ -163,9 +163,7 @@ export default function ProjectListItem({
)} )}
</tr> </tr>
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />} {showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
{showTemplateForm && ( {showTemplateForm && <ProjectPartialCloneForm onClose={handleCancel} onCreated={onRefetch} fileName={filename} />}
<ProjectPartialCloneForm onClose={handleCancel} onCreated={onRefetch} fileName={filename} />
)}
<Dialog <Dialog
isOpen={isDeleteOpen} isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)} onClose={() => setDeleteOpen(false)}
@@ -29,7 +29,8 @@ const sectionCopy: Array<{ key: TemplateSection; title: string; body: string }>
interface ProjectPartialCloneFormProps { interface ProjectPartialCloneFormProps {
onClose: () => void; onClose: () => void;
onCreated: () => Promise<void>; /** receives the name the file was actually given, which can differ if the name was taken */
onCreated: (createdFilename: string) => Promise<void>;
fileName: string; fileName: string;
/** sections switched on when the form opens */ /** sections switched on when the form opens */
preselected?: TemplateSection[]; preselected?: TemplateSection[];
@@ -76,8 +77,8 @@ export default function ProjectPartialCloneForm({
try { try {
setError(null); setError(null);
await partialDuplicateProject(fileName, values.filename, sections); const { filename } = await partialDuplicateProject(fileName, values.filename, sections);
await onCreated(); await onCreated(filename);
onClose(); onClose();
} catch (error) { } catch (error) {
setError(maybeAxiosError(error)); setError(maybeAxiosError(error));
@@ -137,8 +138,9 @@ export default function ProjectPartialCloneForm({
<Info> <Info>
<Info.Body> <Info.Body>
Automations attached to individual events live in the rundown. A template without rundowns carries the Automations attached to individual events live in the rundown, so the two halves travel separately. A
automations themselves, but not the events that point at them. template with only automations leaves behind the events that point at them, and a template with only
rundowns carries events pointing at automations it does not include.
</Info.Body> </Info.Body>
</Info> </Info>
</Panel.Section> </Panel.Section>
@@ -702,18 +702,44 @@ describe('automation reporting', () => {
expect(logSpy.mock.calls[0][1]).toContain('suppressed'); expect(logSpy.mock.calls[0][1]).toContain('suppressed');
}); });
it('shows the suppression notice again after a reload', async () => { it('does not repeat the suppression notice on every load', async () => {
await bind('reporting-clock', TimerLifeCycle.onClock); await bind('reporting-clock', TimerLifeCycle.onClock);
triggerAutomations(TimerLifeCycle.onClock); triggerAutomations(TimerLifeCycle.onClock);
logSpy.mockClear(); logSpy.mockClear();
// onLoad bookends a run and resets the reporting state // roll mode loads at every event boundary, and an operator steps through cues by hand.
triggerAutomations(TimerLifeCycle.onLoad); // Re-notifying on each one would be the very flooding the notice exists to prevent
for (let i = 0; i < 5; i++) {
triggerAutomations(TimerLifeCycle.onLoad);
triggerAutomations(TimerLifeCycle.onClock);
}
expect(logSpy.mock.calls.filter(([, message]) => String(message).includes('suppressed'))).toHaveLength(0);
});
it('shows the suppression notice again after a stop', async () => {
await bind('reporting-clock', TimerLifeCycle.onClock);
triggerAutomations(TimerLifeCycle.onClock);
logSpy.mockClear();
// a stop ends the run, the next one reports from scratch
triggerAutomations(TimerLifeCycle.onStop);
triggerAutomations(TimerLifeCycle.onClock); triggerAutomations(TimerLifeCycle.onClock);
expect(logSpy.mock.calls.some(([, message]) => String(message).includes('suppressed'))).toBe(true); expect(logSpy.mock.calls.some(([, message]) => String(message).includes('suppressed'))).toBe(true);
}); });
it('throttles repeated loads, which the reset used to defeat', async () => {
vi.useFakeTimers();
await bind('reporting-load', TimerLifeCycle.onLoad);
logSpy.mockClear();
triggerAutomations(TimerLifeCycle.onLoad);
triggerAutomations(TimerLifeCycle.onLoad);
expect(logSpy).toHaveBeenCalledTimes(1);
});
it('collapses repeats of the same automation and cycle inside the throttle window', async () => { it('collapses repeats of the same automation and cycle inside the throttle window', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
await bind('reporting-danger', TimerLifeCycle.onDanger); await bind('reporting-danger', TimerLifeCycle.onDanger);
@@ -41,9 +41,9 @@ const lastLoggedAt = new Map<string, number>();
const lastReportedAt = new Map<string, number>(); const lastReportedAt = new Map<string, number>();
/** /**
* Clears the per-load logging state. * Clears the reporting state.
* Called when the runtime loads or stops so the suppression notice is shown again * Called when the runtime stops, so the next run reports from scratch rather than
* for the next show rather than once per server lifetime * inheriting throttles from the last one
*/ */
export function resetAutomationLogState() { export function resetAutomationLogState() {
suppressionNotices.clear(); suppressionNotices.clear();
@@ -55,15 +55,24 @@ export function resetAutomationLogState() {
* Exposes a method for triggering actions based on a TimerLifeCycle event * Exposes a method for triggering actions based on a TimerLifeCycle event
*/ */
export function triggerAutomations(cycle: TimerLifeCycle) { export function triggerAutomations(cycle: TimerLifeCycle) {
// a load or a stop bookends a run: start reporting from scratch so the next show
// gets its own suppression notice rather than inheriting one from the last
if (cycle === TimerLifeCycle.onLoad || cycle === TimerLifeCycle.onStop) {
resetAutomationLogState();
}
if (!getAutomationsEnabled()) { if (!getAutomationsEnabled()) {
return; return;
} }
fireForCycle(cycle);
// A stop ends a run, so the next one reports from scratch. This deliberately does not
// happen on load: loading is not rare, roll mode loads at every event boundary, and
// resetting there would re-emit the suppression notice once per cue, which is the
// flooding the notice exists to prevent.
// It sits out here because fireForCycle returns early when nothing is bound to onStop,
// which is the common case
if (cycle === TimerLifeCycle.onStop) {
resetAutomationLogState();
}
}
function fireForCycle(cycle: TimerLifeCycle) {
const store = eventStore.poll(); const store = eventStore.poll();
let triggers = getAutomationTriggers(); let triggers = getAutomationTriggers();
+3 -4
View File
@@ -235,16 +235,15 @@ export async function loadDemo(_req: Request, res: Response<MessageResponse | Er
* Creates a template: a new project file containing only the selected sections of an existing one. * Creates a template: a new project file containing only the selected sections of an existing one.
* The result is not loaded, so making a template does not disturb the running show. * The result is not loaded, so making a template does not disturb the running show.
*/ */
export async function partialDuplicateProjectFile(req: Request, res: Response<MessageResponse | ErrorResponse>) { export async function partialDuplicateProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
const { filename } = req.params; const { filename } = req.params;
const { newFilename, sections } = req.body; const { newFilename, sections } = req.body;
try { try {
// the created name can differ from what was asked for, generateUniqueFileName resolves collisions
const created = await projectService.createProjectFromSections(filename, newFilename, sections); const created = await projectService.createProjectFromSections(filename, newFilename, sections);
res.status(201).send({ res.status(201).send({ filename: created });
message: `Created template ${created} from ${filename}`,
});
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
if (message.startsWith('Project file')) { if (message.startsWith('Project file')) {
+3 -1
View File
@@ -75,7 +75,9 @@ export const validateSectionsBody = [
body('sections') body('sections')
.isArray({ min: 1 }) .isArray({ min: 1 })
.withMessage(`Select at least one of: ${templateSections.join(', ')}`) .withMessage(`Select at least one of: ${templateSections.join(', ')}`)
.custom((sections: unknown[]) => sections.every((section) => typeof section === 'string' && isTemplateSection(section))) .custom((sections: unknown[]) =>
sections.every((section) => typeof section === 'string' && isTemplateSection(section)),
)
.withMessage(`Sections must be any of: ${templateSections.join(', ')}`), .withMessage(`Sections must be any of: ${templateSections.join(', ')}`),
requestValidationFunction, requestValidationFunction,
+15
View File
@@ -99,6 +99,12 @@ export const demoDb: DatabaseModel = {
trigger: TimerLifeCycle.onStart, trigger: TimerLifeCycle.onStart,
automationId: 'demo-aux-timer', automationId: 'demo-aux-timer',
}, },
{
id: 'demo-trigger-clear',
title: 'Demo: clear the wrap up warning',
trigger: TimerLifeCycle.onFinish,
automationId: 'demo-clear-message',
},
], ],
automations: { automations: {
'demo-aux-timer': { 'demo-aux-timer': {
@@ -119,6 +125,15 @@ export const demoDb: DatabaseModel = {
// self labelled, so nobody mistakes it for something Ontime does on its own // self labelled, so nobody mistakes it for something Ontime does on its own
outputs: [{ type: 'ontime', action: 'message-set', text: 'Demo automation: please wrap up', visible: true }], outputs: [{ type: 'ontime', action: 'message-set', text: 'Demo automation: please wrap up', visible: true }],
}, },
'demo-clear-message': {
id: 'demo-clear-message',
title: 'Demo: clear the wrap up warning',
filterRule: 'all',
filters: [],
// the pair to the warning above. Without it the message would stay on the stage
// timer for the rest of the session, blanking the countdown on every later event
outputs: [{ type: 'ontime', action: 'message-set', text: '', visible: false }],
},
'demo-osc-example': { 'demo-osc-example': {
id: 'demo-osc-example', id: 'demo-osc-example',
title: 'Demo: OSC to a lighting console (example, not wired up)', title: 'Demo: OSC to a lighting console (example, not wired up)',
@@ -9,7 +9,7 @@ import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js'; import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js'; import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js'; import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js'; import { flushPendingWrites, getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js'; import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { logger } from '../../classes/Logger.js'; import { logger } from '../../classes/Logger.js';
import { makeNewProject } from '../../models/dataModel.js'; import { makeNewProject } from '../../models/dataModel.js';
@@ -342,6 +342,10 @@ export async function createProjectFromSections(
throw new Error('At least one section must be selected'); throw new Error('At least one section must be selected');
} }
// writes are debounced, and the natural flow here is "make some automations, then save them
// as a template". Without this the template silently misses anything from the last few seconds
await flushPendingWrites();
const fileData = await parseJsonFile(projectFilePath); const fileData = await parseJsonFile(projectFilePath);
const { data } = parseDatabaseModel(fileData); const { data } = parseDatabaseModel(fileData);
@@ -355,6 +359,13 @@ export async function createProjectFromSections(
} }
const template = safeMerge(makeNewProject(), patch); const template = safeMerge(makeNewProject(), patch);
// makeNewProject seeds an empty rundown and safeMerge merges rundowns by key, so without
// this the template would ship a phantom "Default" rundown alongside the real ones
if (patch.rundowns !== undefined) {
template.rundowns = patch.rundowns;
}
const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(newFilename)); const fileNameWithExtension = generateUniqueFileName(publicDir.projectsDir, ensureJsonExtension(newFilename));
await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8'); await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8');
@@ -3,6 +3,7 @@ import { writeFile } from 'fs/promises';
import { OntimeView, TimerLifeCycle } from 'ontime-types'; import { OntimeView, TimerLifeCycle } from 'ontime-types';
import { Mock } from 'vitest'; import { Mock } from 'vitest';
import { makeNewProject } from '../../../models/dataModel.js';
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js'; import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
import { import {
createProjectFromSections, createProjectFromSections,
@@ -11,7 +12,6 @@ import {
renameProjectFile, renameProjectFile,
} from '../ProjectService.js'; } from '../ProjectService.js';
import { doesProjectExist, parseJsonFile } from '../projectServiceUtils.js'; import { doesProjectExist, parseJsonFile } from '../projectServiceUtils.js';
import { makeNewProject } from '../../../models/dataModel.js';
// stop the database loading from initiating // stop the database loading from initiating
vi.mock('../../../setup/loadDb.js', () => { vi.mock('../../../setup/loadDb.js', () => {
@@ -100,16 +100,20 @@ describe('createProjectFromSections', () => {
(doesProjectExist as Mock).mockReturnValue('/projects/source.json'); (doesProjectExist as Mock).mockReturnValue('/projects/source.json');
(parseJsonFile as Mock).mockResolvedValue({ (parseJsonFile as Mock).mockResolvedValue({
...makeNewProject(), ...makeNewProject(),
urlPresets: [ urlPresets: [{ target: OntimeView.Timer, enabled: true, alias: 'from-source', search: '', displayInNav: false }],
{ target: OntimeView.Timer, enabled: true, alias: 'from-source', search: '', displayInNav: false },
],
automation: { automation: {
enabledAutomations: true, enabledAutomations: true,
enabledOscIn: false, enabledOscIn: false,
oscPortIn: 8888, oscPortIn: 8888,
triggers: [{ id: 't1', title: 'on start', trigger: TimerLifeCycle.onStart, automationId: 'a1' }], triggers: [{ id: 't1', title: 'on start', trigger: TimerLifeCycle.onStart, automationId: 'a1' }],
automations: { automations: {
a1: { id: 'a1', title: 'from source', filterRule: 'all', filters: [], outputs: [{ type: 'http', url: 'http://127.0.0.1/go' }] }, a1: {
id: 'a1',
title: 'from source',
filterRule: 'all',
filters: [],
outputs: [{ type: 'http', url: 'http://127.0.0.1/go' }],
},
}, },
}, },
}); });
+34 -9
View File
@@ -12,11 +12,34 @@ const templateName = 'e2e-automations-template';
* the template carries the automation and its trigger while leaving the rundown behind. * the template carries the automation and its trigger while leaving the rundown behind.
*/ */
test.describe('automations', () => { test.describe('automations', () => {
/**
* Everything created is torn down in afterEach rather than at the end of the test body:
* a mid-test failure would otherwise leak an automation into the project, and CI retries twice.
*
* The template is tracked by the name the server actually used, since it resolves collisions.
*/
let createdTemplate: string | null = null;
let createdTriggers: string[] = [];
let createdAutomations: string[] = [];
test.afterEach(async ({ request }) => { test.afterEach(async ({ request }) => {
try { try {
await request.delete(`${dbURL}/${templateName}.json`); // triggers first, the server refuses to delete an automation that is still referenced
for (const id of createdTriggers) {
await request.delete(`${automationsURL}/trigger/${id}`);
}
for (const id of createdAutomations) {
await request.delete(`${automationsURL}/automation/${id}`);
}
if (createdTemplate !== null) {
await request.delete(`${dbURL}/${createdTemplate}`);
}
} catch { } catch {
/** nothing to do here */ // cleanup is best effort, it must not turn a passing test red
} finally {
createdTriggers = [];
createdAutomations = [];
createdTemplate = null;
} }
}); });
@@ -32,13 +55,14 @@ test.describe('automations', () => {
}); });
expect(createAutomation.status()).toBe(201); expect(createAutomation.status()).toBe(201);
const automation = await createAutomation.json(); const automation = await createAutomation.json();
createdAutomations.push(automation.id);
// 2. bind it to a lifecycle // 2. bind it to a lifecycle
const createTrigger = await request.post(`${automationsURL}/trigger`, { const createTrigger = await request.post(`${automationsURL}/trigger`, {
data: { title: 'e2e trigger', trigger: 'onStart', automationId: automation.id }, data: { title: 'e2e trigger', trigger: 'onStart', automationId: automation.id },
}); });
expect(createTrigger.status()).toBe(201); expect(createTrigger.status()).toBe(201);
const trigger = await createTrigger.json(); createdTriggers.push((await createTrigger.json()).id);
// 3. save the automations as a template, without touching the loaded project // 3. save the automations as a template, without touching the loaded project
const projectList = await (await request.get(`${dbURL}/all`)).json(); const projectList = await (await request.get(`${dbURL}/all`)).json();
@@ -48,22 +72,20 @@ test.describe('automations', () => {
data: { newFilename: templateName, sections: ['automation'] }, data: { newFilename: templateName, sections: ['automation'] },
}); });
expect(makeTemplate.status()).toBe(201); expect(makeTemplate.status()).toBe(201);
createdTemplate = (await makeTemplate.json()).filename;
expect(createdTemplate).toBeTruthy();
// the running project is untouched // the running project is untouched
const afterTemplate = await (await request.get(`${dbURL}/all`)).json(); const afterTemplate = await (await request.get(`${dbURL}/all`)).json();
expect(afterTemplate.lastLoadedProject).toBe(currentProject); expect(afterTemplate.lastLoadedProject).toBe(currentProject);
// 4. the template carries the automation and its trigger, and nothing else // 4. the template carries the automation and its trigger, and nothing else
const template = await (await request.post(`${dbURL}/download`, { data: { filename: templateName } })).json(); const template = await (await request.post(`${dbURL}/download`, { data: { filename: createdTemplate } })).json();
expect(Object.values(template.automation.automations)).toContainEqual( expect(Object.values(template.automation.automations)).toContainEqual(
expect.objectContaining({ title: 'e2e automation' }), expect.objectContaining({ title: 'e2e automation' }),
); );
expect(template.automation.triggers).toContainEqual(expect.objectContaining({ title: 'e2e trigger' })); expect(template.automation.triggers).toContainEqual(expect.objectContaining({ title: 'e2e trigger' }));
expect(template.urlPresets).toEqual([]); expect(template.urlPresets).toEqual([]);
// 5. clean up: the trigger has to go first, the server refuses to delete a referenced automation
expect((await request.delete(`${automationsURL}/trigger/${trigger.id}`)).status()).toBe(204);
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
}); });
test('refuses to delete an automation that a trigger still points at', async ({ request }) => { test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
@@ -77,18 +99,21 @@ test.describe('automations', () => {
}, },
}) })
).json(); ).json();
createdAutomations.push(automation.id);
const trigger = await ( const trigger = await (
await request.post(`${automationsURL}/trigger`, { await request.post(`${automationsURL}/trigger`, {
data: { title: 'e2e blocking trigger', trigger: 'onFinish', automationId: automation.id }, data: { title: 'e2e blocking trigger', trigger: 'onFinish', automationId: automation.id },
}) })
).json(); ).json();
createdTriggers.push(trigger.id);
const refused = await request.delete(`${automationsURL}/automation/${automation.id}`); const refused = await request.delete(`${automationsURL}/automation/${automation.id}`);
expect(refused.status()).toBe(400); expect(refused.status()).toBe(400);
expect((await refused.json()).message).toContain('e2e blocking trigger'); expect((await refused.json()).message).toContain('e2e blocking trigger');
await request.delete(`${automationsURL}/trigger/${trigger.id}`); // and it goes through once the reference is removed
expect((await request.delete(`${automationsURL}/trigger/${trigger.id}`)).status()).toBe(204);
expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204); expect((await request.delete(`${automationsURL}/automation/${automation.id}`)).status()).toBe(204);
}); });
}); });