Files
ontime/apps/server/src/api-data/automation/automation.utils.ts
T
Claude 5ea3845f0d feat(automation): make a successful fire visible
Every logger call in the automation module was a failure path, so a working
automation and a misconfigured one looked identical: nothing happened either
way, as far as the user could see.

Two channels, because they answer different questions. A new AUTOMATION log
origin says what happened and when. A coalesced socket message feeds a "last
fired" column in the panel, which answers whether an automation is alive at
all: one that stays blank while its neighbours tick is the clearest sign that
a filter or a trigger is wrong.

Flood control is the whole difficulty here. onClock fires every second and the
logger queue holds 100 entries, so per-fire logging on a continuous lifecycle
would evict everything else within two minutes. Those cycles log a single
notice per load explaining the silence and nothing after; the rest dedupe
inside a one second window so a rapid reload does not spam. The socket message
still goes out for continuous cycles, throttled to once a second, because the
panel needs it to show the automation is running.

Log.tsx carried six copies of the same twelve line button. Adding a seventh
origin was the moment to collapse them, so the next one is free. The client
log store was also unbounded while the server queue is capped; it now holds
500 entries.

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

297 lines
8.6 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 {
AutomationOutput,
EntryId,
FilterRule,
MaybeNumber,
OntimeAction,
ProjectRundowns,
RundownEntries,
isOntimeEvent,
ontimeActionKeyValues,
} from 'ontime-types';
import { getPropertyFromPath, millisToString, removeLeadingZero, splitWhitespace } from 'ontime-utils';
import type { OscArgInput, OscArgOrArrayInput } from 'osc-min';
type FilterOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains';
export function isFilterOperator(value: string): value is FilterOperator {
return ['equals', 'not_equals', 'greater_than', 'less_than', 'contains'].includes(value);
}
export function isFilterRule(value: string): value is FilterRule {
return value === 'all' || value === 'any';
}
export function isOntimeActionAction(value: string): value is OntimeAction['action'] {
return ontimeActionKeyValues.includes(value);
}
/**
* Describes what an automation sends, for the log line of a successful fire
* @example 'OSC ×2, HTTP'
*/
export function summariseOutputs(outputs: AutomationOutput[]): string {
const labels: Record<AutomationOutput['type'], string> = { osc: 'OSC', http: 'HTTP', ontime: 'Ontime action' };
const counts = new Map<AutomationOutput['type'], number>();
for (const output of outputs) {
counts.set(output.type, (counts.get(output.type) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([type, count]) => (count > 1 ? `${labels[type]} ×${count}` : labels[type]))
.join(', ');
}
function toOscValue(argString: string): OscArgInput {
const argAsNum = Number(argString);
// NOTE: number like: 1 2.0 33333
if (!Number.isNaN(argAsNum)) {
return { type: argString.includes('.') ? 'float' : 'integer', value: argAsNum };
}
if (argString.startsWith('"') && argString.endsWith('"')) {
// NOTE: "quoted string"
return { type: 'string', value: argString.substring(1, argString.length - 1) };
}
if (argString === 'TRUE') {
// NOTE: Boolean true
return { type: 'true' };
}
if (argString === 'FALSE') {
// NOTE: Boolean false
return { type: 'false' };
}
// NOTE: string
return { type: 'string', value: argString };
}
export function stringToOSCArgs(argsString: string | undefined): OscArgInput | OscArgOrArrayInput[] | undefined {
if (typeof argsString === 'undefined' || argsString === '') return;
const matches = splitWhitespace(argsString);
if (!matches) return;
if (matches.length === 1) {
return toOscValue(matches[0]);
}
const parsedArguments: OscArgOrArrayInput[] = matches.map(toOscValue);
return parsedArguments;
}
// any value inside double curly braces {{val}}
const placeholderRegex = /{{(.*?)}}/g;
/**
* Parses a templated string to values in a nested object
*/
export function parseTemplateNested(template: string, state: object, humanReadable = quickAliases): string {
let parsedTemplate = template;
const matches = Array.from(parsedTemplate.matchAll(placeholderRegex));
for (const match of matches) {
const variableName = match[1];
const variableParts = variableName.split('.');
let value: string | undefined = undefined;
if (variableParts[0] === 'human') {
const lookupKey = variableParts[1];
if (lookupKey in humanReadable) {
const newTemplate = `{{${humanReadable[lookupKey].key}}}`;
const parsed = parseTemplateNested(newTemplate, state, humanReadable);
value = humanReadable[lookupKey].cb(parsed);
} else {
value = undefined;
}
} else {
// we cast to string since this will be used in a string context
value = getPropertyFromPath(variableName, state) as string;
}
if (value !== undefined) {
parsedTemplate = parsedTemplate.replace(match[0], value);
}
}
return parsedTemplate;
}
/**
* Handles the specific case where the MaybeNumber is encoded in a string
* After parsed, the value is formatted to a human-readable string
*/
function formatDisplayFromString(value: string, hideZero = false): string {
let valueInNumber: MaybeNumber = null;
// the value will be a string, so we need to convert it to the Maybe type
if (value !== 'null') {
const parsedValue = Number(value);
if (!Number.isNaN(parsedValue)) {
valueInNumber = parsedValue;
}
}
let formatted = millisToString(valueInNumber, { fallback: hideZero ? '00:00' : '00:00:00' });
if (hideZero) {
formatted = removeLeadingZero(formatted);
}
return formatted;
}
type AliasesDefinition = Record<string, { key: string; cb: (value: string) => string }>;
/**
* This object matches some common RuntimeState paths
* to a function that formats them to a human-readable string
*/
const quickAliases: AliasesDefinition = {
clock: { key: 'clock', cb: (value: string) => formatDisplayFromString(value) },
duration: { key: 'timer.duration', cb: (value: string) => formatDisplayFromString(value, true) },
expectedEnd: {
key: 'timer.expectedFinish',
cb: (value: string) => formatDisplayFromString(value),
},
runningTimer: {
key: 'timer.current',
cb: (value: string) => formatDisplayFromString(value, true),
},
elapsedTime: {
key: 'timer.elapsed',
cb: (value: string) => formatDisplayFromString(value, true),
},
startedAt: { key: 'timer.startedAt', cb: (value: string) => formatDisplayFromString(value) },
};
export function isEquivalent(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return isBooleanEquals(a, b);
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase() === b;
}
// overload the edge case where we use empty string to check if a value does not exist
// this also avoids the case where 0 == ''
if (b === '') {
return a === null || a === undefined;
}
return a == b;
}
export function isContained(a: unknown, b: string): boolean {
// handle the case where we are comparing boolean strings
if (typeof a === 'boolean') {
return false;
}
// make string comparisons case insensitive
if (typeof a === 'string') {
return a.toLowerCase().includes(b);
}
if (typeof a === 'number') {
return a.toString().toLowerCase().includes(b);
}
return false;
}
/**
* Utility encapsulates logic for comparing two strings which may encode numbers
* @example isGreaterThan('10', '5') // true
* @example isGreaterThan('5', '10') // false
* @example isGreaterThan('Ontime', 'Cool') // false
*/
export function isGreaterThan(a: unknown, b: string): boolean {
// If either value is not a number, there is no logical comparison to be made
if (typeof a !== 'number') {
return false;
}
const bValue = Number(b);
if (isNaN(bValue)) {
return false;
}
return a > bValue;
}
/**
* Utility encapsulates logic for comparing two strings which may encode numbers
* @example isLessThan('10', '5') // false
* @example isLessThan('5', '10') // true
* @example isLessThan('Ontime', 'Cool') // false
*/
export function isLessThan(a: unknown, b: string): boolean {
// If either value is not a number, there is no logical comparison to be made
if (typeof a !== 'number') {
return false;
}
const bValue = Number(b);
if (isNaN(bValue)) {
return false;
}
return a < bValue;
}
/**
* Utility encapsulates logic for comparing two strings which may encode booleans
* @example isBooleanEquals(true, 'true') // true
* @example isBooleanEquals(true, 'false') // false
* @example isBooleanEquals(true, 'something') // false
*/
export function isBooleanEquals(a: boolean, b: string): boolean {
if (b === 'true') {
return a === true;
}
if (b === 'false') {
return a === false;
}
return false;
}
/**
* Checks is an automation is used in a rundown
*/
function isAutomationUsedInRundown(
entries: RundownEntries,
flatOrder: EntryId[],
automationId: string,
): EntryId | undefined {
for (let i = 0; i < flatOrder.length; i++) {
const eventId = flatOrder[i];
const entry = entries[eventId];
// only ontime events can contain triggers
if (isOntimeEvent(entry) && entry.triggers) {
for (const trigger of entry.triggers) {
if (trigger.automationId === automationId) {
return entry.id;
}
}
}
}
}
/**
* Checks if an automation is used in any of the project rundowns
*/
export function isAutomationUsed(
projectRundowns: ProjectRundowns,
automationId: string,
): [string, EntryId] | undefined {
for (const rundownId in projectRundowns) {
const rundown = projectRundowns[rundownId];
const usedInEvent = isAutomationUsedInRundown(rundown.entries, rundown.flatOrder, automationId);
if (usedInEvent) {
return [rundown.title, usedInEvent];
}
}
}