"use client"
import Link from "next/link"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
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 SchedulerJobStatusDto,
type SchedulerStatusDto,
type SchedulerRunRowDto,
} from "@/lib/scheduler-settings"
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, runSchedulerJobNow, refreshScheduler } from "@/shared/api/scheduler"
import { ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRunSnapshot,
type GreBgpSnapshotRunSnapshot,
type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type GeoipUpdateRunSnapshot,
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 { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { LogsCollectorSettings } from "@/components/logs/logs-collector-settings"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
CalendarClockIcon,
CheckCircleIcon,
ChevronDownIcon,
DatabaseIcon,
LoaderCircleIcon,
RefreshCwIcon,
XCircleIcon,
} from "lucide-react"
function extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error && reason.name === "ZodError") return "Настройки не подтверждены: получен некорректный ответ сервера"
if (reason instanceof Error) return reason.message
return String(reason)
}
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 === "router_logs") return
{snap.skipped &&
Сбор логов выключен или приостановлен.
}{snap.servers.map(server =>
{server.name}: +{server.added} событий{server.gap ? " · Возможен разрыв истории" : ""}{server.error ? " · " + server.error : ""}
)}
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}
Запись в PostgreSQL на{" "}
{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 === "geoip_update") {
const g = snap as GeoipUpdateRunSnapshot
return (
{g.skipped ? (
Прогон пропущен: обновление уже выполнялось или задача отключена.
) : null}
Баз проверено
{g.checked}
Скачано
{g.downloaded}
Без изменений
{g.skippedUnchanged}
Объём, МБ
{(g.bytes / 1024 / 1024).toFixed(1)}
{g.errors.length ? (
{g.errors.map((e, i) => (
{e}
))}
) : null}
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
Снимок на{" "}
{new Date(a.sampledAt).toLocaleString("ru-RU")}
. Данные для правил берутся из PostgreSQL после джоб сбора (трафик, 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(() => {
const parsed = parseSchedulerRunSnapshot(r.resultJson)
return parsed?.job === r.jobKey ? parsed : null
}, [r.resultJson, r.jobKey])
const hasSnapshot = r.resultJson != null && (typeof r.resultJson !== "string" || r.resultJson.trim() !== "")
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 ? (
) : (
Ошибка не записывалась (статус ok).
)}
Результаты измерений
{snapshot ? (
) : hasSnapshot ? (
Снимок недоступен
Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.
) : (
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
)}
)
}
const SETTINGS_LABELS = { traffic: "Трафик", rest: "Серверы REST API", uptime: "Uptime", internet: "Internet path", cert: "Сертификаты" } as const
const SETTINGS_PERMISSIONS = { traffic: "mm:traffic:write", rest: "mm:servers:write", uptime: "mm:uptime:write", internet: "mm:network:write", cert: "mm:certificates:write" } as const
type SettingsKey = keyof typeof SETTINGS_LABELS
type SettingsRead = { known: boolean; error: string | null; receivedAt: string | null }
const INITIAL_SETTINGS: Record = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record
export default function DataCollectionPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return Загрузка настроек источника…
return
}
function DataCollectionContent() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const [trafficCollector, setTrafficCollector] = useState> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState> | null>(null)
const [uptimeCollector, setUptimeCollector] = useState> | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState> | null>(null)
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
const dirtySettings = useRef(new Set())
const dirtyFields = useRef>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
const [dirtyCount, setDirtyCount] = useState(0)
const [saveMessage, setSaveMessage] = useState(null)
const mutationLock = useRef(false)
const writable = (key: SettingsKey) => isLive && can(SETTINGS_PERMISSIONS[key]) && settingsReads[key].known && !settingsReads[key].error
const markDirty = (key: SettingsKey, field: string) => { dirtySettings.current.add(key); dirtyFields.current[key].add(field); setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0)) }
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 [schedulerStatus, setSchedulerStatus] = useState(null)
const [schedulerStatusError, setSchedulerStatusError] = useState(null)
const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState(null)
const [runsKnown, setRunsKnown] = useState(false)
const [runsError, setRunsError] = useState(null)
const [runsReceivedAt, setRunsReceivedAt] = useState(null)
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 reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
useEffect(() => {
reader.current.active = true
const state = reader.current
return () => { state.active = false; state.revision++; state.controller?.abort() }
}, [])
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
})
}, [setOpenRunIds])
const schedulerJobsByKey = useMemo(() => {
const jobs = schedulerStatus?.jobs ?? []
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record
}, [schedulerStatus?.jobs])
const loadCollectors = useCallback(async () => {
if (!isLive || !reader.current.active || mutationLock.current) return
const revision = ++reader.current.revision
reader.current.controller?.abort()
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
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
const runsRequest = listSchedulerRuns(backendUrl, { limit: 80, jobKey: runsFilter }, { signal }).then(data => {
if (current()) { setSchedulerRuns(data.runs); setRunsKnown(true); setRunsError(null); setRunsReceivedAt(new Date().toISOString()) }
return data
}, error => { if (current()) setRunsError(extractApiError(error)); throw error })
const statusRequest = getSchedulerStatus(backendUrl, { signal }).then(data => {
if (current()) { setSchedulerStatus(data); setSchedulerStatusError(null); setSchedulerStatusReceivedAt(new Date().toISOString()) }
return data
}, error => { if (current()) setSchedulerStatusError(extractApiError(error)); throw error })
function publish(key: SettingsKey, request: Promise, accept: (data: T, updateDraft: boolean) => void) {
return request.then(data => {
if (current()) {
accept(data, !dirtySettings.current.has(key))
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}
}, error => {
if (current()) setSettingsReads(prev => ({ ...prev, [key]: { ...prev[key], error: extractApiError(error) } }))
})
}
await Promise.allSettled([
publish("traffic", getTrafficCollectorSettings(backendUrl, { signal }), (data, draft) => {
setTrafficCollector(data)
if (draft) { setTrafficIntervalDraft(String(data.intervalSec)); setTrafficRetentionDraft(String(data.retentionDays)); setDraftTrafficEnabled(data.enabled) }
}),
publish("rest", getServersApiPingSettings(backendUrl, { signal }), (data, draft) => {
setServersApiCollector(data)
if (draft) { setServersApiIntervalDraft(String(data.intervalSec)); setDraftServersApiEnabled(data.enabled) }
}),
publish("uptime", getUptimeCollectorSettings(backendUrl, { signal }), (data, draft) => {
setUptimeCollector(data)
if (draft) {
setUptimeResourceIntervalDraft(String(data.intervalSec)); setUptimeIntervalDraft(String(data.probeIntervalSec)); setUptimeSpeedIntervalDraft(String(data.speedIntervalSec)); setUptimeRetentionDraft(String(data.retentionDays))
setDraftResourcesEnabled(data.resourcesEnabled); setDraftPingEnabled(data.pingEnabled); setDraftSpeedEnabled(data.speedEnabled)
}
}),
publish("internet", getInternetPathSettings(backendUrl, { signal }), (data, draft) => {
setInternetPathCollector(data)
if (draft) { setInternetPathIntervalDraft(String(data.intervalSec)); setDraftInternetPathEnabled(data.enabled) }
}),
publish("cert", getCertificateRenewSettings(backendUrl, { signal }), (data, draft) => {
if (draft) { setCertRenewIntervalDraft(String(data.intervalSec)); setRenewBeforeDaysDraft(String(data.renewBeforeDays)); setDraftCertRenewEnabled(data.enabled) }
}),
runsRequest, statusRequest,
])
} catch (e) {
if (current()) setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
if (current()) setPageBusy(false)
}
}, [backendUrl, isLive, runFilterJobKey])
const writeSettings = useCallback(async (key: SettingsKey, patch: Record) => {
if (!isLive || !reader.current.active || !can(SETTINGS_PERMISSIONS[key]) || !settingsReads[key].known || settingsReads[key].error) throw new Error("Настройки не получены или недостаточно прав для записи")
const data = key === "traffic" ? await putTrafficCollectorSettings(backendUrl, patch)
: key === "rest" ? await putServersApiPingSettings(backendUrl, patch)
: key === "uptime" ? await putUptimeCollectorSettings(backendUrl, patch)
: key === "internet" ? await putInternetPathSettings(backendUrl, patch)
: await putCertificateRenewSettings(backendUrl, patch)
if (Object.entries(patch).some(([field, value]) => (data as Record)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
if (!reader.current.active) return
if (key === "traffic") setTrafficCollector(data as Awaited>)
if (key === "rest") setServersApiCollector(data as Awaited>)
if (key === "uptime") setUptimeCollector(data as Awaited>)
if (key === "internet") setInternetPathCollector(data as Awaited>)
setSettingsReads(prev => ({ ...prev, [key]: { known: true, error: null, receivedAt: new Date().toISOString() } }))
}, [backendUrl, isLive, settingsReads])
const persistSchedulerDrafts = async () => {
if (mutationLock.current || !reader.current.active) return
const drafts: Record> = {
traffic: { intervalSec: trafficIntervalDraft, retentionDays: trafficRetentionDraft }, rest: { intervalSec: serversApiIntervalDraft },
uptime: { intervalSec: uptimeResourceIntervalDraft, probeIntervalSec: uptimeIntervalDraft, speedIntervalSec: uptimeSpeedIntervalDraft, retentionDays: uptimeRetentionDraft },
internet: { intervalSec: internetPathIntervalDraft }, cert: { intervalSec: certRenewIntervalDraft, renewBeforeDays: renewBeforeDaysDraft },
}
const plan: { key: SettingsKey; patch: Record }[] = []
try {
for (const key of Object.keys(drafts) as SettingsKey[]) {
if (!dirtyFields.current[key].size) continue
if (!writable(key)) throw new Error("Настройки не получены или недостаточно прав: " + SETTINGS_LABELS[key])
const patch: Record = {}
for (const field of dirtyFields.current[key]) {
const raw = drafts[key][field]; const value = Number(raw)
const min = field === "retentionDays" || field === "renewBeforeDays" ? 1 : key === "cert" ? 300 : key === "internet" ? 30 : key === "rest" || field === "speedIntervalSec" ? 10 : 5
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < min || (field === "renewBeforeDays" && value > 90)) throw new Error(SETTINGS_LABELS[key] + ": укажите целое значение от " + min + (field === "renewBeforeDays" ? " до 90" : ""))
patch[field] = value
}
plan.push({ key, patch })
}
if (!plan.length) return
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
const successes: string[] = [], errors: string[] = []
for (const { key, patch } of plan) {
if (!reader.current.active) break
try {
await writeSettings(key, patch)
if (!reader.current.active) return
for (const field of Object.keys(patch)) dirtyFields.current[key].delete(field)
if (!dirtyFields.current[key].size) dirtySettings.current.delete(key)
successes.push(SETTINGS_LABELS[key])
} catch (error) { if (reader.current.active) errors.push(SETTINGS_LABELS[key] + ": " + extractApiError(error)) }
}
if (!reader.current.active) return
setDirtyCount(Object.values(dirtyFields.current).reduce((count, fields) => count + fields.size, 0))
if (successes.length) setSaveMessage("Сохранение подтверждено: " + successes.join(", "))
if (errors.length) setCollectorError(errors.join("; "))
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const targets = {
traffic: ["traffic", "enabled", setDraftTrafficEnabled], servers_rest_ping: ["rest", "enabled", setDraftServersApiEnabled],
uptime_resources: ["uptime", "resourcesEnabled", setDraftResourcesEnabled], uptime_ping: ["uptime", "pingEnabled", setDraftPingEnabled], uptime_speed: ["uptime", "speedEnabled", setDraftSpeedEnabled],
internet_path: ["internet", "enabled", setDraftInternetPathEnabled], certificates_renew: ["cert", "enabled", setDraftCertRenewEnabled],
} as const
if (!(jobKey in targets) || mutationLock.current || !reader.current.active) return
const [key, field, accept] = targets[jobKey as keyof typeof targets]
mutationLock.current = true; reader.current.revision++; reader.current.controller?.abort(); setPageBusy(false); setSchedulerSaveBusy(true); setCollectorError(null); setSaveMessage(null)
try {
await writeSettings(key, { [field]: nextEnabled })
if (!reader.current.active) return
accept(nextEnabled); setSaveMessage("Сохранение подтверждено: " + SETTINGS_LABELS[key])
} catch (error) { if (reader.current.active) setCollectorError(extractApiError(error)) }
finally { mutationLock.current = false; if (reader.current.active) setSchedulerSaveBusy(false) }
}, [writeSettings])
useEffect(() => {
if (!isLive) return
queueMicrotask(() => { void loadCollectors() })
}, [isLive, loadCollectors])
const handleRefreshScheduler = async () => {
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 {
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[] = (() => {
return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const settingsKey: SettingsKey = jobKey === "traffic" ? "traffic" : jobKey === "servers_rest_ping" ? "rest" : jobKey.startsWith("uptime_") ? "uptime" : jobKey === "certificates_renew" ? "cert" : "internet"
const fixedSchedule = jobKey === "router_logs" || jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups" || jobKey === "geoip_update"
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
const settingsKnown = fixedSchedule ? Boolean(j) : settingsReads[settingsKey].known
return {
id: jobKey,
settingsKnown,
jobKey,
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
fixedSchedule,
enabled,
intervalValue: settingsKnown ? intervalValue : "",
intervalReadOnly: fixedSchedule,
intervalDisabled: !settingsKnown || schedulerSaveBusy || (!fixedSchedule && !writable(settingsKey)),
defaultInterval,
job: j,
onEnabledChange: fixedSchedule
? undefined
: (nextEnabled) => {
void handleJobEnabledChange(jobKey, nextEnabled)
},
onIntervalChange: (value: string) => { markDirty(settingsKey, jobKey === "uptime_ping" ? "probeIntervalSec" : jobKey === "uptime_speed" ? "speedIntervalSec" : "intervalSec"); onIntervalChange(value) },
onRunNow: async () => { await handleRunNow(jobKey) },
runNowDisabled: schedulerSaveBusy || !can("mm:settings:admin") || !j || j.running,
runNowLoading: runNowJobKey === jobKey,
saveBusy: schedulerSaveBusy || !writable(settingsKey),
}
})
})()
const enabledJobsCount = schedulerStatus?.jobs.filter(job => job.enabled).length ?? 0
const schedulerJobCount = schedulerStatus?.jobs.length ?? 0
const runningJobsCount = schedulerStatus?.jobs.filter(job => job.running).length ?? 0
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
const stats = [
{
label: "Включено задач",
value: schedulerStatus ? `${enabledJobsCount} / ${schedulerJobCount}` : "—",
sub: schedulerStatusError ? (schedulerStatus ? "Последний подтверждённый статус" : "Статус не получен") : schedulerStatus ? "По сохранённым задачам планировщика" : "Ожидание статуса",
icon: ,
},
{
label: "Сейчас выполняется",
value: schedulerStatus ? String(runningJobsCount) : "—",
sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
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: settingsReads.traffic.error ? "Последний сбор требует обновления" : trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
icon: trafficCollector?.lastError ? (
) : trafficCollector?.lastCollectedAt ? (
) : ( ),
},
{
label: "Журнал (в списке)",
value: runsKnown ? String(schedulerRuns.length) : "—",
sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
icon: ,
},
]
return (
Настройки
{ void loadCollectors() }}
>
Обновить
{ void handleRefreshScheduler() }}>
Перечитать таймеры
}
/>
{!prefsHydrated && (
)}
{prefsHydrated && !isLive && (
Открыть настройки
)}
{isLive && schedulerStatusError ? (
{schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}
{schedulerStatusError}
) : null}
{isLive && schedulerStatusReceivedAt ?
Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}
: null}
{isLive && Object.entries(settingsReads).map(([key, value]) => (
{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " · Получено: " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}
{value.error ?
{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""} : null}
))}
{isLive && saveMessage ?
{saveMessage}
: null}
{isLive && collectorError && (
Операция не завершена
{collectorError}
)}
{isLive && can("mm:settings:admin") &&
setSchedulerStatus(previous => previous ? { ...previous, jobs: previous.jobs.map(job => job.jobKey === "router_logs" ? { ...job, enabled: settings.enabled, intervalSec: settings.intervalSec } : job) } : previous)} />}
{isLive && (
<>
({
id: `dc-${i}`,
label: {s.label} ,
value: s.value,
hint: {s.sub} ,
icon: s.icon,
iconClassName:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "text-destructive"
: s.label === "Сейчас выполняется" && runningJobsCount > 0
? "text-warning"
: s.label === "Журнал (в списке)" && errorRunsInView
? "text-destructive"
: s.label === "Трафик — последний сбор" && trafficCollector?.lastCollectedAt
? "text-success"
: "text-muted-foreground",
variant:
s.label === "Трафик — последний сбор" && trafficCollector?.lastError
? "destructive" as const
: s.label === "Журнал (в списке)" && errorRunsInView
? "warning" as const
: "default" as const,
}))}
/>
Планировщик сбора данных
Интервалы и вкл/выкл по задачам. Переключатель сохраняет только свой флаг; кнопка ниже — изменённые интервалы и срок хранения. Ручной запуск использует сохранённые настройки, без отправки черновика.
Обновлять сертификаты за (дней)
{ markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
disabled={!writable("cert") || schedulerSaveBusy}
/>
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
{ void persistSchedulerDrafts() }}
>
{schedulerSaveBusy ? : null}
Сохранить изменения
Трафик — последний сбор:{" "}
{trafficCollector?.lastCollectedAt
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}
{trafficCollector?.collectorRunning ? (
коллектор занят
) : null}
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
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}`
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
Uptime (агрегат в настройках):{" "}
{uptimeCollector?.lastCollectedAt
? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
Internet Path snapshot:{" "}
{internetPathCollector?.lastCollectedAt
? new Date(internetPathCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {internetPathCollector?.lastDurationMs != null ? `${internetPathCollector.lastDurationMs} мс` : "—"}
{internetPathCollector?.lastError
? `Internet Path: ${internetPathCollector.lastError}`
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
{isLive ? : null}
Задача
{ setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
>
Все
{SCHEDULER_JOB_KEYS.map((k) => (
{SCHEDULER_JOB_LABELS[k]}
))}
}
contentClassName="px-0 pb-0"
>
{runsError ? (
{runsKnown ? "История не обновлена" : "История не получена"}
{runsError}
) : null}
{runsReceivedAt ?
История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}
: null}
{!runsKnown ? (
!runsError ?
Загрузка журнала…
: null
) : 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}
))}
)}
>
)}
)
}