Files
MikrotikManager/app/(main)/data-collection/page.tsx
T
DenozordecandCursor 5f29cfaf0f
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
fix(ui): выровнять chrome Frame и убрать Card-оболочки
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00

1293 lines
55 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 { FormToggle } from "@/components/form-kit"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { useDataSource } from "@/lib/data-source"
import {
SCHEDULER_JOB_DESCRIPTIONS,
SCHEDULER_JOB_KEYS,
SCHEDULER_JOB_LABELS,
type CollectorSettingsDto,
type SchedulerJobStatusDto,
type SchedulerRunRowDto,
type UptimeSettingsDto,
} from "@/lib/scheduler-settings"
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRunSnapshot,
type GreBgpSnapshotRunSnapshot,
type InternetPathRunSnapshot,
type CertificatesRenewRunSnapshot,
type BackupsRunSnapshot,
type PingRunSnapshot,
type ResourcesRunSnapshot,
type SchedulerRunSnapshot,
type ServersRestPingRunSnapshot,
type SpeedScheduledRunSnapshot,
type TrafficRunSnapshot,
} from "@/lib/scheduler-run-snapshot"
import {
AlertEngineRuleDiagGrid,
PingSnapshotGrid,
ResourcesSnapshotGrid,
ServersRestPingSnapshotGrid,
SpeedSnapshotGrid,
TrafficSnapshotGrid,
} from "@/components/data-grids/snapshot-data-grid"
import {
DataCollectionSchedulerDataGrid,
type SchedulerJobGridRow,
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { 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 extractApiError(reason: unknown): string {
if (reason instanceof ApiClientError) return reason.message
if (reason instanceof Error) return reason.message
return String(reason)
}
function readSettled<T>(result: PromiseSettledResult<T>): T | null {
return result.status === "fulfilled" ? result.value : null
}
function collectSettledErrors(results: PromiseSettledResult<unknown>[], labels: string[]): string[] {
const errors: string[] = []
for (let i = 0; i < results.length; i += 1) {
const result = results[i]
if (result.status === "rejected") {
errors.push(`${labels[i]}: ${extractApiError(result.reason)}`)
}
}
return errors
}
function fmtMs(ms: number): string {
if (ms < 1000) return `${ms} мс`
const s = ms / 1000
return s < 60 ? `${s.toFixed(1)} с` : `${Math.floor(s / 60)} м ${Math.round(s % 60)} с`
}
function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
if (snap.job === "traffic") {
const t = snap as TrafficRunSnapshot
return (
<div className="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>
<DataPageCard>
<TrafficSnapshotGrid servers={t.servers} />
</DataPageCard>
</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>
<DataPageCard>
<ResourcesSnapshotGrid servers={u.servers} />
</DataPageCard>
</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>
<DataPageCard>
<ServersRestPingSnapshotGrid servers={s.servers} />
</DataPageCard>
</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>
<DataPageCard>
<PingSnapshotGrid probes={p.probes} />
</DataPageCard>
</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>
<DataPageCard>
<SpeedSnapshotGrid runs={s.runs} />
</DataPageCard>
</div>
)
}
if (snap.job === "gre_bgp") {
const g = snap as GreBgpSnapshotRunSnapshot
return (
<div className="space-y-3">
{g.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущий сбор GRE/BGP ещё выполнялся.</p>
) : null}
{g.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {g.fatalError}</AlertDescription>
</Alert>
) : null}
<p className="text-xs text-muted-foreground">
Запись в SQLite на{" "}
<span className="font-mono tabular-nums">{new Date(g.sampledAt).toLocaleString("ru-RU")}</span>
{" "} строки GRE и BGP для движка оповещений.
</p>
<dl className="grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">Строк GRE</dt>
<dd className="font-mono font-medium">{g.greWritten}</dd>
</div>
<div>
<dt className="text-muted-foreground">Строк BGP</dt>
<dd className="font-mono font-medium">{g.bgpWritten}</dd>
</div>
</dl>
{g.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{g.errors.map((e, i) => (
<p key={i} className="break-words">
{e}
</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "internet_path") {
const p = snap as InternetPathRunSnapshot
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Снимок internet-path на{" "}
<span className="font-mono tabular-nums">{new Date(p.sampledAt).toLocaleString("ru-RU")}</span>
</p>
<dl className="grid grid-cols-2 gap-3 text-xs">
<div>
<dt className="text-muted-foreground">Home routers</dt>
<dd className="font-mono font-medium">{p.homes}</dd>
</div>
<div>
<dt className="text-muted-foreground">Сохранение snapshot</dt>
<dd className="font-mono font-medium">{p.snapshotSaved ? "ok" : "no"}</dd>
</div>
</dl>
{p.fatalError ? (
<Alert variant="destructive" className="py-2">
<AlertCircleIcon />
<AlertDescription className="text-xs">Критическая ошибка: {p.fatalError}</AlertDescription>
</Alert>
) : null}
</div>
)
}
if (snap.job === "certificates_renew") {
const c = snap as CertificatesRenewRunSnapshot
return (
<div className="space-y-3">
{c.skipped ? (
<p className="text-xs text-amber-600 dark:text-amber-400">Прогон пропущен: предыдущая проверка ещё выполнялась или задача отключена.</p>
) : null}
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Проверено</dt>
<dd className="font-mono font-medium">{c.checked}</dd>
</div>
<div>
<dt className="text-muted-foreground">Обновлено</dt>
<dd className="font-mono font-medium">{c.renewed}</dd>
</div>
<div>
<dt className="text-muted-foreground">Пропущено</dt>
<dd className="font-mono font-medium">{c.skippedTargets}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{c.errors.length}</dd>
</div>
</dl>
{c.errors.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{c.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "backups") {
const b = snap as BackupsRunSnapshot
return (
<div className="space-y-3">
<dl className="grid grid-cols-2 gap-3 text-xs sm:grid-cols-4">
<div>
<dt className="text-muted-foreground">Слот расписания</dt>
<dd className="font-mono font-medium">{b.due ? "да" : "нет"}</dd>
</div>
<div>
<dt className="text-muted-foreground">Создано</dt>
<dd className="font-mono font-medium">{b.created}</dd>
</div>
<div>
<dt className="text-muted-foreground">Ошибки</dt>
<dd className="font-mono font-medium">{b.failures}</dd>
</div>
<div>
<dt className="text-muted-foreground">Удалено старых</dt>
<dd className="font-mono font-medium">{b.pruned}</dd>
</div>
</dl>
{b.errors?.length ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 space-y-1">
{b.errors.map((e, i) => (
<p key={i} className="break-words">{e}</p>
))}
</div>
) : null}
</div>
)
}
if (snap.job === "alert_engine") {
const a = snap as AlertEngineRunSnapshot
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>
<DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
</DataPageCard>
</div>
) : null}
</div>
)
}
return null
}
function RunRowDetail({ r }: { r: SchedulerRunRowDto }) {
const jobTitle = SCHEDULER_JOB_LABELS[r.jobKey] ?? r.jobKey
const jobDesc = SCHEDULER_JOB_DESCRIPTIONS[r.jobKey] ?? "—"
const snapshot = useMemo(() => 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>
)
}
type SchedulerToggleOverrides = {
trafficEnabled?: boolean
serversApiEnabled?: boolean
resourcesEnabled?: boolean
pingEnabled?: boolean
speedEnabled?: boolean
internetPathEnabled?: boolean
certRenewEnabled?: boolean
}
function applySchedulerJobsToDrafts(
jobs: SchedulerJobStatusDto[],
setters: {
setDraftTrafficEnabled: (value: boolean) => void
setDraftServersApiEnabled: (value: boolean) => void
setDraftResourcesEnabled: (value: boolean) => void
setDraftPingEnabled: (value: boolean) => void
setDraftSpeedEnabled: (value: boolean) => void
setDraftInternetPathEnabled: (value: boolean) => void
setDraftCertRenewEnabled: (value: boolean) => void
},
) {
const byKey = Object.fromEntries(jobs.map((job) => [job.jobKey, job])) as Record<string, SchedulerJobStatusDto>
if (byKey.traffic) setters.setDraftTrafficEnabled(!!byKey.traffic.enabled)
if (byKey.servers_rest_ping) setters.setDraftServersApiEnabled(!!byKey.servers_rest_ping.enabled)
if (byKey.uptime_resources) setters.setDraftResourcesEnabled(!!byKey.uptime_resources.enabled)
if (byKey.uptime_ping) setters.setDraftPingEnabled(!!byKey.uptime_ping.enabled)
if (byKey.uptime_speed) setters.setDraftSpeedEnabled(!!byKey.uptime_speed.enabled)
if (byKey.internet_path) setters.setDraftInternetPathEnabled(!!byKey.internet_path.enabled)
if (byKey.certificates_renew) setters.setDraftCertRenewEnabled(!!byKey.certificates_renew.enabled)
}
export default function DataCollectionPage() {
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [trafficCollector, setTrafficCollector] = useState<CollectorSettingsDto | null>(null)
const [serversApiCollector, setServersApiCollector] = useState<CollectorSettingsDto | null>(null)
const [uptimeCollector, setUptimeCollector] = useState<UptimeSettingsDto | null>(null)
const [internetPathCollector, setInternetPathCollector] = useState<CollectorSettingsDto | null>(null)
const [trafficIntervalDraft, setTrafficIntervalDraft] = useState("30")
const [trafficRetentionDraft, setTrafficRetentionDraft] = useState("14")
const [uptimeResourceIntervalDraft, setUptimeResourceIntervalDraft] = useState("300")
const [uptimeIntervalDraft, setUptimeIntervalDraft] = useState("15")
const [uptimeSpeedIntervalDraft, setUptimeSpeedIntervalDraft] = useState("60")
const [uptimeRetentionDraft, setUptimeRetentionDraft] = useState("14")
const [draftTrafficEnabled, setDraftTrafficEnabled] = useState(true)
const [draftServersApiEnabled, setDraftServersApiEnabled] = useState(false)
const [serversApiIntervalDraft, setServersApiIntervalDraft] = useState("120")
const [draftResourcesEnabled, setDraftResourcesEnabled] = useState(true)
const [draftPingEnabled, setDraftPingEnabled] = useState(true)
const [draftSpeedEnabled, setDraftSpeedEnabled] = useState(true)
const [draftInternetPathEnabled, setDraftInternetPathEnabled] = useState(true)
const [internetPathIntervalDraft, setInternetPathIntervalDraft] = useState("300")
const [draftCertRenewEnabled, setDraftCertRenewEnabled] = useState(true)
const [certRenewIntervalDraft, setCertRenewIntervalDraft] = useState("21600")
const [renewBeforeDaysDraft, setRenewBeforeDaysDraft] = useState("30")
const [schedulerRuns, setSchedulerRuns] = useState<SchedulerRunRowDto[]>([])
const [runFilterJobKey, setRunFilterJobKey] = useState<string>("")
const [runNowJobKey, setRunNowJobKey] = useState<string | null>(null)
const [schedulerSaveBusy, setSchedulerSaveBusy] = useState(false)
const [pageBusy, setPageBusy] = useState(false)
const [collectorError, setCollectorError] = useState<string | null>(null)
const [openRunIds, setOpenRunIds] = useState<Set<string>>(() => new Set())
const toggleRunOpen = useCallback((id: string, open: boolean) => {
setOpenRunIds((prev) => {
const next = new Set(prev)
if (open) next.add(id)
else next.delete(id)
return next
})
}, [])
const schedulerJobsByKey = useMemo(() => {
const jobs = uptimeCollector?.scheduler?.jobs ?? []
return Object.fromEntries(jobs.map((j) => [j.jobKey, j])) as Record<string, SchedulerJobStatusDto>
}, [uptimeCollector?.scheduler?.jobs])
const loadCollectors = useCallback(async () => {
if (!isLive) return
setCollectorError(null)
setPageBusy(true)
try {
const runsQuery =
runFilterJobKey && SCHEDULER_JOB_KEYS.includes(runFilterJobKey as (typeof SCHEDULER_JOB_KEYS)[number])
? `?limit=80&jobKey=${encodeURIComponent(runFilterJobKey)}`
: "?limit=80"
const [trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes] = await Promise.allSettled([
apiFetch<CollectorSettingsDto>("/api/traffic/settings"),
apiFetch<CollectorSettingsDto>("/api/servers-api-ping/settings"),
apiFetch<UptimeSettingsDto>("/api/uptime/settings"),
apiFetch<CollectorSettingsDto>("/api/internet-path/settings"),
apiFetch<{ enabled: boolean; intervalSec: number; renewBeforeDays: number }>("/api/certificates/renew-settings"),
apiFetch<{ runs: SchedulerRunRowDto[] }>(`/api/scheduler/runs${runsQuery}`),
])
const loadErrors = collectSettledErrors(
[trafficRes, serversApiRes, uptimeRes, internetPathRes, certRenewRes, runsRes],
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты", "журнал планировщика"],
)
if (loadErrors.length > 0) {
setCollectorError(loadErrors.join("; "))
}
const traffic = readSettled(trafficRes)
if (traffic) {
setTrafficCollector(traffic)
setTrafficIntervalDraft(String(traffic.intervalSec))
setTrafficRetentionDraft(String(traffic.retentionDays))
setDraftTrafficEnabled(!!traffic.enabled)
}
const serversApi = readSettled(serversApiRes)
if (serversApi) {
setServersApiCollector(serversApi)
setDraftServersApiEnabled(!!serversApi.enabled)
setServersApiIntervalDraft(String(serversApi.intervalSec ?? 120))
}
const uptime = readSettled(uptimeRes)
if (uptime) {
setUptimeCollector(uptime)
setUptimeResourceIntervalDraft(String(uptime.intervalSec ?? 300))
setUptimeIntervalDraft(String(uptime.probeIntervalSec ?? 15))
setUptimeSpeedIntervalDraft(String(uptime.speedIntervalSec ?? 60))
setUptimeRetentionDraft(String(uptime.retentionDays))
setDraftResourcesEnabled(!!(uptime.resourcesEnabled ?? uptime.enabled))
setDraftPingEnabled(!!(uptime.pingEnabled ?? uptime.enabled))
setDraftSpeedEnabled(!!(uptime.speedEnabled ?? uptime.enabled))
if (uptime.scheduler?.jobs?.length) {
applySchedulerJobsToDrafts(uptime.scheduler.jobs, {
setDraftTrafficEnabled,
setDraftServersApiEnabled,
setDraftResourcesEnabled,
setDraftPingEnabled,
setDraftSpeedEnabled,
setDraftInternetPathEnabled,
setDraftCertRenewEnabled,
})
}
}
const internetPath = readSettled(internetPathRes)
if (internetPath) {
setInternetPathCollector(internetPath)
setDraftInternetPathEnabled(!!internetPath.enabled)
setInternetPathIntervalDraft(String(internetPath.intervalSec ?? 300))
}
const certRenew = readSettled(certRenewRes)
if (certRenew) {
setDraftCertRenewEnabled(!!certRenew.enabled)
setCertRenewIntervalDraft(String(certRenew.intervalSec ?? 21600))
setRenewBeforeDaysDraft(String(certRenew.renewBeforeDays ?? 30))
}
const runsPayload = readSettled(runsRes)
if (runsPayload) {
setSchedulerRuns(runsPayload.runs ?? [])
}
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось загрузить данные")
} finally {
setPageBusy(false)
}
}, [apiFetch, isLive, runFilterJobKey])
const persistSchedulerDrafts = useCallback(async (overrides: SchedulerToggleOverrides = {}) => {
const trafficEnabled = overrides.trafficEnabled ?? draftTrafficEnabled
const serversApiEnabled = overrides.serversApiEnabled ?? draftServersApiEnabled
const resourcesEnabled = overrides.resourcesEnabled ?? draftResourcesEnabled
const pingEnabled = overrides.pingEnabled ?? draftPingEnabled
const speedEnabled = overrides.speedEnabled ?? draftSpeedEnabled
const internetPathEnabled = overrides.internetPathEnabled ?? draftInternetPathEnabled
const certRenewEnabled = overrides.certRenewEnabled ?? draftCertRenewEnabled
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)
const certRenewInt = Math.max(300, Number.parseInt(certRenewIntervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(renewBeforeDaysDraft, 10) || 30))
const saveResults = await Promise.allSettled([
apiFetch("/api/traffic/settings", {
method: "PUT",
body: JSON.stringify({
enabled: trafficEnabled,
intervalSec: tInt,
retentionDays: tRet,
}),
}),
apiFetch("/api/servers-api-ping/settings", {
method: "PUT",
body: JSON.stringify({
enabled: serversApiEnabled,
intervalSec: sApiInt,
}),
}),
apiFetch("/api/uptime/settings", {
method: "PUT",
body: JSON.stringify({
resourcesEnabled,
pingEnabled,
speedEnabled,
intervalSec: uRes,
probeIntervalSec: uPing,
speedIntervalSec: uSpd,
retentionDays: uRet,
}),
}),
apiFetch("/api/internet-path/settings", {
method: "PUT",
body: JSON.stringify({
enabled: internetPathEnabled,
intervalSec: ipInt,
}),
}),
apiFetch("/api/certificates/renew-settings", {
method: "PUT",
body: JSON.stringify({
enabled: certRenewEnabled,
intervalSec: certRenewInt,
renewBeforeDays,
}),
}),
])
const saveErrors = collectSettledErrors(
saveResults,
["трафик", "серверы REST API", "uptime", "internet path", "сертификаты"],
)
if (saveErrors.length > 0) {
throw new Error(`Не все настройки сохранились: ${saveErrors.join("; ")}`)
}
}, [
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
internetPathIntervalDraft,
renewBeforeDaysDraft,
serversApiIntervalDraft,
trafficIntervalDraft,
trafficRetentionDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeRetentionDraft,
uptimeSpeedIntervalDraft,
])
const handleJobEnabledChange = useCallback(async (jobKey: (typeof SCHEDULER_JOB_KEYS)[number], nextEnabled: boolean) => {
const overrides: SchedulerToggleOverrides = {}
if (jobKey === "traffic") {
setDraftTrafficEnabled(nextEnabled)
overrides.trafficEnabled = nextEnabled
} else if (jobKey === "servers_rest_ping") {
setDraftServersApiEnabled(nextEnabled)
overrides.serversApiEnabled = nextEnabled
} else if (jobKey === "uptime_resources") {
setDraftResourcesEnabled(nextEnabled)
overrides.resourcesEnabled = nextEnabled
} else if (jobKey === "uptime_ping") {
setDraftPingEnabled(nextEnabled)
overrides.pingEnabled = nextEnabled
} else if (jobKey === "uptime_speed") {
setDraftSpeedEnabled(nextEnabled)
overrides.speedEnabled = nextEnabled
} else if (jobKey === "certificates_renew") {
setDraftCertRenewEnabled(nextEnabled)
overrides.certRenewEnabled = nextEnabled
} else if (jobKey === "internet_path") {
setDraftInternetPathEnabled(nextEnabled)
overrides.internetPathEnabled = nextEnabled
} else {
return
}
setCollectorError(null)
setSchedulerSaveBusy(true)
try {
await persistSchedulerDrafts(overrides)
await loadCollectors()
} catch (e) {
setCollectorError(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setSchedulerSaveBusy(false)
}
}, [loadCollectors, persistSchedulerDrafts])
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 schedulerGridRows = useMemo<SchedulerJobGridRow[]>(() => {
return SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
const enabled = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
? draftTrafficEnabled
: jobKey === "servers_rest_ping"
? draftServersApiEnabled
: jobKey === "uptime_resources"
? draftResourcesEnabled
: jobKey === "uptime_ping"
? draftPingEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const intervalValue = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
? trafficIntervalDraft
: jobKey === "servers_rest_ping"
? serversApiIntervalDraft
: jobKey === "uptime_resources"
? uptimeResourceIntervalDraft
: jobKey === "uptime_ping"
? uptimeIntervalDraft
: jobKey === "uptime_speed"
? uptimeSpeedIntervalDraft
: jobKey === "certificates_renew"
? certRenewIntervalDraft
: internetPathIntervalDraft
const onIntervalChange = fixedSchedule
? () => {}
: jobKey === "traffic"
? setTrafficIntervalDraft
: jobKey === "servers_rest_ping"
? setServersApiIntervalDraft
: jobKey === "uptime_resources"
? setUptimeResourceIntervalDraft
: jobKey === "uptime_ping"
? setUptimeIntervalDraft
: jobKey === "uptime_speed"
? setUptimeSpeedIntervalDraft
: jobKey === "certificates_renew"
? setCertRenewIntervalDraft
: setInternetPathIntervalDraft
const defaultInterval = fixedSchedule
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
? 30
: jobKey === "servers_rest_ping"
? 120
: jobKey === "uptime_resources"
? 300
: jobKey === "uptime_ping"
? 15
: jobKey === "uptime_speed"
? 60
: jobKey === "certificates_renew"
? 21600
: 300
return {
id: jobKey,
jobKey,
label: SCHEDULER_JOB_LABELS[jobKey] ?? jobKey,
description: SCHEDULER_JOB_DESCRIPTIONS[jobKey],
fixedSchedule,
enabled,
intervalValue,
intervalReadOnly: fixedSchedule,
intervalDisabled: !enabled && !fixedSchedule,
defaultInterval,
job: j,
onEnabledChange: fixedSchedule
? undefined
: (nextEnabled) => {
void handleJobEnabledChange(jobKey, nextEnabled)
},
onIntervalChange,
onRunNow: 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)
}
},
runNowLoading: runNowJobKey === jobKey,
saveBusy: schedulerSaveBusy,
}
})
}, [
apiFetch,
certRenewIntervalDraft,
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
handleJobEnabledChange,
internetPathIntervalDraft,
loadCollectors,
runNowJobKey,
schedulerJobsByKey,
schedulerSaveBusy,
serversApiIntervalDraft,
trafficIntervalDraft,
uptimeIntervalDraft,
uptimeResourceIntervalDraft,
uptimeSpeedIntervalDraft,
])
const enabledJobsCount = useMemo(() => {
const jobs = uptimeCollector?.scheduler?.jobs
if (jobs?.length) return jobs.filter((job) => job.enabled).length
let n = draftTrafficEnabled ? 1 : 0
if (draftServersApiEnabled) n += 1
if (draftResourcesEnabled) n += 1
if (draftPingEnabled) n += 1
if (draftSpeedEnabled) n += 1
if (draftInternetPathEnabled) n += 1
if (draftCertRenewEnabled) n += 1
return n
}, [
draftCertRenewEnabled,
draftInternetPathEnabled,
draftPingEnabled,
draftResourcesEnabled,
draftServersApiEnabled,
draftSpeedEnabled,
draftTrafficEnabled,
uptimeCollector?.scheduler?.jobs,
])
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: uptimeCollector?.scheduler?.jobs?.length
? "По сохранённым задачам планировщика"
: "По переключателям на этой странице",
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">
{!prefsHydrated && (
<OpsPanel
title="Загрузка настроек подключения"
description="Читаем режим данных и адрес API из локальных настроек."
>
<div />
</OpsPanel>
)}
{prefsHydrated && !isLive && (
<OpsPanel
title="Нужен live-режим"
description="Планировщик и журнал читаются только с бекенда. Включите «Живые» данные и проверьте URL бекенда в настройках."
contentClassName="px-5 pb-5"
>
<Link href="/settings" className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-8")}>
Открыть настройки
</Link>
</OpsPanel>
)}
{isLive && 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) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
<IconTile variant="elevated" aria-hidden="true" className="size-10.5 text-muted-foreground">
{s.icon}
</IconTile>
<div className="min-w-0 flex-1 flex flex-col gap-0.5">
<p className="text-muted-foreground text-sm font-medium">{s.label}</p>
<p className="text-xl leading-none font-bold tabular-nums truncate">{s.value}</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug line-clamp-2">{s.sub}</p>
</div>
</FramePanel>
</Frame>
))}
</div>
<DataPageCard>
<div className="border-b border-border px-5 py-4">
<p className="text-base font-medium">Планировщик сбора данных</p>
<p className="text-xs text-muted-foreground mt-1">
Интервалы и вкл/выкл по задачам. Переключатель сразу сохраняет задачу на бекенде; кнопка ниже интервалы и срок хранения.
</p>
</div>
<DataCollectionSchedulerDataGrid rows={schedulerGridRows} />
<Separator />
<div className="space-y-3 px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов трафика (дней)</p>
<Input
value={trafficRetentionDraft}
onChange={(e) => setTrafficRetentionDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Хранение сэмплов uptime (дней)</p>
<Input
value={uptimeRetentionDraft}
onChange={(e) => setUptimeRetentionDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
<div>
<p className="text-xs text-muted-foreground mb-1.5">Обновлять сертификаты за (дней)</p>
<Input
value={renewBeforeDaysDraft}
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
/>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
Для ping-проб у отдельных записей в мониторинге можно задать свой интервал (0 = глобальный «Интервал ping» в таблице).
</p>
<Button
size="sm"
disabled={schedulerSaveBusy}
onClick={async () => {
setSchedulerSaveBusy(true)
setCollectorError(null)
try {
await persistSchedulerDrafts()
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>
</DataPageCard>
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
headerRight={
<div className="flex items-center gap-2 shrink-0">
<label className="text-xs text-muted-foreground whitespace-nowrap" htmlFor="run-filter">
Задача
</label>
<select
id="run-filter"
className="h-8 rounded-md border border-input bg-background px-2 text-xs min-w-[180px]"
value={runFilterJobKey}
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>
}
contentClassName="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>
)}
</OpsPanel>
</>
)}
</div>
</div>
</div>
)
}