refactor(automation): trim the branch to reduce risk

Adoption is low, and the app must not be put at risk fixing that. A measured
look at the diff showed the risk was concentrated in exactly two places: the
timer's hot path and a new file-writing route. Everything that actually makes
automations discoverable — recipes, a panel that explains itself, one-step
creation, Test buttons that report — is client-only settings-panel code with
zero server churn. This cuts the two pieces that weren't, and simplifies two
more that only existed to serve them.

Removed entirely: templates. Newest, riskiest, and the least connected to the
adoption problem — it helps someone who already uses automations share them,
not someone who has never tried the feature. It was also the source of two of
the bugs found in review.

Removed: the websocket broadcast behind "last fired". reportFired ran inside
triggerAutomations, called from onClock every second and from onUpdate. A new
protocol message pushed to every connected client, including stage displays,
up to once a second per automation, is real new traffic on the busiest code
path in the app for a feature still trying to prove it's worth using. The log
line stays: it's additive, throttled, nowhere near the hot path once written,
and answers the same question — did this run — without a new message.

Trimmed: the demo goes from two automations tied together by necessity — a
danger-time message and a second automation whose only job was undoing the
first on finish — to one, attached by an event-level trigger instead of a
global one. Same two discovery wins, an automation visibly fires on Play and
per-event triggers are found by opening an event, with no message mutation
and nothing to keep in sync. That pairing was also the first thing review
found broken.

Trimmed: the delete dialog no longer deletes blocking triggers and restores
them if the automation still won't delete. That rollback was the other
multi-request destructive sequence review found a bug in. It now confirms,
names what's blocking, and says to remove global triggers from the Global
Triggers list first — a single always-safe request the user already has.

What stays: one-step creation (lifecycles picked on the automation form), the
recipe library, and the panel legibility work — none of it touches the server
or the runtime.

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 21:12:26 +00:00
parent cdde054b14
commit ed39f86a81
21 changed files with 40 additions and 725 deletions
+1 -26
View File
@@ -1,12 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import {
DatabaseModel,
MessageResponse,
ProjectData,
ProjectFileListResponse,
QuickStartData,
TemplateSection,
} from 'ontime-types';
import { DatabaseModel, MessageResponse, ProjectData, ProjectFileListResponse, QuickStartData } from 'ontime-types';
import { apiEntryUrl } from './constants';
import type { RequestOptions } from './requestOptions';
@@ -124,24 +117,6 @@ export async function duplicateProject(filename: string, newFilename: string): P
return res.data;
}
/**
* HTTP request to create a template: a project file holding only the selected sections
* The result is not loaded, the current project stays as it is
*/
export async function partialDuplicateProject(
filename: string,
newFilename: string,
sections: TemplateSection[],
): Promise<{ filename: string }> {
const url = `${dbPath}/${filename}/partial-duplicate`;
const decodedUrl = decodeURIComponent(url);
const res = await axios.post(decodedUrl, {
newFilename,
sections,
});
return res.data;
}
/**
* HTTP request to rename a project file
*/
@@ -1,25 +0,0 @@
import type { TimerLifeCycle } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
type FiredRecord = { at: number; cycle: TimerLifeCycle };
type AutomationFiredStore = {
fired: Record<string, FiredRecord>;
};
/**
* Tracks when each automation last ran.
* Ephemeral by design: this is runtime feedback for the settings panel, not project data,
* and it is fed by a socket message the server already coalesces to once a second per automation
*/
const automationFired = createStore<AutomationFiredStore>(() => ({
fired: {},
}));
export const useAutomationFired = () => useStore(automationFired);
export const addAutomationFired = (automationId: string, cycle: TimerLifeCycle) =>
automationFired.setState((state) => ({
fired: { ...state.fired, [automationId]: { at: Date.now(), cycle } },
}));
-5
View File
@@ -28,7 +28,6 @@ import {
} from '../api/constants';
import { invalidateAllCaches } from '../api/utils';
import { ontimeQueryClient } from '../queryClient';
import { addAutomationFired } from '../stores/automationFired';
import {
getClientId,
getClientName,
@@ -174,10 +173,6 @@ export const connectSocket = () => {
addLog(payload as Log);
break;
}
case MessageTag.AutomationFired: {
addAutomationFired(payload.automationId, payload.cycle);
break;
}
case MessageTag.RuntimeData: {
patchRuntime(payload);
updateDevTools(payload);
@@ -1,8 +1,3 @@
.muted {
color: $muted-gray;
}
.lastFired {
font-size: $aux-text-size;
color: $green-400;
}
@@ -1,22 +1,15 @@
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoShareOutline, IoSparklesOutline, IoTrash } from 'react-icons/io5';
import { Fragment, useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5';
import { PROJECT_LIST } from '../../../../common/api/constants';
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 Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { useOrderedProjectList } from '../../../../common/hooks-query/useProjectList';
import { ontimeQueryClient } from '../../../../common/queryClient';
import { useAutomationFired } from '../../../../common/stores/automationFired';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
import ProjectPartialCloneForm from '../project-panel/ProjectPartialCloneForm';
import AutomationForm from './AutomationForm';
import { groupTriggersByAutomation, isAutomation } from './automationUtils';
import DeleteAutomationDialog from './DeleteAutomationDialog';
@@ -47,12 +40,7 @@ export default function AutomationsList({
const { refetch } = useAutomationSettings();
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
const [showRecipes, setShowRecipes] = useState(false);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
const { setLocation } = useAppSettingsNavigation();
const {
data: { lastLoadedProject },
} = useOrderedProjectList();
/**
* A recipe lands in the editor rather than only in the list.
@@ -71,7 +59,6 @@ export default function AutomationsList({
};
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
const { fired } = useAutomationFired();
const arrayAutomations = Object.keys(automations);
@@ -100,44 +87,11 @@ export default function AutomationsList({
blockingTriggers={triggers.filter((trigger) => trigger.automationId === deleteTarget.id)}
onCancel={() => setDeleteTarget(null)}
onDeleted={handleDeleted}
onRefetch={refetch}
/>
)}
{showTemplateForm && (
<ProjectPartialCloneForm
fileName={lastLoadedProject}
preselected={['automation']}
onClose={() => setShowTemplateForm(false)}
onCreated={async () => {
// do not rely on the project panel refetching when it mounts
await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST });
setLocation('project__list');
}}
/>
)}
<Panel.SubHeader>
Manage automations
<Panel.InlineElements relation='inner'>
<DropdownMenu
render={<Button />}
items={[
{
type: 'item',
label: 'Save automations as template',
description: 'A small project with only your automations, to reuse or share',
disabled: arrayAutomations.length === 0 || lastLoadedProject === '',
onClick: () => setShowTemplateForm(true),
},
{
type: 'item',
label: 'Load from a template',
description: 'Bring automations in from another project file',
onClick: () => setLocation('project__list'),
},
]}
>
Share <IoShareOutline />
</DropdownMenu>
<Button onClick={() => setShowRecipes(true)}>
Browse recipes <IoSparklesOutline />
</Button>
@@ -160,11 +114,10 @@ export default function AutomationsList({
<Panel.Table>
<thead>
<tr>
<th style={{ width: '30%' }}>Title</th>
<th style={{ width: '22%' }}>Runs on</th>
<th style={{ width: '13%' }}>Filter rule</th>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Runs on</th>
<th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Sends</th>
<th style={{ width: '12%' }}>Last fired</th>
<th />
</tr>
</thead>
@@ -220,9 +173,6 @@ export default function AutomationsList({
))
)}
</Panel.InlineElements>
<td>
<LastFired at={fired[automationId]?.at} />
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -250,47 +200,3 @@ export default function AutomationsList({
</Panel.Section>
);
}
/**
* Shows how long ago an automation last ran.
* An automation that never ticks while its neighbours do is the clearest signal
* that something upstream of it, a filter or a trigger, is wrong
*/
function LastFired({ at }: { at?: number }) {
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(() => {
if (at === undefined) {
return;
}
const interval = setInterval(() => setTick((value) => value + 1), isRecent ? 1000 : millisPerMinute);
return () => clearInterval(interval);
}, [at, isRecent]);
if (at === undefined) {
return <span className={style.muted}></span>;
}
return <span className={style.lastFired}>{formatElapsed(Date.now() - at)}</span>;
}
const millisPerMinute = 60 * 1000;
function formatElapsed(elapsed: number): string {
const seconds = Math.max(0, Math.floor(elapsed / 1000));
if (seconds < 5) {
return 'just now';
}
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
return `${Math.floor(minutes / 60)}h ago`;
}
@@ -1,7 +1,7 @@
import type { Automation, Trigger } from 'ontime-types';
import { useState } from 'react';
import { addTrigger, deleteAutomation, deleteTrigger } from '../../../../common/api/automation';
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';
@@ -15,65 +15,32 @@ interface DeleteAutomationDialogProps {
blockingTriggers: Trigger[];
onCancel: () => void;
onDeleted: () => void;
/** pulls fresh settings after a rollback, so the restored triggers carry their new ids */
onRefetch: () => Promise<unknown>;
}
/**
* The server refuses to delete an automation that is still referenced, and the panel used to
* dump that refusal into a stray row under the table. Global triggers are ours to clean up, so
* we offer to do it. A reference from a rundown event is not: editing rundown data from a
* settings screen would be a surprising, hard to undo action, so that stays a block with an
* explanation of where to go.
* dump that refusal into a stray row under the table. This dialog confirms first and, on a
* refusal, names what is blocking it: global triggers to remove from the Global Triggers list,
* or an event reference to remove from the event editor. It does not delete those triggers for
* the user — a single extra step there is safer than a delete-then-restore sequence here.
*/
export default function DeleteAutomationDialog({
automation,
blockingTriggers,
onCancel,
onDeleted,
onRefetch,
}: DeleteAutomationDialogProps) {
const [error, setError] = useState<string | null>(null);
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 () => {
setError(null);
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 {
for (const trigger of blockingTriggers) {
await deleteTrigger(trigger.id);
removed.push(trigger);
}
await deleteAutomation(automation.id);
onDeleted();
} catch (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 {
setIsDeleting(false);
}
@@ -96,14 +63,15 @@ export default function DeleteAutomationDialog({
<Info type='warning'>
<Info.Title>
{blockingTriggers.length === 1
? 'One trigger will be deleted with it'
: `${blockingTriggers.length} triggers will be deleted with it`}
? 'One trigger points at this automation'
: `${blockingTriggers.length} triggers point at this automation`}
</Info.Title>
<Info.Body>
{blockingTriggers
.map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`)
.join(', ')}
</Info.Body>
<Info.Footer>Remove them from Global Triggers first, then delete the automation.</Info.Footer>
</Info>
)}
@@ -113,9 +81,6 @@ export default function DeleteAutomationDialog({
<Info.Body>{error}</Info.Body>
<Info.Footer>
Automations attached to a single event have to be removed from that event first, in the event editor.
{removeFailed
? ' Your triggers could not be put back, you will need to recreate them.'
: ' Nothing was deleted.'}
</Info.Footer>
</Info>
)}
@@ -127,7 +92,7 @@ export default function DeleteAutomationDialog({
Cancel
</Button>
<Button variant='destructive' onClick={handleDelete} loading={isDeleting}>
{blockingTriggers.length > 0 ? 'Delete triggers and automation' : 'Delete'}
Delete
</Button>
</>
}
@@ -25,11 +25,10 @@ import { cx } from '../../../../common/utils/styleUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import ProjectForm, { ProjectFormValues } from './ProjectForm';
import ProjectMergeForm from './ProjectMergeForm';
import ProjectPartialCloneForm from './ProjectPartialCloneForm';
import style from './ProjectPanel.module.scss';
export type EditMode = 'rename' | 'duplicate' | 'merge' | 'template' | null;
export type EditMode = 'rename' | 'duplicate' | 'merge' | null;
interface ProjectListItemProps {
current?: boolean;
@@ -121,7 +120,6 @@ export default function ProjectListItem({
const isCurrentlyBeingEdited = filename === editingFilename;
const showProjectForm = (editingMode === 'rename' || editingMode === 'duplicate') && filename === editingFilename;
const showMergeForm = editingMode === 'merge' && isCurrentlyBeingEdited;
const showTemplateForm = editingMode === 'template' && isCurrentlyBeingEdited;
const classes = cx([current && !isCurrentlyBeingEdited && style.current, isCurrentlyBeingEdited && style.isEditing]);
return (
@@ -154,16 +152,14 @@ export default function ProjectListItem({
onChangeEditMode={handleToggleEditMode}
onDelete={() => setDeleteOpen(true)}
onLoad={handleLoad}
isDisabled={loading || showMergeForm || showTemplateForm}
isDisabled={loading || showMergeForm}
onMerge={(filename) => handleToggleEditMode('merge', filename)}
onSaveAsTemplate={(filename) => handleToggleEditMode('template', filename)}
/>
</td>
</>
)}
</tr>
{showMergeForm && <ProjectMergeForm onClose={handleCancel} fileName={filename} />}
{showTemplateForm && <ProjectPartialCloneForm onClose={handleCancel} onCreated={onRefetch} fileName={filename} />}
<Dialog
isOpen={isDeleteOpen}
onClose={() => setDeleteOpen(false)}
@@ -194,10 +190,9 @@ interface ActionMenuProps {
onDelete: () => void;
onLoad: (filename: string) => Promise<void>;
onMerge: (filename: string) => void;
onSaveAsTemplate: (filename: string) => void;
}
function ActionMenu(props: ActionMenuProps) {
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge, onSaveAsTemplate } = props;
const { current, filename, isDisabled, onChangeEditMode, onDelete, onLoad, onMerge } = props;
const handleRename = () => {
onChangeEditMode('rename', filename);
@@ -232,13 +227,6 @@ function ActionMenu(props: ActionMenuProps) {
},
{ type: 'item', icon: IoPencilOutline, label: 'Rename', onClick: handleRename },
{ type: 'item', icon: IoCopyOutline, label: 'Duplicate', onClick: handleDuplicate },
{
type: 'item',
icon: IoCopyOutline,
label: 'Save as template',
description: 'A new project with only the parts you pick',
onClick: () => onSaveAsTemplate(filename),
},
{ type: 'item', icon: IoDocumentOutline, label: 'Download', onClick: handleDownload },
{ type: 'divider' },
{ type: 'item', icon: IoTrash, label: 'Delete', onClick: onDelete, disabled: current },
@@ -1,164 +0,0 @@
import type { TemplateSection } from 'ontime-types';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { partialDuplicateProject } from '../../../../common/api/db';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Info from '../../../../common/components/info/Info';
import Input from '../../../../common/components/input/input/Input';
import Modal from '../../../../common/components/modal/Modal';
import Switch from '../../../../common/components/switch/Switch';
import { removeFileExtension } from '../../../../common/utils/uploadUtils';
import * as Panel from '../../panel-utils/PanelUtils';
import style from './ProjectPanel.module.scss';
const formId = 'project-partial-clone-form';
type CloneFormValues = Record<TemplateSection, boolean> & { filename: string };
const sectionCopy: Array<{ key: TemplateSection; title: string; body: string }> = [
{ key: 'project', title: 'Project data', body: 'Core project metadata and settings.' },
{ key: 'rundowns', title: 'Rundown + Custom Fields', body: 'All rundowns and any associated custom fields.' },
{ key: 'customFields', title: 'Custom Fields', body: 'Custom field definitions on their own.' },
{ key: 'viewSettings', title: 'View Settings', body: 'View configuration, and display preferences.' },
{ key: 'urlPresets', title: 'URL Presets', body: 'Saved links and preset launch parameters.' },
{ key: 'automation', title: 'Automation Settings', body: 'Automations and the triggers that run them.' },
];
interface ProjectPartialCloneFormProps {
onClose: () => void;
/** receives the name the file was actually given, which can differ if the name was taken */
onCreated: (createdFilename: string) => Promise<void>;
fileName: string;
/** sections switched on when the form opens */
preselected?: TemplateSection[];
}
/**
* Creates a template: a new project file holding only the selected sections of this one.
* The inverse of the partial load in ProjectMergeForm, and the pair of them is what lets a
* user share, say, a set of automations without handing over an entire show.
*/
export default function ProjectPartialCloneForm({
onClose,
onCreated,
fileName,
preselected,
}: ProjectPartialCloneFormProps) {
const [error, setError] = useState<string | null>(null);
const {
handleSubmit,
register,
watch,
setValue,
formState: { isSubmitting, errors },
} = useForm<CloneFormValues>({
defaultValues: {
filename: `${removeFileExtension(fileName)} template`,
project: preselected?.includes('project') ?? false,
rundowns: preselected?.includes('rundowns') ?? false,
customFields: preselected?.includes('customFields') ?? false,
viewSettings: preselected?.includes('viewSettings') ?? false,
urlPresets: preselected?.includes('urlPresets') ?? false,
automation: preselected?.includes('automation') ?? false,
},
});
const handleCreate = async (values: CloneFormValues) => {
const sections = sectionCopy.map(({ key }) => key).filter((key) => values[key]);
if (sections.length === 0) {
setError('At least one section must be selected');
return;
}
try {
setError(null);
const { filename } = await partialDuplicateProject(fileName, values.filename, sections);
await onCreated(filename);
onClose();
} catch (error) {
setError(maybeAxiosError(error));
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='compact'
title='Save as template'
bodyElements={
<form id={formId} onSubmit={handleSubmit(handleCreate)}>
<Panel.Section className={style.mergeBody}>
<div className={style.mergeIntro}>
<Panel.Description>
Create a new project containing only the selected parts of{' '}
<span className={style.sourceFile}>{`"${fileName}"`}</span>. Use it as a starting point for new shows,
or share it with someone who needs the same setup.
</Panel.Description>
<div className={style.mergeSummary}>
The current project is not changed and stays loaded. Apply a template to another project with Partial
Load.
</div>
</div>
<label>
Template name
<Input
{...register('filename', { required: { value: true, message: 'Required field' } })}
fluid
placeholder='Automations template'
/>
<Panel.Error>{errors.filename?.message}</Panel.Error>
</label>
<Panel.ListGroup className={style.optionList}>
{sectionCopy.map(({ key, title, body }) => (
<Panel.ListItem key={key}>
<label className={style.optionRow}>
<div className={style.optionCopy}>
<span className={style.optionTitle}>{title}</span>
<span className={style.optionBody}>{body}</span>
</div>
<Switch
size='large'
checked={watch(key)}
onCheckedChange={(value: boolean) => setValue(key, value, { shouldDirty: true })}
/>
</label>
</Panel.ListItem>
))}
</Panel.ListGroup>
<Info>
<Info.Body>
Automations attached to individual events live in the rundown, so the two halves travel separately. A
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>
</Panel.Section>
</form>
}
footerElements={
<div className={style.footerContent}>
{error && <Panel.Error>{error}</Panel.Error>}
<div className={style.footerActions}>
<Button onClick={onClose} variant='ghosted' disabled={isSubmitting}>
Cancel
</Button>
<Button type='submit' form={formId} loading={isSubmitting} variant='primary'>
Create template
</Button>
</div>
</div>
}
/>
);
}