mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-11 18:33:53 +00:00
5ea3845f0d
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
297 lines
8.6 KiB
TypeScript
297 lines
8.6 KiB
TypeScript
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];
|
||
}
|
||
}
|
||
}
|