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:
Claude
2026-08-08 11:06:11 +00:00
parent f1bf8e8bee
commit 5ea3845f0d
12 changed files with 337 additions and 109 deletions
@@ -0,0 +1,25 @@
import type { TimerLifeCycle } from 'ontime-types';
import { useStore } from 'zustand';
import { createStore } from 'zustand/vanilla';
type FiredRecord = { at: number; cycle: TimerLifeCycle };
type AutomationFiredStore = {
fired: Record<string, FiredRecord>;
};
/**
* Tracks when each automation last ran.
* Ephemeral by design: this is runtime feedback for the settings panel, not project data,
* and it is fed by a socket message the server already coalesces to once a second per automation
*/
const automationFired = createStore<AutomationFiredStore>(() => ({
fired: {},
}));
export const useAutomationFired = () => useStore(automationFired);
export const addAutomationFired = (automationId: string, cycle: TimerLifeCycle) =>
automationFired.setState((state) => ({
fired: { ...state.fired, [automationId]: { at: Date.now(), cycle } },
}));
+7 -1
View File
@@ -17,9 +17,15 @@ const logger = createStore<LogStore>(() => ({
export const useLogData = () => useStore(logger);
/**
* The server queue is capped at 100 entries, the client's was not.
* A long show with chatty automations would otherwise grow this forever.
*/
const maxLogEntries = 500;
export const addLog = (log: Log) =>
logger.setState((state) => ({
logs: [log, ...state.logs],
logs: [log, ...state.logs].slice(0, maxLogEntries),
}));
export const clearLogs = () => logger.setState({ logs: [] });
+5
View File
@@ -37,6 +37,7 @@ import {
setClients,
} from '../stores/clientStore';
import { addDialog } from '../stores/dialogStore';
import { addAutomationFired } from '../stores/automationFired';
import { addLog } from '../stores/logger';
import { patchRuntime, patchRuntimeProperty } from '../stores/runtime';
@@ -173,6 +174,10 @@ export const connectSocket = () => {
addLog(payload as Log);
break;
}
case MessageTag.AutomationFired: {
addAutomationFired(payload.automationId, payload.cycle);
break;
}
case MessageTag.RuntimeData: {
patchRuntime(payload);
updateDevTools(payload);
@@ -12,6 +12,7 @@ import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import { isOntimeCloud } from '../../../../externals';
import * as Panel from '../../panel-utils/PanelUtils';
import useAppSettingsNavigation from '../../useAppSettingsNavigation';
const oscApiDocsUrl = 'https://docs.getontime.no/api/protocols/osc/';
@@ -32,6 +33,7 @@ export default function AutomationSettingsForm({
oscInputState,
isLoading,
}: AutomationSettingsProps) {
const { setLocation } = useAppSettingsNavigation();
const {
handleSubmit,
reset,
@@ -97,7 +99,12 @@ export default function AutomationSettingsForm({
<span>- An automation is what to send: OSC and HTTP messages, or an action inside Ontime.</span>
<span>- A trigger is when to send it. Triggers for a single event live in the event editor.</span>
<span>- OSC Input tells Ontime to listen to messages on the specific port.</span>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
<Info.Footer>
<ExternalLink href={oscApiDocsUrl}>See the docs</ExternalLink>
<Button variant='ghosted' size='small' onClick={() => setLocation('network__log')}>
Automations report to the event log
</Button>
</Info.Footer>
</Info>
</Panel.Section>
@@ -1,3 +1,8 @@
.muted {
color: $muted-gray;
}
.lastFired {
font-size: $aux-text-size;
color: $green-400;
}
@@ -1,5 +1,5 @@
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
import { Fragment, useMemo, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { IoAdd, IoPencil, IoSparklesOutline, IoTrash } from 'react-icons/io5';
import { deleteAutomation } from '../../../../common/api/automation';
@@ -10,6 +10,7 @@ import Info from '../../../../common/components/info/Info';
import Tag from '../../../../common/components/tag/Tag';
import { getLifecycleLabel } from '../../../../common/constants/timerLifecycle';
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
import { useAutomationFired } from '../../../../common/stores/automationFired';
import { summariseOutputs } from '../../../../common/utils/automationOutputs';
import * as Panel from '../../panel-utils/PanelUtils';
import AutomationForm from './AutomationForm';
@@ -61,6 +62,7 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
};
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
const { fired } = useAutomationFired();
const arrayAutomations = Object.keys(automations);
@@ -105,10 +107,11 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
<Panel.Table>
<thead>
<tr>
<th style={{ width: '35%' }}>Title</th>
<th style={{ width: '25%' }}>Runs on</th>
<th style={{ width: '15%' }}>Filter rule</th>
<th style={{ width: '30%' }}>Title</th>
<th style={{ width: '22%' }}>Runs on</th>
<th style={{ width: '13%' }}>Filter rule</th>
<th style={{ width: '15%' }}>Sends</th>
<th style={{ width: '12%' }}>Last fired</th>
<th />
</tr>
</thead>
@@ -164,6 +167,9 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
))
)}
</Panel.InlineElements>
<td>
<LastFired at={fired[automationId]?.at} />
</td>
<Panel.InlineElements align='end' relation='inner' as='td'>
<IconButton
variant='ghosted-white'
@@ -186,7 +192,7 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
})}
{deleteError && (
<tr>
<td colSpan={5}>
<td colSpan={6}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
@@ -198,3 +204,41 @@ export default function AutomationsList({ automations, triggers, enabledAutomati
</Panel.Section>
);
}
/**
* Shows how long ago an automation last ran.
* An automation that never ticks while its neighbours do is the clearest signal
* that something upstream of it, a filter or a trigger, is wrong
*/
function LastFired({ at }: { at?: number }) {
const [, setTick] = useState(0);
useEffect(() => {
if (at === undefined) {
return;
}
const interval = setInterval(() => setTick((value) => value + 1), 1000);
return () => clearInterval(interval);
}, [at]);
if (at === undefined) {
return <span className={style.muted}></span>;
}
return <span className={style.lastFired}>{formatElapsed(Date.now() - at)}</span>;
}
function formatElapsed(elapsed: number): string {
const seconds = Math.max(0, Math.floor(elapsed / 1000));
if (seconds < 5) {
return 'just now';
}
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
return `${Math.floor(minutes / 60)}h ago`;
}
+31 -99
View File
@@ -9,118 +9,50 @@ import * as Panel from '../app-settings/panel-utils/PanelUtils';
import style from './Log.module.scss';
const origins = Object.values(LogOrigin);
type OriginFilters = Record<LogOrigin, boolean>;
const allEnabled = Object.fromEntries(origins.map((origin) => [origin, true])) as OriginFilters;
export default function Log() {
const { logs: logData } = useLogData();
const isExtracted = window.location.pathname.includes('/log');
const [showClient, setShowClient] = useState(true);
const [showServer, setShowServer] = useState(true);
const [showRx, setShowRx] = useState(true);
const [showTx, setShowTx] = useState(true);
const [showPlayback, setShowPlayback] = useState(true);
const [showUser, setShowUser] = useState(true);
const [filters, setFilters] = useState<OriginFilters>(allEnabled);
const matchers: LogOrigin[] = [];
if (showUser) {
matchers.push(LogOrigin.User);
}
if (showClient) {
matchers.push(LogOrigin.Client);
}
if (showServer) {
matchers.push(LogOrigin.Server);
}
if (showRx) {
matchers.push(LogOrigin.Rx);
}
if (showTx) {
matchers.push(LogOrigin.Tx);
}
if (showPlayback) {
matchers.push(LogOrigin.Playback);
}
const filteredData = logData.filter((entry) => filters[entry.origin as LogOrigin]);
const filteredData = logData.filter((entry) => matchers.some((match) => entry.origin === match));
const toggleOrigin = useCallback((origin: LogOrigin) => {
setFilters((prev) => ({ ...prev, [origin]: !prev[origin] }));
}, []);
const disableOthers = useCallback((toEnable: LogOrigin) => {
setShowUser(toEnable === LogOrigin.User);
setShowClient(toEnable === LogOrigin.Client);
setShowServer(toEnable === LogOrigin.Server);
setShowRx(toEnable === LogOrigin.Rx);
setShowTx(toEnable === LogOrigin.Tx);
setShowPlayback(toEnable === LogOrigin.Playback);
/** middle click solos an origin */
const soloOrigin = useCallback((toEnable: LogOrigin) => {
setFilters(Object.fromEntries(origins.map((origin) => [origin, origin === toEnable])) as OriginFilters);
}, []);
return (
<div className={cx([style.container, isExtracted && style.extracted])}>
<Panel.InlineElements className={style.buttonBar}>
<span className={style.filterLabel}>Filter by</span>
<Button
variant={showUser ? 'primary' : 'subtle'}
size='small'
aria-pressed={showUser}
aria-label={`${showUser ? 'Hide' : 'Show'} ${LogOrigin.User} events`}
onClick={() => setShowUser((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.User)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.User}
</Button>
<Button
variant={showClient ? 'primary' : 'subtle'}
size='small'
aria-pressed={showClient}
aria-label={`${showClient ? 'Hide' : 'Show'} ${LogOrigin.Client} events`}
onClick={() => setShowClient((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Client)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Client}
</Button>
<Button
variant={showServer ? 'primary' : 'subtle'}
size='small'
aria-pressed={showServer}
aria-label={`${showServer ? 'Hide' : 'Show'} ${LogOrigin.Server} events`}
onClick={() => setShowServer((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Server)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Server}
</Button>
<Button
variant={showPlayback ? 'primary' : 'subtle'}
size='small'
aria-pressed={showPlayback}
aria-label={`${showPlayback ? 'Hide' : 'Show'} ${LogOrigin.Playback} events`}
onClick={() => setShowPlayback((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Playback)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Playback}
</Button>
<Button
variant={showRx ? 'primary' : 'subtle'}
size='small'
aria-pressed={showRx}
aria-label={`${showRx ? 'Hide' : 'Show'} ${LogOrigin.Rx} events`}
onClick={() => setShowRx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Rx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Rx}
</Button>
<Button
variant={showTx ? 'primary' : 'subtle'}
size='small'
aria-pressed={showTx}
aria-label={`${showTx ? 'Hide' : 'Show'} ${LogOrigin.Tx} events`}
onClick={() => setShowTx((s) => !s)}
onAuxClick={() => disableOthers(LogOrigin.Tx)}
onContextMenu={(e) => e.preventDefault()}
>
{LogOrigin.Tx}
</Button>
{origins.map((origin) => {
const isEnabled = filters[origin];
return (
<Button
key={origin}
variant={isEnabled ? 'primary' : 'subtle'}
size='small'
aria-pressed={isEnabled}
aria-label={`${isEnabled ? 'Hide' : 'Show'} ${origin} events`}
onClick={() => toggleOrigin(origin)}
onAuxClick={() => soloOrigin(origin)}
onContextMenu={(e) => e.preventDefault()}
>
{origin}
</Button>
);
})}
<Button variant='subtle-destructive' size='small' onClick={clearLogs} className={style.apart}>
<IoClose /> Clear
</Button>