Files
MikrotikManager/app/(main)/data-collection/page.tsx
T

1215 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { 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 === "traffic") {
const t = snap as TrafficRunSnapshot
return (
<div className="flex flex-col gap-3">
{t.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор трафика ещё выполнялся.</p>
) : null}
{t.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {t.fatalError}</AlertDescription>
</Alert>
) : null}
<p className="text-xs text-muted-foreground">
Сэмплы на момент <span className="font-mono tabular-nums">{new Date(t.sampledAt).toLocaleString("ru-RU")}</span>
</p>
<DataPageCard>
<TrafficSnapshotGrid servers={t.servers} />
</DataPageCard>
</div>
)
}
if (snap.job === "uptime_resources") {
const u = snap as ResourcesRunSnapshot
return (
<div className="flex flex-col gap-3">
{u.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ресурсов уже выполняется.</p>
) : null}
{u.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {u.fatalError}</AlertDescription>
</Alert>
) : null}
<p className="text-xs text-muted-foreground">
Сэмплы на <span className="font-mono tabular-nums">{new Date(u.sampledAt).toLocaleString("ru-RU")}</span>
</p>
<DataPageCard>
<ResourcesSnapshotGrid servers={u.servers} />
</DataPageCard>
</div>
)
}
if (snap.job === "servers_rest_ping") {
const s = snap as ServersRestPingRunSnapshot
return (
<div className="flex flex-col gap-3">
{s.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка API ещё выполнялась.</p>
) : null}
{s.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {s.fatalError}</AlertDescription>
</Alert>
) : null}
<p className="text-xs text-muted-foreground">
GET <span className="font-mono">/system/identity</span> на{" "}
<span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span>
</p>
<DataPageCard>
<ServersRestPingSnapshotGrid servers={s.servers} />
</DataPageCard>
</div>
)
}
if (snap.job === "uptime_ping") {
const p = snap as PingRunSnapshot
return (
<div className="flex flex-col gap-3">
{p.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: сбор ping уже выполняется.</p>
) : null}
{p.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {p.fatalError}</AlertDescription>
</Alert>
) : null}
{p.skippedByInterval != null && p.skippedByInterval > 0 ? (
<p className="text-xs text-muted-foreground">Пропущено по интервалу проб: {p.skippedByInterval}</p>
) : null}
<p className="text-xs text-muted-foreground">
Сэмплы на <span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span> — только пробы, для которых записан замер в этом тике
</p>
<DataPageCard>
<PingSnapshotGrid probes={p.probes} />
</DataPageCard>
</div>
)
}
if (snap.job === "uptime_speed") {
const s = snap as SpeedScheduledRunSnapshot
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
</p>
<DataPageCard>
<SpeedSnapshotGrid runs={s.runs} />
</DataPageCard>
</div>
)
}
if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot
return (
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null}
{g.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {g.fatalError}</AlertDescription>
</Alert>
) : null}
<p className="text-xs text-muted-foreground">
Запись в PostgreSQL на{" "}
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
{" "}— строки GRE и BGP для движка оповещений.
</p>
<dl className="grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">Строк GRE</dt>
<dd className="font-mono font-medium">{g.greWritten}</dd>
</div>
<div>
<dt className="text-muted-foreground">Строк BGP</dt>
<dd className="font-mono font-medium">{g.bgpWritten}</dd>
</div>
</dl>
{g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
</p>
<dl className="grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">Home routers</dt>
<dd className="font-mono font-medium">{p.homes}</dd>
</div>
<div>
<dt className="text-muted-foreground">Сохранение snapshot</dt>
<dd className="font-mono font-medium">{p.snapshotSaved ? "ok" : "no"}</dd>
</div>
</dl>
{p.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {p.fatalError}</AlertDescription>
</Alert>
) : null}
</div>
)
}
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="flex flex-col gap-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Проверено</dt>
<dd className="font-mono font-medium">{c.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Обновлено</dt>
<dd className="font-mono font-medium">{c.renewed}</dd>
</div>
<div>
<dt className="text-muted-foreground">Пропущено</dt>
<dd className="font-mono font-medium">{c.skippedTargets}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{c.errors.length}</dd>
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="flex flex-col gap-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
<dd className="font-mono font-medium">{b.due ? "да" : "нет"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Создано</dt>
<dd className="font-mono font-medium">{b.created}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{b.failures}</dd>
</div>
<div>
<dt className="text-muted-foreground">Удалено старых</dt>
<dd className="font-mono font-medium">{b.pruned}</dd>
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "geoip_update") {
const g = snap as GeoipUpdateRunSnapshot
return (
<div className="flex flex-col gap-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: обновление уже выполнялось или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Баз проверено</dt>
<dd className="font-mono font-medium">{g.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Скачано</dt>
<dd className="font-mono font-medium">{g.downloaded}</dd>
</div>
<div>
<dt className="text-muted-foreground">Без изменений</dt>
<dd className="font-mono font-medium">{g.skippedUnchanged}</dd>
</div>
<div>
<dt className="text-muted-foreground">Объём, МБ</dt>
<dd className="font-mono font-medium">{(g.bytes / 1024 / 1024).toFixed(1)}</dd>
</div>
</dl>
{g.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 flex flex-col gap-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
Снимок на{" "}
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
. Данные для правил берутся из PostgreSQL после джоб сбора (трафик, uptime, REST, GRE+BGP и т.д.).
</p>
<dl className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">Правил проверено</dt>
<dd className="font-mono font-medium">{a.rulesChecked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Отправок (правила)</dt>
<dd className="font-mono font-medium">{a.standaloneFires}</dd>
</div>
<div>
<dt className="text-muted-foreground">Отправок (группы)</dt>
<dd className="font-mono font-medium">{a.groupFires}</dd>
</div>
<div>
<dt className="text-muted-foreground">Без Telegram</dt>
<dd className="font-mono font-medium">{a.skippedNoTelegram ? "да" : "нет"}</dd>
</div>
</dl>
{a.errors?.length ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="flex flex-col gap-1 text-xs">
{a.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
</p>
))}
</AlertDescription>
</Alert>
) : null}
{a.ruleDiag && a.ruleDiag.length > 0 ? (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 flex flex-col gap-2">
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
<DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
</DataPageCard>
</div>
) : null}
</div>
)
}
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 (
<div className="flex flex-col gap-4 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">ID записи</dt>
<dd className="font-mono text-xs break-all bg-muted/60 rounded-md px-2 py-1.5 border border-border">{r.id}</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Ключ задачи</dt>
<dd className="font-mono text-xs">{r.jobKey}</dd>
</div>
<div className="sm:col-span-2 flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Название и назначение</dt>
<dd>
<span className="font-medium">{jobTitle}</span>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{jobDesc}</p>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Старт</dt>
<dd className="tabular-nums text-xs">{new Date(r.startedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Завершение</dt>
<dd className="tabular-nums text-xs">{new Date(r.finishedAt).toLocaleString("ru-RU")}</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Длительность</dt>
<dd className="tabular-nums">
<span className="font-mono">{r.durationMs}</span> мс
<span className="text-muted-foreground text-xs ml-2">({fmtMs(r.durationMs)})</span>
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результат</dt>
<dd>
<Badge
variant="outline"
className={cn(
"text-[10px]",
r.status === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
r.status === "error" && "border-destructive/50 text-destructive",
)}
>
{r.status}
</Badge>
</dd>
</div>
</dl>
{r.error ? (
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-destructive">Текст ошибки</p>
<pre
className="text-xs font-mono whitespace-pre-wrap break-words rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 max-h-48 overflow-y-auto"
>
{r.error}
</pre>
</div>
) : (
<p className="text-xs text-muted-foreground">Ошибка не записывалась (статус ok).</p>
)}
<Separator />
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
{snapshot ? (
<SnapshotTables snap={snapshot} />
) : hasSnapshot ? (
<Alert variant="warning">
<AlertCircleIcon />
<AlertTitle>Снимок недоступен</AlertTitle>
<AlertDescription>Сохранённые результаты имеют неподдерживаемый формат или относятся к другой задаче.</AlertDescription>
</Alert>
) : (
<p className="text-xs text-muted-foreground">
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
</p>
)}
</div>
</div>
)
}
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<SettingsKey, SettingsRead> = Object.fromEntries(Object.keys(SETTINGS_LABELS).map(key => [key, { known: false, error: null, receivedAt: null }])) as Record<SettingsKey, SettingsRead>
export default function DataCollectionPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
if (!prefsHydrated) return <p role="status" className="p-6 text-sm text-muted-foreground">Загрузка настроек источника…</p>
return <DataCollectionContent key={JSON.stringify([mode, backendUrl])} />
}
function DataCollectionContent() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const [trafficCollector, setTrafficCollector] = useState<Awaited<ReturnType<typeof getTrafficCollectorSettings>> | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<Awaited<ReturnType<typeof getServersApiPingSettings>> | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<Awaited<ReturnType<typeof getUptimeCollectorSettings>> | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<Awaited<ReturnType<typeof getInternetPathSettings>> | null>(null)
const [settingsReads, setSettingsReads] = useState(INITIAL_SETTINGS)
const dirtySettings = useRef(new Set<SettingsKey>())
const dirtyFields = useRef<Record<SettingsKey, Set<string>>>({ traffic: new Set(), rest: new Set(), uptime: new Set(), internet: new Set(), cert: new Set() })
const [dirtyCount, setDirtyCount] = useState(0)
const [saveMessage, setSaveMessage] = useState<string | null>(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<SchedulerRunRowDto[]>([])
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatusDto | null>(null)
const [schedulerStatusError, setSchedulerStatusError] = useState<string | null>(null)
const [schedulerStatusReceivedAt, setSchedulerStatusReceivedAt] = useState<string | null>(null)
const [runsKnown, setRunsKnown] = useState(false)
const [runsError, setRunsError] = useState<string | null>(null)
const [runsReceivedAt, setRunsReceivedAt] = useState<string | null>(null)
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
const [pageBusy, setPageBusy] = useState(false)
const [collectorError, setCollectorError] = useState<string | null>(null)
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => 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<string, SchedulerJobStatusDto>
}, [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<T>(key: SettingsKey, request: Promise<T>, 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<string, number | boolean>) => {
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<string, unknown>)[field] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
if (!reader.current.active) return
if (key === "traffic") setTrafficCollector(data as Awaited<ReturnType<typeof getTrafficCollectorSettings>>)
if (key === "rest") setServersApiCollector(data as Awaited<ReturnType<typeof getServersApiPingSettings>>)
if (key === "uptime") setUptimeCollector(data as Awaited<ReturnType<typeof getUptimeCollectorSettings>>)
if (key === "internet") setInternetPathCollector(data as Awaited<ReturnType<typeof getInternetPathSettings>>)
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<SettingsKey, Record<string, string>> = {
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<string, number> }[] = []
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<string, number> = {}
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 === "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: <CalendarClockIcon className="size-4" />,
},
{
label: "Сейчас выполняется",
value: schedulerStatus ? String(runningJobsCount) : "—",
sub: schedulerStatusError ? "Статус требует обновления" : "Фоновые прогоны планировщика",
icon: <LoaderCircleIcon className="size-4" />,
},
{
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 ? (
<XCircleIcon className="size-4" />
) : trafficCollector?.lastCollectedAt ? (
<CheckCircleIcon className="size-4" />
) : (<CalendarClockIcon className="size-4" />),
},
{
label: "Журнал (в списке)",
value: runsKnown ? String(schedulerRuns.length) : "—",
sub: runsError ? (runsKnown ? "Показана последняя подтверждённая история" : "История не получена") : runsKnown ? (errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет") : "Ожидание истории",
icon: <DatabaseIcon className="size-4" />,
},
]
return (
<div className="flex flex-col h-full">
<PageHeader
crumbs={[{ label: "Система" }, { label: "Сбор данных" }]}
actions={
<div className="flex flex-wrap items-center gap-2">
<Link href="/settings" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 inline-flex items-center")}>
Настройки
</Link>
<Button
variant="outline"
size="sm"
disabled={!isLive || pageBusy || schedulerSaveBusy}
onClick={() => { void loadCollectors() }}
>
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
Обновить
</Button>
<Button variant="outline" size="sm" disabled={!isLive || pageBusy || schedulerSaveBusy || !can("mm:settings:admin")} onClick={() => { void handleRefreshScheduler() }}>
Перечитать таймеры
</Button>
</div>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5 max-w-[1100px] mx-auto w-full">
{!prefsHydrated && (
<OpsPanel
title="Загрузка настроек подключения"
description="Читаем режим данных и адрес API из локальных настроек."
>
<div />
</OpsPanel>
)}
{prefsHydrated && !isLive && (
<OpsPanel
title="Нужен live-режим"
description="Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках."
contentClassName="px-5 pb-5"
>
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
Открыть настройки
</Link>
</OpsPanel>
)}
{isLive && schedulerStatusError ? (
<Alert variant="warning">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{schedulerStatus ? "Статус планировщика не обновлён" : "Статус планировщика не получен"}</AlertTitle>
<AlertDescription>{schedulerStatusError}</AlertDescription>
</Alert>
) : null}
{isLive && schedulerStatusReceivedAt ? <p className="text-xs text-muted-foreground">Статус получен: {new Date(schedulerStatusReceivedAt).toLocaleString("ru-RU")}</p> : null}
{isLive && Object.entries(settingsReads).map(([key, value]) => (
<div key={key} className="text-xs text-muted-foreground" data-testid={"settings-" + key}>
<p role="status">{SETTINGS_LABELS[key as SettingsKey]} — {value.known ? "настройки получены" : "настройки не получены"}{value.receivedAt ? " · Получено: " + new Date(value.receivedAt).toLocaleString("ru-RU") : ""}</p>
{value.error ? <Alert variant="warning" className="mt-1"><AlertCircleIcon aria-hidden="true" /><AlertDescription>{value.error}{value.known ? ". Сохранены последние подтверждённые данные." : ""}</AlertDescription></Alert> : null}
</div>
))}
{isLive && saveMessage ? <p role="status" className="text-sm text-muted-foreground">{saveMessage}</p> : null}
{isLive && collectorError && (
<Alert variant="destructive">
<AlertCircleIcon />
<AlertTitle>Операция не завершена</AlertTitle>
<AlertDescription>{collectorError}</AlertDescription>
</Alert>
)}
{isLive && (
<>
<KpiStatGrid
aria-label="Сводка сбора данных"
items={stats.map((s, i) => ({
id: `dc-${i}`,
label: <span className="whitespace-normal">{s.label}</span>,
value: s.value,
hint: <span className="text-xs text-muted-foreground whitespace-normal">{s.sub}</span>,
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,
}))}
/>
<DataPageCard>
<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} />
<Separator />
<div className="flex flex-col gap-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
<Input
value={settingsReads.traffic.known ? trafficRetentionDraft : ""}
aria-label="Хранение трафика"
disabled={!writable("traffic") || schedulerSaveBusy}
onChange={(e) => { markDirty("traffic", "retentionDays"); setTrafficRetentionDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
<Input
value={settingsReads.uptime.known ? uptimeRetentionDraft : ""}
aria-label="Хранение uptime"
disabled={!writable("uptime") || schedulerSaveBusy}
onChange={(e) => { markDirty("uptime", "retentionDays"); setUptimeRetentionDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input
value={settingsReads.cert.known ? renewBeforeDaysDraft : ""}
aria-label="Обновлять сертификаты за дней"
onChange={(e) => { markDirty("cert", "renewBeforeDays"); setRenewBeforeDaysDraft(e.target.value) }}
className="h-8 text-sm"
inputMode="numeric"
disabled={!writable("cert") || schedulerSaveBusy}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM — также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
</p>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
</p>
<Button
size="sm"
aria-label="Сохранить настройки планировщика"
disabled={schedulerSaveBusy || dirtyCount === 0}
onClick={() => { void persistSchedulerDrafts() }}
>
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
Сохранить изменения
</Button>
<div className="text-xs text-muted-foreground flex flex-col gap-0.5 pt-1 border-t border-border/60">
<p>
Трафик — последний сбор:{" "}
{trafficCollector?.lastCollectedAt
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "—"}
{trafficCollector?.collectorRunning ? (
<Badge variant="secondary" className="ml-2 text-[10px]">
коллектор занят
</Badge>
) : null}
</p>
<p className={cn(trafficCollector?.lastError ? "text-destructive" : "")}>
{trafficCollector?.lastError ? `Трафик: ${trafficCollector.lastError}` : (settingsReads.traffic.known ? "Трафик: ошибки не записаны" : "Трафик: состояние неизвестно")}
</p>
<p>
REST API (серверы):{" "}
{serversApiCollector?.lastCollectedAt
? new Date(serversApiCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {serversApiCollector?.lastDurationMs != null ? `${serversApiCollector.lastDurationMs} мс` : "—"}
{serversApiCollector?.collectorRunning ? (
<Badge variant="secondary" className="ml-2 text-[10px]">
проверка занята
</Badge>
) : null}
</p>
<p className={cn(serversApiCollector?.lastError ? "text-destructive" : "")}>
{serversApiCollector?.lastError
? `REST API (серверы): ${serversApiCollector.lastError}`
: (settingsReads.rest.known ? "REST API (серверы): ошибки не записаны" : "REST API (серверы): состояние неизвестно")}
</p>
<p>
Uptime (агрегат в настройках):{" "}
{uptimeCollector?.lastCollectedAt
? new Date(uptimeCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {uptimeCollector?.lastDurationMs != null ? `${uptimeCollector.lastDurationMs} мс` : "—"}
</p>
<p className={cn(uptimeCollector?.lastError ? "text-destructive" : "")}>
{uptimeCollector?.lastError ? `Uptime: ${uptimeCollector.lastError}` : (settingsReads.uptime.known ? "Uptime: ошибки не записаны" : "Uptime: состояние неизвестно")}
</p>
<p>
Internet Path snapshot:{" "}
{internetPathCollector?.lastCollectedAt
? new Date(internetPathCollector.lastCollectedAt).toLocaleString("ru-RU")
: "—"}{" "}
· {internetPathCollector?.lastDurationMs != null ? `${internetPathCollector.lastDurationMs} мс` : "—"}
</p>
<p className={cn(internetPathCollector?.lastError ? "text-destructive" : "")}>
{internetPathCollector?.lastError
? `Internet Path: ${internetPathCollector.lastError}`
: (settingsReads.internet.known ? "Internet Path: ошибки не записаны" : "Internet Path: состояние неизвестно")}
</p>
</div>
</div>
</DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="Последние 80 запусков. Раскройте запись для результатов и объяснения ошибок."
headerRight={
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
Задача
</label>
<select
id="run-filter"
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
value={runFilterJobKey}
disabled={schedulerSaveBusy}
onChange={(e) => { setRunFilterJobKey(e.target.value); setOpenRunIds(new Set()); setSchedulerRuns([]); setRunsKnown(false); setRunsError(null); setRunsReceivedAt(null) }}
>
<option value="">Все</option>
{SCHEDULER_JOB_KEYS.map((k) => (
<option key={k} value={k}>
{SCHEDULER_JOB_LABELS[k]}
</option>
))}
</select>
</div>
}
contentClassName="px-0 pb-0"
>
{runsError ? (
<div className="mx-4 mb-3">
<Alert variant="warning">
<AlertCircleIcon aria-hidden="true" />
<AlertTitle>{runsKnown ? "История не обновлена" : "История не получена"}</AlertTitle>
<AlertDescription>{runsError}</AlertDescription>
</Alert>
</div>
) : null}
{runsReceivedAt ? <p className="px-4 pb-3 text-xs text-muted-foreground">История получена: {new Date(runsReceivedAt).toLocaleString("ru-RU")}</p> : null}
{!runsKnown ? (
!runsError ? <p role="status" className="px-4 py-10 text-center text-sm text-muted-foreground">Загрузка журнала…</p> : null
) : schedulerRuns.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10 px-4">Пока нет прогонов</p>
) : (
<div className="divide-y divide-border">
{schedulerRuns.map((r) => (
<Collapsible
key={r.id}
open={openRunIds.has(r.id)}
onOpenChange={(open) => toggleRunOpen(r.id, open)}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-3 px-5 py-3 text-left text-sm transition-colors",
"hover:bg-muted/50 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
)}
>
<ChevronDownIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform duration-200",
openRunIds.has(r.id) && "rotate-180",
)}
/>
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_auto] gap-2 sm:gap-4 items-center">
<div className="min-w-0">
<span className="font-medium">{SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey}</span>
<span className="text-xs text-muted-foreground font-mono ml-2">{r.jobKey}</span>
</div>
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap sm:text-right">
{new Date(r.finishedAt).toLocaleString("ru-RU")}
</span>
<div className="flex items-center gap-2 sm:justify-end">
<span className="text-xs tabular-nums text-muted-foreground">{r.durationMs} мс</span>
<Badge
variant="outline"
className={cn(
"text-[10px] shrink-0",
r.status === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
r.status === "error" && "border-destructive/50 text-destructive",
)}
>
{r.status}
</Badge>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="px-5 pb-4 pt-0">
<div className="rounded-lg border border-border bg-muted/20 px-4 py-4">
<RunRowDetail r={r} />
</div>
</div>
</CollapsibleContent>
</Collapsible>
))}
</div>
)}
</OpsPanel>
</>
)}
</div>
</div>
</div>
)
}