feat(cron): add scheduled jobs configuration in admin (#59)
* feat(cron): add database schedule jobs instead of file * feat(cron): add scheduled jobs configuration in admin (#59) * reformat: fix codestyle (#59) * reformat: fix codestyle (#59) * feat: disable cron debug (#59)
This commit is contained in:
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal file
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<Dropdown
|
||||
:model-value="modelValue"
|
||||
:options="options"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Интервал"
|
||||
class="tw:w-[14rem] tw:shrink-0"
|
||||
@update:model-value="$emit('update:modelValue', $event ?? '')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
|
||||
/** Пресеты интервалов (label — для отображения, value — cron expression) */
|
||||
const PRESETS = [
|
||||
{ label: 'Раз в минуту', value: '* * * * *' },
|
||||
{ label: 'Раз в 5 минут', value: '*/5 * * * *' },
|
||||
{ label: 'Раз в 10 минут', value: '*/10 * * * *' },
|
||||
{ label: 'Раз в час', value: '0 * * * *' },
|
||||
{ label: 'Раз в 3 часа', value: '0 */3 * * *' },
|
||||
{ label: 'Раз в 6 часов', value: '0 */6 * * *' },
|
||||
{ label: 'Раз в сутки', value: '0 0 * * *' },
|
||||
{ label: 'Раз в неделю', value: '0 0 * * 0' },
|
||||
];
|
||||
|
||||
const presetValues = new Set(PRESETS.map((p) => p.value));
|
||||
|
||||
/** Только пресеты; если текущее значение не из списка — показываем его в списке (уже сохранённое в БД), чтобы не терять отображение */
|
||||
const options = computed(() => {
|
||||
const current = props.modelValue ?? '';
|
||||
if (!current || presetValues.has(current)) {
|
||||
return PRESETS;
|
||||
}
|
||||
return [{ label: current, value: current }, ...PRESETS];
|
||||
});
|
||||
</script>
|
||||
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal file
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<SettingsItem label="URL для cron-job.org">
|
||||
<template #default>
|
||||
<InputGroup>
|
||||
<Button
|
||||
icon="fa fa-refresh"
|
||||
severity="secondary"
|
||||
:loading="regeneratingUrl"
|
||||
v-tooltip.top="'Перегенерировать URL'"
|
||||
@click="confirmRegenerateUrl"
|
||||
/>
|
||||
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard"/>
|
||||
<InputText readonly :model-value="cronJobOrgUrl" class="tw:w-full"/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
<template #help>
|
||||
Создайте задачу на <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>, укажите этот URL и интервал (например, каждые 5 минут). Метод: GET. Учитывайте лимиты по времени запроса на вашем хостинге — для тяжёлых задач возможны таймауты. При утечке URL нажмите «Перегенерировать URL» и обновите задачу на cron-job.org.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import SettingsItem from '@/components/SettingsItem.vue';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { toastBus } from '@/utils/toastHelper.js';
|
||||
import { apiPost } from '@/utils/http.js';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const confirm = useConfirm();
|
||||
const regeneratingUrl = ref(false);
|
||||
|
||||
const cronJobOrgUrl = computed(() => settings.items.cron?.schedule_url ?? '');
|
||||
|
||||
function confirmRegenerateUrl(event) {
|
||||
confirm.require({
|
||||
group: 'popup',
|
||||
target: event.currentTarget,
|
||||
message: 'После смены URL его нужно будет обновить в задаче на cron-job.org. Продолжить?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
rejectProps: { label: 'Отмена', severity: 'secondary', outlined: true },
|
||||
acceptProps: { label: 'Перегенерировать', severity: 'secondary' },
|
||||
accept: () => regenerateUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
async function regenerateUrl() {
|
||||
regeneratingUrl.value = true;
|
||||
try {
|
||||
const res = await apiPost('regenerateCronScheduleUrl', {});
|
||||
if (res?.success && res.data?.api_key) {
|
||||
settings.items.cron.api_key = res.data.api_key;
|
||||
if (res.data.schedule_url !== undefined) {
|
||||
settings.items.cron.schedule_url = res.data.schedule_url;
|
||||
}
|
||||
toastBus.emit('show', {
|
||||
severity: 'success',
|
||||
summary: 'URL обновлён',
|
||||
detail: 'Обновите URL в задаче на cron-job.org',
|
||||
life: 4000,
|
||||
});
|
||||
} else {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: res?.data?.error ?? 'Не удалось перегенерировать URL',
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: err?.response?.data?.error ?? 'Не удалось перегенерировать URL',
|
||||
life: 4000,
|
||||
});
|
||||
} finally {
|
||||
regeneratingUrl.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(cronJobOrgUrl.value);
|
||||
toastBus.emit('show', {
|
||||
severity: 'success',
|
||||
summary: 'Скопировано',
|
||||
detail: 'URL скопирован в буфер обмена',
|
||||
life: 2000,
|
||||
});
|
||||
} catch (err) {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось скопировать текст',
|
||||
life: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal file
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="tw:border tw:border-gray-300 tw:rounded-md tw:bg-white tw:overflow-hidden">
|
||||
<div class="tw:flex tw:flex-col">
|
||||
<div
|
||||
v-for="job in scheduledJobs"
|
||||
:key="job.id"
|
||||
class="tw:flex tw:items-center tw:gap-4 tw:py-3 tw:px-3 tw:border-b tw:border-gray-200 tw:last:border-b-0"
|
||||
>
|
||||
<ToggleSwitch
|
||||
:model-value="Boolean(job.is_enabled)"
|
||||
@update:model-value="onJobToggle(job, $event)"
|
||||
/>
|
||||
<div class="tw:min-w-0 tw:flex-1 tw:flex tw:flex-col tw:gap-0.5">
|
||||
<span class="tw:text-[inherit] tw:font-bold">{{ hasJobMeta(job.name) ? jobMeta(job.name).friendlyName : job.name }}</span>
|
||||
<span v-if="hasJobMeta(job.name) && jobMeta(job.name).description" class="tw:text-sm tw:text-gray-500">
|
||||
{{ jobMeta(job.name).description }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="job.failed_reason"
|
||||
class="tw:inline-flex tw:items-center tw:gap-1 tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-red-100 tw:text-red-700 tw:cursor-default"
|
||||
role="img"
|
||||
:aria-label="'Ошибка: ' + job.failed_reason"
|
||||
v-tooltip.top="errorTooltip(job)"
|
||||
>
|
||||
<i class="fa fa-exclamation-circle" aria-hidden="true"/>
|
||||
Ошибка
|
||||
</span>
|
||||
<div
|
||||
v-else
|
||||
class="tw:flex tw:flex-col tw:gap-0.5 tw:shrink-0 tw:items-end"
|
||||
>
|
||||
<span
|
||||
class="tw:inline-flex tw:items-center tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-green-100 tw:text-green-800 tw:cursor-default"
|
||||
v-tooltip.top="'Дата последнего успешного запуска'"
|
||||
>
|
||||
{{ formatLastRun(job.last_success_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="formatDuration(job.last_duration_seconds)"
|
||||
class="tw:text-xs tw:text-gray-500"
|
||||
>
|
||||
Время выполнения: {{ formatDuration(job.last_duration_seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="tw:shrink-0 tw:w-[14rem]">
|
||||
<CronExpressionSelect
|
||||
:model-value="job.cron_expression"
|
||||
@update:model-value="job.cron_expression = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!scheduledJobs.length" class="tw:text-gray-500 tw:py-4 tw:px-3">
|
||||
Нет запланированных задач
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import ToggleSwitch from 'primevue/toggleswitch';
|
||||
import CronExpressionSelect from '@/components/CronExpressionSelect.vue';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
/** Человекочитаемое имя и описание задач (ключ — job.name с бэкенда) */
|
||||
const JOB_META = {
|
||||
telecart_pulse_send_events: {
|
||||
friendlyName: 'Отправка данных в TeleCart Pulse',
|
||||
description: 'Отправка данных телеметрии о действиях в TeleCart. Требуется для сбора метрик по рассылкам и кампаниям, сделанных через сервис TeleCart Pulse',
|
||||
},
|
||||
};
|
||||
|
||||
function hasJobMeta(jobName) {
|
||||
return jobName in JOB_META;
|
||||
}
|
||||
|
||||
function jobMeta(jobName) {
|
||||
return JOB_META[jobName];
|
||||
}
|
||||
|
||||
const scheduledJobs = computed(() => settings.items.scheduled_jobs ?? []);
|
||||
|
||||
function formatLastRun(value) {
|
||||
if (!value) return '—';
|
||||
const d = typeof value === 'string' ? new Date(value.replace(' ', 'T')) : new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null || seconds === '' || Number(seconds) <= 0) return '';
|
||||
const n = Number(seconds);
|
||||
if (Number.isNaN(n)) return '';
|
||||
if (n < 1) return 'менее 1с';
|
||||
return `${n % 1 ? n.toFixed(1) : Math.round(n)}с`;
|
||||
}
|
||||
|
||||
function errorTooltip(job) {
|
||||
const dateStr = formatLastRun(job.failed_at);
|
||||
return `${dateStr}\n${job.failed_reason ?? ''}`.trim();
|
||||
}
|
||||
|
||||
function onJobToggle(job, isEnabled) {
|
||||
job.is_enabled = isEnabled ? 1 : 0;
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +1,65 @@
|
||||
<template>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label" for="module_tgshop_status">
|
||||
{{ label }}
|
||||
</label>
|
||||
<div class="col-sm-2 tw:flex tw:flex-col tw:gap-1">
|
||||
<label class="control-label" for="module_tgshop_status">
|
||||
{{ label }}
|
||||
</label>
|
||||
<a
|
||||
v-if="docHref"
|
||||
:href="docHref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="tw:inline-flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-500 hover:tw:text-gray-700 tw:underline tw:decoration-dotted tw:decoration-1 tw:underline-offset-2 tw:w-fit tw:self-end"
|
||||
>
|
||||
<i class="fa fa-external-link tw:text-xs" aria-hidden="true"></i>
|
||||
<span>Документация</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<slot name="default"></slot>
|
||||
<div class="help-block">
|
||||
<slot name="help"></slot>
|
||||
</div>
|
||||
<div v-if="hasExpandable">
|
||||
<Button
|
||||
:label="expandableLabel"
|
||||
severity="info"
|
||||
link
|
||||
size="small"
|
||||
@click="expanded = !expanded"
|
||||
/>
|
||||
<div v-show="expanded" class="tw:mt-2 tw:space-y-2">
|
||||
<slot name="expandable"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, useSlots, computed } from 'vue';
|
||||
import Button from 'primevue/button';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/** Ссылка на документацию: отображается под label, открывается в новой вкладке */
|
||||
docHref: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
/** Подпись кнопки раскрытия блока #expandable (по умолчанию «Подробнее») */
|
||||
expandableLabel: {
|
||||
type: String,
|
||||
default: 'Подробнее',
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const hasExpandable = computed(() => !!slots.expandable);
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -93,7 +93,11 @@ export const useSettingsStore = defineStore('settings', {
|
||||
|
||||
cron: {
|
||||
mode: 'disabled',
|
||||
api_key: '',
|
||||
schedule_url: '',
|
||||
},
|
||||
|
||||
scheduled_jobs: [],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -121,6 +125,14 @@ export const useSettingsStore = defineStore('settings', {
|
||||
...this.items,
|
||||
...response.data,
|
||||
};
|
||||
// Нормализуем типы у запланированных задач (is_enabled — число 0/1), чтобы переключение туда-обратно не меняло хеш
|
||||
const jobs = this.items.scheduled_jobs;
|
||||
if (Array.isArray(jobs)) {
|
||||
this.items.scheduled_jobs = jobs.map((job) => ({
|
||||
...job,
|
||||
is_enabled: job.is_enabled === true || job.is_enabled === 1 || job.is_enabled === '1' ? 1 : 0,
|
||||
}));
|
||||
}
|
||||
// Сохраняем хеш исходного состояния после загрузки
|
||||
this.originalItemsHash = md5(JSON.stringify(this.items));
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<SettingsItem label="Режим работы планировщика">
|
||||
<SettingsItem label="Режим работы планировщика" doc-href="https://docs.telecart.pro/features/cron/">
|
||||
<template #default>
|
||||
<SelectButton
|
||||
v-model="settings.items.cron.mode"
|
||||
@@ -13,67 +13,106 @@
|
||||
<div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold">
|
||||
Все фоновые задачи отключены.
|
||||
</div>
|
||||
<div v-else-if="settings.items.cron.mode === 'cron_job_org'">
|
||||
Задачи запускаются по вызову URL с сервиса <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>. Подходит для лёгких задач; при большом количестве товаров или тяжёлых операциях возможны таймауты.
|
||||
</div>
|
||||
<div v-else>
|
||||
Рекомендуемый режим. Использует системный планировщик задач Linux.
|
||||
</div>
|
||||
|
||||
<div class="tw:mt-2">
|
||||
<p>
|
||||
<strong>Системный CRON (рекомендуется):</strong> Стабильное выполнение задач по расписанию, независимо от
|
||||
посещаемости сайта. Добавьте команду в CRON для автоматического выполнения каждые 5 минут.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #expandable>
|
||||
<p>
|
||||
<strong>Системный CRON (рекомендуется):</strong> Задачи выполняются через команду PHP в оболочке сервера (CLI), без HTTP и без ограничений по времени запроса. Не зависит от посещаемости сайта и подходит для любых объёмов данных, в том числе для тяжёлых задач и больших каталогов. Требует доступа к серверу (SSH или панель с CRON). Добавьте команду ниже в планировщик (обычно <code class="tw:px-1 tw:py-0.5 tw:bg-gray-100 tw:dark:bg-gray-800 tw:rounded">crontab -e</code>) для запуска каждые 5 минут.
|
||||
</p>
|
||||
<p>
|
||||
<strong>cron-job.org:</strong> Внешний сервис по расписанию вызывает URL вашего сайта по HTTP. Не требует доступа к серверу — удобно для shared-хостинга без CRON. Ограничения: выполнение идёт через веб-запрос, поэтому есть лимиты по времени (timeout у хостинга и у cron-job.org). <strong>Не подходит для тяжёлых сайтов</strong> (много товаров, большие каталоги, тяжёлые задачи): запрос может обрываться по таймауту, задачи не успеют завершиться. Выбирайте этот способ только если нет доступа к системному CRON и нагрузка на планировщик небольшая.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
|
||||
</p>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem label="Последний запуск CRON">
|
||||
<template #default>
|
||||
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
|
||||
{{ lastRunDate }}
|
||||
</div>
|
||||
<div v-else class="tw:text-gray-500 tw:py-2">
|
||||
Еще не запускался
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
Время последнего успешного выполнения планировщика задач.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
<div class="tw:relative tw:mt-4">
|
||||
<div
|
||||
:class="[
|
||||
'tw:transition-all tw:duration-200',
|
||||
settings.items.cron.mode === 'disabled'
|
||||
? 'tw:blur-[2px] tw:pointer-events-none tw:select-none'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<SettingsItem label="Последний запуск CRON">
|
||||
<template #default>
|
||||
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
|
||||
{{ lastRunDate }}
|
||||
</div>
|
||||
<div v-else class="tw:text-gray-500 tw:py-2">
|
||||
Еще не запускался
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
Время последнего успешного выполнения планировщика задач.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
v-if="settings.items.cron.mode === 'system'"
|
||||
label="Команда для CRON"
|
||||
>
|
||||
<template #default>
|
||||
<InputGroup>
|
||||
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard(cronCommand)"/>
|
||||
<InputText readonly :model-value="cronCommand" class="tw:w-full"/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
<template #help>
|
||||
Добавьте эту строку в конфигурацию CRON на вашем сервере (обычно `crontab -e`), чтобы запускать планировщик каждые
|
||||
5 минут.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
<SettingsItem
|
||||
v-if="settings.items.cron.mode === 'system'"
|
||||
label="Команда для CRON"
|
||||
>
|
||||
<template #default>
|
||||
<InputGroup>
|
||||
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard(cronCommand)"/>
|
||||
<InputText readonly :model-value="cronCommand" class="tw:w-full"/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
<template #help>
|
||||
Добавьте эту строку в конфигурацию CRON на вашем сервере (обычно `crontab -e`), чтобы запускать планировщик каждые
|
||||
5 минут.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<CronJobOrgUrlField v-if="settings.items.cron.mode === 'cron_job_org'"/>
|
||||
|
||||
<SettingsItem label="Задачи планировщика">
|
||||
<template #default>
|
||||
<ScheduledJobsList />
|
||||
</template>
|
||||
<template #help>
|
||||
Включение и отключение задач планировщика. Дата последнего успешного запуска; при ошибке отображается иконка с подсказкой.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settings.items.cron.mode === 'disabled'"
|
||||
class="tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-white/80 tw:dark:bg-gray-900/80 tw:z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="tw:text-lg tw:font-semibold tw:text-gray-600 tw:dark:text-gray-400">
|
||||
Планировщик выключен
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from 'vue';
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Button from "primevue/button";
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import SettingsItem from '@/components/SettingsItem.vue';
|
||||
import ScheduledJobsList from '@/components/ScheduledJobsList.vue';
|
||||
import CronJobOrgUrlField from '@/components/CronJobOrgUrlField.vue';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import {toastBus} from "@/utils/toastHelper.js";
|
||||
import { toastBus } from '@/utils/toastHelper.js';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const cronModes = [
|
||||
{value: 'system', label: 'Системный CRON (Linux)'},
|
||||
{value: 'cron_job_org', label: 'cron-job.org'},
|
||||
{value: 'disabled', label: 'Выключено'},
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user