"use client" import Link from "next/link" import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" import { Frame, FramePanel } from "@/components/reui/frame" import { IconTile } from "@/components/reui/icon-tile" import { OpsPanel } from "@/components/ops-panel" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { useDataSource } from "@/lib/data-source" import { SCHEDULER_JOB_DESCRIPTIONS, SCHEDULER_JOB_KEYS, SCHEDULER_JOB_LABELS, type CollectorSettingsDto, type SchedulerJobStatusDto, type SchedulerRunRowDto, type UptimeSettingsDto, } from "@/lib/scheduler-settings" import { requestJson, ApiClientError } from "@/shared/api/http-client" import { parseSchedulerRunSnapshot, type AlertEngineRunSnapshot, type GreBgpSnapshotRunSnapshot, type InternetPathRunSnapshot, type CertificatesRenewRunSnapshot, type BackupsRunSnapshot, type PingRunSnapshot, type ResourcesRunSnapshot, type SchedulerRunSnapshot, type ServersRestPingRunSnapshot, type SpeedScheduledRunSnapshot, type TrafficRunSnapshot, } from "@/lib/scheduler-run-snapshot" import { AlertEngineRuleDiagGrid, PingSnapshotGrid, ResourcesSnapshotGrid, ServersRestPingSnapshotGrid, SpeedSnapshotGrid, TrafficSnapshotGrid, } from "@/components/data-grids/snapshot-data-grid" import { DataCollectionSchedulerDataGrid, type SchedulerJobGridRow, } from "@/components/data-grids/data-collection-scheduler-data-grid" import { DataPageCard } from "@/components/data-page-card" import { cn } from "@/lib/utils" import { AlertCircleIcon, CalendarClockIcon, CheckCircleIcon, ChevronDownIcon, DatabaseIcon, LoaderCircleIcon, RefreshCwIcon, XCircleIcon, } from "lucide-react" function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } function extractApiError(reason: unknown): string { if (reason instanceof ApiClientError) return reason.message if (reason instanceof Error) return reason.message return String(reason) } function readSettled(result: PromiseSettledResult): T | null { return result.status === "fulfilled" ? result.value : null } function collectSettledErrors(results: PromiseSettledResult[], labels: string[]): string[] { const errors: string[] = [] for (let i = 0; i < results.length; i += 1) { const result = results[i] if (result.status === "rejected") { errors.push(`${labels[i]}: ${extractApiError(result.reason)}`) } } return errors } function fmtMs(ms: number): string { if (ms < 1000) return `${ms} мс` const s = ms / 1000 return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с` } function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) { if (snap.job === "traffic") { const t = snap as TrafficRunSnapshot return (
{t.skipped ? (

Прогон пропущен: предыдущий сбор трафика ещё выполнялся.

) : null} {t.fatalError ? ( Критическая ошибка: {t.fatalError} ) : null}

Сэмплы на момент {new Date(t.sampledAt).toLocaleString("ru-RU")}

) } if (snap.job === "uptime_resources") { const u = snap as ResourcesRunSnapshot return (
{u.skipped ? (

Прогон пропущен: сбор ресурсов уже выполняется.

) : null} {u.fatalError ? ( Критическая ошибка: {u.fatalError} ) : null}

Сэмплы на {new Date(u.sampledAt).toLocaleString("ru-RU")}

) } if (snap.job === "servers_rest_ping") { const s = snap as ServersRestPingRunSnapshot return (
{s.skipped ? (

Прогон пропущен: предыдущая проверка API ещё выполнялась.

) : null} {s.fatalError ? ( Критическая ошибка: {s.fatalError} ) : null}

GET /system/identity на{" "} {new Date(s.sampledAt).toLocaleString("ru-RU")}

) } if (snap.job === "uptime_ping") { const p = snap as PingRunSnapshot return (
{p.skipped ? (

Прогон пропущен: сбор ping уже выполняется.

) : null} {p.fatalError ? ( Критическая ошибка: {p.fatalError} ) : null} {p.skippedByInterval != null && p.skippedByInterval > 0 ? (

Пропущено по интервалу проб: {p.skippedByInterval}

) : null}

Сэмплы на {new Date(p.sampledAt).toLocaleString("ru-RU")} — только пробы, для которых записан замер в этом тике

) } if (snap.job === "uptime_speed") { const s = snap as SpeedScheduledRunSnapshot return (

Прогоны speed на {new Date(s.sampledAt).toLocaleString("ru-RU")} — по очереди для каждой включённой пробы

) } if (snap.job === "gre_bgp") { const g = snap as GreBgpSnapshotRunSnapshot return (
{g.skipped ? (

Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.

) : null} {g.fatalError ? ( Критическая ошибка: {g.fatalError} ) : null}

Запись в SQLite на{" "} {new Date(g.sampledAt).toLocaleString("ru-RU")} {" "}— строки GRE и BGP для движка оповещений.

Строк GRE
{g.greWritten}
Строк BGP
{g.bgpWritten}
{g.errors?.length ? (
{g.errors.map((e, i) => (

{e}

))}
) : null}
) } if (snap.job === "internet_path") { const p = snap as InternetPathRunSnapshot return (

Снимок internet-path на{" "} {new Date(p.sampledAt).toLocaleString("ru-RU")}

Home routers
{p.homes}
Сохранение snapshot
{p.snapshotSaved ? "ok" : "no"}
{p.fatalError ? ( Критическая ошибка: {p.fatalError} ) : null}
) } if (snap.job === "certificates_renew") { const c = snap as CertificatesRenewRunSnapshot return (
{c.skipped ? (

Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.

) : null}
Проверено
{c.checked}
Обновлено
{c.renewed}
Пропущено
{c.skippedTargets}
Ошибки
{c.errors.length}
{c.errors.length ? (
{c.errors.map((e, i) => (

{e}

))}
) : null}
) } if (snap.job === "backups") { const b = snap as BackupsRunSnapshot return (
Слот расписания
{b.due ? "да" : "нет"}
Создано
{b.created}
Ошибки
{b.failures}
Удалено старых
{b.pruned}
{b.errors?.length ? (
{b.errors.map((e, i) => (

{e}

))}
) : null}
) } if (snap.job === "alert_engine") { const a = snap as AlertEngineRunSnapshot return (

Снимок на{" "} {new Date(a.sampledAt).toLocaleString("ru-RU")} . Данные для правил берутся из SQLite после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).

Правил проверено
{a.rulesChecked}
Отправок (правила)
{a.standaloneFires}
Отправок (группы)
{a.groupFires}
Без Telegram
{a.skippedNoTelegram ? "да" : "нет"}
{a.errors?.length ? ( {a.errors.map((e, i) => (

{e}

))}
) : null} {a.ruleDiag && a.ruleDiag.length > 0 ? (

По правилам (почему не ушло в Telegram)

) : null}
) } return null } function RunRowDetail({ r }: { r: SchedulerRunRowDto }) { const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—" const snapshot = useMemo(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson]) return (
ID записи
{r.id}
Ключ задачи
{r.jobKey}
Название и назначение
{jobTitle}

{jobDesc}

Старт
{new Date(r.startedAt).toLocaleString("ru-RU")}
Завершение
{new Date(r.finishedAt).toLocaleString("ru-RU")}
Длительность
{r.durationMs} мс ({fmtMs(r.durationMs)})
Результат
{r.status}
{r.error ? (

Текст ошибки

            {r.error}
          
) : (

Ошибка не записывалась (статус ok).

)}

Результаты измерений

{snapshot ? ( ) : (

Снимок отсутствует (запись до обновления бекенда или задача без детализации).

)}
) } type SchedulerToggleOverrides = { trafficEnabled?: boolean serversApiEnabled?: boolean resourcesEnabled?: boolean pingEnabled?: boolean speedEnabled?: boolean internetPathEnabled?: boolean certRenewEnabled?: boolean } function applySchedulerJobsToDrafts( jobs: SchedulerJobStatusDto[], setters: { setDraftTrafficEnabled: (value: boolean) => void setDraftServersApiEnabled: (value: boolean) => void setDraftResourcesEnabled: (value: boolean) => void setDraftPingEnabled: (value: boolean) => void setDraftSpeedEnabled: (value: boolean) => void setDraftInternetPathEnabled: (value: boolean) => void setDraftCertRenewEnabled: (value: boolean) => void }, ) { const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled) if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled) if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled) if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled) if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled) if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled) if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled) } export default function DataCollectionPage() { const { mode, backendUrl, prefsHydrated } = useDataSource() const isLive = prefsHydrated && mode === "live" const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [trafficCollector, setTrafficCollector] = useState(null) const [serversApiCollector, setServersApiCollector] = useState(null) const [uptimeCollector, setUptimeCollector] = useState(null) const [internetPathCollector, setInternetPathCollector] = useState(null) const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30") const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14") const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300") const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15") const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60") const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14") const [draftTrafficEnabled, setDraftTrafficEnabled] = useState(true) const [draftServersApiEnabled, setDraftServersApiEnabled] = useState(false) const [serversApiIntervalDraft, setServersApiIntervalDraft] = useState("120") const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true) const [draftPingEnabled, setDraftPingEnabled] = useState(true) const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true) const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true) const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300") const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true) const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600") const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30") const [schedulerRuns, setSchedulerRuns] = useState([]) const [runFilterJobKey, setRunFilterJobKey] = useState("") const [runNowJobKey, setRunNowJobKey] = useState(null) const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false) const [pageBusy, setPageBusy] = useState(false) const [collectorError, setCollectorError] = useState(null) const [openRunIds, setOpenRunIds] = useState>(() => new Set()) const toggleRunOpen = useCallback((id: string, open: boolean) => { setOpenRunIds((prev) => { const next = new Set(prev) if (open) next.add(id) else next.delete(id) return next }) }, []) const schedulerJobsByKey = useMemo(() => { const jobs = uptimeCollector?.scheduler?.jobs ?? [] return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record }, [uptimeCollector?.scheduler?.jobs]) const loadCollectors = useCallback(async () => { if (!isLive) return setCollectorError(null) setPageBusy(true) try { const runsQuery = runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number]) ? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}` : "?limit=80" const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([ apiFetch("/api/traffic/settings"), apiFetch("/api/servers-api-ping/settings"), apiFetch("/api/uptime/settings"), apiFetch("/api/internet-path/settings"), apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"), apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`), ]) const loadErrors = collectSettledErrors( [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes], ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"], ) if (loadErrors.length > 0) { setCollectorError(loadErrors.join("; ")) } const traffic = readSettled(trafficRes) if (traffic) { setTrafficCollector(traffic) setTrafficIntervalDraft(String(traffic.intervalSec)) setTrafficRetentionDraft(String(traffic.retentionDays)) setDraftTrafficEnabled(!!traffic.enabled) } const serversApi = readSettled(serversApiRes) if (serversApi) { setServersApiCollector(serversApi) setDraftServersApiEnabled(!!serversApi.enabled) setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120)) } const uptime = readSettled(uptimeRes) if (uptime) { setUptimeCollector(uptime) setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300)) setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15)) setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60)) setUptimeRetentionDraft(String(uptime.retentionDays)) setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled)) setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled)) setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled)) if (uptime.scheduler?.jobs?.length) { applySchedulerJobsToDrafts(uptime.scheduler.jobs, { setDraftTrafficEnabled, setDraftServersApiEnabled, setDraftResourcesEnabled, setDraftPingEnabled, setDraftSpeedEnabled, setDraftInternetPathEnabled, setDraftCertRenewEnabled, }) } } const internetPath = readSettled(internetPathRes) if (internetPath) { setInternetPathCollector(internetPath) setDraftInternetPathEnabled(!!internetPath.enabled) setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300)) } const certRenew = readSettled(certRenewRes) if (certRenew) { setDraftCertRenewEnabled(!!certRenew.enabled) setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600)) setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30)) } const runsPayload = readSettled(runsRes) if (runsPayload) { setSchedulerRuns(runsPayload.runs ?? []) } } catch (e) { setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные") } finally { setPageBusy(false) } }, [apiFetch, isLive, runFilterJobKey]) const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => { const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled const pingEnabled = overrides.pingEnabled ?? draftPingEnabled const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30) const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14) const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300) const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15) const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60) const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14) const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120) const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300) const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600) const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30)) const saveResults = await Promise.allSettled([ apiFetch("/api/traffic/settings", { method: "PUT", body: JSON.stringify({ enabled: trafficEnabled, intervalSec: tInt, retentionDays: tRet, }), }), apiFetch("/api/servers-api-ping/settings", { method: "PUT", body: JSON.stringify({ enabled: serversApiEnabled, intervalSec: sApiInt, }), }), apiFetch("/api/uptime/settings", { method: "PUT", body: JSON.stringify({ resourcesEnabled, pingEnabled, speedEnabled, intervalSec: uRes, probeIntervalSec: uPing, speedIntervalSec: uSpd, retentionDays: uRet, }), }), apiFetch("/api/internet-path/settings", { method: "PUT", body: JSON.stringify({ enabled: internetPathEnabled, intervalSec: ipInt, }), }), apiFetch("/api/certificates/renew-settings", { method: "PUT", body: JSON.stringify({ enabled: certRenewEnabled, intervalSec: certRenewInt, renewBeforeDays, }), }), ]) const saveErrors = collectSettledErrors( saveResults, ["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"], ) if (saveErrors.length > 0) { throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`) } }, [ apiFetch, certRenewIntervalDraft, draftCertRenewEnabled, draftInternetPathEnabled, draftPingEnabled, draftResourcesEnabled, draftServersApiEnabled, draftSpeedEnabled, draftTrafficEnabled, internetPathIntervalDraft, renewBeforeDaysDraft, serversApiIntervalDraft, trafficIntervalDraft, trafficRetentionDraft, uptimeIntervalDraft, uptimeResourceIntervalDraft, uptimeRetentionDraft, uptimeSpeedIntervalDraft, ]) const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => { const overrides: SchedulerToggleOverrides = {} if (jobKey === "traffic") { setDraftTrafficEnabled(nextEnabled) overrides.trafficEnabled = nextEnabled } else if (jobKey === "servers_rest_ping") { setDraftServersApiEnabled(nextEnabled) overrides.serversApiEnabled = nextEnabled } else if (jobKey === "uptime_resources") { setDraftResourcesEnabled(nextEnabled) overrides.resourcesEnabled = nextEnabled } else if (jobKey === "uptime_ping") { setDraftPingEnabled(nextEnabled) overrides.pingEnabled = nextEnabled } else if (jobKey === "uptime_speed") { setDraftSpeedEnabled(nextEnabled) overrides.speedEnabled = nextEnabled } else if (jobKey === "certificates_renew") { setDraftCertRenewEnabled(nextEnabled) overrides.certRenewEnabled = nextEnabled } else if (jobKey === "internet_path") { setDraftInternetPathEnabled(nextEnabled) overrides.internetPathEnabled = nextEnabled } else { return } setCollectorError(null) setSchedulerSaveBusy(true) try { await persistSchedulerDrafts(overrides) await loadCollectors() } catch (e) { setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить") } finally { setSchedulerSaveBusy(false) } }, [loadCollectors, persistSchedulerDrafts]) useEffect(() => { if (!isLive) { setTrafficCollector(null) setServersApiCollector(null) setUptimeCollector(null) setInternetPathCollector(null) setSchedulerRuns([]) return } void loadCollectors() }, [isLive, loadCollectors]) useEffect(() => { setOpenRunIds(new Set()) }, [runFilterJobKey]) const handleRefreshScheduler = async () => { if (!isLive) return setCollectorError(null) try { await apiFetch("/api/scheduler/refresh", { method: "POST" }) await loadCollectors() } catch (e) { setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик") } } const schedulerGridRows = useMemo(() => { return SCHEDULER_JOB_KEYS.map((jobKey) => { const j = schedulerJobsByKey[jobKey] const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" const enabled = fixedSchedule ? Boolean(j?.enabled ?? true) : jobKey === "traffic" ? draftTrafficEnabled : jobKey === "servers_rest_ping" ? draftServersApiEnabled : jobKey === "uptime_resources" ? draftResourcesEnabled : jobKey === "uptime_ping" ? draftPingEnabled : jobKey === "uptime_speed" ? draftSpeedEnabled : jobKey === "certificates_renew" ? draftCertRenewEnabled : draftInternetPathEnabled const intervalValue = fixedSchedule ? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) : jobKey === "traffic" ? trafficIntervalDraft : jobKey === "servers_rest_ping" ? serversApiIntervalDraft : jobKey === "uptime_resources" ? uptimeResourceIntervalDraft : jobKey === "uptime_ping" ? uptimeIntervalDraft : jobKey === "uptime_speed" ? uptimeSpeedIntervalDraft : jobKey === "certificates_renew" ? certRenewIntervalDraft : internetPathIntervalDraft const onIntervalChange = fixedSchedule ? () => {} : jobKey === "traffic" ? setTrafficIntervalDraft : jobKey === "servers_rest_ping" ? setServersApiIntervalDraft : jobKey === "uptime_resources" ? setUptimeResourceIntervalDraft : jobKey === "uptime_ping" ? setUptimeIntervalDraft : jobKey === "uptime_speed" ? setUptimeSpeedIntervalDraft : jobKey === "certificates_renew" ? setCertRenewIntervalDraft : setInternetPathIntervalDraft const defaultInterval = fixedSchedule ? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20)) : jobKey === "traffic" ? 30 : jobKey === "servers_rest_ping" ? 120 : jobKey === "uptime_resources" ? 300 : jobKey === "uptime_ping" ? 15 : jobKey === "uptime_speed" ? 60 : jobKey === "certificates_renew" ? 21600 : 300 return { id: jobKey, jobKey, label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey, description: SCHEDULER_JOB_DESCRIPTIONS[jobKey], fixedSchedule, enabled, intervalValue, intervalReadOnly: fixedSchedule, intervalDisabled: !enabled && !fixedSchedule, defaultInterval, job: j, onEnabledChange: fixedSchedule ? undefined : (nextEnabled) => { void handleJobEnabledChange(jobKey, nextEnabled) }, onIntervalChange, 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) } }, runNowLoading: runNowJobKey === jobKey, saveBusy: schedulerSaveBusy, } }) }, [ apiFetch, certRenewIntervalDraft, draftCertRenewEnabled, draftInternetPathEnabled, draftPingEnabled, draftResourcesEnabled, draftServersApiEnabled, draftSpeedEnabled, draftTrafficEnabled, handleJobEnabledChange, internetPathIntervalDraft, loadCollectors, runNowJobKey, schedulerJobsByKey, schedulerSaveBusy, serversApiIntervalDraft, trafficIntervalDraft, uptimeIntervalDraft, uptimeResourceIntervalDraft, uptimeSpeedIntervalDraft, ]) const enabledJobsCount = useMemo(() => { const jobs = uptimeCollector?.scheduler?.jobs if (jobs?.length) return jobs.filter((job) => job.enabled).length let n = draftTrafficEnabled ? 1 : 0 if (draftServersApiEnabled) n += 1 if (draftResourcesEnabled) n += 1 if (draftPingEnabled) n += 1 if (draftSpeedEnabled) n += 1 if (draftInternetPathEnabled) n += 1 if (draftCertRenewEnabled) n += 1 return n }, [ draftCertRenewEnabled, draftInternetPathEnabled, draftPingEnabled, draftResourcesEnabled, draftServersApiEnabled, draftSpeedEnabled, draftTrafficEnabled, uptimeCollector?.scheduler?.jobs, ]) const schedulerJobCount = SCHEDULER_JOB_KEYS.length const runningJobsCount = useMemo( () => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length, [uptimeCollector?.scheduler?.jobs], ) const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns]) const stats = useMemo( () => [ { label: "Включено задач", value: `${enabledJobsCount} / ${schedulerJobCount}`, sub: uptimeCollector?.scheduler?.jobs?.length ? "По сохранённым задачам планировщика" : "По переключателям на этой странице", icon: , }, { label: "Сейчас выполняется", value: String(runningJobsCount), sub: "Фоновые прогоны планировщика", icon: , }, { label: "Трафик — последний сбор", value: trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" }) : "—", sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных", icon: trafficCollector?.lastError ? ( ) : ( ), }, { label: "Журнал (в списке)", value: String(schedulerRuns.length), sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет", icon: , }, ], [ enabledJobsCount, schedulerJobCount, runningJobsCount, trafficCollector?.lastCollectedAt, trafficCollector?.lastDurationMs, trafficCollector?.lastError, schedulerRuns.length, errorRunsInView, ], ) return (
Настройки
} />
{!prefsHydrated && (
)} {prefsHydrated && !isLive && ( Открыть настройки )} {isLive && collectorError && ( Ошибка загрузки статуса коллекторов {collectorError} )} {isLive && ( <>
{stats.map((s) => (

{s.label}

{s.value}

{s.sub}

))}

Планировщик сбора данных

Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже — интервалы и срок хранения.

Хранение сэмплов трафика (дней)

setTrafficRetentionDraft(e.target.value)} className="h-8 text-sm" inputMode="numeric" />

Хранение сэмплов uptime (дней)

setUptimeRetentionDraft(e.target.value)} className="h-8 text-sm" inputMode="numeric" />

Обновлять сертификаты за (дней)

setRenewBeforeDaysDraft(e.target.value)} className="h-8 text-sm" inputMode="numeric" />

Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).

Трафик — последний сбор:{" "} {trafficCollector?.lastCollectedAt ? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}{" "} · {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"} {trafficCollector?.collectorRunning ? ( коллектор занят ) : null}

{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : "Трафик: ошибок нет"}

REST API (серверы):{" "} {serversApiCollector?.lastCollectedAt ? new Date(serversApiCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}{" "} · {serversApiCollector?.lastDurationMs != null ? `${serversApiCollector.lastDurationMs} мс` : "—"} {serversApiCollector?.collectorRunning ? ( проверка занята ) : null}

{serversApiCollector?.lastError ? `REST API (серверы): ${serversApiCollector.lastError}` : "REST API (серверы): ошибок нет"}

Uptime (агрегат в настройках):{" "} {uptimeCollector?.lastCollectedAt ? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}{" "} · {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}

{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : "Uptime: ошибок нет"}

Internet Path snapshot:{" "} {internetPathCollector?.lastCollectedAt ? new Date(internetPathCollector.lastCollectedAt).toLocaleString("ru-RU") : "—"}{" "} · {internetPathCollector?.lastDurationMs != null ? `${internetPathCollector.lastDurationMs} мс` : "—"}

{internetPathCollector?.lastError ? `Internet Path: ${internetPathCollector.lastError}` : "Internet Path: ошибок нет"}

} contentClassName="px-0 pb-0" > {schedulerRuns.length === 0 ? (

Пока нет прогонов

) : (
{schedulerRuns.map((r) => ( toggleRunOpen(r.id, open)} >
{SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey} {r.jobKey}
{new Date(r.finishedAt).toLocaleString("ru-RU")}
{r.durationMs} мс {r.status}
))}
)}
)}
) }