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>
}
/>
);
}
@@ -1,6 +1,5 @@
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
import { socket } from '../../../adapters/WebsocketAdapter.js';
import { logger } from '../../../classes/Logger.js';
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
import { RuntimeState } from '../../../stores/runtimeState.js';
@@ -653,12 +652,10 @@ describe('testConditions()', () => {
*/
describe('automation reporting', () => {
let logSpy = vi.spyOn(logger, 'info');
let socketSpy = vi.spyOn(socket, 'sendAsJson');
beforeEach(async () => {
vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
logSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
socketSpy = vi.spyOn(socket, 'sendAsJson').mockImplementation(() => {});
await deleteAllTriggers();
resetAutomationLogState();
@@ -753,18 +750,4 @@ describe('automation reporting', () => {
triggerAutomations(TimerLifeCycle.onDanger);
expect(logSpy).toHaveBeenCalledTimes(2);
});
it('reports a fire to the clients at most once a second, including on continuous lifecycles', async () => {
vi.useFakeTimers();
await bind('reporting-clock', TimerLifeCycle.onClock);
socketSpy.mockClear();
triggerAutomations(TimerLifeCycle.onClock);
triggerAutomations(TimerLifeCycle.onClock);
expect(socketSpy).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1001);
triggerAutomations(TimerLifeCycle.onClock);
expect(socketSpy).toHaveBeenCalledTimes(2);
});
});
@@ -4,7 +4,6 @@ import {
type AutomationOutput,
type FilterRule,
LogOrigin,
MessageTag,
RuntimeStore,
TimerLifeCycle,
isHTTPOutput,
@@ -13,7 +12,6 @@ import {
} from 'ontime-types';
import { getPropertyFromPath } from 'ontime-utils';
import { socket } from '../../adapters/WebsocketAdapter.js';
import { logger } from '../../classes/Logger.js';
import { isOntimeCloud } from '../../setup/environment.js';
import { eventStore } from '../../stores/EventStore.js';
@@ -37,8 +35,6 @@ const reportThrottleMs = 1000;
const suppressionNotices = new Set<string>();
/** last time we logged a given automation + cycle pair */
const lastLoggedAt = new Map<string, number>();
/** last time we told the clients about a given automation */
const lastReportedAt = new Map<string, number>();
/**
* Clears the reporting state.
@@ -48,7 +44,6 @@ const lastReportedAt = new Map<string, number>();
export function resetAutomationLogState() {
suppressionNotices.clear();
lastLoggedAt.clear();
lastReportedAt.clear();
}
/**
@@ -115,19 +110,11 @@ function fireForCycle(cycle: TimerLifeCycle) {
}
/**
* Makes a successful automation visible, which it previously was not:
* the log answers what happened, the socket message answers whether an automation is alive
* Makes a successful automation fire visible in the log, which it previously was not
*/
function reportFired(automationId: string, automation: Automation, cycle: TimerLifeCycle) {
const now = Date.now();
// the panel shows a last fired time, so continuous lifecycles still report, but at most once a second
const lastReported = lastReportedAt.get(automationId);
if (lastReported === undefined || now - lastReported >= reportThrottleMs) {
lastReportedAt.set(automationId, now);
socket.sendAsJson(MessageTag.AutomationFired, { automationId, cycle });
}
if (continuousCycles.includes(cycle)) {
// one notice per load is enough to explain why the log goes quiet from here
if (!suppressionNotices.has(automationId)) {
@@ -231,30 +231,6 @@ 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.
* The result is not loaded, so making a template does not disturb the running show.
*/
export async function partialDuplicateProjectFile(req: Request, res: Response<{ filename: string } | ErrorResponse>) {
const { filename } = req.params;
const { newFilename, sections } = req.body;
try {
// the created name can differ from what was asked for, generateUniqueFileName resolves collisions
const created = await projectService.createProjectFromSections(filename, newFilename, sections);
res.status(201).send({ filename: created });
} catch (error) {
const message = getErrorMessage(error);
if (message.startsWith('Project file')) {
res.status(403).send({ message });
return;
}
res.status(500).send({ message });
}
}
/**
* Duplicates a project file.
* Receives the original project filename (`filename`) from the request parameters
-9
View File
@@ -8,7 +8,6 @@ import {
listProjects,
loadDemo,
loadProject,
partialDuplicateProjectFile,
patchPartialProjectFile,
postProjectFile,
projectDownload,
@@ -23,7 +22,6 @@ import {
validateNewProject,
validatePatchProject,
validateQuickProject,
validateSectionsBody,
} from './db.validation.js';
export const router: Router = express.Router();
@@ -41,12 +39,5 @@ router.get('/all', listProjects);
router.post('/load', validateFilenameBody, loadProject);
router.post('/demo', loadDemo);
router.post('/:filename/duplicate', validateFilenameParam, validateNewFilenameBody, duplicateProjectFile);
router.post(
'/:filename/partial-duplicate',
validateFilenameParam,
validateNewFilenameBody,
validateSectionsBody,
partialDuplicateProjectFile,
);
router.put('/:filename/rename', validateFilenameParam, validateNewFilenameBody, renameProjectFile);
router.delete('/:filename', validateFilenameParam, deleteProjectFile);
@@ -1,5 +1,4 @@
import { body, param } from 'express-validator';
import { isTemplateSection, templateSections } from 'ontime-types';
import sanitize from 'sanitize-filename';
import { ensureJsonExtension } from '../../utils/fileManagement.js';
@@ -68,21 +67,6 @@ export const validateNewFilenameBody = [
requestValidationFunction,
];
/**
* @description Validates a request to clone selected sections of a project into a template.
*/
export const validateSectionsBody = [
body('sections')
.isArray({ min: 1 })
.withMessage(`Select at least one of: ${templateSections.join(', ')}`)
.custom((sections: unknown[]) =>
sections.every((section) => typeof section === 'string' && isTemplateSection(section)),
)
.withMessage(`Sections must be any of: ${templateSections.join(', ')}`),
requestValidationFunction,
];
/**
* @description Validates request with filename in the body.
*/
+8 -36
View File
@@ -1,4 +1,4 @@
import { DatabaseModel, OntimeView, TimerLifeCycle } from 'ontime-types';
import { DatabaseModel, OntimeView } from 'ontime-types';
import { backstageRundown, broadcastRundown, stageRundown } from './demoRundowns.js';
@@ -77,12 +77,14 @@ export const demoDb: DatabaseModel = {
},
},
/**
* The demo ships with working automations so the engine is visible the first time
* The demo ships with a working automation so the engine is visible the first time
* someone presses Play, rather than hidden behind an empty settings panel.
*
* Everything that actually fires is an Ontime action: the demo must not put traffic
* on whatever network it happens to be opened on. The OSC entry is there to be read
* and edited, and is deliberately left without a trigger.
* It fires an Ontime action: the demo must not put traffic on whatever network it
* happens to be opened on. It is attached via an event-level trigger rather than a
* global one, so per-event triggers are also discoverable by browsing the rundown
* instead of reading docs. The OSC entry is there to be read and edited, and is
* deliberately left without a trigger of its own.
*
* The ids are hand written and must match the map keys. Ids are only generated for
* automations created through the DAO, so literals are safe here.
@@ -92,20 +94,7 @@ export const demoDb: DatabaseModel = {
// never open a listening socket without the user asking for it
enabledOscIn: false,
oscPortIn: 8888,
triggers: [
{
id: 'demo-trigger-aux',
title: 'Demo: aux timer on start',
trigger: TimerLifeCycle.onStart,
automationId: 'demo-aux-timer',
},
{
id: 'demo-trigger-clear',
title: 'Demo: clear the wrap up warning',
trigger: TimerLifeCycle.onFinish,
automationId: 'demo-clear-message',
},
],
triggers: [],
automations: {
'demo-aux-timer': {
id: 'demo-aux-timer',
@@ -117,23 +106,6 @@ export const demoDb: DatabaseModel = {
{ type: 'ontime', action: 'aux1-start' },
],
},
'demo-danger-message': {
id: 'demo-danger-message',
title: 'Demo: warn the stage at danger',
filterRule: 'all',
filters: [],
// 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 }],
},
'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': {
id: 'demo-osc-example',
title: 'Demo: OSC to a lighting console (example, not wired up)',
+3 -3
View File
@@ -125,9 +125,9 @@ export const stageRundown: Rundown = {
triggers: [
{
id: 'demo-event-trigger',
title: 'Wrap up warning',
trigger: TimerLifeCycle.onDanger,
automationId: 'demo-danger-message',
title: 'Aux timer with the event',
trigger: TimerLifeCycle.onStart,
automationId: 'demo-aux-timer',
},
],
},
@@ -1,7 +1,7 @@
import { copyFile, writeFile } from 'fs/promises';
import { copyFile } from 'fs/promises';
import { join } from 'path';
import { DatabaseModel, LogOrigin, ProjectFileListResponse, TemplateSection } from 'ontime-types';
import { DatabaseModel, LogOrigin, ProjectFileListResponse } from 'ontime-types';
import { getErrorMessage, getFirstRundown } from 'ontime-utils';
import { parseCustomFields } from '../../api-data/custom-fields/customFields.parser.js';
@@ -9,7 +9,7 @@ import { parseDatabaseModel } from '../../api-data/db/db.parser.js';
import { getCurrentRundown } from '../../api-data/rundown/rundown.dao.js';
import { parseRundowns } from '../../api-data/rundown/rundown.parser.js';
import { initRundown } from '../../api-data/rundown/rundown.service.js';
import { flushPendingWrites, getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { getDataProvider, initPersistence } from '../../classes/data-provider/DataProvider.js';
import { safeMerge } from '../../classes/data-provider/DataProvider.utils.js';
import { logger } from '../../classes/Logger.js';
import { makeNewProject } from '../../models/dataModel.js';
@@ -318,60 +318,6 @@ export async function createProjectWithPatch(fileName: string, initialData: Part
return createProject(fileName, sanitisedData);
}
/**
* Creates a new project file containing only the given sections of an existing one.
* This is how a user makes a template: a small project holding, say, only automations
* or only custom fields, which can then be shared and applied with a partial load.
*
* Unlike createProjectWithPatch, this does NOT load the result. Saving a template must
* not pull the operator out of the show they are running.
*
* @throws if the source does not exist or cannot be parsed
*/
export async function createProjectFromSections(
sourceFilename: string,
newFilename: string,
sections: TemplateSection[],
): Promise<string> {
const projectFilePath = doesProjectExist(sourceFilename);
if (projectFilePath === null) {
throw new Error('Project file not found');
}
if (sections.length === 0) {
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 { data } = parseDatabaseModel(fileData);
const patch: Partial<DatabaseModel> = {};
for (const section of sections) {
// a rundown without its custom fields is not usable on the other side
if (section === 'rundowns') {
patch.customFields = data.customFields;
}
Object.assign(patch, { [section]: data[section] });
}
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));
await writeFile(getPathToProject(fileNameWithExtension), JSON.stringify(template, null, 2), 'utf-8');
return fileNameWithExtension;
}
/**
* Deletes a project file
*/
@@ -1,17 +1,8 @@
import { writeFile } from 'fs/promises';
import { OntimeView, TimerLifeCycle } from 'ontime-types';
import { Mock } from 'vitest';
import { makeNewProject } from '../../../models/dataModel.js';
import { isLastLoadedProject } from '../../app-state-service/AppStateService.js';
import {
createProjectFromSections,
deleteProjectFile,
duplicateProjectFile,
renameProjectFile,
} from '../ProjectService.js';
import { doesProjectExist, parseJsonFile } from '../projectServiceUtils.js';
import { deleteProjectFile, duplicateProjectFile, renameProjectFile } from '../ProjectService.js';
import { doesProjectExist } from '../projectServiceUtils.js';
// stop the database loading from initiating
vi.mock('../../../setup/loadDb.js', () => {
@@ -26,13 +17,7 @@ vi.mock('../../app-state-service/AppStateService.js', () => ({
vi.mock('../projectServiceUtils.js', () => ({
doesProjectExist: vi.fn(),
getPathToProject: vi.fn().mockImplementation((name: string) => `/projects/${name}`),
parseJsonFile: vi.fn(),
}));
vi.mock('fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof import('fs/promises')>()),
writeFile: vi.fn(),
getPathToProject: vi.fn(),
}));
/**
@@ -80,54 +65,3 @@ describe('renameProjectFile', () => {
);
});
});
describe('createProjectFromSections', () => {
it('throws an error if origin project does not exist', async () => {
(doesProjectExist as Mock).mockReturnValue(null);
await expect(createProjectFromSections('does not exist', 'template', ['automation'])).rejects.toThrow(
'Project file not found',
);
});
it('throws an error if nothing was selected', async () => {
(doesProjectExist as Mock).mockReturnValue('/projects/source.json');
await expect(createProjectFromSections('source.json', 'template', [])).rejects.toThrow(
'At least one section must be selected',
);
});
it('writes a project carrying only the selected sections', async () => {
(doesProjectExist as Mock).mockReturnValue('/projects/source.json');
(parseJsonFile as Mock).mockResolvedValue({
...makeNewProject(),
urlPresets: [{ target: OntimeView.Timer, enabled: true, alias: 'from-source', search: '', displayInNav: false }],
automation: {
enabledAutomations: true,
enabledOscIn: false,
oscPortIn: 8888,
triggers: [{ id: 't1', title: 'on start', trigger: TimerLifeCycle.onStart, automationId: 'a1' }],
automations: {
a1: {
id: 'a1',
title: 'from source',
filterRule: 'all',
filters: [],
outputs: [{ type: 'http', url: 'http://127.0.0.1/go' }],
},
},
},
});
await createProjectFromSections('source.json', 'template.json', ['automation']);
expect(writeFile).toHaveBeenCalledOnce();
const written = JSON.parse((writeFile as Mock).mock.calls[0][1] as string);
// the automations came across, triggers included
expect(written.automation.automations.a1.title).toBe('from source');
expect(written.automation.triggers).toHaveLength(1);
// and the url preset, which parses cleanly but was not selected, did not
expect(written.urlPresets).toEqual([]);
});
});
+2 -62
View File
@@ -2,23 +2,12 @@ import { expect, test } from '@playwright/test';
const baseURL = 'http://localhost:4001';
const automationsURL = `${baseURL}/data/automations`;
const dbURL = `${baseURL}/data/db`;
const templateName = 'e2e-automations-template';
/**
* Covers the loop that makes automations shareable:
* create one, clone only the automations into a template project, and check that
* the template carries the automation and its trigger while leaving the rundown behind.
* Covers the automation delete flow: the server refuses to delete an automation that a
* trigger still points at, and clears once the reference is removed.
*/
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[] = [];
@@ -31,63 +20,14 @@ test.describe('automations', () => {
for (const id of createdAutomations) {
await request.delete(`${automationsURL}/automation/${id}`);
}
if (createdTemplate !== null) {
await request.delete(`${dbURL}/${createdTemplate}`);
}
} catch {
// cleanup is best effort, it must not turn a passing test red
} finally {
createdTriggers = [];
createdAutomations = [];
createdTemplate = null;
}
});
test('an automation and its trigger survive a round trip through a template project', async ({ request }) => {
// 1. create an automation
const createAutomation = await request.post(`${automationsURL}/automation`, {
data: {
title: 'e2e automation',
filterRule: 'all',
filters: [],
outputs: [{ type: 'ontime', action: 'aux1-start' }],
},
});
expect(createAutomation.status()).toBe(201);
const automation = await createAutomation.json();
createdAutomations.push(automation.id);
// 2. bind it to a lifecycle
const createTrigger = await request.post(`${automationsURL}/trigger`, {
data: { title: 'e2e trigger', trigger: 'onStart', automationId: automation.id },
});
expect(createTrigger.status()).toBe(201);
createdTriggers.push((await createTrigger.json()).id);
// 3. save the automations as a template, without touching the loaded project
const projectList = await (await request.get(`${dbURL}/all`)).json();
const currentProject = projectList.lastLoadedProject;
const makeTemplate = await request.post(`${dbURL}/${currentProject}/partial-duplicate`, {
data: { newFilename: templateName, sections: ['automation'] },
});
expect(makeTemplate.status()).toBe(201);
createdTemplate = (await makeTemplate.json()).filename;
expect(createdTemplate).toBeTruthy();
// the running project is untouched
const afterTemplate = await (await request.get(`${dbURL}/all`)).json();
expect(afterTemplate.lastLoadedProject).toBe(currentProject);
// 4. the template carries the automation and its trigger, and nothing else
const template = await (await request.post(`${dbURL}/download`, { data: { filename: createdTemplate } })).json();
expect(Object.values(template.automation.automations)).toContainEqual(
expect.objectContaining({ title: 'e2e automation' }),
);
expect(template.automation.triggers).toContainEqual(expect.objectContaining({ title: 'e2e trigger' }));
expect(template.urlPresets).toEqual([]);
});
test('refuses to delete an automation that a trigger still points at', async ({ request }) => {
const automation = await (
await request.post(`${automationsURL}/automation`, {
-20
View File
@@ -4,23 +4,3 @@ export interface QuickStartData {
project: Pick<DatabaseModel['project'], 'title'>;
settings: Pick<DatabaseModel['settings'], 'timeFormat' | 'language'>;
}
/**
* Sections of a project that can be cloned on their own into a template project:
* a small project holding, say, only automations, which can be shared and then
* applied to another project with a partial load
*/
export const templateSections = [
'project',
'rundowns',
'customFields',
'viewSettings',
'urlPresets',
'automation',
] as const satisfies ReadonlyArray<keyof DatabaseModel>;
export type TemplateSection = (typeof templateSections)[number];
export function isTemplateSection(value: string): value is TemplateSection {
return (templateSections as ReadonlyArray<string>).includes(value);
}
+1 -13
View File
@@ -1,5 +1,4 @@
import type { Client } from '../../definitions/Clients.type.js';
import type { TimerLifeCycle } from '../../definitions/core/TimerLifecycle.type.js';
import type { Log } from '../../definitions/runtime/Logger.type.js';
import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js';
import type { MaybeNumber } from '../../utils/utils.type.js';
@@ -18,7 +17,6 @@ export enum MessageTag {
Log = 'log',
RuntimeData = 'runtime-data',
Refetch = 'refetch',
AutomationFired = 'automation-fired',
}
// CLIENT TO SERVER
@@ -38,15 +36,6 @@ type ListClientPacket = {
};
type RuntimePacket = { tag: MessageTag.RuntimeData; payload: Partial<RuntimeStore> };
/**
* Reports that an automation ran, so clients can show it is alive.
* Coalesced server side: high frequency lifecycles do not send one of these per fire.
*/
type AutomationFiredPacket = {
tag: MessageTag.AutomationFired;
payload: { automationId: string; cycle: TimerLifeCycle };
};
type RefetchPacket = {
tag: MessageTag.Refetch;
payload: {
@@ -69,5 +58,4 @@ export type WsPacketToClient =
| LogPacket
| ListClientPacket
| RuntimePacket
| RefetchPacket
| AutomationFiredPacket;
| RefetchPacket;
+1 -2
View File
@@ -66,8 +66,7 @@ export type {
} from './definitions/core/CustomFields.type.js';
// SERVER RESPONSES
export type { QuickStartData, TemplateSection } from './api/db/db.type.js';
export { templateSections, isTemplateSection } from './api/db/db.type.js';
export type { QuickStartData } from './api/db/db.type.js';
export type {
AuthenticationStatus,
NetworkInterface,