mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-17 03:53: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]));
|
||||
}
|
||||
Reference in New Issue
Block a user