Files
MikrotikManager/app/(main)/data-collection/page.tsx
T
Denozordec 6d8379501c refactor: replace custom API fetch logic with requestJson utility across multiple pages
Updated the API fetching mechanism in various components to utilize the new requestJson function for improved consistency and error handling. This change affects the alerts, dashboard, data collection, filters, gre, network map, probes, recursive routes, route optimizer, servers, settings, traffic, and uptime pages.
2026-05-07 13:35:41 +07:00

1238 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import Link from "next/link"
import { useCallback, useEffect, useMemo, 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 {
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 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 ? (
<p className="text-xs text-destructive">Критическая ошибка: {t.fatalError}</p>
) : 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 ? <p className="text-xs text-destructive">Критическая ошибка: {u.fatalError}</p> : 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 ? <p className="text-xs text-destructive">Критическая ошибка: {s.fatalError}</p> : 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 ? <p className="text-xs text-destructive">Критическая ошибка: {p.fatalError}</p> : 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 ? <p className="text-xs text-destructive">Критическая ошибка: {g.fatalError}</p> : 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 === "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 ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive space-y-1">
{a.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
</p>
))}
</div>
) : 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 [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 [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, runsRes] = await Promise.all([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
])
setTrafficCollector(traffic)
setServersApiCollector(serversApi)
setUptimeCollector(uptime)
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))
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
setPageBusy(false)
}
}, [apiFetch, isLive, runFilterJobKey])
useEffect(() => {
if (!isLive) {
setTrafficCollector(null)
setServersApiCollector(null)
setUptimeCollector(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
return n
}, [
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 && (
<Card className="border-destructive/50">
<CardContent className="flex items-start gap-2 pt-4 pb-4 px-4">
<AlertCircleIcon className="size-4 text-destructive shrink-0 mt-0.5" />
<p className="text-sm text-destructive">{collectorError}</p>
</CardContent>
</Card>
)}
{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
: draftSpeedEnabled
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
: uptimeSpeedIntervalDraft
const setIv = fixedSchedule
? () => {}
: jobKey === "traffic"
? setTrafficIntervalDraft
: jobKey === "servers_rest_ping"
? setServersApiIntervalDraft
: jobKey === "uptime_resources"
? setUptimeResourceIntervalDraft
: jobKey === "uptime_ping"
? setUptimeIntervalDraft
: setUptimeSpeedIntervalDraft
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
: 60
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 setDraftSpeedEnabled(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)
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 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>
</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>
)
}