mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-13 15:27:25 +02:00
add campaign jitter
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -31,6 +31,10 @@ type Campaign struct {
|
||||
ConstraintStartTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintStartTime"`
|
||||
ConstraintEndTime nullable.Nullable[vo.CampaignTimeConstraint] `json:"constraintEndTime"`
|
||||
|
||||
// jitter is used only during scheduling, not persisted to database
|
||||
JitterMin nullable.Nullable[int] `json:"jitterMin,omitempty"`
|
||||
JitterMax nullable.Nullable[int] `json:"jitterMax,omitempty"`
|
||||
|
||||
Name nullable.Nullable[vo.String64] `json:"name"`
|
||||
|
||||
SaveSubmittedData nullable.Nullable[bool] `json:"saveSubmittedData"`
|
||||
@@ -166,6 +170,39 @@ func (c *Campaign) Validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// validate jitter values if specified
|
||||
// negative values are allowed for asymmetric jitter (e.g., -10 to 20 means send 10 min early to 20 min late)
|
||||
if c.JitterMin.IsSpecified() && !c.JitterMin.IsNull() && c.JitterMax.IsSpecified() && !c.JitterMax.IsNull() {
|
||||
jitterMin := c.JitterMin.MustGet()
|
||||
jitterMax := c.JitterMax.MustGet()
|
||||
if jitterMax < jitterMin {
|
||||
return validate.WrapErrorWithField(errors.New("jitter max must be greater than or equal to jitter min"), "jitterMax")
|
||||
}
|
||||
// validate jitter doesn't exceed campaign duration
|
||||
if c.SendStartAt.IsSpecified() && !c.SendStartAt.IsNull() && c.SendEndAt.IsSpecified() && !c.SendEndAt.IsNull() {
|
||||
startAt := c.SendStartAt.MustGet()
|
||||
endAt := c.SendEndAt.MustGet()
|
||||
campaignDuration := endAt.Sub(startAt)
|
||||
|
||||
// check if max jitter (positive) could push beyond end time
|
||||
maxJitterDuration := time.Duration(jitterMax) * time.Minute
|
||||
if maxJitterDuration > campaignDuration {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("jitter max cannot exceed campaign duration"),
|
||||
"jitterMax",
|
||||
)
|
||||
}
|
||||
|
||||
// check if min jitter (negative) could push before start time
|
||||
minJitterDuration := time.Duration(jitterMin) * time.Minute
|
||||
if minJitterDuration < -campaignDuration {
|
||||
return validate.WrapErrorWithField(
|
||||
errors.New("jitter min cannot exceed campaign duration"),
|
||||
"jitterMin",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// must not be set from api consumers
|
||||
if c.NotableEventID.IsSpecified() && !c.NotableEventID.IsNull() {
|
||||
c.NotableEventID.SetNull()
|
||||
|
||||
@@ -167,6 +167,9 @@ func (c *Campaign) Create(
|
||||
c.Logger.Errorw("failed to get campaign by id", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
// preserve jitter values from original campaign (not persisted to db)
|
||||
createdCampaign.JitterMin = campaign.JitterMin
|
||||
createdCampaign.JitterMax = campaign.JitterMax
|
||||
err = c.schedule(ctx, session, createdCampaign)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to schedule campaign", "error", err)
|
||||
@@ -180,6 +183,49 @@ func (c *Campaign) Create(
|
||||
|
||||
// schedule campaign schedules the campaign
|
||||
// this is a service method that does not perform auth, use with consideration
|
||||
// applyJitter applies random jitter to a time based on jitter min/max in minutes
|
||||
// if min is negative of max (symmetric), randomly applies as positive or negative
|
||||
// e.g., min=-10, max=10 means randomly offset by 0-10 minutes, then randomly early or late
|
||||
// clamps the result to stay within startBound and endBound
|
||||
func applyJitter(baseTime time.Time, jitterMin, jitterMax int, startBound, endBound time.Time) time.Time {
|
||||
if jitterMin == 0 && jitterMax == 0 {
|
||||
return baseTime
|
||||
}
|
||||
|
||||
var randomJitter int
|
||||
|
||||
// check if symmetric jitter (min = -max)
|
||||
if jitterMin == -jitterMax {
|
||||
// symmetric: pick random magnitude, then randomly apply as positive or negative
|
||||
magnitude := rand.Intn(jitterMax + 1)
|
||||
if rand.Intn(2) == 0 {
|
||||
randomJitter = -magnitude
|
||||
} else {
|
||||
randomJitter = magnitude
|
||||
}
|
||||
} else {
|
||||
// asymmetric: generate random jitter between min and max
|
||||
jitterRange := jitterMax - jitterMin
|
||||
if jitterRange > 0 {
|
||||
randomJitter = jitterMin + rand.Intn(jitterRange+1)
|
||||
} else {
|
||||
randomJitter = jitterMin
|
||||
}
|
||||
}
|
||||
|
||||
jitteredTime := baseTime.Add(time.Duration(randomJitter) * time.Minute)
|
||||
|
||||
// clamp to campaign bounds
|
||||
if jitteredTime.Before(startBound) {
|
||||
return startBound
|
||||
}
|
||||
if jitteredTime.After(endBound) {
|
||||
return endBound
|
||||
}
|
||||
|
||||
return jitteredTime
|
||||
}
|
||||
|
||||
func (c *Campaign) schedule(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
@@ -329,10 +375,27 @@ func (c *Campaign) schedule(
|
||||
return fmt.Errorf("no recipients to schedule for '%s'", campaign.Name.MustGet())
|
||||
}
|
||||
scheduledEvent := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_SCHEDULED]
|
||||
|
||||
// get jitter values if specified
|
||||
jitterMin := 0
|
||||
jitterMax := 0
|
||||
if campaign.JitterMin.IsSpecified() && !campaign.JitterMin.IsNull() {
|
||||
jitterMin = campaign.JitterMin.MustGet()
|
||||
}
|
||||
if campaign.JitterMax.IsSpecified() && !campaign.JitterMax.IsNull() {
|
||||
jitterMax = campaign.JitterMax.MustGet()
|
||||
}
|
||||
c.Logger.Debugw("scheduling campaign with jitter",
|
||||
"campaignName", campaign.Name.MustGet(),
|
||||
"jitterMin", jitterMin,
|
||||
"jitterMax", jitterMax,
|
||||
)
|
||||
|
||||
// handle single recipient
|
||||
if recipientsCount == 1 {
|
||||
recpID := nullable.NewNullableWithValue(recipients[0].ID.MustGet())
|
||||
campaignID := nullable.NewNullableWithValue(campaign.ID.MustGet())
|
||||
startAt = applyJitter(startAt, jitterMin, jitterMax, startAt, endAt)
|
||||
campaignRecipient := &model.CampaignRecipient{
|
||||
RecipientID: recpID,
|
||||
CampaignID: campaignID,
|
||||
@@ -406,6 +469,8 @@ func (c *Campaign) schedule(
|
||||
// get the next recipient
|
||||
recipient := recipients[0]
|
||||
recipients = recipients[1:]
|
||||
// apply jitter to send time
|
||||
currentDayStart = applyJitter(currentDayStart, jitterMin, jitterMax, startAt, endAt)
|
||||
// save
|
||||
campaignRecipient := &model.CampaignRecipient{
|
||||
RecipientID: recipient.ID,
|
||||
@@ -451,6 +516,8 @@ func (c *Campaign) schedule(
|
||||
sa := campaignRecipients[i-1].SendAt.MustGet().Add(interval * time.Duration(1))
|
||||
sentAt = sa
|
||||
}
|
||||
// apply jitter to send time
|
||||
sentAt = applyJitter(sentAt, jitterMin, jitterMax, startAt, endAt)
|
||||
// todo perhaps this array is unnecesssary
|
||||
//recpID := recipient.ID.MustGet()
|
||||
//campaignID := campaign.ID.MustGet()
|
||||
@@ -1411,6 +1478,9 @@ func (c *Campaign) UpdateByID(
|
||||
c.Logger.Errorw("failed to add recipient groups", "error", err)
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
// preserve jitter values from incoming campaign (not persisted to db)
|
||||
current.JitterMin = incoming.JitterMin
|
||||
current.JitterMax = incoming.JitterMax
|
||||
err = c.schedule(ctx, session, current)
|
||||
if err != nil {
|
||||
c.Logger.Errorw("failed to re-schedule campaign", "error", err)
|
||||
|
||||
@@ -516,6 +516,8 @@ export class API {
|
||||
* @param {Array} [campaign.constraintWeekDays]
|
||||
* @param {string} [campaign.constraintStartTime]
|
||||
* @param {string} [campaign.constraintEndTime]
|
||||
* @param {number} [campaign.jitterMin]
|
||||
* @param {number} [campaign.jitterMax]
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
create: async ({
|
||||
@@ -541,7 +543,9 @@ export class API {
|
||||
webhookIncludeData,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime
|
||||
constraintEndTime,
|
||||
jitterMin,
|
||||
jitterMax
|
||||
}) => {
|
||||
return await postJSON(this.getPath('/campaign'), {
|
||||
companyID,
|
||||
@@ -566,7 +570,9 @@ export class API {
|
||||
webhookIncludeData,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime
|
||||
constraintEndTime,
|
||||
jitterMin,
|
||||
jitterMax
|
||||
});
|
||||
},
|
||||
|
||||
@@ -595,6 +601,8 @@ export class API {
|
||||
* @param {Array} [campaign.constraintWeekDays]
|
||||
* @param {string} [campaign.constraintStartTime]
|
||||
* @param {string} [campaign.constraintEndTime]
|
||||
* @param {number} [campaign.jitterMin]
|
||||
* @param {number} [campaign.jitterMax]
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
update: async ({
|
||||
@@ -620,7 +628,9 @@ export class API {
|
||||
webhookIncludeData,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime
|
||||
constraintEndTime,
|
||||
jitterMin,
|
||||
jitterMax
|
||||
}) => {
|
||||
return await postJSON(this.getPath(`/campaign/${id}`), {
|
||||
templateID,
|
||||
@@ -644,7 +654,9 @@ export class API {
|
||||
webhookIncludeData,
|
||||
constraintWeekDays,
|
||||
constraintStartTime,
|
||||
constraintEndTime
|
||||
constraintEndTime,
|
||||
jitterMin,
|
||||
jitterMax
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script>
|
||||
export let id = 'jitter-slider';
|
||||
export let valueMin = 0;
|
||||
export let valueMax = 0;
|
||||
|
||||
// jitter options: symmetric jitter (applied randomly as positive or negative)
|
||||
const jitterOptions = [
|
||||
{ value: 0, label: 'No jitter' },
|
||||
{ value: 1, label: '±1 min' },
|
||||
{ value: 2, label: '±2 min' },
|
||||
{ value: 5, label: '±5 min' },
|
||||
{ value: 10, label: '±10 min' },
|
||||
{ value: 15, label: '±15 min' },
|
||||
{ value: 20, label: '±20 min' },
|
||||
{ value: 30, label: '±30 min' },
|
||||
{ value: 45, label: '±45 min' },
|
||||
{ value: 60, label: '±60 min' },
|
||||
{ value: 90, label: '±90 min' },
|
||||
{ value: 120, label: '±120 min' }
|
||||
];
|
||||
|
||||
let selectedIndex = 0; // default to "no jitter"
|
||||
|
||||
// update values when index changes - symmetric jitter
|
||||
$: {
|
||||
const jitter = jitterOptions[selectedIndex].value;
|
||||
valueMin = -jitter;
|
||||
valueMax = jitter;
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
selectedIndex = parseInt(event.currentTarget.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pt-4 pb-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-400 py-1 transition-colors duration-200">
|
||||
<slot>Jitter</slot>
|
||||
|
||||
<span class="italic font-normal">
|
||||
({jitterOptions[selectedIndex].label})
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
{id}
|
||||
type="range"
|
||||
min="0"
|
||||
max="11"
|
||||
bind:value={selectedIndex}
|
||||
on:input={handleInput}
|
||||
class="w-96 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer hover:[&::-moz-range-thumb]:bg-blue-700 transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,6 +48,7 @@
|
||||
import BigButton from '$lib/components/BigButton.svelte';
|
||||
import ToIcon from '$lib/components/ToIcon.svelte';
|
||||
import Datetime from '$lib/components/Datetime.svelte';
|
||||
import JitterSlider from '$lib/components/JitterSlider.svelte';
|
||||
import RelativeTime from '$lib/components/RelativeTime.svelte';
|
||||
import AutoRefresh from '$lib/components/AutoRefresh.svelte';
|
||||
import CheckboxField from '$lib/components/CheckboxField.svelte';
|
||||
@@ -203,16 +204,30 @@
|
||||
const SPREAD_MANUAL = 'manual';
|
||||
const SPREAD_IMMEDIATE = 'immediate';
|
||||
const SPREAD_1MIN = '1min';
|
||||
const SPREAD_2MIN = '2min';
|
||||
const SPREAD_5MIN = '5min';
|
||||
const SPREAD_10MIN = '10min';
|
||||
const SPREAD_20MIN = '20min';
|
||||
const SPREAD_30MIN = '30min';
|
||||
const SPREAD_1HOUR = '1hour';
|
||||
const SPREAD_2HOUR = '2hour';
|
||||
const SPREAD_5HOUR = '5hour';
|
||||
const SPREAD_12HOUR = '12hour';
|
||||
const SPREAD_24HOUR = '24hour';
|
||||
|
||||
const spreadOptionMap = new BiMap({
|
||||
Manual: SPREAD_MANUAL,
|
||||
'1 minute': SPREAD_1MIN,
|
||||
'2 minutes': SPREAD_2MIN,
|
||||
'5 minutes': SPREAD_5MIN,
|
||||
'10 minutes': SPREAD_10MIN,
|
||||
'20 minutes': SPREAD_20MIN,
|
||||
'1 hour': SPREAD_1HOUR
|
||||
'30 minutes': SPREAD_30MIN,
|
||||
'1 hour': SPREAD_1HOUR,
|
||||
'2 hours': SPREAD_2HOUR,
|
||||
'5 hours': SPREAD_5HOUR,
|
||||
'12 hours': SPREAD_12HOUR,
|
||||
'24 hours': SPREAD_24HOUR
|
||||
});
|
||||
|
||||
let spreadOption = SPREAD_MANUAL;
|
||||
@@ -223,12 +238,26 @@
|
||||
return 0;
|
||||
case SPREAD_1MIN:
|
||||
return 60000;
|
||||
case SPREAD_2MIN:
|
||||
return 120000;
|
||||
case SPREAD_5MIN:
|
||||
return 300000;
|
||||
case SPREAD_10MIN:
|
||||
return 600000;
|
||||
case SPREAD_20MIN:
|
||||
return 1200000;
|
||||
case SPREAD_30MIN:
|
||||
return 1800000;
|
||||
case SPREAD_1HOUR:
|
||||
return 3600000;
|
||||
case SPREAD_2HOUR:
|
||||
return 7200000;
|
||||
case SPREAD_5HOUR:
|
||||
return 18000000;
|
||||
case SPREAD_12HOUR:
|
||||
return 43200000;
|
||||
case SPREAD_24HOUR:
|
||||
return 86400000;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -260,7 +289,9 @@
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
webhookValue: null,
|
||||
webhookIncludeData: false
|
||||
webhookIncludeData: false,
|
||||
jitterMin: 0,
|
||||
jitterMax: 0
|
||||
};
|
||||
|
||||
let modalError = '';
|
||||
@@ -640,7 +671,9 @@
|
||||
constraintStartTime: contraintStartTimeUTC,
|
||||
constraintEndTime: contraintEndTimeUTC,
|
||||
webhookID: webhookMap.byValueOrNull(formValues.webhookValue),
|
||||
webhookIncludeData: formValues.webhookIncludeData
|
||||
webhookIncludeData: formValues.webhookIncludeData,
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
@@ -704,7 +737,9 @@
|
||||
denyPageID: denyPageMap.byValueOrNull(formValues.denyPageValue),
|
||||
evasionPageID: denyPageMap.byValueOrNull(formValues.evasionPageValue),
|
||||
webhookID: webhookMap.byValueOrNull(formValues.webhookValue),
|
||||
webhookIncludeData: formValues.webhookIncludeData
|
||||
webhookIncludeData: formValues.webhookIncludeData,
|
||||
jitterMin: formValues.jitterMin !== 0 ? formValues.jitterMin : null,
|
||||
jitterMax: formValues.jitterMax !== 0 ? formValues.jitterMax : null
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
@@ -830,7 +865,9 @@
|
||||
obfuscate: false,
|
||||
selectedCount: 0,
|
||||
webhookValue: null,
|
||||
webhookIncludeData: false
|
||||
webhookIncludeData: false,
|
||||
jitterMin: 0,
|
||||
jitterMax: 0
|
||||
};
|
||||
scheduleType = 'basic';
|
||||
allowDenyType = 'none';
|
||||
@@ -1313,10 +1350,10 @@
|
||||
</div>
|
||||
|
||||
{#if formValues.sendStartAt}
|
||||
<div class="pl-36 pt-4 pb-6">
|
||||
<div class="pt-4 pb-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p
|
||||
class="text-sm font-semibold text-slate-600 dark:text-gray-300 transition-colors duration-200"
|
||||
class="font-semibold text-slate-600 dark:text-gray-400 py-1 transition-colors duration-200"
|
||||
>
|
||||
Distribution Speed
|
||||
|
||||
@@ -1325,32 +1362,53 @@
|
||||
{#if spreadOption === SPREAD_MANUAL}
|
||||
Manual timing
|
||||
{:else if spreadOption === SPREAD_1MIN}
|
||||
1 minutes apart
|
||||
1 minute apart
|
||||
{:else if spreadOption === SPREAD_2MIN}
|
||||
2 minutes apart
|
||||
{:else if spreadOption === SPREAD_5MIN}
|
||||
5 minutes apart
|
||||
{:else if spreadOption === SPREAD_10MIN}
|
||||
10 minutes apart
|
||||
{:else if spreadOption === SPREAD_20MIN}
|
||||
20 minutes apart
|
||||
{:else if spreadOption === SPREAD_30MIN}
|
||||
30 minutes apart
|
||||
{:else if spreadOption === SPREAD_1HOUR}
|
||||
1 hour apart
|
||||
{:else if spreadOption === SPREAD_2HOUR}
|
||||
2 hours apart
|
||||
{:else if spreadOption === SPREAD_5HOUR}
|
||||
5 hours apart
|
||||
{:else if spreadOption === SPREAD_12HOUR}
|
||||
12 hours apart
|
||||
{:else if spreadOption === SPREAD_24HOUR}
|
||||
24 hours apart
|
||||
{/if}
|
||||
)
|
||||
</span>
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="4"
|
||||
max="11"
|
||||
bind:value={speedIndex}
|
||||
class="w-48 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700"
|
||||
class="w-96 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-600 [&::-webkit-slider-thumb]:cursor-pointer hover:[&::-webkit-slider-thumb]:bg-blue-700 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-blue-600 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer hover:[&::-moz-range-thumb]:bg-blue-700 transition-colors duration-200"
|
||||
on:input={(event) => {
|
||||
const index = parseInt(event.currentTarget.value);
|
||||
const speeds = [
|
||||
SPREAD_MANUAL,
|
||||
SPREAD_1MIN,
|
||||
SPREAD_2MIN,
|
||||
SPREAD_5MIN,
|
||||
SPREAD_10MIN,
|
||||
SPREAD_20MIN,
|
||||
SPREAD_1HOUR
|
||||
SPREAD_30MIN,
|
||||
SPREAD_1HOUR,
|
||||
SPREAD_2HOUR,
|
||||
SPREAD_5HOUR,
|
||||
SPREAD_12HOUR,
|
||||
SPREAD_24HOUR
|
||||
];
|
||||
spreadOption = speeds[index];
|
||||
const milliseconds = getSpreadMilliseconds(spreadOption);
|
||||
@@ -1520,6 +1578,14 @@
|
||||
options={Array.from(sortOrder.keys())}>Delivery order</TextFieldSelect
|
||||
>
|
||||
|
||||
<JitterSlider
|
||||
id="jitter-slider"
|
||||
bind:valueMin={formValues.jitterMin}
|
||||
bind:valueMax={formValues.jitterMax}
|
||||
>
|
||||
Jitter
|
||||
</JitterSlider>
|
||||
|
||||
<DateTimeField
|
||||
bind:value={formValues.closeAt}
|
||||
min={formValues.sendEndAt
|
||||
@@ -1894,6 +1960,15 @@
|
||||
{spreadOptionMap.byValue(spreadOption)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if formValues.jitterMin !== 0 || formValues.jitterMax !== 0}
|
||||
<span class="text-grayblue-dark font-medium">Jitter:</span>
|
||||
<span
|
||||
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
{formValues.jitterMin} to {formValues.jitterMax} minutes
|
||||
</span>
|
||||
{/if}
|
||||
{:else if scheduleType === 'schedule'}
|
||||
<span class="text-grayblue-dark font-medium">Active days:</span>
|
||||
<span
|
||||
@@ -1909,6 +1984,15 @@
|
||||
{formValues.contraintStartTime} - {formValues.contraintEndTime}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if formValues.jitterMin !== 0 || formValues.jitterMax !== 0}
|
||||
<span class="text-grayblue-dark font-medium">Jitter:</span>
|
||||
<span
|
||||
class="text-pc-darkblue dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
{formValues.jitterMin} to {formValues.jitterMax} minutes
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if formValues.closeAt}
|
||||
|
||||
Reference in New Issue
Block a user