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
@@ -0,0 +1,498 @@
"use client"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import {
CompactDataGrid,
type CompactDataGridColumn,
type CompactDataGridProps,
} from "@/components/data-grids/compact-data-grid"
import type {
AlertEngineRuleDiagSnapshot,
PingProbeSnapshot,
ResourceServerSnapshot,
ServerRestPingSnapshot,
SpeedRunSnapshot,
TrafficServerSnapshot,
} from "@/lib/scheduler-run-snapshot"
type SnapshotRow = { id: string }
function withRowId<T extends { serverId?: number; probeId?: string; ruleId?: string; target?: string }>(
rows: T[],
idFn: (row: T, index: number) => string,
): (T & SnapshotRow)[] {
return rows.map((row, index) => ({ ...row, id: idFn(row, index) }))
}
function SnapshotOkBadge({
ok,
okLabel = "ok",
errLabel = "ошибка",
}: {
ok: boolean
okLabel?: string
errLabel?: string
}) {
return ok ? (
<Badge variant="outline" className="text-[10px] border-emerald-500/40 text-emerald-700 dark:text-emerald-400">
{okLabel}
</Badge>
) : (
<Badge variant="outline" className="text-[10px] border-destructive/50 text-destructive">
{errLabel}
</Badge>
)
}
function SnapshotErrorCell({ error, className }: { error?: string; className?: string }) {
return (
<span
className={cn("text-destructive max-w-[220px] truncate block", className)}
title={error}
>
{error ?? "—"}
</span>
)
}
function SnapshotDataGrid<T extends SnapshotRow>(
props: Omit<CompactDataGridProps<T>, "compact">,
) {
return <CompactDataGrid {...props} compact />
}
function TrafficSnapshotGrid({ servers }: { servers: TrafficServerSnapshot[] }) {
const data = withRowId(servers, (s) => String(s.serverId))
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
{
id: "host",
header: "Хост",
accessorKey: "host",
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
},
{
id: "ok",
header: "Результат",
enableSorting: false,
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
},
{
id: "interfaces",
header: "IF",
accessorKey: "interfaces",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => r.interfaces ?? "—",
},
{
id: "sumRxMbps",
header: "Σ RX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.sumRxMbps != null ? `${r.sumRxMbps} Мбит/с` : "—"),
},
{
id: "sumTxMbps",
header: "Σ TX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.sumTxMbps != null ? `${r.sumTxMbps} Мбит/с` : "—"),
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов трафика" />
}
function ResourcesSnapshotGrid({ servers }: { servers: ResourceServerSnapshot[] }) {
const data = withRowId(servers, (s) => String(s.serverId))
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "name",
header: "Сервер",
enableSorting: false,
cell: (r) => (
<div>
<span className="font-medium">{r.name}</span>
<span className="block font-mono text-[10px] text-muted-foreground">{r.host}</span>
</div>
),
},
{
id: "status",
header: "Статус",
enableSorting: false,
cell: (r) => (
<Badge
variant="outline"
className={cn(
"text-[10px]",
r.status === "online" && "border-emerald-500/40 text-emerald-700 dark:text-emerald-400",
r.status === "offline" && "border-destructive/50 text-destructive",
)}
>
{r.status}
</Badge>
),
},
{
id: "cpuLoadPct",
header: "CPU %",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => r.cpuLoadPct ?? "—",
},
{
id: "memory",
header: "Память",
enableSorting: false,
headerClassName: "text-right",
cellClassName: "text-right tabular-nums whitespace-nowrap",
cell: (r) =>
r.memUsedMb != null && r.memTotalMb != null ? `${r.memUsedMb} / ${r.memTotalMb} МБ` : "—",
},
{
id: "memUsedPct",
header: "% RAM",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.memUsedPct != null ? `${r.memUsedPct}%` : "—"),
},
{
id: "disk",
header: "Диск своб.",
enableSorting: false,
headerClassName: "text-right",
cellClassName: "text-right tabular-nums whitespace-nowrap",
cell: (r) =>
r.diskFreeMb != null && r.diskTotalMb != null ? `${r.diskFreeMb} / ${r.diskTotalMb} МБ` : "—",
},
{
id: "uptimeSeconds",
header: "Uptime",
cellClassName: "tabular-nums",
cell: (r) => (r.uptimeSeconds != null ? fmtUptimeSec(r.uptimeSeconds) : "—"),
},
{
id: "board",
header: "Плата / ROS",
enableSorting: false,
cell: (r) => (
<div className="max-w-[140px]">
<span className="block truncate" title={r.boardName}>{r.boardName || "—"}</span>
<span className="block truncate text-muted-foreground font-mono text-[10px]" title={r.rosVersion}>
{r.rosVersion || ""}
</span>
</div>
),
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[160px]" />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ресурсов" />
}
function ServersRestPingSnapshotGrid({ servers }: { servers: ServerRestPingSnapshot[] }) {
const data = withRowId(servers, (s) => String(s.serverId))
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{ id: "name", header: "Сервер", accessorKey: "name", cell: (r) => <span className="font-medium">{r.name}</span> },
{
id: "host",
header: "Хост",
accessorKey: "host",
cell: (r) => <span className="font-mono text-muted-foreground">{r.host}</span>,
},
{
id: "ok",
header: "Результат",
enableSorting: false,
cell: (r) => <SnapshotOkBadge ok={r.ok} errLabel="недоступен" />,
},
{
id: "latencyMs",
header: "RTT REST",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.latencyMs != null ? `${r.latencyMs} мс` : "—"),
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов REST ping" />
}
function PingSnapshotGrid({ probes }: { probes: PingProbeSnapshot[] }) {
const data = withRowId(probes, (p) => `${p.probeId}-${p.target}`)
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "name",
header: "Проба",
enableSorting: false,
cell: (r) => (
<div>
<span className="font-medium">{r.name}</span>
<span className="block font-mono text-[10px] text-muted-foreground">{r.probeId}</span>
</div>
),
},
{
id: "target",
header: "Цель",
accessorKey: "target",
cell: (r) => <span className="font-mono">{r.target}</span>,
},
{ id: "srcServerName", header: "Источник", accessorKey: "srcServerName" },
{
id: "srcInterface",
header: "IF",
accessorKey: "srcInterface",
cell: (r) => <span className="font-mono text-muted-foreground">{r.srcInterface || "—"}</span>,
},
{
id: "rttMs",
header: "RTT",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.rttMs != null ? `${r.rttMs} мс` : "—"),
},
{
id: "lossPct",
header: "Loss",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => `${r.lossPct}%`,
},
{
id: "status",
header: "Статус",
enableSorting: false,
cell: (r) => <Badge variant="outline" className="text-[10px]">{r.status}</Badge>,
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => <SnapshotErrorCell error={r.error} className="max-w-[180px]" />,
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет сэмплов ping" />
}
function SpeedSnapshotGrid({ runs }: { runs: SpeedRunSnapshot[] }) {
const data = withRowId(runs, (r) => r.probeId)
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "probeId",
header: "Проба",
accessorKey: "probeId",
cell: (r) => <span className="font-mono">{r.probeId}</span>,
},
{
id: "route",
header: "Маршрут",
enableSorting: false,
cell: (r) => (
<span className="whitespace-nowrap">
{r.srcServerName} <span className="text-muted-foreground"></span> {r.dstServerName}
</span>
),
},
{
id: "interfaces",
header: "Интерфейсы",
enableSorting: false,
cell: (r) => (
<span className="font-mono text-[10px]">
<span className="block">{r.srcInterface || "—"}</span>
<span className="block text-muted-foreground">{r.dstInterface || "—"}</span>
</span>
),
},
{
id: "protocol",
header: "Протокол",
enableSorting: false,
cell: (r) => `${r.protocol} / ${r.direction} / ${r.durationSec}s`,
},
{
id: "txAvgMbps",
header: "TX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.txAvgMbps != null ? `${Number(r.txAvgMbps).toFixed(1)}` : "—"),
},
{
id: "rxAvgMbps",
header: "RX",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.rxAvgMbps != null ? `${Number(r.rxAvgMbps).toFixed(1)}` : "—"),
},
{
id: "pingRttMs",
header: "Ping RTT",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.pingRttMs != null ? `${r.pingRttMs} мс` : "—"),
},
{
id: "pingLossPct",
header: "Loss",
headerClassName: "text-right",
cellClassName: "text-right tabular-nums",
cell: (r) => (r.pingLossPct != null ? `${r.pingLossPct}%` : "—"),
},
{
id: "ok",
header: "Результат",
enableSorting: false,
cell: (r) => <SnapshotOkBadge ok={r.ok} />,
},
{
id: "error",
header: "Ошибка",
enableSorting: false,
cell: (r) => (
<div className="max-w-[200px]">
<span className="text-destructive block truncate" title={r.error}>{r.error ?? ""}</span>
{r.pingError ? (
<span className="text-[10px] text-amber-600 dark:text-amber-400 block truncate" title={r.pingError ?? ""}>
ping: {r.pingError}
</span>
) : null}
</div>
),
},
]
return <SnapshotDataGrid data={data} columns={columns} emptyTitle="Нет прогонов speed" />
}
function transitionRu(t: AlertEngineRuleDiagSnapshot["hitTransition"]): string {
switch (t) {
case "problem":
return "проблема"
case "recovery":
return "восстановление"
case "neutral":
return "нейтрально"
default:
return "—"
}
}
function blockedRu(b: AlertEngineRuleDiagSnapshot["blocked"]): string {
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 "—"
}
}
function AlertEngineRuleDiagGrid({ ruleDiag }: { ruleDiag: AlertEngineRuleDiagSnapshot[] }) {
const data = withRowId(ruleDiag, (d) => d.ruleId)
const columns: CompactDataGridColumn<(typeof data)[number]>[] = [
{
id: "ruleId",
header: "ID правила",
accessorKey: "ruleId",
cell: (r) => (
<span className="font-mono max-w-[140px] truncate block" title={r.ruleId}>
{r.ruleId}
</span>
),
},
{
id: "evalHit",
header: "Сработало",
cell: (r) => (r.evalHit ? "Да" : "Нет"),
},
{
id: "hitTransition",
header: "Тип срабатывания",
cell: (r) => transitionRu(r.hitTransition),
},
{
id: "stabilityOk",
header: "Стабильность",
cell: (r) => (r.stabilityOk ? "Да" : "Нет"),
},
{
id: "cooldownOk",
header: "Кулдаун",
cell: (r) => (r.cooldownOk ? "Да" : "Нет"),
},
{
id: "telegramOk",
header: "Telegram",
cell: (r) => (r.telegramOk ? "Да" : "Нет"),
},
{
id: "hitMessage",
header: "Сообщение",
enableSorting: false,
cell: (r) => (
<span className="font-mono max-w-[280px] truncate text-muted-foreground block" title={r.hitMessage ?? ""}>
{r.hitMessage ?? "—"}
</span>
),
},
{
id: "blocked",
header: "Причина блока",
cell: (r) => <span className="text-muted-foreground">{blockedRu(r.blocked)}</span>,
},
]
return (
<SnapshotDataGrid
data={data}
columns={columns}
emptyTitle="Нет диагностики по правилам"
/>
)
}
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}м`
}
export {
SnapshotDataGrid,
TrafficSnapshotGrid,
ResourcesSnapshotGrid,
ServersRestPingSnapshotGrid,
PingSnapshotGrid,
SpeedSnapshotGrid,
AlertEngineRuleDiagGrid,
type SnapshotRow,
}