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
386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
"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 }
|