Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 2m11s
Docker images / updater-image (push) Successful in 50s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
1437 lines
71 KiB
TypeScript
1437 lines
71 KiB
TypeScript
"use client"
|
||
|
||
import Link from "next/link"
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button, buttonVariants } from "@/components/ui/button"
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from "@/components/ui/collapsible"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Separator } from "@/components/ui/separator"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import {
|
||
SCHEDULER_JOB_DESCRIPTIONS,
|
||
SCHEDULER_JOB_KEYS,
|
||
SCHEDULER_JOB_LABELS,
|
||
type CollectorSettingsDto,
|
||
type SchedulerJobStatusDto,
|
||
type SchedulerRunRowDto,
|
||
type UptimeSettingsDto,
|
||
} from "@/lib/scheduler-settings"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import {
|
||
parseSchedulerRunSnapshot,
|
||
type AlertEngineRuleDiagSnapshot,
|
||
type AlertEngineRunSnapshot,
|
||
type GreBgpSnapshotRunSnapshot,
|
||
type InternetPathRunSnapshot,
|
||
type CertificatesRenewRunSnapshot,
|
||
type BackupsRunSnapshot,
|
||
type PingRunSnapshot,
|
||
type ResourcesRunSnapshot,
|
||
type SchedulerRunSnapshot,
|
||
type ServersRestPingRunSnapshot,
|
||
type SpeedScheduledRunSnapshot,
|
||
type TrafficRunSnapshot,
|
||
} from "@/lib/scheduler-run-snapshot"
|
||
import { cn } from "@/lib/utils"
|
||
import {
|
||
AlertCircleIcon,
|
||
CalendarClockIcon,
|
||
CheckCircleIcon,
|
||
ChevronDownIcon,
|
||
DatabaseIcon,
|
||
LoaderCircleIcon,
|
||
RefreshCwIcon,
|
||
XCircleIcon,
|
||
} from "lucide-react"
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={checked}
|
||
onClick={() => onChange(!checked)}
|
||
className={cn(
|
||
"relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors cursor-pointer",
|
||
checked ? "bg-primary" : "bg-input",
|
||
)}
|
||
>
|
||
<span
|
||
className={cn(
|
||
"pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
|
||
checked ? "translate-x-4" : "translate-x-0",
|
||
)}
|
||
/>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
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 fmtUptimeSec(sec: number): string {
|
||
if (sec <= 0) return "—"
|
||
const d = Math.floor(sec / 86400)
|
||
const h = Math.floor((sec % 86400) / 3600)
|
||
const m = Math.floor((sec % 3600) / 60)
|
||
if (d > 0) return `${d}д ${h}ч`
|
||
if (h > 0) return `${h}ч ${m}м`
|
||
return `${m}м`
|
||
}
|
||
|
||
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
|
||
if (snap.job === "traffic") {
|
||
const t = snap as TrafficRunSnapshot
|
||
return (
|
||
<div className="space-y-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>
|
||
<div className="overflow-x-auto rounded-md border border-border">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||
<th className="px-3 py-2 font-medium">Хост</th>
|
||
<th className="px-3 py-2 font-medium">Результат</th>
|
||
<th className="px-3 py-2 font-medium text-right">IF</th>
|
||
<th className="px-3 py-2 font-medium text-right">Σ RX</th>
|
||
<th className="px-3 py-2 font-medium text-right">Σ TX</th>
|
||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{t.servers.map((s) => (
|
||
<tr key={s.serverId} className="hover:bg-muted/30">
|
||
<td className="px-3 py-2 font-medium">{s.name}</td>
|
||
<td className="px-3 py-2 font-mono text-muted-foreground">{s.host}</td>
|
||
<td className="px-3 py-2">
|
||
{s.ok ? (
|
||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||
ok
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||
ошибка
|
||
</Badge>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{s.interfaces ?? "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{s.sumRxMbps != null ? `${s.sumRxMbps} Мбит/с` : "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{s.sumTxMbps != null ? `${s.sumTxMbps} Мбит/с` : "—"}</td>
|
||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (snap.job === "uptime_resources") {
|
||
const u = snap as ResourcesRunSnapshot
|
||
return (
|
||
<div className="space-y-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>
|
||
<div className="overflow-x-auto rounded-md border border-border">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||
<th className="px-3 py-2 font-medium">Статус</th>
|
||
<th className="px-3 py-2 font-medium text-right">CPU %</th>
|
||
<th className="px-3 py-2 font-medium text-right">Память</th>
|
||
<th className="px-3 py-2 font-medium text-right">% RAM</th>
|
||
<th className="px-3 py-2 font-medium text-right">Диск своб.</th>
|
||
<th className="px-3 py-2 font-medium">Uptime</th>
|
||
<th className="px-3 py-2 font-medium">Плата / ROS</th>
|
||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{u.servers.map((s) => (
|
||
<tr key={s.serverId} className="hover:bg-muted/30">
|
||
<td className="px-3 py-2">
|
||
<span className="font-medium">{s.name}</span>
|
||
<span className="block font-mono text-[10px] text-muted-foreground">{s.host}</span>
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<Badge
|
||
variant="outline"
|
||
className={cn(
|
||
"text-[10px]",
|
||
s.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||
s.status === "offline" && "border-destructive/50 text-destructive",
|
||
)}
|
||
>
|
||
{s.status}
|
||
</Badge>
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{s.cpuLoadPct ?? "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
||
{s.memUsedMb != null && s.memTotalMb != null ? `${s.memUsedMb} / ${s.memTotalMb} МБ` : "—"}
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{s.memUsedPct != null ? `${s.memUsedPct}%` : "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
|
||
{s.diskFreeMb != null && s.diskTotalMb != null ? `${s.diskFreeMb} / ${s.diskTotalMb} МБ` : "—"}
|
||
</td>
|
||
<td className="px-3 py-2 tabular-nums">{s.uptimeSeconds != null ? fmtUptimeSec(s.uptimeSeconds) : "—"}</td>
|
||
<td className="px-3 py-2 max-w-[140px]">
|
||
<span className="block truncate" title={s.boardName}>{s.boardName || "—"}</span>
|
||
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={s.rosVersion}>{s.rosVersion || ""}</span>
|
||
</td>
|
||
<td className="px-3 py-2 text-destructive max-w-[160px] truncate" title={s.error}>{s.error ?? "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (snap.job === "servers_rest_ping") {
|
||
const s = snap as ServersRestPingRunSnapshot
|
||
return (
|
||
<div className="space-y-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>
|
||
<div className="overflow-x-auto rounded-md border border-border">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||
<th className="px-3 py-2 font-medium">Сервер</th>
|
||
<th className="px-3 py-2 font-medium">Хост</th>
|
||
<th className="px-3 py-2 font-medium">Результат</th>
|
||
<th className="px-3 py-2 font-medium text-right">RTT REST</th>
|
||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{s.servers.map((row) => (
|
||
<tr key={row.serverId} className="hover:bg-muted/30">
|
||
<td className="px-3 py-2 font-medium">{row.name}</td>
|
||
<td className="px-3 py-2 font-mono text-muted-foreground">{row.host}</td>
|
||
<td className="px-3 py-2">
|
||
{row.ok ? (
|
||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
|
||
ok
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
|
||
недоступен
|
||
</Badge>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">
|
||
{row.latencyMs != null ? `${row.latencyMs} мс` : "—"}
|
||
</td>
|
||
<td className="px-3 py-2 text-destructive max-w-[220px] truncate" title={row.error}>{row.error ?? "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (snap.job === "uptime_ping") {
|
||
const p = snap as PingRunSnapshot
|
||
return (
|
||
<div className="space-y-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>
|
||
<div className="overflow-x-auto rounded-md border border-border">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||
<th className="px-3 py-2 font-medium">Проба</th>
|
||
<th className="px-3 py-2 font-medium">Цель</th>
|
||
<th className="px-3 py-2 font-medium">Источник</th>
|
||
<th className="px-3 py-2 font-medium">IF</th>
|
||
<th className="px-3 py-2 font-medium text-right">RTT</th>
|
||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
||
<th className="px-3 py-2 font-medium">Статус</th>
|
||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{p.probes.map((x) => (
|
||
<tr key={`${x.probeId}-${x.target}`} className="hover:bg-muted/30">
|
||
<td className="px-3 py-2">
|
||
<span className="font-medium">{x.name}</span>
|
||
<span className="block font-mono text-[10px] text-muted-foreground">{x.probeId}</span>
|
||
</td>
|
||
<td className="px-3 py-2 font-mono">{x.target}</td>
|
||
<td className="px-3 py-2">{x.srcServerName}</td>
|
||
<td className="px-3 py-2 font-mono text-muted-foreground">{x.srcInterface || "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{x.rttMs != null ? `${x.rttMs} мс` : "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{x.lossPct}%</td>
|
||
<td className="px-3 py-2">
|
||
<Badge variant="outline" className="text-[10px]">{x.status}</Badge>
|
||
</td>
|
||
<td className="px-3 py-2 text-destructive max-w-[180px] truncate" title={x.error}>{x.error ?? "—"}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (snap.job === "uptime_speed") {
|
||
const s = snap as SpeedScheduledRunSnapshot
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
Прогоны speed на <span className="font-mono tabular-nums">{new Date(s.sampledAt).toLocaleString("ru-RU")}</span> — по очереди для каждой включённой пробы
|
||
</p>
|
||
<div className="overflow-x-auto rounded-md border border-border">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-border bg-muted/40 text-muted-foreground text-left">
|
||
<th className="px-3 py-2 font-medium">Проба</th>
|
||
<th className="px-3 py-2 font-medium">Маршрут</th>
|
||
<th className="px-3 py-2 font-medium">Интерфейсы</th>
|
||
<th className="px-3 py-2 font-medium">Протокол</th>
|
||
<th className="px-3 py-2 font-medium text-right">TX</th>
|
||
<th className="px-3 py-2 font-medium text-right">RX</th>
|
||
<th className="px-3 py-2 font-medium text-right">Ping RTT</th>
|
||
<th className="px-3 py-2 font-medium text-right">Loss</th>
|
||
<th className="px-3 py-2 font-medium">Результат</th>
|
||
<th className="px-3 py-2 font-medium">Ошибка</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{s.runs.map((x) => (
|
||
<tr key={x.probeId} className="hover:bg-muted/30">
|
||
<td className="px-3 py-2 font-mono">{x.probeId}</td>
|
||
<td className="px-3 py-2 whitespace-nowrap">
|
||
{x.srcServerName} <span className="text-muted-foreground">→</span> {x.dstServerName}
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-[10px]">
|
||
<span className="block">{x.srcInterface || "—"}</span>
|
||
<span className="block text-muted-foreground">{x.dstInterface || "—"}</span>
|
||
</td>
|
||
<td className="px-3 py-2">{x.protocol} / {x.direction} / {x.durationSec}s</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{x.txAvgMbps != null ? `${Number(x.txAvgMbps).toFixed(1)}` : "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{x.rxAvgMbps != null ? `${Number(x.rxAvgMbps).toFixed(1)}` : "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{x.pingRttMs != null ? `${x.pingRttMs} мс` : "—"}</td>
|
||
<td className="px-3 py-2 text-right tabular-nums">{x.pingLossPct != null ? `${x.pingLossPct}%` : "—"}</td>
|
||
<td className="px-3 py-2">
|
||
{x.ok ? (
|
||
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">ok</Badge>
|
||
) : (
|
||
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">ошибка</Badge>
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2 max-w-[200px]">
|
||
<span className="text-destructive block truncate" title={x.error}>{x.error ?? ""}</span>
|
||
{x.pingError ? (
|
||
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={x.pingError ?? ""}>ping: {x.pingError}</span>
|
||
) : null}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (snap.job === "gre_bgp") {
|
||
const g = snap as GreBgpSnapshotRunSnapshot
|
||
return (
|
||
<div className="space-y-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">
|
||
Запись в SQLite на{" "}
|
||
<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 space-y-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="space-y-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="space-y-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 space-y-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="space-y-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 space-y-1">
|
||
{b.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
|
||
const transitionRu = (t: AlertEngineRuleDiagSnapshot["hitTransition"]) => {
|
||
switch (t) {
|
||
case "problem":
|
||
return "проблема"
|
||
case "recovery":
|
||
return "восстановление"
|
||
case "neutral":
|
||
return "нейтрально"
|
||
default:
|
||
return "—"
|
||
}
|
||
}
|
||
const blockedRu = (b: AlertEngineRuleDiagSnapshot["blocked"]) => {
|
||
switch (b) {
|
||
case "no_hit":
|
||
return "условие не выполнено"
|
||
case "stability":
|
||
return "стабильность (confirmStabilitySec)"
|
||
case "cooldown":
|
||
return "cooldown"
|
||
case "no_telegram":
|
||
return "нет Telegram"
|
||
case "dedupe_positive":
|
||
return "дедуп восстановления"
|
||
case "in_group":
|
||
return "в группе (отдельно не шлём)"
|
||
default:
|
||
return "—"
|
||
}
|
||
}
|
||
return (
|
||
<div className="space-y-3">
|
||
<p className="text-xs text-muted-foreground">
|
||
Снимок на{" "}
|
||
<span className="font-mono tabular-nums">{new Date(a.sampledAt).toLocaleString("ru-RU")}</span>
|
||
. Данные для правил берутся из SQLite после джоб сбора (трафик, 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="space-y-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 space-y-2">
|
||
<p className="text-[11px] font-medium text-muted-foreground">По правилам (почему не ушло в Telegram)</p>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[11px] border-collapse">
|
||
<thead>
|
||
<tr className="text-left text-muted-foreground border-b border-border">
|
||
<th className="py-1 pr-2 font-medium">ID правила</th>
|
||
<th className="py-1 pr-2 font-medium">Сработало</th>
|
||
<th className="py-1 pr-2 font-medium">Тип срабатывания</th>
|
||
<th className="py-1 pr-2 font-medium">Стабильность</th>
|
||
<th className="py-1 pr-2 font-medium">Кулдаун</th>
|
||
<th className="py-1 pr-2 font-medium">Telegram</th>
|
||
<th className="py-1 pr-2 font-medium">Сообщение</th>
|
||
<th className="py-1 font-medium">Причина блока</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{a.ruleDiag.map((d) => (
|
||
<tr key={d.ruleId} className="border-b border-border/60 font-mono">
|
||
<td className="py-1 pr-2 max-w-[140px] truncate" title={d.ruleId}>
|
||
{d.ruleId}
|
||
</td>
|
||
<td className="py-1 pr-2">{d.evalHit ? "Да" : "Нет"}</td>
|
||
<td className="py-1 pr-2">{transitionRu(d.hitTransition)}</td>
|
||
<td className="py-1 pr-2">{d.stabilityOk ? "Да" : "Нет"}</td>
|
||
<td className="py-1 pr-2">{d.cooldownOk ? "Да" : "Нет"}</td>
|
||
<td className="py-1 pr-2">{d.telegramOk ? "Да" : "Нет"}</td>
|
||
<td className="py-1 pr-2 max-w-[280px] truncate text-muted-foreground" title={d.hitMessage ?? ""}>
|
||
{d.hitMessage ?? "—"}
|
||
</td>
|
||
<td className="py-1 text-muted-foreground">{blockedRu(d.blocked)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</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(() => parseSchedulerRunSnapshot(r.resultJson ?? null), [r.resultJson])
|
||
return (
|
||
<div className="space-y-4 text-sm">
|
||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3">
|
||
<div className="space-y-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="space-y-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 space-y-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="space-y-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="space-y-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="space-y-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="space-y-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="space-y-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="space-y-2">
|
||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Результаты измерений</p>
|
||
{snapshot ? (
|
||
<SnapshotTables snap={snapshot} />
|
||
) : (
|
||
<p className="text-xs text-muted-foreground">
|
||
Снимок отсутствует (запись до обновления бекенда или задача без детализации).
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function DataCollectionPage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
|
||
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
|
||
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
|
||
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
|
||
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
|
||
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
|
||
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
|
||
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
|
||
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
|
||
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
|
||
const [draftTrafficEnabled, setDraftTrafficEnabled] = useState(true)
|
||
const [draftServersApiEnabled, setDraftServersApiEnabled] = useState(false)
|
||
const [serversApiIntervalDraft, setServersApiIntervalDraft] = useState("120")
|
||
const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true)
|
||
const [draftPingEnabled, setDraftPingEnabled] = useState(true)
|
||
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
|
||
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
|
||
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
|
||
const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
|
||
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
|
||
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
|
||
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
|
||
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 toggleRunOpen = useCallback((id: string, open: boolean) => {
|
||
setOpenRunIds((prev) => {
|
||
const next = new Set(prev)
|
||
if (open) next.add(id)
|
||
else next.delete(id)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const schedulerJobsByKey = useMemo(() => {
|
||
const jobs = uptimeCollector?.scheduler?.jobs ?? []
|
||
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
|
||
}, [uptimeCollector?.scheduler?.jobs])
|
||
|
||
const loadCollectors = useCallback(async () => {
|
||
if (!isLive) return
|
||
setCollectorError(null)
|
||
setPageBusy(true)
|
||
try {
|
||
const runsQuery =
|
||
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
|
||
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
|
||
: "?limit=80"
|
||
const [traffic, serversApi, uptime, internetPath, certRenew, runsRes] = await Promise.all([
|
||
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
|
||
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
|
||
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
|
||
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
|
||
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
|
||
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
|
||
])
|
||
setTrafficCollector(traffic)
|
||
setServersApiCollector(serversApi)
|
||
setUptimeCollector(uptime)
|
||
setInternetPathCollector(internetPath)
|
||
setSchedulerRuns(runsRes.runs ?? [])
|
||
setTrafficIntervalDraft(String(traffic.intervalSec))
|
||
setTrafficRetentionDraft(String(traffic.retentionDays))
|
||
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
|
||
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
|
||
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
|
||
setUptimeRetentionDraft(String(uptime.retentionDays))
|
||
setDraftTrafficEnabled(!!traffic.enabled)
|
||
setDraftServersApiEnabled(!!serversApi.enabled)
|
||
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
|
||
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
|
||
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
|
||
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
|
||
setDraftInternetPathEnabled(!!internetPath.enabled)
|
||
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
|
||
setDraftCertRenewEnabled(!!certRenew.enabled)
|
||
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
|
||
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
|
||
} catch (e) {
|
||
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
|
||
} finally {
|
||
setPageBusy(false)
|
||
}
|
||
}, [apiFetch, isLive, runFilterJobKey])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
setTrafficCollector(null)
|
||
setServersApiCollector(null)
|
||
setUptimeCollector(null)
|
||
setInternetPathCollector(null)
|
||
setSchedulerRuns([])
|
||
return
|
||
}
|
||
void loadCollectors()
|
||
}, [isLive, loadCollectors])
|
||
|
||
useEffect(() => {
|
||
setOpenRunIds(new Set())
|
||
}, [runFilterJobKey])
|
||
|
||
const handleRefreshScheduler = async () => {
|
||
if (!isLive) return
|
||
setCollectorError(null)
|
||
try {
|
||
await apiFetch("/api/scheduler/refresh", { method: "POST" })
|
||
await loadCollectors()
|
||
} catch (e) {
|
||
setCollectorError(e instanceof Error ? e.message : "Не удалось обновить планировщик")
|
||
}
|
||
}
|
||
|
||
const enabledJobsCount = useMemo(() => {
|
||
let n = draftTrafficEnabled ? 1 : 0
|
||
if (draftServersApiEnabled) n += 1
|
||
if (draftResourcesEnabled) n += 1
|
||
if (draftPingEnabled) n += 1
|
||
if (draftSpeedEnabled) n += 1
|
||
if (draftInternetPathEnabled) n += 1
|
||
if (draftCertRenewEnabled) n += 1
|
||
return n
|
||
}, [
|
||
draftCertRenewEnabled,
|
||
draftInternetPathEnabled,
|
||
draftPingEnabled,
|
||
draftResourcesEnabled,
|
||
draftServersApiEnabled,
|
||
draftSpeedEnabled,
|
||
draftTrafficEnabled,
|
||
])
|
||
|
||
const schedulerJobCount = SCHEDULER_JOB_KEYS.length
|
||
|
||
const runningJobsCount = useMemo(
|
||
() => (uptimeCollector?.scheduler?.jobs ?? []).filter((j) => j.running).length,
|
||
[uptimeCollector?.scheduler?.jobs],
|
||
)
|
||
|
||
const errorRunsInView = useMemo(() => schedulerRuns.filter((r) => r.status === "error").length, [schedulerRuns])
|
||
|
||
const stats = useMemo(
|
||
() => [
|
||
{
|
||
label: "Включено задач",
|
||
value: `${enabledJobsCount} / ${schedulerJobCount}`,
|
||
sub: "По переключателям на этой странице (до сохранения)",
|
||
icon: <CalendarClockIcon className="size-4 text-muted-foreground" />,
|
||
},
|
||
{
|
||
label: "Сейчас выполняется",
|
||
value: String(runningJobsCount),
|
||
sub: "Фоновые прогоны планировщика",
|
||
icon: <LoaderCircleIcon className="size-4 text-amber-500" />,
|
||
},
|
||
{
|
||
label: "Трафик — последний сбор",
|
||
value: trafficCollector?.lastCollectedAt
|
||
? new Date(trafficCollector.lastCollectedAt).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit", day: "2-digit", month: "2-digit" })
|
||
: "—",
|
||
sub: trafficCollector?.lastError ? trafficCollector.lastError : trafficCollector?.lastDurationMs != null ? `${trafficCollector.lastDurationMs} мс` : "нет данных",
|
||
icon: trafficCollector?.lastError ? (
|
||
<XCircleIcon className="size-4 text-destructive" />
|
||
) : (
|
||
<CheckCircleIcon className="size-4 text-emerald-500" />
|
||
),
|
||
},
|
||
{
|
||
label: "Журнал (в списке)",
|
||
value: String(schedulerRuns.length),
|
||
sub: errorRunsInView ? `${errorRunsInView} с ошибкой` : "ошибок в показанных — нет",
|
||
icon: <DatabaseIcon className="size-4 text-sky-500" />,
|
||
},
|
||
],
|
||
[
|
||
enabledJobsCount,
|
||
schedulerJobCount,
|
||
runningJobsCount,
|
||
trafficCollector?.lastCollectedAt,
|
||
trafficCollector?.lastDurationMs,
|
||
trafficCollector?.lastError,
|
||
schedulerRuns.length,
|
||
errorRunsInView,
|
||
],
|
||
)
|
||
|
||
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}
|
||
onClick={() => { void loadCollectors() }}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", pageBusy && "animate-spin")} />
|
||
Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm" disabled={!isLive || pageBusy} 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">
|
||
{!isLive && (
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Нужен live-режим</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-5 pb-5">
|
||
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
|
||
Открыть настройки
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{isLive && collectorError && (
|
||
<Alert variant="destructive">
|
||
<AlertCircleIcon />
|
||
<AlertTitle>Ошибка загрузки статуса коллекторов</AlertTitle>
|
||
<AlertDescription>{collectorError}</AlertDescription>
|
||
</Alert>
|
||
)}
|
||
|
||
{isLive && (
|
||
<>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{stats.map((s) => (
|
||
<Card key={s.label}>
|
||
<CardContent className="px-5 py-4 flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||
<p className="text-xl font-semibold tabular-nums mt-0.5 truncate">{s.value}</p>
|
||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
|
||
</div>
|
||
<div className="shrink-0 mt-0.5">{s.icon}</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader className="border-b border-border pb-4">
|
||
<CardTitle className="text-base">Планировщик сбора данных</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
Интервалы и вкл/выкл по задачам. Сохранение отправляет настройки на бекенд и перезапускает таймеры.
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="px-0 pb-0">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-xs text-muted-foreground">
|
||
<th className="text-left font-medium px-5 py-3">Задача</th>
|
||
<th className="text-center font-medium px-3 py-3 w-[1%]">Вкл</th>
|
||
<th className="text-left font-medium px-4 py-3">Интервал (с)</th>
|
||
<th className="text-left font-medium px-4 py-3">Последний прогон</th>
|
||
<th className="text-left font-medium px-4 py-3">Статус</th>
|
||
<th className="text-right font-medium px-4 py-3 w-[1%]">Сейчас</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-border">
|
||
{SCHEDULER_JOB_KEYS.map((jobKey) => {
|
||
const j = schedulerJobsByKey[jobKey]
|
||
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
|
||
const en = 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 iv = 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 setIv = 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 defSec = fixedSchedule
|
||
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
|
||
: jobKey === "traffic"
|
||
? 30
|
||
: jobKey === "servers_rest_ping"
|
||
? 120
|
||
: jobKey === "uptime_resources"
|
||
? 300
|
||
: jobKey === "uptime_ping"
|
||
? 15
|
||
: jobKey === "uptime_speed"
|
||
? 60
|
||
: jobKey === "certificates_renew"
|
||
? 21600
|
||
: 300
|
||
return (
|
||
<tr key={jobKey} className="hover:bg-muted/40">
|
||
<td className="px-5 py-3 align-top">
|
||
<span className="font-medium">{SCHEDULER_JOB_LABELS[jobKey] ?? jobKey}</span>
|
||
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||
{SCHEDULER_JOB_DESCRIPTIONS[jobKey]}
|
||
</p>
|
||
<p className="text-[11px] text-muted-foreground font-mono mt-1">{jobKey}</p>
|
||
</td>
|
||
<td className="px-3 py-3 text-center align-top">
|
||
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
|
||
<Toggle
|
||
checked={en}
|
||
onChange={(v) => {
|
||
if (fixedSchedule) return
|
||
if (jobKey === "traffic") setDraftTrafficEnabled(v)
|
||
else if (jobKey === "servers_rest_ping") setDraftServersApiEnabled(v)
|
||
else if (jobKey === "uptime_resources") setDraftResourcesEnabled(v)
|
||
else if (jobKey === "uptime_ping") setDraftPingEnabled(v)
|
||
else if (jobKey === "uptime_speed") setDraftSpeedEnabled(v)
|
||
else if (jobKey === "certificates_renew") setDraftCertRenewEnabled(v)
|
||
else setDraftInternetPathEnabled(v)
|
||
}}
|
||
/>
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 w-28 align-top">
|
||
<Input
|
||
value={iv}
|
||
onChange={(e) => setIv(e.target.value)}
|
||
className="h-8 text-sm tabular-nums"
|
||
inputMode="numeric"
|
||
readOnly={fixedSchedule}
|
||
disabled={!en && !fixedSchedule}
|
||
placeholder={String(defSec)}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3 text-xs text-muted-foreground align-top">
|
||
{j?.lastFinishedAt ? new Date(j.lastFinishedAt).toLocaleString("ru-RU") : "—"}
|
||
{j?.lastDurationMs != null && (
|
||
<span className="block text-[11px]">{j.lastDurationMs} мс</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-3 align-top">
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
{j?.running ? (
|
||
<Badge variant="secondary" className="text-[10px]">
|
||
выполняется
|
||
</Badge>
|
||
) : null}
|
||
{j?.lastStatus ? (
|
||
<Badge
|
||
variant="outline"
|
||
className={cn(
|
||
"text-[10px]",
|
||
j.lastStatus === "ok" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
|
||
j.lastStatus === "error" && "border-destructive/50 text-destructive",
|
||
)}
|
||
>
|
||
{j.lastStatus}
|
||
</Badge>
|
||
) : null}
|
||
{j?.lastError ? (
|
||
<span
|
||
className="text-[10px] text-destructive max-w-[200px] truncate block"
|
||
title={j.lastError}
|
||
>
|
||
{j.lastError}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-right align-top">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="h-8"
|
||
disabled={j?.running || runNowJobKey !== null}
|
||
onClick={async () => {
|
||
setRunNowJobKey(jobKey)
|
||
setCollectorError(null)
|
||
try {
|
||
await apiFetch(`/api/scheduler/jobs/${encodeURIComponent(jobKey)}/run-now`, {
|
||
method: "POST",
|
||
})
|
||
await loadCollectors()
|
||
} catch (e) {
|
||
setCollectorError(e instanceof Error ? e.message : "Ошибка запуска")
|
||
} finally {
|
||
setRunNowJobKey(null)
|
||
}
|
||
}}
|
||
>
|
||
<RefreshCwIcon className={cn("size-3.5", runNowJobKey === jobKey && "animate-spin")} />
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Separator />
|
||
<div className="space-y-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={trafficRetentionDraft}
|
||
onChange={(e) => 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={uptimeRetentionDraft}
|
||
onChange={(e) => 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={renewBeforeDaysDraft}
|
||
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
|
||
className="h-8 text-sm"
|
||
inputMode="numeric"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
|
||
</p>
|
||
<Button
|
||
size="sm"
|
||
disabled={schedulerSaveBusy}
|
||
onClick={async () => {
|
||
setSchedulerSaveBusy(true)
|
||
setCollectorError(null)
|
||
try {
|
||
const tInt = Math.max(5, Number.parseInt(trafficIntervalDraft, 10) || 30)
|
||
const tRet = Math.max(1, Number.parseInt(trafficRetentionDraft, 10) || 14)
|
||
const uRes = Math.max(5, Number.parseInt(uptimeResourceIntervalDraft, 10) || 300)
|
||
const uPing = Math.max(5, Number.parseInt(uptimeIntervalDraft, 10) || 15)
|
||
const uSpd = Math.max(10, Number.parseInt(uptimeSpeedIntervalDraft, 10) || 60)
|
||
const uRet = Math.max(1, Number.parseInt(uptimeRetentionDraft, 10) || 14)
|
||
const sApiInt = Math.max(10, Number.parseInt(serversApiIntervalDraft, 10) || 120)
|
||
const ipInt = Math.max(30, Number.parseInt(internetPathIntervalDraft, 10) || 300)
|
||
await apiFetch("/api/traffic/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
enabled: draftTrafficEnabled,
|
||
intervalSec: tInt,
|
||
retentionDays: tRet,
|
||
}),
|
||
})
|
||
await apiFetch("/api/servers-api-ping/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
enabled: draftServersApiEnabled,
|
||
intervalSec: sApiInt,
|
||
}),
|
||
})
|
||
await apiFetch("/api/uptime/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
resourcesEnabled: draftResourcesEnabled,
|
||
pingEnabled: draftPingEnabled,
|
||
speedEnabled: draftSpeedEnabled,
|
||
intervalSec: uRes,
|
||
probeIntervalSec: uPing,
|
||
speedIntervalSec: uSpd,
|
||
retentionDays: uRet,
|
||
}),
|
||
})
|
||
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
|
||
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
|
||
await apiFetch("/api/internet-path/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
enabled: draftInternetPathEnabled,
|
||
intervalSec: ipInt,
|
||
}),
|
||
})
|
||
await apiFetch("/api/certificates/renew-settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
enabled: draftCertRenewEnabled,
|
||
intervalSec: certRenewInt,
|
||
renewBeforeDays,
|
||
}),
|
||
})
|
||
await loadCollectors()
|
||
} catch (e) {
|
||
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
|
||
} finally {
|
||
setSchedulerSaveBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
{schedulerSaveBusy ? <LoaderCircleIcon className="size-4 animate-spin mr-2" /> : null}
|
||
Сохранить настройки планировщика
|
||
</Button>
|
||
<div className="text-xs text-muted-foreground space-y-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}` : "Трафик: ошибок нет"}
|
||
</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}`
|
||
: "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}` : "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}`
|
||
: "Internet Path: ошибок нет"}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="border-b border-border flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||
<div>
|
||
<CardTitle className="text-base">Журнал прогонов</CardTitle>
|
||
<CardDescription className="text-xs">
|
||
SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки.
|
||
</CardDescription>
|
||
</div>
|
||
<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}
|
||
onChange={(e) => setRunFilterJobKey(e.target.value)}
|
||
>
|
||
<option value="">Все</option>
|
||
{SCHEDULER_JOB_KEYS.map((k) => (
|
||
<option key={k} value={k}>
|
||
{SCHEDULER_JOB_LABELS[k]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="px-0 pb-0">
|
||
{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>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|