Added internet path settings and snapshot management to the application. This includes new database tables for internet path settings and snapshots, API routes for fetching and managing internet path data, and integration into the dashboard and data collection pages. Enhanced the scheduler to support internet path jobs, ensuring regular data collection and updates. Updated relevant types and interfaces to accommodate the new functionality.
1331 lines
66 KiB
TypeScript
1331 lines
66 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 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 === "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, backendStatus } = useDataSource()
|
||
const isLive = mode === "live" && backendStatus === true
|
||
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 [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, 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<{ 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))
|
||
} 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
|
||
return n
|
||
}, [
|
||
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"
|
||
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
|
||
: 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
|
||
: internetPathIntervalDraft
|
||
const setIv = fixedSchedule
|
||
? () => {}
|
||
: jobKey === "traffic"
|
||
? setTrafficIntervalDraft
|
||
: jobKey === "servers_rest_ping"
|
||
? setServersApiIntervalDraft
|
||
: jobKey === "uptime_resources"
|
||
? setUptimeResourceIntervalDraft
|
||
: jobKey === "uptime_ping"
|
||
? setUptimeIntervalDraft
|
||
: jobKey === "uptime_speed"
|
||
? setUptimeSpeedIntervalDraft
|
||
: 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
|
||
: 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 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-[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,
|
||
}),
|
||
})
|
||
await apiFetch("/api/internet-path/settings", {
|
||
method: "PUT",
|
||
body: JSON.stringify({
|
||
enabled: draftInternetPathEnabled,
|
||
intervalSec: ipInt,
|
||
}),
|
||
})
|
||
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>
|
||
)
|
||
}
|