feat(automation): add a recipe library

The panel started empty with no examples, so the first thing a new user saw
was a form that assumes they already know what OSC, a lifecycle and a template
string are. Eight recipes now cover the common integrations plus three that
need no external software at all, so the library is useful on a bare laptop.

A recipe is not a new kind of object. Installing one posts an ordinary
automation and its triggers through the same endpoints the form uses, then
opens it in the editor: seeing that it is editable is the point.

Every external target defaults to this machine, so a mis-click cannot put
traffic on a venue network, and a test asserts that along with the rest of
the server's automation contract, since recipes are constants that would
otherwise rot silently into something that 400s on install.

OSC recipes are hidden in the cloud build, where OSC output is skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
This commit is contained in:
Claude
2026-08-08 11:00:44 +00:00
parent 8ed708138d
commit f1bf8e8bee
7 changed files with 481 additions and 14 deletions
@@ -0,0 +1,116 @@
import type { Automation } from 'ontime-types';
import { useState } from 'react';
import { maybeAxiosError } from '../../../../../common/api/utils';
import Button from '../../../../../common/components/buttons/Button';
import Info from '../../../../../common/components/info/Info';
import ExternalLink from '../../../../../common/components/link/external-link/ExternalLink';
import Modal from '../../../../../common/components/modal/Modal';
import Tag from '../../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../../common/constants/timerLifecycle';
import { summariseOutputs } from '../../../../../common/utils/automationOutputs';
import { isOntimeCloud } from '../../../../../externals';
import * as Panel from '../../../panel-utils/PanelUtils';
import { automationRecipes, recipeCategoryLabels, recipeCategoryOrder, type AutomationRecipe } from './automationRecipes';
import { installRecipe } from './recipeUtils';
import style from './RecipeLibraryModal.module.scss';
interface RecipeLibraryModalProps {
onClose: () => void;
/** called with the installed automation so the caller can open it for editing */
onInstalled: (automation: AutomationRecipe, created: Automation) => void;
}
export default function RecipeLibraryModal({ onClose, onInstalled }: RecipeLibraryModalProps) {
const [installing, setInstalling] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// OSC is not available in the cloud service, offering those recipes there would be a lie
const available = isOntimeCloud
? automationRecipes.filter((recipe) => !recipe.automation.outputs.some((output) => output.type === 'osc'))
: automationRecipes;
const handleInstall = async (recipe: AutomationRecipe) => {
setError(null);
setInstalling(recipe.id);
try {
const created = await installRecipe(recipe);
onInstalled(recipe, created);
} catch (error) {
setError(maybeAxiosError(error));
} finally {
setInstalling(null);
}
};
return (
<Modal
isOpen
onClose={onClose}
showBackdrop
showCloseButton
size='wide'
title='Automation recipes'
bodyElements={
<div className={style.library}>
<Info>
<Info.Body>
Recipes are a starting point, not a black box. Each one is added as a normal automation that you can edit,
test or delete. Recipes that reach external software are set to this machine, so point them at the right
device before you rely on them.
</Info.Body>
</Info>
{recipeCategoryOrder.map((category) => {
const recipes = available.filter((recipe) => recipe.category === category);
if (recipes.length === 0) {
return null;
}
return (
<section key={category} className={style.category}>
<Panel.Title>{recipeCategoryLabels[category]}</Panel.Title>
<div className={style.recipeGrid}>
{recipes.map((recipe) => (
<article key={recipe.id} className={style.recipe}>
<div className={style.recipeTitle}>{recipe.title}</div>
<div className={style.recipeDescription}>{recipe.description}</div>
<Panel.InlineElements relation='inner' wrap='wrap'>
{recipe.triggers.map((cycle) => (
<Tag key={cycle}>{getLifecycleLabel(cycle)}</Tag>
))}
{summariseOutputs(recipe.automation.outputs).map(({ type, label, count }) => (
<Tag key={type}>{count > 1 ? `${label} ×${count}` : label}</Tag>
))}
{recipe.needsSetup && <Tag variant='warning'>Needs a target</Tag>}
</Panel.InlineElements>
<div className={style.recipeActions}>
{recipe.docsUrl && <ExternalLink href={recipe.docsUrl}>Docs</ExternalLink>}
<Button
variant='primary'
size='small'
loading={installing === recipe.id}
disabled={installing !== null}
onClick={() => handleInstall(recipe)}
>
{recipe.needsSetup ? 'Add and configure' : 'Add'}
</Button>
</div>
</article>
))}
</div>
</section>
);
})}
</div>
}
footerElements={
<>
{error && <Panel.Error>{error}</Panel.Error>}
<Button onClick={onClose}>Close</Button>
</>
}
/>
);
}