fix(scheduler): подтверждать ручные операции и учитывать частичные ошибки

This commit is contained in:
Denozordec committed 2026-10-03 11:56:08 +07:00
1 parent cb377fb60c
commit 5df337f6d7
7 files changed
+117 -36

No files matched your search

+33 -33
View File
@@ -27,8 +27,8 @@ import {
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings, putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "@/shared/api/collector-settings"
import { can } from "@/lib/auth"
import { getCertificateRenewSettings, putCertificateRenewSettings } from "@/shared/api/certificates"
import { getSchedulerStatus, listSchedulerRuns } from "@/shared/api/scheduler"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import { getSchedulerStatus, listSchedulerRuns, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
import { ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRunSnapshot,
@@ -70,12 +70,6 @@ import {
XCircleIcon,
} from "lucide-react"
function makeApiFetch(backendUrl: string) {
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
return requestJson<T>(backendUrl, path, init)
}
}
function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
@@ -524,7 +518,6 @@ export default function DataCollectionPage() {
function DataCollectionContent() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
@@ -597,7 +590,6 @@ function DataCollectionContent() {
const controller = new AbortController(); reader.current.controller = controller
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
const current = () => reader.current.active && revision === reader.current.revision && !controller.signal.aborted
setCollectorError(null)
setPageBusy(true)
try {
const runsFilter = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number] : undefined
@@ -733,14 +725,34 @@ function DataCollectionContent() {
}, [isLive, loadCollectors])
const handleRefreshScheduler = async () => {
if (!isLive) return
setCollectorError(null)
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin")) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
try {
await apiFetch("/api/scheduler/refresh", { method: "POST" })
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик")
}
const data = await refreshScheduler(backendUrl)
if (!reader.current.active) return
setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString())
setSaveMessage("Перечитывание таймеров подтверждено сервером")
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}
const handleRunNow = async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number]) => {
const job = schedulerJobsByKey[jobKey]
if (!isLive || !reader.current.active || mutationLock.current || !can("mm:settings:admin") || !job || job.running) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setRunNowJobKey(jobKey); setCollectorError(null); setSaveMessage(null)
try {
const data = await runSchedulerJobNow(backendUrl, jobKey)
if (!reader.current.active) return
if (data.status) { setSchedulerStatus(data.status); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
else setSchedulerStatusError(data.statusError ?? "Статус после запуска недоступен")
const skipped = "skipped" in data.result.result && data.result.result.skipped
const notDue = data.result.result.job === "backups" && !data.result.result.due
setSaveMessage((!data.ok ? "Запуск завершился с ошибками: " : skipped || notDue ? "Прогон пропущен: " : "Запуск подтверждён: ") + SCHEDULER_JOB_LABELS[jobKey])
const warnings = [data.result.error, data.result.eventError ? "Журнал событий: " + data.result.eventError : null, data.statusError ? "Статус: " + data.statusError : null].filter(Boolean)
if (warnings.length) setCollectorError(warnings.join("; "))
} catch (error) {
if (reader.current.active) setCollectorError(extractApiError(error) + ". Результат операции неизвестен; перед повторным запуском обновите журнал.")
} finally { mutationLock.current = false; if (reader.current.active) { setSchedulerSaveBusy(false); setRunNowJobKey(null) } }
}
const schedulerGridRows: SchedulerJobGridRow[] = (() => {
@@ -829,20 +841,8 @@ function DataCollectionContent() {
void handleJobEnabledChange(jobKey, nextEnabled)
},
onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
onRunNow: async () => {
setRunNowJobKey(jobKey)
setCollectorError(null)
try {
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
method: "POST",
})
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
} finally {
setRunNowJobKey(null)
}
},
onRunNow: async () => { await handleRunNow(jobKey) },
runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
runNowLoading: runNowJobKey === jobKey,
saveBusy: schedulerSaveBusy || !writable(settingsKey),
}
@@ -906,7 +906,7 @@ function DataCollectionContent() {
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy} onClick={() => { void handleRefreshScheduler() }}>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy || !can("mm:settings:admin")} onClick={() => { void handleRefreshScheduler() }}>
Перечитать таймеры
</Button>
</div>
@@ -992,7 +992,7 @@ function DataCollectionContent() {
<div className="border-b border-border px-5 py-4">
<p className="text-base font-medium">Планировщик сбора данных</p>
<p className="text-xs text-muted-foreground mt-1">
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.
Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
</p>
</div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />