diff --git a/apps/client/src/common/api/db.ts b/apps/client/src/common/api/db.ts index 733f82454..cae17c3fa 100644 --- a/apps/client/src/common/api/db.ts +++ b/apps/client/src/common/api/db.ts @@ -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 */ diff --git a/apps/client/src/common/stores/automationFired.ts b/apps/client/src/common/stores/automationFired.ts deleted file mode 100644 index bd14a722d..000000000 --- a/apps/client/src/common/stores/automationFired.ts +++ /dev/null @@ -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; -}; - -/** - * 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(() => ({ - fired: {}, -})); - -export const useAutomationFired = () => useStore(automationFired); - -export const addAutomationFired = (automationId: string, cycle: TimerLifeCycle) => - automationFired.setState((state) => ({ - fired: { ...state.fired, [automationId]: { at: Date.now(), cycle } }, - })); diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index 23a5c45cb..a9366cc36 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -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); diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss index 8fcff2405..3137d0529 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.module.scss @@ -1,8 +1,3 @@ .muted { color: $muted-gray; } - -.lastFired { - font-size: $aux-text-size; - color: $green-400; -} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx index 8d9db0bbf..252ab8791 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/AutomationsList.tsx @@ -1,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(null); const [showRecipes, setShowRecipes] = useState(false); - const [showTemplateForm, setShowTemplateForm] = useState(false); const [deleteTarget, setDeleteTarget] = useState(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 && ( - setShowTemplateForm(false)} - onCreated={async () => { - // do not rely on the project panel refetching when it mounts - await ontimeQueryClient.invalidateQueries({ queryKey: PROJECT_LIST }); - setLocation('project__list'); - }} /> )} Manage automations - } - 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 - @@ -160,11 +114,10 @@ export default function AutomationsList({ - Title - Runs on - Filter rule + Title + Runs on + Filter rule Sends - Last fired @@ -220,9 +173,6 @@ export default function AutomationsList({ )) )} - - - ); } - -/** - * 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 ; - } - - return {formatElapsed(Date.now() - at)}; -} - -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`; -} diff --git a/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx index 9a1d8bb50..5687fbd66 100644 --- a/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx +++ b/apps/client/src/features/app-settings/panel/automations-panel/DeleteAutomationDialog.tsx @@ -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; } /** * 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(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({ {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`} {blockingTriggers .map((trigger) => `${trigger.title} (${getLifecycleLabel(trigger.trigger)})`) .join(', ')} + Remove them from Global Triggers first, then delete the automation. )} @@ -113,9 +81,6 @@ export default function DeleteAutomationDialog({ {error} 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.'} )} @@ -127,7 +92,7 @@ export default function DeleteAutomationDialog({ Cancel } diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx index d0f397876..703067a70 100644 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx +++ b/apps/client/src/features/app-settings/panel/project-panel/ProjectListItem.tsx @@ -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)} /> )} {showMergeForm && } - {showTemplateForm && } setDeleteOpen(false)} @@ -194,10 +190,9 @@ interface ActionMenuProps { onDelete: () => void; onLoad: (filename: string) => Promise; 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 }, diff --git a/apps/client/src/features/app-settings/panel/project-panel/ProjectPartialCloneForm.tsx b/apps/client/src/features/app-settings/panel/project-panel/ProjectPartialCloneForm.tsx deleted file mode 100644 index 025c421ea..000000000 --- a/apps/client/src/features/app-settings/panel/project-panel/ProjectPartialCloneForm.tsx +++ /dev/null @@ -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 & { 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; - 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(null); - - const { - handleSubmit, - register, - watch, - setValue, - formState: { isSubmitting, errors }, - } = useForm({ - 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 ( - - -
- - Create a new project containing only the selected parts of{' '} - {`"${fileName}"`}. Use it as a starting point for new shows, - or share it with someone who needs the same setup. - -
- The current project is not changed and stays loaded. Apply a template to another project with Partial - Load. -
-
- - - - - {sectionCopy.map(({ key, title, 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. - - -
- - } - footerElements={ -
- {error && {error}} -
- - -
-
- } - /> - ); -} diff --git a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts index 67114f452..6fb3e5108 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.service.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.service.test.ts @@ -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); - }); }); diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index 4b3fbd7dc..2c675d205 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -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(); /** last time we logged a given automation + cycle pair */ const lastLoggedAt = new Map(); -/** last time we told the clients about a given automation */ -const lastReportedAt = new Map(); /** * Clears the reporting state. @@ -48,7 +44,6 @@ const lastReportedAt = new Map(); 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)) { diff --git a/apps/server/src/api-data/db/db.controller.ts b/apps/server/src/api-data/db/db.controller.ts index 61fe23eb3..c45f2df63 100644 --- a/apps/server/src/api-data/db/db.controller.ts +++ b/apps/server/src/api-data/db/db.controller.ts @@ -231,30 +231,6 @@ export async function loadDemo(_req: Request, res: Response) { - 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 diff --git a/apps/server/src/api-data/db/db.router.ts b/apps/server/src/api-data/db/db.router.ts index e6c0cc953..cdac334c3 100644 --- a/apps/server/src/api-data/db/db.router.ts +++ b/apps/server/src/api-data/db/db.router.ts @@ -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); diff --git a/apps/server/src/api-data/db/db.validation.ts b/apps/server/src/api-data/db/db.validation.ts index cdb58d3f0..40e91fdcd 100644 --- a/apps/server/src/api-data/db/db.validation.ts +++ b/apps/server/src/api-data/db/db.validation.ts @@ -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. */ diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 47cd28464..e9df8bc30 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -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)', diff --git a/apps/server/src/models/demoRundowns.ts b/apps/server/src/models/demoRundowns.ts index aecfdf524..f8400f204 100644 --- a/apps/server/src/models/demoRundowns.ts +++ b/apps/server/src/models/demoRundowns.ts @@ -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', }, ], }, diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 33e7a3289..3cad948c3 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -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 { - 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 = {}; - 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 */ diff --git a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts index 86f1dc7a9..ba79327e8 100644 --- a/apps/server/src/services/project-service/__tests__/ProjectService.test.ts +++ b/apps/server/src/services/project-service/__tests__/ProjectService.test.ts @@ -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()), - 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([]); - }); -}); diff --git a/e2e/tests/features/215-automations.spec.ts b/e2e/tests/features/215-automations.spec.ts index 0b02b96d2..fdca2280f 100644 --- a/e2e/tests/features/215-automations.spec.ts +++ b/e2e/tests/features/215-automations.spec.ts @@ -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`, { diff --git a/packages/types/src/api/db/db.type.ts b/packages/types/src/api/db/db.type.ts index e559d1d05..cae4cbca6 100644 --- a/packages/types/src/api/db/db.type.ts +++ b/packages/types/src/api/db/db.type.ts @@ -4,23 +4,3 @@ export interface QuickStartData { project: Pick; settings: Pick; } - -/** - * 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; - -export type TemplateSection = (typeof templateSections)[number]; - -export function isTemplateSection(value: string): value is TemplateSection { - return (templateSections as ReadonlyArray).includes(value); -} diff --git a/packages/types/src/api/websocket/data.type.ts b/packages/types/src/api/websocket/data.type.ts index f42946e11..3d09f2158 100644 --- a/packages/types/src/api/websocket/data.type.ts +++ b/packages/types/src/api/websocket/data.type.ts @@ -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 }; -/** - * 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; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e82e4f666..77e98fd91 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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,