mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-10 00:29:41 +00:00
refactor(automation): give triggers back to the triggers list
The automation form had grown a lifecycle picker that created and deleted global triggers behind the user's back. It broke the model the panel is built on — an automation is what to send, a trigger is when — and left the global triggers section describing itself as a place to rename things made elsewhere. Triggers now belong only to the triggers list and the event editor. The automation form edits a title, filters and outputs, and saves in one request: the two-request save, its partial-failure recovery and the snapshot it reconciled against are all gone with it. An automation with no global trigger offers to make one from its own row, opening the trigger form with the automation preselected, so the connection is still one click away without the form pretending to own it. Other changes in the same pass: - the form uses the compact modal instead of the wide one. Nothing in it justified 1800px, and output fields now pair up two to a row rather than stretching across four columns - a blank automation gets its own header button beside Start from recipe, instead of being a footnote under the recipe list - the settings form seeds itself from the query when it resolves, as the other settings panels do. It was showing automations as OFF while they were on - the filter operator list goes back to the three master offered, which makes the note about not_contains unnecessary rather than explanatory - comments that narrated the change rather than explaining the code Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
This commit is contained in:
+5
-18
@@ -1,18 +1,3 @@
|
||||
/**
|
||||
* The wide modal body does not scroll, so the form owns it.
|
||||
* Without this the form is simply clipped: four outputs is enough to put Save out of reach.
|
||||
*/
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.formScroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.outerColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -20,8 +5,6 @@
|
||||
font-size: calc(1rem - 1px);
|
||||
color: $ui-white;
|
||||
padding-block: 0.5rem;
|
||||
// leaves the overlay scrollbar somewhere to sit without covering a field
|
||||
padding-right: 0.5rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
@@ -83,9 +66,13 @@
|
||||
color: $secondary-text-gray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two columns, so fields that belong together sit on one row: a host and its port,
|
||||
* a filter field and its operator. Anything wider than half a card opts out with spanFull
|
||||
*/
|
||||
.cardBody {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
+196
-329
@@ -1,24 +1,9 @@
|
||||
import {
|
||||
Automation,
|
||||
AutomationDTO,
|
||||
AutomationFilter,
|
||||
TimerLifeCycle,
|
||||
Trigger,
|
||||
isHTTPOutput,
|
||||
isOSCOutput,
|
||||
isOntimeAction,
|
||||
} from 'ontime-types';
|
||||
import { Automation, AutomationDTO, AutomationFilter, isHTTPOutput, isOSCOutput, isOntimeAction } from 'ontime-types';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { IoAdd, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import {
|
||||
addAutomation,
|
||||
addTrigger,
|
||||
deleteTrigger,
|
||||
editAutomation,
|
||||
testOutput,
|
||||
} from '../../../../common/api/automation';
|
||||
import { addAutomation, editAutomation, testOutput } from '../../../../common/api/automation';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -28,13 +13,12 @@ import Input from '../../../../common/components/input/input/Input';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import Modal from '../../../../common/components/modal/Modal';
|
||||
import RadioGroup from '../../../../common/components/radio-group/RadioGroup';
|
||||
import ScrollArea from '../../../../common/components/scroll-area/ScrollArea';
|
||||
import Select from '../../../../common/components/select/Select';
|
||||
import Tag from '../../../../common/components/tag/Tag';
|
||||
import useAutomationSettings from '../../../../common/hooks-query/useAutomationSettings';
|
||||
import useCustomFields from '../../../../common/hooks-query/useCustomFields';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { cycles, isAutomation, makeFieldList, makeTriggerTitle, operators, type OutputErrors } from './automationUtils';
|
||||
import { isAutomation, makeFieldList, operators, type OutputErrors } from './automationUtils';
|
||||
import HttpOutputForm from './HttpOutputForm';
|
||||
import OntimeActionForm from './OntimeActionForm';
|
||||
import OscOutputForm from './OscOutputForm';
|
||||
@@ -48,55 +32,21 @@ const formId = 'automation-form';
|
||||
/** how long a successful test keeps its confirmation on screen */
|
||||
const testFeedbackDuration = 2000;
|
||||
|
||||
/** lifecycles that fire continuously, and are worth a warning before a user picks one */
|
||||
const continuousCycles: TimerLifeCycle[] = [TimerLifeCycle.onClock, TimerLifeCycle.onUpdate];
|
||||
|
||||
interface AutomationFormProps {
|
||||
automation: Automation | AutomationDTO;
|
||||
/** global triggers, used to resolve which lifecycles this automation is currently bound to */
|
||||
triggers: Trigger[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AutomationForm({ automation, triggers, onClose }: AutomationFormProps) {
|
||||
/**
|
||||
* Edits what an automation sends: its filters and its outputs.
|
||||
* When it runs is a separate concept, owned by the triggers list and by the event editor.
|
||||
*/
|
||||
export default function AutomationForm({ automation, onClose }: AutomationFormProps) {
|
||||
const isEdit = isAutomation(automation);
|
||||
const { data } = useCustomFields();
|
||||
const { refetch } = useAutomationSettings();
|
||||
const fieldList = useMemo(() => makeFieldList(data), [data]);
|
||||
|
||||
/**
|
||||
* The triggers the server holds for this automation, as far as this form knows.
|
||||
*
|
||||
* Seeded from a snapshot taken when the form opens, never from the live prop: settings are
|
||||
* polled, so a trigger created elsewhere while this form is open must not be deleted by a
|
||||
* save that never saw it. It then advances as each request succeeds, so a save that fails
|
||||
* half way leaves only the outstanding work for the retry.
|
||||
*/
|
||||
const [syncedTriggers, setSyncedTriggers] = useState<Trigger[]>(() =>
|
||||
isAutomation(automation) ? triggers.filter((trigger) => trigger.automationId === automation.id) : [],
|
||||
);
|
||||
const syncedCycles = useMemo(
|
||||
() => Array.from(new Set(syncedTriggers.map((trigger) => trigger.trigger))),
|
||||
[syncedTriggers],
|
||||
);
|
||||
const [selectedCycles, setSelectedCycles] = useState<TimerLifeCycle[]>(syncedCycles);
|
||||
/** set once a create succeeds, so a retry after a failed trigger sync edits instead of creating a duplicate */
|
||||
const [createdId, setCreatedId] = useState<string | null>(null);
|
||||
|
||||
// both are deduped, so equal lengths and one being a subset makes them the same selection
|
||||
const cyclesAreDirty =
|
||||
selectedCycles.length !== syncedCycles.length || selectedCycles.some((cycle) => !syncedCycles.includes(cycle));
|
||||
|
||||
const toggleCycle = (cycle: TimerLifeCycle) => {
|
||||
setSelectedCycles((prev) => (prev.includes(cycle) ? prev.filter((c) => c !== cycle) : [...prev, cycle]));
|
||||
};
|
||||
|
||||
/**
|
||||
* A lifecycle can carry several differently named triggers, which the chips collapse into one.
|
||||
* Unchecking it removes all of them, so say which ones rather than deleting them quietly.
|
||||
*/
|
||||
const triggersToRemove = syncedTriggers.filter((trigger) => !selectedCycles.includes(trigger.trigger));
|
||||
|
||||
/**
|
||||
* Test results are keyed by the field array id rather than the index:
|
||||
* removing an output shifts every index after it, which would leave feedback on the wrong row
|
||||
@@ -216,59 +166,21 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciles the lifecycle selection against the global triggers.
|
||||
* Runs after the automation itself is saved: a new automation has no id until then.
|
||||
*
|
||||
* Every request advances the synced snapshot as it succeeds, so pressing save again after
|
||||
* a failure half way through retries only what is left. Without that a retry would re-add
|
||||
* a trigger it already created, and re-delete one it already deleted, which the server
|
||||
* rejects outright.
|
||||
*/
|
||||
const syncTriggers = async (automationId: string, title: string) => {
|
||||
for (const trigger of triggersToRemove) {
|
||||
await deleteTrigger(trigger.id);
|
||||
setSyncedTriggers((prev) => prev.filter((synced) => synced.id !== trigger.id));
|
||||
}
|
||||
|
||||
const toAdd = selectedCycles.filter((cycle) => !syncedCycles.includes(cycle));
|
||||
for (const cycle of toAdd) {
|
||||
const created = await addTrigger({ title: makeTriggerTitle(title, cycle), trigger: cycle, automationId });
|
||||
setSyncedTriggers((prev) => [...prev, created]);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: AutomationDTO) => {
|
||||
// a stale failure from the previous attempt would otherwise sit under a successful retry
|
||||
clearErrors('root');
|
||||
|
||||
// saving happens in two requests, so a retry after a partial failure must edit rather than create again
|
||||
const existingId = isAutomation(automation) ? automation.id : createdId;
|
||||
let automationId: string;
|
||||
|
||||
try {
|
||||
if (existingId) {
|
||||
await editAutomation(existingId, { id: existingId, ...values });
|
||||
automationId = existingId;
|
||||
if (isAutomation(automation)) {
|
||||
await editAutomation(automation.id, { id: automation.id, ...values });
|
||||
} else {
|
||||
const created = await addAutomation(values);
|
||||
setCreatedId(created.id);
|
||||
automationId = created.id;
|
||||
await addAutomation(values);
|
||||
}
|
||||
} catch (error) {
|
||||
setError('root', { message: maybeAxiosError(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await syncTriggers(automationId, values.title);
|
||||
} catch (error) {
|
||||
// the automation itself is saved, only its triggers failed. Keep the form open so the user can retry
|
||||
refetch();
|
||||
setError('root', { message: `Automation saved, but its triggers failed: ${maybeAxiosError(error)}` });
|
||||
return;
|
||||
}
|
||||
|
||||
refetch();
|
||||
onClose();
|
||||
};
|
||||
@@ -288,14 +200,10 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
||||
return `${fieldLabel} ${operatorLabel} ${value ? `“${value}”` : 'nothing'}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* A failed save reports itself as a root error, which react-hook-form counts against
|
||||
* isValid. Left alone that disables the very retry the message is asking the user to make,
|
||||
* so a root error on its own does not block submitting again.
|
||||
*/
|
||||
// a failed save lands on `root`, which react-hook-form counts against isValid.
|
||||
// Only errors on actual fields should stand between the user and another attempt
|
||||
const invalidFields = Object.keys(errors).filter((field) => field !== 'root');
|
||||
const canSubmit = !isSubmitting && (isDirty || cyclesAreDirty) && (isValid || invalidFields.length === 0);
|
||||
const hasContinuousCycle = selectedCycles.some((cycle) => continuousCycles.includes(cycle));
|
||||
const canSubmit = !isSubmitting && isDirty && (isValid || invalidFields.length === 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -303,239 +211,198 @@ export default function AutomationForm({ automation, triggers, onClose }: Automa
|
||||
onClose={onClose}
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='wide'
|
||||
size='compact'
|
||||
title={isEdit ? 'Edit automation' : 'Create automation'}
|
||||
bodyElements={
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.form}>
|
||||
<ScrollArea className={style.formScroll} contentClassName={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Automation options</h3>
|
||||
<div className={style.titleSection}>
|
||||
<form id={formId} onSubmit={handleSubmit(onSubmit)} className={style.outerColumn}>
|
||||
<div className={style.innerColumn}>
|
||||
<div className={style.titleSection}>
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<Panel.Description>
|
||||
Without filters the outputs are sent every time the automation is triggered.
|
||||
</Panel.Description>
|
||||
<div className={style.ruleSection}>
|
||||
{fieldFilters.length > 1 && (
|
||||
<label>
|
||||
Title
|
||||
<Input
|
||||
{...register('title', { required: { value: true, message: 'Required field' } })}
|
||||
fluid
|
||||
placeholder='Load preset'
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
<Panel.Error>{errors.title?.message}</Panel.Error>
|
||||
</div>
|
||||
|
||||
<div className={style.titleSection}>
|
||||
<label id='runs-on-label'>Runs on</label>
|
||||
<Panel.Description>
|
||||
Pick the moments in the timer lifecycle that should run this automation. You can also attach it to a
|
||||
single event from the event editor.
|
||||
</Panel.Description>
|
||||
<Panel.InlineElements relation='inner' wrap='wrap' aria-labelledby='runs-on-label' role='group'>
|
||||
{cycles.map(({ id, label, value }) => {
|
||||
const cycle = value as TimerLifeCycle;
|
||||
const isSelected = selectedCycles.includes(cycle);
|
||||
return (
|
||||
<Button
|
||||
key={id}
|
||||
size='small'
|
||||
variant={isSelected ? 'primary' : 'subtle'}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggleCycle(cycle)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Panel.InlineElements>
|
||||
{hasContinuousCycle && (
|
||||
<Panel.Description tone='warning'>
|
||||
Every second and On Timer Update fire continuously while the timer runs. Add a filter unless you
|
||||
mean to send on every tick.
|
||||
</Panel.Description>
|
||||
)}
|
||||
{triggersToRemove.length > 0 && (
|
||||
<Panel.Description tone='warning'>
|
||||
{`Saving removes ${triggersToRemove.length === 1 ? 'the trigger' : `${triggersToRemove.length} triggers`}: ${triggersToRemove
|
||||
.map((trigger) => trigger.title)
|
||||
.join(', ')}`}
|
||||
</Panel.Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Filters (optional)</h3>
|
||||
<Panel.Description>
|
||||
Without filters the outputs are sent every time the automation is triggered.
|
||||
</Panel.Description>
|
||||
<div className={style.ruleSection}>
|
||||
{fieldFilters.length > 1 && (
|
||||
<label>
|
||||
Trigger outputs if
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={watch('filterRule')}
|
||||
onValueChange={(value) => setValue('filterRule', value, { shouldDirty: true })}
|
||||
items={[
|
||||
{ value: 'all', label: 'All filters pass' },
|
||||
{ value: 'any', label: 'Any filter passes' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{fieldFilters.map((field, index) => {
|
||||
const description = describeFilter(index);
|
||||
return (
|
||||
<div key={field.id} className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag>Filter</Tag>
|
||||
<span className={style.cardSummary}>{description}</span>
|
||||
<IconButton
|
||||
aria-label='Delete filter'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.cardBody}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={operators}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='This automation does nothing yet'
|
||||
description='An automation without outputs will be triggered, but it has nothing to send.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
const rowErrors = getOutputErrors(index);
|
||||
const cardProps = {
|
||||
testState: testResults[output.id],
|
||||
onTest: () => handleTest(index, output.id),
|
||||
onDelete: () => removeOutput(index),
|
||||
};
|
||||
|
||||
if (isOSCOutput(output)) {
|
||||
return (
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
{...cardProps}
|
||||
>
|
||||
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHTTPOutput(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
|
||||
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
{fieldFilters.map((field, index) => {
|
||||
const description = describeFilter(index);
|
||||
return (
|
||||
<div key={field.id} className={style.card}>
|
||||
<div className={style.cardHeader}>
|
||||
<Tag>Filter</Tag>
|
||||
<span className={style.cardSummary}>{description}</span>
|
||||
<IconButton
|
||||
aria-label='Delete filter'
|
||||
variant='ghosted-destructive'
|
||||
onClick={() => removeFilter(index)}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={style.cardBody}>
|
||||
<label>
|
||||
Runtime data source
|
||||
<Select<string | null>
|
||||
// need to normalize '' to null for the Select to show the placeholder
|
||||
value={watch(`filters.${index}.field`) || null}
|
||||
onValueChange={(value) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.field`, value, { shouldDirty: true });
|
||||
}}
|
||||
options={fieldList.map(({ value, label }) => ({
|
||||
value,
|
||||
label,
|
||||
disabled: value === null,
|
||||
}))}
|
||||
aria-label='Event field'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.field?.message}</Panel.Error>
|
||||
</label>
|
||||
<label>
|
||||
Matching condition
|
||||
<Select
|
||||
value={watch(`filters.${index}.operator`)}
|
||||
onValueChange={(value: string | null) => {
|
||||
if (value === null) return;
|
||||
setValue(`filters.${index}.operator`, value as AutomationFilter['operator'], {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={operators}
|
||||
aria-label='Operator'
|
||||
/>
|
||||
<Panel.Error>{errors.filters?.[index]?.operator?.message}</Panel.Error>
|
||||
</label>
|
||||
<label className={style.spanFull}>
|
||||
Value to match
|
||||
<Input {...register(`filters.${index}.value`)} fluid placeholder='<empty / no value>' />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'OSC',
|
||||
description: 'Send an OSC message to a device on the network',
|
||||
onClick: handleAddNewOSCOutput,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'HTTP',
|
||||
description: 'Call a URL, for webhooks and REST APIs',
|
||||
onClick: handleAddNewHTTPOutput,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Ontime action',
|
||||
description: 'Change something inside Ontime, like a message or an aux timer',
|
||||
onClick: handleAddnewOntimeAction,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Add output <IoAdd />
|
||||
</DropdownMenu>
|
||||
<Button onClick={handleAddNewFilter}>
|
||||
Add filter <IoAdd />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className={style.innerColumn}>
|
||||
<h3>Outputs</h3>
|
||||
<Info>
|
||||
Type {'{{'} in any field to drop in Ontime runtime data, like the running event title.{' '}
|
||||
<ExternalLink href={integrationsDocsUrl}>read the docs</ExternalLink>
|
||||
</Info>
|
||||
|
||||
{fieldOutputs.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='This automation does nothing yet'
|
||||
description='An automation without outputs will be triggered, but it has nothing to send.'
|
||||
/>
|
||||
)}
|
||||
|
||||
{fieldOutputs.map((output, index) => {
|
||||
const rowErrors = getOutputErrors(index);
|
||||
const cardProps = {
|
||||
testState: testResults[output.id],
|
||||
onTest: () => handleTest(index, output.id),
|
||||
onDelete: () => removeOutput(index),
|
||||
};
|
||||
|
||||
if (isOSCOutput(output)) {
|
||||
return (
|
||||
<OutputCard
|
||||
key={output.id}
|
||||
label='OSC'
|
||||
kindClass={style.tagOsc}
|
||||
summary={watch(`outputs.${index}.address`)}
|
||||
{...cardProps}
|
||||
>
|
||||
<OscOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isHTTPOutput(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='HTTP' kindClass={style.tagHttp} {...cardProps}>
|
||||
<HttpOutputForm index={index} output={output} register={register} rowErrors={rowErrors} />
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOntimeAction(output)) {
|
||||
return (
|
||||
<OutputCard key={output.id} label='Ontime action' kindClass={style.tagOntime} {...cardProps}>
|
||||
<OntimeActionForm
|
||||
value={output.action}
|
||||
index={index}
|
||||
register={register}
|
||||
rowErrors={rowErrors}
|
||||
setValue={setValue}
|
||||
watch={watch}
|
||||
/>
|
||||
</OutputCard>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
<div>
|
||||
<DropdownMenu
|
||||
render={<Button />}
|
||||
items={[
|
||||
{
|
||||
type: 'item',
|
||||
label: 'OSC',
|
||||
description: 'Send an OSC message to a device on the network',
|
||||
onClick: handleAddNewOSCOutput,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'HTTP',
|
||||
description: 'Call a URL, for webhooks and REST APIs',
|
||||
onClick: handleAddNewHTTPOutput,
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
label: 'Ontime action',
|
||||
description: 'Change something inside Ontime, like a message or an aux timer',
|
||||
onClick: handleAddnewOntimeAction,
|
||||
},
|
||||
]}
|
||||
>
|
||||
Add output <IoAdd />
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
footerElements={
|
||||
|
||||
+9
-3
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { editAutomationSettings } from '../../../../common/api/automation';
|
||||
@@ -50,13 +51,18 @@ export default function AutomationSettingsForm({
|
||||
},
|
||||
});
|
||||
|
||||
// the panel renders before the query resolves, so the form is seeded with placeholder
|
||||
// settings. Take the loaded ones when they arrive, as the other settings panels do
|
||||
useEffect(() => {
|
||||
reset({ enabledAutomations, enabledOscIn, oscPortIn });
|
||||
}, [enabledAutomations, enabledOscIn, oscPortIn, reset]);
|
||||
|
||||
const onSubmit = async (formData: AutomationSettingsProps) => {
|
||||
try {
|
||||
await editAutomationSettings(formData);
|
||||
reset(formData);
|
||||
// the rest of the panel reads these settings from the query, and the automations list
|
||||
// greys itself out while they are off. Without this it keeps the stale answer until the
|
||||
// slow poll comes round, so turning automations on appears to do nothing
|
||||
// the rest of the panel reads these flags from the query, which is otherwise only
|
||||
// refreshed on a slow poll: refetch so a toggle takes effect where it is visible
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
const message = maybeAxiosError(error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Automation, AutomationDTO, NormalisedAutomation, Trigger } from 'ontime-types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { IoAdd, IoPencil, IoTrash } from 'react-icons/io5';
|
||||
import { IoAdd, IoPencil, IoSparkles, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
@@ -15,6 +15,7 @@ import { groupTriggersByAutomation, isAutomation } from './automationUtils';
|
||||
import DeleteAutomationDialog from './DeleteAutomationDialog';
|
||||
import NewAutomationDialog from './NewAutomationDialog';
|
||||
import { getLifecycleLabel } from './timerLifecycle';
|
||||
import TriggerForm from './TriggerForm';
|
||||
|
||||
import style from './AutomationsList.module.scss';
|
||||
|
||||
@@ -40,46 +41,49 @@ export default function AutomationsList({
|
||||
}: AutomationsListProps) {
|
||||
const { refetch } = useAutomationSettings();
|
||||
const [editing, setEditing] = useState<Automation | AutomationDTO | null>(null);
|
||||
const [isPickingStart, setIsPickingStart] = useState(false);
|
||||
const [isPickingRecipe, setIsPickingRecipe] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Automation | null>(null);
|
||||
/** the automation a new global trigger should point at, set from the row that asked for it */
|
||||
const [triggerTarget, setTriggerTarget] = useState<Automation | null>(null);
|
||||
|
||||
const lifecyclesByAutomation = useMemo(() => groupTriggersByAutomation(triggers), [triggers]);
|
||||
const automationIds = Object.keys(automations);
|
||||
|
||||
/** a recipe creates the automation itself, so it lands in the list rather than in a form */
|
||||
const handleCreated = async () => {
|
||||
setIsPickingStart(false);
|
||||
setIsPickingRecipe(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleStartEmpty = () => {
|
||||
setIsPickingStart(false);
|
||||
setEditing(emptyAutomation);
|
||||
};
|
||||
|
||||
const handleDeleted = async () => {
|
||||
setDeleteTarget(null);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleTriggerCreated = async () => {
|
||||
setTriggerTarget(null);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
{editing !== null && (
|
||||
<AutomationForm
|
||||
// the form snapshots the automation's lifecycles on mount, so it must never be
|
||||
// reused across two different automations
|
||||
// the form seeds itself from the automation once, so it must never be reused across two of them
|
||||
key={isAutomation(editing) ? editing.id : 'new'}
|
||||
automation={editing}
|
||||
triggers={triggers}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
{isPickingStart && (
|
||||
<NewAutomationDialog
|
||||
onClose={() => setIsPickingStart(false)}
|
||||
onStartEmpty={handleStartEmpty}
|
||||
onCreated={handleCreated}
|
||||
{isPickingRecipe && <NewAutomationDialog onClose={() => setIsPickingRecipe(false)} onCreated={handleCreated} />}
|
||||
{triggerTarget !== null && (
|
||||
<TriggerForm
|
||||
automations={automations}
|
||||
trigger={null}
|
||||
automationId={triggerTarget.id}
|
||||
onCancel={() => setTriggerTarget(null)}
|
||||
postSubmit={handleTriggerCreated}
|
||||
/>
|
||||
)}
|
||||
{deleteTarget !== null && (
|
||||
@@ -92,9 +96,14 @@ export default function AutomationsList({
|
||||
)}
|
||||
<Panel.SubHeader>
|
||||
Manage automations
|
||||
<Button onClick={() => setIsPickingStart(true)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button onClick={() => setIsPickingRecipe(true)}>
|
||||
Start from recipe <IoSparkles />
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(emptyAutomation)}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
|
||||
<Panel.Divider />
|
||||
@@ -121,11 +130,16 @@ export default function AutomationsList({
|
||||
{!isLoading && automationIds.length === 0 && (
|
||||
<Panel.TableEmpty
|
||||
title='No automations yet'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. Start from a recipe to see one working.'
|
||||
description='An automation sends OSC or HTTP messages, or runs an Ontime action, whenever a trigger fires. A recipe fills one in for a known workflow, like a video switcher or a chat channel.'
|
||||
action={
|
||||
<Button variant='primary' onClick={() => setIsPickingStart(true)}>
|
||||
New automation <IoAdd />
|
||||
</Button>
|
||||
<Panel.InlineElements>
|
||||
<Button variant='primary' onClick={() => setIsPickingRecipe(true)}>
|
||||
Start from recipe <IoSparkles />
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(emptyAutomation)}>
|
||||
New automation <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -139,12 +153,14 @@ export default function AutomationsList({
|
||||
<td>{automation.title}</td>
|
||||
<td>
|
||||
{/*
|
||||
* Only global triggers are visible here: an automation can also be attached to
|
||||
* single events, which live in the rundown. An empty cell is therefore not the
|
||||
* same as never running, so it says nothing rather than claiming that.
|
||||
* Only global triggers are listed here: an automation can also be attached to
|
||||
* single events, which live in the rundown. No global trigger therefore does not
|
||||
* mean it never runs, so the cell offers to add one rather than claiming anything.
|
||||
*/}
|
||||
{lifecycles.length === 0 ? (
|
||||
<span className={style.muted}>—</span>
|
||||
<Button size='small' variant='subtle' onClick={() => setTriggerTarget(automation)}>
|
||||
Add trigger <IoAdd />
|
||||
</Button>
|
||||
) : (
|
||||
<div className={style.tags}>
|
||||
{lifecycles.map((cycle) => (
|
||||
|
||||
+12
-25
@@ -31,23 +31,21 @@ import style from './NewAutomationDialog.module.scss';
|
||||
|
||||
interface NewAutomationDialogProps {
|
||||
onClose: () => void;
|
||||
/** hands over to the full automation form for someone who wants to start empty */
|
||||
onStartEmpty: () => void;
|
||||
onCreated: (automation: Automation) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The single entry point for making an automation.
|
||||
* Builds a working automation for a known workflow.
|
||||
*
|
||||
* Two steps in one dialog rather than two stacked ones: pick a recipe, then answer only
|
||||
* what that recipe cannot know — where your gear is, how long the timer runs. Everything
|
||||
* else the recipe already decided, which is the point of having recipes at all.
|
||||
*/
|
||||
export default function NewAutomationDialog({ onClose, onStartEmpty, onCreated }: NewAutomationDialogProps) {
|
||||
export default function NewAutomationDialog({ onClose, onCreated }: NewAutomationDialogProps) {
|
||||
const [selected, setSelected] = useState<AutomationRecipe | null>(null);
|
||||
|
||||
return selected === null ? (
|
||||
<RecipePicker onClose={onClose} onStartEmpty={onStartEmpty} onSelect={setSelected} />
|
||||
<RecipePicker onClose={onClose} onSelect={setSelected} />
|
||||
) : (
|
||||
<RecipeSetup recipe={selected} onClose={onClose} onBack={() => setSelected(null)} onCreated={onCreated} />
|
||||
);
|
||||
@@ -66,11 +64,10 @@ function matches(recipe: AutomationRecipe, query: string): boolean {
|
||||
|
||||
interface RecipePickerProps {
|
||||
onClose: () => void;
|
||||
onStartEmpty: () => void;
|
||||
onSelect: (recipe: AutomationRecipe) => void;
|
||||
}
|
||||
|
||||
function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
|
||||
function RecipePicker({ onClose, onSelect }: RecipePickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const available = useMemo(
|
||||
@@ -108,7 +105,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
|
||||
showBackdrop
|
||||
showCloseButton
|
||||
size='compact'
|
||||
title='New automation'
|
||||
title='Start from a recipe'
|
||||
bodyElements={
|
||||
<div className={style.picker}>
|
||||
<div className={style.search}>
|
||||
@@ -139,7 +136,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
|
||||
{results.length === 0 && (
|
||||
<Panel.EmptyState
|
||||
title='No recipe matches that'
|
||||
description='Try the name of the software, or start from an empty automation.'
|
||||
description='Try the name of the software, or close this and build the automation yourself.'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -173,14 +170,7 @@ function RecipePicker({ onClose, onStartEmpty, onSelect }: RecipePickerProps) {
|
||||
</ScrollArea>
|
||||
</div>
|
||||
}
|
||||
footerElements={
|
||||
<>
|
||||
<Button variant='ghosted-white' className={style.apart} onClick={onStartEmpty}>
|
||||
Start from an empty automation
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
</>
|
||||
}
|
||||
footerElements={<Button onClick={onClose}>Cancel</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -203,12 +193,9 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
|
||||
const setValue = (name: string, value: string) => setValues((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
/**
|
||||
* What this dialog has already put on the server.
|
||||
*
|
||||
* Creating takes one request per trigger on top of the automation itself, so a failure
|
||||
* part way through leaves work already done. Recording it means pressing create again
|
||||
* edits that automation and adds only the triggers still missing, rather than making a
|
||||
* second automation and firing the same cycles twice.
|
||||
* What this dialog has already put on the server: creating takes one request per trigger
|
||||
* on top of the automation itself, so a second attempt edits what exists and adds only
|
||||
* the triggers still missing, rather than making a duplicate that fires the same cycles twice.
|
||||
*/
|
||||
const created = useRef<Automation | null>(null);
|
||||
const createdCycles = useRef<Set<TimerLifeCycle>>(new Set());
|
||||
@@ -236,8 +223,8 @@ function RecipeSetup({ recipe, onClose, onBack, onCreated }: RecipeSetupProps) {
|
||||
}
|
||||
onCreated(created.current);
|
||||
} catch (error) {
|
||||
// what did land is a normal automation, visible in the list. Say what happened and let
|
||||
// the user press create again rather than undoing work behind their back
|
||||
// whatever landed is a normal automation, visible in the list. Report the failure and
|
||||
// leave the retry to the user rather than rolling back work behind their back
|
||||
setError(maybeAxiosError(error));
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
|
||||
@@ -16,11 +16,13 @@ const formId = 'trigger-form';
|
||||
interface TriggerFormProps {
|
||||
automations: NormalisedAutomation;
|
||||
trigger: Trigger | null;
|
||||
/** preselects the automation for a new trigger, used when creating one from an automation row */
|
||||
automationId?: string;
|
||||
onCancel: () => void;
|
||||
postSubmit: () => void;
|
||||
}
|
||||
|
||||
export default function TriggerForm({ automations, trigger, onCancel, postSubmit }: TriggerFormProps) {
|
||||
export default function TriggerForm({ automations, trigger, automationId, onCancel, postSubmit }: TriggerFormProps) {
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
@@ -33,7 +35,7 @@ export default function TriggerForm({ automations, trigger, onCancel, postSubmit
|
||||
defaultValues: {
|
||||
title: trigger?.title,
|
||||
trigger: trigger?.trigger ?? (cycles[0].value as TimerLifeCycle | undefined),
|
||||
automationId: trigger?.automationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
automationId: trigger?.automationId ?? automationId ?? automations?.[Object.keys(automations)[0]]?.id,
|
||||
},
|
||||
resetOptions: {
|
||||
keepDirtyValues: true,
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function TriggersList({ triggers, automations, isLoading }: Trigg
|
||||
<Panel.Divider />
|
||||
<Panel.Section>
|
||||
<Panel.Description>
|
||||
Triggers are managed from the automation itself. This list is for naming them, or for pointing several
|
||||
differently named triggers at the same automation.
|
||||
A global trigger runs an automation at a point in the timer lifecycle, whichever event is loaded. To run an
|
||||
automation on one event only, add the trigger from the event editor instead.
|
||||
</Panel.Description>
|
||||
{duplicates && (
|
||||
<Panel.Error>
|
||||
@@ -109,8 +109,8 @@ export default function TriggersList({ triggers, automations, isLoading }: Trigg
|
||||
title='No triggers yet'
|
||||
description={
|
||||
canAdd
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle. The usual way to create one is to pick the lifecycles in the automation itself.'
|
||||
: 'Create an automation first, then pick the lifecycles it should run on.'
|
||||
? 'Triggers run an automation at a given point of the timer lifecycle, like when an event starts or finishes.'
|
||||
: 'A trigger needs an automation to run. Create the automation first, then come back and decide when it should run.'
|
||||
}
|
||||
action={
|
||||
canAdd ? (
|
||||
|
||||
+1
-7
@@ -1,6 +1,6 @@
|
||||
import { TimerLifeCycle, Trigger } from 'ontime-types';
|
||||
|
||||
import { checkDuplicates, cycles, groupTriggersByAutomation, operators } from '../automationUtils';
|
||||
import { checkDuplicates, cycles, groupTriggersByAutomation } from '../automationUtils';
|
||||
|
||||
describe('checkDuplicates', () => {
|
||||
it('should return undefined if there are no duplicates', () => {
|
||||
@@ -51,12 +51,6 @@ describe('groupTriggersByAutomation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('operators', () => {
|
||||
it('does not offer not_contains, which the server validation rejects', () => {
|
||||
expect(operators.map(({ value }) => value)).not.toContain('not_contains');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycles', () => {
|
||||
it('uses the shared user facing labels', () => {
|
||||
expect(cycles.find(({ value }) => value === 'onStart')?.label).toBe('On Start');
|
||||
|
||||
@@ -2,10 +2,7 @@ import { Automation, AutomationDTO, AutomationFilter, CustomFields, TimerLifeCyc
|
||||
|
||||
import { getLifecycleLabel, lifecycleLabels } from './timerLifecycle';
|
||||
|
||||
/**
|
||||
* Names a trigger created from an automation's lifecycle picker.
|
||||
* Shared so a trigger made by the form and one made by a recipe read the same in the list.
|
||||
*/
|
||||
/** Names the trigger a recipe creates alongside its automation, so the pair is recognisable in the triggers list */
|
||||
export function makeTriggerTitle(automationTitle: string, cycle: TimerLifeCycle): string {
|
||||
return `${automationTitle} — ${getLifecycleLabel(cycle)}`;
|
||||
}
|
||||
@@ -34,18 +31,11 @@ export const cycles: CycleLabel[] = [
|
||||
{ id: 9, label: lifecycleLabels.onDanger, value: 'onDanger' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Filter operators offered in the automation form
|
||||
* NOTE: not_contains is supported by the type and by the runtime, but the server
|
||||
* validation list omits it, so an automation using it cannot be saved.
|
||||
* It stays out of the UI until the server accepts it.
|
||||
*/
|
||||
/** Filter operators offered in the automation form, phrased to read as a sentence in the filter summary */
|
||||
export const operators: Array<{ value: AutomationFilter['operator']; label: string }> = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'greater_than', label: 'is greater than' },
|
||||
{ value: 'less_than', label: 'is less than' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -114,10 +104,7 @@ export function checkDuplicates(triggers: Trigger[]) {
|
||||
return duplicates.length > 0 ? duplicates : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups the lifecycles each automation is bound to
|
||||
* Used to show when an automation runs, and to highlight the ones that never will
|
||||
*/
|
||||
/** Collects the lifecycles each automation is bound to, so the list can show when it runs */
|
||||
export function groupTriggersByAutomation(triggers: Trigger[]): Record<string, TimerLifeCycle[]> {
|
||||
const grouped: Record<string, TimerLifeCycle[]> = {};
|
||||
|
||||
|
||||
@@ -118,8 +118,6 @@ describe('deleteTrigger()', () => {
|
||||
});
|
||||
|
||||
it('ignores a trigger that is already gone', async () => {
|
||||
// a client reconciling several triggers must not be stuck because another client
|
||||
// removed one of them first: the end state it asked for is the one it gets
|
||||
const before = getAutomationTriggers();
|
||||
await expect(deleteTrigger('never-existed')).resolves.toBeUndefined();
|
||||
expect(getAutomationTriggers()).toEqual(before);
|
||||
|
||||
@@ -87,9 +87,8 @@ export async function deleteTrigger(id: string): Promise<void> {
|
||||
const triggers = getAutomationTriggers();
|
||||
const index = triggers.findIndex((trigger) => trigger.id === id);
|
||||
|
||||
// ignore request if the trigger does not exist, as deleteAutomation does for the same reason:
|
||||
// the caller asked for it to be gone and it is, and failing here makes a client that is
|
||||
// reconciling several triggers unable to finish once another client removed one of them
|
||||
// deleting is idempotent, as it is in deleteAutomation: the state the caller asked for
|
||||
// already holds, and erroring would only punish a client that raced another one
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user