fix(ui): заменить таблицы на карточки данных и улучшить функциональность поиска
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m37s
Docker images / frontend-image (push) Successful in 1m50s
Docker images / updater-image (push) Successful in 44s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 7s

This commit is contained in:
Denozordec
2026-06-30 22:22:51 +07:00
parent a1a9124f3d
commit d3a2d38b37
63 changed files with 8509 additions and 3652 deletions
+156 -460
View File
@@ -28,7 +28,6 @@ import {
import { requestJson, ApiClientError } from "@/shared/api/http-client"
import {
parseSchedulerRunSnapshot,
type AlertEngineRuleDiagSnapshot,
type AlertEngineRunSnapshot,
type GreBgpSnapshotRunSnapshot,
type InternetPathRunSnapshot,
@@ -41,6 +40,19 @@ import {
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,
@@ -86,16 +98,6 @@ function fmtMs(ms: number): string {
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
@@ -113,44 +115,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
<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>
<DataPageCard>
<TrafficSnapshotGrid servers={t.servers} />
</DataPageCard>
</div>
)
}
@@ -170,59 +137,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
<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>
<DataPageCard>
<ResourcesSnapshotGrid servers={u.servers} />
</DataPageCard>
</div>
)
}
@@ -243,42 +160,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
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>
<DataPageCard>
<ServersRestPingSnapshotGrid servers={s.servers} />
</DataPageCard>
</div>
)
}
@@ -301,41 +185,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
<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>
<DataPageCard>
<PingSnapshotGrid probes={p.probes} />
</DataPageCard>
</div>
)
}
@@ -346,56 +198,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
<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>
<DataPageCard>
<SpeedSnapshotGrid runs={s.runs} />
</DataPageCard>
</div>
)
}
@@ -535,36 +340,6 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
}
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">
@@ -605,40 +380,9 @@ function SnapshotTables({ snap }: { snap: SchedulerRunSnapshot }) {
{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>
<DataPageCard className="border-0 shadow-none bg-transparent">
<AlertEngineRuleDiagGrid ruleDiag={a.ruleDiag} />
</DataPageCard>
</div>
) : null}
</div>
@@ -1057,6 +801,130 @@ export default function DataCollectionPage() {
}
}
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
@@ -1223,179 +1091,7 @@ export default function DataCollectionPage() {
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left font-medium px-5 py-3">Задача</th>
<th className="text-center font-medium px-3 py-3 w-[1%]">Вкл</th>
<th className="text-left font-medium px-4 py-3">Интервал (с)</th>
<th className="text-left font-medium px-4 py-3">Последний прогон</th>
<th className="text-left font-medium px-4 py-3">Статус</th>
<th className="text-right font-medium px-4 py-3 w-[1%]">Сейчас</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{SCHEDULER_JOB_KEYS.map((jobKey) => {
const j = schedulerJobsByKey[jobKey]
const fixedSchedule = jobKey === "gre_bgp" || jobKey === "alert_engine" || jobKey === "backups"
const en = fixedSchedule
? Boolean(j?.enabled ?? true)
: jobKey === "traffic"
? draftTrafficEnabled
: jobKey === "servers_rest_ping"
? draftServersApiEnabled
: jobKey === "uptime_resources"
? draftResourcesEnabled
: jobKey === "uptime_ping"
? draftPingEnabled
: jobKey === "uptime_speed"
? draftSpeedEnabled
: jobKey === "certificates_renew"
? draftCertRenewEnabled
: draftInternetPathEnabled
const iv = fixedSchedule
? String(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
? trafficIntervalDraft
: jobKey === "servers_rest_ping"
? serversApiIntervalDraft
: jobKey === "uptime_resources"
? uptimeResourceIntervalDraft
: jobKey === "uptime_ping"
? uptimeIntervalDraft
: jobKey === "uptime_speed"
? uptimeSpeedIntervalDraft
: jobKey === "certificates_renew"
? certRenewIntervalDraft
: internetPathIntervalDraft
const setIv = fixedSchedule
? () => {}
: jobKey === "traffic"
? setTrafficIntervalDraft
: jobKey === "servers_rest_ping"
? setServersApiIntervalDraft
: jobKey === "uptime_resources"
? setUptimeResourceIntervalDraft
: jobKey === "uptime_ping"
? setUptimeIntervalDraft
: jobKey === "uptime_speed"
? setUptimeSpeedIntervalDraft
: jobKey === "certificates_renew"
? setCertRenewIntervalDraft
: setInternetPathIntervalDraft
const defSec = fixedSchedule
? Number(j?.intervalSec ?? (jobKey === "gre_bgp" ? 30 : 20))
: jobKey === "traffic"
? 30
: jobKey === "servers_rest_ping"
? 120
: jobKey === "uptime_resources"
? 300
: jobKey === "uptime_ping"
? 15
: jobKey === "uptime_speed"
? 60
: jobKey === "certificates_renew"
? 21600
: 300
return (
<tr key={jobKey} className="hover:bg-muted/40">
<td className="px-5 py-3 align-top">
<span className="font-medium">{SCHEDULER_JOB_LABELS[jobKey] ?? jobKey}</span>
<p className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
{SCHEDULER_JOB_DESCRIPTIONS[jobKey]}
</p>
<p className="text-[11px] text-muted-foreground font-mono mt-1">{jobKey}</p>
</td>
<td className="px-3 py-3 text-center align-top">
<span className={fixedSchedule ? "inline-flex pointer-events-none opacity-50" : "inline-flex"}>
<FormToggle
checked={en}
disabled={fixedSchedule || schedulerSaveBusy}
onChange={(v) => {
if (fixedSchedule || schedulerSaveBusy) return
void handleJobEnabledChange(jobKey, 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>
<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">