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,242 @@
"use client"
import { useMemo } from "react"
import {
type ColumnDef,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table"
import type { Server } from "@/lib/data"
import { Flag } from "@/components/flag"
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
import {
DATA_GRID_CELL_PAD,
DATA_GRID_CELL_PAD_FIRST,
DATA_GRID_CELL_PAD_LAST,
} from "@/components/data-grids/shared/data-grid-layout"
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
import { cn } from "@/lib/utils"
import { ArrowRightIcon, RefreshCwIcon } from "lucide-react"
export interface SpeedTestRunRow {
id: string
startedAt: number
srcServerId: string
dstServerId: string
srcInterface?: string
dstInterface?: string
protocol: "tcp" | "udp"
direction: "transmit" | "receive" | "both"
durationSec: number
txAvgMbps: number
rxAvgMbps: number
status: "running" | "done" | "error"
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
srcInterfaceAddress?: string | null
dstInterfaceAddress?: string | null
}
interface UptimeSpeedHistoryDataGridProps {
runs: SpeedTestRunRow[]
servers: Server[]
}
function UptimeSpeedHistoryDataGrid({ runs, servers }: UptimeSpeedHistoryDataGridProps) {
const columns = useMemo<ColumnDef<SpeedTestRunRow>[]>(
() => [
{
id: "startedAt",
accessorKey: "startedAt",
header: ({ column }) => <DataGridSortHeader column={column} title="Время" className="ml-1" />,
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap tabular-nums font-mono text-xs">
{new Date(row.original.startedAt).toLocaleString("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
day: "2-digit",
month: "2-digit",
})}
</span>
),
meta: {
headerTitle: "Время",
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
},
},
{
id: "route",
accessorFn: (row) => `${row.srcServerId}-${row.dstServerId}`,
header: ({ column }) => <DataGridSortHeader column={column} title="Маршрут" />,
cell: ({ row }) => {
const run = row.original
const src = servers.find((s) => s.id === run.srcServerId)
const dst = servers.find((s) => s.id === run.dstServerId)
return (
<div className="font-mono whitespace-nowrap text-xs">
<div className="flex items-center gap-1.5">
<Flag code={src?.country ?? "UN"} size={13} />
<span>{src?.name ?? run.srcServerId}</span>
<ArrowRightIcon className="size-3 text-muted-foreground" />
<Flag code={dst?.country ?? "UN"} size={13} />
<span>{dst?.name ?? run.dstServerId}</span>
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{run.srcInterfaceAddress && run.dstInterfaceAddress
? `${run.srcInterfaceAddress} → ${run.dstInterfaceAddress}`
: "внутренние IP: auto/не указаны"}
</div>
</div>
)
},
meta: { headerTitle: "Маршрут", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "params",
accessorFn: (row) => `${row.protocol}-${row.direction}-${row.durationSec}`,
header: ({ column }) => <DataGridSortHeader column={column} title="Параметры" />,
cell: ({ row }) => {
const run = row.original
return (
<div className="flex items-center gap-1 text-muted-foreground whitespace-nowrap text-xs">
<span className="inline-flex items-center rounded border px-1.5 py-0.5 text-[10px] font-semibold bg-muted/60 border-border/60">
{run.protocol.toUpperCase()}
</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.direction}</span>
<span className="text-muted-foreground/60">·</span>
<span>{run.durationSec}s</span>
</div>
)
},
meta: { headerTitle: "Параметры", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => <DataGridSortHeader column={column} title="Статус" />,
cell: ({ row }) => {
const status = row.original.status
if (status === "running") {
return (
<span className="inline-flex items-center gap-1 text-[var(--status-degraded-fg)] text-xs">
<RefreshCwIcon className="size-3 animate-spin" />
running
</span>
)
}
if (status === "error") {
return <span className="text-[var(--status-offline-fg)] text-xs">error</span>
}
return <span className="text-[var(--status-online-fg)] text-xs">done</span>
},
meta: { headerTitle: "Статус", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "txAvgMbps",
accessorKey: "txAvgMbps",
header: ({ column }) => <DataGridSortHeader column={column} title="TX avg" />,
cell: ({ row }) => {
const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-tx)]"
style={{ width: `${(run.txAvgMbps / maxVal) * 100}%` }}
/>
</div>
<span className="font-mono tabular-nums text-[var(--chart-tx)] font-medium whitespace-nowrap text-xs">
{run.txAvgMbps} Мбит/с
</span>
</div>
)
},
meta: { headerTitle: "TX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "rxAvgMbps",
accessorKey: "rxAvgMbps",
header: ({ column }) => <DataGridSortHeader column={column} title="RX avg" />,
cell: ({ row }) => {
const run = row.original
const maxVal = Math.max(run.txAvgMbps, run.rxAvgMbps, 1)
return (
<div className="flex items-center gap-2 min-w-[120px]">
<div className="w-16 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-rx)]"
style={{ width: `${(run.rxAvgMbps / maxVal) * 100}%` }}
/>
</div>
<span className="font-mono tabular-nums text-[var(--chart-rx)] font-medium whitespace-nowrap text-xs">
{run.rxAvgMbps} Мбит/с
</span>
</div>
)
},
meta: { headerTitle: "RX avg", headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
},
{
id: "afterBtPing",
accessorFn: (row) => row.afterBtPing?.rttMs ?? -1,
header: ({ column }) => <DataGridSortHeader column={column} title="Ping после BT" />,
cell: ({ row }) => {
const run = row.original
if (run.status !== "done") return <span className="text-xs">—</span>
if (run.afterBtPing?.error) {
return (
<span className="text-[var(--status-offline-fg)] text-xs font-mono" title={run.afterBtPing.error}>
ошибка
</span>
)
}
if (run.afterBtPing?.rttMs != null) {
return (
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-violet-600 dark:text-violet-400">
{run.afterBtPing.rttMs} мс
{run.afterBtPing.lossPct != null && run.afterBtPing.lossPct > 0 && (
<span className="text-amber-600 dark:text-amber-400"> · {run.afterBtPing.lossPct}%</span>
)}
</span>
)
}
return (
<span className="font-mono tabular-nums whitespace-nowrap text-xs text-amber-600 dark:text-amber-400">
timeout
{run.afterBtPing?.lossPct != null && <span> · {run.afterBtPing.lossPct}%</span>}
</span>
)
},
meta: {
headerTitle: "Ping после BT",
headerClassName: DATA_GRID_CELL_PAD_LAST,
cellClassName: DATA_GRID_CELL_PAD_LAST,
},
},
],
[servers],
)
const table = useReactTable({
data: runs,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
initialState: { sorting: [{ id: "startedAt", desc: true }] },
})
return (
<DataGridShell
table={table}
recordCount={runs.length}
tableClassNames={{ bodyRow: cn("group/row text-xs") }}
/>
)
}
export { UptimeSpeedHistoryDataGrid, type UptimeSpeedHistoryDataGridProps }