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
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:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user