Files
ontime/apps/client/src/features/app-settings/panel/automations-panel/recipes/RecipeLibraryModal.tsx
T
Claude cdde054b14 fix(automation): review fixes across the automations rework
Reviewing the branch turned up defects, several of them worse than the
problems the original commits set out to solve.

Destructive: deleting an automation removed its global triggers and only then
discovered a rundown event also referenced it. The delete failed, the user
cancelled, and their triggers were gone for good. The server reports trigger
references before event ones, so this was reachable whenever both existed.
Deleted triggers are now recreated when the delete is refused.

Silently wrong: a template read the project from disk while writes are
debounced by three seconds, so "make some automations, save them as a
template" could produce a template without them. Verified by hand: an
automation created milliseconds earlier now appears.

The demo shipped half a pair. The danger warning set a stage message visible
and nothing ever cleared it, so from the first event that hit its danger
window the message covered the countdown for the rest of the session. The
recipe library ships the clearing counterpart; the demo now does too, and the
whole cycle is verified against a running server.

Flood control undid itself. resetAutomationLogState ran on every onLoad, and
roll mode loads at every event boundary, so the "logging suppressed" notice
was re-emitted once per cue: exactly the flooding it exists to prevent. It
also wiped the throttle for onLoad and onStop immediately before writing to
it. Reset now happens on stop only, outside the early returns that made the
first attempt at this a no-op.

Trigger reconciliation diffed a mount-time selection against a live prop.
Settings are polled, so a trigger created in another tab while the form was
open would be deleted by a save that never saw it. It now diffs against the
snapshot, and says which triggers a save will remove rather than removing
several same-lifecycle triggers silently.

Also: a rundowns template no longer carries a phantom empty rundown from
makeNewProject; the partial-duplicate endpoint returns the name it actually
used, since collisions get renamed; the template flow invalidates the project
list rather than relying on a refetch on mount; the last-fired label drops to
a one minute cadence instead of holding a 1Hz timer per automation forever;
e2e cleanup moved to afterEach so a mid-test failure stops leaking state.

The e2e spec has now been run against a real server, green on both the demo
project and a blank one, leaving no residue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LpbLJVVT26tzWkduck1M9H
2026-08-08 16:43:29 +00:00

122 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
}
/>
);
}