mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-16 19:43:06 +00:00
feat(automation): add composer
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Automation,
|
||||
AutomationComposition,
|
||||
AutomationCompositionDTO,
|
||||
AutomationDTO,
|
||||
AutomationOutput,
|
||||
AutomationSettings,
|
||||
AutomationUsage,
|
||||
Trigger,
|
||||
TriggerDTO,
|
||||
} from 'ontime-types';
|
||||
@@ -21,6 +24,11 @@ export async function getAutomationSettings(options?: RequestOptions): Promise<A
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getAutomationUsage(options?: RequestOptions): Promise<AutomationUsage> {
|
||||
const res = await axios.get(`${automationsPath}/usage`, { signal: options?.signal });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to edit the automations settings
|
||||
*/
|
||||
@@ -62,6 +70,14 @@ export async function addAutomation(automation: AutomationDTO): Promise<Automati
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/** Creates a definition and optional global lifecycle bindings atomically. */
|
||||
export async function createAutomationComposition(
|
||||
composition: AutomationCompositionDTO,
|
||||
): Promise<AutomationComposition> {
|
||||
const res = await axios.post(`${automationsPath}/composition`, composition);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP request to update a automation
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { getAutomationUsage } from '../api/automation';
|
||||
import { AUTOMATION } from '../api/constants';
|
||||
|
||||
export default function useAutomationUsage() {
|
||||
return useQuery({
|
||||
queryKey: [...AUTOMATION, 'usage'],
|
||||
queryFn: ({ signal }) => getAutomationUsage({ signal }),
|
||||
});
|
||||
}
|
||||
+13
@@ -19,6 +19,19 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lifecycleGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lifecycleOption {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.ruleSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Automation, AutomationDTO, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
|
||||
import { Automation, AutomationDTO, TimerLifeCycle, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { createAutomationComposition, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Checkbox from '../../../../common/components/checkbox/Checkbox';
|
||||
import { DropdownMenu } from '../../../../common/components/dropdown-menu/DropdownMenu';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
@@ -15,10 +16,11 @@ import Modal from '../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useAutomationUsage from '../../../../common/hooks-query/useAutomationUsage';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import { isOntimeCloud } from '../../../../externals';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
|
||||
import { cycles, isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
|
||||
import HttpOutputForm from './HttpOutputForm';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
import OscOutputForm from './OscOutputForm';
|
||||
@@ -32,16 +34,19 @@ const testFeedbackDuration = 2000;
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
initialLifecycles?: TimerLifeCycle[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
|
||||
export default function AutomationForm({ automation, initialLifecycles = [], onClose }: AutomationFormProps) {
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const { refetch: refetchUsage } = useAutomationUsage();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
const [testResults, setTestResults] = useState<Record<string, TestState>>({});
|
||||
const [submitError, setSubmitError] = useState<string>();
|
||||
const [lifecycles, setLifecycles] = useState<TimerLifeCycle[]>(initialLifecycles);
|
||||
const feedbackTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
const {
|
||||
@@ -157,6 +162,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
await handleCreate(values);
|
||||
}
|
||||
refetch();
|
||||
refetchUsage();
|
||||
|
||||
async function handleEdit(id: string, values: Automation) {
|
||||
try {
|
||||
@@ -169,7 +175,7 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
|
||||
async function handleCreate(values: AutomationDTO) {
|
||||
try {
|
||||
await addAutomation(values);
|
||||
await createAutomationComposition({ automation: values, lifecycles });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setSubmitError(maybeAxiosError(error));
|
||||
@@ -177,7 +183,12 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !isSubmitting && isDirty && isValid;
|
||||
const canSubmit = !isSubmitting && isValid && (isDirty || initialLifecycles.length > 0);
|
||||
const toggleLifecycle = (lifecycle: TimerLifeCycle, checked: boolean) => {
|
||||
setLifecycles((current) =>
|
||||
checked ? [...current, lifecycle] : current.filter((selectedLifecycle) => selectedLifecycle !== lifecycle),
|
||||
);
|
||||
};
|
||||
const addOutputMenu = (
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
@@ -220,6 +231,26 @@ export default function AutomationForm({ automation, onClose }: AutomationFormPr
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
{!isEdit && (
|
||||
<>
|
||||
<h3>Global trigger (optional)</h3>
|
||||
<Panel.Description>
|
||||
Choose when this automation should run globally. Leave all unchecked to save a reusable definition
|
||||
only.
|
||||
</Panel.Description>
|
||||
<div className={style.lifecycleGrid}>
|
||||
{cycles.map(({ value, label }) => (
|
||||
<label key={value} className={style.lifecycleOption}>
|
||||
<Checkbox
|
||||
checked={lifecycles.includes(value)}
|
||||
onCheckedChange={(checked) => toggleLifecycle(value, checked)}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AutomationDTO, NormalisedAutomation } from 'ontime-types';
|
||||
import { AutomationDTO, NormalisedAutomation, TimerLifeCycle } from 'ontime-types';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
|
||||
@@ -9,9 +9,11 @@ import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useAutomationUsage from '../../../../common/hooks-query/useAutomationUsage';
|
||||
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import AutomationForm from './AutomationForm';
|
||||
import RecipeLibraryModal from './RecipeLibraryModal';
|
||||
|
||||
import style from './AutomationsList.module.scss';
|
||||
|
||||
@@ -30,7 +32,10 @@ interface AutomationsListProps {
|
||||
|
||||
export default function AutomationsList({ automations, enabledAutomations, isLoading }: AutomationsListProps) {
|
||||
const { refetch } = useAutomationSettings();
|
||||
const { data: usage, refetch: refetchUsage } = useAutomationUsage();
|
||||
const [automationFormData, setAutomationFormData] = useState<AutomationDTO | null>(null);
|
||||
const [initialLifecycles, setInitialLifecycles] = useState<TimerLifeCycle[]>([]);
|
||||
const [isRecipeLibraryOpen, setIsRecipeLibraryOpen] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
@@ -41,6 +46,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
setDeleteError(maybeAxiosError(error));
|
||||
} finally {
|
||||
refetch();
|
||||
refetchUsage();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,13 +56,33 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{automationFormData !== null && (
|
||||
<AutomationForm automation={automationFormData} onClose={() => setAutomationFormData(null)} />
|
||||
<AutomationForm
|
||||
automation={automationFormData}
|
||||
initialLifecycles={initialLifecycles}
|
||||
onClose={() => setAutomationFormData(null)}
|
||||
/>
|
||||
)}
|
||||
{isRecipeLibraryOpen && (
|
||||
<RecipeLibraryModal
|
||||
onClose={() => setIsRecipeLibraryOpen(false)}
|
||||
onSelect={(automation, lifecycles) => {
|
||||
setInitialLifecycles(lifecycles);
|
||||
setAutomationFormData(automation);
|
||||
setIsRecipeLibraryOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setInitialLifecycles([]);
|
||||
setAutomationFormData(automationPlaceholder);
|
||||
}}
|
||||
>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
<Button onClick={() => setIsRecipeLibraryOpen(true)}>Recipes</Button>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
@@ -75,6 +101,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
<th style={{ width: '15%' }}>Filter rule</th>
|
||||
<th style={{ width: '15%' }}>Filters</th>
|
||||
<th style={{ width: '15%' }}>Sends</th>
|
||||
<th style={{ width: '10%' }}>Usage</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -84,7 +111,13 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
title='No automations yet'
|
||||
description='Create a reusable definition, then attach it to a global or an event trigger.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setAutomationFormData(automationPlaceholder)}>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => {
|
||||
setInitialLifecycles([]);
|
||||
setAutomationFormData(automationPlaceholder);
|
||||
}}
|
||||
>
|
||||
Create automation <IoAdd />
|
||||
</Button>
|
||||
}
|
||||
@@ -111,6 +144,11 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
))
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{usage?.[automationId]
|
||||
? `${usage[automationId].global} global, ${usage[automationId].event} event`
|
||||
: '—'}
|
||||
</td>
|
||||
<Panel.InlineElements align='end' relation='inner' as='td'>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
@@ -133,7 +171,7 @@ export default function AutomationsList({ automations, enabledAutomations, isLoa
|
||||
})}
|
||||
{deleteError && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<td colSpan={6}>
|
||||
<Panel.Error>{deleteError}</Panel.Error>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
|
||||
import { useState } from 'react';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { automationRecipes, defaultRecipeValues, type AutomationRecipe, type RecipeValues } from './automationRecipes';
|
||||
|
||||
interface RecipeLibraryModalProps {
|
||||
onClose: () => void;
|
||||
onSelect: (automation: AutomationDTO, lifecycles: TimerLifeCycle[]) => void;
|
||||
}
|
||||
|
||||
export default function RecipeLibraryModal({ onClose, onSelect }: RecipeLibraryModalProps) {
|
||||
const [recipe, setRecipe] = useState<AutomationRecipe>();
|
||||
const [values, setValues] = useState<RecipeValues>({});
|
||||
|
||||
const selectRecipe = (nextRecipe: AutomationRecipe) => {
|
||||
setRecipe(nextRecipe);
|
||||
setValues(defaultRecipeValues(nextRecipe));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
title={recipe ? recipe.title : 'Automation recipes'}
|
||||
bodyElements={
|
||||
recipe ? (
|
||||
<div>
|
||||
<Panel.Description>{recipe.description}</Panel.Description>
|
||||
{recipe.params.map((param) => (
|
||||
<label key={param.name}>
|
||||
{param.label}
|
||||
<Input
|
||||
fluid
|
||||
type={param.type ?? 'text'}
|
||||
value={values[param.name] ?? ''}
|
||||
onChange={(event) => setValues((current) => ({ ...current, [param.name]: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Panel.ListGroup>
|
||||
{automationRecipes.map((candidate) => (
|
||||
<Panel.ListItem key={candidate.id}>
|
||||
<Panel.Field title={candidate.title} description={candidate.description} />
|
||||
<Button onClick={() => selectRecipe(candidate)}>Choose</Button>
|
||||
</Panel.ListItem>
|
||||
))}
|
||||
</Panel.ListGroup>
|
||||
)
|
||||
}
|
||||
footerElements={
|
||||
recipe ? (
|
||||
<>
|
||||
<Button onClick={() => setRecipe(undefined)}>Back</Button>
|
||||
<Button variant='primary' onClick={() => onSelect(recipe.build(values), recipe.lifecycles)}>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { TimerLifeCycle, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
|
||||
|
||||
import { automationRecipes, defaultRecipeValues } from '../automationRecipes';
|
||||
|
||||
describe('automation recipes', () => {
|
||||
it('builds valid OSC, HTTP, and Ontime definitions with explicit global lifecycles', () => {
|
||||
const built = automationRecipes.map((recipe) => ({
|
||||
recipe,
|
||||
automation: recipe.build(defaultRecipeValues(recipe)),
|
||||
}));
|
||||
|
||||
expect(built.some(({ automation }) => automation.outputs.some(isOSCOutput))).toBe(true);
|
||||
expect(built.some(({ automation }) => automation.outputs.some(isHTTPOutput))).toBe(true);
|
||||
expect(built.some(({ automation }) => automation.outputs.some(isOntimeAction))).toBe(true);
|
||||
expect(built.every(({ recipe }) => recipe.lifecycles.length > 0)).toBe(true);
|
||||
expect(built.flatMap(({ recipe }) => recipe.lifecycles)).toContain(TimerLifeCycle.onStart);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { AutomationDTO, TimerLifeCycle } from 'ontime-types';
|
||||
import { TimerLifeCycle as Cycle } from 'ontime-types';
|
||||
|
||||
export type RecipeValues = Record<string, string>;
|
||||
|
||||
export type AutomationRecipe = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
lifecycles: TimerLifeCycle[];
|
||||
params: { name: string; label: string; defaultValue: string; type?: 'number' }[];
|
||||
build: (values: RecipeValues) => AutomationDTO;
|
||||
};
|
||||
|
||||
function definition(title: string, outputs: AutomationDTO['outputs']): AutomationDTO {
|
||||
return { title, filterRule: 'all', filters: [], outputs };
|
||||
}
|
||||
|
||||
export const automationRecipes: AutomationRecipe[] = [
|
||||
{
|
||||
id: 'qlab-go',
|
||||
title: 'QLab — fire the matching cue',
|
||||
description: 'Sends an OSC GO message for the event cue when an event starts.',
|
||||
lifecycles: [Cycle.onStart],
|
||||
params: [
|
||||
{ name: 'host', label: 'QLab host', defaultValue: '127.0.0.1' },
|
||||
{ name: 'port', label: 'OSC port', defaultValue: '53000', type: 'number' },
|
||||
],
|
||||
build: ({ host, port }) =>
|
||||
definition('QLab GO on event start', [
|
||||
{
|
||||
type: 'osc',
|
||||
targetIP: host.trim(),
|
||||
targetPort: Number(port),
|
||||
address: '/cue/{{eventNow.cue}}/start',
|
||||
args: '',
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'webhook-event-title',
|
||||
title: 'Webhook — send the event title',
|
||||
description: 'Calls a webhook with the running event title when an event starts.',
|
||||
lifecycles: [Cycle.onStart],
|
||||
params: [{ name: 'url', label: 'Webhook URL', defaultValue: 'http://127.0.0.1:3000/ontime' }],
|
||||
build: ({ url }) =>
|
||||
definition('Send event title to webhook', [
|
||||
{ type: 'http', url: `${url.trim()}${url.includes('?') ? '&' : '?'}title={{url:eventNow.title}}` },
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'ontime-stage-warning',
|
||||
title: 'Ontime — show a stage warning',
|
||||
description: 'Shows a message in Ontime when an event reaches its danger window.',
|
||||
lifecycles: [Cycle.onDanger],
|
||||
params: [{ name: 'message', label: 'Message', defaultValue: 'Please wrap up' }],
|
||||
build: ({ message }) =>
|
||||
definition('Warn the stage at danger', [{ type: 'ontime', action: 'message-set', text: message, visible: true }]),
|
||||
},
|
||||
];
|
||||
|
||||
export function defaultRecipeValues(recipe: AutomationRecipe): RecipeValues {
|
||||
return Object.fromEntries(recipe.params.map((param) => [param.name, param.defaultValue]));
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Automation, ErrorResponse } from 'ontime-types';
|
||||
import type { Automation, AutomationComposition, ErrorResponse } from 'ontime-types';
|
||||
|
||||
import { editAutomation, postAutomation } from '../automation.controller.js';
|
||||
import { editAutomation, postAutomation, postAutomationComposition } from '../automation.controller.js';
|
||||
import * as automationDao from '../automation.dao.js';
|
||||
|
||||
vi.mock('../automation.dao.js', () => ({
|
||||
addAutomation: vi.fn(),
|
||||
createAutomationComposition: vi.fn(),
|
||||
editAutomation: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -28,6 +29,15 @@ describe('automation controllers', () => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(automationDao.addAutomation).mockImplementation(async (automation) => ({ id: 'new-id', ...automation }));
|
||||
vi.mocked(automationDao.editAutomation).mockImplementation(async (id, automation) => ({ id, ...automation }));
|
||||
vi.mocked(automationDao.createAutomationComposition).mockImplementation(async ({ automation, lifecycles }) => ({
|
||||
automation: { id: 'new-id', ...automation },
|
||||
triggers: lifecycles.map((trigger, index) => ({
|
||||
id: String(index),
|
||||
title: `Global ${trigger}`,
|
||||
trigger,
|
||||
automationId: 'new-id',
|
||||
})),
|
||||
}));
|
||||
});
|
||||
|
||||
it('persists normalized outputs when creating an automation', async () => {
|
||||
@@ -81,4 +91,19 @@ describe('automation controllers', () => {
|
||||
expect.objectContaining({ message: 'Automation definitions cannot include triggers' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a definition and global lifecycle bindings through the composition command', async () => {
|
||||
const response = makeResponse() as unknown as Response<AutomationComposition | ErrorResponse>;
|
||||
const request = {
|
||||
body: { automation: requestBody, lifecycles: ['onStart', 'onFinish'] },
|
||||
} as Request;
|
||||
|
||||
await postAutomationComposition(request, response);
|
||||
|
||||
expect(automationDao.createAutomationComposition).toHaveBeenCalledWith({
|
||||
automation: expect.objectContaining({ title: 'OSC definition' }),
|
||||
lifecycles: ['onStart', 'onFinish'],
|
||||
});
|
||||
expect(response.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Automation, AutomationDTO, ProjectRundowns, TimerLifeCycle, TriggerDTO
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import {
|
||||
addAutomation,
|
||||
createAutomationComposition,
|
||||
addTrigger,
|
||||
deleteAll,
|
||||
deleteAllTriggers,
|
||||
@@ -137,6 +138,37 @@ describe('addAutomation()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAutomationComposition()', () => {
|
||||
beforeEach(async () => {
|
||||
await deleteAll();
|
||||
});
|
||||
|
||||
it('writes a definition and all requested global bindings together', async () => {
|
||||
const composition = await createAutomationComposition({
|
||||
automation: { title: 'New definition', filterRule: 'all', filters: [], outputs: [makeHTTPAction()] },
|
||||
lifecycles: [TimerLifeCycle.onStart, TimerLifeCycle.onFinish],
|
||||
});
|
||||
|
||||
expect(getAutomations()[composition.automation.id]).toEqual(composition.automation);
|
||||
expect(getAutomationTriggers()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onStart }),
|
||||
expect.objectContaining({ automationId: composition.automation.id, trigger: TimerLifeCycle.onFinish }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes only a reusable definition when no lifecycle is selected', async () => {
|
||||
const composition = await createAutomationComposition({
|
||||
automation: { title: 'Reusable definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: [],
|
||||
});
|
||||
|
||||
expect(getAutomations()[composition.automation.id]).toEqual(composition.automation);
|
||||
expect(getAutomationTriggers()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editAutomation()', () => {
|
||||
// saving the ID of the added automation
|
||||
let firstAutomation: Automation;
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { ProjectRundowns, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { isAutomationUsed, isHostname, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
|
||||
import {
|
||||
getAutomationUsage,
|
||||
isAutomationUsed,
|
||||
isHostname,
|
||||
parseTemplateNested,
|
||||
stringToOSCArgs,
|
||||
} from '../automation.utils.js';
|
||||
|
||||
describe('isHostname()', () => {
|
||||
it.each(['localhost', 'qlab', 'osc.example.com', 'osc-target.example'])('accepts %s', (hostname) => {
|
||||
@@ -367,3 +373,52 @@ describe('isAutomationUsed()', () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutomationUsage()', () => {
|
||||
it('counts global and event bindings across multiple rundowns without counting missing references', () => {
|
||||
const usage = getAutomationUsage(
|
||||
{
|
||||
used: { id: 'used', title: 'Used', filterRule: 'all', filters: [], outputs: [] },
|
||||
unused: { id: 'unused', title: 'Unused', filterRule: 'all', filters: [], outputs: [] },
|
||||
},
|
||||
[
|
||||
{ id: 'global-1', title: 'Global', trigger: TimerLifeCycle.onStart, automationId: 'used' },
|
||||
{ id: 'global-missing', title: 'Missing', trigger: TimerLifeCycle.onStart, automationId: 'missing' },
|
||||
],
|
||||
{
|
||||
one: {
|
||||
id: 'one',
|
||||
title: 'One',
|
||||
order: ['event-1'],
|
||||
flatOrder: ['event-1'],
|
||||
entries: {
|
||||
'event-1': makeOntimeEvent({
|
||||
id: 'event-1',
|
||||
triggers: [
|
||||
{ id: 'event-trigger-1', title: 'Event', trigger: TimerLifeCycle.onFinish, automationId: 'used' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
two: {
|
||||
id: 'two',
|
||||
title: 'Two',
|
||||
order: ['event-2'],
|
||||
flatOrder: ['event-2'],
|
||||
entries: {
|
||||
'event-2': makeOntimeEvent({
|
||||
id: 'event-2',
|
||||
triggers: [
|
||||
{ id: 'event-trigger-2', title: 'Event', trigger: TimerLifeCycle.onFinish, automationId: 'used' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(usage).toEqual({ used: { global: 1, event: 2 }, unused: { global: 0, event: 0 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseAutomation, parseOutput } from '../automation.validation.js';
|
||||
import { parseAutomation, parseAutomationComposition, parseOutput } from '../automation.validation.js';
|
||||
|
||||
describe('parseAutomation', () => {
|
||||
it('rejects trigger bindings from definition payloads', () => {
|
||||
@@ -8,6 +8,29 @@ describe('parseAutomation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAutomationComposition', () => {
|
||||
it('accepts a reusable definition without global lifecycles', () => {
|
||||
expect(
|
||||
parseAutomationComposition({
|
||||
automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: [],
|
||||
}),
|
||||
).toEqual({
|
||||
automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate global lifecycles', () => {
|
||||
expect(() =>
|
||||
parseAutomationComposition({
|
||||
automation: { title: 'Definition', filterRule: 'all', filters: [], outputs: [] },
|
||||
lifecycles: ['onStart', 'onStart'],
|
||||
}),
|
||||
).toThrow('Duplicate automation lifecycle: onStart');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseOutput', () => {
|
||||
describe('handles OSC outputs', () => {
|
||||
it('parses a valid payload', () => {
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-types';
|
||||
import {
|
||||
Automation,
|
||||
AutomationComposition,
|
||||
AutomationSettings,
|
||||
AutomationUsage,
|
||||
ErrorResponse,
|
||||
Trigger,
|
||||
} from 'ontime-types';
|
||||
import { getErrorMessage } from 'ontime-utils';
|
||||
|
||||
import { oscServer } from '../../adapters/OscAdapter.js';
|
||||
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
|
||||
import * as automationDao from './automation.dao.js';
|
||||
import * as automationService from './automation.service.js';
|
||||
import { parseAutomation, parseOutput } from './automation.validation.js';
|
||||
import { getAutomationUsage } from './automation.utils.js';
|
||||
import { parseAutomation, parseAutomationComposition, parseOutput } from './automation.validation.js';
|
||||
|
||||
export function getAutomationSettings(_req: Request, res: Response<AutomationSettings>) {
|
||||
res.status(200).json(automationDao.getAutomationSettings());
|
||||
}
|
||||
|
||||
export function getAutomationUsageCounts(_req: Request, res: Response<AutomationUsage>) {
|
||||
const settings = automationDao.getAutomationSettings();
|
||||
res
|
||||
.status(200)
|
||||
.json(getAutomationUsage(settings.automations, settings.triggers, getDataProvider().getProjectRundowns()));
|
||||
}
|
||||
|
||||
export async function postAutomationSettings(req: Request, res: Response<AutomationSettings | ErrorResponse>) {
|
||||
try {
|
||||
// body payload is a patch object that must contain root properties
|
||||
@@ -84,6 +99,17 @@ export async function postAutomation(req: Request, res: Response<Automation | Er
|
||||
}
|
||||
}
|
||||
|
||||
export async function postAutomationComposition(req: Request, res: Response<AutomationComposition | ErrorResponse>) {
|
||||
try {
|
||||
const composition = parseAutomationComposition(req.body);
|
||||
const created = await automationDao.createAutomationComposition(composition);
|
||||
res.status(201).send(created);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
res.status(400).send({ message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function editAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
|
||||
try {
|
||||
const automation = parseAutomation(req.body);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationComposition,
|
||||
AutomationCompositionDTO,
|
||||
AutomationDTO,
|
||||
AutomationSettings,
|
||||
NormalisedAutomation,
|
||||
@@ -121,6 +123,37 @@ export async function addAutomation(newAutomation: AutomationDTO): Promise<Autom
|
||||
return automations[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a definition and its global bindings with one settings write. An empty lifecycle list
|
||||
* deliberately creates a reusable definition only.
|
||||
*/
|
||||
export async function createAutomationComposition({
|
||||
automation: newAutomation,
|
||||
lifecycles,
|
||||
}: AutomationCompositionDTO): Promise<AutomationComposition> {
|
||||
const settings = getAutomationSettings();
|
||||
const automations = { ...settings.automations };
|
||||
const triggers = [...settings.triggers];
|
||||
const automationId = getUniqueAutomationId(automations);
|
||||
const automation: Automation = { ...newAutomation, id: automationId };
|
||||
const newTriggers: Trigger[] = [];
|
||||
for (const trigger of lifecycles) {
|
||||
const binding = {
|
||||
id: getUniqueTriggerId([...triggers, ...newTriggers]),
|
||||
title: `Global ${trigger}`,
|
||||
trigger,
|
||||
automationId,
|
||||
};
|
||||
newTriggers.push(binding);
|
||||
}
|
||||
|
||||
automations[automationId] = automation;
|
||||
triggers.push(...newTriggers);
|
||||
await getDataProvider().setAutomation({ ...settings, automations, triggers });
|
||||
|
||||
return { automation, triggers: newTriggers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing automation with a new entry
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
deleteTrigger,
|
||||
editAutomation,
|
||||
getAutomationSettings,
|
||||
getAutomationUsageCounts,
|
||||
postAutomationComposition,
|
||||
postAutomation,
|
||||
postAutomationSettings,
|
||||
postTrigger,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
} from './automation.controller.js';
|
||||
import {
|
||||
validateAutomation,
|
||||
validateAutomationComposition,
|
||||
validateAutomationPatch,
|
||||
validateAutomationSettings,
|
||||
validateTestPayload,
|
||||
@@ -24,6 +27,7 @@ import {
|
||||
export const router: Router = express.Router();
|
||||
|
||||
router.get('/', getAutomationSettings);
|
||||
router.get('/usage', getAutomationUsageCounts);
|
||||
router.post('/', validateAutomationSettings, postAutomationSettings);
|
||||
|
||||
router.post('/trigger', validateTrigger, postTrigger);
|
||||
@@ -31,6 +35,7 @@ router.put('/trigger/:id', validateTriggerPatch, putTrigger);
|
||||
router.delete('/trigger/:id', paramsWithId, deleteTrigger);
|
||||
|
||||
router.post('/automation', validateAutomation, postAutomation);
|
||||
router.post('/composition', validateAutomationComposition, postAutomationComposition);
|
||||
router.put('/automation/:id', validateAutomationPatch, editAutomation);
|
||||
router.delete('/automation/:id', paramsWithId, deleteAutomation);
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
AutomationFilter,
|
||||
AutomationUsage,
|
||||
EntryId,
|
||||
FilterRule,
|
||||
MaybeNumber,
|
||||
OntimeAction,
|
||||
NormalisedAutomation,
|
||||
ProjectRundowns,
|
||||
RundownEntries,
|
||||
Trigger,
|
||||
isOntimeEvent,
|
||||
ontimeActionKeyValues,
|
||||
} from 'ontime-types';
|
||||
@@ -283,3 +286,32 @@ export function isAutomationUsed(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Counts global and event bindings in one pass over global triggers and project rundowns. */
|
||||
export function getAutomationUsage(
|
||||
automations: NormalisedAutomation,
|
||||
globalTriggers: Trigger[],
|
||||
projectRundowns: ProjectRundowns,
|
||||
): AutomationUsage {
|
||||
const usage: AutomationUsage = Object.fromEntries(
|
||||
Object.keys(automations).map((automationId) => [automationId, { global: 0, event: 0 }]),
|
||||
);
|
||||
|
||||
for (const trigger of globalTriggers) {
|
||||
const count = usage[trigger.automationId];
|
||||
if (count) count.global += 1;
|
||||
}
|
||||
|
||||
for (const rundown of Object.values(projectRundowns)) {
|
||||
for (const entryId of rundown.flatOrder) {
|
||||
const entry = rundown.entries[entryId];
|
||||
if (!isOntimeEvent(entry)) continue;
|
||||
for (const trigger of entry.triggers ?? []) {
|
||||
const count = usage[trigger.automationId];
|
||||
if (count) count.event += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isIP } from 'node:net';
|
||||
import { body, param } from 'express-validator';
|
||||
import {
|
||||
AutomationDTO,
|
||||
AutomationCompositionDTO,
|
||||
AutomationFilter,
|
||||
AutomationOutput,
|
||||
HTTPOutput,
|
||||
@@ -48,6 +49,8 @@ export const validateTriggerPatch = [
|
||||
|
||||
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
|
||||
|
||||
export const validateAutomationComposition = [body().custom(parseAutomationComposition), requestValidationFunction];
|
||||
|
||||
export const validateAutomationPatch = [
|
||||
param('id').isString().notEmpty(),
|
||||
body().custom(parseAutomation),
|
||||
@@ -81,6 +84,31 @@ export function parseAutomation(maybeAutomation: unknown): AutomationDTO {
|
||||
return { title, filterRule, filters, outputs: parsedOutputs };
|
||||
}
|
||||
|
||||
/** Parses a create-only command for a reusable definition and its global bindings. */
|
||||
export function parseAutomationComposition(maybeComposition: unknown): AutomationCompositionDTO {
|
||||
assert.isObject(maybeComposition);
|
||||
assert.hasKeys(maybeComposition, ['automation', 'lifecycles']);
|
||||
|
||||
const { automation, lifecycles } = maybeComposition;
|
||||
const parsedAutomation = parseAutomation(automation);
|
||||
assert.isArray(lifecycles);
|
||||
|
||||
const parsedLifecycles = lifecycles.map((lifecycle) => {
|
||||
assert.isString(lifecycle);
|
||||
if (!timerLifecycleValues.includes(lifecycle)) {
|
||||
throw new Error(`Invalid automation lifecycle: ${lifecycle}`);
|
||||
}
|
||||
return lifecycle as AutomationCompositionDTO['lifecycles'][number];
|
||||
});
|
||||
|
||||
const duplicate = parsedLifecycles.find((lifecycle, index) => parsedLifecycles.indexOf(lifecycle) !== index);
|
||||
if (duplicate) {
|
||||
throw new Error(`Duplicate automation lifecycle: ${duplicate}`);
|
||||
}
|
||||
|
||||
return { automation: parsedAutomation, lifecycles: parsedLifecycles };
|
||||
}
|
||||
|
||||
function validateFilters(filters: Array<unknown>): asserts filters is AutomationFilter[] {
|
||||
filters.forEach((condition) => {
|
||||
assert.isObject(condition);
|
||||
|
||||
@@ -22,6 +22,19 @@ export type Automation = {
|
||||
|
||||
export type AutomationDTO = Omit<Automation, 'id'>;
|
||||
|
||||
/** Creates one reusable definition and optional global lifecycle bindings in a single operation. */
|
||||
export type AutomationCompositionDTO = {
|
||||
automation: AutomationDTO;
|
||||
lifecycles: TimerLifeCycle[];
|
||||
};
|
||||
|
||||
export type AutomationComposition = {
|
||||
automation: Automation;
|
||||
triggers: Trigger[];
|
||||
};
|
||||
|
||||
export type AutomationUsage = Record<AutomationId, { global: number; event: number }>;
|
||||
|
||||
export type NormalisedAutomation = Record<AutomationId, Automation>;
|
||||
|
||||
export type Trigger = {
|
||||
|
||||
@@ -31,9 +31,12 @@ export { ontimeActionKeyValues } from './definitions/core/Automation.type.js';
|
||||
export type {
|
||||
OntimeActionKey,
|
||||
Automation,
|
||||
AutomationComposition,
|
||||
AutomationCompositionDTO,
|
||||
AutomationDTO,
|
||||
AutomationFilter,
|
||||
AutomationSettings,
|
||||
AutomationUsage,
|
||||
AutomationOutput,
|
||||
FilterRule,
|
||||
HTTPOutput,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# Automation UI Foundation and Global Composer Tasks
|
||||
|
||||
## PR 1 — automation UI foundation
|
||||
|
||||
### Completed implementation
|
||||
|
||||
- [x] Start from clean `master` and retain the mixed branch as reference only.
|
||||
- [x] Keep automation create/edit payloads definition-only.
|
||||
- [x] Preserve global and event trigger references when definitions are edited.
|
||||
- [x] Refuse deletion of definitions referenced by global triggers or rundown events.
|
||||
- [x] Reject trigger bindings submitted to definition CRUD.
|
||||
- [x] Persist normalized output values returned by server validation.
|
||||
- [x] Support the full filter-operator contract, including `not_contains`.
|
||||
- [x] Extract focused OSC, HTTP, Ontime, and output-card form components.
|
||||
- [x] Add honest per-output test success/error feedback and failed-save retry behavior.
|
||||
- [x] Add shared lifecycle labels and output summaries.
|
||||
- [x] Clarify global trigger scope, duplicate behavior, and missing references.
|
||||
- [x] Improve event trigger lifecycle labels and output visibility.
|
||||
- [x] Keep recipes, composer code, and lifecycle-on-definition state out of the PR.
|
||||
- [x] Cover shared lifecycle labels, the full filter-operator contract, controller rejection of trigger fields, and
|
||||
definition output replacement with focused regression tests.
|
||||
|
||||
### Required before merge
|
||||
|
||||
- [x] Accept IPv6 OSC targets as well as IPv4 and hostnames.
|
||||
- [x] Add a parser regression test using an IPv6 target such as `::1`.
|
||||
- [x] Keep runtime-template hostname support and normalized persistence intact.
|
||||
- [x] Remove stale output-form styles that no rendered component references.
|
||||
- `oscSection`
|
||||
- `httpSection`
|
||||
- `actionSection`
|
||||
- `outputCard`
|
||||
- nested `test`
|
||||
- [x] Run the browser smoke check:
|
||||
- create and edit a definition;
|
||||
- add, remove, and test OSC, HTTP, and Ontime outputs;
|
||||
- retry a save after a server error;
|
||||
- create/edit a global trigger;
|
||||
- attach the same definition to an event;
|
||||
- verify missing and duplicate states;
|
||||
- verify narrow-panel horizontal scrolling and sticky headers.
|
||||
- [x] Show the concise server validation message after a failed save instead of the full raw 422 response and submitted
|
||||
definition; keep the form open and retryable.
|
||||
- [x] Re-run final verification and inspect the final diff for temporary code, stale comments, generated files, and
|
||||
recipe/composer leakage.
|
||||
|
||||
### Verification already observed
|
||||
|
||||
- [x] Server test pipeline: 51 files passed; 778 tests passed; 6 todo.
|
||||
- [x] Client test pipeline: 34 files passed; 276 tests passed.
|
||||
- [x] Client and server type checks passed.
|
||||
- [x] Client and server lint passed.
|
||||
- [x] Format check passed.
|
||||
- [x] Client and server builds passed with only existing Vite warnings.
|
||||
- [x] `git diff --check master...HEAD` passed.
|
||||
|
||||
### PR 1 checkpoint
|
||||
|
||||
- [x] IPv6 compatibility and stale-style cleanup are committed to their owning commits.
|
||||
- [x] Added PR 1 regression coverage is committed separately from production behavior.
|
||||
- [x] Browser smoke check is recorded.
|
||||
- [x] PR 1 is self-sufficient and ready for human review.
|
||||
|
||||
## PR 2 — global automation composer and recipes
|
||||
|
||||
### Task 1: Define the composition contract
|
||||
|
||||
- [ ] Add a create-only command containing a definition plus zero or more global lifecycles.
|
||||
- [ ] Return the created definition and trigger bindings.
|
||||
- [ ] Keep persisted `Automation` and `Trigger` entities and ordinary CRUD unchanged.
|
||||
- [ ] Document that zero lifecycles saves a reusable definition only.
|
||||
|
||||
### Task 2: Add atomic global composition creation
|
||||
|
||||
- [ ] Validate the definition and every lifecycle before writing.
|
||||
- [ ] Normalize or reject duplicate lifecycle inputs consistently.
|
||||
- [ ] Assign IDs and persist the definition and bindings in one settings write.
|
||||
- [ ] Test success, invalid input, duplicate inputs, and no-partial-write failure behavior.
|
||||
|
||||
### Task 3: Build the manual composer
|
||||
|
||||
- [ ] Present separate Global trigger, Conditions, and Actions sections.
|
||||
- [ ] Reuse the PR 1 output editor components where their contracts fit.
|
||||
- [ ] Support multiple global lifecycles and definition-only save.
|
||||
- [ ] Test failed-save retry and refresh of both definitions and triggers.
|
||||
|
||||
### Task 4: Move recipes onto the composer
|
||||
|
||||
- [ ] Restore the recipe catalog and parameter setup without restoring trigger ownership to definitions.
|
||||
- [ ] Make every recipe declare global lifecycle defaults explicitly.
|
||||
- [ ] Submit manual and recipe flows through the same composition command.
|
||||
- [ ] Test representative OSC, HTTP, and Ontime recipes.
|
||||
|
||||
### Task 5: Evaluate complete usage counts
|
||||
|
||||
- [ ] Implement a pure aggregation over global triggers and all project rundowns.
|
||||
- [ ] Test zero usage, both scopes, multiple rundowns, duplicates, and missing references.
|
||||
- [ ] Confirm the read path performs no repeated per-definition scans or persisted counting.
|
||||
- [ ] Ship the UI count only if both global and event usage are complete and cheap; otherwise record the decision to
|
||||
defer it.
|
||||
|
||||
### Task 6: Integrate and verify
|
||||
|
||||
- [ ] Keep event-scoped recipes and event-composer navigation absent.
|
||||
- [ ] Verify disabled automations, errors, modal keyboard behavior, and narrow layouts.
|
||||
- [ ] Run full tests, type checks, lint, formatting, and builds.
|
||||
- [ ] Review the final diff for stale compatibility code and scope leakage.
|
||||
|
||||
### PR 2 checkpoint
|
||||
|
||||
- [ ] Manual and recipe flows create global compositions atomically.
|
||||
- [ ] Reusable definitions remain independently editable and attachable in both scopes.
|
||||
- [ ] Usage information is complete or intentionally omitted.
|
||||
- [ ] PR 2 is ready for human review.
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
# Automation UI Foundation and Global Composer Plan
|
||||
|
||||
## Decision
|
||||
|
||||
Keep the work split into two pull requests:
|
||||
|
||||
1. **PR 1 — automation UI foundation:** reusable definition editing, clearer global/event trigger surfaces, output
|
||||
editing, and validation hardening.
|
||||
2. **PR 2 — global automation composer and recipes:** one create flow that produces a reusable definition and zero or
|
||||
more global trigger bindings.
|
||||
|
||||
The implementation confirms that this boundary is workable. PR 1 is useful without recipes and does not move lifecycle
|
||||
ownership into automation definitions. PR 2 can therefore build on a stable definition/trigger model instead of
|
||||
repairing the old mixed branch.
|
||||
|
||||
## Architecture decisions
|
||||
|
||||
- An automation definition owns its title, filters, and outputs.
|
||||
- A trigger owns lifecycle, scope, and the reference to a definition.
|
||||
- The composer is a create-time orchestration over those two existing resources; it is not a new persisted entity.
|
||||
- The first composer creates global bindings only. Event-scoped recipes remain out of scope.
|
||||
- Users may continue editing definitions and triggers independently after creation.
|
||||
- Recipes use the same composition command as the manual composer.
|
||||
- Usage is derived, not persisted. Show it only if complete global and event counts can be obtained with one bounded
|
||||
aggregation; otherwise omit it.
|
||||
|
||||
## Current PR 1 assessment
|
||||
|
||||
Branch: `automation-ui`
|
||||
|
||||
Commits:
|
||||
|
||||
- `b38e5de9` — template-aware output validation and normalized persistence.
|
||||
- `1732c7cc` — definition/output editing, clearer global/event trigger presentation, and regression coverage for the
|
||||
definition/trigger boundary and shared presentation helpers.
|
||||
|
||||
The two commits can remain in one PR. Although the total diff is slightly above the normal review target, much of it is
|
||||
the extraction of the existing output form into focused components, and the final behavior is one coherent foundation.
|
||||
Do not create a third PR solely for the validation commit.
|
||||
|
||||
### Review result
|
||||
|
||||
The code-review findings are resolved:
|
||||
|
||||
- IPv6 support is restored in OSC target validation with a `::1` regression test while IPv4, hostname, and runtime
|
||||
template handling remain covered.
|
||||
- The obsolete `oscSection`, `httpSection`, `actionSection`, `outputCard`, and nested `test` styles left behind by the
|
||||
output-card extraction have been removed.
|
||||
|
||||
The browser smoke check passed for definition creation/editing, all output types, failed-save retry, global trigger
|
||||
editing, event attachment, duplicate and missing-reference states, and narrow-panel scrolling with sticky headers. It
|
||||
revealed one small UX issue which is now fixed: server-side validation failures show the concise validation message (for
|
||||
example, `Invalid OSC target`) instead of the complete raw 422 response and submitted definition.
|
||||
|
||||
No recipe, composer, lifecycle-on-definition, or event-scoped recipe work should be added while closing these items.
|
||||
|
||||
## PR 2 sequence
|
||||
|
||||
1. Define a create-only global composition request and response without changing definition CRUD.
|
||||
2. Implement atomic server creation of one definition plus distinct global lifecycle bindings.
|
||||
3. Build a manual `When -> If -> Then` composer using the PR 1 output editors.
|
||||
4. Move recipes onto the same composition command and label their global scope explicitly.
|
||||
5. Spike complete usage aggregation across global triggers and all project rundowns; ship the count only if the bounded
|
||||
scan is cheap and does not widen persisted state.
|
||||
6. Verify refresh, error recovery, disabled-automation behavior, responsive layout, and representative recipes.
|
||||
|
||||
## Pull request boundaries
|
||||
|
||||
### PR 1 includes
|
||||
|
||||
- Definition-only create/edit behavior and rejection of trigger fields at that boundary.
|
||||
- Reference-safe deletion behavior and tests for global/event usages.
|
||||
- Output cards, output summaries, test feedback, shared lifecycle labels, and trigger clarity.
|
||||
- Template-aware OSC/HTTP validation.
|
||||
- The minimum shared scrolling changes required by these screens.
|
||||
|
||||
### PR 1 excludes
|
||||
|
||||
- Recipe catalog or recipe picker.
|
||||
- Composer API or UI.
|
||||
- Lifecycle fields on automation definitions.
|
||||
- Trigger reconciliation in definition CRUD.
|
||||
- Usage counts.
|
||||
- Event-scoped recipes.
|
||||
|
||||
### PR 2 includes
|
||||
|
||||
- A dedicated composition contract and atomic global creation path.
|
||||
- Manual and recipe-driven composition using the same path.
|
||||
- Definition-only save as an explicit composer option.
|
||||
- Complete usage counts only if the aggregation spike meets the cost constraint.
|
||||
|
||||
### PR 2 excludes
|
||||
|
||||
- Event-scoped recipes or an event composer.
|
||||
- Persisted usage counters.
|
||||
- Trigger schema/title migration.
|
||||
- Editing shared definitions through trigger rows.
|
||||
- A general workflow engine.
|
||||
|
||||
## Verification gates
|
||||
|
||||
PR 1 must pass focused and full client/server automation tests, client/server type checks and lint, formatting, builds,
|
||||
diff hygiene, and the browser smoke check. PR 2 repeats those gates and adds tests for atomicity, duplicate lifecycle
|
||||
inputs, manual creation, recipe creation, failed-save retry, and any usage aggregation that ships.
|
||||
|
||||
Detailed progress is tracked in `tasks/automation-composer-todo.md`; the unrelated teleprompter plan in
|
||||
`tasks/todo.md` remains untouched.
|
||||
Reference in New Issue
Block a user