1215 lines
66 KiB
TypeScript
1215 lines
66 KiB
TypeScript
"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>
|
||
)
|
||
}
|