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,191 @@
"use client"
import { useMemo } from "react"
import type { PingProbe, Server, ServerType } from "@/lib/data"
import { Flag } from "@/components/flag"
import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot"
import { Sparkline } from "@/components/sparkline"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { cn } from "@/lib/utils"
import { FilterIcon } from "lucide-react"
function formatLossPct(loss: number): string {
if (!Number.isFinite(loss)) return "—"
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
}
function TypeChip({ type }: { type: ServerType }) {
return (
<span
className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
)}
>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
)
}
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
const srv = catalog.find((s) => s.id === probe.srcServerId)
const iface = (probe.srcInterface ?? "").trim() || "auto"
if (!srv) {
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status="offline" />
</span>
<div className="min-w-0">
<p className="text-[13px] font-medium text-muted-foreground truncate">
Сервер <span className="font-mono tabular-nums">{probe.srcServerId}</span>
</p>
<p className="text-[11px] font-mono text-muted-foreground truncate mt-0.5" title={iface}>
{iface}
</p>
</div>
</div>
)
}
return (
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
<span className="mt-1 shrink-0 inline-flex">
<StatusDot status={srv.status} pulse={srv.status === "online"} />
</span>
<div className="flex gap-2 min-w-0 flex-1">
<Flag code={srv.country} size={16} className="shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[13px] font-medium leading-tight truncate">{srv.name}</span>
<TypeChip type={srv.type} />
</div>
<p className="text-[11px] text-muted-foreground mt-0.5 truncate" title={`Интерфейс: ${iface}`}>
<span className="font-mono tabular-nums">{iface}</span>
{srv.site && srv.site !== "—" && (
<span className="text-muted-foreground/90"> · {srv.site}</span>
)}
</p>
</div>
</div>
</div>
)
}
interface DashboardActiveProbesDataGridProps {
probes: PingProbe[]
catalog: Server[]
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
}
function DashboardActiveProbesDataGrid({
probes,
catalog,
isLoading,
emptyTitle = "Нет активных проб",
emptyDescription,
}: DashboardActiveProbesDataGridProps) {
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
() => [
{
id: "source",
header: "Источник",
enableSorting: false,
cell: (p) => <ProbeSourceCell probe={p} catalog={catalog} />,
},
{
id: "name",
header: "Проба",
accessorKey: "name",
cell: (p) => <span className="font-medium">{p.name}</span>,
},
{
id: "target",
header: "Цель",
accessorKey: "target",
cell: (p) => <span className="font-mono text-xs text-muted-foreground">{p.target}</span>,
},
{
id: "filter",
header: "Фильтр",
accessorKey: "filter",
enableSorting: false,
cell: (p) => (
<span className="inline-flex items-center gap-1 text-xs border border-border rounded px-2 py-0.5">
<FilterIcon className="size-3 text-muted-foreground" />
{p.filter}
</span>
),
},
{
id: "rtt",
header: "RTT",
headerClassName: "text-right",
cellClassName: "font-mono text-right",
cell: (p) => (p.rtt == null ? "—" : `${p.rtt} мс`),
},
{
id: "loss",
header: "Потери",
headerClassName: "text-right",
cellClassName: "font-mono text-right",
cell: (p) => (
<span
className={cn(
p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
)}
>
{formatLossPct(p.loss)}
</span>
),
},
{
id: "series",
header: "60с",
enableSorting: false,
headerClassName: "w-36",
cell: (p) => {
const sparkColor =
p.status === "down"
? "hsl(0 84% 60%)"
: p.status === "warn"
? "hsl(32 94% 44%)"
: "hsl(142 76% 36%)"
return <Sparkline data={p.series} width={120} height={24} color={sparkColor} />
},
},
{
id: "status",
header: "Статус",
enableSorting: false,
cell: (p) => (
<StatusBadge
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
/>
),
},
],
[catalog],
)
return (
<CompactDataGrid
data={probes}
columns={columns}
compact
isLoading={isLoading}
emptyTitle={emptyTitle}
emptyDescription={emptyDescription}
/>
)
}
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }