Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 2m7s
Docker images / updater-image (push) Successful in 41s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
"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}
|
||
isLoading={isLoading}
|
||
emptyTitle={emptyTitle}
|
||
emptyDescription={emptyDescription}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export { DashboardActiveProbesDataGrid, type DashboardActiveProbesDataGridProps }
|