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,385 @@
|
||||
"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 { Sparkline } from "@/components/sparkline"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { cn } from "@/lib/utils"
|
||||
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 { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
ServerIcon,
|
||||
SearchIcon,
|
||||
ThermometerIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export interface UptimeResourceRow {
|
||||
serverId: string
|
||||
server: Server
|
||||
hasData: boolean
|
||||
cpu: number
|
||||
cpuHistory: number[]
|
||||
ramUsed: number
|
||||
ramTotal: number
|
||||
ramPct: number
|
||||
hddUsed: number
|
||||
hddTotal: number
|
||||
hddPct: number
|
||||
uptimeSeconds: number
|
||||
boardName: string
|
||||
temp?: number
|
||||
}
|
||||
|
||||
function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) {
|
||||
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 fmtMB(mb: number): string {
|
||||
if (mb >= 1024) return `${(mb / 1024).toFixed(mb >= 10240 ? 0 : 1)} ГБ`
|
||||
return `${mb.toFixed(1)} МБ`
|
||||
}
|
||||
|
||||
function fmtUptime(sec: number): string {
|
||||
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 resPctColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "text-red-600 dark:text-red-400"
|
||||
if (pct >= warn) return "text-amber-600 dark:text-amber-400"
|
||||
return "text-emerald-600 dark:text-emerald-400"
|
||||
}
|
||||
|
||||
function resBarColor(pct: number, warn = 70, crit = 85): string {
|
||||
if (pct >= crit) return "bg-red-500"
|
||||
if (pct >= warn) return "bg-amber-500"
|
||||
return "bg-emerald-500"
|
||||
}
|
||||
|
||||
function MiniBar({
|
||||
pct,
|
||||
warn = 70,
|
||||
crit = 85,
|
||||
className,
|
||||
}: {
|
||||
pct: number
|
||||
warn?: number
|
||||
crit?: number
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("h-1.5 rounded-full bg-muted overflow-hidden", className)}>
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all duration-700", resBarColor(pct, warn, crit))}
|
||||
style={{ width: `${Math.min(100, Math.max(0, pct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface UptimeResourcesDataGridProps {
|
||||
rows: UptimeResourceRow[]
|
||||
}
|
||||
|
||||
function UptimeResourcesDataGrid({ rows }: UptimeResourcesDataGridProps) {
|
||||
const columns = useMemo<ColumnDef<UptimeResourceRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "name",
|
||||
accessorFn: (row) => row.server.name,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Сервер" className="ml-1" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const srv = r.server
|
||||
const offline = srv.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
const isCrit =
|
||||
!noMetrics &&
|
||||
(r.cpu >= 85 || r.ramPct >= 85 || r.hddPct >= 85 || (r.temp ?? 0) >= 70)
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isCrit && <AlertCircleIcon className="size-3.5 text-red-500 shrink-0" />}
|
||||
{!isCrit && <StatusDot status={srv.status} pulse={!offline} />}
|
||||
<Flag code={srv.country} size={16} />
|
||||
<span className="font-mono font-semibold">{srv.name}</span>
|
||||
<TypeChip type={srv.type} />
|
||||
<span className="text-xs text-muted-foreground hidden xl:inline">{srv.site}</span>
|
||||
{!offline && !r.hasData && (
|
||||
<span className="text-[10px] rounded border border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-1.5 py-0.5">
|
||||
нет данных
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Сервер",
|
||||
headerClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_FIRST,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
accessorKey: "boardName",
|
||||
header: () => (
|
||||
<span className="text-xs font-medium text-muted-foreground hidden md:inline">
|
||||
Модель · ROS
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return (
|
||||
<div className="hidden md:flex flex-col leading-tight">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.hasData ? r.boardName : "—"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">{r.server.os}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { headerClassName: "hidden md:table-cell", cellClassName: "hidden md:table-cell px-4 py-3" },
|
||||
},
|
||||
{
|
||||
id: "cpu",
|
||||
accessorKey: "cpu",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CpuIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="CPU" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/30">
|
||||
{offline ? "—" : "нет опроса"}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const cpuColor =
|
||||
r.cpu >= 85 ? "hsl(0 84% 60%)" : r.cpu >= 70 ? "hsl(38 92% 50%)" : "hsl(142 76% 36%)"
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[140px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("font-mono text-sm font-semibold tabular-nums w-10 shrink-0", resPctColor(r.cpu))}>
|
||||
{r.cpu}%
|
||||
</span>
|
||||
<MiniBar pct={r.cpu} className="flex-1" />
|
||||
</div>
|
||||
<Sparkline data={r.cpuHistory} width={120} height={18} color={cpuColor} filled />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "CPU",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[160px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ram",
|
||||
accessorKey: "ramPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="RAM" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.ramPct))}>{r.ramPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.ramUsed)}/{fmtMB(r.ramTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.ramPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "RAM",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "hdd",
|
||||
accessorKey: "hddPct",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<HardDriveIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Диск" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (noMetrics) {
|
||||
return <span className="text-xs text-muted-foreground/30">{offline ? "—" : "нет опроса"}</span>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 min-w-[155px]">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={cn("font-mono font-semibold", resPctColor(r.hddPct))}>{r.hddPct}%</span>
|
||||
<span className="text-muted-foreground/60 font-mono text-[10px]">
|
||||
{fmtMB(r.hddUsed)}/{fmtMB(r.hddTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<MiniBar pct={r.hddPct} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Диск",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "min-w-[175px]"),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
accessorKey: "uptimeSeconds",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ClockIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="Uptime" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{noMetrics ? "—" : fmtUptime(r.uptimeSeconds)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "Uptime",
|
||||
headerClassName: DATA_GRID_CELL_PAD,
|
||||
cellClassName: DATA_GRID_CELL_PAD,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "temp",
|
||||
accessorKey: "temp",
|
||||
header: ({ column }) => (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ThermometerIcon className="size-3.5 text-muted-foreground" />
|
||||
<DataGridSortHeader column={column} title="°C" />
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
const offline = r.server.status !== "online"
|
||||
const noMetrics = offline || !r.hasData
|
||||
if (r.temp !== undefined && !noMetrics) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-sm font-semibold tabular-nums",
|
||||
r.temp >= 70
|
||||
? "text-red-600 dark:text-red-400"
|
||||
: r.temp >= 55
|
||||
? "text-amber-600 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{r.temp}°C
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="text-muted-foreground/30 text-xs">—</span>
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "°C",
|
||||
headerClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
cellClassName: DATA_GRID_CELL_PAD_LAST,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getRowId: (row) => row.serverId,
|
||||
})
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<SearchIcon className="size-4" />}
|
||||
title="Ничего не найдено"
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
tableClassNames={{
|
||||
headerRow: "border-b border-border bg-muted/30",
|
||||
bodyRow: cn(
|
||||
"group/row hover:bg-muted/30",
|
||||
"[&:has([data-resource-offline=true])]:opacity-50",
|
||||
"[&:has([data-resource-crit=true])]:bg-red-500/3",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { UptimeResourcesDataGrid, type UptimeResourcesDataGridProps }
|
||||
Reference in New Issue
Block a user