mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-15 12:23:51 +00:00
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
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { PlayableEvent, TimerLifeCycle } from 'ontime-types';
|
||||
|
||||
import { socket } from '../../../adapters/WebsocketAdapter.js';
|
||||
import { logger } from '../../../classes/Logger.js';
|
||||
import { makeRuntimeStoreData } from '../../../stores/__mocks__/runtimeStore.mocks.js';
|
||||
import { RuntimeState } from '../../../stores/runtimeState.js';
|
||||
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
|
||||
import { addAutomation, addTrigger, deleteAllTriggers } from '../automation.dao.js';
|
||||
import { testConditions, triggerAutomations } from '../automation.service.js';
|
||||
import { resetAutomationLogState, testConditions, triggerAutomations } from '../automation.service.js';
|
||||
import * as httpClient from '../clients/http.client.js';
|
||||
import * as oscClient from '../clients/osc.client.js';
|
||||
import { makeHTTPAction, makeOSCAction } from './testUtils.js';
|
||||
@@ -644,3 +646,99 @@ describe('testConditions()', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A successful fire used to be invisible. Making it visible is only useful if the log
|
||||
* stays readable: onClock fires every second and the logger queue holds 100 entries.
|
||||
*/
|
||||
describe('automation reporting', () => {
|
||||
let logSpy = vi.spyOn(logger, 'info');
|
||||
let socketSpy = vi.spyOn(socket, 'sendAsJson');
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(oscClient, 'emitOSC').mockImplementation(() => {});
|
||||
logSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
|
||||
socketSpy = vi.spyOn(socket, 'sendAsJson').mockImplementation(() => {});
|
||||
|
||||
await deleteAllTriggers();
|
||||
resetAutomationLogState();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function bind(title: string, cycle: TimerLifeCycle) {
|
||||
const automation = await addAutomation({
|
||||
title,
|
||||
filterRule: 'all',
|
||||
filters: [],
|
||||
outputs: [makeOSCAction()],
|
||||
});
|
||||
await addTrigger({ title, trigger: cycle, automationId: automation.id });
|
||||
return automation;
|
||||
}
|
||||
|
||||
it('logs once per automation that fires, not once per output', async () => {
|
||||
await bind('reporting-finish', TimerLifeCycle.onFinish);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onFinish);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy.mock.calls[0][1]).toContain('reporting-finish');
|
||||
});
|
||||
|
||||
it('never logs per fire on a continuous lifecycle, and explains itself once', async () => {
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy.mock.calls[0][1]).toContain('suppressed');
|
||||
});
|
||||
|
||||
it('shows the suppression notice again after a reload', async () => {
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
logSpy.mockClear();
|
||||
|
||||
// onLoad bookends a run and resets the reporting state
|
||||
triggerAutomations(TimerLifeCycle.onLoad);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
|
||||
expect(logSpy.mock.calls.some(([, message]) => String(message).includes('suppressed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('collapses repeats of the same automation and cycle inside the throttle window', async () => {
|
||||
vi.useFakeTimers();
|
||||
await bind('reporting-danger', TimerLifeCycle.onDanger);
|
||||
logSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
triggerAutomations(TimerLifeCycle.onDanger);
|
||||
expect(logSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports a fire to the clients at most once a second, including on continuous lifecycles', async () => {
|
||||
vi.useFakeTimers();
|
||||
await bind('reporting-clock', TimerLifeCycle.onClock);
|
||||
socketSpy.mockClear();
|
||||
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
expect(socketSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(1001);
|
||||
triggerAutomations(TimerLifeCycle.onClock);
|
||||
expect(socketSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
type Automation,
|
||||
type AutomationFilter,
|
||||
type AutomationOutput,
|
||||
type FilterRule,
|
||||
LogOrigin,
|
||||
MessageTag,
|
||||
RuntimeStore,
|
||||
TimerLifeCycle,
|
||||
isHTTPOutput,
|
||||
@@ -11,19 +13,54 @@ import {
|
||||
} from 'ontime-types';
|
||||
import { getPropertyFromPath } from 'ontime-utils';
|
||||
|
||||
import { socket } from '../../adapters/WebsocketAdapter.js';
|
||||
import { logger } from '../../classes/Logger.js';
|
||||
import { isOntimeCloud } from '../../setup/environment.js';
|
||||
import { eventStore } from '../../stores/EventStore.js';
|
||||
import { getAutomationTriggers, getAutomations, getAutomationsEnabled } from './automation.dao.js';
|
||||
import { isContained, isEquivalent, isGreaterThan, isLessThan } from './automation.utils.js';
|
||||
import { isContained, isEquivalent, isGreaterThan, isLessThan, summariseOutputs } from './automation.utils.js';
|
||||
import { emitHTTP } from './clients/http.client.js';
|
||||
import { toOntimeAction } from './clients/ontime.client.js';
|
||||
import { emitOSC } from './clients/osc.client.js';
|
||||
|
||||
/**
|
||||
* Lifecycles that fire continuously while the timer runs.
|
||||
* The logger queue holds 100 entries, so logging every onClock fire would evict
|
||||
* everything else within two minutes and make the log useless.
|
||||
*/
|
||||
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
|
||||
|
||||
/** floor between two reports about the same automation, in milliseconds */
|
||||
const reportThrottleMs = 1000;
|
||||
|
||||
/** automations we have already warned about being bound to a continuous lifecycle */
|
||||
const suppressionNotices = new Set<string>();
|
||||
/** last time we logged a given automation + cycle pair */
|
||||
const lastLoggedAt = new Map<string, number>();
|
||||
/** last time we told the clients about a given automation */
|
||||
const lastReportedAt = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Clears the per-load logging state.
|
||||
* Called when the runtime loads or stops so the suppression notice is shown again
|
||||
* for the next show rather than once per server lifetime
|
||||
*/
|
||||
export function resetAutomationLogState() {
|
||||
suppressionNotices.clear();
|
||||
lastLoggedAt.clear();
|
||||
lastReportedAt.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes a method for triggering actions based on a TimerLifeCycle event
|
||||
*/
|
||||
export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
// a load or a stop bookends a run: start reporting from scratch so the next show
|
||||
// gets its own suppression notice rather than inheriting one from the last
|
||||
if (cycle === TimerLifeCycle.onLoad || cycle === TimerLifeCycle.onStop) {
|
||||
resetAutomationLogState();
|
||||
}
|
||||
|
||||
if (!getAutomationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
@@ -63,10 +100,48 @@ export function triggerAutomations(cycle: TimerLifeCycle) {
|
||||
const shouldSend = testConditions(automation.filters, automation.filterRule, store);
|
||||
if (shouldSend) {
|
||||
send(automation.outputs, store);
|
||||
reportFired(trigger.automationId, automation, cycle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a successful automation visible, which it previously was not:
|
||||
* the log answers what happened, the socket message answers whether an automation is alive
|
||||
*/
|
||||
function reportFired(automationId: string, automation: Automation, cycle: TimerLifeCycle) {
|
||||
const now = Date.now();
|
||||
|
||||
// the panel shows a last fired time, so continuous lifecycles still report, but at most once a second
|
||||
const lastReported = lastReportedAt.get(automationId);
|
||||
if (lastReported === undefined || now - lastReported >= reportThrottleMs) {
|
||||
lastReportedAt.set(automationId, now);
|
||||
socket.sendAsJson(MessageTag.AutomationFired, { automationId, cycle });
|
||||
}
|
||||
|
||||
if (continuousCycles.includes(cycle)) {
|
||||
// one notice per load is enough to explain why the log goes quiet from here
|
||||
if (!suppressionNotices.has(automationId)) {
|
||||
suppressionNotices.add(automationId);
|
||||
logger.info(
|
||||
LogOrigin.Automation,
|
||||
`${automation.title} is bound to ${cycle} and fires continuously, per-fire logging suppressed`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// a rapid reload can fire the same automation on the same cycle several times over
|
||||
const logKey = `${automationId}:${cycle}`;
|
||||
const lastLogged = lastLoggedAt.get(logKey);
|
||||
if (lastLogged !== undefined && now - lastLogged < reportThrottleMs) {
|
||||
return;
|
||||
}
|
||||
lastLoggedAt.set(logKey, now);
|
||||
|
||||
logger.info(LogOrigin.Automation, `${automation.title} fired on ${cycle} → ${summariseOutputs(automation.outputs)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes a method for bypassing the condition check and testing the sending of an output
|
||||
*/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
AutomationOutput,
|
||||
EntryId,
|
||||
FilterRule,
|
||||
MaybeNumber,
|
||||
@@ -25,6 +26,23 @@ export function isOntimeActionAction(value: string): value is OntimeAction['acti
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user